---
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 = <your control law>;                 // 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 = </Lander.outputs:low_fuel>; 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@</Altimeter>)
{ 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 = </Lander.outputs:velocity_y>`,
`float inputs:altitude.connect = </Lander/Altimeter.outputs:range>`. 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 = </Lander.outputs:mass>`,
`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 = </Lander.outputs:piloted>`) 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@</RoverControls>   # lander: </LanderControls>
)
{
}
```

- 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 (`</LanderControls>`)
   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.
