Talos docs

Editor and managed sessions

Since 0.6

The editor and its managed sessions: the workspace and session lifecycle, session-bound control of the running game, and the human-facing editor surface.

Plain text

The editor and its managed sessions: the workspace and session lifecycle, session-bound control of the running game, and the human-facing editor surface.

This document describes what is implemented. The editor has its interface — toolbar, viewport, project and inspector, activity feed, jobs and evidence, drawn with egui in one pass with the game image — and behind it the workspace service link and session authority, focus with held-input release, the cross-OS placement rule, and the Windows GUI-subsystem build.

Contents

  1. Entry points
  2. Session model
  3. Control policy
  4. Device input
  5. Focus
  6. Handshake
  7. Headless worker mode
  8. Scenes
  9. Editing the scene
  10. Selection, Outliner and view state
  11. Transform gizmos
  12. Play controls
  13. Viewport modes
  14. Unsaved work: the prompt, autosave and recovery
  15. Editor Preferences
  16. Prefabs
  17. World Settings
  18. Project Settings
  19. Behaviours
  20. Importing models
  21. The UI category and the viewport preview
  22. The Output Log
  23. The Undo History
  24. The Input table
  25. Exporting a game
  26. Restart state
  27. The workspace service
  28. The interface
  29. The shell
  30. The theme
  31. Windows build
  32. Simulating the human in tests
  33. Known limits

Entry points

CommandBuildDoes
argus-editorcargo build -p argus-editorOpens the window on the launcher (see The launcher): recent projects, the projects in the default projects folder, New project from the bundled template, Open by path. What the desktop shortcut runs
argus-editor <project>as aboveOpens the project (its folder or its argus.project.json) in the editor window. A project that does not open shows the launcher with the reason instead of no window; a project on the other operating system's file system is still refused before the window
argus editor <project> [flags]cargo build -p argus --features editorRuns argus-editor beside this executable (or $ARGUS_EDITOR) with the same arguments. On Unix it execs, leaving no wrapper process; on Windows it waits for the editor as a child and exits with its status, so a launcher that checks pids starts argus-editor.exe directly
argus-editor worker <scene> --session-id … --session-kind test --job-id … [flags]as aboveRuns one test session as a headless worker, in this process (argus::runner::run, no child): the spawned pid is the serving pid. What the editor starts for every test session
argus run <scene> --session-id … --session-kind … [flags]cargo build -p argus --features managedThe same managed runtime through the engine binary, for other launchers (SchedB's jobs, the harness)
argus run <scene> …defaultStandalone, unchanged: no session, every documented flag and default as before

Launchers (the scheduler, the harness, anything that verifies a worker's pid, socket owner or start time) start argus-editor worker … or argus run … built with --features managed directly. Never through the argus editor shim, cargo run or a shell wrapper: each of those leaves the process the launcher spawned wrapping the one that serves (the shim does on Windows; on Unix it execs).

The editor is its own package (crates/editor), so the exported game links no editor code. Managed sessions are the managed feature of crates/app, off by default: it brings argus-workspace (and with it regex), and the default build is the deploy engine that scripts/size-gate.sh measures against its 100 MB gate (SPEC amendment 10). A standalone argus run behaves the same with or without the feature.

argus-editor <project> flags: --window-size WxH (window and render size, default 1280x720), --no-vsync, --no-service (do not start a workspace service; ARGUS_NO_SERVICE=1 too), --port/--ws-port (each Play session's query API; default 0, ephemeral), --log-file PATH and --console (see Windows build), and --test-inject-script <file> (test only; see below). The project is opened, and refused if it must be, before the window is created.

argus run gained these flags; with none of them it behaves exactly as before:

FlagMeaning
--seed <u64>The world seed, set before the first tick; see World seed. A managed session's seed must be at most 2^53−1 (the P1 contract's limit); a standalone one takes the whole u64.
--session-id ses_…Run as this managed session. Needs --session-kind, implies --serve, needs the managed feature.
--session-kind preview|human-play|testThe P1 session kind. test refuses --window; human-play needs it.
--job-id job_…The owning job. Required for test.
--project-id <uuid>, --artifact-id art_…What the session runs. With both given, the flags also make a full P1 Session record (owner: service argus-engine), validated by the contract's rules.
--capabilities a,bWhat the session holds (P1 names; default observe). API callers get these capped by the kind; in-process controls check them directly.
--anonymous-capabilities a,bWhat a caller presenting no identity may use (default observe), never more than the holder.
--base-dir DIRWhere script.module and relative asset paths resolve, instead of the scene file's folder; a worker running an artifact's frozen scene copy needs it.

Every id is checked against its P1 format before anything loads, and a refused flag names itself. The engine generates the nonce; it never takes one. Every serving argus run prints one argus-ready line on stdout (see Handshake). SchedB's launch, argus run <scene> --serve --paused --frames 0 --port 0 --ws-port 0 [--headless], works unchanged.

A project is opened through argus_contract::resolve_project (docs/PROJECT-MANIFEST.md): the manifest's startupScene is the scene the editor shows and plays.

Session model

Session kinds, records and lifecycles are the P1 workspace contract's (docs/WORKSPACE-CONTRACT.md §7, argus_workspace::Session); the editor keeps them in a SessionRegistry and moves a record only along the contract's lifecycle table.

KindWhat it isRuns inOwnerRecord capabilities
previewThe authoring view: a World loaded from the current artifact, never stepped, rendered through the editor camerathe editor processthe humanobserve, capture
human-playOne Play, from Play to Stop: argus::window::Session, the same World stepping, query bridge and rendering argus run --window usesthe editor processthe humanall nine: the human holds them, in-process
testOne test session, owned by a jobits own worker process (argus-editor worker)an agentwhat the job is granted

Authoring state is the editor's, never the game's. Camera and selection live in the editor (EditorCamera, a list of stable entity ids). The authoring world renders and captures through the camera as argus::ViewOverride, so the scene's camera entity, the gameplay rig and the audio listener are untouched.

Play. Editor::play_start(PlayStart::Fresh | PlayStart::Restore(snapshot)) opens a new human-play session from the current ready artifact: a fresh load of its frozen scene, optionally with a Play snapshot the human saved on purpose (save_play_snapshot, F8) applied over it. Pause (set_paused, F10), step (step(n), F11) and the time scale (set_time_scale) go through the same POST /time code as the API (QueryBridge::owner_time), as the session's owner rather than as an API caller. Stop (stop, Shift+Esc) ends the session (running|paused → stopping → stopped), drops the Play world and returns to authoring exactly as the human left it. Play, pause, step and stop repeat without closing the editor; each Play is a new session id and a new query server with a new nonce.

One renderer, two worlds. The renderer whose device presents to the window moves into the Play world at Play (World::take_renderer) and back into the authoring world at Stop (World::attach_renderer). The authoring world keeps its meshes, render target and camera across Play.

What Play loads (C2). When the workspace service has a Ready build and has verified its content, Play loads exactly that build: a crate::artifact::LoadDescriptor binding the revision, the service build and artifact, the SDK check policy, the verified content root, the build job's compiled behaviours, the engine build and the run profile, together with a manifest of every byte it will read. The scene and the assets come from the revision's read-only snapshot; the behaviours come from the build job's output, already transpiled and already checked. Nothing is read from the authored tree and nothing is transpiled at Play startup.

The decision is taken once, before the world opens, and handed on as a value, so a newer build landing while a world loads cannot mix its bytes into the session or change which build the session says it is. A build whose content is missing, short, unverified or unchecked is an Unavailable with a stable code (content-missing, content-incomplete, content-unverified, modules-missing, typecheck-skipped, engine-incompatible, content-mismatch) and the repair in it. It is never a reason to load the authored tree instead. A refused Play leaves the editor as it was: the same artifact, the same document, the authoring view intact, and the previously selected build still playable.

Editor::selected_build is the build a runtime here would open; Editor::play_descriptor is the one a running session opened; Editor::play_unavailable is why the last attempt did not start. Ready, selected, running and failed are four different things and the editor keeps them apart.

Two identities. LoadDescriptor::content_id is the revision plus the digest of every loaded byte: the editor's Play, a background test worker and a native export of one build share it. LoadDescriptor::runtime_id adds the engine binary and the run profile, so those three do not share it. A verdict reached on one engine is not a verdict about another, and a deploy binary is never relabelled with an editor artifact id.

Test sessions load the same build, by check. The editor writes the descriptor's hand-over (crate::artifact::Handover) beside the worker's session record; the worker resolves that selection itself and refuses to run unless the content identity it computes is the one it was handed. Two processes agreeing on the digest of every byte they loaded are running the same build; two processes pointed at one folder are only probably running it.

Retention. Before a world opens, the editor asks the service to keep that build usable (POST /v1/retention), and the link renews the lease while the session lives. Stop releases it; a worker that exits releases its own; an export holds one while it composes (Editor::export_retain). A lease is short and renewed, so an editor that is killed simply stops renewing and its content becomes collectable within one collection round. See docs/WORKSPACE-SERVICE.md.

The authoring preview. A project with no workspace service has no checked build, and Play then runs the preview: the editor's own ReadyArtifact, with behaviours and assets read from the tree behind the artifact's stale check. It is labelled as the preview it is (PlayContent::provenance) and it never claims to have applied a build. Editor::play_last_ready — the artifact this editor kept after a save the service refused — is the same preview path, for the same reason. The preview is a separate thing, not a fallback from a checked build that failed.

Applied is a report, not a prediction. The update-applied acknowledgement is written only after the runtime opened the descriptor, reported its identity and passed the loaded-byte check, and it names the artifact the descriptor carries. A Play that failed, that partially opened, that ran the preview or that fell back to the retained artifact says nothing at all. The mode is restart: this delivery applies a build by loading it again, and does not claim state-preserving hot reload.

Ready artifacts. The authoring preview starts from a ReadyArtifact, never from the live scene file. Preparing one (at open, or Ctrl+F2) reads the manifest, the startup scene, and each project file the scene makes a world read (each script.module that resolves to a .ts/.js file, each asset file). Their bytes are the P1 revision inputs; the artifact id is that revision on this engine build (the running executable's sha256, version, target, profile, features), and the record is a P1 BuildArtifact. The scene and manifest are copied to <project>/.argus/editor/artifacts/<artifactId>/, and Play and workers load that copy. Behaviours and assets are still read from the project folder when a world loads, so their hashes are compared with the files twice: before the load, and again once the world (or the worker, by its ready line) has read them. A changed, missing or newly present file makes the artifact stale, and Play and test sessions refuse it by name until a new artifact is prepared; a save between the two checks refuses too, rather than run other bytes under the artifact's id.

Test sessions. Editor::start_test_session(TestSessionSpec) makes a test record (kept under .argus/editor/workers/<sessionId>/session.json), starts a worker with that record's identity flags (worker::identity_args) and, on a checked build, the descriptor hand-over (--load-descriptor), waits for its ready line, and folds the bound endpoints and pid into the registry. A worker that exits is marked failed (non-zero or a signal) or stopped (clean) the next time the editor polls, which is every frame. Nothing else happens: the human's Play is a different process's business.

Lifecycle steps. A runtime reports where its session has got to, so a bound worker arrives as running straight after starting. The P1 lifecycle goes starting → ready → running; when the workspace service is the session authority, the registry records every step in between (registry::lifecycle_steps), so the service, which checks each transition, sees a legal history.

Keys

Every key is a keymap action with a stable id (crates/editor/src/keymap.rs). The defaults are the unreal preset's; the unity preset is one choice away, and any action can be rebound on Edit ▸ Editor Preferences… ▸ Keyboard Shortcuts (also the toolbar's Settings ▾). The keymap is the person's, not the project's, as the usual editor's Editor Preferences are: <config dir>/argus/editor/keymap.json (%APPDATA% on Windows, ~/Library/Application Support on macOS, $XDG_CONFIG_HOME or ~/.config elsewhere; ARGUS_KEYMAP_FILE overrides it for scripted runs), as { "preset": "unreal" | "unity", "overrides": { "edit.duplicate": "Ctrl+Shift+D" } }. An override is one key, a list, or "" for none, in stopPlayKey's grammar (modifiers and one key joined by +, matched exactly; `, [, ] and the other punctuation keys may be written as the character). A file that does not read is the default preset with the reason on stderr; a bad entry stays at the preset and is named, and the rest applies. Agents read the effective keymap with GET /v1/keymap and run an action by id with POST /v1/do {"do":"action","id":"…"} (AGENT.md).

Action idWhatunreal preset (default)unity preset
tool.select, tool.move, tool.rotate, tool.scaleselect mode (no gizmo), move, rotate, scale toolQ, W, E, RQ, W, E, R
tool.cyclecycle move / rotate / scaleSpaceSpace
tool.space, tool.pivotgizmo in world or local space, at the entity's origin or the selection's centre (Transform gizmos)Ctrl+\`, — (toolbar)X, Z
camera.focusframe the selectionFF
camera.fly-*while the right mouse button is held: forward, back, left, right, up, downW, S, A, D, E, Qsame
view.gamegame view: hide every editor-only drawing (grid, overlays, outline, gizmo) (Viewport modes)GG
view.presentationPresentation view: the viewport alone, a slow camera, the agents' activity (Presentation view)F11F11
view.perspective / top / front / sideviewport projection: perspective, or Top, Front, Side (left) orthographicAlt+G / Alt+J / Alt+H / Alt+Ksame
view.wireframe / unlit / litview modeAlt+2 / Alt+3 / Alt+4same
grid.smaller, grid.largergrid snap size, down and up the usual editor's list (0.01 m to 100 m)[, ]same
edit.undo, edit.redoundo, redo (Editing the scene)Ctrl+Z; Ctrl+Y or Ctrl+Shift+Zsame
edit.copy, edit.cut, edit.pasteclipboard; Ctrl+V pastes on the surface under the pointer (Selection)Ctrl+C, Ctrl+X, Ctrl+Vsame
edit.paste-in-placepaste where the entities were copied fromCtrl+Shift+Vsame
edit.duplicate, edit.deleteduplicate, delete the selectionCtrl+D or Ctrl+W, DeleteCtrl+D, Delete
edit.renamerenameF2F2
edit.select-all, edit.select-noneselect all, clear the selectionCtrl+A, EscCtrl+A, Esc or Shift+D
edit.hide, edit.unhide-all, edit.isolatehide selected, unhide all, hide unselectedH, Ctrl+H, Shift+Hsame
edit.drop-to-floordrop to floorEndEnd
snap.vertexheld while dragging a Move handle, the pivot snaps to the nearest vertex (Transform gizmos)VV
camera.bookmark-save-1 … -9, camera.bookmark-1 … -9keep the view in a bookmark, go back to it (Viewport modes)Ctrl+1 … 9, 1 … 9same
file.savesave the sceneCtrl+SCtrl+S
artifact.prepareprepare a new artifact from the current sourcesCtrl+F2Ctrl+F2
play.start, play.from-snapshotPlay fresh, Play from the last saved Play snapshotF5 or Alt+P, F6F5 or Ctrl+P, F6
play.stop (Play)Stop, besides the project's stopPlayKey (Shift+Esc)—Ctrl+P
play.pause, play.step (Play)pause and resume, step one tickF10 or Pause, F11F10 or Ctrl+Shift+P, F11 or Ctrl+Alt+P
play.eject (Play)detach the view into a free camera, or return to the game's (Play controls)F8F8
play.simulateSimulate: Play without the playerAlt+SAlt+S
play.slower, play.faster (Play)the Play clock one notch down or up 0.1x, 0.25x, 0.5x, 1x, 2x, 4xCtrl+[, Ctrl+]same
play.snapshot, play.capture, play.overlays (Play)save a Play snapshot, capture a frame, debug overlaysShift+F8, F9, F1same

Surface snapping in the alternate preset is also that preset's own gesture, Ctrl+Shift held while dragging the move handle's centre or a plane square; modifiers alone are not a keymap chord, so it belongs to the preset (GET /v1/keymap lists it under mouse there) and the toolbar's Surface toggle does the same in both presets. In the default preset Ctrl+Shift during a drag inverts the grid, as Ctrl does.

The mouse is the same in both presets and is not rebound: right button held flies (drag to look, W A S D to move, Q / E down and up, the wheel sets the speed step 1 to 8, Shift is faster; see Camera speed under Viewport modes), Alt+left drag orbits, middle drag pans, Alt+right drag dollies, the wheel alone zooms. In an ortho view (Top, Front, Side) there is no fly and no orbit: the right or middle button drags the plane with the pointer, and the wheel zooms. A left click selects, Ctrl+click toggles and Shift+click adds, and Ctrl+Alt+left drag is box select (with Shift, it adds) (Selection). A left press on a gizmo handle drags it instead, and wins over the click, the Alt orbit and everything but Ctrl+Alt: Alt+drag duplicates first, Ctrl while dragging inverts the grid snap, and Esc or the right button during the drag puts everything back (Transform gizmos). In Play every key the keymap does not keep, Escape included, goes to the game: the scene's action map, exactly as argus run --window. Esc in a text field leaves the field and does nothing else. While Preferences waits for a key to rebind, no shortcut runs.

Copy, cut, select all, hide, isolate, drop to floor and rename (F2 opens the Outliner's name field on the selection) run the selection's own calls. Paste is the window's: it reads the system clipboard for the command key and V and pastes entities copied from an editor, so edit.paste rebound elsewhere does not read the clipboard. Space, pivot and grid change the gizmo settings, and the view keys the viewport's projection, view mode and game view (Viewport modes); the status bar says what they are now. The transform tool is one of the gizmo settings (Editor::gizmo, Shell::tool; view.tool and view.gizmo.tool in /v1/state), so it is kept in state.json.

Where Talos keeps its own key. Other editors disagree with Talos in a few places, and the editor keeps these on purpose:

  1. F2 renames, as in both; preparing an artifact moved to Ctrl+F2 (the toolbar button stays).
  2. Esc during Play is the game's; Stop is Shift+Esc (below).
  3. F8 during Play ejects the camera, as in the usual editor; the Play snapshot moved to Shift+F8 (an older keymap file that overrides play.snapshot keeps its key).
  4. F1 during Play toggles overlays, where the usual editor shows wireframe; Alt+2 is the authoring wireframe key.
  5. W A S D fly only while the right button is held; the earlier plain-WASD pan is gone.
  6. Q / E are tools with the right button up and fly down / up while it is held, exactly as in the usual editor.

Stop is Shift+Escape, and Escape is the game's. Escape alone used to stop Play, which took it from every game that opens a pause menu on it. The key is the project's to choose: stopPlayKey in <project>/.argus/editor/settings.json (the file that also keeps liveUpdates), as modifiers and one key joined by + — "Shift+Escape" (the default), "F7", "Ctrl+Alt+F12", key names as winit or a scene's input map spells them. The modifiers must match exactly, so with the default Escape reaches the game and only Shift+Escape stops. A value that is not a key, is a modifier alone or is one of Play's own keys (F1, F8 to F11) is refused with the reason on stderr, and Stop stays Shift+Escape. The file is read when the project opens (shell::StopKey::for_project). The toolbar's Stop button stops Play whatever the key.

The window closes with its close button, never a key. The title shows the project and either authoring or the Play session id.

Control policy

A managed runtime serves its query API through argus_query::serve_managed. The guard in crates/query/src/guard.rs checks every request, HEAD and WS /stream included, before any handler runs ("Managed sessions" in crates/query/API.md):

  1. An identity that is not this server instance's (x-argus-session naming another session, or an x-argus-nonce another instance issued) is 409 session_mismatch, on every route.
  2. A request presenting this instance's nonce may use what the session's holder is granted, or it gets 403 capability_denied.
  3. A request presenting no identity may use what anonymous callers are granted, or it gets 428 session_required.

The runtime decides the two grants (argus::session::HostedSession::declaration) from what the session holds (--capabilities, or the editor's record) and what anonymous callers were asked for (--anonymous-capabilities), capped by the kind:

  • preview and human-play: an API caller never gets more than observe, capture and snapshot-read. The human's pause, step, stop and reload are in-process controls in the editor, not API capabilities. The editor grants anonymous callers exactly those three, so agents can watch the human; a plain argus run grants observe unless asked.
  • human-play owned by an agent (argus::session::holder_ceiling_for): the Play of an editor an agent started with editor.start, which names its owner through ARGUS_EDITOR_ACTOR. Nobody sits at that editor and the API is its owner's only hand, so the identified holder gets what the record holds (every capability, for a Play record); ui.activate and ui.push answer there. Anonymous callers keep the three above. A person's Play, and every preview, keep the kind's ceiling whoever asks. The table below is a person's (tests/managed_session.rs builds its human-play records with a human owner); crates/app/src/session.rs (an_agents_own_play_grants_its_holder_what_the_record_holds) and crates/agent/tests/play_parity.rs hold the agent's case.
  • test: the holder gets what the session holds; anonymous callers get what was asked for (default observe).
  • observe is always granted, and anonymous callers never get more than the holder.

Layered checks. QueryBridge::drain_commands repeats the caller-independent half on the simulation thread: a command for a capability the holder is not granted — so no API caller at all is — is answered capability_denied without being applied. The guard has already refused such a request (403 or 428), so this only answers if something bypasses the router; identity-dependent refusals stay with the guard, the only side that sees identity.

In-process controls. A managed runtime's own controls check what the session holds, not the API grant: in window::Session::hotkey, F5 needs reload, F10 needs time, F9 needs capture and the quit keys need stop (Shift+Esc, Alt+F4, and Esc when the game binds nothing to it; docs/EXPORT.md); argus run --watch needs reload. A standalone runtime allows all of them. camera has no in-process control yet.

Each row below is a session holding every capability, so the row shows what the kind itself permits. The columns are a caller presenting this session's id and this instance's nonce; a caller presenting nothing, with the default anonymous grant (observe); a caller presenting nothing, when the launcher asked for every capability for anonymous callers; and a caller presenting this session's id with another instance's nonce (a stale port, or a replaced process). camera, reload and stop have no endpoint in API v1.0.0; their rows are the declaration. tests/managed_session.rs (the_policy_table_in_docs_editor_md_is_what_a_managed_server_enforces) checks every row against a real managed server.

Session kindCapabilityOwn identityNo identity (default)No identity (all asked)Stale nonce
previewobserveallowedallowedallowed409
previewcaptureallowed428allowed409
previewsnapshot-readallowed428allowed409
previewinput403428428409
previewtime403428428409
previewsnapshot-restore403428428409
previewcamera403428428409
previewreload403428428409
previewstop403428428409
human-playobserveallowedallowedallowed409
human-playcaptureallowed428allowed409
human-playsnapshot-readallowed428allowed409
human-playinput403428428409
human-playtime403428428409
human-playsnapshot-restore403428428409
human-playcamera403428428409
human-playreload403428428409
human-playstop403428428409
testobserveallowedallowedallowed409
testcaptureallowed428allowed409
testsnapshot-readallowed428allowed409
testinputallowed428allowed409
testtimeallowed428allowed409
testsnapshot-restoreallowed428allowed409
testcameraallowed428allowed409
testreloadallowed428allowed409
teststopallowed428allowed409

Device input

RuntimeKeyboard, mouse, padsPOST /input
standalone argus run --windowinto the world's action mappermerged with the devices in the same tick, as documented in crates/app/src/input/mod.rs (unchanged)
human-play (editor Play, or argus run --window --session)the human's: into the Play world403 capability_denied with the session's identity, 428 session_required without
previewthe editor camera and selection; never a world403 / 428
test workernone: no window, no padsthe job's, with the session's identity

In the editor, a key only reaches the Play world through Shell::on_keyboard, and only while the game has input (see Focus). A human-play window::Session passes device events to its world (DeviceAccess::device_input); a preview session does not. An API caller cannot write into the human's held-input state at all: no command that could reaches the world.

Focus

Input belongs to the viewport (the game image) or to the editor's interface (focus::Owner, Shell::set_owner). The game gets keys, mouse buttons, motion, wheel and pads only while Playing, with the window focused and the viewport owning input (Shell::game_has_input).

Nothing stays held. Every key and mouse button sent to the game down is tracked (focus::Held); an up is sent only for what the game holds. Whatever takes input away from the game sends the matching ups first:

What happensThe game is sent
the interface takes input (set_owner(Owner::Ui))an up for every held key and button
the window loses focusthe ups, then DeviceEvent::Reset
the stop key in Play (Shift+Esc by default)the ups, before the Play world is dropped
a zero-size resize (minimised)the ups

Pads follow the keyboard: ActionMapper::set_gamepads_suspended holds them whenever the game has no input. While suspended, the mapper still drains the pad backend and drops what it says; suspending releases held sticks, triggers and buttons; resuming drops what the pads said in between, so there is no burst of stale input (a stick still held on resume reads neutral until it moves).

Escape in a focused text field leaves the field and does nothing else; otherwise it goes to the game in Play, or clears the selection in authoring. The stop key (Shift+Escape unless stopPlayKey says otherwise) stops Play. Neither closes the editor. While a text field has the keyboard, the game gets no keys and its pads are suspended.

crates/editor/tests/focus.rs gates focus loss, the interface taking input, a zero-size resize, Escape reaching the game, the stop key and stopPlayKey at the winit boundary.

Handshake

The handshake type, the headers and the error codes are crates/query's (SessionHandshake, x-argus-session, x-argus-nonce; "Managed sessions" and GET /session in crates/query/API.md). The runtime's part:

  • Every serving runtime prints argus-ready <SessionHandshake JSON> on stdout, once, flushed, after both listeners are bound and the first snapshot is published, and before any human-readable stderr line about its listeners (a launcher reads a stderr address with no ready line as a legacy engine): argus run --serve (with "managed": false), argus run --session-id …, each editor Play session, each test worker. It carries the session id, kind, project id, artifact id, job id, the holder's and anonymous capabilities, pid, the server instance's nonce, the bound endpoints, the API version, and devices.
  • devices is {"window", "gamepads", "audio_output"}, declared from the session kind's device policy (DeviceAccess::declared, through Config::with_devices), managed or standalone.
  • The nonce is fresh for every server instance: a worker restarted with the same session id, or a new Play, has a different one, so an identity held from before is 409 session_mismatch.
  • GET /session answers the same body, with the nonce only to a caller that presented it.
  • The editor reads a worker's ready line from stdout only: a ready line on stderr is refused and the process killed, as the harness launcher and argus-jobs refuse it. It adopts a worker only when that line names the session it started, as a test session, from the pid it spawned, with a nonce, declaring no window, gamepads or audio output (TestWorker::spawn; refused otherwise, and the process killed). It keeps that nonce for whoever drives the session (TestWorker::nonce) and shows it nowhere: every logged worker line and every refusal has it replaced by redacted. The human-play identity is Editor::play_identity. Neither nonce is written to .argus/editor/; with the workspace service as the session authority, each is posted to it beside its record (see Session authority).

World seed

argus run --seed <u64> (and a worker's --seed, from its record) sets the world seed before the first tick (World::set_seed). The app's StateSource reports it, so GET /snapshot carries it as rng_seed, and POST /snapshot restores it.

The seed drives both random streams. Behaviours' Math.random and particle scatter derive from the world seed, a stable authored identity (a behaviour's entity id, module and duplicate ordinal; a scene emitter's entity id; for ctx.particles.emit, the behaviour calling it), the frame, and the draws so far on that frame. Equal seeds and equal steps give equal snapshots, whatever order the scene declares its entities in, and a snapshot restored at a tick boundary resumes the same draws.

This is a behaviour change for scripts. Math.random used to be seeded from the clock, so a behaviour saw different numbers on every run. Now a run without --seed has world seed 0, and every behaviour gets the same sequence on every run; pass another --seed for other numbers.

Not covered: Date.now(), new Date() and performance.now() follow the simulation clock between ticks but the wall clock within one (so a behaviour can still time its own loop), which a behaviour that branches on sub-tick timing can see; Date also starts from the wall-clock time the run began; particle state is not in snapshots, so live particles are not restore-equal; and a snapshot taken after draws made between ticks (an init on hot reload) is not restore-equal.

Headless worker mode

A test session never runs in the human's process. Its worker:

  • opens no window and so takes no focus: HostedSession::devices refuses a test session with a window before anything loads, in argus run and in window::Session::open. On Windows the editor starts workers through worker::no_console (CREATE_NO_WINDOW), so no console is allocated and no console window appears; crates/editor/tests/windows_spawn.rs checks that by effect without opening a window;
  • polls no gamepads: pads are opened only by window::Session::open, and only when the session's DeviceAccess::gamepads allows. A worker never runs that path, and a preview session in a window does not open them either;
  • declares all of this on its handshake: devices is {"window": false, "gamepads": false, "audio_output": false}, and the editor refuses a worker that declares otherwise;
  • opens no audio output: DeviceAccess::audio_output is false for every test worker (and for a preview), and window::Session::open copies it into LoadOpts::audio_output, so the worker's mixer runs on NullBackend while the human's Play opens the real output device (crates/app/src/audio_out.rs). The editor's own tests never make a sound either: only the argus-editor binary allows device output, so an in-process Play in a test process declares audio_output: false;
  • renders offscreen unless started --headless, so POST /capture works;
  • dies with its editor: the editor holds the worker's stdin (--parent-pipe), and the worker exits when it closes.

A human-play session is the reverse: it needs a window, and argus run --session <human-play record> without --window is refused.

The launcher

crate::launcher::Launcher is the window's other screen (crate::app::Screen). Its pages, the recent list, the project templates and the palette are the argus-projects crate (crates/projects), which the Talos Launcher draws too; crate::launcher, crate::recent, crate::template and crate::theme re-export them under their old names. The launcher is the same window, surface and painter, with the renderer parked in the App until a project holds it. It is a project browser in the manner of a game engine's: a sidebar (launcher.tab.projects, launcher.tab.new) and two pages.

Projects is a grid of cards (launcher.project.<n>): every project opened here, newest first, each with when it was last opened ("Opened 2 hours ago"), then every project sitting in the projects folder that is not on that list ("In the projects folder"), each with the thumbnail the editor wrote for it (<project>/.argus/editor/thumbnail.png, written by crate::thumbnail at every save and close from the authoring view, at most 320 pixels a side) or a tinted placeholder with its initial. One click selects a card, a double click opens it, and the footer names the selected project's folder with Open (launcher.open) and Remove from list (launcher.forget, recent projects only; the project stays where it is). Browse… (launcher.browse, Windows) asks the operating system for a project folder through a native dialog (rfd, Windows only; elsewhere it is not drawn); Open by path… (launcher.open.toggle) shows a typed path field (launcher.open.path, launcher.open.open) which is the default on Linux. A project that does not open says why in red (launcher.error) and the launcher stays.

New project picks a template card (launcher.new.template.starter: a floor, three boxes, a fixed camera and the player spawn; launcher.new.template.empty: the camera and the spawn only, the least the contract accepts), a Name (launcher.new.name) and a Location (launcher.new.location, with Browse… on Windows), shows the folder it will make, and Create (launcher.new.create; Enter in a field presses it) writes <Location>/<Name>/scenes/main.scene.json then argus.project.json (crate::template::create_project_with), with application.id dev.argus.<slug> and executableName the slug of the name, checked against the manifest's field rules; a non-empty folder or an empty name is refused without writing anything. The default Location is the projects folder: ARGUS_PROJECTS_DIR when it is set, then the person's projects folder setting (projectsDir in %LOCALAPPDATA%\Argus\settings\projects.json, one per person for every engine version and the Talos Launcher; argus_projects::write_projects_dir sets it), then %USERPROFILE%\Documents\Argus (~/Argus elsewhere). The Projects page scans the same folder. Paths with spaces and non-ASCII characters are fine. The third card, Collection course, is the starter course project; see docs/STARTER-COURSE.md. The cards follow the Type row beside the Template heading (launcher.new.type.2d, .3d, .3d hd): 3D and 3D HD show those three, and 2D shows the 2D ones, 2D starter (launcher.new.template.2d starter: a small side-scroller with a tilemap level, a planar player that runs and jumps, a pickup, a goal flag, a HUD, a camera that follows and stops at the level's ends (the built-in camera-2d behaviour), its three behaviours and two CC0 Kenney sheets under scenes/) and 2D empty (launcher.new.template.2d empty: an orthographic camera, the spawn and a floor, set up for pixel art). Picking 2D selects the 2D starter, and going back to 3D the 3D starter; 3D and 3D HD keep the card that was picked (argus_projects::template::Template::for_type). This is setup only: the scenes carry their own orthographic camera and render settings, and run the same in any project (docs/PROJECT-MANIFEST.md, kind).

The New page fits the window the launcher promises, at every display scale it is likely to meet. A 960x640 px window at 200 % scale is a 480x320 pt page, which is the ordinary setting on a 4K laptop, and three template cards beside a fixed 200 pt sidebar did not come close to fitting it: the cards, the Location field and Create all ended up outside. The chrome is what gives way, in this order — the sidebar shrinks with the window (never below 140 pt), the page margins tighten, the heading drops a few points, the cards lose their descriptions to a tooltip and shorten, and the "Creates <path>" preview goes away. What never gives way is the three cards, the two fields and Create, all inside the window and not overlapping. crates/editor/tests/launcher_d4_review_probes.rs asserts exactly that at 100, 125, 150, 175 and 200 %.

Recent projects live outside any project, in %LOCALAPPDATA%\argus\recent.json ($XDG_DATA_HOME/argus/recent.json or ~/.local/share/argus/recent.json elsewhere; ARGUS_RECENT_FILE overrides it): schema 1, ten newest first, deduplicated by path; a missing or unreadable file is an empty list, never a failure. Opening a project remembers it; Close project in the toolbar returns to the launcher (after the unsaved-work prompt) with the renderer handed back (Editor::close_into_renderer), so moving between projects never rebuilds the window. The disk is read when something changed, never per frame: the projects folder is scanned when the Location comes to name another folder and after a create; thumbnails once per project, again after a project closes.

Scenes

A project's scenes are its *.scene.json files, and one of them is open. That is the same definition the workspace service builds by (argus_workspace_service::inputs::role_for), reused rather than restated, so what the File menu lists and what the service builds cannot drift apart. There is no plural field in the manifest and none is needed: startupScene names the one a packaged build boots into, and every other scene is simply a file in the project. The usual editor vocabulary maps directly — a scene is a level, the startup scene is the Game Default Map.

One scene is open at a time. Docking, multi-document tabs and Save Current Scene As are deliberately not built; the shape chosen is one open document, switched.

File menuHit nameWhat it does
New scene…scene.newA dialog: a name (scene.new.name) and a template, Starter or Empty (scene.new.template.<t>). Create (scene.new.create) writes <the open scene's folder>/<name>.scene.json and opens it; Cancel (scene.new.cancel) writes nothing. Empty is a camera, the player spawn and a floor — the floor is not decoration: the navmesh is built from static, non-trigger colliders, so a scene with none is one the critic reports navmesh-failed on, and New scene ▸ Empty used to hand a person exactly that
Open scene ▸scene.open, then scene.open.<project-relative path>A submenu of every scene in the project, the open one marked and disabled. Opens it behind the unsaved-work prompt
Set as startup scenescene.set-startupThe open scene becomes the manifest's startupScene. Disabled when it already is. Allowed on a scene that would not make a playable game — a person may be part way through a level — but the message says when the scene has no static collider, because then the navmesh is empty and nothing in it can route

New scene refuses before it writes. A scene's name is a file name, not a path: / and \ are refused rather than quietly making a folder. Everything else is the contract's own project-path rule (argus_workspace::path_problem), asked about a project-relative path built as text and never through Path::join — join parses, and what it parses depends on the platform, so a:b was once refused as "not inside the project" on Windows and as "Windows forbids …" on Linux. The characters come first and the escape check follows, so a name Windows will not give a file — a device name like CON, a colon, a trailing dot — is refused in the same words everywhere. Three more, each of which the design review found being refused after the file was written or not at all: a dot-leading name, which inputs::is_excluded drops, so the scene was written and then called "not part of the project" by the editor that had just made it; a name longer than Editor::SCENE_NAME_MAX, which used to fail at the write with a raw ENAMETOOLONG after the click; and a name that differs from an existing scene only in case, which coexisted on Linux and collided on Windows (argus_workspace::path_key is the comparison, the same one the rest of the workspace uses). A name that already names a file is refused too: New scene creates a scene, and overwriting one somebody forgot about is not creating one. The reason appears in the dialog as the name is typed, and Create is disabled until there is none.

Open scene rebuilds the artifact. Editor::open_scene freezes the named document (ReadyArtifact::prepare_scene, which is prepare against a path rather than a derived one) and replaces what described the previous one: the selection and camera are entity ids and a viewpoint into a world no longer loaded, the edit history is a history of another file, and the retained "last Ready" build and the Play snapshot are builds and saves of another scene — offering either as a fallback would run the scene the person just navigated away from. The inspector's drafts go with them. The build view is repointed in the same call: a revision is matched by the editor's scene path and the bytes it last wrote (builds.rs), so a panel still matching the previous scene's revision would show a stale Ready and let Play run the previous scene's build.

Unsaved work is asked about first, with the same question and the same prompt.* names the close prompt uses, because it is the same question ("Save changes to <scene>?", Yes / No / Cancel). Cancel keeps the current scene and its changes.

Play runs the open scene with no extra machinery: Play has always run self.artifact, and the artifact is now the open document's. The workspace service already built every scene in a revision (build/stages.rs), so a build for the open scene is there to be matched.

Set as startup is one /startupScene field edit. It goes through the settings backend whenever that backend can edit — the same expected-value rule and the same conflict and lease answers as every other setting. Where the backend is read-only the editor writes the manifest itself, in the bytes a settings apply would have written (argus_workspace_service::settings::manifest_text), and validates the result by opening the project again; a refusal puts the previous bytes back, because a manifest the editor made invalid would stop the project opening at all.

The title bar and the Outliner name the open scene (Editor::window_title, computed in the editor so a headless gate reads exactly what a person reads).

Export packs one scene: the manifest's startup scene, never the one that is open. The Export panel names it, and when the open scene is a different one it says so and names Set as startup scene as the way to change it. A shipped game cannot reach a second scene until runtime scene loading exists, so packing more would add bytes no player reaches — see Exporting a game and docs/EXPORT.md.

Not built: loading a scene at run time. ctx.session.loadScene does not exist; SessionHandle exposes restart() alone. It needs a world teardown and rebuild against a different document mid-session, with asset lifetimes across the swap, which is runtime work and not editor work. It is also the reason an export carries one scene: a game that cannot reach a second scene has no use for one in its payload.

Editing the scene

crate::document::Document is the scene as the human is editing it: the working Scene, the scene as last saved or loaded, the SHA-256 of the file's bytes as last read or written, and undo and redo as whole-scene snapshots with the selection at the time (UNDO_CAP = 200 steps). Every edit is computed first as a complete new scene that already passes the contract (argus_contract::validate, errors only; warnings pass, since the loaded scene may carry them), then shown by bringing the authoring world in line with it (World::reconcile, the hot-reload path, which touches only what differs), and only then committed. An edit the contract refuses — a zero scale, deleting the primary camera or the player spawn — changes nothing and the toolbar names the rule. Nothing here goes through the workspace service: the editor writes the scene file itself, and the revision identity edits live inside is the ready artifact's (ReadyArtifact::prepare hashes the saved file), so a save is a new revision and a new build, not a second persistence system. Change sets through the service are the next increment.

Each step records who made it (the editor's owner; for a scene an agent commits through the control endpoint, /v1/commit with its actor, or an action it asks for, /v1/do with actor beside do, that agent; argus-agent sends its own), when, a short label the toolbar's Undo and a refusal quote ("move #box"), and a title in words for the Undo History ("Move Crate"). A step site that knows better passes both (StepLabel::titled: a rename's old and new names, a prefab's name); otherwise the title is made from the label (document::title_of).

EditDoesUndo step
Transform fields (position; rotation as degrees about X, Y, Z; scale)a drag or a typed value is shown live; the fields are DragValues, so a click types and a drag scrubsone step from the first change to the field's release (begin_transform, set_transform_live, end_transform); set_transform is one step on its own
Duplicatea copy after each selected entity, id <id>-copy (then -copy-2, …), name <name> copy, same parent, tags and components; children are not copied unless selected too, when the child's copy hangs under the parent's copy; the copies are selectedone for the whole selection
Deletethe selected entities and their descendants; the selection is clearedone for the whole selection
Transform fields over several entitiesthe fields they share; a value that differs between them shows "—", and a value typed or dragged is set on every one of themone for all of them (begin_transforms, set_transforms_live, end_transform)
Reparent, rename, paste, cut, drop to floorsee Selection, Outliner and view stateone each
Undo, Redorestore the scene and the selection of the time—
World Settings rowsone field of the scene's meta; see World Settingsone step per row, the same run (begin_meta, set_meta_live, end_property); a checkbox, toggle, dropdown or reset arrow is a step each
Typed property rowsa component's behaviour fields, by kind: a number drags and types, a boolean is a checkbox, a string a text field, an array or an object the JSON text of oneone step per row, the same run as a transform field (begin_property, set_property_live, end_property); a checkbox and Add and Remove are a step each
Discardback to what was last savedone

Edits, Save, Undo and Redo are authoring actions: during Play the controls are disabled and the calls refuse ("stop Play first"). Play runs the saved scene, and Editor::play_start refuses a dirty document outright; the shell asks instead — Save and Play / Discard and Play / Cancel (play.*) — for the toolbar's Play and F5 alike, so nothing is written on the way to Play without the person choosing it. Stop returns to the authoring world, which Play never touched.

Save (Editor::save) writes the scene as pretty-printed JSON with a trailing newline through state::write_atomically (beside the file, then one rename), so a failed write leaves the last saved file intact and the error names the path; then it prepares the artifact Play will run, without reloading the authoring world when the scene is what it already shows. Before writing it reads the file (Editor::check_disk, DiskState): only a file that can be read and hashes to what the document was loaded from or last saved as is written over. A file with other bytes (Changed), no file (Missing) or a file that cannot be read (Unreadable, with the error) writes nothing and opens the prompt Overwrite / Reload / Cancel with the reason (SaveOutcome::Conflict): not being able to check the file is not evidence that it is unchanged. The file is also looked at every 2 s (DISK_CHECK_EVERY; Windows raises no file events for a WSL path) and the toolbar says "Scene changed on disk", "Scene file missing on disk" or "Scene file cannot be read", with Reload, while it does not match. Reload reads the file again, shows it and drops the edit history, which described another base. On open, an artifact restored from state.json whose scene file has changed since is replaced by a new one, with a note, so the editor always shows the file as it is.

Closing with unsaved work asks one question, every time: "Save changes to <files>?" with Yes / No / Cancel, naming every file with unsaved work — see Unsaved work: the prompt, autosave and recovery. Shell::request_close returns false and the decision arrives through Shell::take_exit. Unsaved changes live in memory and in the autosave's copy, never in the file, which keeps what was last saved.

While a prompt is up (Shell::modal_open: unsaved work, a save conflict, Play with unsaved work, the Import modal, the Export panel, the New behaviour dialog or the New scene dialog) input reaches the interface only: no shortcut (F5, Ctrl+S, Ctrl+Z, Ctrl+D, Delete, Ctrl+F2), no camera key or wheel, no picking, and nothing to the game but the releases of what it already holds; Escape answers Cancel — over a running export it asks the export to stop rather than closing the panel out from under a thread that is still writing, and while a name field has the keyboard it leaves the field, so the next Escape is the one that cancels. A frame is still drawn, which is how the Export panel's progress keeps moving while nothing but Cancel can be pressed: the rule is about where input goes, not about whether the interface is painted. A conflict raised by a prompt's Save stacks over it, and once the conflict is answered and the scene is saved the close or Play it was waiting on goes ahead (Shell::continue_after_save).

The inspector's typed property rows

The inspector first showed the transform as numeric fields and every component as read-only JSON below it. Typed rows now give the fields a person actually tunes while authoring a course — a behaviour's speed, an objective's label, whether a trigger fires once — rows of their own, so changing them is not editing JSON by hand.

The rows are read from the values that are there (crate::inspector::component_rows). A component is serialised, and each field below becomes a row whose control is the value's own kind: a number is a DragValue, a boolean a checkbox, a string a single-line text field, and an array or an object the JSON text of one. That is what lets script.props have rows at all — its keys are whatever the behaviour module reads, and nothing in the editor knows them ahead of time.

ComponentRows
scriptone per key of props, by the kind of the value there, plus the add-property row
controllerspeed, jump, crouch
goallabelKey, order
triggerevent, once
colliderisTrigger

Hit names are inspector.prop.<component>.<key>, where <component> is the component's type. An entity carrying two of one type gets <type>-2, <type>-3 … for the later ones, so every control still has a name of its own.

The inspector's own controls live behind @, so a property can be called anything without taking a button's name: Add is inspector.prop.<component>.@add, its name field …@add.name, its kind buttons …@add.kind.{number,boolean,text,list}, and the button that removes property k is inspector.prop.<component>.@remove.<k>. A property whose name starts with @ has it doubled in its row's hit name, so no property name can spell a control's, whatever it contains. Before this a property called add answered to the Add button and a property called x.remove answered to x's remove button — clicking the row pressed the button.

A field that names a file or an asset — script.module, mesh.asset — deliberately has no typed row. It is a reference, changed by the import flow, and a text field over it would be a way to break a scene by typing. Those stay in the raw view.

An edit is one undo step, through the same live run as a transform field. A row's first change opens the run and later changes in it are not steps of their own, so dragging a number is one step from the first change to the release, and typing a string is one step however many characters it takes (Editor::begin_property, set_property_live, end_property over Document::begin_live/commit_live/end_live). A checkbox, Add and Remove are each a whole edit in one click, so they open no run and commit a step of their own — were a checkbox to hold a run open, a second click would be folded into the first and one undo would take back both.

Escape abandons a live run, whether the field is being typed into or dragged, and takes it back as one step: the value is what it was before the field was touched. It keeps the selection — the row belongs to the entity that is still selected — closes the run, and stops the drag, so the pointer being still held down does not write the value straight back. The next edit is then a step of its own. Escape used to reach this only while a field had the keyboard, and a drag does not ask for the keyboard, so Escape mid-drag left the dragged value where it was, the run open, and the next edit folded into the abandoned one, where a single undo took back both.

What the scene will not hold is refused before anything changes. Document::component_edited edits the component as JSON and reads it back as a Component, so the contract's own types decide what a field takes; then the whole scene is checked as every other edit is. A goal given an order of 0.5, or a string typed where a number belongs, changes nothing and the toolbar names the rule. A JSON field commits only what parses, so half-typed text reaches nothing; while the field has the keyboard it keeps what was typed, and leaving the field shows the committed value again. It also refuses null, for the same reason the number rows refuse NaN and 1e309: the editor's own save would take it and argus_contract::to_canonical_string would then refuse the whole scene ("refusing to write null"), so one keystroke in one field would produce a document that cannot be written. Removing the property is what the × is for.

A draft never outlives the value under it. The text a field is holding is kept while it has the keyboard, so half-typed JSON is not overwritten under the typing — but if the document is replaced beneath it, by Reload, by discarding changes or by preparing an artifact, the field takes the new value, moves the caret to the end of it and closes the run. Before this a draft survived a Reload and the next keystroke wrote it back over the file's own value: the row wrote "typed!" where the file said "from-disk!".

An optional field the component does not carry shows its default with "unset" beside it and is written only once it is changed. Opening the inspector on a goal with no order does not give it one.

Adding a property (inspector.prop.script.@add.name, the kind at inspector.prop.script.@add.kind.{number,boolean,text,list}, and inspector.prop.script.@add) introduces a props key — an objective count a behaviour reads — without opening the JSON, starting at 0.0, false, "" or []. A number starts at 0.0 rather than 0 deliberately: a number row always writes an f64, so a new property that began as an integer would change type the first time it was touched. A name that is empty or already taken disables Add and says why. inspector.prop.script.@remove.<key> takes one away. props is the one open map in the contract, so this row is a script's alone.

The raw view stays. Behind the overflow button at the right of each component's category band, Raw JSON (the button is inspector.raw.<component>; pressing it shows or hides the view, collapsed by default, its text at inspector.raw.<component>.text) is the whole component as the file holds it, serialised after every edit — so it is the same value the typed rows are read from, and anything with no typed row is still readable there.

Property rows are authoring actions like every other: during Play they are disabled and the calls refuse. A property edit makes the artifact stale in the ordinary way, so the next Save prepares the build Play runs — edit a speed, save, and it is the new value the running world has.

crates/editor/tests/d4_inspector.rs drives all of it by hit name through the winit boundary, and crates/editor/tests/d4_review_probes.rs adds the adversarial half: the name collisions, Escape mid-drag, the stale draft and the null a JSON row must not write.

Selection, Outliner and view state

The usual editor conventions are the reference, with one rule over all of it: whatever a person does here an agent can do through a tool, and whatever a person did is on the agent's next scene.read or editor.state.

Selecting. The selection is a list of stable entity ids (Editor::selection). In the viewport a click replaces it with what is under the pointer (background clears it), Ctrl+click toggles that entity in or out and Shift+click adds it (Editor::select_at_with, SelectHow). Box select is The usual editor's marquee: Ctrl+Alt and drag with the left button; the rectangle is drawn over the image (viewport.box) and on release every entity the id buffer has inside it is selected, in scene order (World::pick_rect beside World::pick, one read of the id buffer), replacing the selection, or adding to it with Shift held as well. Select all (Editor::select_all) takes every entity that is drawn; Escape clears. The overlay under the scene name says #first +N (M selected).

The Details panel over several entities shows how many, Duplicate and Delete over all of them, and the transform fields they share; a field whose value differs between them reads "—", and an edit to it sets that one value on each, so dragging Y moves them all to the same height and leaves X and Z alone. The whole drag or typed value is one undo step (Document::begin_live / commit_live over a scene computed by Document::transformed_many; a run's step grows to cover every entity it touches).

Their shared components (crate::multi_details). Below the transform, every component all the selected entities carry, matched by type and by position among that type's (script, script-2, as the single entity's hit names are), is drawn with the typed rows they all have, under the same names (inspector.prop.<component>.<key>). A value that differs reads "—" (a checkbox shows its third state; a click sets every one of them on, as the usual editor's does), and an edit sets that one value on every selected entity as one undo step (Editor::set_properties_live, one scene for the whole run; label set <field> on N entities). On a prefab instance, or a part inside one, each lands as an override on its own instance, exactly as a single entity's edit does, and the prefab file is never touched. Components only some of them carry are named on one line, "(not on all selected)" (inspector.not-shared). A lock stops moving and picking, so it leaves these rows alone. Bounded: the shared rows are worked out once per selection and document change (multi_details::Shared), not once a frame. Measured on the llvmpipe gate machine (tests/d18_scale.rs, two_hundred_selected_entities_keep_the_frame_bounded): an idle frame with 200 of 220 scripted entities selected costs about the same as with one selected, and an edit of all 200 plus the frame that shows it stays well under the test's bound; the report of each run prints the numbers.

The Details search box (details.search, at the top of Details, as in the usual editor). It filters the rows by name across the transform (Position, Rotation, Scale; "transform" keeps all three), every component (a component whose type matches keeps all its rows) and the prefab bar (it answers to "prefab", Create Prefab…, Apply, Revert, overrides and its source path). While it holds text only rows are drawn: Add component, the asset and scan tools, the raw JSON and the failures come back when it is cleared. The × (details.search.clear) or Escape in the field clears it, and it is kept when the selection changes, as the usual editor's is. It works the same over a multi-selection.

The Outliner (crate::outliner, split out of ui.rs) selects as the viewport does, and Shift+click selects the range from the last row clicked (Ctrl+Shift adds the range). Drag a row onto another and it hangs under it, keeping where it is in the world (Editor::reparent, document::reparent_scene: the entity's world matrix, composed up its parents as the runtime does, times the new parent's inverse); dragging a selected row moves the whole selection, and only its roots move. Drag onto the space under the rows (outliner.empty) and it becomes a root. A drop onto the entity itself or anything under it is refused with the contract's parent-cycle and the reason is in the status line. Rename is a double-click on a row, or F2 through the keymap (Shell::begin_rename): the name becomes a field (outliner.rename); Enter or clicking away renames (Editor::rename, one step, the id stays), Escape keeps the old name. Each row has an eye and a lock at its right edge (entity.<id>.eye, entity.<id>.lock).

Folders are the usual editor's Outliner folders, and like the usual editor's they are kept in the level: the scene's meta.outliner.folders, a map of root entity id to a folder path ("Monsters/Grunts"; docs/SPEC.md, "Outliner folders"), so an agent reads and writes the same map and a diff shows it. They are organisation only and move nothing in the world. The verbs are crate::folders, each one undo step through Editor::commit_scene:

DoHowHit names
New Folderthe folder button beside the search: a top-level folder holding the selection (or an empty one, with nothing selected), its name open for typing. Right-click the empty space under the rows ▸ New Folder makes an empty top-level one. Right-click a folder ▸ New Subfolder nests an empty oneoutliner.new-folder, menu.outliner.new-folder, menu.folder.new
Renamedouble-click the folder, or right-click ▸ Rename; Enter or clicking away renames every folder under it too, Escape keeps the name; renaming onto a folder that exists merges themoutliner.folder-rename, menu.folder.rename
File entitiesdrag rows (or the selection) onto a folder. A child is unparented first, staying where it is in the world, as the usual editor detaches an attached actor dragged into a folder; one stepfolder.<path>
Take them outdrag onto the space under the rows: out of every folder and parentoutliner.empty
Move to Folder ▸as the usual editor's: right-click an entity ▸ Move to Folder ▸ a folder of the scene, New Folder… (a top-level folder holding them, its name open) or (root) (out of every folder). It moves the selection when the row is in it, as a drag does; the prefab items stay in the same menu, above it. Not on a part of a prefab instance, which follows its instance. One step eachmenu.entity.move-to, menu.entity.move-to.<folder.<path>|new|root>
Nest a folderdrag a folder onto another, or onto the space under the rows for the top level
Open and closethe caret; kept per user and per scene in layout.json (outlinerCollapsed)folder.<path>.caret
Select contentsa click selects every root filed in it and in the folders inside it (Ctrl+click adds them); right-click ▸ Select Contents too. The usual editor does this on a double-click; here a double-click renames, as it does on an entity rowmenu.folder.select
Hide and lockthe folder row's eye and lock act on everything inside it, children includedfolder.<path>.eye, .lock
Delete Folderright-click ▸ Delete Folder: what is in it (entities and folders) moves up to its parent, or the top level; no entity is deletedmenu.folder.delete
Organizethe header menu (…, at the right of the search row) ▸ Organize, or right-click the space under the rows ▸ Organize: every root entity with no folder is filed by prefab (Prefabs/<prefab>), then by kind (Cameras, Lights, Gameplay, Environment, Meshes, Other; docs/SPEC.md, "Outliner folders", Organize). A folder already given is kept, a child is never filed, nothing moves or is reparented; one undo step ("organize outliner"), and the status line reads "Filed N entities into M folders" (or "Nothing to organize" with no step). crate::organize over argus_contract::organize; an agent's is scene.patch organizeoutliner.menu, outliner.organize, menu.outliner.organize

Only roots are filed: a child follows its parent, and a part of a prefab instance follows its instance (filing one is refused with the reason). Empty folders are kept, as the usual editor keeps them: meta.outliner.empty lists each by its deepest path. A New Folder with nothing selected is one (drawn with "(empty)"), and a folder whose last entity is deleted, reparented or dragged out stays, empty. A folder leaves the list when something is filed in it or under it (argus_contract::outliner::tidy_empty, run by every folder verb and by sync). Folder rows come first under each parent and sort by name whatever the Sort (reversed for Name Z-A), as the usual editor's do; a search lists matching folders, then matching entities flat with the folder each is in after its id (an entity matches by its folder's name too). Folders are refused while a prefab is open in Edit Prefab, whose file has none.

Every tool keeps the map in step in the same change (Talos's rule), so the contract's warning outliner-folder-unknown-entity (rule 75) fires only for a hand-edited file: Document::commit and commit_live run argus_contract::outliner::sync, which drops an entry whose entity the edit deleted or gave a parent (delete, cut, reparent, a prefab Create taking a child into the prefab, an agent's POST /v1/commit), and the world is shown the same synced scene (Editor::show). An entry the edit itself wrote is left as written. Duplicate files the copy beside its original, as the usual editor does (outliner::carry); copy puts each root's folder on the clipboard ("folders") and paste files a pasted root that stays a root in it; Create Prefab… keeps the entity's id for the instance, so the instance keeps the folder; placing and importing add ids and re-id nothing. The agent's tools do the same on disk and through the service.

Sorting the Outliner. The Sort menu at the right of the search row (outliner.sort; its rows outliner.sort.scene, .name, .name-desc, .type) orders the rows the way the usual editor's Outliner sorts by its Item Label and Type column headers: Scene order (the scene file's, the default), Name (A-Z), Name (Z-A), or Type. Names compare ignoring case, with runs of digits as numbers ("Crate 2" before "Crate 10"), and the id breaks a tie. Sorting orders the rows under each parent and keeps the tree; a search, which flattens it, lists its matches in the same order, and Shift+click ranges follow the order drawn. Type is a fixed order of kinds, decided from the entity's own components by the first that fits (outliner::TypeBucket), then by name:

OrderKindAn entity with
1prefaba prefab (an instance)
2cameracamera
3lightlight
4meshanything drawn: mesh, sprite, splat or particles
5collidercollider or rigidbody and nothing drawn
6triggertrigger, goal, or a collider with isTrigger
7spawnspawn
8scriptscript and none of the above
9otherany other components (audio, a label, navigation, …)
10emptyno components: a folder or a pivot

The kinds are listed in that order, but decided spawn first, then trigger, then drawn, so a visible trigger volume is a trigger and a spawn point with a marker mesh is a spawn. The button shows the choice (Sort, A-Z, Z-A, Type). The choice is the person's, kept in layout.json (below).

Hide, isolate and lock are view state, not scene state (the usual editor's hidden-in-editor): they never reach the scene file, a build or Play, and they are kept per scene in state.json (views, below).

DoesKey (keymap)
Hidenot drawn in the authoring view, nor anything under it, so it cannot be picked or box-selected; its Outliner row is dimmed; hiding the selection deselects itH (hide_selected), the eye
Show alleverything drawn again, and isolation endsCtrl+H (unhide_all)
Isolateonly the selection and what is under it is drawn; again, everything isShift+H (toggle_isolate)
Lockstill drawn, but a click or a box does not pick it and a person's transform edit is refused ("#id is locked"); drop to floor leaves itthe lock (toggle_lock over the selection)

A hidden entity is left out of the authoring world's instance list (World::set_editor_hidden, entity indices the editor recomputes when the set changes), so it is in neither the image nor the id buffer. Only meshes are hidden this way; a light or a sprite still shows. An agent's edit goes through a lock and its answer says "locked": true with lockedIds: the lock stops a person's pointer, not the agent (POST /v1/commit compares the edit with the locked ids).

Copy, cut and paste (crate::clipboard). Copy puts the selection and everything under it on the system clipboard as scene JSON — {"argusEntities": [...], "assets": {...}, "parents": {...}}, the format the agent guide describes, with "folders" when a copied root is filed — so a person can paste it into a chat with an agent, and paste back what an agent answers. The text goes out as egui's copy_text on the next frame (UiState::copy_out), which is how the window's clipboard hears of it. Paste is the window's own Ctrl+V: the App reads the clipboard for that key and hands egui a paste event, and the editor pastes it when no text field has the keyboard and the text is copied entities (a word on the clipboard is left alone). Every entity gets a fresh id by Duplicate's rule, each root is named <name> copy and goes back where it was copied from (under its old parent if that is in the scene, at the same place in the world), and the pasted roots are selected; one undo step. Cut is copy then delete, one step.

Paste under the pointer (the usual editor's Paste Here). With the pointer over the viewport, Ctrl+V lands the paste on the surface under it, found as a model dropped from the Content browser is (content_browser::drop_point: the mesh the id buffer shows there, else the ground plane y = 0): the pasted roots move together so the bottom of their bounds rests on that point, centred over it (Editor::paste_at). With the pointer off the viewport, or with Ctrl+Shift+V (edit.paste-in-place), the paste goes where it was copied from, as before. Either way it is one undo step, the folder a root was copied from still files it, and one key is one paste: the window still pastes only through its paste event, and the key only says which kind (Shell::paste_event).

Drop to floor (End, Editor::drop_to_floor) moves each selected root straight down until the bottom of its bounds (its mesh and its descendants' meshes, or its origin when it draws nothing) rests on the first thing below the bounds' centre: a collider (the authoring world's physics, a ray that passes through the entity itself) or another entity's mesh (World::surface_hit), whichever is nearer. One undo step; something already resting moves nowhere and makes no step. Agents have it as entity.align with ground: true.

For the keymap. The entry points the keymap binds are on Shell: select_all, clear_selection, copy_selection, cut_selection, paste_text, hide_selected, unhide_all, toggle_isolate, toggle_lock, drop_to_floor, begin_rename and rename.

Transform gizmos

The usual editor conventions are the reference. W, E and R (or the toolbar) put a gizmo on the selection; Q takes it away.

Handles. Move has three axis arrows, three plane squares (drag in that plane) and a centre ring that moves in the plane facing the camera. Rotate has three rings and a screen ring around them that turns about the view direction; a ring's far half is drawn faint and loses to a near one where they cross. Scale has an axis line with a box at each end and a centre box for uniform scale (up and to the right grows). X is red, Y green, Z blue, and the handle under the pointer, or being dragged, is yellow. The gizmo keeps one size on screen whatever the distance, is drawn over the scene, and is painted with egui over the game image, so the renderer knows nothing about it. A square seen exactly edge-on is a line with nothing to grab, as in the usual editor.

Picking. A handle is found on the CPU: the pointer against the handles' shapes projected through the authoring camera, within 7 points of a line or ring. The GPU id buffer stays for entities. The centre handle wins, then the nearest axis, then a square. A left press on a handle starts a drag and is not a pick; Ctrl+Alt is always box select, even over the gizmo.

Space and pivot. Move and Rotate use the world's axes or the pivot entity's own (Ctrl+\`, X in the alternate preset, or the toolbar's World / Local); Scale always uses the entity's own axes. The pivot entity is the one selected last (as in the usual editor). The gizmo sits at its origin, or with Centre (the toolbar, Z in the alternate preset) at the centre of the selection's world bounds (meshes, or the origin of an entity with none).

Snapping. Three toggles and values, in the toolbar at the top right of the viewport (the usual editor's layout: a toggle, then a menu of values showing the current one). Defaults, as the usual editor's: grid on at 0.1 m, angle on at 10°, scale on at 0.25.

  • Grid: in world space the pivot lands on the world grid along the axes being dragged; in local space the move is a whole number of grid steps. [ and ] step through 0.01, 0.05, 0.1, 0.5, 1, 5, 10, 50 and 100 m. Ctrl held while dragging inverts the grid toggle, checked on every pointer move and when Ctrl goes down or up. Ctrl on a handle is a drag, not Ctrl+click's toggle select.
  • Angle: the turn is a whole number of steps.
  • Scale: the factor changes in whole steps of the value (× 1.25, × 1.5, …). A drag never scales below × 0.01, so it never flips or flattens an entity.

Surface snapping (see crate::placement). The usual editor's Surface Snapping: the toolbar's Surface toggle (gizmo.snap.surface, off by default as in the usual editor; Ctrl+Shift held during the drag in the alternate preset). With it on, a drag on the move handle's centre or a plane square puts the selection on the surface under the pointer: the nearest mesh (the authoring world's own geometry) or collider (a floor with no mesh) along the pointer's ray, passing through the dragged entities and everything under them. The selection moves as one, anchored at the gizmo's pivot, and is lifted along the surface's normal until the lowest corner of its meshes' boxes rests on the surface rather than in it (a unit crate dragged over a floor at y = 1 lands at y = 1.5). Align beside it (gizmo.snap.surface.align, the usual editor's Rotate to Surface Normal) also turns the selection about the pivot so the pivot entity's up axis is the surface's normal. The grid does not apply to a surface landing. Where the ray meets nothing the drag moves as it would without snapping. The axis arrows are unchanged. Still one undo step, and Esc still puts everything back.

Vertex snapping. The usual editor's V key: hold V (snap.vertex) while dragging a Move handle and the pivot goes to the vertex nearest the pointer on screen, within 24 points, of any mesh but the dragged ones. The handle still decides the direction: the centre takes the vertex's position, a plane square its position in that plane, an arrow only its position along the axis. With no vertex that near, the drag moves as without V (or lands on a surface with surface snapping on). V can go down or up mid-drag; the entities follow at once.

What the queries cost. The first snap of a drag indexes the rest of the scene's meshes once (placement::Targets: each mesh's world box, from its own box through its matrix, sharing the authoring world's geometry rather than copying it); the scene does not move during a drag, so the index lasts until the release. A surface query tests every box and runs the triangle test only in the boxes the ray enters, nearest first. A vertex query projects every box and reads the vertices only of boxes within the snap radius of the pointer, nearest first, and never more than 200,000 a query. Measured on the llvmpipe gate machine over 3,000 boxes (d18_place::the_vertex_query_is_bounded_on_a_three_thousand_entity_scene, which prints them): the index takes under a millisecond, a vertex query reads 7 boxes (168 vertices) in about 0.3 ms, and a surface query ray-tests one box in about 0.05 ms.

Several entities. One gizmo, at the pivot, moves every selected root by the same change: a child whose parent is also selected moves with the parent and is not moved again. Rotate turns positions about the pivot as well as orientations; Scale spreads positions from the pivot along the gizmo's axes and multiplies each entity's own local scale. Children's local transforms are worked out through their parents, so an entity under a scaled or turned parent goes where the pointer says.

Undo, cancel, locks. A drag is one undo step for everything it moved (Editor::begin_transforms, set_transforms_live, end_transform), labelled like "move 3 entities". Esc or the right button during a drag takes the whole drag back, and the release that follows does nothing; losing window focus or a dialog opening keeps what was done. A locked entity refuses the drag: the press on the handle is not a pick either, and the status bar says why. Alt + drag duplicates the selection and drags the copies, in one undo step with the move (Editor::begin_duplicate_transforms); Esc takes back the copies too, and the selection is the originals again.

Prefab instances. A drag on an instance moves its own transform. A drag on a part inside one (selected in the Outliner) writes a transform override on the instance instead, within the drag's one undo step; see Prefabs.

For agents. The gizmo is a human input device; what an agent needs is the state behind it. /v1/state (and editor.state) has view.gizmo with the fields of state.json's gizmo, and view.tool. /v1/do {"do":"set-view","gizmo":{…}} (and editor.set {gizmo}) changes any of them; an unknown field or a value outside the limits refuses the whole call. The keys' actions run through {"do":"action","id":"tool.space"} and the rest. The handles publish hit names (gizmo.move.x, gizmo.move.xy, gizmo.move.screen, gizmo.rotate.y, gizmo.rotate.screen, gizmo.scale.z, gizmo.scale.uniform) and the toolbar its controls (gizmo.tool.move, gizmo.space, gizmo.pivot, gizmo.snap.grid, gizmo.snap.grid.value, gizmo.snap.grid.0.5, …, and gizmo.snap.surface, gizmo.snap.surface.align), so a script can press them. view.gizmo has surfaceSnap and surfaceAlign, set the same way.

The compact toolbar. The full row is about 760 points wide, more than the viewport at a UI scale of 125 % or more in a 1280-wide window, where Select, Move, Rotate and Scale used to run off the image. Wherever the full row does not fit the image it is drawn compact, as the usual editor's is: the tools, World/Local and the pivot are painted icons (a pointer, four arrows, a turning arc, two squares; a globe or a cube; cross hairs or a bounds box) with their names and keys in the tooltips; the three snaps and surface snapping move into one Snap ▾ menu (gizmo.snap, highlighted while any snap is on), a row per snap with its toggle, its value and every step; the camera speed stays. Every hit name is the same in both forms, the snaps' once the Snap menu is open, and the menu stays open for several picks. The full row's width is remembered from the last frame it was drawn, so the choice does not flicker, and the full row comes back once the viewport is wide enough. d20_browse.rs checks every control whole and apart at 1280×720 and 1600×900, 100 to 200 %.

Play controls

Pause, step, time scale, Eject and Simulate are one set of controls over one mechanism: the Play world's clock (TimeControl) and the view it is drawn through. Nothing here is a second clock.

ControlWhat it doesWhere
Pause / Resumeholds the world: sim.now, physics, behaviours, animation, timers and ctx.time stand still; rendering, the UI and the free camera carry ontoolbar toolbar.pause, F10 or Pause, play.pause
Stepruns exactly one fixed tick (1/60 s) and leaves the world paused; repeats while the key is heldtoolbar.step, F11, play.step
Time scale0.1x, 0.25x, 0.5x, 1x, 2x, 4x (the API takes 0.1 to 10): how many fixed ticks run per wall second. The tick is never stretched, so the same number of ticks gives the same result at any scale the viewport's top-right strip toolbar.timescale, Ctrl+[ / Ctrl+], POST /time {scale}
Ejectdetaches the view into a free-fly camera that starts at the game camera's pose; the game keeps running (paused or not, at its scale). Right button flies, Alt+left orbits, middle pans, the wheel sets the speed; a left click selects the live entity under it; F8 again (or the button) returns to the game camera and gives the game its keys backtoolbar.eject, F8, play.eject
SimulatePlay with the view ejected from the first frame and no input reaching the gamethe Play menu's caret toolbar.play-menu.simulate, Alt+S, play.simulate

What Simulate maps to. Talos has no separate player spawn to skip: the player is a scene entity (spawn for player, else the first character controller) that the device layer drives. So "not possessed" means exactly that: physics, behaviours, AI and timers run, the player entity stays where the scene put it, and nothing the person presses reaches the game (Shell::game_has_input is false and game_key_dropped says so to a caller). F8 in a Simulate shows the game's camera; keys still do not reach the game.

Play-time edits. While ejected, the Outliner and a click on the viewport select live entities (Editor::select_live_at), and the Inspector shows them as they run now (Editor::live_transform). A root entity's Position can be edited (Editor::set_live_position, the world's teleport_entity): it moves the running entity only, the scene document is untouched and never dirty, and the Inspector says "Play only: this position reverts when Play stops". Stop drops the world and puts the selection back as it was before Play. Everything else stays read-only during Play.

The status. The viewport's title plate reads Play, then whatever is not the ordinary run: Play · Paused · 0.25x · Ejected, or Simulate · … (ui::play_status); GET /v1/state carries the same as play.status, with play.paused, play.timeScale, play.ejected and play.simulating.

Agents. No new default agent tool. The control endpoint takes {"do":"time-scale","scale":0.25}, {"do":"eject","on":true} (omit on to flip), {"do":"simulate"} and {"do":"live-position","id":"drifter","position":[x,y,z]}, as well as {"do":"action","id":"play.eject"} for every keymap action above; {"do":"pause"} and {"do":"step","ticks":n} are as before.

Cost. Nothing runs when unused: the ejected camera is an Option in the Play session, read once per Play frame, and the authoring path is untouched. Editor start to playable is unchanged.

Viewport modes

The usual editor conventions are the reference. Three menus float over the top left of the viewport, as the usual editor's viewport toolbar does, and the camera speed sits at the end of the top-right toolbar, where the usual editor reads it out:

Menu (hit name)ChoicesKeys
Projection (viewport.projection, .perspective / .top / .front / .side)Perspective; Top, Front and Side (left) orthographicAlt+G, Alt+J, Alt+H, Alt+K
View mode (viewport.mode, .lit / .unlit / .wireframe)Lit, Unlit, WireframeAlt+4, Alt+3, Alt+2
Show (viewport.show, .grid / .collision / .navmesh / .triggers / .bounds / .selection / .game)the grid, colliders, the navmesh, trigger volumes, bounds, the selection outline; Game viewG for game view
Speed (viewport.speed, .1 to .8, .scalar.<x>, .scalar.value), in the top-right toolbar after the snapsthe camera speed step and scalarright mouse + wheel

The buttons are meant to be seen. People did not find the projection or the view mode: the menus were plain words over the scene. They are now framed buttons, as the usual editor's viewport toolbar has them: viewmodes::MENU_BUTTON_H (22 points) tall, a light outline over a dark fill, a painted icon on the left (a camera for the projection; a lit sphere, a flat disc or a wire triangle for Lit, Unlit and Wireframe) and the caret on the right. Show is its eye alone, its tooltip naming it, so the row fits beside the gizmo toolbar in a 1280-wide window. Where the two would still meet (a narrow viewport, a UI scale of 125 % or more at 1280) the row drops below the toolbar rather than under it (viewmodes::menus_at), and the scene title follows it down. Their tooltips name the keys, read from the person's keymap (viewmodes::projection_tooltip, mode_tooltip): "The camera's projection: Perspective (Alt+G), Top (Alt+J), Front (Alt+H), Side (Alt+K)" and "View mode: Lit (Alt+4), Unlit (Alt+3), Wireframe (Alt+2)". d20_browse.rs checks at 1280 wide and 100 to 200 % that each is whole: inside the image, in a row, clear of every gizmo-toolbar control and the speed readout.

The first-run hint. Until the person has flown once (right mouse held and moved over the viewport) and opened the projection or view-mode menu once, a one-line hint sits at the foot of the viewport: "Hold right mouse + WASD to fly · Perspective and Lit, top left, change the view" (viewport.hint, ui::VIEWPORT_HINT), as the usual editor's first-run tips do. Its × (viewport.hint.close) puts it away for good; Help ▸ Viewport controls (menu.help.viewport) brings it back until both are done again. Per user, in preferences.json as "hints": {"viewportFlown", "viewMenuOpened"} (preferences::Hints), which editor.state's view.prefs.hints reports and editor.set {prefs: {hints: …}} sets. Not in game view, Play, the UI preview or over a settings page; a foreground area wrapped to the viewport's width, so a click on it never picks and a narrow viewport keeps it whole. A Shell with no preferences store (a test, a gate) shows it, since that is a first run.

Camera speed. The usual editor's Camera Speed and Camera Speed Scalar (crate::camera). The step, 1 to 8, is one of 0.1, 0.3, 1, 4, 10, 30, 80 and 200 m/s, about three times apart as the usual editor's eight are, so one wheel notch while flying is a clear change and the whole range runs from nudging along a tabletop to crossing a large map. Step 4, the default, is 4 m/s, as it was, so the default fly feels the same. Steps other than 4 changed meaning (they were 0.5, 1, 2, and 8, 16, 32, 64 m/s): a state file written before keeps its step number, which now flies slower or faster. The scalar multiplies the step: the Speed menu offers ×0.1, ×0.25, ×0.5, ×1, ×2, ×4 and ×8 and a field that takes any value from 0.01 to 100 (viewport.speed.scalar.value), so the camera flies from 1 mm/s to 20 km/s. The readout says what the camera flies at: Speed 4 · 4 m/s, or Speed 4 ×0.5 · 2 m/s with a scalar; Shift still flies three times faster. Right mouse + wheel steps through the eight steps and the status line says the new speed. Both are the camera's, so both are kept in state.json (camera.speed, camera.speedScalar, which reads as 1 from an older file).

Projection. Top looks down -Y with +X right and -Z up the screen; Front looks down -Z (the default camera's way) with +X right; Side is the usual editor's Left view, from -X, with +Z right. An ortho view keeps its own zoom (orthoHeight, half the height it shows, in metres), starts at what perspective showed at the target, and clips nothing in front of or behind the eye. It shares the target with perspective, so F, a pan in Top and a switch back all agree on what is looked at; perspective comes back at the angle it had. In an ortho view the right or middle button drags the plane exactly with the pointer (no fly, no orbit), the wheel zooms, F frames the selection by the zoom, and the grid lies on the view's plane, faint and over everything. The gizmo works the same in every view: its hit test and drag rays are orthographic there, and a handle behind the eye's plane is still in front of its ray.

View mode. Unlit draws every surface as its base colour and texture, with no light and no tonemap. Wireframe draws each mesh's edges over a flat dark background. Both are a view-level flag in the renderer (frame.style.w), not materials, and Lit is untouched: no pass, no pipeline, no allocation, one uniform compare in the surface and sky shaders. The surfaces still write depth and the id buffer in wireframe, so a click picks what is drawn. The edges go through the overlay's line list (not POLYGON_MODE_LINE, which not every backend has): each mesh's unique edges are made once, the first time it is drawn in wireframe, and a frame transforms them by each instance's matrix only when the instances moved (argus_render::debug_view::WireCache); more than 2 million line vertices are cut short rather than stall. Sprites and billboards: unlit draws the picture as it is (cut-out and see-through as in Lit); wireframe draws each billboard's quad as its outline (four sides and the diagonal) facing the camera, and a see-through one is left out of the flat surfaces. Particles draw as they do in Lit. Skinned meshes are drawn in their bind pose. HD: the HD renderer does not draw Unlit or Wireframe yet: the menu row says "Unlit/Wireframe not supported yet in HD", the two choices are greyed out, and the keys, set-view and editor.capture refuse them with that reason; the grid, overlays and outline still draw. The hooks are all in the standard renderer's opaque.wgsl and sky.wgsl (each between // debug_view { and // debug_view }), none in HD's files, and crates/render/tests/view_modes.rs holds Lit byte for byte to the renderer with them cut out. Play always draws Lit, with none of this: the renderer is handed back plain before Play takes it.

Show. The overlays are Play's debug overlays in authoring, built by argus_capture::overlay exactly as a POST /capture with overlays draws them: colliders green, the navmesh cyan (turning it on asks the authoring world for its existing navmesh build, when the scene declares navigation; crates/nav is unchanged, and the menu says when there is nothing to build), triggers amber, bounds white. Hidden entities' overlays are hidden with them. The grid is on the ground (y = 0) in perspective, depth-tested, its finest lines the gizmo's grid snap, times ten until about sixty lines fit either way, every tenth line brighter and the X and Z axes red and blue. The selection outline draws the selection's (and its children's) mesh edges in the usual editor's orange over the frame. The grid and the outline are on by default, as in the usual editor. Every line is cut to the view before it is handed to the renderer (argus_capture::overlay::clip_lines, which POST /capture overlays and Play's F1 overlays use too: a line crossing behind the eye is drawn as a smear, or not at all, by llvmpipe). The lists are rebuilt only when the camera, the scene, the selection or a flag changes: a still frame builds and uploads nothing.

Game view (G, or Show ▸ Game view) hides everything the editor draws over the game — grid, overlays, the selection outline, the gizmo, the viewport's scene and selection labels and agents' name tags — and keeps the selection.

Kept and shared. The view mode, the Show flags and game view are state.json's viewport; the projection, ortho zoom, speed step and scalar are the camera's. /v1/state (and editor.state) has view.viewport: {projection, orthoHeight, mode, show: {grid, collision, navmesh, triggers, bounds, selection}, gameView, cameraSpeed, cameraSpeedScalar, flySpeed} (flySpeed is what the two make, in m/s, read only). /v1/do {"do":"set-view","viewport":{…}} (and editor.set {viewport}) changes any of them; show is an object of flags (a patch) or a list of the ones on; an unknown field or a bad value refuses the whole call. POST /v1/capture with a JSON body {view, mode, overlays, camera} (and editor.capture with the same arguments) draws one frame of the authoring view that way — view persp|top|front|side, mode lit|unlit|wireframe, overlays the Show names that are on (game view off), camera any of view.camera's fields or eye and target — reads it back, and puts the person's view back before it answers: nothing they see or have set changes, and nothing is saved. A bad argument is 422; during Play these arguments are refused (the capture is of the authoring view).

Camera bookmarks (see crate::bookmarks). The usual editor's: Ctrl+1 … 9 keeps the view in that slot and 1 … 9 goes back to it (camera.bookmark-save-N, camera.bookmark-N). A bookmark is the whole view — target, yaw, pitch, distance, field of view, projection and ortho zoom — and going back keeps the camera speed, which is the person's rather than the view's. An empty slot changes nothing and the status line says so. The Perspective menu lists them under Bookmarks (each saved slot, its projection and key; viewport.bookmark.N) with a row of Set buttons (viewport.bookmark.set.N), which run the same actions as the keys. Kept per project in state.json (bookmarks, by slot; a file from before has none). Agents read them in view.viewport.bookmarks ({"3": {…view.camera's fields}}) and set them with {"do":"set-view","viewport":{"bookmarks":{"3": {…} | "current" | null}, "bookmark": 3}} (editor.set {viewport}): bookmarks sets a slot to a view (view.camera's fields, or eye and target), to the view now, or clears it; bookmark goes to a slot, and the rest of the patch applies to that view. An empty slot refuses the whole call.

Caches. The gizmo's Centre pivot walks every selected mesh for its bounds; it is worked out once per selection, scene edit and authoring world (Editor::selection_bounds_center), not per frame.

2D projects

A project whose manifest has kind: "2d" opens in the 2D view (crates/editor/src/two_d.rs); a 3D project is unchanged by all of this.

  • The view is Front ortho (looking down -Z, +X right, +Y up, the plane flat sprites, tilemaps and planar physics use), opened on the scene's primary camera's X and Y at the half-height the game shows (orthoSize, else from pixelsPerUnit, else 5 m). The wheel zooms about the pointer (the point under it stays put), middle and right drags pan, and nothing orbits or flies. F fits the selection's XY bounds. The grid is the XY plane's, at Z = 0. The projection button reads 2D; its menu's 2D view row (viewport.2d) switches to a perspective 3D view of the project for debugging and back. That is this session's view state only: never written to the project or state.json. The other projections are greyed while the 2D view is on.
  • The gizmo keeps the X and Y arrows and the XY square (Move), the Z ring (Rotate) and the X and Y boxes (Scale); its axes turn only about Z, so no drag changes Z. The Details transform stays full 3D: it is how a stray Z is fixed.
  • Snapping: the grid snap as in 3D, and the toolbar's Pixel (gizmo.snap.pixel), which snaps a move to 1 / P metres instead, P being the primary camera's pixelsPerUnit when it is orthographic with one, else 100. Ctrl inverts it as it inverts the grid.
  • Details has typed rows for the 2D fields (crates/editor/src/inspector_2d.rs): a camera's projection, and while orthographic its orthoSize and pixelsPerUnit (unset is "auto"; the × takes one away) and pixelSnap, with fov greyed; a sprite's billboard, anchor, sortLayer and order (flat only), flips, pixelsPerMetre, and the grid and cell of a view cut from a sheet's grid; a rigidbody's planar and six axis locks (planar's own three shown ticked and fixed; both greyed on a body that is not dynamic); a tilemap's sheet, cell size, alpha cutoff, filter and each layer's sort layer, order, collision and visibility (its size shown; its rows are painted in the viewport with the palette under the rows).
  • New scene offers the kind's one-file templates (Template::scene_templates): 2D empty in a 2D project. The control surface's new-scene takes starter and empty as the kind's own, and 2d-empty in a 2D project.

Agents read it all in editor.state: view.viewport.twoD (whether the 2D view is on; null in a 3D project), view.viewport.pixelStep (1 / P) and view.gizmo.pixelSnap, and set it with editor.set: {"viewport": {"twoD": false}} and {"gizmo": {"pixelSnap": true}}. A projection other than front asked for with the 2D view on refuses the whole call.

Presentation view

crate::presentation, for watching a game grow while agents build it, and for recording that. View ▸ Presentation view (menu.view.presentation) or F11 (view.presentation) turns it on and the same again turns it off. It is off unless asked: nothing starts, reads or computes until then.

While it is on the menu bar, toolbar, status bar, Details and the drawer are not drawn, so the viewport fills the window (prompts and windows still draw, because a save conflict must stay answerable). Editor-only drawing is off, as in game view. The camera orbits the scene's bounds (one turn in 90 s), framed again once a second so a growing scene stays in view; or it sits on the transform of a chosen camera entity. The overlay (presentation.overlay) shows an elapsed clock and the newest eight lines of an activity feed. Turning it off puts back the camera and game-view setting the person had; starting Play ends it.

Live. An agent's scene edit made through the workspace protocol reaches any open editor at once (Live updates on, the default: the change is folded into the document and the authoring world through the hot-reload path, never into undo). A scene file written straight to disk is only noticed by an editor, which waits for Reload; the Presentation view reloads it itself once a second, when the person left no unsaved edits, because nobody edits in this view.

Activity feed. A JSONL file, by default .argus/activity.jsonl in the project, in the format tools/timelapse documents: {"t": "2026-09-30T14:05:12Z", "agent": "builder-2", "action": "adds the ground"}, where t is an ISO-8601 string with a zone or epoch milliseconds (time is accepted for t; a number under 1e11 is read as seconds) and agent is optional. A line that does not parse is skipped. The file is checked twice a second by length and modification time and read again only when either changed; the overlay shows each line's time as an offset from the file's first line.

Unsaved work: the prompt, autosave and recovery

Nothing a person has done is lost without them being asked, and nothing is lost to a crash that an autosave could have kept.

Unsaved work is any of: the open scene's document; a prefab in Edit Prefab (and the scene put aside under it); Project Settings typed and not applied (the page keeps them when it is closed, so they count, as argus.project.json). The editor has no text panels: behaviours and screens are edited outside it, and saved by whatever edits them. Shell::unsaved lists the files, in the order Yes saves them; editor.state's dirty is the same list.

Every way out asks the same question: "Save changes to <files>?" with Yes / No / Cancel, one file named in the question, several listed under it (prompt.file.<path>). The ways out are the window's close button (Exit::Quit), Close project, File ▸ Open project… and New project… (each Exit::ToLauncher), the renderer restart a render.profile change asks for (Exit::Reopen), and opening or making another scene (the prompt names only the scene: Open scene is refused in Edit Prefab, and settings are not left behind by it). The launcher is shown only once a project has closed, so quitting it or opening a project from it has nothing unsaved to ask about. Nothing is asked when nothing is unsaved.

  • Yes saves each file in order — the prefab, then Back, then the scene, then Project Settings' Apply — and then goes. It stops at the first that does not save, with the prompt still up: a file changed, missing or unreadable on disk raises Overwrite / Reload / Cancel over it, and once that is answered the rest are saved and the close goes ahead (Shell::continue_after_save); an error (a failed write, a refused Apply: a field conflict, a held lease, a read-only manifest) is shown in the prompt in red (prompt.error).
  • No drops every listed change — the prefab's, the scene's, the typed settings — and goes. The files keep what was last saved.
  • Cancel stays.

The names are prompt.yes, prompt.no and prompt.cancel; Yes and No are also drawn under the the original prompt's prompt.save and prompt.discard, so an older gate presses the same answer. Enter is Yes and Escape is Cancel, while the prompt is the one on top (a conflict or a dialog over it takes the key). An agent reads editor.state's prompt ({kind, exit, scene, files, error}) and answers with /v1/do {"do":"answer","which":"prompt-yes"|"prompt-no"|"prompt-cancel"} or editor.click.

Autosave (crate::autosave). Every N seconds of unsaved edits (default 60, as the usual editor's Editor Preferences ▸ Loading & Saving has it), each dirty document is copied to <project>/.argus/autosave/<its relative path> — never over the file — with a record beside it, <path>.autosave.json: {schema, path, kind: scene|prefab, baseSha256, copySha256, savedAtMs}, the base being the hash of the file's bytes the document was loaded from or last saved as. A copy is not a Save: no revision, no build, no journal entry (the service ignores .argus/), and the document stays unsaved. The interval starts at the first edit not yet copied. It does not stall a frame: the frame clones the document's scene, and one writer thread serializes it and writes the copy and then its record, each atomically; removals go down the same queue, so a removal is never overtaken by an older write. tests/d18_safety.rs measures it on a 3,000-entity scene. A document that is no longer dirty — saved, discarded, or undone back to the file — has its copy removed on the next frame. A session that ends with unsaved work nobody was asked about (a quit signal, a scripted close) copies it as it closes, so the next open offers it back.

The switch and the interval are the person's, Edit ▸ Editor Preferences… ▸ Loading & Saving (prefs.autosave.enabled, prefs.autosave.interval, 10–3600 s), beside Keyboard Shortcuts, kept per user in <config dir>/argus/editor/preferences.json as {"autosave": {"enabled": true, "intervalSeconds": 60}} (ARGUS_PREFERENCES_FILE overrides the path). Only the window's editor reads it (Shell::set_preferences_store); a Shell built directly — every test, a headless gate — autosaves nothing unless told to (Shell::set_autosave). editor.state's autosave is {enabled, intervalSeconds, last, error}.

Crash recovery. A project that opens with copies left in .argus/autosave/ shows Restore unsaved work, a modal listing each (recovery.<path>) with when it was made and Restore / Discard, as the usual editor's "Restore Packages" does. Scenes are listed before prefabs. Restore is offered only when the file still hashes to the copy's base: a copy of a file that has changed since (another writer, a checkout) says so and where the copy is, and offers Discard only, because restoring it would silently undo whatever changed the file. A restored document opens unsaved: the copy becomes the document as one undo step ("restore autosave"; Ctrl+Z gives the file's version back), a scene that is not the open one is opened first, and a prefab opens in Edit Prefab. The copy stays until the document is saved or discarded. Later (recovery.later, or Escape) is The usual editor's Skip: the dialog closes touching nothing, and the next open offers the copies again. A copy put off with Later is never written over: until its offer is answered, the autosave skips that document altogether (the simpler of skipping it and writing a second copy beside it), so work done on it this session is protected by the prompt on the way out, not by an autosave. An agent reads editor.state's recovery (and recoveryLater) and answers with editor.set {recover: {path, restore}} or {recover: {later: true}}, or /v1/do {"do":"recover","path","restore"} / {"do":"recover","later":true}.

Editor Preferences

Edit ▸ Editor Preferences… (also the toolbar's Settings ▾) is the usual editor's page for the person's own settings: none of them is the project's, and all but the keymap are kept in the same <config dir>/argus/editor/preferences.json as autosave's (ARGUS_PREFERENCES_FILE overrides the path), each section under its own field beside "autosave". Every change is written at once, a number field's when the drag ends or the typing is done. A file written before this change (autosave alone) reads as the defaults; a section that does not read is that section's defaults, with the reason on the page (prefs.note). crates/editor/src/preferences.rs, prefs_ui.rs.

  • Loading & Saving: autosave's switch and interval (Unsaved work).
  • Appearance ("appearance": {accent, uiScale, fontSize, density}): pt rows; theme::row_height()): how tall a list, tree or property row is. tab and the switches' fill, egui's and the painter's alike (theme::accent()). Only the accent, not a light theme: the shell's surface and text colours are constants that about two hundred draw calls name directly, so a second palette would mean every one of them asking which theme is on. Each accent holds the contrast the usual editor's blue has (theme::tests). are display × UI scale pixels. A scale above 100 % stops where the window would have fewer than 480×320 points, the smallest shell the layout-fit sweep proves (960×640 at 200 %); the display's own scale is never cut. tests/d19_prefs.rs sweeps every UI scale over displays at 100, 150 and 200 % in both window sizes, at the default and the largest text size. heading and monospace text keep their distance from it. Text the painter lays out itself (the Outliner's rows, the status bar's chips) keeps its size.
    • Density (prefs.appearance.density, comfortable 24 pt rows, the default, or compact 20
    • Accent colour (prefs.appearance.accent, teal, the default, blue or violet): selection, focus, the active
    • UI scale (prefs.appearance.ui-scale, 75 % to 200 %), over the display's own scale: points
    • Font size (prefs.appearance.font-size, 10 to 16 pt, default 12.5): the body text; small,
  • Viewport ("viewport": {fovDegrees, speed, speedScalar, mouseSensitivity, invertY}): every project, from the moment it is changed. and orbiting; Invert Y while flying turns the right-button look over. Every project. menu's step and scalar). A project keeps its own after.
    • Field of view (prefs.viewport.fov, 30° to 120°, default 60°): the perspective view's, in
    • Mouse sensitivity (×0.1 to ×5) scales how far the view turns for the mouse's travel, flying
    • Camera speed and Camera speed scalar: what a new project's camera starts at (the Speed
  • Grid & Snapping (new projects) ("snapping": {gridSnap, grid, angleSnap, angle, scaleSnap, scale}): the snaps a new project's gizmo starts at. A project that already has its own (state.json, Restart state) keeps them; the viewport toolbar changes them there. "New" is a project the editor has no restart state for.
  • Keyboard Shortcuts: the person's keymap (Keys).

Search (prefs.search): one box at the top filters every section. A row is shown when its name or its keywords match ("fov", "invert", "snap"), a shortcut row when its action, id, category or keys do ("duplicate", "F2"); a section whose own name matches ("viewport") is shown whole, one with no row that matches is not drawn at all, and nothing matching says so (prefs.search.none). The section headings are prefs.section.<saving|appearance|viewport|snapping|keys>.

The viewport's menus and the gizmo toolbar are foreground areas over the image; while Project Settings or Editor Preferences covers the viewport they are not drawn (they drew over both pages and took the clicks meant for them).

Agents read the effective preferences in editor.state's view.prefs, in the file's spelling ({appearance, viewport, snapping}), and change any field with editor.set {prefs} (/v1/do {"do":"set-view","prefs":{"viewport":{"fovDegrees":75}}}); an unknown field or a value outside its limits refuses the whole call. An agent's change is the person's too, written to the same file.

Prefabs

The editor's half of prefabs. The data (*.prefab.json, instances, override keys, expansion) is docs/SPEC.md "Prefabs"; the agent's tools are prefab and prefab.place. The usual editor is the reference, and the parity rule is kept: every verb below calls the code the agent's tool calls (crates/editor/src/prefab_edit.rs, which crates/agent/src/prefab_ops.rs wraps), and what it writes is the file an agent reads next.

The document stays as written; the world expands. The document (Editor::document) holds the scene file of record, with instances and overrides, and saves it unchanged except for your edits. The authoring world, the Outliner, Details and the gizmo show the scene as it runs (Editor::resolved): each instance's parts (<instance>/<prefab entity id>) in place, resolved from the project's prefab files. Every edit is checked in the project with instances expanded (Document::set_context), so an override that breaks the contract is refused like any edit. A prefab file changed on disk (an agent's apply, a text editor) is picked up on the next disk check (every 2 s), and every instance shows it; undo, reconcile and Play are unchanged. A scene entity may be parented inside an instance (drop it on a part in the Outliner, or entity.reparent to <instance>/<part>).

Content browser. Prefab tiles (the Blueprint blue, a box in a box) and a Prefabs type in the Filters menu (it was a chip of its own, shown once the project had a prefab). Drag a tile onto the viewport to put an instance where you let go (on the surface under the cursor, else on the ground plane: content_browser::drop_point, the same placement a dropped model gets), or select it and press Place for one at the origin (one undo step, the agent's prefab.place with one item). Double-click or Open is Edit Prefab. Instances (and the tile's right-click Select all instances) selects every instance of it in the open scene.

Create Prefab… Right-click an entity in the Outliner, right-click the viewport with it selected (a right click that flies nowhere, as in the usual editor; a right drag still flies), press Create Prefab… in Details, or drag an Outliner row onto the Content browser. The right-click menus also offer an instance's Select instance, Edit Prefab, Apply and Revert. A path picker suggests prefabs/<name>.prefab.json (or the folder the browser shows). Create writes the prefab file from the entity and everything under it, and puts an instance in their place, as one undo step (the agent's prefab create). Undo puts the entities back; the file stays, as an import's copy does.

Outliner. An instance's row carries the prefab icon. Its parts are listed under it in grey: a part is its prefab's, so it is not dragged, and Delete, Duplicate and reparenting refuse it, naming the instance. Scene entities parented under an instance, or under a part, show normally. A click in the viewport on a part selects its instance, as a click on a Blueprint's component selects the actor; the Outliner reaches the part itself.

Details. Over an instance, or a part of one, a bar reads Prefab: <name> · N overrides with Edit Prefab, Apply and Revert. Every overridden property (a component field, or a part's position, rotation or scale) carries the usual editor's yellow reset arrow, which drops that override (one undo step). An override that no longer applies (prefab-override-unknown) is listed at the bottom in red, with Remove. An edit to a value the prefab owns, on an instance or a part, is written as an override on the instance, under the key grammar scene.patch with resolved: true writes; it never edits the prefab. Apply (a person's, or an agent's prefab apply while the editor runs: the agent's goes through the editor's own Apply, /v1/do {"do":"prefab-apply"}) writes the instance's overrides into the prefab file and takes them off the instance (one undo step; every other instance changes at once, and the status line says how many there are in the project). Revert drops them all. Undo of an Apply puts the overrides back on the instance and the prefab file's bytes back as they were before the Apply, so every other instance is as it was too; redo writes the applied bytes again. Either is refused, changing nothing, when the prefab file no longer holds what the step left there (someone changed it since): the status line says "prefab changed since Apply; not undone".

Edit Prefab opens the prefab file as the document, the scene put aside as it was (its undo, its selection, its camera). A bar over the viewport reads Editing prefab: <name> (N instances) with Save and Back. Everything that edits a scene edits the prefab here (transforms, Details, the Outliner, undo); Save (or Ctrl+S) writes the prefab file. Back returns to the scene, with every instance resolved from the prefab as saved; it is refused while the prefab has unsaved changes, and refused (staying in the prefab) if the scene would not load with it. No nesting: a prefab holds no instance. Play (its button disabled, with the reason), Reload, opening or making another scene (File ▸ Scene items disabled), importing and placing are refused until Back. Closing the window or the project asks one question naming the prefab and the scene under it: Yes saves the prefab, goes Back, then saves the scene, the established order; No drops both. Neither is dropped silently. An agent's scene commit is refused while a person has a prefab open, with the reason; /v1/state (editor.state) says editingPrefab: {path, name, instances, dirty}.

Hit names. prefab.header, prefab.name, prefab.edit, prefab.apply, prefab.revert, prefab.select-instance, prefab.create, prefab.create.path, prefab.create.confirm, prefab.create.cancel, menu.prefab.create / .select-instance / .edit / .apply / .revert (the right-click menus), prefab.stale.<key> and .remove, <row>.reset (for example inspector.transform.position.reset), prefab.editing, prefab.save, prefab.back, content.filters.prefab (the Filters menu's Prefabs row) and its chip content.filter.prefab, content.prefab.instances.

World Settings

The usual editor's World Settings: the open scene's own settings as typed rows, in a tab beside Details in the right column (click the tab, or Window ▸ World Settings; choosing it again goes back to Details). The code is crates/editor/src/world_settings.rs.

It edits the scene's meta, and nothing else. Every row is one field of the scene file under /meta, the same field an agent reads with scene.read and writes with scene.patch (the mapping is in the agent guide). A change is shown in the viewport as it is made (World::reconcile applies gravity, physics quality, the look, the sky and fog in place), is one undo step (titled "World Settings: Set Exposure" in the Undo History), and is saved with the scene.

SectionRows
WorldGravity (x, y, z), Bounds min, Bounds max
PhysicsQuality; Overrides: solver iterations, substeps, continuous collision, collider detail; Sleep: speed, spin, after
RenderingStyle, Shading, Texture filter, Tonemap, Exposure, Resolution scale, Anti-aliasing
EnvironmentSky colour, Env map (old), Ambient; Sky: Custom sky, kind, preset, panorama asset, intensity, rotation, sun from sky, turbidity; Sun: Explicit sun, elevation, azimuth; Atmosphere; Clouds; Fog: Fog, mode, colour, density, height falloff, base height, start, end, max opacity; Fog (HD)
NavigationAuto links: max drop, max jump up, max gap, spacing, max links
BudgetsTriangles, Draw calls, Texture MB, Dynamic bodies
AudioAudio mixer, Voices, Output device, Buses, Snapshots, Sounds, Music
UIStart screen, HUD layout
Inputthe scene's input action map, meta.input: the input table (crate::input_ui::section), the same one Window ▸ Input opens, in a sideways scroller of its own since it is wider than the column

Not here: the scene's name, genre and string table, and meta.outliner (the Outliner's folders). The search keeps the Input section whole when it matches "input", and leaves it out otherwise (its rows are the table's, not World Settings'). The table disables itself during Play.

The rows reuse the inspector's kinds. A number drags and types, three numbers make a vector, a boolean is a checkbox, an enum is a dropdown of exactly the contract's spellings, a colour is a picker beside its hex text (only a whole #RGB or #RRGGBB is written, so half-typed hex writes nothing), a string is a text field (emptying an optional one leaves the field out rather than writing ""). JSON rows: Audio's Buses, Snapshots, Sounds and Music, and UI's HUD layout, are arrays and maps with no typed editor yet; each is the field's JSON text, written only once it parses and is not null, as the inspector's JSON rows are. Toggles put an optional object there or take it away: Custom sky, Explicit sun, Fog and Audio mixer. Ticking one writes the least that validates and looks the same as its absence (a procedural sky at intensity 1; fog with every default; a master bus), so it changes nothing until a row under it is changed. A row under an absent object shows its default, and changing it writes the object first, from the same template.

Every edit goes through the scene's own check. The meta is edited as JSON and read back through the contract's SceneMeta, then the whole scene is validated, so an exposure of 100 changes nothing and the toolbar says render-value-range … it must be more than 0 and at most 64. A drag is one step from the first change to the release; typing is one step however many letters; a checkbox, a toggle, a dropdown and the reset arrow are a step each. Escape abandons a run as it does in Details. During Play the rows are disabled; while a prefab is open they are replaced by a line saying a prefab has no World Settings.

Where a value comes from. A row whose field the scene writes is this scene's: normal text, and, when the scene may leave the field out, the usual editor's yellow reset arrow at the end of the row, which removes the scene's value (one undo step). A row whose field the scene leaves out shows what stands in for it, greyed: the engine's default, which follows what the scene chose (Rendering's fields follow the Style, retro's scale is 0.5; Physics' overrides follow the Quality preset, argus_physics::quality), or auto when the engine decides from more than meta (a procedural sky's turbidity follows its preset; whether the sun follows the sky depends on the scene's lights). The label's hover names the field's pointer and its source. A required field (Bounds, Ambient, the Budgets) has no arrow. Gravity has one: a scene without it takes the project's physics.gravity, else [0, -9.81, 0].

Project defaults (option A). Sean chose project-wide defaults in argus.project.json (Project Settings ▸ Physics, Graphics, Input, Audio) that scenes override field by field. world_settings::project_defaults reads them from the document's context, shaped like /meta, and world_rows shows a row the scene leaves out as from project (in the link colour), with the project's value; the reset arrow's hover names the value that will stand. The editor takes the defaults again whenever the manifest changes (Editor::adopt_scene_defaults: a Project Settings apply, or an agent's project.settings.set the service reports), so the viewport and these rows follow without the scene being opened again. The row reads WorldRow::source and WorldRow::fallback, and an edit only ever writes or removes the scene's value. Over the project's defaults an edit writes only its own field (world_settings::meta_edited_over): an audio block made for Voices or Output device, or by ticking Audio mixer, starts with no buses of its own rather than a master bus that would override the project's, and the Buses JSON row keeps only the buses that differ from the project's same-named ones.

Search. A box at the top (in the header strip beside the tabs when the column is wide enough, else the first line of the panel; the Details search moves the same way) filters rows by label across every section, as the Details search does: a section's or a group's title keeps all its rows, and every section is opened while a search is on. Escape in it, or its ×, clears it.

Which tab is in front is kept for the run (not in layout.json), and is /v1/state's view.layout.detailsTab, details or world; /v1/do {"do":"set-view","layout":{"detailsTab":"world"}} (editor.set {layout}) sets it, opening the column if it was closed.

Hit names. world-settings (the tab; details is the Details tab), menu.window.world-settings, world.search and world.search.clear, world.section.<key> (world, physics, render, environment, nav, budgets, audio, ui, input; the Input table's own controls are input.…), world.group.<section>.<group>, one row world.<path> (world.render.exposure, world.environment.fog.density), a vector's fields world.gravity.x/.y/.z, an enum's items world.<path>.<item> (world.render.tonemap.neutral), a colour's picker world.<path>.pick, and the reset arrow world.<path>.reset.

crates/editor/tests/d19_world.rs drives it through the winit boundary: gravity, exposure and fog density each one step, shown and saved; a drag and a typed word each one step; a refused exposure; a dropdown; the reset arrow; the search; and the panel at 100-200% display scale.

Project Settings

The usual editor's Project Settings: a page over the editor (Edit ▸ Project Settings…, the toolbar's Settings, the Export panel's Edit in Project Settings) with a category list on the left and typed rows on the right. It edits argus.project.json through the workspace service (crates/editor/src/settings.rs, below under The workspace service): what is typed waits for Apply, Revert drops it, and closing the page keeps it.

GroupCategoryRows (manifest pointer)
ProjectIdentityProject type (2D, 3D, 3D HD: /kind with /render/profile, below); Display name, Application id, Executable name, Version, Build number (/application/*)
ProjectStartup and windowStartup scene; Window width and height (whole pixels, 64..8192, checked as typed with the contract's project-window-size), Resizable (a checkbox)
ProjectPackagingIcon, with Choose… and a preview (/application/icon)
ProjectAsset generationConcept images and 3D model generator, each an Enabled checkbox (off by default) and a Provider dropdown (the concept step's shows tripo until another is picked, and the file holds it only once one is); Image model and Concept views (Auto by asset type, 1 or 2; Auto leaves views out of the file, 1 and 2 write a number) for the concept step; Style (render, proportion, wear: dropdowns over the taxonomy's values, None leaves the facet out); Spend (project and session caps, Ask before paying, on by default), with the running spend from the library's ledger and each provider's key status, with where the key comes from in plain words, and a Set key… button that opens the person-only keys set in a terminal (a key is never typed in the editor and never stored in the project); Budgets as one table, a line per class and a column per profile, each cell triangles / texture side, muted at the gate's own ceiling until typed (settings.assets.budget.<class>.<profile>.<triangles|textureMaxSide>). A note says generation uses your own provider keys and costs money. Nothing is generated in a project until it opts in here. Authoring only: a change needs no restart or export
EnginePhysicsDefault gravity (/physics/gravity), Quality (/physics/quality); Overrides and Sleep (/physics/overrides/*)
EngineGraphicsRenderer, standard or HD (/render/profile), shown and not edited: "set by Project type"; Default look: style, shading, filter, tonemap, exposure, resolution scale, anti-aliasing (/graphics/render/*); Quality (HD): preset, target frame rate, the knobs it overrides, the custom profile (/render/quality/*); Windows and Linux: each platform's preset, frame rate and knob overrides over it (/render/quality/platforms/<platform>/*), and what that platform runs
EngineInputThe project's action map (/input) as the Input table
EngineAudioBuses: Master, Music and Effects volume (the buses named master, music, sfx in /audio/buses); Voices, Output device (/audio/voices, /audio/device)

Project type is one row for two fields. It is the first row of Identity (settings./kind), a choice of 2D (kind 2d, render.profile standard), 3D (3d, standard) and 3D HD (3d, hd), the same argus_projects::template::ProjectType New project offers. A choice waits for Apply like any row, and Apply sends both /kind and /render/profile in one change set, the pair an agent sends with project.settings.set; choosing what the project already is sends nothing. The Graphics ▸ Renderer row only shows the renderer, with "set by Project type", so two controls never write render.profile; the service's own /kind field ("Project kind") is this row, not a second one. A manifest from before kind reads as 3D or 3D HD by its renderer. A pair the row does not offer (a hand-edited 2d on hd) reads Custom (2d, hd) with the project-kind-2d-hd warning on the row (settings./kind.diagnostic.project-kind-2d-hd); the project still loads and draws, and choosing any type replaces the pair. The row publishes what it reads as settings./kind.shows.<2d|3d|3d-hd|custom>.

An applied change goes the renderer's restart path: the project reopens (Exit::Reopen), after the unsaved-work prompt when anything is unsaved, whose extra line says the project reopens and scenes are not changed (prompt.then). Changing the type converts nothing. No scene is rewritten; the kind sets the defaults new game cameras and physics take (orthographic and planar for 2D) and how the viewport behaves, and the Identity section says so (settings.identity.project-type).

The Engine categories are the defaults every scene starts from (option A): a scene that leaves a field out of its meta takes the project's, and one that writes it overrides it (World Settings shows which). The rows are World Settings' own for the same field — label, editor, hover line — from world_settings::describe, and a row the manifest leaves out shows, greyed, the engine's default that then stands. A row the manifest writes has the usual editor's reset arrow, which takes the value out of the manifest. A typed row not yet applied has its label in the warning colour.

Graphics reuses the quality system; it adds none. The presets are the contract's RENDER_QUALITY_PRESETS, the knobs and their types RENDER_QUALITY_KNOBS (a boolean is a checkbox, an integer a whole-number field, a choice a dropdown), and a platform's line under its rows ("Linux (this machine) runs: high at 60 fps, 1 knob overridden") is effective_render_quality, the layering the launch plan hands the renderer. The base quality and each platform are the same rows over another pointer. A knob is overridden with Override a knob…, which lists every knob not yet overridden; the arrow gives it back to the preset. Quality is the HD renderer's (project-render-quality-without-hd), so its rows are disabled, with a line saying so, until the Renderer is HD.

Input is the input table over the project's map (input_ui::InputTarget::Project), read from the manifest with anything typed over it (project_settings::ProjectMaps). A project with no map shows the built-in one, and the first change copies it into /input. A change waits for Apply like every other row; Remove the project's map takes /input out.

One edit per settings field. The service takes an edit only at one of its fields, so a row inside one — one override, one platform's preset, one bus's volume — is typed by its own pointer and Apply sends the whole field with the row applied (project_settings::edits): /physics/overrides, /render/quality/platforms, /audio/buses. An object left empty is taken out.

Diagnostics and refusals are on their rows. Every diagnostic of the manifest is shown under the row whose pointer it names (a bus by its index is matched to its name), and the rest at the top of the section. An Apply the service refuses (422 invalid-settings) writes nothing, keeps what was typed, says why at the foot, and puts each diagnostic on its row until the next Apply or Revert: a substep count of 99 is project-default-invalid: physics-quality-invalid: …; every scene that does not set its own takes this default. What a change needs (requires.restart, requires.reExport, from the service) is said once per section, or with a × on the row where the section is mixed.

After an apply the editor follows. The shell re-reads the manifest (Editor::adopt_manifest), takes the scene defaults into the document's context and shows the scene again with them, so the viewport and World Settings reflect the new defaults without the scene being reopened. Play reads the manifest when it starts (Editor::preview_run_scene merges the saved scene's meta over the defaults as they are then). A change made elsewhere — an agent's project.settings.set, another window — is found the same way (the service's change report, or the two-second disk check), and the page reads the settings again, keeping what was typed on it (EditorUi::refresh_settings_view).

Search. One box filters every category at once, as the usual editor's does: a row by its label, pointer or group title, the Input section by an action's or a context's name (or "input", "key", "gamepad"). The tree counts each category's hits, and while a search is on the form shows every category with a hit under its own header.

Not here yet (they are not manifest fields): export targets, development and release builds and installers, which belong to the launcher; default friction and bounciness, collision layers, and a fixed timestep, which the engine does not have as settings; settings.ui (default theme and locale), which project.settings.set edits and this page does not draw yet.

Hit names. settings.category.<slug> (identity, startup-window, packaging, physics, graphics, input, audio), settings.section.<slug> (and .note, .defaults), one row settings.<pointer> (settings./physics/gravity), a vector's fields settings./physics/gravity.x, a dropdown's items settings.<pointer>.<item>, the reset arrow settings.<pointer>.reset, a row's first diagnostic settings.<pointer>.diagnostic, a group title settings.group.<slug>.<group>, Override a knob… settings.<block>/overrides.add and its items .add.<group>.<knob>, a platform's line settings.<block>.effective, and the Input section's settings.input.source; the table's own controls are input.….

crates/editor/tests/d19_project.rs drives it through the winit boundary: a gravity default shown "from project" in World Settings and run in Play; a scene's override and the reset arrow; a project input rebinding a scene without its own plays; a platform preset and knob stored under render.quality.platforms and a refused frame rate on its row; project-default-invalid; the search; and the page at 100-200% display scale. crates/agent/tests/d19_project_settings.rs holds the parity.

Behaviours

This was the one step of the product's exit gate — New project → Import model → Add Behaviour → Play → Revise → Export → Run — the editor could not do. An entity could carry a behaviour, and a person had no way to give it one; script.module was reachable only by editing the scene file by hand or by asking an agent.

Everything here lands as one undo step through Editor::commit_scene, the seam the Content browser's Place already uses: authoring only, computed as a whole new scene that already passes the contract, shown in the authoring world before it is committed, and carrying the selection as it was. So a behaviour a person attaches and a behaviour an agent attaches (crates/agent's entity.add_behaviour) are the same edit arrived at two ways, and Play, the build and undo cannot tell them apart.

Add component

At the top of the Details panel, above the transform, where the usual editor's own "+ Add" is. Not a matter of taste: the panel is 320 points wide at most and as little as 480 tall at 150 % display scaling, and an entity with three components already fills it — a catalogue under the property sections was drawn below the bottom of the window, where a control is not published at all and neither a person nor a gate can press it.

Add component (inspector.@add-component) opens a list of the component kinds the contract will take, each added with the contract's own defaults, and the one under the pointer described on the single line below. A kind is inspector.@add-component.<kind>. The @ is the same control namespace the typed property rows use, so a property can be called anything without taking a control's name.

OfferedAdded asOne per entity
Behaviour (script)nothing on its own — it opens the behaviour catalogue, because a script with no module is a scene the contract refusesno
Collidera unit boxno
Rigid bodydynamic where the entity has a non-trigger collider, kinematic where it has not (dynamic-needs-collider)yes
Triggertriggered, not onceyes
Goalthe next number in the 1..n sequence the contract requires, with a goal id no goal in the scene hasyes
Controller, Camera, Light, Spawn point, Nav area, Nav agent, Label, HUD element, Particlesthe contract's defaults; a new camera is primary only when no camera in the scene is (primary-camera)yes

mesh, animator and audio are not offered. Each names an asset, and an asset reference is the import flow's to write — the same reason script.module and mesh.asset have no typed property row. They arrive through Import and through the Content browser's Place, where the file being named actually is.

A kind that cannot go here says so before it is pressed. A second controller on one entity is a component nothing reads, because crates/app's wire_entity seats one per entity; a trigger with no trigger collider is trigger-needs-trigger-collider. Both are drawn disabled with the reason on hover, rather than left to a refusal in the toolbar after the click. A collider and a behaviour are the two an entity may have several of — an entity may carry a solid shape and a sensing volume, and the starter course runs several behaviours on the player. The contract still has the last word: Document::component_added reads the component back as an argus_contract::Component and then checks the whole scene, so anything the catalogue's precondition misses changes nothing and the toolbar names the rule.

Add behaviour

Add behaviour (inspector.@add-behaviour), or Behaviour in the component list, opens the catalogue of modules script.module may name (behaviour.catalogue), narrowed by a search box (behaviour.search):

  • This project's own, from the .ts and .js files under the open scene's folder. A module name is the file's path under that folder without the extension — behaviors/door — which is what crates/app/src/modules.rs resolves and what the build writes. It is the same enumeration crates/agent's argus://behaviours resource lists a project by, so what an agent will accept and what this catalogue offers cannot drift apart.
  • The built-ins that ship with the engine: player, camera, animator, hud, audio, particles, label. packages/gameplay/src/behaviors/catalogue.ts is the source of truth, and an installed editor has no checkout beside it to read, so the editor carries a copy that tests/d9_behaviour.rs holds to that file — the modules and the props each one reads.
  • New behaviour… (behaviour.new), below.

A module is behaviour.item.<module>. Choosing one writes a script component naming it, as one undo step, and the typed property rows are there the moment it lands.

What a behaviour is attached with is only what its own source states. A built-in's props are the defaults its module spells out (props.x ?? v), so attaching one changes nothing and still gives the person rows to tune. player.speed is deliberately not written: its default is props.speed ?? component?.speed ?? 4, so a speed prop would silently override the entity's own controller component, and an attach that changes how the player moves is not a no-op whatever the number says. Nothing is known about a project behaviour's props, so none are asserted; the add-property row is how they are given.

A second copy of one module on one entity is refused, in the same words entity.add_behaviour uses: two ticks of the same behaviour is never what was meant. The row is drawn and disabled rather than left out, so the person can see that it is already running.

New behaviour from a template

New behaviour… opens a dialog: a name (behaviour.new.name), a template (behaviour.new.template.<t>), Create (behaviour.new.create) and Cancel (behaviour.new.cancel). Nothing is written before Create.

TemplatetIs
Emptyemptyinit and update, and nothing else yet
Rotaterotatespins the entity about one axis at a steady rate
Collectablecollectablethe starter course's pickup, byte for byte
Trigger doortrigger-doorthe starter course's door.ts, byte for byte
Interactableinteractablethe starter course's interactor.ts, byte for byte
Timertimera HUD countdown that emits once when it runs out

Three of the six are fixtures/starter-course/scenes/behaviors/{collectable,door,interactor}.ts byte for byte: a person starting a pickup should start from the pickup the documentation describes, not from a second almost-identical one, and d9_behaviour.rs is what keeps the two copies one thing — the same test tests/template.rs runs over the collection course's scene. The file written is the template's bytes exactly; nothing on the way to disk reformats a file somebody is about to open and read.

It is refused for the same names New scene is refused for, and for the same reasons (see Scenes): a name is a file name and not a path, . and .. are not file names, a dot-leading name is in no revision so nothing would ever build it, a name longer than Editor::SCENE_NAME_MAX fails at the write with a raw ENAMETOOLONG instead of in the dialog, the characters are the contract's own project-path rule asked about a path built as text, and a name that differs from an existing file only in case coexists on Linux and collides on Windows. Two more are this file's own: a name is compared against .js as well as .ts, because two files that resolve to one module name are a module the loader picks one of; and an extension the person typed is stripped rather than doubled. The reason appears in the dialog as the name is typed, and Create is disabled until there is none.

The file is written first and then attached, and a refused attach keeps the file. The two halves fail for different reasons — the write for the name or the disk, the attach for what the scene or the authoring view will not take — and deleting source code somebody has just asked for because the preview could not load it is destroying the wrong thing. The dialog then says what happened to each half.

Once it is written the dialog says so and offers Open in editor (behaviour.open), which opens the file with whatever this system opens a .ts with: Explorer on Windows (which starts the associated program with no console window of its own, exactly as Reveal does), open on macOS, xdg-open elsewhere, with code and notepad behind them. It is spawned and let go — waiting on it would freeze the editor behind somebody else's window — and the error names every program it tried, because "nothing opened and I do not know what it looked for" is the unhelpful half of this failing. A gate opens no windows, so it is tested by dispatch alone.

The dialog is in Shell::modal_open, and so is New scene. Neither used to be (wave-2 F10), on the reasoning that Escape in a name field has to leave the field rather than throw away the name typed into it. That is true, and it is not a reason to leave the authoring shortcuts live behind a dialog. egui::Modal already stops anything beneath the dialog being clicked; the keyboard gate is modal_open, and the only other gate is whether egui wants the keyboard — which goes false the moment a template chip takes the focus off the name field. So with the dialog still up and a chip pressed, Escape cleared the selection, Delete deleted the selected entity, Ctrl+S saved and F5 started Play. Escape is now answered in two stages, the way a running export already answers it: while the name field has the keyboard it leaves the field, and once the field is given up it cancels the dialog. Cancel still closes it. d9_behaviour.rs drives both dialogs through the chip and the shortcut table.

The round trip to Play

Saving the scene makes a revision the workspace service builds, and a behaviour file on disk is an input of that revision whether or not the editor wrote it. A build that refuses the TypeScript names the file and the line in the activity feed and on the toolbar already (see Activity feed and d2_feed.rs). What neither could say is which of the behaviours on the selected entity it is about — so the newest build's first error also appears on that module's own script row, inspector.<component segment>.diagnostic, which is inspector.script.diagnostic for the first script on the entity. The diagnostic's project-relative path is compared against the module resolved under the open scene's folder, case-insensitively and against both extensions the loader takes, so another module's row stays quiet and a built-in never matches — it is not a file in this project and nothing here compiles it. Fixing the file and letting the next build through clears the row.

From the Content browser

A behaviour in the Content browser is a file a person opens and a behaviour a person attaches, so the tab offers both (see The Content browser):

  • Double-clicking a behaviour opens it in the system's editor, the way double-clicking a scene opens the scene and double-clicking a model places it. Before this a behaviour tile was the one tile that answered a double-click with silence.
  • Open (content.open) serves both roles a person opens: a scene, or a behaviour.
  • Attach (content.attach) runs the selected behaviour on the selected entity. It needs both ends and the disabled message says which one is missing. The module name is the file's path under the open scene's own folder, so a behaviour beside a different scene cannot be attached from here and says so, rather than writing a module name nothing would load.

Both are in the row of verbs as well as in a tile's right-click menu, because a headless gate cannot press an item in a popup menu — the same reason every other Content action is a button.

Names

NameIs
inspector.@add-componentopens the component catalogue
inspector.@add-component.<kind>adds that kind with the contract's defaults
inspector.@add-behaviouropens the behaviour catalogue
behaviour.cataloguethe catalogue's surface
behaviour.searchnarrows it
behaviour.item.<module>attaches that module
behaviour.newopens the New behaviour dialog
behaviour.new.name, behaviour.new.template.<t>, behaviour.new.create, behaviour.new.cancelthe dialog
behaviour.openopens the file that was just written, in the system's editor
inspector.script.diagnosticthe last build's error for that module
content.open, content.attachthe Content browser's two

crates/editor/tests/d9_behaviour.rs drives all of it by hit name through the winit boundary, with the workspace service in the test process for the diagnostic.

Importing models

Import model… on the toolbar (toolbar.import) opens the operating system's file picker filtered to .glb and .gltf; where the editor links no picker — everything but Windows — the same flow is reached by handing the path to UiAction::ImportFile, which is also how the gates drive it.

The file is never rewritten. glTF has no unit field and no up-axis field, so the import declares both in the scene's asset entry and the runtime composes them into one transform above the loaded mesh (argus::import_transform, and docs/SPEC.md amendment 15). Nothing is baked into the vertices, which is what keeps the import correct for a skinned mesh, whose vertices are in skin space and whose joints are not.

What is measured

crate::import::measure reads the POSITION vertex data of every triangle primitive reachable from the default scene — the GLB's BIN chunk, or a data: buffer URI in a self-contained .gltf — folds each accessor's vertices into one box, and puts that box through its node's world transform before unioning it. Reachability and the node transforms are what argus::assets::merge_gltf does when the runtime actually draws the file, so the editor measures what the runtime draws — not something near it.

The accessor's declared min/max is read as well, but only to be checked. A declaration further from the vertices than 1e-4 of the box's own size is a warning that names the accessor — "POSITION accessor 0 declared bounds differ from the vertices; the vertices are used" — and the vertices win. A file that declares a 1 cm box over vertices spanning 20 m therefore imports as 20 m, with a box collider 20 m across and a line in the modal saying the declaration was wrong. An accessor whose vertices cannot be read at all — a sparse accessor, or a component type this reader does not decode — falls back to its declaration and says so in the same way.

It is still fast. Each accessor is read once however many nodes instance it, streamed at its own byte stride and folded into a running min/max with nothing allocated per vertex. The four-megabyte, 243,602-triangle model in d3_import.rs::probe_a_four_megabyte_model_stays_responsive measures in 4.2–4.4 ms and the modal opens in 8.1–8.7 ms; the gate refuses anything over 100 ms.

AssetRef.bounds and AssetRef.triangles are therefore the source file's own numbers, in the file's own units and axes, before the import transform. That is what the runtime needs: targetSizeM and baseCenter are resolved against exactly those, and pre-applying the settings here would apply them twice. Everything a person sees, and everything a box collider is sized from, goes through import::preview_bounds, which calls the runtime's own argus::import_transform — one implementation, so the editor and the runtime cannot disagree.

Vertex colours

A primitive's COLOR_0 is drawn. It may be float, or normalised unsigned byte or short, and RGB or RGBA (an RGB colour has alpha 1); argus_assets hands it on as linear RGBA in LoadedMesh::colors, merge_gltf carries it into MeshData::colors, and the opaque pass multiplies it into the base colour the way glTF specifies — baseColorFactor × baseColorTexture × COLOR_0 — and by the entity's tint, for static and skinned meshes and for PBR, unlit and toon materials alike. That is what baked lighting or ambient occlusion, flat-shaded low-poly art and per-vertex tinting need, with no material setting to turn it on.

A primitive without COLOR_0 in a mesh or file where another has it is white — glTF's "no tint" — rather than the column being dropped. A file with no COLOR_0 anywhere carries no column and draws exactly as it did before vertex colours were read: the GPU vertex gets white, and a tint times 1.0 is the tint bit for bit (crates/render/tests/vertex_colors.rs checks that directly, and the render goldens are byte-identical). Only COLOR_0 is read; COLOR_1 and above are ignored. On the GPU it is Unorm8x4: the vertex grew by 4 bytes (static 48 → 52, skinned 72 → 76), and white packs as 255, which decodes to exactly 1.0. Float and 16-bit COLOR_0 are therefore quantised to 8 bits per channel; smooth baked lighting across large triangles can show 1/255 steps in the darks.

What is refused, before anything is copied

RefusedMessage names
An extensionsRequired this engine does not implementthe extension, what is implemented (argus_assets::gltf_load::IMPLEMENTED_EXTENSIONS, read from the loader so the two cannot drift), and a way out for Draco, KHR_texture_basisu and KHR_mesh_quantization
Geometry in a sidecar (a .gltf with a separate .bin)the sidecar, and that a self-contained .glb is what to export
An image in a separate file, missingthe image, where it was looked for, and the material and slot that want it
An image in a separate file, presentthe same, and that a one-file copy would leave it behind
Nothing with triangles in the default scenethat there is no size to import it at — this renderer draws triangles, so a points-only file would import as an invisible entity

Every one of these says "Nothing has been copied into the project", because nothing has: the measurement happens first.

The Import modal

import.*. It shows the file name, the measured size in the file's own units, the triangle, material and texture counts, and any warnings (non-triangle primitives, meshes outside the scene graph, a skinned mesh whose bounds are its bind pose, extensions used but not required).

ControlHit nameDoes
Unit scaleimport.unit-scale, and import.unit.{m,cm,mm,in}the file's units multiplied by this are metres. No range on the field itself: a zero, or anything outside [1e-6, 1e6], is refused in words, with Import disabled, rather than silently clamped. That range is asset-import-invalid's, read from the rule, and it is what a 32-bit transform can hold — a micrometre and a thousand kilometres per file unit
Up axisimport.up-axis, options import.up-axis.{Y,Z,-Y,-Z,X,-X}which axis of the FILE points up. Forward defaults to Z, except Z up → -Y and -Z up → Y, the convention every Z-up exporter uses
Target sizeimport.target-size.on, import.target-sizescales the longest side to this many metres, after the unit scale
Base centreimport.base-centerputs the origin at the base centre, measured after the scale and the rotation, so the floor is whichever axis ended up down
Colliderimport.collider.{none,box,trimesh}explicit, default none. A line of text says what a box would be; nothing is ever chosen from it
Target sizeas abovethe same range as the unit scale, refused the same way
After importimport.after, import.size-warning.*X × Y × Z m, Y-up from preview_bounds, live. Over 100 m across warns that the unit scale is probably wrong. Settings that make no usable transform at all — a scale the file's own measurement drives out of range — show the refusal at import.problem instead, with Import disabled: the line never reads NaN × NaN × NaN
Import, Cancelimport.ok, import.cancel

The modal is modal: while it is up, input reaches it alone (Shell::modal_open), Escape cancels it, and no shortcut, camera key or pick goes anywhere else.

What Import does

  1. Copies the file to assets/<file name> beside the scene file, wherever that is — <project>/scenes/assets/ for the usual scenes/main.scene.json, <project>/scenes/levels/act1/assets/ for a scene three folders down, <project>/assets/ for a scene at the project root. The path in the asset entry is scene-relative and needs no .., and the artifact's staleness check tracks it. A name already taken becomes <name>-2.glb, -3, and so on, even when the bytes are identical: two asset entries sharing one file would mean a reimport of either silently rewriting the other's model.
  2. Adds assets[<id>] — kind: "glb", the scene-relative path, triangles, bounds (the file's own) and import (the source file's name, its SHA-256, the settings and the collider choice). The id is the file stem slugged to ASCII (Café Chair v2.glb → caf-chair-v2), free as both an asset id and an entity id.
  3. Adds an entity with that same id at the origin, with a mesh component and the chosen collider, and selects it. The origin, not in front of the camera: it is where the person can find it and the same place every time, which is what lets a gate assert the result.
  4. Commits all of it as one undo step, labelled import <file>. The scene must pass the contract, which refuses an absolute or .. asset path (asset-path-not-relative), so the editor never writes one.

The model file is written at import time; the scene is not written until Save, and the modal says so. Undo takes the entity and the asset entry back out and leaves the copy where it is: nothing references it, so nothing loads it, and a redo finds it already there.

An import copy is immutable

Once the editor has written a model file into assets/, it never writes to that file again. That one rule is what keeps undo out of the project's files, and it is why undo is a document operation and nothing more.

A reimport therefore writes a new file beside the scene, named <stem>-<the first eight hex of its SHA-256>.glb, and points the asset's path at it. The name is the content's, not a counter's, so reimporting the same bytes twice lands on the same file instead of piling up -2, -3, -4; and a hash already on the stem is replaced rather than appended to, so a file reimported ten times is still chair-<hash>.glb. The stem is the asset's current file name, not the source's, so an asset keeps its own name across reimports however the file it was read from is called — import.source is what records where the bytes came from. On the vanishingly unlikely eight-hex collision with different bytes, the copy takes the next free number instead of overwriting anything.

Undo then writes nothing at all. It restores the document, the document names the file it named before, and that file is still on disk untouched. The undo stack holds no model bytes: Document carries whole-scene snapshots and nothing else, which is also what keeps eight reimports of a 24 MB model from costing 384 MB of undo history (d3_review_probes.rs::memory::probe_eight_reimports_do_not_hold_the_model_in_the_undo_stack measures 0 MB).

Unreferenced model files

The other side of that rule is that reimports and undone imports leave files behind. Nothing deletes them on its own.

Editor::orphaned_models lists the .glb and .gltf files directly inside the scene's assets/ folder that neither the scene being edited nor the scene last saved names. Both are asked deliberately: a file the working document has stopped naming may still be the one the saved scene loads, and a person who then discards their changes would find it gone. The inspector's asset section shows "N unreferenced model files in scenes/assets" (inspector.orphans) with Delete unreferenced (inspector.delete-orphans) beside it, which deletes exactly that list — recomputed at the moment it is pressed, so a file that became referenced in between is never deleted. It is not an undo step: these are files no scene the editor knows about names, so there is nothing in the document to take back. Disabled during Play, like every other authoring action. It is also refused outright when the project could not be read in full — a sibling scene that does not parse (half-written, a merge-conflict marker, a trailing comma) or a scenes folder that could not be listed — and the refusal names the file and says to fix or remove it first: a scene the editor cannot read still owns its models, and "that scene is broken" must never become "so the editor deleted its models".

Importing is an authoring action. During Play it is refused ("stop Play first") and the toolbar button is disabled: Play runs a build prepared from saved files, and copying a model in underneath it would change what a restart loads.

The inspector's asset section

For a selected entity with a mesh, under the transform fields: the asset id, its path, the source file and hash it came from, the import settings as one line, the post-transform size and the triangle count.

  • Reimport… (inspector.reimport) reads a model file into the asset again. On Windows it picks a file; where there is no picker it reads the asset's own file again, which is what a re-export over it needs. The asset id, the entity's transform, every other reference to the asset and every per-entity material override survive, because none of them is touched: only path, bounds, triangles and import change, and the new path is the new copy's (see An import copy is immutable). import.sourceSha256 is the field that makes a running world drop its cached mesh, since the hot-reload diff compares AssetRef values and not file contents. A file that does not measure leaves the asset and every file exactly as they were, and names the reason: the measurement happens before anything is written. One undo step, and undo restores the document alone.
  • Collider… (inspector.collider.{none,box,trimesh}) changes the entity's collider, one undo step, and records the choice in import.collider so a later reimport can offer the same one. A box is sized and centred from the post-transform bounds. A reimport does not resize a collider: geometry a person will stand on is not changed without being asked.
  • Reveal (inspector.reveal) copies the model file's absolute path.

Limits

  • No FBX, no OBJ, no retargeting, no PBR authoring. glTF 2.0 only, as .glb or a self-contained .gltf.
  • One file per import. A .gltf with a sidecar .bin, or with images beside it, is refused rather than imported half-copied; export a .glb with everything embedded.
  • A trimesh collider is previewed only in Play's F1 overlay. The authoring view draws the mesh, not the collision shape.
  • A reimport makes the artifact stale, because the asset entry changed: Play refuses until the next Save prepares a new build. That is the same rule every behaviour and asset file is under.
  • Reimporting the asset's own file cannot undo bytes the editor never saw. Undo points the asset back at the file it named before; if an authoring tool re-exported over that file, its contents are the tool's and not the ones the restored measurements describe.
  • A reimport leaves the previous copy on disk. That is the point — see Unreferenced model files — but a project reimported many times accumulates them until someone presses Delete unreferenced.
  • A reimport costs no memory per round, and both things that used to make it cost are gone. The undo stack held the model's bytes before and after every edit; it holds whole-scene snapshots and nothing else now. Renderer::register_mesh had no counterpart, so World::reconcile dropping an asset's mesh_cache entry freed the parsed glTF and the CPU geometry while the vertex and index buffers stayed; Renderer::free_mesh is that counterpart and World::free_evicted_meshes calls it on exactly the meshes the reload evicted, skipping any a draw binding still names. Measured on this machine with the software Vulkan adapter, warmed up with eight reimports and measured over the next eight: a 3.0 MB model moves the resident set by 0 MB and a 23.9 MB model by 1 MB. With the meshes left unfreed the same runs cost 6 MB and 48 MB per reimport — 2.1 and 2.0 times the file, every round, for as long as the editor stayed open. What is left is inside the noise of a process that parses a 24 MB glTF between the two readings, which is why the probe's bound is a whole file per round rather than zero. Two probes hold it there: memory::probe_eight_reimports_do_not_hold_the_model_in_the_undo_stack isolates the undo stack by reimporting the same bytes, and memory::probe_repeated_reimports_grow_the_undo_stack_without_a_byte_budget alternates the geometry so every round really does register a mesh.
  • A freed MeshId is permanently dead. Ids are positions in the renderer's list, so a freed slot is a tombstone and is never handed out again rather than coming back attached to different geometry. The tombstone costs a word; a session that reimports thousands of times accumulates thousands of words, which is not a budget anyone will notice. The four builtin meshes are never freed, because every scene shares them.
  • import.collider is a record, not the collider that runs. The entity's collider component is.

Scans

Import model… also takes an .obj (with its .mtl and textures beside it) or a .ply: a photogrammetry or scanner mesh of millions of triangles that cannot be drawn as delivered. The file is cooked first, on a thread, behind a small modal with Cancel (import.scan.cooking, import.scan.cancel); the cook writes nothing. crates/scan (argus-scan, also a CLI: argus-scan cook in.obj out.glb) reads it, welds it, decimates it to a triangle budget (500,000 by default) with meshoptimizer, makes two coarser levels, brings its textures to 4096 px, fits up to 32 convex hulls, and writes one GLB: the default scene draws full detail, the coarser levels are MSFT_lod nodes outside it, the hulls a mesh named by the root node's extras.argusCollisionHulls.

The Import modal then opens on that GLB, exactly as for any model, with the cook's report above the settings (import.scan.N), Convex hulls among the collider choices (import.collider.hulls, selected when hulls were made) and Keep the scan's source (import.scan.keep-source, on). Import is the ordinary one: the GLB copied into assets/, the asset and entity added as one undo step. The asset's import record names the file picked (source, sourceSha256) and carries the cook's settings in import.scan; the source and its MTL and textures are kept under assets/scan-src/<stem>-<sha8>/ (never exported, since nothing at run time names them). A scan too large for the scene's asset budget raises meta.budgets.textureMB to fit, and says so.

Levels of detail. The runtime draws one level per entity per frame, chosen by the share of the screen's height the asset's bounding sphere covers (the file's MSFT_screencoverage, 0.25 and 0.075 by default) with a 10% band so an object on a switch does not flicker. The same instance rows reach the standard and HD renderers, so both switch. argus run --lod-freeze N pins every level for captures; World::set_lod_bias moves every switch (a headset renders at 0.5).

The inspector's Scan section (inspector.scan.*), for a scan asset: the triangle, hull and texture numbers and Reprocess (cooks the kept source again on a thread and lands as a reimport: a new content-named GLB, identity kept, one undo step); Ruler (click two points on the scan, type the real distance; sets unitScale and records import.scan.ruler); Align part (click a point on the part, then the same point on the scan, three pairs or more; moves the part, rigid or with a scale, and reports the RMS residual); Cut region (click the scan where a replacement part sits; its triangles inside a box there are removed at the next cook, split cleanly along the box); Box collider (click the corners of a bench; a static box collider fitted to the scan) and Floor and walls (for a room scan: static boxes fitted to the floor and up to four walls). While a tool is picking, viewport clicks land on surfaces instead of selecting. A Reimport… of a scan asset cooks the picked file (or the kept source) with the recorded settings.

Triangle budget. An import never changes meta.budgets.triangles: the scene's full-detail total against it is in the import's answer (triangleBudget: sceneTriangles, budget, over, and a suggestion naming the budget that would hold it), for the author or the agent to act on.

Agents reach all of it: asset.import with a scan object, asset.reprocess, asset.ruler, entity.align and entity.fit_proxies, and the control actions import, reprocess, scan-ruler, align, proxies and surface.

Collision. collider { shape: "hulls" } is the cooked hulls as one compound body, so a concave scan can be grabbed or placed as a dynamic body and its notches stay open.

CUI. The cook and the import never use the network. Every cooked GLB records its source's SHA-256 and the settings (asset.extras.argusScan), and every kept source folder a provenance.json. argus-scan derived <source-sha256> --project <dir> (also the control action derived and the agent tool asset.derived) lists every file in the project derived from a source (cooked GLBs, the kept source, byte-identical copies under .argus/, the project thumbnail) so they can be purged; a file another scan's provenance claims is never listed, even when two scans share a texture byte for byte. Keeping the source is a choice on purpose: data minimisation says keep no copy unless a re-cook will be needed. scan-src/ never enters a revision, a build or an export: the workspace service does not read it and the exporter refuses it by name. Untick Keep the scan's source to keep no copy at all.

The Content browser

The Content tab of the bottom drawer (content.tab) is every file in the project, not the open scene's asset map: a folder tree on the left, tiles on the right, and the handful of things a person does to a file. crate::content_browser.

What is in the project, and what each file is, are the workspace service's answers. The walk is argus_workspace_service::inputs::walk, the exclusions are its is_excluded (.argus and every other dot folder, node_modules, top-level target and dist), and the role is its role_for. The browser refines that one step further, because a browser has to colour a mesh differently from a texture and a revision does not: the service's single Asset role becomes Models, Textures, Audio and Other, beside its Scenes, Scripts and everything else as Other; the table under Narrowing has every extension. One more refinement, and the same kind: anything inputs::ui_role classifies is UI rather than the Behaviour a .tsx would otherwise be — see The UI category and the viewport preview. Nothing about exclusion or role is decided twice, so a file the browser shows and a file a revision captures can never disagree.

The tree always shows the folder that is selected. Clicking a folder row selects and expands it, as the usual editor's does — somebody clicking a folder is asking to see what is in it — and clicking the folder that is already selected is the press that collapses it again. Whatever else sets the selected folder, a breadcrumb or a script, opens every ancestor of it, so the row a script asks for by name exists to be pressed.

Layout. A left column of indented folder rows with a painted disclosure triangle where a folder has folders under it, and a tile grid on the right. Above them a compact row of actions, and under it the breadcrumb, the Filters button, the search box and a chip for each filter that is on. The actions are on top, which is the usual editor's order and which decides what survives a short drawer: at a small window and a high display scale the drawer can be shorter than its contents, and what a person has to keep is the row of verbs rather than the row that refines a list. The tree's root row is named after the project's own folder, because the breadcrumb beside it already says "Content".

Tiles. A painted role mark, the name over up to two lines, the size, and along the bottom a role-coloured stripe in the usual editor's convention — levels yellow, static meshes cyan, scripts blue, textures red. A model whose file the open scene already has an asset entry for also carries its triangle count and the size the runtime gives it, which the inspector's asset section computes from the same numbers. The open scene is marked ◎ and the startup scene ★ in the tile's corner, both at once when they are the same file. A tile is content.item.<project-relative path>, a folder row is content.tree.<path>, and the project root's own row is content.tree. A UI screen's tile carries a rendered thumbnail — a real frame of the screen, drawn by the real runtime. Every other role still carries its painted mark; a model thumbnail is still deferred, since crates/capture rendering a one-entity world from four angles is a change of its own.

Narrowing. The type filter is the usual editor's filter bar: a Filters ▾ button (content.filters) left of the search opens a menu with a check box per type (content.filters.<key>) and Reset Filters (content.filters.reset). Several can be on at once; the menu stays open while they are ticked, as the usual editor's does, and closes on a click outside it or Escape. Each type that is on gets a chip after the search box (content.filter.<key>, "Models ×"), outlined in the type's colour; clicking the chip takes that type off. None on is every type, and the button says how many are on ("Filters (2)"). The search (content.search, case-insensitive over the names in the folder shown) narrows whatever the filters leave, and a breadcrumb goes back up the path (content.crumb.<n>, 0 being the project root). The old Prefabs chip is now the Prefabs row of this menu, one system with the rest.

Filter (key)FilesDecided by
Models (model).glb, .gltf, .obj, .fbxthe service's Asset role, by extension
Textures (image).png, .jpg, .jpeg, .webp, .ktx2, .hdr, .exrthe service's Asset role, by extension
Materials (material)*.material.jsonargus_contract::is_material_file_path
Audio (audio).wav, .ogg, .mp3, .flacthe service's Asset role, by extension
Prefabs (prefab)*.prefab.jsonprefab_ui::is_prefab (the contract's source_problem)
Scripts (behaviour).ts, .tsx, .mts, .cts, .js, .mjs, .cjs outside ui/the service's Behavior role
Screens (UI) (ui)screens, components, styles, themes, scenarios and locales under ui/inputs::ui_role, checked first
Scenes (scene)*.scene.jsonthe service's Scene role
Other (other)everything else: argus.project.json, data .json, a glTF's .bin, fonts, text—

The checks run in the table's "decided by" order where two could claim a file: UI first, then prefab, then material, then the service's role. The choice is the person's, kept in layout.json as contentFilter (the keys, in the menu's order; see the layout below), so it survives a restart and follows the person to every project. Agents read it as editor.state's view.layout.contentFilter and set it with editor.set {layout: {contentFilter: [...]}} (/v1/do set-view), in the keys or the menu's words, singular or plural, any case (["Scripts", "materials"]); [] is every type, and a word that is no type refuses the whole call. project.files keeps its own role and prefix filters, the service's: the browser's types are the editor's reading of those, and a second classifier in the agent crate was not worth the duplicate.

Actions. Double-clicking a scene opens it, and double-clicking a model places it. Both are also buttons, and so are the rest, because a headless gate cannot press an item in a popup menu: content.place, content.open, content.ui.preview, content.ui.thumbnails, content.reveal, content.import, content.delete-unreferenced and content.refresh are always drawn and act on the selected tile — the names are the old ones and the labels are short, so that the row fits the narrowest window the editor promises without wrapping onto a line a short drawer would cut off. Right-clicking a tile gives the same items. A model tile is also a drag source carrying a content_browser::DragModel; the click path never depends on the drop, and d6_shell.rs drives a whole drag through the winit boundary.

ActionWhat it does
OpenUiAction::Scene(SceneAction::Open(…)) — the same path the File menu takes, unsaved-work prompt and all (see Scenes)
PlacePuts the model in the open scene at the origin, as one undo step. Refused while Play runs, while an unsaved-work prompt is waiting, and when the file has gone since the walk
RevealShows the file in the platform's file manager (export::reveal)
Import…Opens the Import modal. The copy still lands in the open scene's assets/, because an asset path is relative to its scene and may not climb out of it (asset-path-not-relative) — so the folder the person asked from cannot be where it goes. Refused, at the button and at the action, while Play runs or an unsaved-work prompt is waiting: it opens the operating system's file picker, and a second picker over a prompt is worse than a button that says why it cannot
GenerateOpens the Generate panel (content.generate). Disabled, with the reason and what to turn on as its hover text, while the project's assets.generation.enabled is not true (asset_generation::refusal)
Generate panelAn in-app window (generate.window, closable with generate.close) over the asset library CLI, one step at a time: prompt (generate.prompt), class (prop, scenery, vehicle, structure; no character; generate.class), Hero asset (vehicles always; generate.hero) and the project style read-only -> reuse first (generate.search; matches with thumbnail, score, provenance, licence, visibility, generate.match.<i>.use imports free, generate.new) -> with concept images on, drafts (generate.draft.<i>), Change only… (generate.change; no cap on rounds: "Round N" and the concept's running cost sit beside it, generate.round), Approve (generate.approve, records the OS user name; required for hero) -> 3D and the gate verdict per profile (generate.verdict.<profile>; each reject with the gate's own Next: fix text under it, generate.verdict.<profile>.reject.<i>[.next]; the answer's warnings, generate.warnings) -> Import (generate.import, offered only on a clean pass), then the Content Browser refreshes. The window opens at about 60% by 70% of the editor window, resizable and never larger than it. The verdict step shows a 3D preview (generate.preview.image, .prev, .next): the generated GLB drawn on the CPU from four turns (glb_preview, no window or GPU; the editor has no model-thumbnail path yet), else the gate's own thumbnail or views picture (generate.preview.source says which). Each step shows its own cost (generate.cost.step, generate.cost.concept) and the asset total, Asset total: US$1.30 (concept 0.20 + 3D 1.10) (generate.cost.total; the CLI's costUsd is the two together). Drafts: the highlighted draft is the approved one or none until the person picks (Approve and Apply change wait for a pick). After a gate refusal there is no Import: Re-check (free) (generate.recheck; regate {id}, shows the new verdict; "Re-check is not available in this build" on an unknown-command refusal, generate.recheck.unavailable), Use anyway (warnings) (generate.anyway, only for warnings without rejects, or a pass with warnings; one-line confirmation generate.anyway.confirm naming the warnings; import {id, project, acceptWarnings: true, by: <person>}), Change only... on the concept (generate.change-concept: back to the drafts with the approved draft selected, a concept image edit priced by estimate) and Retry with feedback (generate.retry, priced by estimate), each with its cost beside it. Every call runs off the UI thread with the step name, elapsed seconds, a spinner and Cancel (kills the child; generate.progress, generate.cancel). Where the project does not ask first, estimate is called before a paid step and "This step costs about US$X (summary)" shows while it runs (generate.estimate), with no dialog. Before a paid step the CLI's library-needs-approval (its estimateUsd, key, summary fields) shows "about US$X" with Confirm (approve, then the same call once; generate.confirm) or Cancel (nothing sent); library-spend-cap shows the cap, what is spent and this step's estimate (its fields) and no retry until the spend settings change. A failed step shows the CLI's message and Retry with feedback (generate.retry). Pending approvals (agents' paid steps) list with Approve / Decline (the request whose cost dialog is on screen is not listed a second time) (generate.approval.<key>.approve|.decline). With generation off the panel never calls the CLI
Library tabWindow > Library (library.tab) opens the Library tab of the bottom drawer: what the asset library already holds, browsable in every project with generation on or off (search, import and rate are free and never generate). A search field (library.search, library.search.go), a class filter (all, prop, scenery, vehicle, structure; library.class) and Project style only (library.style-only, on by default: the manifest's assets.style goes to search's style filter; off shows everything, with "not the project's style" on each off-style row). Rows (library.item.<i>) show thumbnail, name, class, mean score and count, triangles, provider and model, cost, licence, visibility and style. Selecting one opens its details (library.details): every human score with who, when and note, prompt, provider, model, task, date, cost, concept trail (chosen draft, approved by, edits), gate verdict per profile (library.verdict.<profile>), licence, visibility and content hash, read from the cache's records/<id>.json (the CLI has no verb for one full record). Import into project (library.import, free, offered on a clean pass; an asset the gate rejected shows its rejects with their Next: text and no Import, library.reject.<i>[.next]; one with warnings only offers Import anyway (accept warnings), library.import-anyway, behind a one-line confirmation naming them, library.import-anyway.confirm, which sends import {id, project, acceptWarnings: true, by}; Re-check (free), library.regate, runs regate {id} and reads the record again; a shared asset is fetched first; the Content Browser refreshes), Rate (library.rate: 0 to 10 library.rate.score, optional note library.rate.note, Send library.rate.send as the OS user name, the same name as the Generate panel's approval) and Generate something new (library.generate: opens the Generate panel with the search text as its prompt; disabled with the reason while generation is off). Every call runs off the UI thread; an empty library says so in one line (library.empty); a missing CLI shows its error (library.error)
Delete unusedEditor::delete_orphaned_models, the same rule as the inspector's: model files under the open scene's assets/ that no scene in the project names. It walks the project itself before it decides, because a destructive action never reads a cache
RefreshWalks the project again

Place, in three cases and a half. A model already in the project is not imported again. If the open scene has an asset entry for that file, a new entity simply names it and no second entry is written. If it has one imported from these exact bytes under another name, that entry is named too — an asset entry's path points at the copy and never at the source, so without this a second Place of the same library model wrote a byte-identical chair-2.glb and a second asset entry for one model (AssetImport.source_sha256 is the comparison, which is what the importer records and what a reimport already compares). If the file is under the scene's own folder, a new asset entry names it by its scene-relative path, with the bounds and triangle count measured from the file and no import block — nothing here declared its units, and the inspector says so rather than inventing them. Only a model elsewhere in the project is imported, which copies it in, because an asset path may not climb out of its scene's folder. All three are one undo step through Editor::commit_scene, and the entity is the import path's: at the origin, with a mesh component and the file's stem for a name.

The manifest is read again when its answer is about to be used. Editor::adopt_manifest_if_changed compares the manifest's length and modified time against what the editor last read and re-resolves the project when they differ; the Export panel calls it as it opens, and so does every frame the File menu is open. Before this the editor adopted the manifest only from its own Set as startup scene, so a manifest changed by the workspace service, by another window or by a git pull was never taken while the project stayed open — and the Export panel, which is the last thing a person reads before they ship, then named a scene the export would not pack. A manifest that has changed into one the project resolver refuses leaves the editor holding the last good one and says so on standard error: a bad edit elsewhere must not close the project out from under someone with unsaved work.

One walk, and only when something asks for it. The browser and File ▸ Open scene read the same walk: Editor::project_tree and Editor::scenes both come from one content_browser::scan, so a project with both open is walked once, not twice. Nothing polls. The walk happens again when the workspace service reports a file change, when the person presses Refresh, when the editor itself writes a file (an import, a new scene, Delete unreferenced), and when the File menu is opened or the Content tab comes into view with the walk older than two seconds. Before this, Editor::pump_service re-walked the whole project every two seconds on the frame thread for as long as the editor was open — a stat per file and a JSON parse of every scene but the open one, about 10 ms at 1,500 files and roughly 100 ms at 15,000, whether or not anything was looking.

A frame's work is what it draws. The walk builds two indices once: a folder → files map, so a folder's tiles are not a pass over the project, and a folder → subfolders map, so the tree is not quadratic in the number of folders. Each file's name is lowercased at scan time, so a search compares rather than allocating a string per file per frame, and tiles makes one pass, stopping one item past TILES_CAP. content_browser::State::scans counts the walks the browser has adopted and Editor::project_walks the walks themselves; tests/d6_content.rs asserts neither moves between frames that nothing changed, over a folder of 5,000 files.

Folders, and moving files

crate::content_folders for the controls, crate::relocate for the move.

VerbHowHit names
New Folderright-click a folder in the tree ▸ New Folder (inside it), the project's own row (at the root), or the space under the tiles (in the folder shown). Made on disk at once as NewFolder (NewFolder1, … beside one), its name open for typing: Enter or clicking away renames it, Escape keeps the namemenu.content.new-folder, content.folder-rename, content.empty
Renameright-click a folder ▸ Rename. A folder with files in it is a move of every file in it (below), and its subfolders, empty ones too, go with itmenu.content.rename
Deleteright-click a folder ▸ Delete. Only an empty folder is deleted; one with anything in it stays, and the status line says what is inside ("scenes/models is not empty: it holds crate.glb, … and 3 more")menu.content.delete
Movedrag tiles onto a folder in the tree. Ctrl+click picks several (drawn selected), and dragging one of them moves them all; any tile can be dragged, and a model's, a prefab's and a screen's still onto the viewport toocontent.tree.<folder>

The tree lists empty folders too (a walk only sees files, so scan adds the directories that hold none). A folder is a directory, not revision content: it is made and removed on disk, and the workspace service sees the files that go in it.

A move rewrites every reference to the files moved, or refuses and moves nothing. What names a file is what the contract and the loaders resolve, each against its own base: a scene's assets.*.path (relative to the scene's folder, never climbing out, asset-path-not-relative), its script.modules (the same folder's <module>.ts|.js) and each instance's prefab.source (project-relative); a prefab file's asset paths and modules (read from the open scene's folder, where its assets merge); a material's *Texture paths (relative to it, never climbing); a .gltf's buffer and image uris; quoted ./ and ../ specifiers and url(…) in behaviours, UI files and stylesheets (with or without the extension, .js naming a .ts); and anywhere in a JSON or text file (the manifest's startupScene, a data file), a string that is exactly a moved file's project path. A moved file's own references are rebased the same way. A reference that cannot be written from where the files end up (a model dragged out of the folder of a scene that uses it) refuses the whole move with the file and the field in the status line. The open scene itself cannot be moved.

The open scene is rebased, not edited. Its references are rewritten in the document, its saved baseline and every step of its history alike (Document::rebase): a move adds no undo step, is not an unsaved change, and no undo can bring back a path whose file is gone. Its file on disk is rewritten with the other referrers, and when the document was saved, that file is its saved baseline again, so the move raises no "changed on disk". As in the usual editor, a move is not on the undo stack.

One change set. With a workspace service (a human's editor, EditorOpts::human), the moves (put-file at the new path, delete-file at the old) and every rewritten file land as one change set under the editor's owner (Editor::land_files), which a revert of that change (the service's POST /v1/changes/<id>/undo, an agent's revert) takes back whole. Without one, the new files and rewritten referrers are written first and the old files removed last, so no reference is ever dangling. An agent moves files the same way with project.files and move, through a running editor's do: move-files when there is one.

The UI category and the viewport preview

A project's game UI lives under ui/ at the project root — ui/screens/**.tsx, ui/components/**.tsx, ui/styles/**.css, ui/theme/**.css, ui/scenarios/**.json — and the editor gives it a Content browser category, a preview in the viewport, and hot reload. crate::ui_screen, crate::ui_preview, crate::ui_reload.

What it previews is the real game UI runtime. The screen is described by esbuild, registered through argus::UiState::screen_from_json — which is ctx.ui.screen's own path — laid out by Taffy against the project's own cascade, and drawn by crates/render's UI pass into the same offscreen target the authoring view uses. The editor's own interface stays egui; nothing here reimplements a node, a style or a focus rule. §5 says that distinction is the whole point and must not erode, so the two overlays are the only things this preview computes for itself, and both are read straight off the laid-out tree the frame drew.

The UI category

Anything argus_workspace_service::inputs::ui_role classifies is one category, the Filters menu's Screens (UI) (content.filters.ui, its chip content.filter.ui). One rather than five, because a person looking for "the UI" is looking for all of it — and a screen must not be filed with the game's behaviours, which is where the service's own role_for would put a .tsx. A file under ui/ that is not one of the five roles, a ui/README.md say, is not a UI input: it is Other, and it fires no reload.

A screen's tile is a picture of the screen, in the theme the preview shows. It is described at its default scenario, the project's stylesheets are applied in the engine's own order and the theme settings.ui.defaultTheme names is applied by name — the same two calls UiPreview::load_styles makes, so a tile is never a picture of a theme the preview will not show. The world renders one frame at 1080p — §2.5's reference height, so the tile shows the layout the budgets are measured at rather than one that exists only at tile size — and the frame is read back and box-filtered to a fixed 256 px side into .argus/editor/ui-thumbnails/. The authoring world is left exactly as it was found: the screen is popped, the target put back and the theme the world was on restored, so pressing Thumbnails while a preview is up does not change the screen under the operator's eyes.

Nothing is rendered while a frame draws. Rendering a screen is esbuild, an evaluation and a GPU frame, which a draw call may not do. A tile with no thumbnail on disk asks for one through content_browser::Action::RenderThumbnail, at most one screen per frame and at most once per screen, so a folder of screens fills in over a few frames rather than costing an esbuild each in one. A failure is remembered with its reason, so a screen that will not compile is not rebuilt on every frame the drawer is open. Thumbnails (content.ui.thumbnails) forgets them all, which is what a person presses after editing a stylesheet that changed how they all look.

ActionWhat it does
Previewcontent.ui.preview, double-click, or the tile's context menu: shows the screen in the viewport
Set as the scene's start screenThe context menu, and dropping a screen tile on the viewport: writes meta.ui.startScreen — see below
Render thumbnailThe context menu, for one screen
Thumbnailscontent.ui.thumbnails: every screen again

Dropping a screen on the viewport sets the scene's start screen. §5 asks for the gesture and the scene contract now has the field: meta.ui.startScreen, which argus::World::load reads and pushes once the project's screens are registered. The name written is the screen's own — defineScreen({ name }) — and not the file's stem, because only the first is what the screen is registered under; writing the stem would produce a scene that validates, loads and then reports ui.screen.unknown for a screen sitting right there. So the file is described first, which is the same describe the preview and the tile already pay for and usually already cached. A second drop of the screen that is already the start screen says so rather than writing an undo step that changes nothing.

The browser says which screen it is. content.ui.start-screen names the open scene's start screen beside Preview and Thumbnails — "Start screen: main-menu", or "none" — and the screen's own tile carries ▶. The line is right the moment a project opens; the mark needs the file's declared name, which is knowable only by describing it, so it arrives with the tile's thumbnail and a file that has not been described carries no mark rather than a guessed one.

Describing a screen

esbuild, with packages/ui's own JSX flags — the list argus_host::esbuild_args() carries and crates/host/tests/tsx.rs holds it to — plus --bundle, because a project's screen imports @argus/ui and its own components and the engine's per-file transpile has nothing to resolve those against. The bundle is then evaluated, and describeScenario writes the frozen JSON of docs/UI.md §7.

The bundle carries no scenario. The request — which scenario, and any props from ui/scenarios/ — arrives as one JSON argument at evaluation time, so one bundle answers every scenario of one screen. That is what makes a ui/scenarios/** change cost an evaluation rather than a compile, which is the difference the three reload tiers are named for. A bundle is cached against the screen file's length and modification time and dropped whenever a screen or a component changes.

ui/scenarios/<stem>.json is an object of scenario name to props, matched to a screen by file stem — ui/screens/main-menu.tsx takes ui/scenarios/main-menu.json. A screen's own defineScreen({ scenarios }) is the default; a file here adds to it and overrides by name, which lets a project keep a screen's shipped scenarios and still preview it against the one awkward case without editing the screen.

Two tools, found the way the engine finds its own. esbuild through argus_host::find_esbuild (ARGUS_ESBUILD), and @argus/ui through ARGUS_UI_PACKAGE, then the project's tsconfig.json compilerOptions.paths, then node_modules/@argus/ui, packages/ui or sdk/packages/ui walking up from three roots in turn: the project, the editor's own executable, and the directory the editor was started in. The third root is what makes a checkout answer for a project made anywhere — argus run ~/projects/Course typed in the repository, and every test that creates a project under the target directory — where before the project had no @argus/ui, nothing described, and every screen became ui.screen.unknown at run time. The packed SDK carries packages/ui (scripts/sdk/pack.mjs stages the checker, Node, esbuild and the package at sdk/packages/ui, every file named in the SDK manifest and covered by its id), so an installed editor finds @argus/ui under its own sdk/ without ARGUS_UI_PACKAGE; a pack made before the package was added is refused as incomplete rather than used. Node is not one of the tools any more: the bundle is evaluated in process through argus_host::Host::eval_json, so opening a screen in an installed editor needs no Node installation. crates/host/tests/describe_screen.rs still compares the in-process description against Node's where a Node is present, which is the test that let the subprocess go away — and it spells every path for esbuild the way the describer does, so it runs on a Windows runner rather than tripping over \\?\C:\…. A missing tool is a reason shown on the tile and in the preview, not a silent blank.

The package's exports map becomes the aliases, one per entry. esbuild's --alias is a path substitution rather than a package resolution, so aliasing the package directory rewrites @argus/ui/components to <dir>/components, which is not where the file is — and a screen that imports the component library then fails to bundle. ui_screen::ui_package_aliases reads what the package declares ("." becomes @argus/ui, "./components" becomes @argus/ui/components) and emits an alias apiece, so the editor honours the package rather than guessing at its layout.

The preview in the viewport

ui-preview.*. A bar of pickers above the image, the overlays over it.

ControlWhat it does
ui-preview.closeBack to the authoring view; the screen comes off the stack
ui-preview.screenWhat is being previewed, by the name defineScreen gave it
ui-preview.scenario.<name>Describes the screen against that scenario's props
ui-preview.theme.<name>Re-resolves the cascade against that theme's tokens. No reparse: Parsed holds every theme's table. The chip marks the theme the preview is showing, which is never inherited — see below
ui-preview.scale.<n>The player's UI-scale setting (§2.12), 0.75 to 2
ui-preview.locale.none / .<locale>See below
ui-preview.resolution.1080p / .1440p / .2160p / .customThe size the offscreen target is made at
ui-preview.overlay.boundsEvery visible node's computed border box
ui-preview.overlay.focusThe focusable nodes numbered in Tab order
ui-preview.node.<id>Every control of the previewed screen, at the rectangle it is drawn at

The resolution picker owns the target while a preview is up. The offscreen target follows the viewport panel (crate::preview::follow_panel); a preview at 2160p in a 900-point panel must not, or "2160p" would mean "whatever this window happens to be". So follow_panel stands down while previewing and the panel scales the image to fit.

A theme, scale, resolution or overlay change is a re-resolve. docs/UI.md §9 says switching theme "is a re-resolve, not a reparse, so it is instant and touches no disk", and §2.8 says a frame that only restyles does not rebuild. UiPreview::sync compares what the pickers say against what it last applied: when nothing but those moved, it calls argus::UiState::set_theme — which resolves against the token tables the parser already built — and the project's stylesheets are not read, no bundle is built and the tree the runtime holds is the one it already had. Moving the screen or its scenario is what costs a describe. The locale is on the cheap side too, now that it goes through argus::UiState::set_locale rather than being resolved into the description.

The preview loads the theme layer in the engine's order, and always applies a theme explicitly. The default theme is the first @theme block the parser meets, so which file is read first decides it. The engine reads ui/theme/tokens.css, then the file settings.ui.defaultTheme names in argus.project.json, then the rest of ui/theme/** sorted — argus::theme_layer_order — and ui_screen::theme_layer_order is a call to that and nothing else. UiPreview::load_styles then calls set_theme by name rather than inheriting whatever the parser decided, so the chip always has an answer. Only the theme layer is reordered, in the engine as in the preview: @tokens and @theme blocks are token tables, where order decides which table a name resolves against, and ordinary rules are a cascade, where moving one would change what a screen looks like.

There is one order, so there is nothing to warn about. The preview used to reorder the theme layer for itself and report the divergence in the feed as ui.theme.load-order, because the engine sorted ui/theme/** by path and game.css sorts before sim.css. That was a preview deliberately showing a different theme from the game and saying so. The engine orders now, the preview reads the same function, and what you see is what the game draws. A project whose default is not the one it wanted names the file: the design system's gallery names ui/theme/sim.css, which is what puts its layer in docs/UI.md §10's order. crates/editor/src/ui_screen.rs's THEME_LAYER still mirrors packages/ui/src/tokens.ts as the documentation of that layer, and tests/ui_editor.rs reads the TypeScript and holds the Rust to it, the way crates/host/tests/tsx.rs does for the esbuild flags.

The overlays cannot disagree with the picture. The boxes are LayoutBox::painted — where a node is drawn, clicked and navigated to, transforms already resolved — and the order is argus_ui::tab_order, the function the runtime's own Tab key calls. Both are read off the same laid-out tree the frame drew. A disabled button keeps its place in the tree and loses it in the order, because the runtime refuses to focus one.

A scripted human can press a real game control. ui-preview.node.<id> is published for every focusable node whether or not the numbers are switched on, and Shell::on_mouse_button turns a press there into a UiEvent for the runtime's own hit-test rather than into an entity pick. The event is handed to UiState::handle rather than queued: World::queue_ui_event parks it for drive_ui_input, which runs inside World::step, and the authoring world is never stepped.

The locale picker moves the runtime's own locale. A project keeps its translations in ui/locales/<locale>.json, a flat map of key to string, which the scene's meta.strings knows nothing about. ui_screen::read_locales folds every file into the shape argus::UiState::set_locale_overlay takes — "<tag>.<key>" -> text per translation, plus "<key>" -> key per key so the base locale draws the key itself — and UiPreview::load_locales installs it. The overlay is merged over the scene's table and survives the per-frame sync_hud, so UiState::locales lists the project's languages and UiState::set_locale accepts them. Picking one is then the same call POST /ui/locale makes and the same one a game makes.

So a language switch is a re-resolve, like a theme switch. The description is registered with its textKeys intact, the runtime holds the keys, and moving the picker re-resolves them in place: no describe, no re-register, no tree rebuild. tests/ui_editor.rs asserts all three counters hold across the switch. This used to be the one picker where §5's "what it previews is the real game UI runtime" was untrue — the preview rewrote every key into a literal before registering, which left the runtime nothing to re-resolve and made a language change cost a full re-register.

With keys chosen the preview shows the keys, which is what the game draws with no table loaded; that is the base locale plus the key-to-key entries, not a second code path. A key no locale file mentions renders as [[key]], §2.11's convention for a key with no string, so an untranslated label is visible rather than indistinguishable from a translated one. A locale file that will not read or parse is a ui.locale.unreadable diagnostic naming it, in the feed with the rest; the locales that did read stay in the picker.

ui/locales/** has no UiRole, so a translation edit on its own fires no hot reload. The tables are re-read on any sync that describes, and on the cheapest reload tier (UiReload::Scenarios), which covers the two folders a person edits without touching a screen.

Hot reload

crate::ui_reload. The paths one change set carried go to argus_workspace_service::inputs::ui_reload_for, which answers with the strongest reload they require — Screens beats Styles beats Scenarios — and the editor does exactly that. The ranking is not decided twice.

ReloadWhat happensWhat survives
Styles (ui/theme/**, ui/styles/**)The stylesheets are reparsed and the sheet swappedFocus, scroll position, an open dialog, every binding. §5 states this as a requirement, and it holds because swapping the sheet is all that happens: no screen is re-registered, so no tree is rebuilt
Screens (ui/screens/**, ui/components/**)The bundles are dropped, the screen is compiled again and re-registeredHover, press and focus by id. State only while the props shape is unchanged
Scenarios (ui/scenarios/**)The mock data is re-read and the picker refreshedEverything: nothing is recompiled and no sheet is reparsed

It rides the polling path, not a watcher. crates/workspace-service/src/external.rs:4-7 records that Windows delivers no events for files on the WSL path and inotify inside WSL does not see writes made from Windows to /mnt/c, so the service polls and journals what it found. The editor reads those paths off the journal into its feed (FeedStore::take_ui_changes) and hands them here, once per change set. §5 is explicit that UI hot reload has to ride that same path "or it will silently fail to fire on the user's own machine".

A failed reload keeps the working screen. A stylesheet with a refused declaration, or a screen that stops compiling, leaves what is on screen where it is and reports the reason — with the file, the line, the column and the nearest supported alternative that §2.2 requires (ui_screen::UiDiagnostic). A blank viewport is worse than a slightly stale one.

Every diagnostic and every reload reaches the activity feed. §5 asks for the diagnostics to be there "with file and line and click-to-source, exactly as script errors do", and a script error is a row with a file link — so a UI diagnostic is the same row. Editor::report_ui_diagnostics runs once a frame and writes one per stylesheet refusal, screen that would not compile, described-screen warning, refused binding and runtime diagnostic; the file and line become the link a person presses. Editor::reload_ui writes what the reload did, in Reloaded::label's words, with the files it touched as links.

The diagnostic list is the last set, not a log. UiPreview holds what the last parse and the last describe raised and nothing older: a reload replaces the set, report_ui_diagnostics drains it into the feed and hands it back marked, and nothing is written to the feed twice. Six reloads of one broken stylesheet therefore leave six feed rows and one list, where the list used to gain a copy per reload for as long as the editor was open.

A changed props shape is announced. §5 promises state is kept when the props shape is unchanged and says nothing about the other case, where a screen gains a required prop that no scenario supplies and JavaScript interpolates undefined into a sentence the player reads. The description is read before it is registered, the three spellings a missing value has — undefined, NaN and [object Object] — become [[…]] holes in the docs/UI.md §2.11 convention, and each raises a ui.screen.props diagnostic naming the node, the scenario and the text. A whole word only: a literal with "undefined" inside a longer word is left alone.

Screens in Play

The engine registers a project's screens itself now. World::load_ui_screens describes every ui/screens/** through argus_host::screen::Describer — the same describer the preview and the tile use, so what Play registers and what the editor draws are one description — and registers each through UiState::screen_from_json, which is ctx.ui.screen's own path. A behaviour can then ctx.ui.push("main-menu") by name in a Play session, in argus run, and in an exported build; this used to be the editor standing in for a hook the engine did not have, and it is not any more. meta.ui.startScreen goes up straight after, before wire_all, so a behaviour reading ctx.ui.stack() in init sees what the scene asked for rather than racing an empty stack.

What a screen that cannot be described costs is a named error, not silence. A machine with no esbuild or no @argus/ui records one ui.screen.tools error rather than one per screen, a screen that will not compile records ui.screen.compile naming the file, and a meta.ui.startScreen nothing answers to records ui.screen.unknown naming the screens that did register. All of them are ScriptErrors, which argus run prints, a test asserts on and GET /events carries — and fixtures/starter-course's course.ts catches ui.screen.unknown and carries on with the HUD either way. Finding @argus/ui is therefore the difference between a project's screens existing and not; see "Describing a screen" above for the three roots it is looked for under.

The Collection course's screens

fixtures/starter-course/ui/ — and the same files in the bundled template, byte for byte, which tests/template.rs checks the way it checks the scene. main-menu.tsx (start, restart, exit) and results.tsx (pass or fail, the score and the time), a sim theme binding the eleven reserved semantic names, two stylesheets in the documented CSS subset, named mock data, and English and French locale tables. course.ts puts the menu up at the start of a run and pushes the results screen when the player leaves by the exit, through ctx.ui and by name; it never imports a screen and never walks one.

fixtures/walkthroughs/ui-preview.txt drives the whole preview on the desktop, and tests/ui_editor.rs runs the same file headless through the same Shell calls.

The Output Log

The usual editor's Output Log. Window ▸ Output Log (menu.window.log) opens the Output Log tab of the bottom drawer (drawer.tab.log, also log.tab), and the status bar's Log button (status.drawer.log), as the usual editor's bar has one. crate::output_log.

What is in it. One line per message, each with a time (UTC, 12:03:04.123), a category, a severity (verbose, info, warning, error), the text, and an entity and a file when it names them:

categorywhat writes it
editorevery message the editor wrote to stderr (or --log-file) before saves, imports, opening and stopping Play, refusals, an action that failed. Written through log_line!, which is eprintln! plus the log, so stderr and the log file keep every word they had
enginePlay starting (what it runs and from where), and the UI diagnostics the preview and a running game raise (ui.css.*, ui.describe.*, … with the file and line)
servicestarting, reaching and stopping the workspace service, and what it refused
buildthe service's journal: each build going Ready, Failed (with its reason) or Superseded, and each diagnostic a build recorded, with its location as the file and line and its entity
scripta running game's behaviours, read from the Play world after each of its frames. Script errors (World::errors): the entity, the module, the phase, the thrown message, whether the behaviour was disabled; the entity and the behaviour's file as links, and for a .js behaviour the line the stack names. ctx.log lines (info, with the data argument) and script warnings (null-handle, with the module's file) from World::script_output, the newest 512 the engine keeps; ctx.log's severity maps onto the line's, so console.warn/console.error show as warning/error once the engine has them
criticcheck results (check <id> passed/failed: summary) and the diagnostics a check recorded

Journal history from before the editor started is left out: the log is this session's, and the Activity feed is the project's history.

Controls. A chip per severity (log.severity.<sev>, errors and warnings with their counts) and per category (log.category.<cat>), a search box (log.search, case-blind over the text, the entity and the file), Clear (log.clear) and Follow (log.follow). The view follows the newest line; scrolling up pauses it, and scrolling back to the end or pressing Follow resumes it. Rows are log.row.<id>; a line's entity is log.row.<id>.entity, which selects it as the Outliner does, and its file is log.row.<id>.file, which switches the drawer to Content with the file's folder open and the file selected.

Bounded, and cheap with a full buffer. The log keeps 10,000 lines (output_log::CAP) of at most 2,048 bytes each; the oldest go first. It is process-wide, as stderr is, so it outlives a project closed and another opened. The tab keeps the ids that pass its filter and adds only the lines that arrived since each frame, rebuilding them only when the filter changes or the log is cleared, and draws only the rows on screen (ScrollArea::show_rows). tests/d18_log.rs measures it with the buffer full.

Parity. An agent reads the same lines through the same filter: POST /v1/log on the control endpoint, which an agent reads as editor.state {log} (AGENT.md), with after as a cursor. shown: true reads through the filter the person has on. Clear clears the stream for both; ids never repeat, so an agent's cursor stays good across it. GET /v1/state carries log: {last, lines, errors, warnings}.

Not in it (yet). Scripts have no console global: a behaviour calling console.log throws a ReferenceError, which does reach the log as a script error. A .ts behaviour's error has no line link, because it runs as esbuild's output with no source map and the stack's line is the compiled file's. The engine's own argus: stderr lines (a navmesh rebuilt, a reload) are not captured.

The Undo History

The usual editor's Edit ▸ Undo History. Window ▸ Undo History (menu.window.history) opens the Undo History tab of the bottom drawer (drawer.tab.history, also history.tab). It has no status-bar button: the bar is full at 150 %, and The usual editor's has none either. crate::history_ui.

What it lists. Every step the shown document's undo and redo hold (up to UNDO_CAP each) and every change another writer landed that the editor folded in (Document::apply_external, up to UNDO_CAP), newest at the top, in the order they happened (Document::history). Each row is the time (UTC, as the Output Log's), what the step did in words, and who made it: You for the person at this editor, an agent by the display name presence has for it (else its own name, else its id; the id is in the tooltip), the workspace service, or a change from outside the editor. The current position is the newest step that is done, marked with › on a highlighted row; the steps after it, the redo side, are greyed. Below them all is Start of history, before every step.

EditRow
a move, a drag or typed transform, over one or manyMove Crate, Move 12 entities
renameRename Goblin to Grunt
duplicate, delete, cut, pasteDuplicate 2 entities, Delete Crate, Paste 3 entities
Details property rows, over one or manyEdit speed on Goblin, Edit speed on 40 entities
foldersCreate folder Monsters, Move Goblin to folder Monsters, Rename folder A to B, Delete folder A
prefabsCreate prefab Crate, Place prefab Crate, Apply prefab Crate, Revert prefab overrides on Crate
import, place, discard, restore autosaveImport tree.glb, Place tree.glb, Discard changes, Restore autosave
an agent's scene through the editorits own label ("Move box-b") under its name
another writer's change folded inRename Orc to Brute, Add Crate, Delete Crate, Edit Crate, Edit 3 entities, Edit scene settings

External changes are not undone from here. Undo belongs to the person (see Editing the scene): an agent's change folded in from the journal never enters the undo stack, and nothing the person undoes or redoes takes it back. The panel still lists it where it happened, so a person reads an agent's work in order with their own, marked · external with the reason in its tooltip ("Undo and Redo leave it as it is; revert it from the Activity feed").

Jump. Clicking a row undoes or redoes, one step at a time, until the history stands just after that row (Editor::history_jump, UiAction::HistoryJump): one click, several steps, as the usual editor's does. A click on an external change lands just after it (the person's steps before it done, those after it undone); Start of history undoes everything listed. Each step is the plain Undo or Redo, refused on the same terms — another writer's later change to what it touched, or a prefab file changed since Apply — and the first refusal stops the jump there: what was already undone stays undone, and the panel's top line (history.note, whole on hover) and the toolbar say how far it got and why ("the jump back stopped after 2 of 3 steps: cannot undo "move #goblin": #entity/goblin was changed by agent Layout since …"), until the next jump. A new edit ends the redo line, as it always has.

Names drawn. history.rows (what of the list can be pressed), history.row.<index> for each row on screen (history.row.0 for the start) and history.note. The rows are drawn with ScrollArea::show_rows, so only those on screen are published. Five drawer tabs do not fit the strip at 200 % of 1280x720 (the drawer under the viewport is about 370 points): there the strip uses the status bar's short names (DrawerTab::bar_label), and below the promised window size it scrolls sideways rather than running under Hide.

Parity. editor.state answers history: {current, total, steps}, the newest 50 (history_ui::DEFAULT_LIMIT) first, each {index, label, actor: {kind, id, name}, at, undoable} plus redo: true on the greyed side and why on an external change; editor.state {history: N} reads N (POST /v1/history {"limit": N}), and GET /v1/state carries the same history. An agent jumps with editor.set {history: N}, which is /v1/do {"do":"history-jump","index":N} and answers moved; a stopped jump is the refusal, with the same words. index counts from 1 per document and is never reused, so the index an agent read still names that step after more are added; Reload and opening another scene start a new history. See AGENT.md.

The Profiler

Window > Profiler (menu.window.profiler, also profiler.tab) opens the Profiler tab of the bottom drawer (drawer.tab.profiler; "Prof" on the crowded status bar and the compact strip). It reads the flight recorder: the last few seconds of timed spans that the running game records into a fixed ring at a cost of a few microseconds a frame. Nothing is recorded, saved or sent by looking at the tab; while it is open and a game runs it copies the newest 240 frames five times a second.

  • The headline (profiler.bound): what the frames are bound by (CPU script, CPU physics, CPU render, GPU with the pass, or paced by vsync), the frame cost and its p95, and the top finding of the analyser (GET /profile/explain's first). With nothing running it says to play.
  • The frame-time strip (profiler.strip): a column for each frame, height by cost on a linear scale that follows the 99th-percentile frame of the window (never less than 25 ms), with the 16.7 ms budget line drawn over the bars (and 33 ms when it fits), coloured by what dominated it (script, physics, render, GPU). An ordinary frame sits well under the budget line and a hitch towers over it; a bar taller than the scale is cut off at the top with a white cap. A frame far above its neighbours is a spike: a red mark under the cap, and its own profiler.strip.spike.<frame> control. Pressing the strip, or a spike, selects that frame and freezes the strip so the frame stays under the pointer; Follow (profiler.follow) puts the newest back. The rule is the same one that saves a capture on its own: over 2.5 times the median frame and over it by 2 ms, or over 33 ms.
  • The timeline (profiler.timeline, mode profiler.mode.timeline): the selected frame's spans on one clock, a row per depth on each track: Main (the frame and tick, the phases, each behaviour call), worker threads, GPU (one span per pass, when the run has GPU timestamps), present and load. Hover names a bar, its length and, for a behaviour, its entity; pressing a behaviour bar selects its entity in the viewport and Outliner. profiler.bar.<span name> is the widest bar of each name.
  • The flame graph (mode profiler.mode.flame): the same spans merged by path over the frames shown, the widest child first; profiler.flame.<path>.
  • Capture now (profiler.capture, while playing): saves the ring as a capture under .argus/profile/. The editor also saves one on its own when a frame spikes, and the status bar shows Spikes N (status.profiler) while a game runs and has hitched; pressing it opens this tab. The newest 8 captures are kept.
  • Source (profiler.source) picks the live ring or a saved capture (profiler.source.pick.<id>), so a spike can be read after the game stopped. Compare with... (profiler.compare, items profiler.compare.pick.<id>) draws the shown window against a capture: a before strip and an after strip, the frame cost change with its noise band, and the spans that changed significantly (profiler.diff.row.<path>), green for faster and red for slower.
  • Heat (profiler.heat, while playing) tints each entity's bounds in the viewport by what it costs, one metric at a time: Script (ms per tick), Triangles or Contacts (profiler.heat.metric.<name>), cold blue to hot yellow, entities that cost nothing untinted. Surfaces (profiler.heat.surfaces) also paints the meshes themselves in the standard renderer's Cost view (every surface its heat colour, the rest dark grey); the HD renderer does not read the view-mode flag, so there the boxes are the whole map. The map turns the draw counters (POST /profile/detail) on while it shows and off when it closes.
  • Play records its input. Every Play that starts from the authored scene writes session-<id>.replay beside the captures (the newest four are kept), so a capture has a replay recipe: profile.diff (or POST /profile/rerun) can replay that run against the project as it is now and check a fix like for like. A Play restored from a snapshot cannot be replayed from load; its captures say so.
  • F3 in the game window shows two more rows under the stats when the recorder is on: what the last frames are bound by with the top finding, and the last 48 frames as a strip of characters (. cheap to @ costly, ! a spike).

The pictures are laid out by argus_profile::layout, and drawn a second time, with no window and no GPU, by argus_profile::raster for the query API's GET /profile/image?kind=strip|timeline|flame|heat and the profile.view tool; a test puts the editor's bar list beside the rasteriser's. Files: crates/editor/src/profiler.rs, crates/profile/src/layout.rs, crates/profile/src/raster.rs, crates/app/src/profile_image.rs; tests crates/editor/tests/p7_profiler.rs, crates/app/tests/profile_image.rs.

The Input table

Window ▸ Input edits the open scene's action map, meta.input (docs/INPUT.md), as a table (crates/editor/src/input_ui.rs).

  • Contexts down the left. The radio marks the one live when the scene loads (active); Rename (or a double-click) renames one in place, and Enter keeps the name; × deletes one with its actions; the field below adds one. Click a context to show its actions.
  • Actions of that context on the right, one row each: name, kind (button, axis1, axis2), keys, gamepad, and the kind's options (toggle on a button; mouse, deadzone and sensitivity are shown and edited in JSON). The row below adds an action by name and kind; × removes one; Rename (or a double-click on the name) renames one in place with its bindings, Enter keeps the name, and a name the context already has is refused.
  • Keys. Click a key and press the new one, as Editor Preferences ▸ Keyboard Shortcuts does: while the slot waits, no shortcut runs and the game hears nothing, and Escape on its own gives up. Modifiers are keys here (a game binds LShift). An axis takes its keys a set at a time, shown with | between sets: four for axis2 (up, left, down, right, the tooltip says which), two for axis1 (negative, positive). "+ set of 4" waits for four presses and adds them as one change; × beside a set removes it. A slot waiting for a key also takes a typed name (for Escape, which the press cancels on), in any spelling the key list answers to; it is stored in the engine's.
  • Gamepad is a dropdown of every source the contract takes (GamepadBinding), or none.
  • Shared keys. Two actions of one context on one key or one pad source are allowed, as the engine allows them, and each is named above the table ("Shared: LShift drives dash and sprint") with the key drawn in the warning colour.
  • Refused. A typed name that is not a key, or a pressed key the engine has no name for (Home, a numpad key), is refused in the contract's own words and nothing changes: input-key-unknown: "Back" on gameplay.jump is not a key name, so it binds nothing; it is a pad button: write "gamepad": "select". A change the contract refuses for another reason is refused the same way, with its message.
  • Undo and save. Every change is one undo step through the scene edit path (it is in the Undo History, Ctrl+Z takes it back), and Save writes it. The table shows the map the scene plays: its own actions over the project's (Project Settings › Input), or the built-in map when the project has none. Over a project map a change writes only what it changes — the one action, keyed by context and action name, and active if that changed (input_ui::scene_delta) — so a later change to any other project action still reaches the scene. A scene cannot take away a context or an action the project has (the merge would add it back): removing or renaming one is refused and says so. Without a project map the first change copies the built-in map in, as before. The table is read-only during Play: Play runs the saved scene.

Two maps. The table edits an input map at a JSON pointer (input_ui::InputTarget): the open scene's /meta/input, or the project's default map, /input in argus.project.json, which Project Settings ▸ Input draws. Where each is read and written is an input_ui::Maps: the editor for the scene's (one undo step each), the settings page for the project's (typed, sent with Apply). The edits, key checks, capture and drawing (input_ui::table) are shared. Window ▸ Input is not drawn over Project Settings, whose table has the same names.

Parity. An agent edits the same map with scene.patch on /meta/input; no tool of its own. Each control is named input.* in editor.controls (input.action.jump.key.0, input.action.jump.gamepad.north, input.context.menu.rename), so editor.click and editor.key drive the table as a person does.

Exporting a game

Export… on the toolbar (toolbar.export) opens the Export panel: one branded executable a person with no Talos can double-click. The installed editor has no cargo, no Node and no checkout, so nothing here builds anything — the two binaries an export is composed from are installed beside the editor, and the composition itself is crates/export running in this process (docs/EXPORT.md).

What is exported is a revision, not what is on screen. The workspace service turns each save into a revision and builds it; the panel exports the last build it found Ready, and says so by name. Two directories are read, both the service's: the revision's read-only snapshot (.argus/snapshots/<revisionId>/) for the manifest, scene, icon and assets, and the build job's output (.argus/jobs/<jobId>/out/modules/) for the behaviours that build transpiled. Neither is the authored tree, so nothing typed since the build can reach the executable, and "this file is exactly revision X" holds by construction. Nothing is transpiled at export time: bytes no build ever checked do not go into a game.

With no Ready build, Export is refused in the build view's own words — "Failed — scenes/jump.ts:5:19", "No workspace service: builds are not checked here" — rather than in a second vocabulary for the same fact (BuildsView::export_blocked).

What is left out is said, not implied (export.excluded.<i>). Unsaved changes to the scene get a line naming the file and the revision that is being exported; a build still running for a newer revision gets a line saying which build this is instead; an edit the service is still waiting to settle gets a line naming the files; and files changed outside the editor that are waiting for an explicit Apply get a line naming them and sending the person to the feed, where the Apply is (Activity feed).

Branding is shown and never edited here (export.branding.*): display name, executable name, version, build number and icon, read from Project Settings through the same SettingsBackend the settings section edits, with Edit in Project Settings (export.settings) closing the panel and bringing that section into view. Two places to change one name is one place too many.

The destination is a folder (export.destination, with Browse… where the editor links a picker — Windows), defaulting to dist/ beside the project. The executable is named from the manifest's executableName. A folder that is not there yet is created by the export; a path that is a file is refused.

The job runs on a thread and reports through a channel, as the service link does: the panel drains it each frame, so nothing about an export that reads every asset and hashes a whole executable happens on the UI thread. Progress is by stage (export.stage, export.progress) — checking the inputs, verifying the launcher, reading the engine, collecting files, writing the icon and version, building the payload, composing, verifying, putting it in place — with a file count and the file being worked on inside the long one. Cancel (export.cancel) sets a flag the composer checks between stages and inside the asset loop; the panel goes on saying "Cancelling…" until the composer answers, because saying "cancelled" while a thread is still writing would be untrue.

On success the panel names the file, its SHA-256 and its payload id (export.done, export.sha256, export.payload-id), and offers Open Output (export.open-output), which shows the executable in the file manager and never runs it. The payload id hashes the revision, artifact and build ids along with the content, so the executable reports which build it is out of its own bytes.

On failure the panel shows the stable code and the message (export.error: binary-mismatch, size-gate, verify-failed, … — the codes docs/EXPORT.md lists), and states that a previous export at that path was not touched (export.untouched). That is a fact about the mechanism, not a hope: the executable is composed under a temporary name beside the destination, read back and verified, and renamed over the destination only then.

The two binaries. The deploy engine (argus-deploy-engine.exe) and the launcher template (argus-launcher.exe) live beside argus-editor.exe, where scripts\windows\install-editor.ps1 -Deploy … -Launcher … puts them, each with its SHA-256 recorded beside it as <name>.sha256. The editor reads the launcher and checks it against that recorded hash by its bytes before composing, so a swapped or corrupted launcher is caught rather than shipped. Neither missing binary is built on demand: the panel says which file is missing and which flag installs it.

Restart state

<project>/.argus/editor/state.json, schema version 1, written atomically (write beside, rename over; on Windows a replace refused with Access is denied is retried up to 40 times 25 ms apart, about a second, before it fails) 250 ms after the last authoring change, on every Play snapshot save, and on close:

FieldMeaning
schemaVersion1
projectIdthe manifest's projectId when written; a state file from another project is ignored, with a note
cameratarget, yaw, pitch, distance, fovYDegrees, speed, and projection and orthoHeight; a camera outside the editor's limits is ignored
selectionstable entity ids; ids the scene no longer has are dropped, with a note
viewportthe viewport's mode (lit, unlit, wireframe), show flags and gameView (Viewport modes); absent reads as the defaults. The camera's projection, orthoHeight and speed are in camera
gizmothe transform gizmo's tool, space, pivot, gridSnap, grid, angleSnap, angle, scaleSnap, scale (Transform gizmos); absent reads as the defaults, and values outside the limits are ignored, with a note
viewsby scene path, the hidden, isolated and locked entity ids of that scene's authoring view; left out when nothing is hidden or locked. An editor from before this change refuses a file that has it (unknown field) and opens with defaults, with a note
lastArtifactthe artifact the authoring view showed; reopened if it still matches the sources, otherwise a new one is prepared, with a note
lastSnapshotthe last Play snapshot saved on purpose, project-relative
savedAtMsmilliseconds since the Unix epoch

An unreadable or foreign state file never stops the editor opening: it opens with defaults and Editor::reopened().notes says what was ignored and why. Play state is never restored implicitly: Play always starts fresh, or from a snapshot the human chooses (F6). There is no player save system.

Also under .argus/editor/: artifacts/<artifactId>/ (artifact.json, inputs.json, scene.json), snapshots/, captures/, workers/<sessionId>/, and sessions.json, the session registry when no workspace service is the authority (see below). When the editor opens without a service, any session a previous editor process left non-terminal — a crash mid-Play, a killed editor — is marked interrupted ("the editor process that owned it ended") rather than left running.

The workspace service

Placement

The editor runs on the operating system that owns the project's files, as the workspace service does. OpenProject::open asks the service crate's rule (argus_workspace_service::placement::placement, which decides by file-system identity, not by spelling) before it reads or writes anything, and refuses a project on the other OS's file system (wrong-os-for-project) or one whose file system it cannot identify (placement-unverified). argus-editor <project> opens the project before its window, so a refused project shows no window and writes nothing under .argus/. A project under WSL is edited by an editor running in WSL; a Windows-native editor refuses \\wsl.localhost\…, and a Linux editor refuses a project on /mnt/c.

The service on the desktop

A developer runs argus-workspace-service serve --project <dir> in a terminal. Someone who opens the editor from a desktop shortcut has no terminal and no reason to know the service exists, so the editor starts one itself (crate::supervise::Supervisor). Without it there is no activity feed, no jobs panel, no build states and no session authority — the half of the editor that managed sessions are about.

When. Opening a project, before Editor::open, so the editor's own open-time discovery finds it and takes its journal as the session authority for the run (Session authority). Closing the project stops it: returning to the launcher stops it and opening the next project starts one for that root. The order is deliberate — the editor closes first, because closing posts the run's last session records to the service.

What it starts. argus-workspace-service serve --project <root> --no-selection, as a child process, on an ephemeral port. The build pipeline runs (never --no-build): Ready is the service's Ready, which is what the toolbar shows. --no-selection keeps a service the editor spawned from changing the person's remembered project outside the project folder; the editor has its own recent-projects list. On Windows the child is started with CREATE_NO_WINDOW through worker::no_console, so it can open no console window and take no foreground.

Which binary. $ARGUS_WORKSPACE_SERVICE when it is set, otherwise beside the editor's own executable: the multi-call argus[.exe], run as argus workspace-service serve … (an install has one, and its argus-workspace-service[.exe] is only a small forwarding stand-in, docs/INSTALL.md "One executable for the tools"), or a real argus-workspace-service[.exe] when that is what is there (a checkout's target folder) — which is where scripts\windows\install-editor.ps1 -Service <path> puts it, with its SHA-256 recorded beside it as the editor's is. With neither, the editor says so, naming both places, and runs without a service. The installer warns when it has none to bundle.

The log. The service's standard output and error are appended to <project>\.argus\editor\service.log, moved aside to .log.1 past 8 MB, as the editor's own log is. When that file cannot be opened — a read-only .argus\editor, for instance — the service still starts, its output is discarded, and the editor says which file and why.

It never touches a service it did not start. If one already answers for the project — the CLI, CI, another editor — the editor uses it, starts nothing, and leaves it running at close. Two editors on one project are therefore the second adopting the first's service. The discovery file is removed on stop only while it still names the instance the editor started.

Stopping. The service has no shutdown request: it stops on Ctrl-C or SIGTERM. So the editor sends SIGTERM and waits up to five seconds for the service to close its workspace, then terminates it. On Windows there is no SIGTERM, and a windowless child shares no console to be sent Ctrl-C on, so the process is terminated and the editor removes the discovery file the service had no chance to remove itself. The service takes no parent pipe, unlike a test worker: an editor that is killed outright leaves its service running, and the next editor adopts it.

A WSL project opened from Windows. Refused, by the same placement rule as everything else (Placement): a Windows-native process cannot be the writer for a project on the WSL file system, so it does not start a service for one either. In practice argus-editor.exe <\\wsl.localhost\…> never gets this far — the project itself is refused before the window. Where a project opens but placement will not have this process serve it, the editor opens with no service and says which refusal it was (wrong-os-for-project, placement-unverified). Run the editor, and its service, on the operating system that owns the files.

Opting out. --no-service on the command line, or ARGUS_NO_SERVICE=1 in the environment. Either way the editor does exactly what it did before it follows a service that is already running and starts none. A refusal is never fatal — the editor opens, and the reason goes to its messages (stderr, or --log-file), not to the launcher's error line, which belongs to a project that did not open.

Content roots

Play and test workers load a revision's read-only snapshot (<project>/.argus/snapshots/rev_<hex>/) and the build job's compiled output (<project>/.argus/jobs/<jobId>/out/modules/), never the authored tree: the workspace service applies a multi-file change set as an intent entry followed by writes, so the authored files can be briefly mixed on disk, and a behaviour read from there is bytes no check ever saw.

Where those two directories come from is the service's answer, not a guess. GET /v1/builds/{revision}/content-root re-materialises the revision's snapshot from the object store and hashes every file in it against the revision's own inputs before it answers; only then does it say verified: true, and only a selection carrying that is loaded. A path assembled from a revision id is a guess about what is on disk, and a read-only flag is not an integrity boundary. GET /v1/builds's own contentRoot field is not that answer and the editor does not load from it.

crate::content::ContentRoots remains the authoring preview's seam — "the content root for revision X" — with AuthoredTree as its implementation: the artifact's frozen scene copy, with behaviours and assets from the authored tree behind the artifact's stale check. Standalone argus run <scene> keeps its file watcher and reload, and resolves script.module beside the scene as it always has.

crate::service::ServiceLink is a background thread. It finds the project's service through .argus/service.json and GET /v1/instance (argus_workspace_service::discovery::resolve: the pid check on the service's own OS, then the instance handshake; never a guessed port) and follows it:

  • GET /v1/log?after=<cursor>&limit=500, every 250 ms, up to eight pages a round;
  • GET /v1/status, every second;
  • GET /v1/evidence/{sha256} when a screenshot is opened (at most 32 MiB held).

Everything it reads reaches the editor through a bounded channel (256 events), and each editor frame folds in at most 64 (EVENTS_PER_FRAME), so a busy service never stalls a frame. The cursor survives reconnects: it is the last sequence number read, and after a dropped connection or a restarted service reading resumes after it, so no entry is shown twice and none is skipped silently. The first connection starts at the newest 2,000 entries. While no service answers, the link retries every second and reports each distinct reason once.

Session authority

Whether a service answers as the editor opens decides who keeps the session registry for the whole run.

A service answered: its journal is the authority.

  • Every record change is posted to POST /v1/sessions?source=argus-editor, one lifecycle step at a time, with the serving engine's instance from its ready line (nonce, pid, endpoints): the Play runtime's for a human-play session, the worker's for a test session.
  • Each live session is heartbeated every five seconds (POST /v1/sessions/{id}/heartbeat).
  • The editor writes no sessions.json and does no interrupted-recovery of its own: the service recovers sessions a dead editor left.
  • While the service is unreachable, records wait in bounded queues (1,024 in the registry, 256 in the link) and go in order once it answers; anything dropped from a full queue is counted (ServiceState::dropped_records).
  • A record the service refuses (wrong-project, illegal-transition, terminal-session, engine-replaced, revision-not-runnable) is reported (ServiceState::refused, the feed's record-refused row, and a stderr line) and never retried.
  • On close, the last records are posted (close waits at most two seconds for the service), then sessions.json gets its one write, as a record of how the run ended.

No service answered: sessions.json is the registry, as before, for the whole run. A service that starts later is followed for the feed and jobs, but is not given the sessions.

Unverified content. Sessions naming an artifact the service does not know are accepted and marked artifactVerified: false; that is every editor session until Play runs revision snapshots. The feed says so on the session's row ("unverified content: not a build the service checked"), and ServiceState::artifact_verified(session) gives the toolbar the same.

crates/editor/tests/service_link.rs and crates/editor/tests/e2_service.rs gate the link and the authority against a real service.

Activity feed

crate::feed::FeedStore folds log pages into rows a person can act on: actor, action and result, with links to a diff, an entity, stored evidence (a screenshot, a trace), a job, or a source file.

  • Bounded. The newest 2,000 rows (FEED_CAP); every row's raw entry is cut at 2,048 bytes, its label at 512, each diff at 8,192, and its links at 12. Memory is bounded by the row cap however long the journal grows.
  • Truncation is a row. History the feed did not load (feed-history-not-loaded) and entries the service's retention removed before the feed read them (journal-truncated-unread, from the page's truncatedBefore) are marker rows with counts. Rows dropped to keep the cap are counted (dropped()).
  • Filters (All, Failures, Jobs, Changes) each keep an index of the rows they admit, updated as rows arrive and leave, so a frame reads only the rows it shows.
  • Labels are for people. A per-revision story follows a change through its build, checks and apply: "jump change compiled; traversal passed; applied to your session". Applied comes only from the runtime's acknowledgement: an update-applied entry whose outcome is applied, ok, succeeded or success. A completed change, a build or a passed check never sets it. The raw entry stays behind the row.
  • Tolerant. An entry kind the editor does not describe is still a row, with its raw JSON.

Changes made outside the editor are applied from the feed. The workspace service reads files that change outside the workspace protocol once they have stopped changing; one file it applies itself, but several wait for an actor to say the edit is finished, because stable reads do not prove that it is (docs/WORKSPACE-SERVICE.md). Until someone does, those files are in no revision at all, so nothing builds, plays or exports from them. The row that found them names them and carries Apply (feed.apply-external, and feed.apply-external.<group id>, which is the name to press when more than one group is waiting), which posts POST /v1/external/<group>/apply as human:local through the service link. From the press until the service answers, the button is disabled and the row says it is applying (feed.apply-external.<group id>.applying); on success the row names the revision the group became, and that revision builds as any other does; on failure the row shows the service's own message and offers the Apply again, because a refusal is not a reason to hide the changes. A group that a later one superseded says why and offers nothing. While a group is waiting, the Export panel says those files are in no revision and are not in the export, and sends the person to the feed to apply them.

Agents

crate::presence is what the human sees of the agents that work in the background. PresenceModel holds the roster, what each agent has in scope or has just changed, and the updates held behind Refresh. It is folded from PresenceEvents that a PresenceSource hands over once a frame (Presence::pump, from Shell::frame_with_dt). NoPresence is the source until the service and the agent supply one, FakePresence is the gates' source, and ARGUS_TEST_PRESENCE=<script.json> gives a scripted run a fake one. The buttons emit UiAction::Presence(PresenceAction): the switch, Refresh and Apply are the model's own, and Pause, Revert and Diff are forwarded to the source.

  • Roster. The status bar's chip counts the agents and opens a list of them. Each row shows the agent's colour, hashed from its name into theme::AGENT_PALETTE, and its activity, with Pause/Resume, Revert all since… (the last 1, 5 or 15 minutes, or since this editor opened) and Show changes, which filters the feed to that agent.
  • Marks. An entity an agent has in scope, or changed in the last 10 s (RECENT_MS), gets a tinted Outliner row with a stripe and the agent's initial. It also gets a name tag in the agent's colour over it in the authoring view, and a ring flashes for 700 ms when a change lands. The tags are painted by the viewport overlay's painter, so they add no render pass and take no clicks. The human's selection is never touched, and it still wins the row's fill.
  • Live updates. Remembered per project in .argus/editor/settings.json (liveUpdates, default on), which is written when the switch is flipped. The editor core owns state.json, so the switch is not kept there. When the switch is on, a change is applied to the view as it lands. The exception is a staged set while Play runs, which queues until Refresh or Stop. When the switch is off, everything queues. The toolbar then shows "N updates ready (x scene, y code)" (toolbar.updates) with Refresh (toolbar.refresh). The button says "Refresh (restarts Play)" when something queued cannot reach a running game. Turning the switch back on with a queue offers "Apply N queued updates?" rather than applying them unasked. PresenceModel::take_applied hands what was applied, in order, to the editor core to fold into the document and Play.
  • Feed. A per-agent filter (FeedStore::count_by/get_by, an index like the others). Every change that landed carries Revert and Diff, and a change held behind Refresh carries Apply, which applies that one alone.

Jobs panel model

crate::jobs::JobsView is built from job and check-result entries and GET /v1/status's live jobs: the newest 256 jobs, 16 results each, and 64 results that name no job, with counts of what was dropped.

  • Each job shows its state, the phase the service gives it (queued, running, session ready, checking, done), its kind as its resource class, and either the progress a runner reported or "progress not reported". Progress lives in the service's memory only, so it is absent for a job that never reported one and is lost when the service restarts.
  • Stale: a result for any artifact other than the running one stays inspectable and is marked stale; it cannot validate what the human is playing.
  • Incomplete because: the job's state and reason, whether the service is waiting for it to report again after a restart, and each result's outcome, failure kind and limitations, in words.
  • Cancel goes through JobControl. ServiceJobControl posts POST /v1/jobs/{id}/cancel with the human as the actor; the service answers terminal-job for a job that has already finished, and the refusal is shown rather than swallowed. A job can be cancelled while it is not terminal (argus_workspace::is_terminal_job_state, the service's own rule). Cancelling needs a runner polling: GET /v1/jobs lists only runners that polled within the service's RUNNER_TTL_MS (15 s), so an empty list is the whole signal, and the button then carries that reason instead of being dead.

Project Settings

crate::settings::SettingsBackend is shaped like the service's settings API (GET /v1/settings, POST /v1/settings/check, POST /v1/settings/apply, GET /v1/settings/icon?path=; an edit is {field, expect, value} by JSON pointer, and a stale expect is a field conflict with the expected, actual and proposed values).

ServiceSettings is what the editor uses. Shell::new resolves <project>/.argus/service.json, verifies the instance and talks to those routes, so a settings change is a change set on argus.project.json with a lease, an expected version and a journal entry — the same path an agent's edit of that file takes — and the write is a revision the build pipeline builds. The apply is made as the person at this editor (Actor), and its three refusals are shown rather than raised: a field someone changed underneath the edit (409 field-conflict, naming the field and what it holds now), another change holding the manifest (409 lease-held, naming the holder), and a merged manifest that would not validate (422 invalid-settings, with the diagnostics).

The page holds the view it last read until something says the manifest moved underneath it: its own Apply says so, and so does anything else that edits a settings field (EditorUi::forget_settings_view), which today means Set as startup scene. Now a change made elsewhere does too: whenever the editor takes the manifest again (Editor::manifest_generation), the page reads the settings again and keeps what was typed (EditorUi::refresh_settings_view).

ManifestView is the fallback, when no service answers or the discovery file names one that has gone. It reads argus.project.json through the project resolver, shows each violation on its field, previews the icon by reading the file, and edits nothing — only the service can record a settings change, and it says so where a person would otherwise type. RecordingSettings is the test seam the automated gate edits through.

Authoring context

crate::state::EditorStateStore is the whole interface for the restart state (load, save); FileStateStore, writing state.json, is its only implementation.

The interface

crate::ui::EditorUi is the editor's interface: egui 0.36, run once per editor frame over the editor core, after the editor's own frame. A frame of it reads the editor and records what the person asked for (ui::UiAction); the shell applies those after the frame through the same calls its keys make (Shell::apply), so a button and a key are one path.

AreaWhat it shows and does
Menu barFile (New project…, Open project…, Save, Close project), Edit (Undo, Redo, Duplicate, Delete, Project Settings…), Window (Activity, Jobs, Content, Outliner, Details), Help (Viewport controls, About Talos Editor, Report a bug…, Request a feature…, Copy the documentation path), each item with its keyboard shortcut as text and each dispatching the same UiAction the toolbar and the keys do
Toolbarone row — left Save (a disk mark), Undo, Redo, Import model… (see Importing models), Export… (see Exporting a game); centre Edit and the transport as marks, Play a green triangle, Pause two bars, Step a triangle against a bar, Stop a red square, each naming itself on hover; right the project's name in the quiet style every state item has (toolbar.project; it follows a settings apply, as the window title does), Settings (the Project Settings page) and Close project. What the editor is holding is the status bar below, not a second toolbar row
Status baralong the bottom of the window (the usual editor's bottom bar). Left: Activity, Jobs, Content, Log (status.drawer.<tab>; short labels, because at 150 % in a 1280-wide window the bar is full), which open the drawer on that tab and close it when pressed again; then, only while a Play session is live, "Play is up to date" or "N updates pending" (toolbar.pending) — at rest it says "Play runs the latest save", because the count is about restarting a run and there is nothing to restart — then the agent chip ("3 agents", status.agents), which opens the roster, and the Live updates switch (status.live-updates; see Agents). Right, reading left to right: the build state (toolbar.build-state), the workspace service's state (toolbar.service), "Unsaved changes" or "Saved" (toolbar.save-status), and Content <id> for the artifact Play loads (toolbar.artifact). Above the bar, and only when there is something to say, a wrapped row of notices: "Scene changed on disk" with Reload, the last Ready build and Play last Ready, "unverified content", the last refusal and the last error
Viewportthe game image, drawn across the whole panel at the render target's size, which follows the panel's, composed with the panels in one pass; a click selects the entity drawn under the pointer (pointer, to the image rect, to a target pixel, to the authored id). A painted overlay at the top-left names the open scene and whether it is authoring or playing (viewport.overlay), with the selected entity's id under it in the usual editor's orange (viewport.selection); both are painted, not widgets, so a click there still reaches the viewport
Outliner and Detailsthe right column, the tree over the inspector with a splitter between them, either one hidden from the Window menu, each under a header strip with its name as a tab (outliner, details); a search box over the tree (outliner.search) narrows it by name, id or tag and flattens it while it is typed in. The scene's entities as rows (entity.<id>): a kind mark coloured by what the entity is for, its name, and its id dimmed after it, the selection filling the row in the accent. Then the selected entity's Duplicate and Delete, its transform as numeric fields (inspector.transform.position.x, …), its components' behaviour fields as typed rows (inspector.prop.controller.speed, …) over a collapsed raw JSON view, the asset section for an entity with a mesh (where it was imported from, its settings, Reimport, Collider, Reveal, and Delete unreferenced when the scene's assets/ folder holds model files nothing names — see Importing models), components, a copy-path link to its behaviour source, and failures in the feed that name it
Project Settingsa page over everything between the toolbar and the status bar (settings.page), opened from Edit → Project Settings…, the toolbar's Settings and the Export panel's Edit in Project Settings — not while Play runs, because the page would cover the viewport the run is drawing into, which is the rule every other File and Edit item already followed. A search that matches nothing says so (settings.empty), and edits typed and not applied are kept when the page is closed and counted at the top when it is reopened (settings.unapplied), so nobody pushes an edit they thought they had abandoned: a search box, the category tree from Section::ALL on the left with each section's count (settings.category.<slug>), the form under section headers on the right, capped at 720 pt with a fixed label column, each field's diagnostics (the categories are grouped Project and Engine, and the Engine ones are the scene defaults, see Project Settings), and "needs a restart or a new export" said once per section (settings.section.<slug>.note) with a × beside the individual fields only where the section is mixed; the icon with Choose… and a preview served by the workspace service, then Apply, Revert and Close
Activity feedbuild entries as "Building scenes/main.scene.json", "Ready", "Failed: scenes/jump.ts:5:19 — message" with a source link, and revision-runnable entries; the newest rows the scroll area shows, and an explicit line for rows dropped, entries never read and housekeeping not shown. Consecutive entries that say the same thing are one row and a count ("… ×8"), and a garbage collection that freed no bytes is counted rather than kept — but a row that leads anywhere (a diff, an entity, a job, a file, a group of external changes) is never folded into another, because folding it would take away something a person can press; a row opens an "Activity entry" window with its links (diff, entity, screenshot or trace, job, source) and its raw entry, so the links are never cut off by the panel's height; "Applied" only on a runtime acknowledgement
Build statethe toolbar shows, for the revision this scene's last save produced, the workspace service's own state in plain words (toolbar.build-state: Editing… / Building… / Checking… / Ready / Failed — file:line — message; a click opens the feed row), the last Ready build while the newest is Failed (toolbar.last-ready), and Play last Ready (toolbar.play-last-ready) to play the artifact this editor prepared before the failed one, without adopting it (Stop puts the current one back); Content <id> names the artifact Play loads. With no service: "No workspace service: builds are not checked here"
Promptsmodal: Unsaved changes ("Save changes to <files>?", Yes / No / Cancel, prompt.*) when closing with unsaved work or opening another scene over it; Restore unsaved work (recovery.*) when a project opens with autosaves the last session left; Unsaved changes before Play (Save and Play / Discard and Play / Cancel, play.*); The scene cannot be saved over as it is (Overwrite / Reload / Cancel, conflict.*) when a save finds the file changed, missing or unreadable; Import (import.*), what a model file measured and what the declared settings will make of it; Export (export.*), the revision that would be exported, its branding read-only, the destination, progress, Cancel and what the last export wrote
Drawerover the viewport, above the status bar, tabs Activity / Jobs / Content / Output Log (drawer.tab.<tab>, also drawn as feed.tab, jobs.tab, content.tab and log.tab; the Output Log is its own section), a grab handle that sizes it (drawer.splitter) and a Hide/Show button (drawer.toggle) leaving the tab strip; pressing the tab already showing closes it too, and so does the status bar's button for it. Jobs holds what used to be a column of its own, as a grid of cards that fills the width, newest first: each job's state and phase, resource class and reported progress ("progress not reported" only while it could still be reported); stale marking against the service's currentArtifact; why it is incomplete; Cancel through the service with its reason when it cannot, and no Cancel at all on a job that has finished; its screenshots

Help ▸ Report a bug… and Request a feature… (menu.help.report-bug, menu.help.feature; crate::report). Report a bug opens the Launcher's own report, not a second kind: the same argus_usage::reports::Report, the same form (category Bug / Feature request / Question, a title of up to 120 characters, a description, and Attach logs, off until ticked, with the warning "Logs can contain file names and paths from your project."), and the same Review and send… screen, which shows the report's JSON and the log text exactly as they will go (report.review.fields, report.review.logs). Always included: the engine version (this editor's), the launcher version (0.0.0: the editor cannot ask the Launcher) and the OS class and build. The editor's log bundle begins with its version, the open project's name and where the editor's log is, then the last 2,000 lines of the editor's log (--log-file), the workspace service's (.argus/editor/service.log) and the Launcher's (argus-hub.log), each only if it exists, with the profile folder replaced by %USERPROFILE%. Project files are never attached. Only Send writes anything: Cancel, Back, the window's × and Escape (once no field has the keyboard) save nothing. Send writes the reviewed report to the reports outbox (%LOCALAPPDATA%\Argus\reports\outbox\<id>\, argus_usage::reports::save) and starts argus-hub --send-reports from the Launcher's install, which sends it; the editor makes no network call. With no Launcher to start, the dialog says the report waits in the outbox for the Launcher's next run; on a managed or offline install it is saved for email to feedback@cadarn.ai and nothing is started. The dialog is modal (Shell::modal_open) while it is open, so typing in it drives nothing else. There is no shortcut: a menu item is enough (Ctrl+Shift+F1 is the Launcher's). Request a feature… opens https://cadarn.ai/talos/roadmap (report::ROADMAP_URL), the testers' roadmap, in the system browser. Tests replace the outbox and the Launcher start (Shell::set_outbox) and the browser (Shell::set_url_opener), so no test writes the person's outbox, starts a program or opens a page (tests/ed_help.rs).

Painting. The game keeps drawing into its RenderTarget, at the viewport panel's size. Each frame crate::paint::Painter copies the target's colour into a texture registered with egui-wgpu (remade, with the same id, whenever the target's size changes), and paints every egui primitive, the viewport's image included, into the swapchain image through argus_render::surface::Surface::present_with, on the renderer's device. Nothing is read back to the CPU. A headless shell runs and tessellates the interface each frame and paints nothing.

Input. Every winit event goes to the interface's input (crate::egui_input::EguiInput, the editor's own translator, so tests and the window take one path) and to the shell's routing in the same call. A press on the game image gives input to the viewport and a press anywhere else to the interface (see Focus). While a text field has the keyboard, keys go to it and not to the game or the editor's shortcuts, and Esc leaves the field and does nothing else. The window's clipboard is egui-winit's; IME is allowed while a text field wants it.

Bounded. The feed draws only the rows its scroll area shows (ScrollArea::show_rows over the store's per-filter index); failures for the selected entity are gathered once per selection and feed position. With 100,000 events folded into the feed, a frame draws at most 13 feed rows in the gate's layout (tests/ui_bound.rs).

Where things are. Each control a person clicks is recorded by name with its rectangle (EditorUi::hit: toolbar.play, menu.edit.undo, drawer.tab.jobs, feed.row.<seq>, feed.link.evidence.<sha256>, entity.<id>, settings./application/displayName, export.ok, viewport, and so on), so the scripted-human gate presses exactly what a person would. The interface pass renamed nothing: every name the suites pressed before it is still drawn, and the new surfaces took new names — menu.file, menu.file.<item>, menu.edit.<item>, menu.window.<item>, menu.help.<item>, toolbar.settings, outliner, details, drawer.tab.<tab>, drawer.toggle, drawer.splitter, feed.tab, jobs.tab, content.tab, settings.page, settings.search, settings.category.<slug>, settings.section.<slug>, settings.revert, settings.close, settings.error, settings.icon.browse, settings.icon.preview, settings.icon.error, settings.icon.none, about.window, about.version, about.build and about.docs.

The fix pass renamed nothing either. It moved the status items out of the toolbar into the status bar, where they keep the names they had (toolbar.save-status, toolbar.artifact, toolbar.pending, toolbar.live, toolbar.freeze, toolbar.service, toolbar.build-state, and in the notices row toolbar.disk-changed, toolbar.reload, toolbar.last-ready, toolbar.play-last-ready, toolbar.unverified, toolbar.error) — the name is where a gate presses, not where the control lives. The names it added are status.drawer.content, status.drawer.activity, status.drawer.jobs, toolbar.project, outliner.search, viewport.overlay, viewport.selection, settings.section.<slug>.note, settings.empty and settings.unapplied.

MaPresence renamed nothing. The Live and Freeze placeholders became the Live updates switch, which is drawn under status.live-updates and under both old names. It added status.agents, roster, roster.<agent>, roster.<agent>.name, roster.<agent>.pause, roster.<agent>.revert-since, roster.<agent>.revert-since.{1m,5m,15m,all}, roster.<agent>.changes, toolbar.updates, toolbar.refresh, entity.<id>.agent.<agent> (the initial on a marked Outliner row), viewport.tag.<id>, feed.filter.agent, feed.filter.agent.all, feed.filter.agent.<agent>, feed.revert.<seq>, feed.diff.<seq> and feed.apply.<seq>. <agent> is the agent's actor id.

Marks are painted, not typed. The font egui bundles has no glyph for a good many symbols, and a character it lacks is drawn as nothing at all — no box, no warning, no failing test. Six marks the editor drew were invisible for that reason: ◧ and ▤ on Content tiles, ▾ and ▸ on the folder tree, ✓ in the File menu and ● for the open scene, and the drawer's toggle was an empty button for the same reason. The toolbar's transport, the Content tiles' role marks and the folder tree's disclosure triangles are shapes now (ui::Mark, content_browser::Role::mark); theme::TEXT_MARKS is the list of characters the interface still draws as text, and theme::tests::every_mark_has_a_glyph_in_the_bundled_font asks the bundled font about each of them.

The shell

The window is laid out the way the usual editor's window is, in this order (crate::ui::draw):

ClaimPanelSize
1menubar, a top panelthe row's height, not resizable
2toolbar, a top panelone row of controls, not resizable
3status, a bottom panelone row, plus a wrapped notices row when there is something to say; claimed before the right column, so it runs the width of the window as the usual editor's bottom bar does
4details, a right panel320 pt, capped at 40 % of the width, holding outliner over the inspector; claimed before the drawer, so it runs the full height
5drawer, a bottom panelUiState::drawer_height, capped at 45 % of what is left, spanning the viewport rather than the window
6the viewport, the central panelthe rest

The order matters. The right column is claimed before the drawer, so it runs from the toolbar to the bottom of the window and the drawer spans only the viewport beside it — which is both what the usual editor does and what the inspector needs, because a control scrolled out of a panel is drawn, and named, and cannot be pressed. A shorter Details panel is not a cosmetic change; it silently puts the property rows out of reach.

The left edge belongs to the viewport. Before this change one 280-point left column held the scene tree, the inspector, the asset section and Project Settings in a single scroll, and the jobs held 300 points of their own on the right; Project Settings was below the fold in both screenshots the design review came from. The tree and the inspector are the right column now, Project Settings is a page, and the jobs are a tab.

The toolbar is laid out, not wrapped. Its three groups sit at computed rectangles — the centre takes TOOLBAR_CENTRE_WIDTH and the two sides split what is left — so Play stays in the middle of the window whatever else changes. A wrapped row moved it every time a build state grew a word. The status bar is laid out the same way, its right-hand group capped at STATUS_RIGHT_WIDTH so the drawer buttons on the left always have room at 150 % display scaling; the notices above it are wrapped, so they can never reach past the window however much they have to say.

The viewport fills its panel (ViewportD6). The view is no longer drawn at the size the project was opened at and letterboxed into the panel: the panel's size in physical pixels drives the offscreen target, so the frame is rendered at the size it is shown at and framed for the shape it is shown in. Each frame Shell::frame_with_dt measures the rect ui::viewport drew the image in and hands it to preview::follow_panel, which clamps it to 64..=4096 a side and asks the world the viewport shows — the authoring view's, or Play's while playing, because the editor runs Play inside the editor process, on the one renderer it moves between the two worlds. World::request_target allocates a new RenderTarget once that size has held for 150 ms, and World::resize_target is what replaces it: the four attachments, the two bind groups that sample them and the readback buffer, and nothing else — the scene, the entity store, the mesh and texture caches, the physics world and the renderer all stay as they were. The camera follows with no help, because World::submit reads its aspect from the target every frame. paint::Painter remakes the egui texture the image is sampled from when it sees a new target size, keeping its TextureId, so a resize costs one texture rather than a leaked one per resize — and while the panel and the target disagree, for the settle after a drag or the first frame of a Play, the last frame is stretched over the panel rather than the copy being skipped and the image freezing. Why it is debounced: a resize allocates roughly 24 bytes a pixel, and a window or a splitter being dragged reports a new size every frame; nothing is asked for at all while the pointer is holding something, so a drag of any length costs one reallocation, at the end. crates/app/tests/d6_resize_target.rs holds the engine half (the world survives, the aspect follows, the clamp, the debounce, fifty resizes and the memory); crates/editor/tests/d6_viewport.rs holds the editor's (the target follows the panel and not the window, picking is exact before and after a resize, the thumbnail and F9 are written at the target's size).

The drawer is sized from editor state, not from egui's panel memory (UiState::drawer_height), so closing it and opening it again gives back the height it had. Its height settles on the frame after the toggle, because the panel's size is decided before the strip inside it is drawn. A drag on its grab handle (drawer.splitter, which shows the resize cursor) is kept per user in layout.json (drawerHeight, below), inside the same limits the drawer is drawn in; a double-click on the handle puts the default 240 points back.

The right column and the Outliner resize, and the person's sizes are kept (crate::layout), as the usual editor's panels are. Two splitters, The usual editor's: panel.splitter, a strip just inside the column's left edge (so a press on it never picks in the viewport), sets the column's width, and outliner.splitter, over the edge between the Outliner and the Details, sets the Outliner's height. Hovered or dragged, a splitter shows the resize cursor and an accent line; a drag follows the pointer exactly from where it was pressed; a double-click puts that size back to its default. The limits keep both sides usable at every display scale: the column is at least 160 points and leaves the viewport at least 360; the Outliner is at least 48 points (its header, the search row and a row) and leaves the Details at least 120. A size the person has not set is the default, which follows the window (the column 320 points or 40 % of the window, whichever is less; the Outliner a third of the column, up to 200 points), so the default layout is exactly what it was. Per user, not per project, beside the keymap: <config dir>/argus/editor/layout.json (ARGUS_LAYOUT_FILE overrides it for scripted runs), as { "panelWidth": 380, "outlinerHeight": 260, "drawerHeight": 300, "outlinerSort": "type" }; a size left out or null is the default. It also holds contentFilter, the Content Browser's type filter (["model", "prefab"]; left out is every type, and a word the editor does not know is dropped when the file is read). It also holds outlinerCollapsed: the Outliner folders the person closed, per scene (keyed <project folder>#<scene path>, at most 64 scenes, the least recently changed dropped first); a folder not listed is open. It is written once when a drag ends, and when the sort or a folder's caret changes. Only the window's editor reads and writes it (crate::app calls Shell::set_layout_store(LayoutStore::user())); a Shell built directly — every test, a headless gate — keeps the defaults in memory unless handed a store. A file that does not read is the default layout with the reason on stderr; a size that is not a positive number keeps its default; a field it does not know is ignored. /v1/state has view.layout: {panelWidth, outlinerHeight, drawerHeight} as drawn last frame (points; outlinerHeight is null unless the Outliner and the Details share the column, both are null while it is closed, and drawerHeight is null while the drawer is closed) and outlinerSort; /v1/do {"do":"set-view","layout":{…}} (and editor.set {layout}) sets any of them, a size to null for its default. With no editor running, editor.state answers from the project's state.json and has no view.layout: the layout is the person's, not the project's.

A model dragged out of the Content drawer and let go on the viewport is placed in the open scene (content_browser::dropped_model on the viewport's response, which answers only for the widget the pointer is over on the frame the button comes up), where it was let go, as the usual editor places a dragged asset: on the surface under the cursor (the authoring view's own pick, Editor::surface_at), else on the ground plane y = 0 along the cursor's ray, else at the origin. One function, content_browser::drop_point, places a dropped model and a dropped prefab alike. Place and a double-click still put it at the origin. The drop handlers ask for their payload's type before taking it: egui's take_payload takes whatever is carried, so a model check on a prefab's release used to drop it. Place is a button in the drawer as well, so nothing the browser does is reachable only by dragging. The two halves are asserted separately: that a model tile carries its path while it is dragged in tests/d6_content.rs, and that a release over the game image puts the model in the scene in tests/d6_shell.rs.

Project Settings blocks the viewport, not the shell. The page is an egui::Area over the panels, so EditorUi::over_viewport stops giving the game the pointer while it is open, and the menu bar and the toolbar above it stay live — there is always a way back out. The prompts and the menus are Modals and popups, which sit above it.

A hit name is a control a person can press. Every name the interface publishes carries Response::interact_rect — the widget's rectangle clipped to what is actually on screen — and a widget whose clipped rectangle is empty is not published at all, because egui refuses to interact with a clipped widget. The panels under the Project Settings page publish nothing while it is up, for the same reason: they are still drawn, because leaving the page has to give back exactly what was there, but a click on one of them lands on the page. A modal prompt is the same rule: egui::Modal blocks input to everything beneath it, so while one is up the map holds the prompt's controls and nothing else — the suppression is applied where the prompts draw, not decided from a flag before them, so a prompt added later is covered without anything to keep in step. The design review measured what the old rule cost: 178 of 200 Outliner rows and the 300th content tile were published as controls, and pressing them did nothing at all; the re-review measured 51 more under the Play prompt.

A skipped test says so, and a gate can forbid skipping. Some of tests/d6_content.rs and of the design review probes need packages/gameplay/dist and an esbuild binary to open a project whose scene carries behaviours. Without them those tests print SKIPPED <test>: <reason> and return — which cargo reports as ok and shows only under --nocapture, so a whole file can be green without having run, which is what the design review hit. ARGUS_REQUIRE_TOOLING=1 makes a skip a failure instead, and every gate and CI run should set it. The other half of the answer is needing the toolchain in fewer places: a test whose project has no behaviours builds one and runs anywhere, so 8 of the 16 tests in d6_content.rs need it rather than 13 of 14.

Everything fits the window the editor promises, at the scales a person has. crates/editor/tests/d6_shell.rs asserts that every rectangle the interface draws is inside a 1280x720 window at 100, 125 and 150 % — 150 % of that window is 853x480 points — with and without the settings page. tests/d1_workflow.rs and tests/d3_review_probes.rs hold the same line for the workflow's and the importer's controls at 150 %.

The theme

crate::theme is one Visuals and one Style for the whole product: the launcher and the editor each call theme::apply on their context, and neither carries a colour of its own. Before this change only the launcher was themed — the editor ran in stock egui dark — and the two halves disagreed about red (#e15555 against #eb6464).

The values approximate a familiar dark editor theme: window #1A1A1A, panels #242424, the header strip and the menu bar #1C1C1C, borders #383838, recessed fields #1E1E1E, buttons #3C3C3C, text #DCDCDC over #9E9E9E over #6B6B6B, accent blue #0E6BB8–#2C7FE8, 12.5 pt body text, 20 pt rows and 3 px radii. Epic publish no swatch list, so every value is meant to be adjusted: open the editor you are matching, eyedrop the surface, and change the one constant. Nothing else in the editor holds a colour.

Separately from the chrome, eight constants carry meaning rather than surface — ERROR, WARNING, OK, ACCENT, LINK, PLAY, STOP and SELECTION (the selection orange familiar from other editors, which is also the colour the viewport marks the selected entity in).

Every colour the editor draws words in reads on every surface it draws them on, which is the design review's finding: the theme's own test checked the panel alone, while the same words are drawn on a button, in a field and on the header strip — where ERROR was 3.21:1, OK 3.97:1, the hyperlink colour 2.79:1 and disabled text 2.07:1. So ERROR is a coral rather than a red (a red dark enough to look like the usual editor's #ED1B34 cannot reach 4.5:1 on a #3C3C3C button at all), OK and SELECTION are one step lighter than the usual editor's, disabled text is lifted to clear 3:1 and still sits below muted, and the accent — right for a selection fill, a focus ring and an active tab's underline, and not readable as a word — keeps its value while the one place it would be a word, a link, has its own LINK. STOP and PLAY keep the usual editor's values because they are shapes, not words. The unit test now checks every colour against PANEL, INPUT, HEADER and CONTROL, and the surfaces to their order, so an eyedropper pass cannot quietly flatten the shell or make a failure unreadable.

the person may choose the accent (blue, teal or violet) and the body text size in Editor Preferences ▸ Appearance. theme::apply_appearance puts them on the context, and whatever the painter draws in the accent reads it through theme::accent() and theme::accent_deep() rather than the ACCENT constants, which stay the usual editor's blue (the launcher's). The shell stays dark: a light theme would need every surface and text constant above to become a lookup.

Windows build

argus-editor.exe is a GUI-subsystem program (windows_subsystem = "windows"): opened from Explorer or a shortcut it opens no console, and so no console window takes the foreground.

  • Whenever it has no usable standard error, its messages go to --log-file, by default %LOCALAPPDATA%\argus\logs\argus-editor.log (moved aside to .log.1 past 8 MB). On any OS, --log-file PATH sends them there explicitly.
  • --console attaches to the console it was started from, or opens one, and points the standard handles its launcher did not redirect at it.
  • The worker subcommand keeps the pipes its launcher gave it; the editor starts workers with CREATE_NO_WINDOW.
  • The project is opened before the window, so a refused project exits with its reason in the log and never shows a window.

Simulating the human in tests

The gates play the human at the winit event boundary, with no OS-level input synthesis and nothing taking focus. argus_editor::test_inject is clearly labelled test-only: it builds winit's own values (KeyCode, the logical Key and the text a key types, ElementState, MouseButton, MouseScrollDelta, Ime, ModifiersState) and calls Shell::on_keyboard, on_mouse_button, on_cursor_moved, on_cursor_left, on_mouse_motion, on_wheel, on_focus, on_modifiers, on_ime and on_scale_factor — exactly the calls the real event loop (shell::App::window_event) makes. Everything from there on is the production path, the interface's input included. test_inject::click(shell, name) moves the cursor to the centre of the control the interface drew under name and presses and releases it; type_text types characters.

crates/editor/tests/e2_human.rs is the scripted-human gate: through those calls alone, a person watches a change arrive, inspects a failure, opens its screenshot, selects the affected entity, edits a Project Setting through the settings seam and resumes Play.

For a real windowed run, argus-editor <project> --test-inject-script <file> replays the same events inside a real window at rendered frame numbers. A scripted run needs no focus, so its window is created non-activating (with_active(false); honoured on Windows and macOS, ignored by X11 and Wayland) and never grabs the cursor. Its events are: one event per line, <frame> key <KeyCode> down|up, button left|right|middle down|up, cursor X Y, motion DX DY, wheel LINES, focus in|out, click <control>, hover <control>, type <text>, modifiers none|ctrl|shift|alt[,…], scale FACTOR, resize W H, import <path>, close.

A scripted step that cannot be applied is named and the run fails. A click or a hover on a control that is not drawn is refused — the message names what is drawn under the same prefix, not every control on screen — and the runner logs MISSED frame <n>: <event> was not applied: <why> and ends the run non-zero with every miss listed. The script carries on after one, as a person does after a misclick, but a walkthrough can no longer pass having quietly done nothing: that is how a run clicked a Content tree folder row inside a collapsed parent, never selected the folder, and still reported success. A control that is still drawn but has moved between the aim and the press is the silent half of the same problem, so a press is re-aimed at the name the script took aim at, on the frame it is delivered.

hover <control> rests the pointer on a control and leaves it there, which is how a script reaches anything inside a submenu: scene.open.<path> is drawn only while File ▸ Open scene is open, so a click alone can never press it. Three lines, with frames between them, walk it:

10 click menu.file
14 hover scene.open
20 click scene.open.scenes/arena.scene.json

What neither covers is winit and the OS themselves: key repeat, the order a platform delivers events in, focus the OS decides to move.

Known limits

  • Describing a screen needs @argus/ui. The packed SDK carries it at sdk/packages/ui and a checkout answers for a project made anywhere, because the directory the editor was started in is one of the three roots discovery walks; ARGUS_UI_PACKAGE overrides both. A missing package is the reason on the tile, not a blank one.
  • A project that names no default theme gets a path sort for everything after ui/theme/tokens.css, which for the design system's layer is not the order docs/UI.md §10 fixes — game.css sorts before sim.css. Setting settings.ui.defaultTheme in argus.project.json is how a project chooses; the editor no longer second-guesses it, so a project that has not chosen previews exactly as it plays.
  • A translation edit fires no hot reload. ui/locales/** has no UiRole, so nothing classifies it as a UI input on its own. The tables are re-read on any sync that describes and on a ui/scenarios/** change, which covers editing a string and previewing again, but not editing one while a preview sits idle.
  • A UI preview is an authoring activity. It draws on the authoring world, so it shows nothing while Play is running, and the screen it puts up is popped when it closes.
  • The target follows the panel, within a frame or two. It is resized 150 ms after the panel settles, and clamped to 64..=4096 a side, so a panel dragged shut renders 64×64 and a panel on a wall of monitors renders 4096 wide and is scaled up to fill.
  • Agents appear only when a presence source is attached. Until the service and the agent wire GET /v1/presence and the journal into a PresenceSource, the chip says "0 agents". The switch and the queue work, but nothing lands in them. Viewport name tags are drawn in authoring only, because Play's camera is the game's. Refresh (restarts Play) reports that a restart is needed; the restart itself is the editor core's. Cancel works through the workspace service; it is disabled, with the reason, for a job that has already finished and when no runner is polling.
  • Rotation is edited as degrees about X, Y, Z and stored as the quaternion they make (rounded to 1e-6), so a rotation authored by hand as a quaternion shows as the nearest Euler angles, and typing the shown values back may change the stored quaternion by rounding.
  • A save rewrites the file in the editor's form: pretty-printed JSON, 2-space indent, every array element on its own line, floats as 2.0. A scene file kept in another layout gets a large first diff.
  • Undo is in memory and does not survive a restart or a Reload; unsaved changes are lost when the editor crashes.
  • Children are not duplicated with their parent, and an entity's children go with it when it is deleted.
  • A project whose startupScene names a file that is not there does not open. The manifest is the project's definition and the contract refuses it, as it does for the builder, the exporter and the workspace service; the editor does not repair someone's manifest unasked. The refusal lists the scenes that are in the project and says which line to change. An offer in the launcher to set one of them and open is the real fix and is a change of its own.
  • The interface keeps little layout across restarts. The right column's width, the Outliner's height and sort, and the drawer's height are kept per user (layout.json, above); which drawer tab shows, the open feed row and the selected filter live for the run. The three splitters show the resize cursor.
  • The interface in a real window has not been run by a gate. The headless gates run and tessellate it; painting through present_with needs a window, which needs the coordinator's OK.
  • Play worlds' renderer resources are not released. argus_render::Renderer can give a mesh back — free_mesh, which World::reconcile calls on the meshes an asset swap evicts — but a texture has no unregister API at all, and nothing walks a finished Play world's caches on the way out. So each Play of an asset-heavy scene uploads its glTF meshes and textures onto the shared renderer again and they stay, and a long session of many Plays grows GPU memory until the editor restarts. Built-in primitives are shared and are never freed. A session of many reimports no longer grows it: see Limits under Import.
  • A Play that fails to load after taking the renderer loses it. The Play world is loaded from the same frozen document the authoring world already loaded, and the query server binds ephemeral ports, so this is not expected; if it happens the editor reports it and must be restarted.
  • Scripts and assets are not bundled into artifacts. The stale checks refuse a changed one instead. Built-in gameplay modules (packages/gameplay/dist) are not revision inputs.
  • The engine identity is the running executable. In the test suite that is the test binary, not argus-editor.
  • camera, reload and stop have no API endpoints. Their capabilities are declared and enforced by declaration; a worker stops when killed or when its editor goes. The editor's F5/F9 equivalents are the human's in-process controls.
  • Behaviours that branch on the calendar or on sub-tick wall time are not reproducible (Date starts at the run's wall-clock start; performance.now() is wall time within a tick and simulation time across ticks), and Math.random repeats across runs unless --seed changes: see World seed.
  • Content comes from the authored tree, so the service marks every editor session unverified, until Play and test sessions run revision snapshots; see Content roots.
  • Session authority is decided when the editor opens. A service that starts later does not take the sessions over for that run.
  • A service that hangs, rather than exits, holds the link for up to the service client's 120 s request timeout; frames are unaffected, but the feed and records wait.
  • One editor per project is assumed: state.json and sessions.json are not locked.
  • A Windows-native editor cannot open a project under \\wsl.localhost; run the editor in WSL for a WSL project.
  • Windows TypeScript scenes: a Windows engine against a project on NTFS needs a Windows esbuild on PATH or ARGUS_ESBUILD. The gate scenes use a plain .js behaviour and need no esbuild.