Plugins
A plugin adds a feature to a project that not every project needs: DIS networking, an instructor console, scorecards.
A plugin adds a feature to a project that not every project needs: DIS networking, an instructor console, scorecards. A project that does not list a plugin does not load it and pays nothing for it. Some plugins ship with the engine; others are installed by the launcher from the update source, or from a file on a site with no network (Installing a plugin). Why it works this way: PROPOSAL-PLUGINS.md.
In the commands, argus is the engine's command and @argus the scope of its TypeScript packages.
Contents
- Adding a plugin
- Installing a plugin
- What a plugin may do
- Settings
- Removing a plugin
- The project file
- Using a plugin in a scene
- Writing a plugin
- Publishing a plugin
- The descriptor
- Problems and their codes
Adding a plugin
From a terminal in the project's folder (or any folder inside it):
argus plugin list # what is installed, and what this project already uses (marked *)
argus plugin info dis # what it carries, what it may do, the settings it takes
argus plugin add dis # add it
add prints what it is about to do, then does it:
argus plugin add: adds "dis" (/projects/harbour-drill)
it may:
net.udp (opens UDP ports: binds and sends, broadcast and multicast included): sends this simulation's entities and receives other simulations' entities as DIS Entity State PDUs
world.spawn (spawns and removes entities from outside a behaviour): spawns, moves and removes the entities other simulations publish
entry: {"id":"dis"}
plugins, in load order: dis
copies dis/remote.prefab.json
wrote dis/remote.prefab.json, argus.project.json
--dry-runprints the same and writes nothing.--set key=valuesets a setting as it adds (see Settings); repeat it for more.--version 0.1makes the project need that release line of the plugin.--project <dir>names the project instead of the current folder;--jsonprints one JSON object instead.- Some plugins copy starter files in (the Training plugin adds a sample scorecard). A file that already exists is kept, never overwritten; the output says
keeps <file>.
In the editor: Project Settings > Plugins lists the installed plugins. Add shows what the plugin may do and adds it when you confirm; Remove takes it out.
From an agent: the plugin tool (in the release toolset; turn it on with tools.enable): {"op": "list"}, {"op": "info", "id": "dis"}, {"op": "add", "id": "dis", "set": ["port=3001"]}, {"op": "remove", "id": "dis"}. An add answers with the same plan the CLI prints, capabilities included, and "dryRun": true stops there. A set value is JSON when it parses, so a group goes whole: "origin={\"lat\": 46.80, \"lon\": 8.23}".
All three do exactly the same thing: they run the same code. While the editor has the project open, add and remove plugins in Project Settings or through the agent (both go through the editor's workspace service, so the change becomes a revision); the command line writes the file directly.
Installing a plugin
A plugin the engine does not ship is installed once per machine by the launcher, then added to each project that uses it. In the commands, argus-hub is the launcher's command.
argus-hub --plugins # what is installed here, and what the update source offers
argus-hub --install-plugin radio # the newest release
argus-hub --install-plugin radio@1.2 # the newest 1.2.x (also: radio@1, radio@1.2.0)
argus-hub --uninstall-plugin radio@1.2.0 # one version; `radio` alone removes every version
argus plugin add radio # then, in the project's folder
- Where it goes:
plugins/<id>/<version>/under the launcher's folder (%LOCALAPPDATA%\Arguson Windows,~/.local/share/arguselsewhere;ARGUS_HOMEnames another). One folder per version, never changed after it is installed. Several versions of a plugin sit side by side. - Which version a project gets: the newest installed one, unless its entry names a
version(The project file); then the newest installed version that meets it.argus plugin listmarks an installed plugin and names its other installed versions. - Checked before it lands: the launcher verifies the publisher's signature on the plugin's manifest before it fetches anything, and every file's SHA-256 as it arrives. A plugin that does not verify is not installed. The same sign-in that downloads engines downloads plugins.
- Access: a plugin in the update source is closed until Cadarn gives it to a person, to an organization (every member) or to everyone.
argus-hub --pluginsmarks one your account was not given as(needs access: ...)("access": falsewith--json), and installing it is refused with that reason; your sign-in is fine. A plugin already installed stays. The rules are inservices/cloud/PLUGIN-ACCESS.md. - No network: on a connected machine take the plugin's
.argus-packfile; on the site,argus-hub --import-pack radio-1.2.0.argus-packinstalls it with the same checks and no connection. - The engine's own plugins stay the engine's. An installed plugin with the id of one the engine ships is not used (
plugin listsays so): a shipped plugin's compiled-in part belongs to that engine build. - Code of its own: an installed plugin carries behaviours, UI screens, a web app, agent tools, schemas and data, and code that runs at the engine's hook points as a WebAssembly part. A compiled-in part cannot be installed: it is built into the engine.
What a plugin may do
Every plugin declares what it may do beyond what any behaviour can, each with a reason, from this list:
| Capability | Allows |
|---|---|
net.udp | opening UDP ports: binding and sending, broadcast and multicast included |
net.http.serve | serving pages and an API under /plugins/<id>/ on the engine's local query server, reachable from this machine only |
net.out | outbound network connections, to the hosts it names |
session.control | pausing, stepping and scaling time, injecting events, taking over entities |
world.spawn | spawning and removing entities from outside a behaviour |
fs.project.write | writing files in the project folders it names |
fs.project.read | reading files in the project folders it names |
render.draw | drawing meshes and textures of its own every frame |
render.camera | taking over the camera, and leaving the scene's own meshes out of the frame |
input.pointer | reading the mouse wheel, the pointer's movement and its buttons |
They are shown before anything is written, by plugin add, plugin info, the editor's confirmation and the agent tool, and the engine holds a plugin to them when it runs.
Your own server. A plugin reaches only the hosts its net.out names. A project can let it reach more, for data it serves itself: "allowHosts": ["tiles.example.org"] in the plugin's entry in the project file. It widens only a plugin that declares net.out. argus plugin add <id> --allow-host tiles.example.org writes it (repeatable; the agent's plugin tool takes "allowHosts": [...] on an add).
Keys. A service that needs a key gets it from this machine, never from a project or a plugin:
argus plugin key set <plugin> <host> --file <a file holding the key>
argus plugin key list
argus plugin key remove <plugin> <host>
- The key is kept in the launcher's folder (
keys/<plugin>/<host>), readable by you only. - It is sent to that host alone, on the requests of that plugin that ask for it. The plugin's code never sees it, and it is never written into a log, a status or an error.
Settings
A plugin's settings live in the project file, under its entry's config. argus plugin info <id> lists them with their defaults. Set them when adding, or later by adding again:
argus plugin add dis --set port=3001 --set origin.lat=46.80 --set origin.lon=8.23
argus plugin add dis --set address=192.168.1.255 # already listed: this only changes the setting
argus plugin add dis --set port= # an empty value goes back to the default
A value is read as JSON when it is JSON (3001, true, [1, 2], "text") and as text otherwise. Dotted names reach inside a group (origin.lat). Every setting is checked against the plugin's own schema before the file is written; a wrong one is refused with plugin-config, naming the setting and what it must be.
Removing a plugin
argus plugin remove dis --strip
--strip also takes the components that attach the plugin's behaviours out of the project's scenes and prefabs, so the project still runs without it: only those components change in each file, its formatting kept. Without --strip the components stay, the output names them entity by entity, and those scenes will not load until the plugin is added again or the components go (remove <id> --strip takes them out later too, once the plugin is no longer listed). --dry-run says what it would do and writes nothing. Files the plugin copied in stay in the project, and when the project had settings for it, the output prints the plugin add that puts it back as it was. An agent takes components out with its scene tools; the editor's Inspector removes a component. A plugin another listed plugin needs cannot be removed until that one is (plugin-requires).
The project file
plugin add writes this into argus.project.json; you can also write it by hand:
"plugins": [
{ "id": "dis", "config": { "port": 3001, "origin": { "lat": 46.80, "lon": 8.23 } } },
{ "id": "training" }
]
| Field | Meaning |
|---|---|
id | the plugin, as plugin list shows it |
version | optional: MAJOR, MAJOR.MINOR or MAJOR.MINOR.PATCH; the installed plugin must match the parts given. Absent: the installed one |
config | optional: the plugin's settings |
allowHosts | optional: hosts this project lets the plugin reach beside its own (host names: tiles.example.org) |
The list's order is the order plugins load and run in. An engine from before plugins refuses a project file that has the field, as it refuses any field it does not know.
Using a plugin in a scene
- Its behaviours:
{"type": "script", "module": "dis:entity", "props": {...}}. The part before the colon is the plugin; a file of your own never hides a plugin's behaviour.plugin info <id>lists them. To attach one without editing the scene file:argus plugin attach dis:entity --entity car --props '{"marking": "CAR7"}'(the startup scene unless--scene <file>; only that component is written into the file;--dry-runshows it). The editor's Inspector and an agent's scene tools attach them too, and an entity may carry severalscriptcomponents. - Its TypeScript (a plugin that ships a package;
plugin infosays "behaviours import it as ..."):import { ... } from "@argus/training"in a behaviour, once the project liststraining. - Its page (a plugin with a web app):
http://127.0.0.1:<query port>/plugins/<id>/while the game runs;GET /pluginslists every running plugin's page. - Its compiled-in part's status:
GET /plugins/<id>/statuswhile the game runs (refreshed every 30 ticks). - Its agent tools: through the agent's
plugintool,{"op": "tools", "id": "<id>"}and{"op": "run", "id": "<id>.<tool>"}. - Its scripts (command-line tools of its own;
argus plugin info <id>names them):argus plugin run training score runs/drive.replay --scorecard training/crossing-drive.scorecard.json. The script runs on the Node the engine's tools use (an installed engine's own;nodeon PATH in a checkout;ARGUS_NODEnames another) from the current folder, with every argument after its name, and the command exits with the script's code. In a checkout the plugin is built first when it needs to be (below). Options forplugin runitself, such as--project, go before the plugin's id.
A plugin's guide. A README.md at a plugin's root is its guide: plugin info names its path (guide in the JSON form), which is where a person or an agent reads how to use the plugin. A published plugin lists it in ship.
The first-party plugins' own READMEs (plugins/<id>/README.md) say what each one does: DIS, Training, Motion platform.
When a listed plugin cannot load, argus run does not start the project: it names the plugin and why (plugin-unknown, plugin-version, plugin-config, plugin-native, ...). A training scenario that runs without the plugin it was written for is worse than one that does not start. The editor still opens such a project, so the plugin can be fixed or removed in Project Settings; its Play leaves the plugin out and says so in the log.
Writing a plugin
A plugin of your own needs no repository and no build: A plugin of your own, with no build is the whole recipe. First-party plugins live in the engine's repository, plugins/<id>/, and are listed in plugins/registry.json:
plugins/
registry.json { "schemaVersion": 1, "plugins": ["dis", "training", "geodata"] }
<id>/
plugin.json the descriptor (below)
package.json, src/ the TypeScript package; its build writes dist/
native/ optional: the Rust crate argus-plugin-<id>
web/ ui/ schemas/ templates/ tools.json README.md
The engine finds this folder by walking up from the project (a checkout), else in the SDK it ships ($ARGUS_SDK/plugins, <exe>/sdk/plugins); $ARGUS_PLUGINS names one directly. It loads the plugins of that one folder that its registry.json lists, in that order; a folder it does not list is not loaded (plugin list says so). Plugins shared between people and engine versions, with their own registry, come later.
A plugin with no native part needs no Rust at all: TypeScript, a web app and data are enough, and plain JavaScript with no build is enough for behaviours.
A plugin of your own, with no build
An installed engine's plugins are its sdk/plugins folder, and the engine loads one plugins folder. Copy it to a folder of your own (an engine update then leaves your plugin alone; copy the engine's folder again after an update to take its plugins' new versions, and keep yours), add your plugin there, list it in that folder's registry.json, and point the engine at the folder:
my-plugins/ a copy of <engine>/sdk/plugins
registry.json { "schemaVersion": 1, "plugins": ["dis", "training", "geodata", "hello"] }
hello/
plugin.json
behaviours/entries.json
behaviours/rise.js
hello/plugin.json (the fields are below):
{ "schemaVersion": 1, "id": "hello", "displayName": "Hello", "version": "0.1.0", "apiVersion": 1,
"description": "Makes an entity rise.", "platforms": ["windows", "linux", "macos"],
"behaviours": "behaviours/entries.json" }
hello/behaviours/entries.json names each behaviour and its file, relative to itself:
{ "version": 1, "modules": { "rise": { "file": "rise.js", "summary": "Moves the entity up 2 m a second." } } }
hello/behaviours/rise.js is a behaviour like any other, an ES module whose default export has the hooks (API.md is the whole ctx; a first one needs little more than ctx.state and ctx.props (below), ctx.log(message), the ctx.entity.position property and ctx.entity.translate([x, y, z]), ctx.events.emit(name, data), and an onEvent(ctx, ev) hook, whose ev is {name, data}). A plugin's behaviours are JavaScript: the engine compiles a project's own TypeScript behaviours, not a plugin's, so a plugin written in TypeScript builds its .js first.
export default {
init(ctx) { ctx.state.speed = ctx.props.speed ?? 2; ctx.log("rise: start"); },
update(ctx, dt) { ctx.entity.translate([0, ctx.state.speed * dt, 0]); },
};
The default export is one object that every entity with the behaviour shares, so this is too: keep an entity's own values in ctx.state (one per entity, kept across a hot reload) and read its props from ctx.props ({"type": "script", "module": "hello:rise", "props": {"speed": 3}}; {} when it has none).
Then, with ARGUS_PLUGINS set to my-plugins in the shell (or the system's environment):
argus plugin info hello # checks the descriptor
argus plugin add hello
plugin info hello lists hello:rise under its behaviours. To try it, save this as scenes/hello.scene.json in the project (a camera and one box that carries the behaviour; every meta field here is required):
{ "version": 1,
"meta": { "name": "hello", "genre": "third-person", "bounds": { "min": [-10, -1, -10], "max": [10, 20, 10] },
"budgets": { "triangles": 1000, "drawCalls": 10, "textureMB": 4, "dynamicBodies": 4 },
"environment": { "ambient": 0.4 }, "strings": {} },
"assets": { "cube": { "kind": "glb", "path": "builtin:cube", "triangles": 12 } },
"entities": [
{ "id": "cam", "name": "camera", "tags": [], "transform": { "position": [0, 3, 8], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"components": [{ "type": "camera", "kind": "fixed", "fov": 60, "primary": true }] },
{ "id": "riser", "name": "riser", "tags": [], "transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"components": [{ "type": "mesh", "asset": "cube" }, { "type": "script", "module": "hello:rise" }] }
] }
and run it in the project, three simulated seconds with no window, writing the box's position once a second:
argus run . --scene scenes/hello.scene.json --headless --seconds 3 --record-data rise.csv --data-fields riser:position --data-every 60
The log says [riser] rise: start, and rise.csv has the box 2 m higher each second. Run a scene in its project like this (argus run <project> --scene <file>): a scene file run on its own (argus run scenes/hello.scene.json) has no project, so no plugins load.
apiVersion is the plugin API the engine serves (plugin info refuses another, saying which it serves); leave engine out.
Exports. A desktop export of a project packs the plugins it lists at app/plugins/** (both exporters, the same files and bytes): registry.json naming exactly those plugins, and for each what a running game reads of it (descriptor, settings schema, contribution data, behaviours, UI screens, web app); never its sources, starter files or agent tools, and the compiled-in parts are already in the engine. The files only authoring reads (a files rule's schema, tools, onAdd.copy[].from) are left out, and a packed plugin is not refused for their absence; there, GET /plugins names no toolset. The exported game resolves them from its own payload and refuses to start without them, as argus run does. The exporter builds a plugin first when it needs to (below). A web export of a project with plugins is refused for now (plugins-not-exported), and a scene-only export cannot carry plugins (export the project).
Installed engines carry the first-party plugins in their SDK: scripts/sdk/pack.mjs copies plugins/registry.json and exactly what each listed descriptor names into sdk/plugins/ (never sources, native crates or tests).
Building a plugin. What a plugin ships under dist/ (and any other output of its build) is generated, so a fresh checkout has none of it. Both packers, the SDK pack and the desktop export, build a plugin before packing it when its folder has a build script in its package.json and is not fresh: never built by a packer, or a file or folder in it, or the folder itself (node_modules and dot entries aside), is newer than the stamp the last such build left in node_modules/.cache/plugin-build/. A folder counts because deleting a file changes only its folder's time, so a deleted dist/ or built file is built again. They run pnpm run build in the plugin's folder, its output on stderr; packs running at the same time take a lock there, so one builds and the others wait. A failed build stops the pack and names pnpm --filter ./plugins/<id> build to see why; a plugin whose built files are still missing is refused. An installed SDK's plugins have no package.json and are never built. While working on one, build it yourself: pnpm --filter ./plugins/<id> build (the root pnpm build builds every plugin too).
A native part
For what scripts cannot do (sockets, per-entity work every tick, file formats). The crate is plugins/<id>/native, package argus-plugin-<id>, depending on argus-plugins alone; the engine builds it in behind a Cargo feature (plugin-<id>, in the standard builds) and lists it in crates/app/src/plugin_native.rs. It exports one start(&StartContext) -> Result<Box<dyn NativePlugin>, PluginError> and implements:
| Hook | When | May |
|---|---|---|
input_phase(world) | every tick, after session commands and before behaviours and physics, held ticks too | read world.external_input() (what it submitted last tick, recorded in a replay; in a playback, the recording's), spawn and move its entities. Never a socket or a clock |
end_of_step(world) | every tick, after physics and behaviours | read the settled state; when world.tick().sends() (a first run, confirmed, live), read its sockets, submit_external what arrived, send what goes out |
status() | on request | answer a JSON summary |
stop() | a restart, a scene switch, the end of the run | close sockets |
Both hooks see one tick() (the input phase's tick, time and dt; the sim clock, never the wall clock). What a part submits is the world's external input under the plugin's id (World::submit_external), recorded in a replay ("xin" lines); in a playback tick().mode is Playback, external_input() hands out the recording's and a submission is dropped (one external-input-dropped warning), so a replay reproduces a run with no network. The StartContext grants only declared capabilities: udp_bind needs net.udp; spawn_prefab, set_pose and despawn need world.spawn. Events (world.emit) reach behaviours' onEvent at the start of the next tick. The other way, at the end of the step world.emitted(name) reads the newest ctx.events.emit(name, data) a behaviour sent to everyone that tick: how a behaviour hands a part numbers only it knows (an engine's rpm). A part is started when the world finishes loading and stopped on a rebuild; a project that lists no plugin with a part runs neither hook. A part whose start fails (a port taken) is left out of the run, loudly: a plugin-failed line and event, and its status says why. Use argus-dmath for transcendental maths (the sim-maths rule).
A WebAssembly part
Code that travels with the plugin. A compiled-in part is built into the engine, so only a plugin the engine ships can have one; a WebAssembly part is a file in the plugin's folder, so an installed plugin (Installing a plugin) can carry code of its own. The engine runs it at the same two hook points, with the same recorded external input and the same capability checks.
The plugin API is crates/plugin-wasm/wit/plugin.wit (engine:plugin@1). A part exports start(config), input-phase, end-of-step, frame, run-job, status and stop, and imports five interfaces:
host, the simulation:tick,scripted,tagged,pose-of,spawn-prefab,set-pose,despawn,external-input,submit-external,emitandlog;scene, what the scene is and where, read-only:georeference,footprintandground-heights;render, drawing:view,mesh-create,mesh-free,texture-create,texture-write,texture-free,material-create,draw,set-camera,hide-scene,scene-fade,set-far,set-sky,set-creditsandpointer;jobs, work off the engine's threads:submit,take-finished,pendingandworkers;data, files and the network, for jobs only:read,write,exists,entries,fetchandfetch-with.
Settings, props, statuses and event data cross as JSON text.
Drawing (needs render.draw). frame is called once for each frame drawn, before the frame's draw list is built. There a part hands the renderer meshes, RGBA textures and materials (lit, or unlit for imagery that carries its own lighting; an unlit material casts no shadow), each by a handle of its own, and calls draw with the whole list of what it draws from now on: a mesh, a material, a transform in scene metres and a fade. view answers the camera and the frame's size.
- Visual only: nothing in
renderreaches the simulation, andframeis not part of a tick, so a replay or a headless run calls it a different number of times, or never. - The renderer keeps a material for the whole run: make a fixed set and reuse them, writing new pixels into a pool of textures (
texture-write) as the globe's imagery does. - The camera (needs
render.camera):set-cameratakes the view for the frames from now on, with an eye, a target, a field of view and its own nearest and farthest distances (a part that draws a planet sets both from its altitude);nonegives it back to the game.hide-sceneleaves the scene's own meshes out of the frame, for a part that shows another place until it hands over. A view somebody else set (the editor's, a replay camera) is never taken away. - Handing over to the scene (needs
render.camera):scene-fadedraws the scene's own meshes thinned out (0 whole, 1 not at all), so a part that hid the scene brings it in without a pop;set-fardraws the game's own camera out to a distance the part names, for a part that draws a horizon around the scene and leaves the camera to the game. - The sky:
set-sky(needsrender.camera) draws two colours behind the scene, straight up and at the horizon, in place of the scene's own sky, which still lights it: a part whose camera leaves the ground shows the black of space there.nonegives the scene's own back. - Credits:
set-creditsgives the engine a line it draws small at the bottom right of every frame from then on, a recording without its HUD included, wrapped to the frame's width: the sources whose licences ask to be named. It is drawn with the project's font (ui/fonts/); in a project with none the log says so once, with the credits. - The pointer (needs
input.pointer):pointeranswers the wheel, the movement and the buttons since the part last asked. - One part holds at most 16,384 meshes, 2,048 textures and 4,096 materials, and draws at most 65,536 things a frame; past that a call answers an error. What a part made is given back when the run ends.
The scene (no capability: it changes nothing). scene.georeference answers the scene's place on the Earth (its meta.georeference, the defaults filled in), scene.footprint its rectangle on the ground (its terrain's, else its bounds) and scene.ground-heights its terrain's height under a list of points. They answer the same at a tick's hooks and at the frame hook, and nothing in a job: a job that needs the ground is handed it in its input.
Writing one in Rust (fixtures/plugins/wasm-mover is a whole plugin to copy):
# Cargo.toml: crate-type = ["cdylib"], wit-bindgen = "=0.57.1"
# src/lib.rs: wit_bindgen::generate!({ path: "<the folder of plugin.wit>", world: "plugin" });
# impl Guest for Part { ... } export!(Part);
cargo build --release --target wasm32-unknown-unknown
argus-plugin-compile component target/wasm32-unknown-unknown/release/<crate>.wasm --out plugin.wasm
and in plugin.json: "wasm": { "component": "plugin.wasm" }. plugin pack packs it like any other file the descriptor names. A plugin has a compiled-in part or a WebAssembly part at run time, not both.
Jobs. jobs.submit(kind, input) queues a call of the part's own run-job(kind, input), which runs in another instance of the part on a worker thread and answers bytes or an error; take-finished hands back what finished since the part last asked, by id. Decoding, meshing and anything else too slow for a frame goes there.
- A job sees its input and nothing else: none of the part's state, nothing of the world, and it queues no jobs.
- When a job finishes depends on the machine. Nothing the simulation computes may depend on it: jobs are for what is drawn.
- One call of
take-finishedhands over about 4 MB of results at most (always one job at least), and the next call the rest: many jobs finishing together do not all land, and upload, in one frame. - A part gets one to eight workers, by the machine's cores;
jobs.workerssays how many, so a part that fetches can keep some free of jobs that wait on a server. - A job runs on the same fuel and memory budget as any call. One that traps answers its error, and the next job gets a fresh instance. A part has at most 1,024 jobs queued or running, each taking and answering at most 64 MB.
Files and the network are for jobs only, so a frame never waits on a disk or a server and the simulation never depends on either. From anywhere else a call answers an error.
read,write,existsandentriestake a place and a relative path that never leaves it:fs.project.writenames (read and write); launcher's folder).plugin: the plugin's own folder, read only;project: the project's folder, under the folders itsfs.project.readcapability names (read) andcache: a folder of the plugin's own on this machine, kept between runs (cache/plugin-data/<id>under the
fetch(url)answers the body of an HTTPS GET that returns 200, from a host the plugin'snet.outcapability names or the project lets it reach (allowHosts), and no other; no redirect is followed. The standard engine has the network client (featurenet-tls). It answers an error on an offline install (theOfflinepolicy), and in a build without the client, which an export is unless it is built with the feature.fetch-with(url, headers)isfetchwith request headers:authorization,accept, and names that startx-, nothing that changes where a request goes.{key}in a header's value stands for the host's stored key too (Bearer {key}), for a service that takes its key or a token in a header.{key}in the address's query stands for the key the user stored for that host (plugin key set): the engine puts it there for that request, and what the part hears if the request fails names the address as the part wrote it. A part never holds a key.
What a part cannot do.
- It imports the plugin API and nothing else: no clock, no randomness, no sockets, and no files or network but the
datainterface inside a job.argus-plugin-compilerefuses a component that asks for anything more, by name, so build forwasm32-unknown-unknown(no WASI). - It changes the world only as the plugin's capabilities allow:
spawn-prefab,set-poseanddespawnanswer an error withoutworld.spawn. - Each call runs on a fuel budget (about two billion instructions) and the part's memory stops at 512 MB. Both are counted the same on every machine. A part that runs past either, or that panics, is stopped for the rest of the run: behaviours hear a
plugin-stoppedevent{plugin, message}, the plugin's status says why, and the game goes on. - A part whose
startanswers an error (settings it refuses) is left out of the run, as a compiled-in part is: aplugin-failedline and event, and its status says why.
The same run every time. The same component runs the same instructions on every platform; a NaN it computes is the canonical one; it reads nothing from outside the simulation but its recorded external input. Two runs of a project with a part are the same run, bit for bit.
Compiled on your machine. A plugin ships the component, never compiled code. The first time a project runs a part, the engine has argus-plugin-compile (beside the engine, or what ARGUS_PLUGIN_COMPILE names) compile it for this machine and keeps the result in cache/plugin-code under the launcher's folder, named by the component's SHA-256 and the runtime's version. A later run loads it from there in well under a millisecond. Without the compiler and without a cached copy the part does not start, and its status says so.
What a part costs a frame. With ARGUS_PLUGIN_TIMING set (to anything), the log says every 300 frames how long each part's frame took a frame, and how many things the part draws.
Not yet: an exported game does not carry WebAssembly parts, and the web build does not run them. The part's own status is asked once every 30 steps.
Publishing a plugin
A published plugin is a signed manifest over its files in the update source, as an engine version is (docs/PLAN-LAUNCHER.md, section 4). Four steps, the last three with the release tool (argus-release):
argus plugin pack radio --out build/radio # the files the descriptor names, in a new folder
argus-release stage-plugin --src build/radio --tree release # objects and an unsigned manifest
argus-release sign --key release.pem release/plugins/radio/1.2.0/manifest.json
argus-release promote-plugin --tree release --key release.pem --id radio --version 1.2.0 --summary "A radio."
plugin packtakes the plugin's id or its folder and writes exactly what its descriptor names: the descriptor, schemas, contribution data, the tools file and the scripts its tools run, starter files, the behaviours manifest and every module it names, the folders of the TypeScript entry and types, the UI and web folders, and whatevershiplists, and its WebAssembly part. Never sources or tests. A file the descriptor names that is missing stops it (build the plugin first), and so does a compiled-in part.- The id, version and plugin API in the manifest come from the plugin's own
plugin.json. A published version is immutable: publish a new version, never the same one twice. promote-pluginadds the version toplugins/index.json, the signed list the launcher reads. Earlier versions stay listed, since a project may ask for one.- An offline pack of a staged, signed version, in the same file format as an engine's:
argus-release pack --tree release --manifest plugins/radio/1.2.0/manifest.json --out radio-1.2.0.argus-pack.
Uploading the release tree to the update source is the release process's step, as for an engine.
The descriptor
plugins/<id>/plugin.json. Paths are relative to the plugin's folder and must stay inside it.
{
"schemaVersion": 1,
"id": "training",
"displayName": "Training",
"version": "0.1.0",
"apiVersion": 1,
"description": "Instructor console, scorecards and after-action review.",
"platforms": ["windows", "linux", "macos", "web"],
"capabilities": [
{ "id": "net.http.serve", "why": "serves the instructor console page" },
{ "id": "session.control", "why": "pauses, injects events and takes over entities for the instructor" },
{ "id": "fs.project.write", "why": "writes reports", "paths": ["output/training/"] }
],
"ts": { "entry": "dist/lib/index.js", "types": "dist/types/index.d.ts" },
"behaviours": "dist/entries.json",
"ui": "ui/",
"web": { "root": "web/" },
"files": [{ "match": "**/*.scorecard.json", "schema": "schemas/scorecard.schema.json" }],
"contributes": { "journal.kinds": "data/kinds.json", "journal.metrics": "data/metrics.json" },
"tools": "tools.json",
"configSchema": "schemas/config.schema.json",
"onAdd": { "copy": [{ "from": "templates/sample.scorecard.json", "to": "training/sample.scorecard.json" }] },
"requires": { "plugins": [] }
}
| Field | Required | Meaning |
|---|---|---|
schemaVersion | yes | 1 |
id | yes | lowercase letters, digits and single hyphens, starting with a letter, at most 32; the folder's name |
displayName, description | yes | one line each, for lists |
version | yes | the plugin's own MAJOR.MINOR.PATCH |
apiVersion | yes | the plugin API it was written for; this engine serves 1 |
engine | no | the engine series it was built for ("0.8"), for plugins shared apart from the engine (later); first-party plugins and your own leave it out |
platforms | yes | any of windows, linux, macos, web |
capabilities | no | [{id, why}] from the list; fs.project.write adds paths, net.out adds hosts |
ts | no | the built package: entry (JavaScript), types (declarations) |
behaviours | no | the built behaviours' entries.json (the @argus/gameplay shape); scenes name them <id>:<name> |
ui | no | a folder of UI screens, registered as <id>/<screen> |
web | no | {root}: a folder served at /plugins/<id>/ |
files | no | [{match, schema}]: JSON Schemas for the plugin's own project files; * matches within a folder, ** across |
contributes | no | data for engine systems, point name to file (journal.kinds, journal.metrics: Training Core) |
tools | no | the agent tools file (below) |
native | no | {crate, runtime}: the compiled-in part, argus-plugin-<id>; runtime: false for an authoring-only part |
configSchema | no | the JSON Schema a project's config must meet. Absent: the plugin takes no settings |
onAdd | no | {copy: [{from, to}]}: starter files copied into the project on add, never overwriting |
requires | no | {plugins: [...]}: other plugins the project must list before this one |
ship | no | other files or folders an installed engine carries for the plugin that no field above names (a command-line tool of its own). Each JavaScript file it names is a script argus plugin run starts |
Files you write (schemas, templates, tools.json, the web root, the UI folder) must exist when the descriptor is read; files a build makes (ts, behaviours) are checked when the plugin loads.
Settings schemas use the part of JSON Schema settings need: type, properties, required, additionalProperties, items, enum, const, minimum, maximum, exclusiveMinimum, exclusiveMaximum, minLength, maxLength, minItems, maxItems, and the annotations $schema, $id, title, description, default, examples. Any other keyword is refused when the plugin is read, so nothing a schema says goes unchecked. Give every setting a default and a description: plugin info shows both.
Agent tools (tools.json):
{ "toolset": "training",
"tools": [{ "name": "training.report", "description": "...", "inputSchema": { "type": "object" },
"run": { "node": "dist/tools/report.js" } }] }
run is {"node": file} (a module exporting run(args, ctx), run on the engine's Node) or {"query": "GET /plugins/<id>/..."} (a call to the running game); {"native": op} is reserved for compiled-in parts. A Node tool's ctx is { projectRoot, play, log }: play is null when no game is running, else get(path) and post(path, body) against the running game's query API, answered through the agent's own session (so they pass the query server's guard; there is no URL to fetch directly), a body that is not JSON coming back as its text. Names are <id>.<name>, with dots like every engine tool.
Agents reach them through the one plugin tool, so a plugin adds nothing to any agent's tool list: {"op": "tools", "id": "training"} lists the plugin's tools with their input schemas, and {"op": "run", "id": "training.report", "args": {...}} runs one in a project that lists the plugin.
Problems and their codes
| Code | Means | Do |
|---|---|---|
project-plugin | an entry's id is not a plugin id, is listed twice, or its version is not MAJOR[.MINOR[.PATCH]] | fix the entry in the project file |
plugin-unknown | no installed plugin has that id | argus plugin list shows the installed ones |
plugin-version | the installed plugin does not match the project's version | set version to the installed line, or remove it |
plugin-config | a setting is not one the plugin has, or not a value it takes | argus plugin info <id> lists the settings |
plugin-requires | the plugin needs another the project does not list first (or a remove would leave one without it) | add the other plugin first, or remove the dependent one first |
plugin-api | the plugin was written for another plugin API or engine series | use the plugin release made for this engine |
plugin-platform | the plugin does not run on this target (a native-only plugin in a web export) | leave the plugin out of that target's project |
plugin-native | the plugin's compiled-in part is not in this engine build | use a build that has it (the standard engine builds have every first-party part) |
plugin-descriptor | a plugin's plugin.json is broken or names a missing file | for plugin authors: the message names the field |
plugin-start | the plugin's compiled-in part could not start: a UDP port it binds is taken, a multicast group refused it | free the port or set another (argus plugin add <id> --set port=...); the line names the address |
plugin-project | the project file could not be read or written, or changed while the edit was made | run the command again |
plugin-not-listed | a remove names a plugin the project does not list | argus plugin list marks the listed ones |