--- name: authoring-vessel-controllers description: > Author vehicle controllers in LunCoSim so spacecraft, landers, rovers, and drones can move, fly, drive, land, or accept pilot control. Use when adding or debugging autopilot, GNC, guidance, waypoint following, thruster response, manual takeover, a Modelica control model, a controller program prim, or the wired `piloted` authority signal. This skill assigns control math to Modelica, sequencing and events to Rhai, and structure, sensors, wiring, and authority to USD. It covers the unwired-input failure mode, PortRegistry input ownership, sensor-based feedback, and reuse of the closest production exemplar. The shipped lander is an example, not the universal production contract. --- # Authoring vessel controllers Read [`luncosim-architecture`](../luncosim-architecture/SKILL.md) before adding a sensor, actuator, generated network, or custom USD field. This skill owns the vessel-controller recipe; the architecture skill owns the standard-schema and no-compatibility gate. A vessel that drives itself (a GNC, an autopilot) is built from **three layers, each in the language that fits it**. Never blur them. | Layer | Language | Owns | Rule | |---|---|---|---| | **Control LAW** | **Modelica** (`.mo`) | the math: PID, schedules, mixing, τ=I·α | ALL control math lives here. Never compute a control law in rhai. | | **Logic / sequencing** | **rhai** (`.rhai`) | phases, events, mission steps, reactions | EVENT-DRIVEN only. No per-tick control loops, no time-stepping. | | **Structure / wiring / authority** | **USD** (`.usda`) | sensors, wires, possession, identity | Declarative. Sensors are referenced library prims; a wire is a native USD connection. | The shipped lander is one reference implementation: `assets/models/Lander.mo`, `assets/scenarios/lander_subsystems.rhai`, `assets/vessels/landers/descent_lander.usda` (referenced by `assets/scenes/luncosim/lander_ops.usda`). For another vehicle, select the closest **production** controller, vehicle asset, composing scene, and test before authoring a new one. A tutorial model can clarify the idea while still being too simplified to serve as the production contract. ## Start with the closest production exemplar Before writing a new controller: 1. Search `assets/models/`, `assets/vessels/`, `assets/components/`, `assets/scenes/`, and `assets/scenarios/` for an existing controller and its consumer. 2. Read the model's declared inputs/outputs, the USD wires, and the scene test that observes the behavior. 3. Reuse the same authority, sensor, frame, and actuation boundaries. Override vehicle facts in USD and controller tuning through the established input or parameter contract. 4. Add a new `.mo` only when the existing equation or public interface is genuinely insufficient; do not fork a working controller for naming alone. This is a discovery rule, not a requirement to reuse one particular vehicle. For a new controller assembly, use the generic Rhai authoring checkpoint before debugging the equations: `model_context` reads the exact program/body tree, `readiness_report` checks the caller's topology, physicality, mount, connection, control, and runtime policy, and `port_graph`/`wiring_plan` validates the standard USD endpoint paths and types. Keep the controller's continuous law in Modelica and the phase/mission policy in Rhai; these facades only inspect and return dry typed USD plans. See the [model-authoring guide](../../docs/scripting-guide.md#model-and-assembly-authoring-human-and-ai). When propellant changes a hull's mass properties, connect the Modelica mass, body-local COM, and diagonal inertia outputs to the physical Avian `inputs:mass`, `inputs:com_x/y/z`, and `inputs:inertia_xx/yy/zz` through the document API. Controller inputs such as `controller_inertia_xx/yy/zz` consume separate wires; those inputs do not update the physical body. The live physical endpoint retains native f64 solver values through collider recomputation. Scalar inertia updates require an axis-aligned tensor and reject nonzero cross terms before commit; they cannot represent a coupled tensor update. Keep articulated appendages as their own bodies: the joint-island mass outputs are complete translational mass, not an assumed rigid assembly inertia. Verify live burn mass, COM, and inertia against `QueryPhysicsState` in the owning authored Rhai scene test. See the [lander mass-property contract](../../docs/architecture/lander-actuation-modelica.md#visualization-and-live-state). ## 1. The control law → a Modelica model The model reads what the vessel **senses** and outputs force/torque. It is a PROGRAM, and a program is a prim: the vessel's own flight-control system is inseparable from the airframe, so the vessel prim applies `LunCoProgramAPI` and names the model in place — `uniform asset info:sourceAsset = @models/MyController.mo@`. Its `inputs:` ARE the vessel's control surface. A control law that is *bolted on* (a guidance component, a supervisory script) is a a `Scope` applying `LunCoProgramAPI` CHILD prim instead, so deleting the prim removes the behaviour. Ports are wired with native USD connections (§3). Because it drives a force on a body the client predicts, it must promise it steps fast enough for that: `uniform bool lunco:program:realtimeSafe = true`. Without the promise the wiring pass refuses it a `force_*`/`torque_*` port and says why. ```modelica model MyController input Real altitude, descent_rate; // SENSED (wired from sensors, see §3) input Real piloted = 0.0; // authority gate (wired, see §4) input Real external_throttle = 0.0; // the pilot's stick (when piloted) output Real force_y, throttle; Real gnc_throttle, cmd; equation gnc_throttle = ; // math, DIRECT (no lag) cmd = piloted*external_throttle + (1.0-piloted)*gnc_throttle; // yield-to-pilot gate force_y = cmd * max_thrust; end MyController; ``` **Gotchas that will waste hours if you don't know them:** - **rumoca folds unwired, algebraic-only inputs to their default.** An `input Real x` used only in algebraic equations, never wired and never written, is constant-folded → runtime writes to it never reach the solver. To keep an input LIVE, either **wire it** (see §3/§4) or **route it through a `der`** (`der(x_live)=(x-x_live)/0.02; use x_live`). Symptom: `set()` returns true but has no effect. - **Inputs that feed a `der` are always live** (a state depends on them) — that's why `external_throttle`/`pitch` are spool-filtered (`der(filter)=(cmd-filter)/tau`): it both gives pilot feel AND keeps them live. - **Keep the control (GNC) path DIRECT — no spool.** A lag on the autonomous path makes it sluggish and can tumble the vehicle. Spool only the pilot's stick. - **rumoca mis-lowers `if` on algebraic vars.** Use `min`/`max` for clamps and a branch-free arithmetic blend (`a*x+(1-a)*y`) for selection, never a nested `if`. For a rover route, the drivetrain and any continuous control law remain the actuator-side Modelica/USD contract. Both skid `RoverDrivetrain` and wheel-steered `RoverAckermannDrivetrain` consume `parameters:forward_yaw_offset` on the drivetrain prim to align guidance with the wheel travel frame. Navigation uses −Z forward by default; a body-local +X wheel heading requires −π/2. Derive the travel direction from the authored steering axis crossed with the wheel axle, and keep that frame parameter when switching drive laws. Verify waypoint arrival in the authored production route test with its actual axle frame. A scene-level Rhai route program reads the route's composed point prims, waits for generic sensor enter events, and publishes current named-port guidance through the shared bridge. The route is not stored on the rover. User possession controls the local `ControlLink`, HUD, and manual-input session; possession alone does not stop an enabled route program. The shared controller requests `ClaimControl` when the operator first presses a bound intent on a target owned by another session. The existing `control.authority.take` Rhai policy decides whether that handoff is allowed. After the claim, the target-scoped semantic edge reaches the route policy and the held port command is applied. Route editing resolves composed `LunCoProgramAPI` and `inputs:subject` bindings, including when the pointer context has no selected or possessed subject. Identity and hierarchy queries explicitly request `attrs: []`; omitted `attrs` reads every authored attribute. Discover programs through bounded, branch-local `QueryUsdPrims` batches: Rhai limits aggregate string bytes in nested values, so a whole CAD hierarchy level can exceed the limit even without attributes. Verify both a unique unpossessed route and ambiguity rejection, and exercise the active `scene_interaction` hook through native window input for UI acceptance. ## 2. High-level logic → rhai, event-driven A a `Scope` applying `LunCoProgramAPI` child prim on the vessel, naming a `.rhai` scenario (`uniform asset info:sourceAsset = @scenarios/my_supervisor.rhai@`), does supervision and sequencing — **never a control loop**. React to events; don't poll or step. ```rhai fn on_event(me, evt, ctx) { if evt.name == "lander_touchdown" { /* advance the mission */ } if evt.name == "low_fuel" { notify_kind("Low fuel", "warn"); } } ``` - Phase timing comes from the mission sequencer (`wait`, `wait_for`, `wait_until`) or from Modelica condition outputs connected to `LunCoEvent` prims, not `dt` counting: `def LunCoEvent "LowFuel" { float inputs:trigger.connect = ; uniform token lunco:event:name = "lander_low_fuel"; uniform token lunco:event:severity = "warning" }`. - **Do not** write the vessel's command ports every tick from rhai. If you're tempted to, the logic belongs in the model (math) or the wiring (authority). ## 3. Sensors → USD library primitives, wired The controller reads SENSORS, not the god-view body. Sensors are reusable prims in `assets/vessels/sensors/` (`imu.usda`, `altimeter.usda`), referenced + mounted: ```usda def "Altimeter" (prepend references = @../../vessels/sensors/altimeter.usda@) { double3 xformOp:translate = (0, -3.3, 0); uniform token[] xformOpOrder = ["xformOp:translate"] } ``` A wire is a native USD connection, authored on the prim that CONSUMES the value — `float inputs:descent_rate.connect = `, `float inputs:altitude.connect = `. Wired inputs are live (they reach the solver). Physical constants a model needs (mass, inertia) come from the body's own ports (`inputs:vehicle_mass.connect = `, `inputs:inertia_xx.connect = …`) — USD-derived, not magic numbers. A **Modelica parameter** is authored as a typed USD constant and is placed in the compile-time parameter set by contract classification. A runtime Modelica `input` is a live signal and needs a native USD connection or an explicit runtime writer; do not infer its kind from the USD spelling alone. Rust publishes only generic built-in observations. The sensor asset and USD connections identify placement and topology; Modelica performs filtering, frame conversion, navigation, and control. Do not add a semantic sensor registry, a world-coordinate force special case, or a fallback port when the parsed Modelica contract does not match the authored scene. ## 4. User possession → the `piloted` signal + `ControlLink` **This is the key pattern. Do not build a bespoke gate.** - **The GNC is INTERNAL** (part of the model). A local or remote user is an external session that may possess the vessel; `SessionRegistry` and RBAC (`may_take_control`) arbitrate those user sessions. An authored mission or route program is scenario policy, not a possession session. - The internal controller **yields** to whoever possesses via the **`piloted`** port: a read-only cosim port (`PILOTED_BACKEND`, `lunco-cosim/src/ports.rs`) that is `1.0` when any session owns the vessel (`SessionRegistry::owner_of(...).is_some()`), else `0`. - Wire it (`float inputs:piloted.connect = `) into the model as the manual-input authority signal. Gate the pilot stick with it, then combine manual input with authored program/autopilot inputs through the model's explicit authority signals. Do not let a possession change replace or zero an active program's enable, target, or speed inputs. Possession owns the manual input claim; authored model policy owns the command mux. - When a bound local control intent arrives while another session owns the target, the shared controller requests the existing generic `ClaimControl` transition. The authored `control.authority.take` policy decides whether the local session can take the endpoint; Rust does not special-case an autopilot role. - The pilot's stick reaches `external_throttle`/`pitch`/… through the vessel's intent→port `Controls` scope (next section) when they possess. Camera-follow without taking control: `follow(entity)` (inserts a chase camera, no `ControlLink`). Scene replacement clears possession claims for outgoing USD prims at the shared `SceneTeardown` boundary. Because claims use stable `GlobalEntityId` values, a replacement projection may reuse an id without inheriting the previous scene's driver; persistent non-scene ids are not cleared by that sweep. `AcquireControl` and `ReleaseControlSource` are the single owner of the possession transaction: they validate the endpoint and local binding before changing `SessionRegistry` or `ControlLink`. A handoff releases prior claims for that session (except the selected target). Releasing possession removes the local control/camera binding and updates the manual-input authority; it does not write endpoint ports or stop an authored autopilot. Wire-applied commands update host authority only and never bind a remote session to the local camera. Explicit endpoint lifecycle stops remain separate from possession release. ### Guidance policy is separate from user possession A route or mission program publishes authored guidance through the vessel's generic named-port surface and the model's existing guidance/actuation contract. It does not call `AcquireControl` or claim a user session. Releasing possession leaves the program's input ports and enable state intact. The generic `route_follow` policy stops guidance on a pressed or pulsed non-`Action` `intent.edge` from its currently possessed subject; input from the free avatar or another unpossessed surface cannot stop it. `Action` remains the route toggle. Other authored autopilots should consume the same semantic edge contract to yield. When a mission also drives that subject, retire its driving branch on the operator route's `route_started` and `route_stopped` events. Subscribe to the running script host (`usd_path(me)` at the emitter), which can be the parent scope of the `LunCoProgramAPI` source prim. Retiring a mission must preserve a newly started operator program's guidance writes. Test the HUD handoff while mission guidance is active immediately after deployment, then verify it remains stopped across subsequent ticks and a scene reload. An attended deployment may explicitly hand the local operator to the new vehicle with `AcquireControl { bind_camera: true }`. A host-authority claim with `bind_camera: false` does not move local keyboard input to that vehicle. Keep this discrete operator handoff separate from publishing guidance. Do not set `Position`, `LinearVelocity`, `ModelicaModel.inputs`, or a private actuator component to make a scenario move; those bypass the authored input and model contracts. ## What makes an entity *active* — the intent→port `Controls` scope A vessel is **possessable + drivable** when it carries two things: 1. **An actuation surface** — the command ports a pilot or AI writes. A rover gets `throttle/steer/brake` from its composed USD/model network (the projection stamps a separate `MobilityRoot` and `OutputPorts`); a cosim vessel gets its `.mo` inputs (`external_throttle`, `pitch`, …). This surface is topology-derived; you don't hand-write it. 2. **A `Controls` scope** — the intent→port map (stage 2 of control), read into a `lunco_control_core::ControlBinding`. Without it a vessel can be possessed but **keyboard input does nothing** — `drive_from_bindings` skips a bindingless vessel. (API / `set_input` / rhai can still drive it by port name — that path needs only the surface.) For desktop manual control, the workbench owns the UI-to-simulation focus boundary. A press resolved to the main 3D scene surrenders retained egui `TextEdit` focus before it publishes `EguiFocus`, so a possessed vessel can receive the shared input map after a perspective switch. Active text fields continue to capture keyboard input until that explicit scene press. The controller must consume this published focus state; it must not read raw keys again, clear focus itself, or add a vehicle-specific input path. The local `Embodiment` is a separate domain role. It also has an `InputPorts` surface and binding, but those ports are its free-flight embodiment and are not a vessel possession target. Click resolution and `AcquireControl` enforce this boundary by accepting a non-`Embodiment` input surface; `SceneCamera` is presentation metadata and does not participate in possession. Author the scope as a **child `references` arc** to the shared profile — the SAME arc kind the wheels use, so it composes through a spawn/reference. Root `subLayers` and `inherits` do not provide the required spawned control profile. ```usda # on the vessel prim — a rover: def "Controls" ( prepend references = @../control_profiles.usda@ # lander: ) { } ``` - Profiles live in `assets/vessels/control_profiles.usda`: `RoverControls` (forward/back→throttle, left/right→steer, brake→brake) and `LanderControls` (forward/back→body pitch, left/right→body roll, yaw_left/right→body yaw, thrust→external_throttle, release→release). The lander profile selects a stable `orbit` camera: camera orientation never remaps these body axes. W/S/A/D/Q/E and G are only the bundled `input_bindings` labels; help must resolve the current settings rather than hardcoding those keys. In the bundled lander mission, G writes the authored `release` input; the scene Rhai program edge-detects it after touchdown and calls generic `DetachJoint` for the authored dock. Keep that delivery policy in Rhai, not in a vehicle-specific Rust port backend. The path is relative to the vessel file (`@../../control_profiles.usda@` one dir deeper, `@../../vessels/control_profiles.usda@` from a scene). - **Override one intent** by redefining that child locally over the reference: `def "Controls" (references=…) { def "action" { uniform string lunco:port = "handbrake" } }`. - **A new control scheme** = new intents in the referenced profile (or authored inline) — data, not Rust. The key→intent half is the shared leafwing `UserIntent` map, so a saved keymap rebinds every vessel; you only choose what each intent *actuates* here. - The default P binding is the shared `pause` intent. The avatar hotkey toggles `SetTimeTransport` from that intent; it is not a vehicle port or a raw-key controller special case. - **Make an entity drivable at RUNTIME**: author the `Controls` child (and give it an actuation surface) via the USD-op API on the new prim — it composes immediately and the possessing avatar can drive it. No Rust, no restart. This is how you "build a new entity and teach the avatar to control it." For discrete controls, use `intent_pulse(target, "release")` or `intent_edge(target, intent, edge)` from the control prelude. The runtime publishes one target-scoped `intent.edge` event for `pressed`, `released`, or `pulse`; consume it in the authored Rhai supervisor and let that policy drive the appropriate Modelica/port behavior. Do not emulate a pulse with two ordered held commands, and do not add a vehicle-specific Rust action path. The helper returns the edge command `id`. Use it with `query("CausalTrace", #{target: target, correlation_id: edge.id})` to inspect the current authored binding, selected public port owner, connection/native joint admission, measured channels, and the edge's classified producer origin. Rhai-origin records include the owner cycle, generation, logical sequence, and the stable actor id when issued by a Twin scenario. Twin scenario helper calls omit `producer_id`; actorless application Rhai and API/direct typed callers supply a stable nonzero ID. External discrete edges include their committed scene generation, effective tick, and per-tick sequence; deterministic Rhai simulation edges have no external admission stamp. This is a diagnostic snapshot; an empty or pending stage means the path is incomplete. The bounded trace is not a session replay log. ## The recipe (checklist) 0. Complete the exemplar audit above and identify the actual controller, vehicle, scene, and test owner before creating files. 1. Write or reuse the control law as a `.mo` model: sensed inputs → force/torque; `min`/`max` clamps; DIRECT control path; a `piloted` gate. Der-feed any tunable gain you want Inspector-editable at sim-rate. 2. Reference the sensors it needs from `assets/vessels/sensors/` and mount them. 3. On the vessel prim: apply `LunCoProgramAPI`, name the model (`uniform asset info:sourceAsset = @models/MyController.mo@`), promise `uniform bool lunco:program:realtimeSafe = true`, author the connections (sensor + body ports → model `inputs:`, incl. `inputs:piloted`, and model force/torque → the body), and add a `Controls` child that `references` a profile (``) so the pilot's intents reach the stick ports. 4. Add a `Scope` applying `LunCoProgramAPI` child prim naming a `.rhai` supervisor for events/sequencing (no control loop), with connected `LunCoEvent` children for model conditions. 5. Verify: unpossessed → the GNC flies it; possess → the pilot drives (gate flips via `piloted`); release → GNC resumes. Tune live via the Inspector or `set()`. ## Anti-patterns - ❌ Control math in rhai — belongs in Modelica. - ❌ Per-tick rhai routing / an unconditional self-wire — clobbers the pilot; use the `piloted` gate instead. - ❌ An in-model `manual` flag toggled at runtime — folds unless der-fed; and it's per-model. `piloted` is the general, wired, first-class signal. - ❌ Reading the god-view body pose — read sensors (altimeter, IMU) so it's a real GNC. - ❌ Magic constants (torque, mass) — wire them from the body's ports (inertia, mass). - ❌ Starting a new controller from a mission report or tutorial snippet without reading the closest shipped production exemplar and its runtime test. - ❌ Forking a production controller only to rename the vehicle — keep reusable equations and put vehicle-specific facts in USD-authorized parameters.