Talos docs

Plugins

Since next

A plugin adds a feature to a project that not every project needs: DIS networking, an instructor console, scorecards.

Plain text

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

  1. Adding a plugin
  2. Installing a plugin
  3. What a plugin may do
  4. Settings
  5. Removing a plugin
  6. The project file
  7. Using a plugin in a scene
  8. Writing a plugin
  9. Publishing a plugin
  10. The descriptor
  11. 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-run prints the same and writes nothing.
  • --set key=value sets a setting as it adds (see Settings); repeat it for more.
  • --version 0.1 makes the project need that release line of the plugin.
  • --project <dir> names the project instead of the current folder; --json prints 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%\Argus on Windows, ~/.local/share/argus elsewhere; ARGUS_HOME names 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 list marks 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 --plugins marks one your account was not given as (needs access: ...) ("access": false with --json), and installing it is refused with that reason; your sign-in is fine. A plugin already installed stays. The rules are in services/cloud/PLUGIN-ACCESS.md.
  • No network: on a connected machine take the plugin's .argus-pack file; on the site, argus-hub --import-pack radio-1.2.0.argus-pack installs 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 list says 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:

CapabilityAllows
net.udpopening UDP ports: binding and sending, broadcast and multicast included
net.http.serveserving pages and an API under /plugins/<id>/ on the engine's local query server, reachable from this machine only
net.outoutbound network connections, to the hosts it names
session.controlpausing, stepping and scaling time, injecting events, taking over entities
world.spawnspawning and removing entities from outside a behaviour
fs.project.writewriting files in the project folders it names
fs.project.readreading files in the project folders it names
render.drawdrawing meshes and textures of its own every frame
render.camerataking over the camera, and leaving the scene's own meshes out of the frame
input.pointerreading 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" }
]
FieldMeaning
idthe plugin, as plugin list shows it
versionoptional: MAJOR, MAJOR.MINOR or MAJOR.MINOR.PATCH; the installed plugin must match the parts given. Absent: the installed one
configoptional: the plugin's settings
allowHostsoptional: 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-run shows it). The editor's Inspector and an agent's scene tools attach them too, and an entity may carry several script components.
  • Its TypeScript (a plugin that ships a package; plugin info says "behaviours import it as ..."): import { ... } from "@argus/training" in a behaviour, once the project lists training.
  • Its page (a plugin with a web app): http://127.0.0.1:<query port>/plugins/<id>/ while the game runs; GET /plugins lists every running plugin's page.
  • Its compiled-in part's status: GET /plugins/<id>/status while the game runs (refreshed every 30 ticks).
  • Its agent tools: through the agent's plugin tool, {"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; node on PATH in a checkout; ARGUS_NODE names 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 for plugin run itself, 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:

HookWhenMay
input_phase(world)every tick, after session commands and before behaviours and physics, held ticks tooread 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 behavioursread 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 requestanswer a JSON summary
stop()a restart, a scene switch, the end of the runclose 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, emit and log;
  • scene, what the scene is and where, read-only: georeference, footprint and ground-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-credits and pointer;
  • jobs, work off the engine's threads: submit, take-finished, pending and workers;
  • data, files and the network, for jobs only: read, write, exists, entries, fetch and fetch-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 render reaches the simulation, and frame is 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-camera takes 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); none gives it back to the game. hide-scene leaves 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-fade draws 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-far draws 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 (needs render.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. none gives the scene's own back.
  • Credits: set-credits gives 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): pointer answers 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-finished hands 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.workers says 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, exists and entries take a place and a relative path that never leaves it: fs.project.write names (read and write); launcher's folder).
    • plugin: the plugin's own folder, read only;
    • project: the project's folder, under the folders its fs.project.read capability names (read) and
    • cache: 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's net.out capability names or the project lets it reach (allowHosts), and no other; no redirect is followed. The standard engine has the network client (feature net-tls). It answers an error on an offline install (the Offline policy), and in a build without the client, which an export is unless it is built with the feature.
  • fetch-with(url, headers) is fetch with request headers: authorization, accept, and names that start x-, 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 data interface inside a job. argus-plugin-compile refuses a component that asks for anything more, by name, so build for wasm32-unknown-unknown (no WASI).
  • It changes the world only as the plugin's capabilities allow: spawn-prefab, set-pose and despawn answer an error without world.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-stopped event {plugin, message}, the plugin's status says why, and the game goes on.
  • A part whose start answers an error (settings it refuses) is left out of the run, as a compiled-in part is: a plugin-failed line 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 pack takes 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 whatever ship lists, 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-plugin adds the version to plugins/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": [] }
}
FieldRequiredMeaning
schemaVersionyes1
idyeslowercase letters, digits and single hyphens, starting with a letter, at most 32; the folder's name
displayName, descriptionyesone line each, for lists
versionyesthe plugin's own MAJOR.MINOR.PATCH
apiVersionyesthe plugin API it was written for; this engine serves 1
enginenothe 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
platformsyesany of windows, linux, macos, web
capabilitiesno[{id, why}] from the list; fs.project.write adds paths, net.out adds hosts
tsnothe built package: entry (JavaScript), types (declarations)
behavioursnothe built behaviours' entries.json (the @argus/gameplay shape); scenes name them <id>:<name>
uinoa folder of UI screens, registered as <id>/<screen>
webno{root}: a folder served at /plugins/<id>/
filesno[{match, schema}]: JSON Schemas for the plugin's own project files; * matches within a folder, ** across
contributesnodata for engine systems, point name to file (journal.kinds, journal.metrics: Training Core)
toolsnothe agent tools file (below)
nativeno{crate, runtime}: the compiled-in part, argus-plugin-<id>; runtime: false for an authoring-only part
configSchemanothe JSON Schema a project's config must meet. Absent: the plugin takes no settings
onAddno{copy: [{from, to}]}: starter files copied into the project on add, never overwriting
requiresno{plugins: [...]}: other plugins the project must list before this one
shipnoother 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

CodeMeansDo
project-pluginan 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-unknownno installed plugin has that idargus plugin list shows the installed ones
plugin-versionthe installed plugin does not match the project's versionset version to the installed line, or remove it
plugin-configa setting is not one the plugin has, or not a value it takesargus plugin info <id> lists the settings
plugin-requiresthe 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-apithe plugin was written for another plugin API or engine seriesuse the plugin release made for this engine
plugin-platformthe 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-nativethe plugin's compiled-in part is not in this engine builduse a build that has it (the standard engine builds have every first-party part)
plugin-descriptora plugin's plugin.json is broken or names a missing filefor plugin authors: the message names the field
plugin-startthe plugin's compiled-in part could not start: a UDP port it binds is taken, a multicast group refused itfree the port or set another (argus plugin add <id> --set port=...); the line names the address
plugin-projectthe project file could not be read or written, or changed while the edit was maderun the command again
plugin-not-listeda remove names a plugin the project does not listargus plugin list marks the listed ones