diff --git a/docs/locomotion-cut.md b/docs/locomotion-cut.md index b015c6a7b..c49e8439d 100644 --- a/docs/locomotion-cut.md +++ b/docs/locomotion-cut.md @@ -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:` 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 |