Imagination pass, aetheria-release, session self-2026-10-09-morning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 KiB
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/CultMathat45c2f40holds vector types,math,BatchMath,VoronoiandRandom, 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
LookDirectionand realisesMovementDirectionwith 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
LookDirectiondirectly, 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 isVelocityLimitData. 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.
- 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
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
- 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) andEntity.LookDirection(world-frame heading command; the controller reads only itsxz). 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. - One owner for actuator commands. The ship controller (new,
ShipControl, below) is the only writer ofThrusterandAetherDrivethrottles.Ship.cs's mixer, its rotation buckets and its strafe compensation stop existing, and do not survive as a mode or a fallback. - 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 epsilonThruster.Executeuses, and the current resolvedThrust(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. - 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
ShipControlstep insideShip.Update. No input source writesThruster.Axis,AetherDrive.AxisorEntity.Directiondirectly. - Heading changes only through actuators.
Entity.Directionis 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. - 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. - A dead actuator never receives throttle. A destroyed, disabled or unpowered actuator gets zero throttle, not a saturated request it cannot honour.
- 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
ShipControlstates. 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 rewriteAgents/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 isGameData/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 inMoveToState, docking approach and formation flight are also out.
Not consumers
Assets/Scripts/Gameplay/*presentation (ShipInstanceparticles, audio parameters) reads throttles and never writes them.TurretControllerwritesEntity.LookDirectionfor 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:
RecalculateThrust(Ship.cs:147-247) sums per-bucket thrust over thrusters whose item isActive.- It zeroes every thruster's
Axis(:256). - 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). - Forward and reverse buckets add
±MovementDirection.y(:275-276). - Rotation:
deltaRot = sqrt(|dot(look, right)|)·sign(:278-285) is added to the clockwise bucket and subtracted from the counter-clockwise bucket (:287-288). 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.CCWdestroyed, 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,
AgentplusMoveToStatetoward a point 300 m to port leaves the ship at speed 0 after 120 s.Agent.Acceleratepoints 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:283lerpsDirectiontoward 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:105uses the unnormalized item direction). Base also turns slower as error passes 90°:deltaRotfollowssin, so it is zero at 180°. - First tick. Items are not yet
Activeon the firstUpdateafterActivate, so the first-tick strafe compensation divides by a zero count (NaN, unused) and the strafe fires uncompensated. TurnTimeis wrong and unused.Ship.cs:61-66divides 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.
RemoveThrusternever restores a thruster whose durability comes back, andRecalculate*readsActivewhile the mixer still commands inactive thrusters. - The live catalog's only ship flies on a drive.
GameData/Aetheria.ccholds one ship hull, LonginusX (mass 2500, drag 0.1, 6×17), with anAetherDrivehardpoint and noThrusterhardpoint. 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/ConditionRatioTestsequip a one-cell thruster byTryFindSpaceonto a 5×5 hull. It lands at (1,1) withTorque−0.707, andMovementDirection (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:
TurretControllerhas noEntityTypeRestriction, so equipping it on a ship would create a thirdLookDirectionwriter racing the player or agent. Restrict it to non-ship hulls when turret work next opens (TurretController.cs:14).Thruster.Executeapplies rotation per thruster inside the tick, which couples later thrusters' force to earlier thrusters' rotation. It is a small artefact, and it isThruster.cs's to fix, not the allocator's.AetherDrive.Executelogs "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-cut1and merged intocodex/fire-control-12ate160bbe5. 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.ccis 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 "useGameplaySettings.FiringArc"; thefiring-arc-migratecommand intools/AetherDbis the precedent for deriving one from mount rotation.ItemRolelists on the restored designs, andProductRolequalities on their products.RoleAuthoringTestsrefuses a stat naming a role its design lacks, anddocs/content-batch-one.mdfixes the nomenclature (injector/nozzlefor thrusters,platingfor hulls). The live "deep space burnout" carriesinjector, nozzle; "Large Drive" carries none.- A
FactionProductDataper restored design.LoadoutGeneratorpicks products, not designs, so a design without one exists but is never generated or sold. The catalog holds 47 products today. - Any
PowerSupplyterm 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:
ItemManagermints theLot(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; aFactionProductDatafor each; roles for each; firing arcs on the Djinni's weapon hardpoints; each hull'sVelocityLimitbehaviour 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 throughAetheriaStores.Open(catalogWritable: true)only when applying, writes every record in oneCommit, and refuses to run twice. The Cut 9 importer (tools/AetherDb/Import.cs, added in70fbaca1and 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,GearDataandFactionProductDatarecords. - 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.
- Owner: the catalog (
- Verification:
FireControlCut7Tests.ShippedCatalogOpensAndGeneratesAnArmedHullstill passes unchanged: the real catalog opens, everyEquippableItemDatadeserializes, and LonginusX still generates an armed loadout.- New
RestoredHullsTestsagainst 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 throughTryEquipat that hardpoint's own position, and the ship then moves and turns underMovementDirection/LookDirectionfor a few ticks. Before Cut 3 this runs on the old mixer, which is the point: it proves the content, not the controller. LoadoutGeneratorgenerates each restored hull when filtered to it, which is the check that the products and roles are authored correctly.dotnet run --project tools/AetherDb -- censusnames the two hulls with their makers, anddanglingreports 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 from45c2f40(Aetheria's pinnedCultLibRevision,Directory.Build.props). Depends on nothing. - First: run
dotnet test packages/cultmath/tests/CultMath.Testsgreen 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 formin ||A x − b||²subject tolo ≤ 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.
floatin,Span/ReadOnlySpanarguments, caller-supplied workspace. No allocation per call.xis 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.mdRules, for example aCultMath.BoundedLeastSquaresstatic 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 = 1on 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), bumpunity/org.gamecult.cultmath/package.jsonto 0.2.4, and tagcultmath-unity-v0.2.4by CultLib's own release path. In Aetheria, bumpPackages/manifest.json'sorg.gamecult.cultmathtag andDirectory.Build.propsCultLibRevisionin 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 testof CultMath.Tests. Aetheria'sdotnet test tests/Aetheria.Shared.Testsstill 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.Axisfor each probe input is(my, mx, sqrt(|sin e|)·sign), including e = 30°, which givesAxis.z = 0.7071. - Deletes first (separate commit, build and tests green, no behaviour change):
Ship.cs:61-66TurnTime(6 lines).Behaviors.cs:96-99IAnalogBehavior(4 lines) and its mention in theThrusterdeclaration (Thruster.cs:40).Entity.cs:2007, the commentedIAnalogBehaviorline.
- 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;Activatekeepsbase.Activate()and gathers actuators, about 20 lines).:123-124(subscriptions) and:127-143(RemoveAetherDrive,RemoveThruster, 17 lines).:145-249(theThrustCalculationregion, 105 lines).:255-291(mixer and snap, 37 lines).- The public
Axissetters:Thruster.cs:45-49andAetherDrive.cs:81-85become getters plus a command method onlyShipControlcalls. - Total is about 200 lines out of
Ship.cs.
- Keeps:
Ship.cs:253gate (active, not in a wormhole);:294-310drag, position, gravity and rotation quaternion; wormhole code;Thruster.Execute/AetherDrive.Executephysics unchanged. The throttle is still_input/_axis, so power requests, audio and particles read what they read today. - Adds:
ShipControlinAssets/Scripts/ServerShared/ShipControl.cs, owned byShipand stepped fromShip.Updatewhere the mixer was. It is one file and one owner. Types and rules:-
Actuator columns. A narrow interface implemented by
ThrusterandAetherDrive. There are two implementers and one consumer; this replacesIAnalogBehavior.- 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 (
Rotateof (0,1) by mount rotation) times liveEvaluate(Thrust)/Mass. Yaw isTorque·Thrust·TorqueMultiplier/Mass. - Zeroing: the column is zero when
!Item.Active.ValueorItem.PowerSupply ≤ 1e-4, which is the same predicateExecutegates on.Thruster.Thrust(stale, updated only inExecute) 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
MaximumRpmfrom its own stats (rotor mass, λ, λ multiplier, coupling efficiency,AetherTorqueMultiplier), scaled by the same liveness and by its condition. It is not the instantaneousRpm. 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
ShipControlbuilds a desired wrenchv = (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
Bis the live column matrix,cap_axisis that axis's achievable extreme, andridgeis 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.
capfor 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 whatMovementDirectionis a fraction of. Probe values: Longinus+y172.41,±x68.97, yaw 8.25 rad/s (472.9°/s); Djinni+y55.56,−y18.52,±x37.04, yaw 3.25 rad/s (186.2°/s). -
Demand.
F_d = (mx · cap(±x), my · cap(±y))fromMovementDirection.ω_d = min(profile(e) · cap(dir), e / dt)toward theLookDirectionheading,profile(e) = sqrt(sin e)for e ≤ 90° (base's own curve) and 1 beyond, with thee/dtterm landing the heading without overshoot in place of the deleted snap. A zeroLookDirectionmeans hold heading: no demand, no NaN. -
Direction choice stays a time comparison,
angle_in_that_direction / cap(dir), infinite wherecapis 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 = Bcap(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_dis 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.
- (a) Turning while forward thrust is also wanted. The mains are already firing for
-
TorqueFloorhas no consumer left. It was read only atShip.cs:115,118. The field goes fromGameplaySettings(Settings.cs:205) along with its authored value inAssets/Resources/Settings.asset;TorqueMultiplierandAetherTorqueMultiplierstay, 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:UpdatecallsShipControl.Step(dt)inside the existing:253gate. The drag and position code below stays.Ship.cs:92-119:Activatecollects actuators intoShipControl(allThrusterandAetherDrivebehaviours). No classification.Ship.cs:121-125: the constructor drops bothItemDestroyedsubscriptions.Thruster.cs:40: implement the actuator interface, dropIAnalogBehavior.:45-49: public getter, internal command.:61-71:Torquestays 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-131andConditionRatioTests.cs:94-95: equip the consumer thruster on the hull's centre column, usingTryEquip(item, int2(2, y))with a free y, soTorque == 0. These tests pin brownout and condition, not flight. Their asserted values (for example.1fatBrownoutTests.cs:162) must not change. Fix the fixture, never the assertion.PowerCurveTests.cs:52-62's comment about theAxisbeing clobbered becomes true for a different reason; update the wording only.
- Authority map:
- Owner:
ShipControldecides every actuator throttle and the feasible turn direction. - Inputs:
MovementDirection,LookDirection.xz, currentDirection,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.AxisandAetherDrive.Axisare display and command readbacks. - Forbidden writers:
Ship.Update's mixer (gone); any assignment toThruster.Axis/AetherDrive.AxisoutsideShipControl;Ship.cs:283's directDirectionwrite; bucket pruning onItemDestroyed; any Unity-side write of throttles. Also forbidden insideShipControl: 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 throughShipControl.Step. Wormhole exit leaves the gate closed until the animation ends, as today. - Deletion line: the delete commits above land before
ShipControlgains behaviour.
- Owner:
- 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):IntactLonginusMatchesBaseandIntactDjinniMatchesBase: 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:
- 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.
- 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.
- Djinni strafe reaches 37.0 m/s² against base's 34.0 (+9%), by balancing torque with Stern 3 and Bow.
- 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.Axisequals(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 realItemDamagepath. WriteLookDirectiondirectly, 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 withTh.CCWdestroyed, 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 withMovementDirectionzero. 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 withMovementDirectionzero 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 withLookDirectionfixed. 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: defaultLookDirectiongives no NaN inDirectionor any throttle.DestroyedThrusterNeverReceivesThrottle: for each Djinni thruster in turn, destroy it, then drive 50 ticks of mixed inputs (forward, strafe, turn both ways, diagonal). ItsAxisis 0 on every tick. Repeat forEnabled = falseand 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 wayActionGameManagerwrites them (LookDirectionwith nonzero pitch from yaw/pitch Euler,MovementDirectionfrom a stick value). The other gets them through anAgentstate writing the same yaw, flat. AfterUpdate, every actuator throttle is identical. Pins R2.LonginusMissingMainHoldsHeadingUnderThrust:Th.Ldestroyed, 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
cd846916to hit only the lines this cut deletes:\.Axis\s*[-+]?=inAssets/Scriptsshould match nothing outsideShipControl.cs. At base it matches exactlyShip.cs:256,264,273,275,276,287,288,291.FieldDriver.cs'sRectTransform.Axisdoes not match; checked._forwardThrusters|_clockwiseThrusters|StrafeTorqueThrusters|RecalculateThrust|TurnTime\(should match onlyShipControl.cs's newTurnTime.TorqueFloorshould match nothing inAssets/Scripts, and nothing inAssets/Resources/Settings.assetonce Unity rewrites it.IAnalogBehaviorshould match nothing.lerp\(DirectioninShip.csshould match nothing.- No least-squares code outside CultMath:
Cholesky|pseudo.?inverse|ActiveSetinAssets/Scriptsshould match nothing.
- Stryker:
dotnet stryker --since:<Cut 3 base>fromtests/Aetheria.Shared.Tests. Every non-equivalent survivor inShipControl.cs,Thruster.csandAetherDrive.csis 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.
- Builds:
- 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,TorqueFloorand its authored value).ShipControl.csadds 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) andAgent.cs:60-78(the body ofAccelerate, 19 lines).MoveTo.cs:26(the duplicateLookDirectionwrite). The signatureAccelerate(float2, bool noTurn)stays, sinceCombat.cs:127andMoveTo.cs:27call 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
ShipControlsolve 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 = θ*unlessnoTurn. WriteMovementDirectionas the per-half-axis fractions that point the translation demand along Δv, invertingShipControl's own mapping (aShipControlhelper, 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:
Agentdecides what the AI writes to the two inputs. - Inputs: target velocity and objective position, the
ShipControlstep used as the prediction model, current velocity and heading. - Outputs:
LookDirection(unlessnoTurn) andMovementDirection. - Derived: the plan (heading, time of last replan) is command state only.
- Forbidden writers:
MoveToStatewritingLookDirection; any AI code writing throttles orDirection; 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) andCombatStateboth go throughAccelerate.
- Owner:
- Verification:
DjinniWithoutSternReachesTargetByCrabbing: stern cluster destroyed, target 300 m at a bearing 30° right of the nose, driven throughAgentplus a fixed-targetMoveToState. 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 ownVelocityLimitin 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 withTh.CCWdestroyed 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:
LookDirectioninAgents/States/MoveTo.csshould match nothing. - Stryker
--since:<Cut 4 base>overAgent.cs,MoveTo.csandShipControl.csqueries. - 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).
LookDirectionis both the heading command and the aim, read byLockWeapon.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
TorqueFloorloses 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 |