--- 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. 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 ` for a single scene, or `./scripts/run_scene_tests.sh --no-build ` 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` with a zero delta while leaving `Time` 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 [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":"","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://` 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, 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.