Locomotion map: one full-wrench solve, a predicting planner, and TorqueFloor dies

Q5 is dissolved rather than answered: the controller asks for the force and
the torque it wants together and the solver minimises both, so the lurch is
spent only when the translation was wanted anyway. The planner rolls
candidates forward and scores where the ship ends up, which is what makes
strafing beat the big circle. The legacy hulls' second behaviour is
VelocityLimit, not VelocityConversion, as the operator said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 20:50:14 +02:00
commit 6f4c11fc01

View file

@ -2,8 +2,10 @@
Date: 2026-09-22
Status: target and cut map, from an Imagination pass, revised 2026-09-22 after the operator ruled Q1, Q2 and
Q3 and asked for the legacy thruster hulls back. None of it has landed. **The cuts are renumbered by that
Status: target and cut map, from an Imagination pass, revised twice on 2026-09-22: first after the operator
ruled Q1, Q2 and Q3 and asked for the legacy thruster hulls back, then after the operator approved the content
inventory, corrected the hull-behaviour guess and dissolved Q5 by replacing the controller's rotation demand with
a single full-wrench solve. None of it has landed. **The cuts are renumbered by that
revision**: the content restore is the new Cut 1, and what the first committed version called Cuts 1 to 4 are now
Cuts 2 to 5. Every anchor is against
`origin/codex/item-provenance` at `cd846916`; the working tree on `codex/fire-control-10` (`e8fbb6a6`) has an
@ -71,6 +73,25 @@ Operator rulings on this map, 2026-09-22, relayed by Self:
- **Restore the legacy thruster hulls.** Asked whether the lost layouts could come back: "can we restore these
layouts?" They become Cut 1, the campaign's first cut, ahead of the solver.
Second round, same day:
- **Cut 1 is approved as inventoried, optional gear included.** "Yes and yes": the Longinus and Djinni hulls,
Medium Drive, and Small Drive, Victoire, Talaria and RevvITup 2.0 as well.
- **The second hull behaviour is not `VelocityConversion`.** "I don't think VelocityConversion was on either
hull." The decode agrees: it is `VelocityLimitData`. See Cut 1.
- **Q5 is dissolved, not answered: the rotation demand is not sized at all.** "It should only do that if the
ship's controller also *wants* to accelerate forwards that much, but I'm not sure how to encode that while
allowing the lurch. Like, lurch burn to compensate for a missing attitude thruster is also only a good move when
you both need to turn a bit and don't mind being all the way over there after. It doesn't help when, say,
there's a guy to port and your aft thruster is out, because you'd just end up turning in a great big circle
without changing your bearing at all." On the shape that replaced it: "Yeah, that's better."
- **Cut 3** now runs one weighted least-squares solve over the whole desired wrench (Fx, Fy, yaw) at once,
under throttle bounds. No separate rotation demand, no `TorqueFloor`-sized demand, no piecewise fall-back, and
no branch anywhere on whether a ship can turn a given way. The weights are the design knob, and both error
terms share a currency because each axis is normalized by what that ship can actually achieve on it.
- **Cut 4** chooses manoeuvres by predicted outcome over a short horizon, simulating each candidate, never by a
rule. The operator's case above is its test.
---
# Part I: Target
@ -110,9 +131,10 @@ creates a real handicap and not an exploit.
way round against a slow lurching turn without simulating either.
7. **A dead actuator never receives throttle.** A destroyed, disabled or unpowered actuator gets zero throttle,
not a saturated request it cannot honour.
8. **Capability is one model.** Turn time, turn direction, rotation capacity and translation capacity are computed
in one place (`ShipControl`), from the same effectiveness matrix the allocator solves with. The AI queries them
and never recomputes a private estimate.
8. **Capability is one model.** What a ship can do is computed in one place (`ShipControl`), from the same live
column matrix the allocator solves with: the achievable extremes, the turn-direction comparison, and the solve
itself. The AI reads those and predicts with the controller's own step; it never keeps a private estimate of
what the ship can do, and never branches on which actuators are missing.
## Canonical implementations
@ -155,7 +177,7 @@ runtime-derived per tick, or per planner cadence, and are never saved.
| `MovementDirection`, `LookDirection` (runtime) | fields on `Ship`/`Entity` | overwritten each frame by whoever flies the ship | player (`ActionGameManager`) or AI (`Agent` layer), never both on one ship (`Zone.cs:111` gives agents only to `!IsPlayerShip`) |
| Committed turn direction, planner plan (runtime) | fields on `ShipControl` / `Agent` | reset on wormhole, dock, spawn | `ShipControl` / `Agent` |
| `ThrusterData`, `AetherDriveData`, `HullData.Hardpoints/Shape` (persisted, catalog) | catalog record key | authored | unchanged; read only |
| `GameplaySettings.TorqueFloor`, `TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:205-207`; values in `Assets/Resources/Settings.asset`: 0.5, 0.1, 0.1) | field name | authored | unchanged values. `TorqueFloor` changes role; see Cut 3 |
| `GameplaySettings.TorqueMultiplier`, `AetherTorqueMultiplier` (authored, `Settings.cs:206-207`; values in `Assets/Resources/Settings.asset`: 0.1, 0.1) | field name | authored | unchanged. `TorqueFloor` (`:205`, authored 0.5) loses its last consumer in Cut 3 and is deleted with its authored value |
This campaign adds no authored **field**. Cut 1 adds authored **records** (hulls, thruster designs and their
products) to `GameData/Aetheria.cc` using the schema as it stands. If Hands finds it needs a tunable, that is an
@ -316,7 +338,7 @@ matrices. A production float implementation without allocations will be faster t
| `RemoveThruster`, `RemoveAetherDrive`, their `ItemDestroyed` subscriptions (`Ship.cs:123-143`) | internal | **Die**; liveness is read live (invariant 3) |
| `Agent.Accelerate` (`Agent.cs:58-79`), `FORWARD_DELTA_THRESHOLD`/`THRUST_DELTA_THRESHOLD` (`:16-17`) | called by `MoveTo.cs:27`, `Combat.cs:127` | **Rewritten** in Cut 4 (planner). Signature kept |
| `Agent.TopSpeed` (`Agent.cs:26`) | `MoveTo.cs:25`, `Combat.cs:127`. No live catalog item carries `VelocityLimit` or `VelocityConversion` (probe), so it is 100 everywhere | Survives |
| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` | **Survives with a new role**: it sizes the rotation demand (Cut 3, Q5) and no longer classifies which thrusters fire |
| `GameplaySettings.TorqueFloor` (`Settings.cs:205`) | `Ship.cs:115,118` only | **Dies** in Cut 3. The full-wrench solve has no threshold to apply it to, and nothing else reads it |
| `TorqueMultiplier`, `AetherTorqueMultiplier` | `Thruster.cs:107`, `AetherDrive.cs:155` | Survive; also enter the yaw-rate row of each column |
| `Entity.Direction` writers | `Thruster.cs:106`, `AetherDrive.cs:154`, `Ship.cs:79` (wormhole), `Ship.cs:283` (snap), `ActionGameManager.cs:848` (spawn), `EntitySerializer.cs:66` | `Ship.cs:283` **dies** (invariant 5); the others survive |
| `Entity.Velocity` external writers | `HullCollider.cs:15` (collision impulse), `ShieldManager.cs:79` (bounce), `VelocityConversion.cs:41`, `VelocityLimit.cs:48` | Not control; these are external forces and constraints. Untouched |
@ -342,8 +364,13 @@ be a better decision than turning around the long way, I wouldn't introduce a th
fixtures follow the data ("Good catch on the Longinus main thrusters, I was misremembering"), and Cut 1 exists
because of "can we restore these layouts?". The Q2 text below is marked as history; the live design is in Cut 3.
Open: **Q4** (heading versus aim) blocks only Cut 5. **Q5** (how the rotation demand is sized under Q2 = B) blocks
Cut 3. Cut 1's recoverable-content list is an operator review item before Hands writes any record.
Second-round rulings (operator, 2026-09-22): Cut 1 approved as inventoried, optional gear included ("Yes and
yes"); the second hull behaviour is not `VelocityConversion` ("I don't think VelocityConversion was on either
hull") and the decode names it `VelocityLimitData`; **Q5 is dissolved** by the full-wrench solve ("Yeah, that's
better"), so the Q5 text below and every "sized demand" design it belonged to are history.
Open: **Q4** (heading versus aim) blocks only Cut 5. **Q6** (the one weight in Cut 3's solve) does not block
Hands, who starts at the recommended default; the operator settles it by feel in the Cut 1 play check.
Follow-ups outside this campaign:
- `TurretController` has no `EntityTypeRestriction`, so equipping it on a ship would create a third
@ -356,7 +383,8 @@ Follow-ups outside this campaign:
## Cut 1. Restore the legacy thruster ship hulls to the live catalog
A pure content cut, separable from every code cut, so Soul can falsify it on its own. Operator, 2026-09-22: "can
we restore these layouts?"
we restore these layouts?", and on the inventory below, optional gear included: "Yes and yes". **The list is
approved**; what is left for review is anything Hands finds that this map did not name.
- **Repo/branch:** Aetheria, a branch from `codex/locomotion`. Depends on nothing. It blocks no code, but nothing
in this campaign can be flown on a thruster ship until it lands, and Cuts 3 and 4 get their real fixtures from
@ -413,18 +441,32 @@ today). So no art, no prefab work and no faction work is needed.
thrusters request zero energy, so matching them is the safe default and is a content decision.
- Provenance is runtime, not authored: `ItemManager` mints the `Lot` (`docs/item-provenance-target.md`).
**One open identification.** Both legacy hulls carry a second hull behaviour that LonginusX does not (behaviour
tag 11 in the legacy union, a single `PerformanceStat`; Longinus `50..50`, Djinni the same). LonginusX carries only
the one LonginusX still has today, `ReflectorData`. Hands must identify tag 11 against the current `BehaviorData`
union before authoring. **If it is `VelocityConversion`, it matters to this whole campaign**: that behaviour damps
velocity toward the nose (`VelocityConversion.cs:41`), which is a direct tax on crab walking, and no item in the
live catalog carries it. Put the identification in front of the operator with the rest of the list.
**The second hull behaviour, identified** (probe; the operator was right that it is not `VelocityConversion`).
Both legacy hulls carry a behaviour LonginusX does not, union tag 11 in the legacy record. The union table is in
`Assets/Scripts/ServerShared/Behaviors/Behaviors.cs` at the legacy-project commit `7006a6b0` and unchanged at
`b4c06b50`: **tag 11 is `VelocityLimitData`**; `VelocityConversionData` is tag 10, and it is on neither hull. The
table cross-checks against the live catalog on every record that survived: tag 6 `ReflectorData` (live LonginusX
has exactly that and nothing else), 8 `ThrusterData` and 9 `WearData` ("deep space burnout" carries both), 3
`RadiatorData` (Arctica), 7 `ShieldData` (legacy "Shield"), 31 `CapacitorData` (legacy "Capacitor").
The payload is a single `PerformanceStat` in `Key(1)`, `VelocityLimitData.TopSpeed`, authored `50..50` with the
legacy exponents `1, 0, 1.5` on both hulls. So **both restored hulls cap at 50 units of speed**, where a hull
without the behaviour leaves `Agent.TopSpeed` at its 100 default (`Agent.cs:26`) and leaves the player uncapped.
`VelocityLimit.Execute` clamps `Entity.Velocity` (`VelocityLimit.cs:48`), so this is a real handling difference on
the restored hulls and Cut 4's arrival times are measured with it.
**The legacy stat shape differs from today's.** A legacy `PerformanceStat` is
`[Min, Max, HeatExponentMultiplier, DurabilityExponentMultiplier, QualityExponent]`; today it is `Min`, `Max` and
a `Terms` list of `StatTerm` (`ItemData.cs:632-643`, keys 2 to 4 retired). Every restored stat has to be
translated, not copied. `docs/stats-power-cut1-migration.md` is the precedent for that mapping and the authority
on what each legacy exponent becomes.
### The cut
- **Deletes first:** nothing. This is content restoration.
- **Adds:** the two hull records, `Medium Drive`, and whichever optional thrusters the operator's review keeps;
a `FactionProductData` for each; roles for each; firing arcs on the Djinni's weapon hardpoints.
- **Adds:** the two hull records and all five thruster designs (`Medium Drive`, `Small Drive`, `Victoire`,
`Talaria`, `RevvITup 2.0`), approved 2026-09-22; a `FactionProductData` for each; roles for each; firing arcs on
the Djinni's weapon hardpoints; each hull's `VelocityLimit` behaviour with its translated stat.
- **Mechanism:** a one-shot command in `tools/AetherDb` (`restore-hulls [apply]`), in the shape of the existing
`*-migrate [apply]` commands: dry run by default, opens the catalog through `AetheriaStores.Open(catalogWritable:
true)` only when applying, writes every record in one `Commit`, and refuses to run twice. The Cut 9 importer
@ -452,9 +494,9 @@ live catalog carries it. Put the identification in front of the operator with th
roles are authored correctly.
- `dotnet run --project tools/AetherDb -- census` names the two hulls with their makers, and `dangling` reports
no new dangling reference.
- Operator: the review of the list above before anything is written, then the Studio click-through, then a play
check flying a restored hull. That play check is what unblocks the rest of this campaign.
- **Operator questions:** the recoverable list itself, and the behaviour-tag-11 identification, both above.
- Operator: the Studio click-through, then a play check flying a restored hull. That play check is what
unblocks the rest of this campaign, and it is where Q6's weight gets settled by feel.
- **Operator questions:** none open. The list is approved and tag 11 is identified.
- **Ledger estimate:** no source removed. About 3 to 7 catalog records added, plus their products, plus a one-shot
command of roughly 150 lines that is deleted again in this campaign. Net carrying cost: the content, and nothing
else.
@ -544,52 +586,81 @@ live catalog carries it. Put the identification in front of the operator with th
condition. It is not the instantaneous `Rpm`. Rotor spin-up is the drive's internal dynamics, not an
allocation decision. This choice is what keeps LonginusX exact: for a lone drive, self-consistent capacity
cancels the scale, so throttle equals input.
- **Heading controller** (below the inputs, shared):
- `look = LookDirection.xz`. A zero look means hold heading: no NaN, no command.
- **No actuator is excluded from rotation** (Q2 = B). The allocator may spend any live column's torque to meet
the rotation demand, mains included, and a ship with no attitude thruster left on one side turns that way by
differential main thrust, lurching forward while it does.
- What `TorqueFloor` still does is **size the demand**, not gate an actuator. Per rotation direction,
`ω_cap(dir)` is the sum of that direction's yaw-rate entries over columns whose
`|Thruster.Torque| > TorqueFloor` (drive torque columns always count); **if that sum is zero, `ω_cap(dir)`
is the sum over every column instead.** So a ship with attitude authority does not burn its mains to turn
a few percent faster, and a ship without it still turns. This piecewise sizing is Q5; it is the only part of
the Q2 = B design that is a choice rather than a consequence.
- Probe, at the fixture values: a Longinus with `Th.CCW` destroyed has zero counter-clockwise attitude
authority, so the fall-back sizes the demand from its mains, and the allocator turns it counter-clockwise at
78.5°/s with `Th.R` at full throttle and 86.20 m/s² of forward lurch. Clockwise is untouched at 394.4°/s.
A hull whose single main sits on the centre line (torque 0) has no counter-clockwise authority at all, even
with the fall-back, so for that ship the long way round is the only way round.
- **Direction choice:** pick the direction that minimizes `angle_in_that_direction / ω_cap(dir)`, with
infinity where `ω_cap = 0`. With the fall-back in play this is a real comparison rather than a formality:
the damaged Longinus above takes the long way round for a 90° turn to port (270°/394.4 = 0.685 s against
90°/78.5 = 1.15 s) and the lurching short way for a 20° turn to port (0.255 s against 0.862 s), which is
exactly the operator's "can be a better decision than turning around the long way". Keep the committed
direction unless the other is faster by a margin, so symmetric ships do not dither near 180°.
- **Rate demand:** `ω_d = min(profile(e)·ω_cap, e/dt)`, where `profile(e) = sqrt(sin e)` for e ≤ 90° (base's
curve, which preserves the pins) and 1 beyond. The `e/dt` cap lands the heading without overshoot and
replaces the snap.
- It exposes `TurnTime(heading)` and `RotationCapacity(dir)` for Cut 4. Both account for the fall-back, so
the planner sees the same slow lurching turn the controller would actually fly.
- **Translation demand:**
- `F_d = (mx·cap(±x), my·cap(±y))`, where `cap(d)` is the along-d component of the allocator's own solution to
"a huge demand along d, rotation held at zero". Capacity is self-consistent: one objective, and no second
rule for what full stick means.
- It exposes `TranslationCapacity(bodyDir)` along any body direction, same definition, for Cut 4.
- Probe values: Longinus cap(+y) 172.41, cap(+x) 13.74 (with side effects, so the demanded strafe comes out
about 0.09 m/s²). Djinni cap(+x) 37.04, cap(+y) 55.56, cap(−y) 18.52.
- **Allocation objective:** one bounded-LS call, priorities by weight separation (Johansen & Fossen §
prioritization):
- P1, yaw rate equals `ω_d`. Separation about 1e3.
- P2, translation error `F − F_d` in plain L2 (isotropic), each row normalized by the ship's largest column
magnitude.
- P3, a small ridge on throttle (about 1e-3), for tie-breaking and conditioning.
- Warm start from last tick's throttles.
- Probe: a demand-frame weighting with perpendicular error weighted k = 2 gave no benefit over isotropic
(k = 1). Keep it isotropic.
- **Budget:** 1 allocation solve plus 4 capacity solves per ship per tick. At the scratch timings that is well
under 100 µs per ship. Hands measures with a production build and reports. The fix for cost is solver
efficiency, not a cache, because a cache would be a second truth for liveness (invariant 3).
- **One solve over the whole wrench.** There is no rotation stage and no translation stage. Each tick
`ShipControl` builds a desired wrench `v = (Fx_d, Fy_d, ω_d)` and solves, once,
min over u in [lo, hi] of Σ_axis ( w_axis · (B_axis · u − v_axis) / cap_axis )² + ridge · |u|²
where `B` is the live column matrix, `cap_axis` is that axis's achievable extreme, and `ridge` is about 1e-3.
The normalization is what lets a rotation error and a translation error be compared: each residual reads as a
fraction of what this ship can do on that axis.
- **Achievable extremes.** `cap` for an axis is the largest value that axis can take inside the throttle box,
which for a box constraint is just the sum of the positive entries of that row (the negative ones for the
other half-axis). Six numbers, no solve: `+x`, `−x`, `+y`, `−y`, clockwise, counter-clockwise. They are also
what `MovementDirection` is a fraction of. Probe values: Longinus `+y` 172.41, `±x` 68.97, yaw 8.25 rad/s
(472.9°/s); Djinni `+y` 55.56, `−y` 18.52, `±x` 37.04, yaw 3.25 rad/s (186.2°/s).
- **Demand.** `F_d = (mx · cap(±x), my · cap(±y))` from `MovementDirection`. `ω_d = min(profile(e) · cap(dir),
e / dt)` toward the `LookDirection` heading, `profile(e) = sqrt(sin e)` for e ≤ 90° (base's own curve) and 1
beyond, with the `e/dt` term landing the heading without overshoot in place of the deleted snap. A zero
`LookDirection` means hold heading: no demand, no NaN.
- **Direction choice** stays a time comparison, `angle_in_that_direction / cap(dir)`, infinite where `cap` is
zero, with the committed direction held unless the other is faster by a margin. It is not a branch on whether
the ship "can" turn: under Q2 = B `cap(dir)` is almost always non-zero, and the comparison is what makes the
long way round appear.
- **Weights.** Translation rows weigh 1. The yaw row weighs `w`, the single knob (Q6), default **3**. There is
no other tuning constant in the objective.
- **The three regimes fall out of that solve; none of them is a rule** (closed-loop probe, below):
- (a) *Turning while forward thrust is also wanted.* The mains are already firing for `F_d`, so their torque
costs nothing extra, and the ship turns with them.
- (b) *Turning in place.* `F_d` is zero, so every newton of main thrust is pure error, and the solver spends it
only to the extent the remaining yaw residual is worth more. The intact Longinus is no longer forced into
the 84 m/s² burn the earlier sized-demand design produced.
- (c) *No attitude authority in that direction.* The only way to cut the yaw residual at all is the mains, so
the solver takes the lurch, and the direction comparison decides whether that beats the long way round.
- **`TorqueFloor` has no consumer left.** It was read only at `Ship.cs:115,118`. The field goes from
`GameplaySettings` (`Settings.cs:205`) along with its authored value in `Assets/Resources/Settings.asset`;
`TorqueMultiplier` and `AetherTorqueMultiplier` stay, because they are physics.
- **Budget.** One solve per ship per tick, plus six sums. The earlier design's four extra capacity solves are
gone. The fix for cost is solver efficiency, not a cache, because a cache would be a second truth for liveness
(invariant 3).
### Closed-loop probe behind Cut 3
The single-tick table in Part II is measured against the real engine at `cd846916`. The numbers in this section
come from a **separate scratch model**: the fixture columns above, kinematic rotation, `dt` 0.02, the objective as
stated, and the base mixer re-implemented against the same integrator so the two are compared like for like. They
are design evidence and test targets, not engine pins; Hands reproduces them against the real engine and reports
the differences.
| Manoeuvre (Longinus fixture) | base mixer | solve at w = 3 |
|---|---|---|
| Turn 90° in place | 0.32 s, 13.9 m/s of drift | 0.30 s, 16.9 m/s of drift, main peaks at 0.73 throttle |
| Turn 90° at full forward | 0.32 s | 0.30 s |
| Strafe right, 1 s, look held | no strafe at all | 10.7 m/s gained, heading holds within 1.1° |
| Djinni turn 90° in place | 0.68 s, no drift | 0.68 s, no drift, no stern throttle |
The drift in a turn is dominated by the nose thruster, which pushes sideways whatever the controller does: base
pays 13.9 m/s for a 90° turn and the solve pays 16.9 m/s, in about the same time. That is the honest shape of
regime (b): the main does contribute torque, but the turn does not become a forward burn.
The yaw weight trades heading-holding against how hard the mains are spent, and on this hull both come from the
same pair of actuators:
| w | strafe gained in 1 s | heading deviation while strafing | 90° turn in place | main peak |
|---|---|---|---|---|
| 0.5 | 0.1 m/s | 179° (heading lost) | 1.62 s | 0.14 |
| 1 | 46.4 m/s | 59° | 0.60 s | 0.37 |
| 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 |
| **3** | **10.7 m/s** | **1.1°** | **0.30 s** | **0.73** |
| 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 |
Damaged cases at w = 3: a Longinus with `Th.CCW` destroyed reaches a heading 90° to port **the long way round**
(0.74 s, sweeping +269°) and a heading 20° to port **the short way, on differential main thrust** (1.18 s, sweeping
−18°, gaining 19.7 m/s). A hull whose single main sits on the centre line has no counter-clockwise authority at
all and always takes the long way (0.86 s). A Longinus with both nose thrusters gone still turns 20° in 0.60 s on
its mains alone, and does it while gaining 83 m/s if forward thrust was also asked for against 20 m/s if it was
not — regime (a) against regime (b) on one fixture.
- **Per-file changes** (against `cd846916`):
- `Ship.cs:251-292`: `Update` calls `ShipControl.Step(dt)` inside the existing `:253` gate. The drag and position
code below stays.
@ -607,15 +678,16 @@ live catalog carries it. Put the identification in front of the operator with th
- **Authority map:**
- Owner: `ShipControl` decides every actuator throttle and the feasible turn direction.
- Inputs: `MovementDirection`, `LookDirection.xz`, current `Direction`, `Mass`, `dt`, each actuator's live
column, `TorqueFloor`/`TorqueMultiplier`/`AetherTorqueMultiplier`.
- Outputs: per-actuator throttle (`Thruster._input`, `AetherDrive._axis`); capability queries
(`TurnTime`, `RotationCapacity`, `TranslationCapacity`).
- Derived state: columns, capacities and `ω_d` are per-tick derived, never stored across ticks except the warm
start and the committed turn direction, which are command state and not truth. `Thruster.Axis` and
column, `TorqueMultiplier`/`AetherTorqueMultiplier`.
- Outputs: per-actuator throttle (`Thruster._input`, `AetherDrive._axis`); the achievable extremes, the
movement-command mapping, `TurnTime(heading)`, and the step itself, which Cut 4 uses as its prediction model.
- Derived state: columns, extremes and the demand are per-tick derived, never stored across ticks except the
warm start and the committed turn direction, which are command state and not truth. `Thruster.Axis` and
`AetherDrive.Axis` are display and command readbacks.
- Forbidden writers: `Ship.Update`'s mixer (gone); any assignment to `Thruster.Axis`/`AetherDrive.Axis` outside
`ShipControl`; `Ship.cs:283`'s direct `Direction` write; bucket pruning on `ItemDestroyed`; any Unity-side
write of throttles.
write of throttles. Also forbidden inside `ShipControl`: any test of whether an actuator is "for" rotation or
translation, any per-direction special case, and any second solve that re-decides part of the wrench.
- Shared paths: player writes (`ActionGameManager.cs:1254,1263`) and AI writes (`Agent`, `MoveTo`, `Combat`) both
reach actuators only through `ShipControl.Step`. Wormhole exit leaves the gate closed until the animation
ends, as today.
@ -630,12 +702,13 @@ live catalog carries it. Put the identification in front of the operator with th
are pinned at their own probed values. Pins the allocator against base.
- **Accepted divergences** (operator, 2026-09-22, "The divergence is fine"). Soul reads these as pinned
behaviour, not as regressions, and anything outside them is a regression:
1. Longinus turning in place fires a main at 0.12 throttle: forward creep 10.7 m/s² where base had 0, and
lateral slip 67.3 m/s² where base had 69.0.
2. Longinus thrusting forward while turning 30° keeps 164.8 m/s² of the 172.4 base forward (−4%).
1. Longinus turning in place spends its mains for torque, peaking near 0.73 throttle at w = 3. The
manoeuvre costs 16.9 m/s of drift against base's 13.9 and finishes in 0.30 s against base's 0.32.
2. Longinus strafing gains a weak strafe it never had: about 10.7 m/s in a held second, heading holding
within about 1°, where base produced exactly nothing.
3. Djinni strafe reaches 37.0 m/s² against base's 34.0 (+9%), by balancing torque with Stern 3 and Bow.
4. Djinni turning in place runs Stern 2 and Bow at about 0.10 each; net force stays zero, but their exhaust
is visible.
4. Djinni turning in place may run a stern thruster and the bow thruster against each other at low throttle;
net force stays zero, but their exhaust is visible.
Plus two intended fixes, which are not divergences to tolerate but behaviour this cut is buying: turns past
90° run at full rate instead of decaying to zero at 180°, and `|Direction|` no longer shrinks when the look
is directly aft.
@ -645,14 +718,29 @@ live catalog carries it. Put the identification in front of the operator with th
centred main (torque 0, so no differential authority) and one clockwise attitude thruster, the
counter-clockwise one destroyed through the real `ItemDamage` path. Write `LookDirection` directly, as the
player would, to 90° left. Heading changes clockwise every tick (never counter-clockwise), and the ship
arrives within 2° in under 1.0 s; the base-rate bound is 270°/394.33°/s = 0.685 s. Base comparison: a ship
of that shape never turns at all. Pins the shared feasible-direction choice.
arrives within 2° in under 1.2 s (model: 0.86 s). Base comparison: a ship of that shape never turns at all.
Pins the shared feasible-direction choice.
- `LonginusMissingCcwPrefersTheLongWayAtNinetyDegrees`: the two-main Longinus with `Th.CCW` destroyed, looking
90° left. It turns clockwise, because 0.685 s beats the 1.15 s its mains would take. Pins that the
comparison is by time, not by a rule about which actuators are "for" turning.
- `LonginusMissingCcwLurchesRoundTheShortWayAtTwentyDegrees`: the same ship looking 20° left. It turns
counter-clockwise on differential main thrust (`Th.R` at full), arrives within 2°, and its forward velocity
rises while it does. Pins Q2 = B: the lurching turn is available and is chosen when it is faster.
90° left. It sweeps clockwise through about +270° and arrives (model: 0.74 s). Pins that the comparison is by
time, not by a rule about which actuators are "for" turning.
- `LonginusMissingCcwLurchesRoundTheShortWayAtTwentyDegrees`: the same ship looking 20° left. It sweeps
counter-clockwise through about −20° on differential main thrust, arrives within 2°, and its speed rises
while it does (model: 1.18 s, 19.7 m/s). Pins that the lurching turn is available and is chosen when it is
faster.
- `IntactShipTurningInPlaceDoesNotBurnForward`: the intact Djinni, looking 90° right with
`MovementDirection` zero. Its speed at the end of the turn is under 1 m/s and no stern or bow thruster
exceeds a low throttle. Pins regime (b) on a hull that has the actuators to keep translation clean. The
intact Longinus, which does not, is covered by accepted divergence 1 instead, with its own bound: drift no
worse than 1.3× base's 13.9 m/s and turn time no worse than base's 0.32 s.
- `NoAttitudeAuthorityStillTurns`: a Longinus with both nose thrusters destroyed, looking 20° right. It
arrives, on differential main thrust, and it gains speed doing so (model: 0.60 s). Pins regime (c).
- `TurningIsCheaperWhenForwardThrustIsAlsoWanted`: the same mains-only ship, 20° right, run twice with
`MovementDirection` zero and at full forward. Both arrive in the same time; the second gains much more
speed because the burn was wanted anyway (model: 20 m/s against 83 m/s). Pins regime (a) as a consequence of
one solve rather than a special case.
- `StrafeHoldsHeading`: the intact Longinus, full strafe held for 1 s with `LookDirection` fixed. Heading
deviation stays under 2° (model at w = 3: 1.1°). Pins the weight's other half, which is what stops the nose
swinging when a ship strafes with a thruster that also yaws.
- `LonginusMissingCcwTurnsRightAsBefore`: looking 90° right reaches within 2° in 0.32 s ± one tick (base
value). Pins that damage on one side leaves the other side alone.
- `LookDirectlyBehindTurns`: intact, look exactly aft. The ship turns, `|Direction|` stays 1 ± 1e-4, and there
@ -676,6 +764,8 @@ live catalog carries it. Put the identification in front of the operator with th
checked.
- `_forwardThrusters|_clockwiseThrusters|StrafeTorqueThrusters|RecalculateThrust|TurnTime\(` should match only
`ShipControl.cs`'s new `TurnTime`.
- `TorqueFloor` should match nothing in `Assets/Scripts`, and nothing in `Assets/Resources/Settings.asset`
once Unity rewrites it.
- `IAnalogBehavior` should match nothing.
- `lerp\(Direction` in `Ship.cs` should match nothing.
- No least-squares code outside CultMath: `Cholesky|pseudo.?inverse|ActiveSet` in `Assets/Scripts` should
@ -686,10 +776,11 @@ live catalog carries it. Put the identification in front of the operator with th
- Operator: fly LonginusX and confirm it feels unchanged (strafe, turn, look behind now turns). Then fly a
restored Longinus or Djinni from Cut 1, shoot a thruster off it, and confirm the damaged handling and the
lurching turn. Without Cut 1 this half of the check cannot be done at all.
- **Operator questions:** Q5, below. Q1, Q2 and Q3 are ruled.
- **Ledger estimate:** −200 lines in `Ship.cs`, −10 elsewhere. `ShipControl.cs` adds about 250–350 lines, tests
about 400. Net source roughly +100. That buys actuator-loss capability, which is the explicit ask, and removes
seven parallel role sets, the snap, and permanent pruning.
- **Operator questions:** Q6, below, which does not block the cut. Q1, Q2, Q3 are ruled and Q5 is dissolved.
- **Ledger estimate:** −200 lines in `Ship.cs`, −12 elsewhere (`IAnalogBehavior`, `TorqueFloor` and its authored
value). `ShipControl.cs` adds about 250–350 lines, tests about 500. Net source roughly +100. That buys
actuator-loss capability, which is the explicit ask, and removes seven parallel role sets, the snap, permanent
pruning, one authored setting and four capacity solves a tick.
## Cut 4. Heading planner in the AI
@ -700,62 +791,82 @@ live catalog carries it. Put the identification in front of the operator with th
- **Deletes first:** `Agent.cs:16-17` (the two thresholds) and `Agent.cs:60-78` (the body of `Accelerate`, 19
lines). `MoveTo.cs:26` (the duplicate `LookDirection` write). The signature `Accelerate(float2, bool noTurn)`
stays, since `Combat.cs:127` and `MoveTo.cs:27` call it.
- **Adds:** in `Agent` (above the inputs, AI only):
- Candidates: 16 evenly spaced headings, plus the Δv direction, plus the current heading.
- Score for each: `TurnTime(θ) + |Δv| / TranslationCapacity(Δv expressed in θ's body frame)`, infinite where
capacity is 0. Both terms come from `ShipControl`'s queries (invariant 8).
- **No direction is assumed impossible.** Under Q2 = B a damaged ship usually can still turn both ways, one of
them slowly and with a lurch, so `TurnTime` returns a finite number either way and the scoring compares them.
Nothing in the planner may branch on "this ship is a one-way turner"; the only asymmetry it sees is in the
numbers `ShipControl` hands it.
- Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, and on a cadence
(for example every 0.25 s) or when the actuator set changes. The cadence exists for cost: 18 capacity solves
per replan.
- **Adds:** in `Agent` (above the inputs, AI only). **The planner chooses by predicted outcome, never by a
rule.** It simulates each candidate and compares where the ship actually ends up.
- **Candidate manoeuvre:** a pair (heading command, movement command) held for the horizon. The set is the
headings the ship might fly with the translation that points the demand along Δv at each: 16 evenly spaced
headings (Hands default; 8 to 32 is the sensible range), plus the Δv direction and the current heading.
- **Prediction:** roll each candidate forward over a horizon of 1.0 s (Hands default; 0.5 to 2.0 s is the
sensible range) in 5 substeps (3 to 10), stepping the *same* `ShipControl` solve and the same kinematics the
ship will actually fly. There is no second physics model and no closed-form estimate: one owner for what the
ship does, which is invariant 8 applied to prediction.
- **Score:** the predicted outcome at the horizon — remaining distance to the objective, and the closing speed
that ends the horizon (so a manoeuvre that arrives pointing the wrong way with no closing speed loses to one
that is simply nearer and still closing). Infinite where the candidate ends further away than it started.
- **This is what answers the operator's case.** A ship whose rotation has to be bought with main thrust turns in
a great circle: the prediction shows the heading improving while the range opens, so a lateral burn that keeps
the bearing and closes wins on its own numbers. Nothing in the planner tests whether the ship is a one-way
turner, whether a thruster is missing, or which actuator would be used.
- Pick the argmin with hysteresis: replan when a candidate beats the current plan by a margin, on a cadence
(0.25 s default) or when the actuator set changes. The cadence exists for cost: 18 candidates × 5 substeps is
about 90 solves per replan, roughly 1 ms per replan per ship at the scratch timings, so a 0.25 s cadence over
20 AI ships is a few percent of a core. Hands measures and reports; if it is too slow the answer is fewer
candidates or a shorter horizon, both of which are stated ranges, not a cache.
- Write `LookDirection = θ*` unless `noTurn`. Write `MovementDirection` as the per-half-axis fractions that point
the translation demand along Δv, inverting `ShipControl`'s own mapping (a `ShipControl` helper, so the AI never
re-derives it), with a deadband near zero Δv.
- `noTurn` (combat) skips heading choice but still gets capability-correct thrust direction. A Djinni with no
- `noTurn` (combat) skips the heading half of the candidate but still predicts the translation. A Djinni with no
stern facing its target strafes or backs off on its own.
- **Authority map:**
- Owner: `Agent` decides what the AI writes to the two inputs.
- Inputs: target velocity, `ShipControl` capability queries, current velocity and heading.
- Inputs: target velocity and objective position, the `ShipControl` step used as the prediction model, current
velocity and heading.
- Outputs: `LookDirection` (unless `noTurn`) and `MovementDirection`.
- Derived: the plan (heading, time of last replan) is command state only.
- Forbidden writers: `MoveToState` writing `LookDirection`; any AI code writing throttles or `Direction`; any AI
capacity estimate not read from `ShipControl`.
- Forbidden writers: `MoveToState` writing `LookDirection`; any AI code writing throttles or `Direction`; any
second model of what the ship will do, whether a closed-form estimate, a capability table of its own, or a
branch on which actuators are missing.
- Shared paths: `MoveToOrbitState` (patrol) and `CombatState` both go through `Accelerate`.
- **Verification:**
- `DjinniWithoutSternReachesTargetByCrabbing`: stern cluster destroyed, target 300 m at a bearing 30° right of
the nose. Driven through `Agent` plus `MoveToState` subclass with a fixed target. The ship arrives within 10 m
in under 8 s, and the planner's chosen heading puts a lateral pair along Δv. Base never arrives. Pins R1(2) and
the stuck-forever cheese.
the nose, driven through `Agent` plus a fixed-target `MoveToState`. The ship arrives within 10 m in under 8 s.
Base never arrives (speed 0 at 120 s). Pins R1(2) and the stuck-forever cheese.
- `CrabBeatsTheBigCircle`: **the operator's case, and the reason Cut 4 predicts instead of deciding.** The
fixture is a hull that can strafe cleanly but can only rotate by spending a main: two off-centre mains aft and
one amidships lateral pair whose torque cancels, no attitude thrusters, with an objective abeam to port at
300 m. A turn-to-face policy run in the same harness spends the manoeuvre sweeping a wide arc that barely
improves the bearing and opens the range; the planner's choice closes it. Pinned as time to reach the
objective, and as range at the horizon, never as "the planner chose to strafe".
- `CrabBeatsPointAndBurnWhenMainIsWeak`: Djinni with Stern 1 and Stern 2 destroyed (asymmetric; Stern 3's torque
is balanced by the laterals), target 300 m at 60° left. The planner arrives strictly sooner than a naive
point-and-burn agent run in the same harness through the same Cut 3 allocator. Pins that crab walking wins when
it should, as a relation and not a captured number.
- `IntactShipsStillPointAndBurn`: an intact Djinni picks a heading within 10° of Δv and arrives no later than
base's 4.34 s + 10%. Pins no regression.
- `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the planner turns toward
it (a Longinus has no reverse thrust).
- `PlannerPrefersTheLurchingTurnWhenItIsFaster`: the Longinus with `Th.CCW` destroyed, given a Δv 20° to port.
The heading it writes is reached counter-clockwise, and the forward lurch shows in the trajectory. Pins that
the planner inherits the controller's Q2 = B comparison instead of a one-way-turner assumption.
- `PlannerUsesShipControlCapacities`: after destroying a thruster, the planner's turn time for a dead-side
heading equals `ShipControl.TurnTime` for that heading. Pins invariant 8 and the long-way-aware estimate.
point-and-burn agent run in the same harness through the same Cut 3 controller. A relation, not a captured
number.
- `IntactShipsStillPointAndBurn`: an intact Djinni arrives no later than base's 4.34 s + 10%, with the restored
hull's own `VelocityLimit` in force. Pins no regression.
- `LonginusWithoutReverseFlipsToBrake`: target velocity opposite the current velocity; the predicted-outcome
score puts the ship round, because a Longinus has no reverse thrust.
- `PlannerTakesTheLurchWhenItPays`: the Longinus with `Th.CCW` destroyed and a Δv 20° to port, where the
lurching turn is genuinely faster. The ship gets there sooner than a fixed-heading strafe would in the same
harness. Pins that the planner inherits the controller's comparison rather than avoiding the lurch on
principle.
- `PlannerPredictionMatchesFlight`: run a candidate's prediction, then fly the same commands for the same
horizon, and compare the end states. They agree within a small tolerance. Pins that the prediction uses the
real controller and that nobody has grown a second model.
- Negative grep: `LookDirection` in `Agents/States/MoveTo.cs` should match nothing.
- Stryker `--since:<Cut 4 base>` over `Agent.cs`, `MoveTo.cs` and `ShipControl.cs` queries.
- Operator: watch a patrol Djinni with a stern thruster shot out crab to its next orbit.
- **Ledger estimate:** −22 lines, +120 lines of source, +250 lines of test.
- **Ledger estimate:** −22 lines, +140 lines of source (the prediction loop replaces the closed-form score),
+300 lines of test. No new type beyond the plan and the candidate.
## Cut 5. Combat facing and tactics (target shape only)
This cut gets mapped after fire-control Cut 12 lands, because that cut decides what presenting a side is worth.
- **Owner:** `CombatState` (`Combat.cs:88-127`), above the inputs.
- **Shape:** choose the orbit side and facing from `ShipControl` capabilities (per-direction rotation authority,
translation capacity along candidate directions) and from weapon arcs. Under Q2 = B there are few true one-way
turners, so the tactic is about asymmetry rather than impossibility: a ship that turns one way fast and the other
way slowly, with a lurch, keeps its target drifting toward its fast side. A ship with good turret arcs and poor rotation holds a heading and lets the
- **Shape:** extend Cut 4's predicted-outcome scoring with what the weapons want — arcs, ranges and the aim the
fire-control path will take — so the facing a ship holds is the one its predicted damage favours, not a rule
about its turn asymmetry. A ship that turns one way fast and the other way slowly will, under that scoring, tend
to keep its target drifting toward its fast side, because that is what predicts better. A ship with good turret arcs and poor rotation holds a heading and lets the
turrets work. Weapons with fixed arcs pull the heading onto the target. It uses the Cut 4 planner for thrust.
- **Fork:** Q4 (heading versus aim), which must be ruled before mapping.
@ -764,20 +875,29 @@ This cut gets mapped after fire-control Cut 12 lands, because that cut decides w
Each question gives options and a recommendation. Self paces these one at a time. Q1, Q2 and Q3 were ruled on
2026-09-22; their text is kept as history, and the live design is in the cuts above.
- **Q5: how the rotation demand is sized under Q2 = B (blocks Cut 3).** No actuator is excluded from rotation, so
the only remaining choice is how much rotation the controller asks for. The demand cannot simply be "everything
every actuator could produce": a Longinus turning 90° in place would then run a main at 0.97 throttle and lurch
forward at 84 m/s², about half its full thrust, on every hard turn (probe).
- **A.** Size the demand from attitude authority (columns above `TorqueFloor`, plus drive torque), and fall back
to every column when that is zero for a direction. An intact ship does not burn its mains to turn a few
percent faster; a ship with no attitude thruster left on one side still turns that way, at 78.5°/s with a
lurch on the fixture Longinus. No actuator is ever excluded from *meeting* the demand.
- **B.** Always size from every column. Simpler to state, and it makes every hard turn on a ship with off-centre
mains a forward lurch, intact or not.
- **Q6: the yaw weight in Cut 3's solve (does not block Hands).** The solve has exactly one knob, the weight on
the yaw residual against the translation residuals, after each axis is normalized by that ship's achievable
extreme. On a Longinus both the strafe and the turn come from the same nose thrusters, so the knob trades
heading-holding against how hard the mains are spent for torque (closed-loop model, table in Cut 3):
**Recommended: A.** It is what makes Q2 = B's lurching turn an option the ship takes when it is worth it,
instead of a permanent tax on turning. The cost of A is that `ω_cap` is piecewise, which is stated in Cut 3 and
pinned by two tests.
| w | strafe gained in a held second | heading deviation while strafing | 90° turn in place | main peak |
|---|---|---|---|---|
| 1 | 46.4 m/s | 59° | 0.60 s | 0.37 |
| 2 | 13.7 m/s | 3.8° | 0.36 s | 0.63 |
| 3 | 10.7 m/s | 1.1° | 0.30 s | 0.73 |
| 5 | 9.9 m/s | 0.4° | 0.28 s | 0.79 |
The Djinni is unaffected at every value: it has the actuators to do both cleanly. Base, for reference, does not
strafe at all and turns 90° in 0.32 s.
- **A: w = 3.** Heading holds while strafing, turns match base's speed, and the mains carry a real share of the
torque in a turn.
- **B: w ≈ 1.** The mains stay nearly out of turns, but a Longinus asked to strafe swings its nose most of the
way round, because strafing and turning are the same thrusters.
- **C: w = 5.** Heading holding is near-exact and the mains are spent hardest.
**Recommended: A.** Hands starts there and the Cut 3 pins are written against it. This is a feel question, so
the honest place to settle it is the Cut 1 play check; changing it afterwards moves numbers in pins, not
structure.
- **Q4: heading versus aim (blocks Cut 5 only).** `LookDirection` is both the heading command and the aim, read by
`LockWeapon.cs:94`'s lock cone, guided projectiles, reticle targeting and the tractor beam. Capability-aware
facing wants the AI to hold a heading that is not its aim, and under R2 any split applies to the player too.
@ -787,7 +907,7 @@ Each question gives options and a recommendation. Self paces these one at a time
**Recommended: A**, decided when Cut 5 is mapped.
### Ruled (history)
### Ruled or dissolved (history)
- **Q1: intact-ship divergence from base.** **Ruled A, 2026-09-22: "The divergence is fine."** The four accepted
divergences and their magnitudes are listed in Cut 3's verification. The rejected option was to require exact
@ -795,10 +915,15 @@ Each question gives options and a recommendation. Self paces these one at a time
- **Q2: rotation from translation thrusters.** **Ruled B, 2026-09-22: "that's absolutely viable and can be a
better decision than turning around the long way, I wouldn't introduce a thruster exclusion for this."** The
option this map recommended (A: rotation authority only from thrusters above `TorqueFloor`, so a lost attitude
thruster means that direction is gone) was rejected. Cut 3 and Cut 4 are written to B, and Q5 is what is left of
the question.
thruster means that direction is gone) was rejected. Cut 3 and Cut 4 are written to B.
- **Q3: Longinus fixture layout.** **Ruled, 2026-09-22: "Good catch on the Longinus main thrusters, I was
misremembering."** Fixtures follow the data: two aft 2×2 mains.
- **Q5: how the rotation demand is sized.** **Dissolved, 2026-09-22.** The question assumed a separate rotation
demand that something had to size. The operator refused the premise — "it should only do that if the ship's
controller also *wants* to accelerate forwards that much" — and the answer is one solve over the whole wrench
with both residuals in the same currency, which makes the three regimes consequences rather than rules. Neither
of the options this map offered (size from attitude authority with a fall-back; size from everything) survives,
and `TorqueFloor` loses its last consumer. What is left of the question is Q6, the single weight.
## Subtraction ledger (estimates)
@ -806,6 +931,6 @@ Each question gives options and a recommendation. Self paces these one at a time
|---|---|---|---|
| 1 | 0 source; the one-shot command is deleted again in this campaign | 3–7 catalog records + products; ~150 lines of throwaway tool + ~120 test | No new package, target, asset or authored field. Existing prefabs, schematics and factions are reused |
| 2 | 0 | ~200 src + ~150 test (CultLib) | CultMath gains one file; cultmath-unity 0.2.4; Aetheria pin bump |
| 3 | ~210 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters) | ~300 src + ~450 test | `IAnalogBehavior` removed; no new package, target or authored field |
| 4 | ~22 | ~130 src + ~280 test | none |
| 3 | ~212 (`Ship.cs` ~200, `Behaviors.cs` 4, `Entity.cs` 1, setters, `TorqueFloor` and its authored value) | ~300 src + ~500 test | `IAnalogBehavior` and one authored setting removed; four capacity solves a tick removed; no new package, target or authored field |
| 4 | ~22 | ~140 src + ~300 test | none |
| 5 | not mapped | not mapped | not mapped |