---
name: test-via-api
description: >
  How to verify luncosim changes end-to-end without asking the
  user to click. Trigger whenever a UI flow needs verification — a new
  diagram, a fix to drill-in, a screenshot to confirm a regression, a
  smoke test of any reflect-registered Event command. The workbench
  exposes a small HTTP API on `--api PORT`; this skill is the runbook
  for driving it from curl, capturing screenshots, diagnosing failures,
  and adding new commands when the existing surface isn't enough. Also
  trigger when you catch yourself about to `pkill lunica`,
  write a temp `.rs` test binary to inspect rumoca state, chain a
  `sleep 30 && tail` poll, or ask the user "can you check the
  screenshot?". The right move is always: send a command, take a
  screenshot, read it, decide.
---

# Test the workbench via API

The production `luncosim` exposes a reflect-registered Event API on
`--api PORT` (default 4101). Always pass this flag when launching the luncosim, including
visual checks; use another explicit free port if 4101 is occupied. UI verification — diagrams rendering,
drill-ins, simulations, file ops — should be driven from this API
rather than asking the user to click.

## Mode policy

Use a **headful windowed `luncosim` session by default** whenever the work
concerns a scene, Editor, viewport, UI, motion, layout, or any result the user
needs to watch. Start the production binary with `--api PORT` and a graphical
display; do not add `--no-ui`, `--offscreen`, or another windowless flag. Keep
that window alive while iterating so the user can see each coherent change.

Use `--no-ui`/`luncosim-server` only for explicitly numeric or API-only checks
where there is no visual acceptance claim. Use `--offscreen` only when the user
specifically requests an offscreen recording or a graphics test that is meant
to run without a visible window. If a requested visual check cannot run
headfully, stop and report the display/session blocker instead of silently
falling back to headless mode.

## Live shader iteration

Shader source edits are a live-test path. Keep the production luncosim running, edit the
WGSL under `assets/shaders/`, then dispatch `ReloadShader` through the same API. A bare
engine path such as `shaders/starfield.wgsl` resolves the active default-source or
`lunco://` asset identity; an explicit `lunco://…` or `twin://…` path is exact. An empty
path reloads every currently loaded WGSL asset, not an arbitrary hard-coded file list.
The response reports the queued paths and fails if no active asset matches. Confirm the
command result, then inspect the unchanged window; shader compiler errors remain in the
render log. Do not relaunch the app just to pick up a shader edit.

## Tutorial and Rhai iteration

For Twin lifecycle regressions, open an editor document in Twin A, replace it
with Twin B, and inspect `ListOpenDocuments` plus USD preview state. No old
document ID or preview may survive; repeat `OpenTwin` on B to verify fresh
admission. A rejected Twin path must preserve the current documents. Twin
replacement closes loose/library editor documents as well as Twin-owned files;
`RestartScene` preserves editable documents and tests a different lifecycle.
Pending editor and workspace-restore work must not recreate old tabs after
replacement. Modelica `CloseDocument` is owned by the headless core, so exercise
scratch-document closure in both headless and windowed hosts.

The maintained document-close scene gate is
`scenes/tests/twin_session_retirement/twin_session_retirement.usda` with verdict
channel `TWIN_DOCUMENT_CLOSE`. Run it through `$LUNCOSIM_BIN test`. The
gate covers dirty SysML drafts as well as Modelica scratch documents; assert
actual domain retirement rather than only removal from workspace metadata.
The windowed switch/reopen/rejected-candidate gate is
`python scripts/api/test_twin_session_retirement.py`; set `LUNCOSIM_BIN` and
an explicit free `LUNCOSIM_API_PORT`. Its assertions are authored in
`assets/scenarios/tests/twin_session_retirement.rhai`, and its process wrapper
verifies API `Exit` and port release.
The driver waits for the fresh document's scene projection and readiness after
each replacement and captures generated
documents as well as editor documents. `LUNCOSIM_TEST_TWIN_A` and
`LUNCOSIM_TEST_TWIN_B` select exact native Twin roots; otherwise it uses the
maintained fixtures. `LUNCOSIM_TEST_SCREENSHOT` optionally captures the final
replacement before cleanup. Require the `TWIN_BROWSER_RETIREMENT` Rhai verdict
for the current file index and runtime owners, `TWIN_MODELICA_RETIREMENT` for
the Models document registry, and `TWIN_CATALOG_RETIREMENT` for retired metadata
and spawn sources. The driver covers OpenTwin, OpenFolder and native/file-URI
OpenFile replacement. Include a round trip: reopening
a Twin must resolve its authored logical dependencies through the fresh mount,
while requests from its retired origin remain rejected.
Set `LUNCOSIM_TEST_RECENT_MENU=1` to exercise the actual native Recent Twin menu
at the default desktop scale (the same coordinates as the loading-replacement
driver). The scene-only LoadScene and RestartScene checks preserve the open
Twin and require the replacement/restarted projection before their verdicts.
AddTwin and AddFolderToWorkspace preserve the existing documents and active
Twin; subsequent replacement must retire all added source catalogs too.

Tutorial behavior is authored in `assets/tutorials/**/*.rhai` and should be
tested through its production scene gate in `assets/scenes/tests/` with the
observer in `assets/scenarios/tests/`. After editing either Rhai file, rerun
`./scripts/run_scene_tests.sh --no-build --exact <scene-name>` for a single
scene, or `./scripts/run_scene_tests.sh --no-build <scene-substring>` for a
group; the runner uses four independent headless processes by default, and
`-j/--jobs N` changes that bound (`-j 1` is the serial diagnostic mode). This
does not change each gate process's deterministic `--threads 1 --jitter 0`, and
graphics assertions remain a separate serial offscreen pass. Do not rebuild the
Rust core for a script-only change. The observer must verify public `cmd:*`
events plus the resulting live state and emit a real verdict. Parsing or
`--validate` is only preflight evidence.

The generic model-authoring facades have the same production gate. The focused
fixture is `model_authoring`:

```bash
./scripts/run_scene_tests.sh --no-build --exact model_authoring -j 4
```

Its Rhai observer exercises `model_context`, `readiness_report`,
`scene_recipe`, `port_graph`, `wiring_plan`, and `publish_component`, including
fail-loud missing endpoint, control, asset, identity, and provenance cases.
Use this narrow gate after changing the tool or its authored fixture; a parse
pass alone does not prove the namespaced calls work in production.

The live port-owner collision contract is covered the same way:

```bash
./scripts/run_scene_tests.sh --no-build --exact port_owner_collision -j 4
```

Its USD fixture owns the duplicate and clean control cases; the Rhai observer
calls `RunLint` and verifies the structured `LintReport` winner, shadowed owner,
property paths, and precedence. Prefer this authored pair for observable lint
behavior instead of embedding USDA or fake backend components in Rust tests.

Public USD query-provider behavior belongs in authored `.usda` fixtures and
Rhai scene gates, exercised through the production query bridge. Keep Rust
tests for provider internals only when the behavior cannot be observed through
that public surface; do not construct `DocumentRegistry` worlds with inline
USDA to duplicate inspection, target-resolution, or synchronization behavior.
The `usd_query_api` gate covers those public contracts, while the assembly
proposal lifecycle gate covers edit-session inspection.

Editor material edits belong in an authored USD fixture plus an editor Rhai
scenario, not a Rust test with a multiline USDA string. The
`usd_material_edit_projection` fixture exercises typed material edits,
whole-source replacement, and the preview's projected-generation lifecycle
through the production editor runner. Keep Rust coverage for the focused
USD-to-render-intent mapping mechanism, where the mapping itself is the subject.

For source-preview isolation, run `scripts/api/test_usd_source_isolation.py`
with an exact `--scene`, `--source`, composed `--selection-path`, free `--port`
and `--log`. Its `RunScenarioAsset` invocation supplies all required parameters
to `assets/scenarios/tests/usd_source_isolation.rhai` and requires eight authored
checks, including invalid-source rejection, unchanged Twin/topology and
advancing physics. `--screenshot` records the exact viewport/selection context;
the shared `ProductionSession.capture_screenshot` requires fresh publication
before API Exit. Pair the resulting image with those handles when checking
Prim-tree reveal and highlight.

For spatial safety coverage, keep malformed authored transforms in the USD
projection layer: that layer must reject them before ECS materialization. Test
runtime-state admission at the `lunco-usd-avian` bridge owner, where a finite
but f32-unrepresentable pose must raise a named `RuntimeFaults` record and
`PhysicsHolds::SAFETY_FAILURE` before Avian runs. Test public `Raycast` and
`GroundHeight` with a finite but unrepresentable query origin through an
authored production scene; the expected result is `{hit:false}`, not a
terminal simulation fault. This distinction keeps query-input validation from
masking an engine-state failure and avoids duplicating guards in sensors,
terrain, and vehicle callers.

For terminal runtime-fault recovery, verify the two owners separately and then
the lifecycle seam. `RuntimeFaults` must pause `Time<Physics>` with a zero delta
while leaving `Time<Virtual>` available for diagnostics and teardown. The bad
scene is not repaired or resumed. `SceneTeardown` clears only the outgoing
scene's terminal fault and `PhysicsHolds::SAFETY_FAILURE`; the physics owner
also resets scene-owned holds, deliberate-step debt, and the physics clock
before replacement admission. The focused regression names are
`terminal_runtime_fault_pauses_physics_until_cleared` in `lunco-physics` and
`fault_then_scene_reload_can_admit_a_replacement_runtime` in `lunco-usd-sim`.

The multi-process scene-test runner cannot prove same-process replacement: its
expected terminal-fault process exits when the authored verdict is observed.
Use a lifecycle test or a live API session that explicitly submits teardown,
loads the replacement, and checks the new scene's admission/status. Do not add
an automatic repair, retry, process restart, or fault-clearing fallback to make
the invalid scene continue.

Scene, render, and editor acceptance runs are isolated by default and use a
fresh production process. Their launchers keep settings in memory and disable
runtime-overlay reads and writes regardless of Twin policy. The production
scene runner fails before scenario start if a file-backed authored USD
document is already dirty; runtime setup must not turn a clean fixture into
unsaved authored work.

For a one-shot assertion that needs the currently loaded USD stage, use
`./scripts/api/run_rhai_test.sh <port> <test.rhai> [probe-prim]`. It prepends
the test libraries and delegates to the native `luncosim rhai --stdout` client,
which calls `RunRhai` on the existing production session. Editing and rerunning
the test does not restart the app. Use `./scripts/api/run_scenario.sh`
when the assertion should remain attached as a persistent observer.

When a test depends on a Twin-scoped Rhai tool, register or reload that library
in the same session first, confirm it with `ListToolLibraries`/`GetToolLibrary`,
and make a minimal namespaced call before running the real observer. Discovery
does not prove that the current Rhai engine has rebuilt its static module set.
For a user-requested same-session workflow, use `RunRhai` or an attached
`RunScenario`; do not substitute the multi-process scene-test runner.

For an interactive lesson, keep one production session and use
`RunScenarioAsset` through `/api/commands`, then inspect the HUD and event
stream. `RunScenario` is the live hot-reload path for a script attached to an
existing host. Restart only when changing Rust or when a clean scene lifecycle
is itself under test.

To exercise a discrete semantic action, address the target's `api_id` and send
one `SimulateIntentEdge` command; do not model a pulse as two API requests:

```json
{
  "type": "ExecuteCommand",
  "command": "SimulateIntentEdge",
  "params": {
    "target": 1234,
    "intent": "release",
    "edge": "pulse",
    "producer_id": 4123
  }
}
```

The response includes the canonical `intent`, `edge`, `producer_id`,
`correlation_id`, and command `id`. Reuse the same nonzero `producer_id` for
commands from one API producer. API submissions also include `admission` with
the committed scene generation, effective simulation tick, and per-tick
sequence. Confirm
delivery through the `intent.edge` telemetry event (`source`,
`value.target_gid`, and `value.correlation_id` identify the same target/action);
its admission fields carry the same stamp. Use the returned `correlation_id`
for the exact edge's trace, even when the scene emits later edges:

```json
{
  "type": "ExecuteCommand",
  "command": "CausalTrace",
  "params": {"target": 1234, "correlation_id": 5678}
}
```

The response composes the authored binding, selected `PortRegistry` owner,
USD connection/native-joint admission, current retained measurements, and the
producer's admission stamp. An empty/pending stage is a real incomplete path.
A target-scoped command still passes the normal ownership/authority gate; an
acknowledgement alone does not prove that a consuming Twin policy acted on the
edge.

For a held control, send one `SimulateIntent` command with `held: true` or
`false` and a stable nonzero `producer_id`, reusing that ID for later commands
from the same API producer. An external command targeting fixed-simulation
state returns a correlation id and next-tick admission stamp; verify the
matching `intent.hold` event has the same producer, target, held value, and
stamp. The held state changes at that fixed tick. Local-embodiment commands
remain on the interaction cadence.

## Live runtime HTML/CSS iteration

The native `luncosim` UI watches the retained runtime surfaces under
`assets/ui/`. Edit a surface's `.html` or `.css` in place and inspect the same
window; HUI rebuilds the affected template and Flair reapplies the stylesheet
without a binary rebuild or relaunch. Editing `runtime_surfaces.json` rebuilds
the registered surface roots and action bindings. Rust exposure producers and
action observers still require a rebuilt production binary.

Use `ReadExposures` to verify the data side independently of the pixels:

```bash
curl -s -X POST http://127.0.0.1:4101/api/commands \
  -H 'content-type: application/json' \
  -d '{"type":"ExecuteCommand","command":"ReadExposures","params":{"surface":"driven-vessel"}}' | jq .
```

The response's `revision` changes only when an exposed value or visibility flag
changes. If it is stable, an unchanged runtime surface should not rebuild its
view-model. Use `CaptureScreenshot` for the visual check. `ReloadShader` and
`RunScenario` reload WGSL and Rhai respectively; neither reloads HTML/CSS.

Runtime UI is a small native HUI/Flair language, not a browser DOM. Do not
expect JavaScript, forms, text inputs, full CSS, or host-font fallback. Read the
[runtime UI skill](../runtime-ui/SKILL.md) for the supported surface contract,
placement/dock ownership, font rules, and performance gates.

## Session lifecycle

Each agent doing runtime work owns a luncosim session on a distinct explicit free
API port. Concurrent agent sessions are allowed on different ports. Launch from
the same repository checkout and working directory as that agent's terminal,
using that checkout's production binary. Before replacing your own session, send
`Exit` and verify its process and port are gone; never control another agent's
session or reuse an occupied port. Keep your current process for live shader/Rhai
edits; restart only when a rebuilt binary or an explicit clean session is required.

## Lifecycle (start → drive → stop)

```bash
# 1. Resolve the production binary in this checkout, choose a free API port
#    owned by this agent, and start it from this checkout's working directory.
#    Keep it alive in the runner's background session.
"$LUNCOSIM_BIN" --api 4101

# 2. Wait for the readiness contract, not just an open socket:
until curl -s http://127.0.0.1:4101/api/ready 2>/dev/null \
  | jq -e '.data.ready == true and .data.world_hold == false and .data.pending_count == 0' >/dev/null; do
  sleep 1
done

# 3. Send commands (see catalog below).

# 4. Stop with Exit, NEVER pkill / kill (user has to confirm those):
curl -s -X POST http://127.0.0.1:4101/api/commands \
  -H "Content-Type: application/json" \
  -d '{"type":"ExecuteCommand","command":"Exit","params":{}}'
```

After `Exit`, verify that the process and `:4101` listener are gone before
starting another session. A queued command or a reachable socket is not proof
that a scene is ready; `/api/ready` is the gate for scene load, Modelica compile
and participant initialization.
For scene replacements that change `ActivePhysicsFrame`, also query each new
dynamic body with `QueryPhysicsState` and require `physics_pose_seeded == true`
before declaring admission complete; this catches a physics clock blocked
before the replacement scene's first pose is written.

## Curl shape

Every typed command uses the tagged envelope
`{"type":"ExecuteCommand","command":"<Name>","params":{...}}`.
Include `params` even for parameterless commands. Built-in discovery and entity
listing use their own explicit `type` values.

```bash
curl -s -X POST http://127.0.0.1:4101/api/commands \
  -H "Content-Type: application/json" \
  -d '{"type":"ExecuteCommand","command":"OpenClass","params":{"qualified":"Modelica.Blocks.Continuous.PID"}}'
```

Successful fire-and-forget response: `{"data":{"accepted":true}}`. A result-returning typed command puts its command-specific payload in the same `data` envelope. Malformed envelopes are rejected at the transport boundary, and invalid typed parameters return HTTP 422. A deferred command may resolve its acknowledgement on the same request, but that is not necessarily completion of the domain work. `RunExperiment` returns its exact `experiment_id` once registered; the numerical solve remains asynchronous and is read through `RunStatus` and `GetExperimentResult` using that id. Rhai's in-process `cmd` may expose a pending command id; `command_result(id)` resolves that deferred command acknowledgement only. There is no generic HTTP command-id polling endpoint, and callers must not guess the run from its label or newest position.

### Loading a scene or model

Choose the command that owns the address type and check its result:

| command | takes | notes |
|---|---|---|
| `OpenTwin` | a **folder** containing `twin.toml` | auto-loads `[usd] default_scene` |
| `LoadScene` | a root-qualified `twin://` or `lunco://` address | mounts a scene address; it is not a filesystem opener |
| `OpenFile` | a filesystem path or supported URI | extension-routes the document to its owning domain; USD paths resolve their Twin root |

Passing the `.usda` *file* to `OpenTwin` fails the `twin.toml` check and is
refused with a `warn!`.

`LoadScene` is not a general file opener. Bare and absolute filesystem paths
are refused with

```
[scene] `…` is not a root-qualified scene address — LoadScene takes `lunco://…` or `twin://…`
```

The command returns a terminal rejection before admission; the currently mounted
scene remains active. Read the command result and query the active scene before
trusting a screenshot. Use `OpenFile`
for a filesystem path; it resolves the workspace layer and preserves the
document-first mounting contract.

`CaptureScreenshot` returns the PNG as the **response body**; write those bytes
yourself rather than relying on `save_to_file`.

### Validate an asset without loading it

`ValidateAsset` prepares fresh file facts without mounting a scene. Its initial
query returns `pending` with an `operation_id`; poll the same query with only
that ID. The consumed `ready` result contains `report` and actual
`source_revisions`; `failed` contains a terminal diagnostic. Retired or consumed
IDs reject. Do not resubmit the initial path while waiting.

```bash
curl -s -X POST http://127.0.0.1:4101/api/commands \
  -H "Content-Type: application/json" \
  -d '{"type":"ExecuteCommand","command":"ValidateAsset","params":{"path":"lunco://models/LunCo/Electrical/Battery.mo"}}'
```

The native CLI uses the same validators without constructing an app:

```bash
"$LUNCOSIM_BIN" --validate assets/models/LunCo/Electrical/Battery.mo
```

Full runbook — per-extension checks, exit codes, and the CWD path-resolution
trap: [`validate-assets`](../validate-assets/SKILL.md).

### Validate a Twin namespace without loading a scene

`ValidateTwin` is the read-only Twin-wide counterpart. Pass an explicit local
folder and use `policy: "error"` in CI when a resolver collision must fail:

```bash
curl -s -X POST http://127.0.0.1:4101/api/commands \
  -H "Content-Type: application/json" \
  -d '{"type":"ExecuteCommand","command":"ValidateTwin","params":{"path":"/work/rover-twin","policy":"error"}}'
```

Poll the returned `operation_id`; `ready.report` contains indexed entries,
resolver scopes, collisions, source-read errors, and namespace findings. For a
mounted browser Twin pass its current `twin://<assigned-authority>` instead of
a native folder. For the active Twin after `OpenFolder`/`OpenTwin`, use
`cmd("RunLint", #{scope: "twin", policy: "warn"})` and read
`query("GetDiagnostics", #{scope: "twin"})` for the returned lint revision.
The queued Ack is admission information; the scope's pending/ready/failed state
is the terminal report. Native and mounted browser Twin lint share the same
async preparation. Unreadable or invalid sources fail the scope even with
`policy: "warn"`; retirement or supersession discards the exact operation.
Loaded-stage lint remains available on both platforms.

## Command ownership

This skill owns the generic API envelope, runtime lifecycle, screenshots, and
end-to-end evidence. For Modelica-specific loading, compile/run, experiment,
and plot commands, use [`run-modelica`](../run-modelica/SKILL.md); keep that
catalog in one place.

## Verification workflow

```
1. Start workbench (run_in_background:true).
2. Monitor until READY.
3. OpenFile or OpenClass to load model.
4. Wait ~3-5s for rumoca parse + projection (background tasks).
5. OpenClass or drill action if scoping to a sub-class.
6. Wait ~3-5s for the post-drill projection to land.
7. FitCanvas + sleep 1.
8. CaptureScreenshot → /tmp/foo.png.
9. Read the PNG to inspect.
10. Check the process log for lines like `[Projection] import done in Xms: N nodes M edges`.
11. Exit when done.

## Rover modeling loop: reload the live scene, then measure it

For a world-direction tracker, use the API to verify one complete coordinate
chain after reload: target vector in the mount frame, controller setpoint,
measured joint angle, and rendered boresight. Do not accept a controller's
internal `locked` state alone; it can be self-consistent with an incorrect axis
or boresight convention.

Keep one luncosim process running while iterating on a rover. Edit the USD, then
use `OpenFile` for a file-backed asset, `RestartScene` for the mounted scene, or
`ApplyUsdOp` for an in-place authored opinion. Reattach a diagnostic script with
`RunScenario`; this hot-reloads only that script. Read `ScriptInspect`,
`QueryEntity`, `rover_status`, and relevant ports while the simulation is live.

A rover test must report measured telemetry and movement, not merely compile or
compare two values at rest. Use `luncosim test` for deterministic CI verdicts,
but keep the live API check because it exercises the production reload and
command paths. Do not add a second reload command or a standalone rover test
binary.

For presentation work, establish the acceptance chain in order: builtin
raycast drive first (`DRIVETRAIN PARITY: PASS`), then the Modelica drive-law
overlay (`MODELICA DRIVE LAW: PASS`), then optional power/thermal/autonomy.
Never use a visual screenshot as a substitute for either verdict: a rover that
does not move, or one still driven by the builtin kernel after a failed Modelica
overlay, can look plausible in a parked frame.

Partial USD object/reference reload is intentionally not exposed yet. Until its
composition, connection, and Modelica-worker lifecycle are implemented as one
operation, use the full `RestartScene` reload for rover tests. A successful full
reload must re-run USD prim projection, cosim model creation/compilation, and
connection rewiring before the test verdict is trusted.

```

For a placed rover, also query the composed USD transform after reload. Check
that every authored rotation op appears in `xformOpOrder` and that the effective
heading comes from one placement layer. For a fixed solar panel, list the
composed `SolarPanel`, `Battery` and rover-root network entities, then read the
rover-root boundary ports. Presence of a panel mesh is not a power verdict: require
positive `solar_power`/panel `power_out`, a valid incidence, and battery current
or changing `soc`.

## Production tutorial tests

Tutorial acceptance belongs to the production `$LUNCOSIM_BIN` binary.
Build that binary in the worktree, run the scene-test command directly, and
capture its exit code and authored verdict. `--validate` proves only USD
parsing; a successful acknowledgement proves validation and dispatch, not that
the simulation has finished its work. A live API check must also wait for `/api/ready` to report `ready:true`,
`world_hold:false`, and `pending_count:0`.

Autopilot checks should observe the same `AcquireControl` and port-write events
as a human control sequence, plus a real movement/port predicate and the final
goal. Keep declared cosim topology separate from current samples: a connection
may resolve before the first sample, but an absent value is not a valid zero.
The complete boundary is in
[`tutorial-autopilot-and-port-contracts`](../../docs/architecture/tutorial-autopilot-and-port-contracts.md).

For source-backed program authoring, query `ListOpenDocuments` for the USD
document, dispatch `AttachProgram`, then verify `ListPorts`, `CosimStatus`, and
`GetBrokenConnections`. The production Rhai gate is:

```bash
"$LUNCOSIM_BIN" test \
  --scene scenes/tests/program_attach_command.usda --max-ticks 3000
```

It proves both a declared Modelica participant and the visible error status for
an attached source with no port contract. Do not treat a prim appearing in the
scene tree or a fire-and-forget command acknowledgement as a running model.

## Diagnosing common failures

- **Non-Unicode native CLI arguments**: the process rejects them with exit code
  2 before startup. Use the storage-owned file URI conversion when a native
  filesystem address must cross a Unicode command boundary.
- **"0 nodes 0 edges" after drill-in**: the target class resolved but
  conversion dropped nodes. Check:
  1. `InspectActiveDoc` → are the components really there in the AST?
     If not, parse failed.
  2. If components exist: their TYPES probably aren't in
     `local_classes_by_short` or the source-library palette. The diagram-builder
     registers the target's nested + sibling classes (sibling-pass in
     `panels/canvas_projection.rs`, the `local_classes_by_short`
     registration); connector types need to be in
     `library_index.json` (regenerate via
     `cargo run -p lunco-modelica-assets --bin modelica_library_indexer`).
- **"Command 'X' not found or not API-accessible"**: the Event isn't
  reflect-registered. Put a shared Modelica-facing payload in
  `lunco-modelica-ui-core`; keep its observer in the owning UI package, give
  its observer the `#[on_command(X)]` attribute, and list that observer in the
  `register_commands!(...)` block in
  `crates/lunco-modelica-ui/src/ui/commands/mod.rs` (see [§ Add a command](#add-a-command)).
- **API returns 500 / silent no-op**: check `params` includes the
  empty object `{}` even for parameterless commands.
- **Projection deadline exceeded (60s)**: rumoca parse stall, usually
  from a synchronous library load inside the worker pool. Move heavy loads to
  a separate `std::thread::spawn` and use the cache-only source-aware resolver
  in the projection (`peek_class_cached`).
- **A rebuilt binary is not visible**: replace the session through `Exit`, verify
  port 4101 is free, then start the rebuilt production binary.

## Add a command

When testing reveals a missing API surface, add the command immediately
rather than asking the user:

1. Put a shared command payload in its domain's contract crate when another
   package must emit it (for example, Modelica-facing payloads in
   `lunco-modelica-ui-core`, or shared scene-edit payloads in
   `lunco-scene-command-contracts`). Define it with `#[Command]`. Keep the
   observer in the behavior-owning package and mark it with `#[on_command(...)]`
   (both attributes come from `lunco_core`). For the Modelica UI, the observer
   file is under `crates/lunco-modelica-ui/src/ui/commands/`:
   ```rust
   use lunco_core::{Command, on_command};

   #[Command(default)]              // or `#[Command]` if you impl Default
   pub struct MyCommand { pub foo: String }

   #[on_command(MyCommand)]
   pub fn on_my_command(trigger: On<MyCommand>, mut commands: Commands) {
       let foo = trigger.event().foo.clone();
       commands.queue(move |world: &mut World| { /* ... */ });
   }
   ```
   `#[Command]` emits the `Event`/`Reflect`/`reflect(Event)` derives and
   `#[on_command]` generates the `register_type` + `add_observer` wiring —
   you don't write them by hand.
2. Add the observer fn to the `register_commands!(...)` list in
   `crates/lunco-modelica-ui/src/ui/commands/mod.rs` (use the
   `module::fn` path form, e.g. `inspect::on_my_command`).
3. Build, restart workbench, curl it.

## What NOT to do

- Don't `pkill -f lunica`. The user has to confirm; use
  `Exit` command.
- Don't write standalone test binaries / temp `.rs` files to verify
  rumoca behaviour. Add an `Inspect*` command if the workbench can't
  already surface what you need.
- Don't chain `sleep 30 && tail ...`. Use Monitor with an `until` loop.
- Don't ask the user to take a screenshot or check anything visually
  unless API verification is genuinely impossible.

### Native mouse look

`InjectWindowInput` distinguishes absolute `PointerMove { x, y }` from raw
relative `MouseMotion { delta_x, delta_y }`. Cursor coordinates drive picking
and egui; raw motion drives the native camera input map. For mouse look, hold
the configured `input_binding("look_button")`, emit raw motion, and release the
button. Cursor positioning alone does not rotate a camera. The bridge emits
both the native `WindowEvent::MouseMotion` and typed Bevy `MouseMotion` message.

For path interoperability, run the production `twin_search_paths` scene gate:
it exercises layer/Twin search precedence and imports, tool discovery, and
timeline discovery with literal `#`, `%`, and spaces in filenames. Distinguish
Linux runtime evidence from Windows-native CI tests. Root resolution and
application builders are fallible; an invalid `LUNCO_ASSET_ROOT` must exit with
a diagnostic rather than panic or silently discover another library.

`python scripts/api/test_path_interoperability.py` uses an owned windowed
production session on `LUNCOSIM_API_PORT` (default 4193) and a fresh temporary
Twin below `target/`. Its Rhai observer verifies Modelica documents through
literal filenames, canonical-root and case-only renames, rejected existing or
nonportable targets, and save/readback at the renamed path. It authors a USD
document through typed document operations, saves it with a literal filename,
and admits it through the Twin's default scene to verify its Modelica source
equation and loaded canonical stage. API `Exit` must release both the process
and port.
