Aetheria/docs/locomotion-cut.md
Meta Vens 439d488ac8 Map: flight control allocates thrust (body facts, authority map, rationale); locomotion-cut.md superseded for Cuts 2-3
Imagination pass, aetheria-release, session self-2026-10-09-morning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:42:38 +02:00

77 KiB
Raw Blame History

Locomotion: Control Allocation and Capability-Aware AI

Date: 2026-09-22

Superseded on 2026-10-09 for its body facts, consumer audit and Cuts 2-3 by docs/aetheria-release-map.md, section "Flight control allocates thrust": the aether drive was deleted and Ship.Turn replaced LookDirection as the facing command after this was written. Cuts 4 and 5 remain design input only.

Status: target and cut map, from an Imagination pass revised on 2026-09-22. Cut 1 (the Longinus and Djinni hulls, five thruster designs, and tutorial entrance station rule) landed on codex/locomotion-cut1 and was merged into codex/fire-control-12 at e160bbe5 on 2026-09-26. Cuts 2 to 5 remain target work. The operator's Q1, Q2, Q3 and Q5 rulings below still govern that work. The cuts were renumbered by the 2026-09-22 revision: the content restore became Cut 1, and what the first committed version called Cuts 1 to 4 became Cuts 2 to 5. Historical anchors below are against origin/codex/item-provenance at cd846916; the then-current working tree on codex/fire-control-10 (e8fbb6a6) had an identical tree for every file named below. The first half of this document is the target (the ends). The second half is the cut map (the means). When the campaign closes, split the target out as docs/locomotion-target.md and keep this file as history.

Claims marked (probe) come from running code: scratch builds of Aetheria.Shared from cd846916 against CultLib 45c2f40, run in the session scratchpad. That covers catalog dumps, a legacy-msgpack decode, base-commit flight measurements and a solver comparison. None of the probe sources are kept. Each result below says what was run. The base suite passes 244/244 in that scratch copy.

Rulings

Operator, 2026-09-22:

  • Problem statement. "We're gonna need much smarter locomotion AI if they're to survive getting a thruster shot out. Longinus (non X) had one big thruster in the back and two attitude thrusters in the front. Losing an attitude thruster means you can't rotate that way anymore. Losing a thruster on the Djinn usually means some form of crab walking just became optimal. Naively trying to use a thruster that's been shot out makes them real easy to cheese."
  • R1, two layers. (1) Control allocation owns which thrusters fire. Each live actuator contributes a body-frame wrench (Fx, Fy, τ) per unit throttle, with throttle in [0,1]. A bounded least-squares solve finds the throttles for the desired wrench (Johansen & Fossen 2013 control-allocation survey). This replaces the rotation buckets and the strafe torque compensation. Thrusters that are destroyed, unpowered, disabled or degraded drop out of the matrix or scale down in it. (2) The AI plans with the allocator's achievable set. For a desired Δv it scores candidate headings by achievable acceleration along Δv plus turn time in the directions the ship can still rotate. Crab walking and the long way round emerge from that scoring; neither is special-cased.
  • R2, the same-inputs invariant. "Player and AI are controlling the same ship with the same inputs, this is an Aetheria invariant." The player's controls go through the same allocator, through the same input surface the AI writes. Any second path that bypasses it is a forbidden writer.
  • R3. A capability-aware combat facing/tactics layer is wanted as its own later cut. Example: a one-way turner keeps its target drifting the way it can turn and leans on its turret arcs. This document maps it at target-shape level only.
  • R4. If CultMath lacks a bounded least-squares solver, CultMath gets one, as a CultLib cut. Aetheria does not grow a local helper. CultMath has none (probe: packages/cultmath/src/CultMath at 45c2f40 holds vector types, math, BatchMath, Voronoi and Random, with no linear solver of any kind).

Self, relaying an operator answer, 2026-09-22. This is the layer split, stated precisely:

  • Below the inputs, shared by player and AI, is the ship's own controller plus the allocator. It turns toward LookDirection and realises MovementDirection with whatever actuators are live. Choosing a feasible turn direction belongs here, including the long way round when one rotation direction is dead. If it lived in the AI planner, a player ship with a dead attitude thruster would still stick when looking toward its dead side. Holding heading against torque coupling (strafing with an off-centre thruster) also belongs here.
  • Above the inputs, AI only, is the heading planner. It decides what to write into LookDirection/MovementDirection, for example to turn sideways and crab-walk. The player makes that choice by hand.
  • The long-way test therefore belongs to the shared-controller cut and drives LookDirection directly, as the player would. The crab-walk test belongs to the planner cut.

Operator rulings on this map, 2026-09-22, relayed by Self:

  • Q1 = A, the divergence is accepted. "The divergence is fine." A small divergence from base behaviour on intact thruster ships is accepted: the Longinus's forward creep of about 10.7 m/s² while turning in place, and the Djinn's roughly 9% faster strafe. Base is pinned exactly only where the old mixer and the allocator agree, and the accepted divergences are named with their measured magnitudes in Cut 3's verification, so Soul can tell an accepted divergence from a regression. No per-thruster role rules are reintroduced to match base.
  • Q2 = B, differential main thrust is allowed. "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." Any live actuator's torque may be spent on rotation, mains included. A damaged ship turning toward its dead side lurches forward, and the planner may prefer that to the long way round when it is faster.
  • Q3, fixtures follow the data. "Good catch on the Longinus main thrusters, I was misremembering." Two aft 2×2 mains, as both legacy catalogs author them.
  • 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

Objective

A ship that loses actuators keeps flying as well as its remaining actuators allow, whether a player or an AI is flying it. The AI picks headings and thrust directions from what the ship can still do, so shooting out a thruster creates a real handicap and not an exploit.

Invariants

  1. Same inputs. A ship is commanded only through Ship.MovementDirection (body frame, each component a fraction of the ship's capacity along that half-axis) and Entity.LookDirection (world-frame heading command; the controller reads only its xz). The player writes both (Gameplay/ActionGameManager.cs:1254, :1263). The AI writes the same two fields. Nothing else commands a ship's actuators. Identical inputs to identical ships give identical actuator throttles, whoever wrote them.
  2. One owner for actuator commands. The ship controller (new, ShipControl, below) is the only writer of Thruster and AetherDrive throttles. Ship.cs's mixer, its rotation buckets and its strafe compensation stop existing, and do not survive as a mode or a fallback.
  3. The allocator reads live actuator state every tick. An actuator's column is derived each tick from its live stats and liveness: EquippedItem.Active (enabled, thermally online, durability online), a power grant above the same epsilon Thruster.Execute uses, and the current resolved Thrust (or the drive's own stats) with its durability, heat and power curves applied. No bucket, cache or event-pruned set decides whether an actuator counts. A repaired or re-powered actuator rejoins by the same read that dropped it.
  4. Manual and programmatic inputs share one commit path. A player keypress, an AI state write, and any future scripted or network input all land in the same two fields, and they reach the actuators by the same ShipControl step inside Ship.Update. No input source writes Thruster.Axis, AetherDrive.Axis or Entity.Direction directly.
  5. Heading changes only through actuators. Entity.Direction is written by actuator execution (Thruster.Execute, AetherDrive.Execute), by wormhole scripting (Ship.ExitWormhole), by spawn placement (ActionGameManager.cs:848) and by save unpack (EntitySerializer.cs:66). The controller does not write it. Today's snap-to-look lerp (Ship.cs:280-284) is a free, thrusterless rotation, and it goes.
  6. Kinematic rotation is retained. Rotation stays kinematic: yaw rate is proportional to live throttle (Thruster.cs:106-107, AetherDrive.cs:154-155), with no angular momentum. Nothing found here argues for raising it. It keeps turn time exact (angle divided by rate), which is what lets the planner compare a long 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. 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

  • Solver: CultMath (F:\Projects\CultLib\packages\cultmath), a box-constrained linear least-squares solver, added by Cut 2. It is the only numerical authority. Aetheria composes the problem and calls it.
  • Allocation reference: T. A. Johansen and T. I. Fossen, "Control allocation: A survey," Automatica 49(5), 2013. Its weighted-least-squares form with priorities is what ShipControl states. The active-set method follows O. Härkegård, "Efficient active set algorithms for solving constrained least squares problems in aircraft control allocation," CDC 2002, which is bounded-variable least squares in Stark and Parker's sense.

Out of scope

  • Fire-control Cut 12 (edge-on hit placement in FireControl.cs, Entity.ApplyHit, DamageSchematic) is being mapped concurrently by another Imagination pass. Cuts 2 to 4 here touch none of those files. The allocator is a read-only downstream consumer of item destruction (ItemDamage, Entity.cs:488). Overlap: Cut 5 (combat facing) will rewrite Agents/States/Combat.cs:88-127, and presenting armour edge-on is exactly what Cut 12's hit placement rewards. Cut 5 must be mapped after Cut 12 lands. Cut 1 restores catalog content, and the only file it and Cut 12 could both touch is GameData/Aetheria.cc; Cut 1 must not be in flight while another cut writes the catalog.
  • The old AetheriaEve/CultMesh/daemon rebuild is taxidermy. It is not precedent and not a consumer.
  • Thruster geometry (the normalized moment-arm Thruster.Torque, Thruster.cs:64-69, which ignores lever length and uses unweighted shape cells for centre of mass) is the game's model and stays as authored. Arrival braking in MoveToState, docking approach and formation flight are also out.

Not consumers

  • Assets/Scripts/Gameplay/* presentation (ShipInstance particles, audio parameters) reads throttles and never writes them.
  • TurretController writes Entity.LookDirection for turret entities. That is the AI above the inputs for a non-ship, and it does not drive ship actuators. See the audit.

0b: identity, lifecycle, authority

Nothing persistent changes. Actuator wrench columns, capacities, the chosen turn direction and planner state are runtime-derived per tick, or per planner cadence, and are never saved.

Kind Named by Lifecycle Decides
Actuator column (runtime) the Thruster/AetherDrive behaviour instance on an EquippedItem rebuilt every tick from live stats; appears and disappears with liveness ShipControl derives; item state (durability, heat, power, Enabled) is owned by EquippedItem/PowerBus as today
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.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 operator question, and any new field on a persisted CultCache document must be nullable (MessagePack nil rule).


Part II: Body map (what the probes established)

The mixer today

Ship.Update (Ship.cs:251-292) runs before base.Update executes behaviours. The agents' writes come earlier still, because Zone.cs:170-173 updates agents before entities. The steps:

  1. RecalculateThrust (Ship.cs:147-247) sums per-bucket thrust over thrusters whose item is Active.
  2. It zeroes every thruster's Axis (:256).
  3. Strafe: the right and left buckets get ±MovementDirection.x, minus a torque compensation for the thrusters whose torque sign matches the bucket total (:257-274).
  4. Forward and reverse buckets add ±MovementDirection.y (:275-276).
  5. Rotation: deltaRot = sqrt(|dot(look, right)|)·sign (:278-285) is added to the clockwise bucket and subtracted from the counter-clockwise bucket (:287-288).
  6. AetherDrive.Axis = (MovementDirection.y, MovementDirection.x, deltaRot) (:291). There is no allocation for drives at all.

Buckets are built once in Activate from mount rotation and from Thruster.Torque against TorqueFloor (:102-118). RemoveThruster prunes them permanently on ItemDestroyed (:123, :133-143).

Thruster.Axis saturates to [0,1] (Thruster.cs:45-49). Execute (:94-115) applies Velocity -= facing·input· Thrust/Mass·dt, then rotates Direction kinematically by input·Torque·Thrust·TorqueMultiplier/Mass·dt. Its gate is input > .01 && PowerSupply > 1e-4.

Findings (probe)

Fixtures here are built from the legacy catalog (next section): hull shape, hardpoints, thruster items with constant Thrust (Large Drive 250000, Medium Drive 200000; mass 100 each), TorqueFloor 0.5, TorqueMultiplier 0.1. Each ship sits at (37, −12) facing normalize(0.8, 0.6), runs 3 warm-up ticks, is reset, and is measured over one tick at dt 0.02. Body-frame acceleration is (r, f) in m/s². Heading rate is compass degrees per second, clockwise positive.

Torque factors (Thruster.Torque, the sine of the moment arm): Longinus mains ±0.159 (below the floor, so never used for rotation), attitude thrusters ±0.998. Djinni stern 0 / ±0.161, laterals ±0.727 (aft) and ±0.867 (fore), bow 0.

Base flight table (cd846916, after warm-up). These are the Cut 3 pins:

Input Longinus (r, f, °/s) Djinni (r, f, °/s)
forward (0,1) 2.37, 172.38, 0 0.11, 55.56, 0
reverse (0,−1) 0, 0, 0 0, −18.52, 0
strafe right (1,0) 0, 0, 0 (compensation zeroes the lone attitude thruster) 34.03, 0.42, 0 (axes 1.00 / 0.84)
strafe left (−1,0) 0, 0, 0 −34.03, 0.42, 0
look 90° right 68.97, 0, +394.33 0.01, 0.59, +169.13
look 90° left −68.97, 0, −394.33 0.01, −0.50, −169.13
look 30° right 48.77, 0, +278.84 0, 0.30, +119.59
look directly behind 0, 0, 0, **and ` Direction
forward+strafe (.707,.707) 1.18, 121.89, 0 24.12, 39.49, 0

The small cross terms (2.37, 0.42) are real. Execute rotates Direction thruster by thruster within one tick, so later thrusters push along an already-rotated nose. The allocator cannot see this, so pins need a tolerance for it.

Defects the table exposes:

  • Dead-side stick. On a Longinus with Th.CCW destroyed, looking 90° left gives a heading error of 90.0° after 30 s. It never turns. Looking right, it reaches within 2° in 0.32 s, the same as intact.
  • Main-thruster cheese. On a Djinni with all three stern thrusters destroyed, Agent plus MoveToState toward a point 300 m to port leaves the ship at speed 0 after 120 s. Agent.Accelerate points at Δv and burns forward thrusters only (Agent.cs:64-68), and it never reaches the strafe branch. Intact, the same run arrives in 4.34 s. With only Stern 2 lost it arrives in 4.98 s; with only Port Aft lost, 4.40 s.
  • Snap-to-look. Inside |sin error| < .01, Ship.cs:283 lerps Direction toward the look vector, which is a rotation no thruster pays for. When the look is directly behind, the lerp goes through the origin and shrinks |Direction|, and thruster force scales with that length (Thruster.cs:105 uses the unnormalized item direction). Base also turns slower as error passes 90°: deltaRot follows sin, so it is zero at 180°.
  • First tick. Items are not yet Active on the first Update after Activate, so the first-tick strafe compensation divides by a zero count (NaN, unused) and the strafe fires uncompensated.
  • TurnTime is wrong and unused. Ship.cs:61-66 divides angle by the sum of torque factors over mass, which is unitless nonsense: 261535 s for a Longinus 90° turn. It has no consumer.
  • Permanent pruning. RemoveThruster never restores a thruster whose durability comes back, and Recalculate* reads Active while the mixer still commands inactive thrusters.
  • The live catalog's only ship flies on a drive. GameData/Aetheria.cc holds one ship hull, LonginusX (mass 2500, drag 0.1, 6×17), with an AetherDrive hardpoint and no Thruster hardpoint. It moves on "Traction" (rotor mass (30,20,5), λ (0.5,1,1), MaxRpm 40000..80000). Two thruster designs exist ("deep space burnout", "Large Drive"), but no live ship can mount them (docs/content-batch-one.md §1 says the same). AetherDrive is therefore the load-bearing actuator for the player today, and the allocator must reproduce its base behaviour exactly. Cut 1 restores the thruster hulls, which is what makes any play check on a thruster ship possible at all.
  • Existing tests sit on an off-centre thruster. BrownoutTests/ConditionRatioTests equip a one-cell thruster by TryFindSpace onto a 5×5 hull. It lands at (1,1) with Torque −0.707, and MovementDirection (0,−1) fires it while it spins the ship. Under an allocator that holds heading, a lone off-centre thruster cannot fire without rotating, so those fixtures must be re-seated (Cut 3).

Legacy hull layouts (probe)

The live catalog is the post-breach minimal rebuild, so absence there proves nothing. The layouts come from the git-LFS objects of GameData/Legacy/AetherDB.2021-03-05.msgpack (142998fd…) and AetherDB.2021-04-14.msgpack (21ce7bd2…), both present in .git/lfs/objects, decoded with MessagePack ConvertToJson. Shape cells are bool[w,h], flattened as x*h + y, with y = 0 at the stern.

Longinus (2021-04-14: mass 2500, drag 0.1, 6×17; the 2021-03-05 copy has mass 5000). The shape matches the live LonginusX's. Rows from y = 0: .####. ×4, ###### ×5, .####. ×2, ..##.. ×6.

Hardpoint Pos Shape Rotation
Th.L (1,0) 2×2 Reversed (pushes forward)
Th.R (3,0) 2×2 Reversed
Th.CW (2,14) 1×2 CounterClockwise (pushes starboard, nose right)
Th.CCW (3,14) 1×2 Clockwise (pushes port, nose left)

The data has two aft 2×2 thrusters, not one. The operator remembered "one big thruster" and ruled on it, 2026-09-22: "Good catch on the Longinus main thrusters, I was misremembering." Fixtures follow the data.

Djinni (mass 10000, drag 0.2, 14×17). Rows from y = 0: ....######...., ...########..., ..##########.., .############. ×2, ############## ×5, .############., ...########... ×3, ....######...., .....####....., ......##.......

Hardpoint Pos Shape Rotation
Stern 1 (6,0) 2×1 Reversed
Stern 2 (5,1) 2×1 Reversed
Stern 3 (7,1) 2×1 Reversed
Port Aft (4,4) 1×2 CounterClockwise
Port Fore (4,11) 1×2 CounterClockwise
Starboard Aft (9,4) 1×2 Clockwise
Starboard Fore (9,11) 1×2 Clockwise
Bow (6,14) 2×1 None (pushes aft)

Legacy thruster items used for the fixtures: Large Drive 2×2, 250000 thrust, mass 100. Medium Drive 2×1, thrust 100000..300000 (fixtures use a constant 200000), mass 100. Small Drive 1×1, 75000.

Solver choice (probe)

The comparison ran on the fixtures' own normalized columns, plus a 16-column doubled Djinni: 2000 random wrench demands each, cost measured against a 20000-iteration projected-gradient reference, double precision, allocation-heavy scratch C#, Release build.

Method Worst excess cost Suboptimal cases Mean time
Primal active set (bounded-variable LS, Härkegård WLS form) ≤ 7e-15 0 in every case 1.8–9.5 µs (n 3–8), 32–38 µs (n 15–16); ≤ 30 iterations cold
Redistributed pseudo-inverse up to 4e3 270–1564 of 2000 (n ≤ 8) about half the active set's time

Pick the active set. It is exact, its cold-start cost is bounded, and it warm-starts from last tick's solution. The redistributed pseudo-inverse saves about half the time and is wrong in up to 78% of cases on these very matrices. A production float implementation without allocations will be faster than the scratch numbers.

Consumer audit

cd846916, every *.cs outside Assets/Plugins. "Dies" means deleted, not deprecated.

Surface Readers / writers Fate
Ship.MovementDirection (Ship.cs:27) W: ActionGameManager.cs:1263 (player), Agent.cs:67,73,77 (AI); tests BrownoutTests.cs:143,402, ConditionRatioTests.cs:184,193,243,258. R: Ship.cs:261-291 Survives as the translation input. Its meaning is defined by ShipControl (fraction of capacity per half-axis)
Entity.LookDirection (Entity.cs:55) W: ActionGameManager.cs:1254 (player), Agent.cs:66, MoveTo.cs:26, Combat.cs:126, TurretController.cs:72,75 (turret entities; no EntityTypeRestriction, TurretController.cs:14-15). R (as heading): Ship.cs:278. R (as aim): LockWeapon.cs:94, GuidedProjectileManager.cs:56, EntityInstance.cs:404, ShipInstance.cs:117 (tractor), ZoneRenderer.cs:432, ActionGameManager.cs:376 (reticle target pick) Survives as heading command and aim. MoveTo.cs:26 dies in Cut 4 (a double writer: Accelerate overwrites it when Δv > 20). The heading/aim conflation is Cut 5's question (Q4)
Thruster.Axis (Thruster.cs:45-49) W: Ship.cs:256-288 only. R: ShipInstance.cs:139 (particles), Thruster.cs:88 (power request), :96 (audio), :102-111 Getter survives. The public setter dies; only ShipControl commands throttle
AetherDrive.Axis (AetherDrive.cs:81-85) W: Ship.cs:291 only. R: drive internals, audio :170 Same as Thruster.Axis
IAnalogBehavior (Behaviors.cs:96-99) implemented by Thruster.cs:40; consumer only a comment, Entity.cs:2007 Dies (Cut 3 deletes first)
Bucket sets _forward/_reverse/_right/_left/_clockwise/_counterClockwiseThrusters, _thrusterItems, _aetherDrives, _aetherDriveItems (Ship.cs:30-39) Ship.cs only Die
ForwardThrust, ReverseThrust, LeftStrafeThrust, RightStrafeThrust, ClockwiseTorque, CounterClockwiseTorque, Left/RightStrafeTotalTorque, Left/RightStrafeTorqueThrusters (Ship.cs:50-59) written by RecalculateThrust, read by the mixer; no reader outside Ship.cs (grep, including Unity side and tests) Die
Ship.TurnTime (Ship.cs:61-66) no consumer Dies; replaced by ShipControl.TurnTime (Cut 3), consumed in Cut 4
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 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
Undock capability check (ActionGameManager.cs:913) GetBehavior<Thruster/AetherDrive>() == null Survives. It could later read the capability model; not this campaign
Mining, Survey, HaulingTask, StationTowing (Agents/Tasks/*.cs) data-only task classes; no movement code (grep) Not consumers. PatrolOrbitsState drives MoveToOrbitState, which Cut 4 covers
Player input path Input.Player.Look → _entityYawPitch → LookDirection (:1250-1254); Input.Player.Move → MovementDirection (:1261-1264). No other action writes motion (grep of Input.Player.*: targeting, heat, shield, stance, tractor, ping, UI) Already on the shared surface. The only bypasses are the ones Ship.cs itself holds (the snap, direct Axis writes), and both die in Cut 3

Out-of-scope note: the working tree has an uncommitted change to Assets/Scripts/AetheriaInput.cs (a "Cycle Target Item" action). That is fire-control work, and it does not touch motion.


Part III: Cut map

Status header

Status: cut map. Ends are owned by Part I above; this section owns the means.

Cut 1 landed and is merged into codex/fire-control-12 at e160bbe5. Cuts 2 to 5 have not landed.

Rulings (operator, 2026-09-22): Q1 A ("The divergence is fine"), Q2 B ("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"), Q3 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.

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.

Tutorial station (operator, 2026-09-25): "What we're testing is going to be the tutorial level, right? Let's drop a station in there." The live catalog already has a station hull: Zenith, sold by Aeronautics Unlimited. The story-station path is dead because StoryProcessor has been commented out at Galaxy.cs:242 since ce1a0a46 (2021). Ruled: faction station now. The tutorial's entrance zone (Galaxy.Entrance) always gets a generated faction station with a docking bay. That is deterministic, not merely likely, and the test pins it on the real tutorial construction path. Reviving the Ink story stations belongs to a later narrative campaign, which must first find out why they were switched off.

Legacy hazard, recorded 2026-09-26 (Soul, Cut 1 fix batch 2): VelocityLimit is a hull behaviour gated by the hull's Active (Entity.cs:1624, :1636, VelocityLimit.cs:45-49). Thrusters are items with their own gating. Any path that takes the hull offline while the thrusters keep firing removes the cap. Under frozen fixture physics, with the player's override on the thrusters, speed reached 1237. On the shipped settings the hull did not go offline in 300 s. The behaviour dates from 2020-04 and 2021-03. Cut 3 and Cut 4 measure arrival times under this cap, so they must not assume it always holds.

Follow-ups outside this campaign:

  • TurretController has no EntityTypeRestriction, so equipping it on a ship would create a third LookDirection writer racing the player or agent. Restrict it to non-ship hulls when turret work next opens (TurretController.cs:14).
  • Thruster.Execute applies rotation per thruster inside the tick, which couples later thrusters' force to earlier thrusters' rotation. It is a small artefact, and it is Thruster.cs's to fix, not the allocator's.
  • AetherDrive.Execute logs "FUCK FUCK FUCK FUCK" on NaN velocity (AetherDrive.cs:157-158), a leftover probe.

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?", 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, implemented on codex/locomotion-cut1 and merged into codex/fire-control-12 at e160bbe5. The restored hulls are now the real fixtures for Cuts 3 and 4. The catalog mutation is complete.
  • Standing rule (memory: aetheria-content-audits-pre-breach): GameData/Aetheria.cc is the post-breach bare-minimum rebuild, so absence there is not evidence of absence, and the operator's word is the authority on what the pre-breach content was. The list below is therefore an operator review item before Hands writes anything into the catalog.

What the legacy record actually holds (probe)

Sources, all decoded in the scratchpad: the git-LFS objects behind GameData/Legacy/AetherDB.2021-04-14.msgpack (21ce7bd2…, 76 KB) and AetherDB.2021-03-05.msgpack (142998fd…, 59 KB), and the last GameData/AetherDB.msgpack before the Cut 9 import (8963a686…, 46 KB, the commit's own parent at 70fbaca1^). All three are present in .git/lfs/objects; nothing had to be fetched.

The comparison explains the loss. The 2021-04-14 record carries 5 ship-class hulls and 45 gear designs. The import source carries 3 hulls (LonginusX, Zenith, Turret) and 25 gear. Longinus, Djinni and every thruster below the 2×2 class were already gone from the file Cut 9 imported, which is why the live catalog has no thruster ship.

Recoverable in full from 2021-04-14 (name, shape, mass, price, hardpoint list with position, shape, rotation and armour, behaviours, temperature band, manufacturer, prefab and schematic paths):

Record Kind Legacy detail Notes
Longinus ship hull 6×17, mass 2500, drag 0.1, price 7,500,000, maker Alakrita The shape is byte-identical to the live LonginusX's. 4 thruster hardpoints (Th.L/Th.R 2×2 aft, Th.CW/Th.CCW 1×2 in the nose) plus control module, 2 energy, 2 launcher, 2 radiator, reactor, sensors
Djinni ship hull 14×17, mass 10000, drag 0.2, price 10,000,000, maker Rossum & Douglas 8 thruster hardpoints (3 stern, 4 lateral, 1 bow) plus 4 launcher, 2 ballistic, reactor, control module, 4 radiator, sensors, and a shield hardpoint
Medium Drive thruster gear 2×1, mass 100, thrust 100000..300000, price 50,000, no maker The one item both restored hulls need. Fits the 2×1 stern/bow hardpoints and, rotated, the 1×2 lateral and nose ones
Small Drive thruster gear 1×1, mass 50, thrust 75000, price 25,000, no maker Optional; the cheap end of the line
Victoire thruster gear 2×1, mass 25, thrust 200000..1500000, maker Alakrita Optional; the racing thruster, and a natural Longinus fit
Talaria thruster gear 2×1, mass 150, thrust 75000..250000, maker Aeronautics Unlimited Optional
RevvITup 2.0 thruster gear 2×2, mass 150, thrust 250000..1000000, maker NiteLife Energy Optional; a second 2×2 beside the two the catalog already ships

Everything those records depend on is already in the live catalog: all 12 factions (Alakrita and Rossum & Douglas included), both existing 2×2 thrusters, and the Unity presentation. The prefabs still carry the hardpoint transforms by their legacy names — Th.L, Th.R, Th.CW, Th.CCW in Assets/Content/Prefabs/Ships/ Longinus.prefab (GUID 4a3db609…, already the hull the live LonginusX points at) and Thruster Stern 1..3, Thruster Port/Starboard Fore/Aft and Thruster Bow in Djinni.prefab (GUID 79024f63…). The schematics exist too (schema_Longinus.png 51702555…, already referenced; djinni_schematic_transparent.png 66f3acdd…, unused today). So no art, no prefab work and no faction work is needed.

Not recoverable, because it post-dates the breach and has to be authored:

  • HardpointData.FiringArc (Key 6) for the Djinni's weapon hardpoints. The legacy hardpoint record stops at armour. 0 means "use GameplaySettings.FiringArc"; the firing-arc-migrate command in tools/AetherDb is the precedent for deriving one from mount rotation.
  • ItemRole lists on the restored designs, and ProductRole qualities on their products. RoleAuthoringTests refuses a stat naming a role its design lacks, and docs/content-batch-one.md fixes the nomenclature (injector/nozzle for thrusters, plating for hulls). The live "deep space burnout" carries injector, nozzle; "Large Drive" carries none.
  • A FactionProductData per restored design. LoadoutGenerator picks products, not designs, so a design without one exists but is never generated or sold. The catalog holds 47 products today.
  • Any PowerSupply term on the restored thrusters' stats (the stats-and-power cut's brownout curve). The shipped 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).

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. (Corrected 2026-09-26: the decode behind the restore gives Longinus 100..100 with exponents 1, 0, 0 and Djinni 50..50 with 1, 0, 1.5. The catalog carries those values.) So both restored hulls are capped, 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 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 (tools/AetherDb/Import.cs, added in 70fbaca1 and deleted by Cut 10) is the precedent for reading the legacy [tag, payload] records and rewriting them by the target type's [Key] shape; read it out of git history rather than re-deriving the mapping. Delete the command in the same campaign, as Cut 10 deleted the importer, so the carrying cost of the restore is zero once it has run.
  • Authority map:
    • Owner: the catalog (GameData/Aetheria.cc) owns authored content; the operator owns what the content is.
    • Inputs: the legacy LFS records, the existing factions and assets, the operator's review of the list above.
    • Outputs: new HullData, GearData and FactionProductData records.
    • Derived state: none. Runtime Lots and provenance are minted as for any other design.
    • Forbidden writers: nothing else may write the catalog while this cut is in flight; the one-shot command must refuse a second apply rather than upsert twice.
    • Deletion line: the command goes once the records are in.
  • Verification:
    • FireControlCut7Tests.ShippedCatalogOpensAndGeneratesAnArmedHull still passes unchanged: the real catalog opens, every EquippableItemData deserializes, and LonginusX still generates an armed loadout.
    • New RestoredHullsTests against the real catalog, read-only: each restored hull exists, has the expected thruster hardpoint count (Longinus 4, Djinni 8), every thruster hardpoint accepts a catalog thruster design through TryEquip at that hardpoint's own position, and the ship then moves and turns under MovementDirection/LookDirection for a few ticks. Before Cut 3 this runs on the old mixer, which is the point: it proves the content, not the controller.
    • LoadoutGenerator generates each restored hull when filtered to it, which is the check that the products and 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 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.

Cut 2. CultMath: bounded least squares (CultLib)

  • Repo/branch: F:\Projects\CultLib, a branch from 45c2f40 (Aetheria's pinned CultLibRevision, Directory.Build.props). Depends on nothing.
  • First: run dotnet test packages/cultmath/tests/CultMath.Tests green at base and record the count.
  • Deletes first: none. This is a pure addition, and it is the ruled owner fill (R4).
  • Adds: one C# file in packages/cultmath/src/CultMath/ with a static solver for min ||A x − b||² subject to lo ≤ x ≤ hi. It serves a dense, small problem (m ≤ ~24 rows, n ≤ ~24 columns). Rules:
    • The method is a primal active set (bounded-variable LS). Each iteration solves the free set by Cholesky on the normal equations, or by QR if Hands prefers conditioning. It terminates on KKT: at each bound the gradient sign must be consistent with that bound.
    • float in, Span/ReadOnlySpan arguments, caller-supplied workspace. No allocation per call.
    • x is both warm start and result. The solver clamps it to the box first.
    • It returns an iteration count or status, has a bounded iteration cap, is deterministic, and keeps its last feasible iterate on a cap hit. A rank-deficient free set is handled (the caller adds a small ridge row, and the solver must not throw on a singular pivot).
    • Naming stays boring and HLSL-adjacent per docs/design.md Rules, for example a CultMath.BoundedLeastSquares static class. It is C#-only like the intercept helpers (math.cs). No Rust or HLSL mirror, because it is not a batch kernel and no shader needs it.
  • Tests in packages/cultmath/tests/CultMath.Tests:
    • InteriorOptimumEqualsNormalEquations: a problem whose unconstrained optimum lies inside the box returns it. Pins the free-set solve.
    • KktHoldsOnRandomBoxedProblems: randomized problems up to 24×16 are checked against a projected-gradient reference to 1e-4 relative cost, and each active bound's gradient sign is checked. Pins optimality and the multiplier test.
    • AllBoundsActive, ZeroColumn, DuplicateColumns: degenerate columns return without throwing and are optimal. Pins rank handling.
    • WarmStartMatchesColdStart: a solution from a perturbed warm start equals the cold solution. Pins that the start does not bias the result.
    • AsymmetricBounds: lo = −1, hi = 1 on some columns and [0,1] on others. Pins bidirectional actuators.
    • Fixtures must not be degenerate: non-square, non-identity A, and b not in A's range.
  • Release: build the Unity package (packages/cultmath/scripts/build-unity-package.ps1), bump unity/org.gamecult.cultmath/package.json to 0.2.4, and tag cultmath-unity-v0.2.4 by CultLib's own release path. In Aetheria, bump Packages/manifest.json's org.gamecult.cultmath tag and Directory.Build.props CultLibRevision in one commit.
  • Authority map: Owner: CultMath. Inputs: A, b, bounds, warm start. Outputs: x and status. Forbidden writers: no least-squares or linear-solve code in Aetheria/Assets/Scripts/** (negative grep in Cut 3).
  • Verification: dotnet build packages/cultmath/src/CultMath/CultMath.csproj; dotnet test of CultMath.Tests. Aetheria's dotnet test tests/Aetheria.Shared.Tests still passes 244 after the bump. Unity compiles the new package, and only the operator can check that.
  • Ledger estimate: +150..220 lines of source and +150 lines of test in CultLib; Aetheria gets a 2-line bump.

Cut 3. ShipControl: shared controller and allocator replace the mixer

  • Repo/branch: Aetheria, a branch from the Cut 2 bump commit. Depends on Cut 2. Cut 1 is not a prerequisite for the code, but its restored hulls are what the operator's play check needs.
  • First: before editing, commit the base flight numbers above as test constants, reproduced by the fixture spec in "Findings". They are computed at cd846916, never captured from new code. Also run a LonginusX-with-Traction pin at base: AetherDrive.Axis for each probe input is (my, mx, sqrt(|sin e|)·sign), including e = 30°, which gives Axis.z = 0.7071.
  • Deletes first (separate commit, build and tests green, no behaviour change):
    • Ship.cs:61-66 TurnTime (6 lines).
    • Behaviors.cs:96-99 IAnalogBehavior (4 lines) and its mention in the Thruster declaration (Thruster.cs:40).
    • Entity.cs:2007, the commented IAnalogBehavior line.
  • Deletes (behaviour commit):
    • Ship.cs:30-39 (bucket and item sets, 10 lines).
    • :50-59 (capacity properties and strafe lists, 10 lines).
    • :96-118 (bucket construction; Activate keeps base.Activate() and gathers actuators, about 20 lines).
    • :123-124 (subscriptions) and :127-143 (RemoveAetherDrive, RemoveThruster, 17 lines).
    • :145-249 (the ThrustCalculation region, 105 lines).
    • :255-291 (mixer and snap, 37 lines).
    • The public Axis setters: Thruster.cs:45-49 and AetherDrive.cs:81-85 become getters plus a command method only ShipControl calls.
    • Total is about 200 lines out of Ship.cs.
  • Keeps: Ship.cs:253 gate (active, not in a wormhole); :294-310 drag, position, gravity and rotation quaternion; wormhole code; Thruster.Execute/AetherDrive.Execute physics unchanged. The throttle is still _input/_axis, so power requests, audio and particles read what they read today.
  • Adds: ShipControl in Assets/Scripts/ServerShared/ShipControl.cs, owned by Ship and stepped from Ship.Update where the mixer was. It is one file and one owner. Types and rules:
    • Actuator columns. A narrow interface implemented by Thruster and AetherDrive. There are two implementers and one consumer; this replaces IAnalogBehavior.

      • Each actuator reports its columns every tick: body-frame (Fx, Fy) in m/s² and yaw rate in rad/s (clockwise positive) per unit throttle, plus bounds. It then accepts its throttles back.
      • Thruster: one column, [0,1]. F is the negative item facing (Rotate of (0,1) by mount rotation) times live Evaluate(Thrust)/Mass. Yaw is Torque·Thrust·TorqueMultiplier/Mass.
      • Zeroing: the column is zero when !Item.Active.Value or Item.PowerSupply ≤ 1e-4, which is the same predicate Execute gates on. Thruster.Thrust (stale, updated only in Execute) is not the source.
      • AetherDrive: three axes. Translation is split into ± columns, [0,1] each, because the drive's efficiency depends on sign and speed (AetherDrive.cs:138). Torque is one column, [−1,1].
      • Drive authority is nominal. Effectiveness is the drive's force at MaximumRpm from its own stats (rotor mass, λ, λ multiplier, coupling efficiency, AetherTorqueMultiplier), scaled by the same liveness and by its 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.
    • 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.
    • Ship.cs:92-119: Activate collects actuators into ShipControl (all Thruster and AetherDrive behaviours). No classification.
    • Ship.cs:121-125: the constructor drops both ItemDestroyed subscriptions.
    • Thruster.cs:40: implement the actuator interface, drop IAnalogBehavior. :45-49: public getter, internal command. :61-71: Torque stays as the geometry factor.
    • AetherDrive.cs:81-85: same pattern. Add the nominal-authority read next to its stats.
    • tests/Aetheria.Shared.Tests/BrownoutTests.cs:111-131 and ConditionRatioTests.cs:94-95: equip the consumer thruster on the hull's centre column, using TryEquip(item, int2(2, y)) with a free y, so Torque == 0. These tests pin brownout and condition, not flight. Their asserted values (for example .1f at BrownoutTests.cs:162) must not change. Fix the fixture, never the assertion. PowerCurveTests.cs:52-62's comment about the Axis being clobbered becomes true for a different reason; update the wording only.
  • 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, 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. 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.
    • Deletion line: the delete commits above land before ShipControl gains behaviour.
  • Verification:
    • Builds: dotnet build Aetheria.Shared; dotnet test tests/Aetheria.Shared.Tests -m:1 -p:CultLibRoot=…. Unity batchmode compile (operator, or Self when the editor is closed).
    • Tests (new file LocomotionControlTests.cs, fixtures exactly as in "Findings": off-origin, off-axis nose with |fx| > |fy|, nonzero dt, asymmetric damage):
      • IntactLonginusMatchesBase and IntactDjinniMatchesBase: every row of the base table within tolerance (2% of that ship's full-scale acceleration or turn rate), except the four accepted divergences below, which 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 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 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.
      • LonginusXDriveAxisEqualsBaseFormula: a drive-only hull; for every probe input, AetherDrive.Axis equals (my, mx, sqrt(|sin e|)·sign) to 1e-3. Pins the live player ship's feel exactly.
      • CentreMainHullTakesTheLongWayRound: the long-way fixture, built so the long way is the only way. One 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.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 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 is no NaN. Pins the snap's deletion and the >90° profile.
      • ZeroLookHoldsHeading: default LookDirection gives no NaN in Direction or any throttle.
      • DestroyedThrusterNeverReceivesThrottle: for each Djinni thruster in turn, destroy it, then drive 50 ticks of mixed inputs (forward, strafe, turn both ways, diagonal). Its Axis is 0 on every tick. Repeat for Enabled = false and for a zero power grant. Pins invariant 7.
      • RepairedThrusterRejoins: destroy, restore durability, UpdatePerformance. The next tick's strafe uses it. Pins the live read (invariant 3) against the old permanent pruning.
      • PlayerAndAiInputsGiveIdenticalThrottles: two identical Djinni hulls with the same damage. One gets inputs the way ActionGameManager writes them (LookDirection with nonzero pitch from yaw/pitch Euler, MovementDirection from a stick value). The other gets them through an Agent state writing the same yaw, flat. After Update, every actuator throttle is identical. Pins R2.
      • LonginusMissingMainHoldsHeadingUnderThrust: Th.L destroyed, forward input. Heading drift stays under 1° over 2 s, and forward acceleration is at least 70 m/s² (probe: 78 with 12 lateral slip). Pins torque-coupling compensation in the shared layer.
    • Negative greps, verified against cd846916 to hit only the lines this cut deletes:
      • \.Axis\s*[-+]?= in Assets/Scripts should match nothing outside ShipControl.cs. At base it matches exactly Ship.cs:256,264,273,275,276,287,288,291. FieldDriver.cs's RectTransform.Axis does not match; 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 match nothing.
    • Stryker: dotnet stryker --since:<Cut 3 base> from tests/Aetheria.Shared.Tests. Every non-equivalent survivor in ShipControl.cs, Thruster.cs and AetherDrive.cs is killed or triaged by name. Float boundary flips are equivalent, per the fire-control Cut 11 ruling.
    • 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: 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

  • Repo/branch: Aetheria, after Cut 3.
  • First: pin base AI results as constants. On base, a Djinni with all stern thrusters destroyed never reaches the target (speed 0 at 120 s). Intact, it reaches it in 4.34 s. Both come from the probe (Agent plus MoveTo, dt 0.02, target 300 m to port from (37,−12), nose normalize(0.8,0.6)).
  • 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). 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 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 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 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 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 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, +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: 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.

Operator questions

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.

  • 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):

    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.

    • A. Add a heading input beside the aim on the shared surface. The player's heading follows the view unless a player control is added later.
    • B. Keep one input, and the AI accepts degraded lock cones and missile aim while it manoeuvres.

    Recommended: A, decided when Cut 5 is mapped.

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 base behaviour, which would have meant reintroducing per-thruster role rules into the objective.
  • 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.
  • 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)

Cut Removed Added Dependencies / targets
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 ~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