Talos docs

Self-test: the release smoke check

Since 0.6Changed in 0.7Changed in next

--self-test <scenario> plays a scenario inside the engine and exits with its verdict.

Plain text

--self-test <scenario> plays a scenario inside the engine and exits with its verdict. It is how a release build is smoke-tested on every platform (docs/PLAN-PLATFORMS.md §8): a release engine serves no query API (G2), and on Android, iOS and the web there is nothing to connect to anyway, so the check runs in-process and needs no server.

argus run <project | scene.json> --self-test <scenario> [--headless]
<exported game> --self-test <scenario> [--headless]
  • Exit code: 0 when every step and every check passed, 1 when anything failed, 2 when the scenario could not be found or read.
  • Report: self-test.json, written to $ARGUS_SELF_TEST_OUT if set, else beside $ARGUS_LOG_FILE (an exported game's logs/ folder in its unpack directory), else the platform's log folder (%LOCALAPPDATA%\Argus\logs, ~/Library/Logs/Argus, $XDG_STATE_HOME/argus/logs or ~/.local/state/argus/logs). Captures are saved beside it as self-test-<name>.png. Besides the steps and checks it carries frames (ticks run), digest (the simulation's state at the end, WorldState::digest: every entity, its transform, script state, HUD and goals, so two engines given the same scenario can be compared) and screens at the end of the run: stack (screen names, bottom first), registered and the active input context, as /ui/screens names them. A release engine has no query API, so this is how a caller sees which menu is up.
  • Saves: a run plays from no save, as a first launch does, in a folder of its own made in the system's temporary folder, so ctx.save neither reads nor writes the player's save. --save-dir or $ARGUS_SAVE_DIR names another folder, for a test that inspects what the game saved.
  • Stdout: one line, argus-self-test {"ok":…,"scenario":…,"frames":…,"report":"<path>"}.
  • Surface: never a window. Without --headless the engine renders offscreen, so the capture checks run; with --headless there is no GPU and every capture is skipped (reported as skipped, never faked). An exported game's launcher forwards --self-test and runs the engine offscreen unless --headless is given.
  • Serving: none. --self-test conflicts with --serve, --watch and the session flags.
  • Time: fixed 60 Hz ticks. Seconds and milliseconds in a scenario become ticks, so a scenario is deterministic in the critic build and never depends on how fast the machine is.

Finding the scenario

In this order:

  1. a file path, absolute or relative to the working directory. An exported game's launcher runs the engine in its unpack directory, so it makes a relative path to a file it can see absolute against the folder the game was started from before handing it over;
  2. <project root>/selftest/<name>.json. play, play.json and selftest/play.json (either slash) all name selftest/play.json. A project export, from engine export or the editor, packs the project's selftest/*.json at app/selftest/, so a shipped game runs its own scenarios by name;
  3. smoke, the built-in scenario: 180 ticks, no script errors, and a frame that is not blank.

The format

One format for scripted play: the bench's step language (bench/README.md, "Scripts"; bench/lib/exportrun.mjs) with the same spellings, run against the world directly instead of over HTTP, plus an expect block.

{
  "selfTest": 1,
  "description": "What this scenario proves.",
  "maxFrames": 1800,
  "steps": [
    { "wait": "screen", "name": "main-menu", "top": true },
    { "press": "Start" },
    { "wait": "screen-gone", "name": "main-menu" },
    { "axis": "move", "value": [0, 1], "frames": 60 },
    { "capture": "playing" }
  ],
  "expect": { "goals": 1, "screen": "hud", "top": false, "scriptErrors": 0, "capture": "auto" }
}

selfTest is required, so a stray JSON file is never run as a scenario. Unknown keys are refused. maxFrames (default 3600) is the tick budget for the whole run; running out fails it.

Steps

stepdoesdiffers from the bench
{ "frames": 30 }run 30 ticksself-test only
{ "sleep": 500 }run 500 ms of ticks (30)ticks, not wall-clock
{ "wait": "screen", "name": "hud", "top": true }until the screen is on the stack (on top, with top)
{ "wait": "screen-gone", "name": "main-menu" }until it is not
{ "wait": "text", "match": "score" }until a visible node's text contains it (case-blind)a plain substring: the engine links no regex engine, and a pattern with regex characters is refused
{ "press": "Start" }activate the nearest focusable, enabled ancestor of the visible text Start (whole, case-blind), through the same code POST /ui/activate runs; { "press": { "id": "main-menu:start" } } names the nodewaits settleMs (default 1000) of ticks after start first, as the bench does; a label starting ^ is not a regex here
{ "action": "jump", "frames": 6, "repeat": 3, "gapFrames": 18 }hold the action, through the same code POST /input places holds with; fails if nothing consumes it within bindTimeout seconds (default 3). "player": 2 (a whole number, 1 to 4) drives that local player's entity, as POST /input's player does, once the game has seated them (waited for within bindTimeout; a number past the scene's meta.players max fails at once); player 1's when absentgapMs also accepted
{ "axis": "move", "value": [0, 1], "frames": 30 }the same, for an axis action: value is [x, y] for every axis, and a one-axis action (axis1, such as a vehicle's throttle or steer) reads x, so { "axis": "steer", "value": [1, 0] } steers right; a bare number is read as [value, 0]; player as for action
{ "drive": "road", "road": "track", "frames": 600, "minDistanceM": 300 }drive the player along a road for frames ticks: each tick it holds throttle (default 1, eased off the harder it steers) and a steer toward the point lookAheadM (8) ahead on the road, the way the player faces, on the actions throttleAction / steerAction (throttle, steer); a throttle the scene binds as a button is pressed on that share of ticks instead (0.5: every other tick); road may be left out when the scene has one, and may be a list of roads driven as one route, each joined to the one before whichever way it was drawn ("road": ["a", "b"]). Answers distanceM (along the road) and maxOffRoadM (furthest past the road's edge: flat out, a kart runs a metre or two wide in a tight bend, and a lower throttle holds a tighter line); fails short of minDistanceM (0), or when nothing consumes the actions within bindTimeoutself-test only; always player 1's
{ "touch": [0.2, 0.8], "to": [0.2, 0.5], "frames": 60 }put a finger down at a normalised screen position, walk it to to (default: where it landed) and lift it after frames ticks, through the same holds; fails if no action reads touch within bindTimeout (docs/INPUT.md, "Touch")self-test only; always player 1's
{ "capture": "menu" }render, save self-test-menu.png, fail if the frame is blankskipped under --headless
{ "expect": "goals", "min": 2 }until that many distinct goals have been reached (timeout seconds)self-test only
{ "repeatUntil": { "screen": "game-over" }, "max": 80, "do": [ … ] }the inner steps again and again until the screen is on the stack (screenGone: until it is not), checked before each round; fails after max rounds (default 30) or when an inner step failsno exited condition

Steps run one after another, and an action or axis step holds its one action for its frames before the next step starts: one step cannot hold two actions, so a steer step after a throttle step steers with the throttle released (a vehicle coasts through it).

Every waiting step takes timeout in seconds (default 15). A failed step stops the run: every later step assumed the ones before. The bench steps that only make sense from outside the process — launch, restart, checkUi, track, read, and the expect kinds var, moved, saved and exit — are refused by name rather than skipped.

XR steps (--xr-sim)

With the desktop XR simulator (argus run <project> --xr-sim[=profile] --self-test <scenario>, docs/status/xr-sim.md) a scenario places and works the head and both hands, in fixed ticks, so a VR game is checked with no headset. Every pose is in the XR origin's tracking space, or, with relativeTo, in that entity's own frame, worked out again every tick: a hand put on a moving vehicle's control stays on it.

stepdoes
{ "xrHead": …, "position": [x, y, z], "yaw"?, "pitch"?, "roll"?, "relativeTo"?, "seconds"? }moves the head there (degrees; or "rotation": [x, y, z, w]), over seconds
{ "xrHand": "left"|"right", "position": [x, y, z], "rotation"?, "relativeTo"?, "seconds"?, "hold"? }moves a hand there over seconds; with relativeTo it then stays on that entity (hold, default true) until another xrHand step moves it
{ "xrInput": "left"|"right", "trigger"?, "squeeze"?, "thumbstick"?: [x, y], "primary"?, "secondary"?, "thumbstickClick"?, "menu"?, "seconds"? }sets the controller's values (0 to 1, -1 to 1 for the stick), then runs seconds
{ "xrHandPose": "left"|"right", "tracked"?: true, "curl"?: 0 }hand tracking on or off; curl closes the fingers, 0 open to 1 a fist
{ "xrTracking": "left"|"right", "lost": true }the controller (and the tracked hand) loses tracking, or finds it again with false
{ "xrRecenter": true }the head and hands back at rest on the origin

An XR step in a run without the simulator fails, naming --xr-sim. The same scenario runs unchanged under every headset profile (--xr-sim=focus3, pico4, quest3, index, or a headset file): the poses are in the scene's frame, not the headset's.

Checks (expect)

keypasses whendefault
goalsat least this many distinct goals were reached (goal-reached events), or every goal the scene declares with "all"not checked
screen, topthe screen is on the stack at the end (on top, with top)not checked
scriptErrorsthe run recorded at most this many script errors, load-time errors included0
capturethe final frame is not blank: true requires it, false skips it, "auto" checks it when the run renders"auto"
xrEyeswith true, both XR eyes of the last frame are not blank (--xr-sim)not checked

"Not blank" means at least 0.5% of pixels differ from the frame's first pixel by more than 8 in some channel: a clear-colour frame, a black frame or a failed draw fails it.

Where it runs

  • crates/app/tests/self_test.rs — the checked-in fixtures/selftest/starter-course-start.json on the starter course, and each way a scenario has to fail.
  • packages/export/test/native.test.ts — --self-test smoke on a release export.
  • scripts/release/matrix.sh — every platform the machine can build: the same scene export headless on Linux and Windows, and a project export offscreen on Linux, recorded in dist/release/<version>/MATRIX.json.

Known gap (not the self-test's): a scene-only export carries no font, and a scene that declares text refuses to load without one, so such an export can only run --headless. A project export packs a font under app/ui/fonts/ and renders. The matrix's capture check therefore runs on a project.