Aetheria/docs/headless-playground-cut.md
soul bc05417450 Command seam fixes: the player's switches, ping and stance flips are PilotCommand presses; triggers, switches and ping are internal
ActivatedBehavior replaces IActivatedBehavior so Activate/Deactivate can be internal under Unity's C# 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:33:20 +02:00

71 KiB

Headless Playground: Run Lifecycle Cut Map

Date: 2026-09-17

Status: Imagination pass, cut map. Nothing here has landed. Anchors are against codex/item-provenance HEAD 1e647953. Mechanism claims marked (probe) were run headless against the real catalog GameData/Aetheria.cc (read-only) and the authored Assets/Resources/Settings.asset. Run and player stores went to a temp directory. The probe was built with --artifacts-path in scratch, so this tree got no bin/obj writes. CultLib was a temporary detached worktree at a0813c6, since removed. The probe source is not kept; §2.8 records what it did.

0. Target

Operator, 2026-09-17: player inputs "map directly to the same entity inputs the AI uses". An agent should be able to "spin up a playground ... generate galaxies, puppeteer an entity around yourself using the existing agent routines", and "This should be possible without involving Unity".

Ends:

  • An agent (Hands or Soul) runs a scripted session headless. The script can:
    • start a new game (galaxy, entrance zone, player ship);
    • drive the pilot by hand-set entity inputs or by an existing Agent;
    • dock, undock, warp between zones, take hits, die, and save;
    • reopen the stores and Continue.
  • The session emits a compact trace. It exits non-zero when an expectation fails or an exception escapes.
  • First consumers:
    • the headless half of the CultCache-migration play smoke (docs/cultcache-migration-cut.md:2739-2765);
    • the headless half of the provenance Cut C operator checks (docs/item-provenance-cut.md §6, "Operator-only checks after Cut C");
    • the reopen defect in §2.6.

Invariants:

  • One owner of the run lifecycle. The playground and ActionGameManager both call the same ServerShared owner. Neither re-implements new game, zone entry, warp, dock, save, Continue or death.
  • One input surface. Manual input, agent routines and playground commands write the same entity fields and call the same behavior methods. The playground has no private input path and writes no entity state the player cannot reach. Exceptions are named commands that call the same owner the Unity console calls.
  • Determinism. A scenario with a given seed produces the same trace twice. The probe's two runs matched line for line.
  • The playground never touches GameData/run.cc or GameData/player.cc. It opens the catalog read-only and keeps its run and player stores in a directory it is given.

Non-goals:

  • No rendering, no Unity, no new daemon, no CultMesh surface.
  • No headless ballistics. Fire control does not detect hits; it rolls them, headless (fork D ruling, Cut 2). Unity flies the shot to the outcome.
  • No trade menu, no action-bar UI, no input devices.
  • Tier colour, properties panel, input screen and CultCache Studio rendering stay operator checks.

1. Verdict on the operator's claim

Mostly true for movement and firing. False for how combat resolves, and the AI's combat state cannot run without Unity.

  • True:
    • Player movement and AI movement write the same Ship.MovementDirection (Ship.cs:27). Player: ActionGameManager.cs:1201. AI: Agent.cs:67,73,77.
    • Both write the same Entity.LookDirection (Entity.cs:42). Player: ActionGameManager.cs:1189-1192. AI: MoveTo.cs:26, Combat.cs:111, Agent.cs:66.
    • Both write the same Entity.Target (Entity.cs:40). Player: ActionGameManager.cs:366-397. AI: Minion.cs:14.
    • Both fire through the same Weapon.Activate/Deactivate. Player: ActionBarSlot.cs:190-204, via the action bar at ActionGameManager.cs:411-412. AI: Combat.cs:56,106,108.
  • Player-only inputs, all plain entity writes that an agent could make:
    • TractorPower (ActionGameManager.cs:1214-1216);
    • OverrideShutdown, HeatsinksEnabled, shield Enabled, Sensor.Ping (:339-362);
    • gear ActivatedBehavior (ActionBarSlot.cs:159-168);
    • consumables (ActionBarSlot.cs:119-122, Entity.TryActivateConsumable).
  • Player-only lifecycle actions. These are the orchestration this map moves:
    • interact, which means warp, dock or undock (ActionGameManager.cs:298-320);
    • tow (:889-913);
    • boarding another docked ship (InventoryPanel.cs:117-118).
  • False: combat resolves in Unity.
    • InstantWeapon.OnFire only raises an event (InstantWeapon.cs:183). The projectile is a Unity effect manager (EntityInstance.cs:203-204).
    • Hits are physics colliders (HullCollider.cs:18, GuidedProjectile.cs:170).
    • Turning a hit into armor, item and hull damage is EntityInstance.DamageSchematic (EntityInstance.cs:276-314), with hit-shape and penetration at :330-395.
    • NPC death removes the entity from the zone at :437. Loot drops in the same place (:405-435), rolled with UnityEngine.Random.
    • Pickup is a collision trigger (ShieldManager.cs:29).
    • (probe) Weapon groups fired 6 OnFire events at an enemy 50 units ahead. Enemy hull stayed 500.0 -> 500.0.
  • False: CombatState needs Unity-written data. It indexes Ship.HardpointTransforms (Combat.cs:101). Only EntityInstance.cs:516 writes that dictionary, from barrel transforms.
    • (probe) A Minion with a target throws KeyNotFoundException: 'Energy Hardpoint ' on its first combat step.
    • Every NPC agent that acquires a target would throw the same way headless.
    • The weapons' own direction reader already has a fallback (Behaviors.cs:24-43). CombatState does not use it.

2. Body findings

2.1 Where the run lifecycle lives today

All of this is Unity code:

Concern Anchor Unity dependencies
Store composition ActionGameManager.cs:43-63 (static, Application.dataPath) path only
Handoff across scene load statics CurrentGalaxy, IsTutorial (:96-97), written by MainMenu.cs:104-105,126,138,152,166, read by SectorMap.cs:114-310 SceneManager.LoadScene (MainMenu.cs:106,139,167)
New game: clear MainMenu.cs:112 none
New game: galaxy MainMenu.cs:125,148 (UnityEngine.Random.value background noise), :127-141,153-169 (Task.Run, then Observable.NextFrame) Random, UniRx main-thread scheduler
ItemManager ActionGameManager.cs:264; RNG clock-seeded at ItemManager.cs:20 Debug.Log
Start / Continue StartGame :725-770: spawn player ship :733-744; Continue resolves the docked child :751-762; action bar restore :764-767 renderer binding, SectorMap reveal
Zone entry PopulateLevel :639-675: generate/unpack :643-654; move pilot :659-666 ZoneRenderer.LoadZone, Debug.Log
Wormhole placement (a game rule) ZoneRenderer.cs:222-231 (dir * Pack.Radius * GameSettings.WormholeDistanceRatio) lives in a MonoBehaviour
Warp detect ActionGameManager.cs:312-316; EnterWormhole :615-637, which saves on arrival (:635) ZoneRenderer.WormholeInstances as the wormhole list
Dock / undock / tow :801-823, DoDock :825-843 (cameras, menus), :845-887 (dialogs), :889-913 cameras, dialogs
Board a docked ship InventoryPanel.cs:117-118 UI button
Save SaveRun :241-249, called at quit :236 and warp :635 action-bar slots (:246)
Death BindToEntity subscribes Death -> Die (:1053); Die :1080-1115 clears the run (:1088) Time.time, Observable.EveryUpdate post-fx
Tick Update :1151-1220: input :1189-1216, then Zone.Update(Time.deltaTime) :1218 Time.deltaTime, pause flag
Dead code IntroCutscene :772-799; its only call is commented out (:760) coroutine, Time.time

2.2 Already headless

  • Aetheria.Shared/Aetheria.Shared.csproj compiles Assets/Scripts/ServerShared/** plus UniRx (UniRxLibrary) and Ink with no Unity defines. It is the Unity-independence check.
  • Galaxy generation, zone generation, Zone, entities, behaviors, agents, RunSave, AetheriaStores and ItemManager all run under it. The probe measured:
    • galaxy seed 42: 196 zones;
    • the first zone with ships and stations: 7 entities and 4 agents;
    • a player LonginusX spawned through LoadoutGenerator;
    • thrust by hand-set MovementDirection reached 142 m/s in 5 s;
    • a Minion patrol moved the player ship 642 units in 10 s;
    • TryDock and TryUndock succeeded (Entity.cs:721-755);
    • Ship.EnterWormhole followed by stepping fired OnEnteredWormhole (Ship.cs:82-90,372);
    • Death fired on hull damage (Entity.cs:277-280);
    • RunSave.Capture and RunSave.Commit wrote a 489 KB run.cc.
  • Settings: GameSettings is a ScriptableObject (GameSettings.cs:11) holding ServerShared classes.
    • tools/AetherDb/AuthoredSettings.cs reads them from the asset YAML. The probe placed every field (0 unplaced).
    • Two lifecycle inputs are Unity-only GameSettings fields: StartingHullName (GameSettings.cs:17, authored LonginusX) and WormholeDistanceRatio (:23, authored 0.75).
  • Randomness:
    • ItemManager.Random is clock-seeded (ItemManager.cs:20).
    • Galaxy is clock-seeded only when seed == 0 (Galaxy.cs:108,158).
    • Zone seeds from the zone name (Zone.cs:54).
    • Zone.Update runs belt updates on Task.Run (Zone.cs:144). It is still deterministic (probe: identical traces).

2.3 Agents

  • The agent API is Agent.Update(dt) (Agent.cs:47-56) over a BaseState graph.
  • Minion (Minion.cs:7-21) patrols (PatrolOrbits.cs), auto-targets the first visible enemy (:14), and enters CombatState.
  • Agents are created only for packed non-player ships at zone construction (Zone.cs:88-93,99-106) and are updated before entities (:147-150).
  • Nothing ever removes one from Zone.Agents.
  • The player ship never gets an agent.
  • An agent held in a zone's list would stay behind when its ship warps (PopulateLevel moves the entity, not agents).
  • There is no "fly to a point" state. MoveToState is abstract and its only concrete state is MoveToOrbitState (MoveTo.cs:7-37).

2.4 AetherDb

  • Commands: census, station-fit, hardpoint-fit, loadout [seed], save, factions, dangling, settings, settings-dump (Program.cs:18-35).
  • Stores open through AetherDb.Open, which calls AetheriaStores.Open with GameData/run.cc hard-wired (AetherDb.cs). save (Program.cs:349-410) is the only played-save reader.
  • It already owns AuthoredSettings (YamlDotNet) and references Aetheria.Shared, so it has everything the playground needs.

2.5 Tests at HEAD

(probe) dotnet test tests/Aetheria.Shared.Tests at 1e647953 with CultLib a0813c6: 32 passed, 2 failed. Both failures are MessagePackSerializationException: Failed to deserialize SavedZone value:

  • LoadoutTests.MaterializedLotsSurviveSaveAndReload
  • RunSaveTests.CommitKeepsOnlyReachableLots

This contradicts the "34 tests green" verification that docs/item-provenance-cut.md sets for Cut B.

2.6 The reopen defect, confirmed

(probe) Reopening a run store that holds a SavedZone with any entity throws:

MessagePackSerializationException: Failed to deserialize SavedZone value.
  TypeAccessException: No hash-resistant equality comparer available for type: CultMath.int2
  • The failing field is EntityPack.PersistedBehaviors, a Dictionary<int2, PersistentBehaviorData[]> (EntitySerializer.cs:200), written at :35-40 and read at :126-130.
  • The player ship's dictionary was empty (0 persisted behaviors) and it still threw. MessagePack builds the dictionary's comparer before reading any entry.
  • CultMessagePackSecurity runs UntrustedData and adds a comparer only for CultRecordRef<T> keys.
  • No other run-store type has a struct dictionary key. SavedGame uses int keys, the ledger uses int, and PlayerSettings uses string.
  • Consequence: Continue has been unable to load any run that visited a zone since entities entered the run store under this security. Migration smoke step 6 cannot pass. Unity reads through the same CultLib serializer, which is inferred, not run here.

2.7 Asides found in passing (not this cut's to fix)

  • TargetNearest picks the farthest visible enemy: MaxBy(length(...)) (ActionGameManager.cs:378-379).
  • The interact loop enters every wormhole in range, then calls Dock() anyway (:312-317).
  • SaveRun passes DockedEntity ?? CurrentEntity (:245), but BindToEntity nulls DockedEntity (:946). The order is fragile, not wrong today.

2.8 The probe (discarded)

A scratch console referencing Aetheria.Shared and AuthoredSettings.cs. It:

  1. Loaded authored settings.
  2. Opened the stores (catalog read-only; run and player in temp).
  3. Generated a galaxy with seed 42 and set ItemManager.Random to seed 7.
  4. Generated zones outward from the entrance until one held ships and stations.
  5. Spawned the player ship, drove it by hand, then by a Minion.
  6. Forced a target, fired groups by hand, then docked, undocked and entered a wormhole.
  7. Killed an NPC by hull damage, then saved, disposed and reopened.

Traces from two runs were identical.

3. Authority map

Owner: Run, a new ServerShared class (Assets/Scripts/ServerShared/Run.cs). It owns one live run's lifecycle: start, zone entry, pilot, dock state, warp, save, Continue and death. There is one instance per run. It holds no Unity type.

  • Inputs:

    • the CultCache from AetheriaStores.Open, composed by the caller;
    • GameplaySettings, ZoneGenerationSettings, PlanetSettings;
    • a uint seed for ItemManager;
    • Action<string> log;
    • for a new run, a caller-built Galaxy and isTutorial;
    • dt per step;
    • commands from either lowering;
    • SavedActionBarBinding[] at save, owned by the Unity action bar (the playground passes empty).
  • Outputs:

    • state: Galaxy, ItemManager, Zone, Pilot (the piloted Entity), Station and Bay when docked, TowingStation, Ended;
    • events: ZoneEntered(Zone), PilotChanged(Entity), Docked(Entity, EquippedDockingBay), Undocked, WarpArrived(GalaxyZone), Saved, Died(CauseOfDeath);
    • store writes through RunSave.Commit and RunSave.Clear only.
  • Derived state:

    • ActionGameManager.CurrentEntity, .Zone, .ItemManager, .DockedEntity, .DockingBay and .TowingStation become read-only projections of Run, for display only.
    • ZoneRenderer.WormholeInstances becomes render-only: its keys are Zone.Wormholes, and it is no longer the wormhole list gameplay reads.
    • SectorMap reads Run.Galaxy for display.
  • Forbidden writers after the cut:

    • ActionGameManager may not call RunSave.Clear, RunSave.Capture, RunSave.Commit, ZoneGenerator.GenerateZone or new Zone, and may not add or remove the pilot in Zone.Entities.
    • It may not construct ItemManager, spawn the player ship, subscribe Death to end the run, or set CurrentEntity, DockedEntity, DockingBay or TowingStation.
    • MainMenu may not call RunSave.Clear or construct Galaxy(cache, saved).
    • InventoryPanel may not write CurrentEntity or DockingBay.DockedShip.
    • ZoneRenderer may not create Wormhole objects.
    • The playground may not write Position, Velocity, Hull.Durability or zone membership directly. It has no hit command; damage comes only from fire control (fork D ruling).
  • Fire control (Cut 2). FireControl (ServerShared) owns hit, cell and damage; Zone owns death removal, loot drops and the pickup commit.

    • Entity.TargetItem is a command, written only through TrySelectTargetItem. The aimed item is derived from it plus reveal.
    • Unity weapon effects and HullCollider are presentation of ShotOutcome.
    • Forbidden deciders of hits or damage: Unity weapon effects, HullCollider, any Physics.* query, EntityInstance, ShieldManager (it may only call Zone.TryPickUp), the playground, and Entity.HardpointTransforms (deleted).
    • Firing arc: the mount direction is Behavior.Direction, derived from the ship's Direction and the mounted item's ItemRotation (itself HardpointData.Rotation, Entity.cs:559); the arc width is GameplaySettings.FiringArc (fork W for overrides). Prefab WeaponHardpoint.FiringPoint transforms and ArticulationPoint limits are presentation: they aim barrels and place effects, and decide no shot.
  • Shared paths:

    • Manual input and agent input write the same entity fields (§1), and both go before Run.Step. Unity writes input in Update, then calls run.Step(Time.deltaTime). The playground updates its pilot agent inside Run.Step, before Zone.Update, so the order matches.
    • Direct interaction (Unity's Interact key), programmatic interaction (playground dock/warp) and Continue's docked landing all commit through Run.TryDock, Run.TryEnterWormhole and Run.Land.
    • A new run and Continue share Run.EnterZone.
    • Warp arrival and quit share Run.Save.
  • Pilot agent:

    • Run.PilotAgent (nullable) is owned by Run, not by Zone.Agents, so it follows the pilot across warps.
    • Unity leaves it null. It may never be non-null while Unity input is enabled.
    • Zone.Agents stays the owner of NPC agents.
  • Deletion line:

    • ActionGameManager: bodies of SaveRun, EnterWormhole, PopulateLevel's simulation half, StartGame, Dock, DoDock's state half, Undock's checks, TowShip, Die's clear, and IntroCutscene (entirely);
    • statics CurrentGalaxy and IsTutorial;
    • MainMenu.cs:104-105,112;
    • ZoneRenderer.cs:222-231's wormhole construction;
    • ItemManager.cs:20's clock seed.

    All of these go in the same cut that adds Run. None survives as a fallback.

Unity dependencies, each injected or cut:

Dependency Where Disposition
Time.deltaTime :1218 injected: Run.Step(float dt)
Time.time IntroCutscene, Die post-fx cutscene deleted; post-fx stays in Unity, on Died
UnityEngine.Random MainMenu.cs:125,148 (background noise), EntityInstance.cs:413-428 (loot) noise stays in the menu as a galaxy-generation input; loot roll moves to Zone on ItemManager.Random (Cut 2); effect spread jitter (ProjectileManager.cs:20-26) stays visual
Clock RNG ItemManager.cs:20 cut; the seed is a constructor argument, and Unity passes a clock-derived one
Coroutines IntroCutscene deleted (dead)
UniRx Observable.NextFrame in the menu, EveryUpdate in Die stay in Unity; Run uses C# events, and Entity.Death (UniRx, headless-compiled) is what Run subscribes to
Debug.Log :264, :657, MainMenu injected Action<string>
Scene load MainMenu stays; the handoff static becomes ActionGameManager.NewRun : (Galaxy, bool)?, command-only, consumed once in Start; null means Continue
MonoBehaviour state _currentEntity, DockedEntity, DockingBay, TowingStation, Zone, ItemManager projections of Run
Barrel transforms EntityInstance.cs:516 -> HardpointTransforms HardpointTransforms deleted (Cut 2); the arc is item rotation plus FiringArc (fork A); barrels and ArticulationPoint are presentation only, GetBarrel
Physics.* hit queries Projectile, GuidedProjectile, Laser, Lightning, HullCollider subjects deleted from damage paths; FireControl rolls (Cut 2)

4. Cuts

Build budget for every cut:

  • Packages: Aetheria.Shared (netstandard2.1), tools/AetherDb and tests/Aetheria.Shared.Tests (net10.0), Debug only.
  • CultLib at a0813c6, unmodified, under fork R (a).
  • No new project, target, package or schema store.
  • Build host: this Windows workstation, which is also the target for the headless checks.
  • Assembly-CSharp is proven only by Unity batchmode with the editor closed, per docs/item-provenance-cut.md §6. This is the operator's call.
  • Headless commands use --artifacts-path <scratch> while another agent is reading the tree.

Cut 0: make a run with entities reopenable (fork R)

Ruled (operator, 2026-09-17): fork R is the pairs array. It lands with the provenance Cut B fix batch; see docs/item-provenance-cut.md.

  • EntitySerializer.cs:200: PersistedBehaviors becomes (int2 position, PersistentBehaviorData[] data)[], matching Equipment at :197-199.
    • Key 5 is kept. The wire shape changes from a map to an array; saves are discarded and no GameData/run.cc exists.
    • :35-40 packs pairs.
    • :126-130 builds a local lookup at unpack.
  • RunSaveTests.cs BarePack initializer: Array.Empty<...>().
  • Verification:
    • The two HEAD-red tests go green: 34 passed.
    • Mutation: restore the Dictionary<int2,…> type. Both tests go red with the int2 comparer message.
    • Add ReopenedPackKeepsPersistedBehaviors to RunSaveTests. A pack with two positions, each with one persisted behavior, commits and reopens, and the positions and data are equal.
      • Mutation: pack drops data, so the test goes red.
      • Mutation: unpack ignores the lookup, so the test goes red.
  • Negative: rg -n "Dictionary<int2" Assets/Scripts/ServerShared is empty.
  • Ledger: about ±6 C#, +25 test.

Cut 1: Run becomes the single lifecycle owner; Unity lowers it

This is subtraction first, then one new file.

  • Deletes and moves:
    • ActionGameManager.cs:
      • IntroCutscene :772-799 (dead).
      • Statics CurrentGalaxy and IsTutorial (:96-97).
      • SaveRun body :243-248 becomes Run?.Save(actionBar). Quit (:236) calls it.
      • EnterWormhole :615-637 and the detect loop :312-316 become Run.TryEnterWormhole(). WarpArrived drives SectorMap.QueueZoneReveal.
      • PopulateLevel :639-675: the simulation half moves into Run.EnterZone. What stays is a ZoneEntered handler: ZoneRenderer.LoadZone, PlayMusic, rebind.
      • StartGame :725-770 becomes Run.New or Run.Continue in Start. It keeps the sector reveal and action-bar restore in Unity. Restore reads min(slots, saved.Length), so a playground-written save continues in Unity.
      • Dock :801-823 becomes Run.TryDock(). DoDock :825-843 keeps cameras and menus on Docked; its state writes :828,830,840-841 go to Run.
      • Undock :845-887 becomes Run.Undock(), returning UndockResult. The dialog text maps from the result.
      • TowShip :889-913 becomes Run.Tow().
      • Die :1080-1088 becomes a Died handler: unbind, menus, post-fx, SavePlayerSettings. The RunSave.Clear at :1088 goes. :1053 subscribes Run.Died.
      • :264 constructs no ItemManager.
      • Update :1218 becomes Run.Step(Time.deltaTime).
    • MainMenu.cs:
      • Continue (:102-107) sets ActionGameManager.NewRun = null and loads the scene.
      • New Game drops :112 and sets NewRun = (sector, tutorial) at :138,166.
    • SectorMap.cs:114-310 reads ActionGameManager.Instance.Run.Galaxy.
    • InventoryPanel.cs:117-118 becomes GameManager.Run.Board(ship).
    • ZoneRenderer.cs:222-231 iterates zone.Wormholes.
    • ItemManager.cs:20: the seed becomes a constructor argument. Call sites: 11 in tests, 1 in AetherDb, and Run.
  • Settings (fork S (a)):
    • StartingHullName and WormholeDistanceRatio move from GameSettings.cs:17,23 to GameplaySettings (Settings.cs:155).
    • The asset keys move under GameplaySettings: in Settings.asset by text edit, with Unity closed.
    • Readers ActionGameManager.cs:738 and ZoneRenderer go through ItemManager.GameplaySettings.
  • Zone.cs: public Wormhole[] Wormholes, built in the constructor from GalaxyZone.AdjacentZones with the rule from ZoneRenderer.cs:226-230. Null galaxy zone gives an empty array.
  • New Run.cs: types and rules, no bodies here.
    • static Run New(CultCache, GameplaySettings, ZoneGenerationSettings, PlanetSettings, Galaxy, bool isTutorial, uint seed, Action<string> log)
      1. RunSave.Clear.
      2. ItemManager over RunSave.Lots, seeded.
      3. EnterZone(Entrance).
      4. Spawn the player ship per :734-743.
      5. Set the pilot. Returns a live run.
    • static Run Continue(CultCache, …, uint seed, Action<string> log)
      • Returns null when there is no SavedGame.
      • Otherwise Galaxy(cache, saved, log), then EnterZone(saved zone), then Land(saved entity).
      • Land handles an orbital entity per :753-756: pilot is the IsPlayerShip child, and the run is docked there.
    • void Step(float dt)
      • Throws if Ended.
      • Updates PilotAgent, then Zone.Update(dt).
    • void EnterZone(GalaxyZone): the rule from :643-666. It raises ZoneEntered.
    • bool TryEnterWormhole()
      • Picks the nearest Zone.Wormholes entry within GameplaySettings.WormholeExitRadius. Requires the pilot to be a Ship, not mid-animation, and undocked.
      • On OnEnteredWormhole:
        1. EnterZone(target).
        2. Discover adjacent zones.
        3. ExitWormhole at the back-link wormhole with WormholeExitVelocity * ItemManager.Random.NextFloat2Direction().
        4. Save(lastActionBar).
        5. Raise WarpArrived.
    • EquippedDockingBay TryDock(): the rule from :803-821, within DockingDistance.
    • UndockResult Undock(): Ok, NotDocked, MissingCockpit, MissingThruster, MissingReactor, BayNotEmpty. Check order as :850-885.
    • bool Board(Ship): docked only; the ship must be the station's IsPlayerShip child; updates Bay.DockedShip.
    • void Tow(): the rule from :891-911.
    • void Save(SavedActionBarBinding[] actionBar)
      • RunSave.Capture(cache, Galaxy, Zone, Station ?? Pilot, isTutorial, actionBar), then Commit(…, ItemManager.Lots).
      • No-op when Ended.
      • Remembers actionBar for warp saves.
    • Death: Run subscribes Pilot.Death whenever the pilot changes. On death:
      1. Ended = true.
      2. RunSave.Clear.
      3. Dispose subscriptions.
      4. Raise Died(cause).
    • Agent PilotAgent { get; set; }: setting it while Ended throws.
  • Tests (in RunTests, same project, fixture catalog as LoadoutTests). Each is paired with the mutation that must kill it:
Test Asserts Mutation that must kill it
NewRunSpawnsPilotInEntrance pilot is a Ship with IsPlayerShip, in Zone.Entities, Zone.GalaxyZone == Galaxy.Entrance; the run store is cleared of a prior SavedGame New skips RunSave.Clear; spawn omits Zones.Entities.Add
WarpMovesPilotAndSaves stepping after TryEnterWormhole lands the pilot in the target zone, out of the old zone's entities, with SavedGame.CurrentZone equal to the target index after reopen arrival skips Save; EnterZone leaves the pilot in the old zone
ContinueLandsDocked save while docked, reopen, Continue: Station is the saved orbital, Pilot is its player child Land binds to the orbital itself
DeathEndsAndClears hull death clears every run record; Step throws; Save writes nothing Died handler omits Clear; Ended not set
PilotAgentFollowsWarp a Minion set as PilotAgent still updates the pilot (position changes) after a warp; Zone.Agents of both zones exclude it agent added to Zone.Agents
WormholesAreZoneOwned Zone.Wormholes.Length == AdjacentZones.Count and positions follow the ratio ratio ignored
  • Verification:
    • Headless build and tests green.
    • Unity batchmode has no error CS.
    • Negative greps:
      • rg -n "RunSave\.(Clear|Commit|Capture)" Assets/Scripts --glob '!ServerShared/**' is empty.
      • rg -n "CurrentGalaxy|IsTutorial\s*=" Assets/Scripts is empty.
      • rg -n "new Wormhole" Assets/Scripts matches only Zone.cs.
      • rg -n "new ItemManager\(" Assets/Scripts matches only Run.cs.
      • rg -n "ZoneGenerator\.GenerateZone|new Zone\(" Assets/Scripts --glob '!ServerShared/**' is empty.
      • rg -n "CurrentEntity\s*=[^=]" Assets/Scripts --glob '!ServerShared/**' is empty.
      • rg -n "DateTime\.Now" Assets/Scripts/ServerShared/ItemManager.cs is empty.
  • Ledger:
    • ActionGameManager about -190 +45; MainMenu -4 +2; ZoneRenderer -9 +3; InventoryPanel -2 +1; SectorMap ±12 renames; ItemManager ±1; Zone +10; Run.cs about +190; tests about +200.
    • Statics: -2 +1.
    • Net production code is about +40. That buys the invariant: the lifecycle has one owner, and every rule it holds existed before in a MonoBehaviour.

Cut 2: fire control rolls hits; death and loot resolve in Zone (forks F, A, W, T, V, E, L)

Rulings, 2026-09-17: "Just roll the dice based on weapon stats, targeting system stats (new subsystem) and sensor state." And: "We want the player as well as the AI to be able to aim for specific subsystems once it has gathered enough target data to reveal those subsystems". Anchors are at HEAD 562cdcf2. Hands' uncommitted Behaviors.cs edit moves the BehaviorData union list down by 10 lines.

Body facts this cut stands on ((probe) of GameData/Aetheria.cc read-only, CultLib a0813c6 worktree since removed, plus a GUID grep of effect prefabs):

  • 51 designs, 37 products. LonginusX is the only ship hull (ControlModule, Sensors, Energy 2, Launcher 2, Radiator 2, Reactor, AetherDrive). Turret has Ballistic 2 and Sensors 1. Zenith has no weapon hardpoints.
  • 18 weapon designs, all single-target, lowered by four effects: ProjectileManager 8, LaserManager 3 (pulse, velocity 0), GuidedProjectileManager 5 (3 GuidedWeaponData -> InstantWeapon, 2 LauncherData -> LockWeapon), LightningGunManager 1.
  • No design uses ConstantWeaponData, ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine, airburst (Flak Cannon), MIRV splits or ShieldData.
  • Sensor state exists: Sensor.Execute accumulates Entity.EntityInfoGathered[other] in [0,1] with decay (Sensor.cs:121-154); TargetDetectionInfoThreshold (0.1) gates VisibleEntities (Entity.cs:196-207); LockWeapon.cs:92 scales lock by info.
  • TargetArmorInfoThreshold (0.5) and TargetGearInfoThreshold (0.8) are authored and read by no code (Settings.cs:207-208, Settings.asset:320-321): the reveal tiers the ruling describes, unwired.
  • Sensors-hardpoint designs are not if i see you first, The Bat and Tractor Beam (no behaviors). EquipHardpoints (LoadoutGenerator.cs:208-228) can fit the Tractor Beam into LonginusX's only Sensors slot; that ship gathers no info, so sees and hits nothing.
  • What decides a firing arc today ((probe) of the prefab YAML, world transforms composed from m_LocalPosition/m_LocalRotation/m_Father, plus a catalog read of HullData.Hardpoints):
    • Gameplay uses only the barrel's current world forward. EntityInstance.cs:514-517 copies FiringPoint[0] (WeaponHardpoint.cs:7, matched by name to HardpointData.Transform at :268) into HardpointTransforms. Combat.cs:100-102 fires when dot(barrel, toTarget) > .99 (about 8 degrees). TurretController.cs:80-83 does the same through Behavior.Direction (Behaviors.cs:31-33). The player's fire is not gated at all.
    • Barrels swing through ArticulationPoint (ArticulationPoint.cs). It yaws and pitches toward LookAtPoint (EntityInstance.cs:400-403, :519), clamped to authored YawMin/YawMax/PitchMin/PitchMax relative to its parent, at Speed degrees per second. No other arc data exists: no angle field on HardpointData, WeaponData or any effect.
    • Catalog hulls and their weapon hardpoints (all Rotation None):
      • LonginusX -> Ships/Longinus.prefab. En.L/En.R barrels sit under ArticulationPoints with yaw [-1, 1], pitch [-80, 80], speed 60: fixed forward. La.L/La.R tubes have no articulation, forward (0,0,1). Both tubes are authored at the same local position (-2.23, 0.35, 0.36), a presentation defect.
      • Turret -> Turrets/Turret.prefab. Left Gun/Right Gun barrels sit under a Turret Yaw Pivot with yaw [-720, 720] (unlimited) and a pitch point [-45, 28], speed 15.
      • Zenith -> Stations/Zenith.prefab: no weapon hardpoints.
      • Djinni.prefab carries articulation but no catalog hull references it.
    • ShipInstance.cs:70 and EntityInstance.cs:242,257 match thruster, sound and radiator transforms by name. They are presentation and do not touch arcs.
  • Mount direction convention (matches thrusters). Behavior.Direction's fallback (Behaviors.cs:37) is Entity.Direction.Rotate(item.Rotation) (Extensions.cs:152-166): None = forward, Reversed = aft, Clockwise = right, CounterClockwise = left (Agent.cs:72 takes Clockwise as right). A thruster pushes against that vector (Thruster.cs:79): None thrusters are the reverse group and Reversed the forward group (Ship.cs:103-107), CounterClockwise the right group (:109). A weapon fires along it. Thruster.cs:62's float2(1,0).Rotate(...) is the torque lever, not the mount direction. A hardpoint-mounted item takes the hardpoint's rotation (Entity.cs:559), so the mount direction is authored as HardpointData.Rotation, already catalog data.
  • Aim is planar. Entity.Direction is float2; steering reads LookDirection.xz (Ship.cs:278). Height differences come from terrain and GridOffset; pitch exists only in ArticulationPoint.

Owner: FireControl, a new ServerShared static class (FireControl.cs).

  • It owns the engage gate, hit probability, reveal, the roll, cell choice and damage application.
  • Its only state is the pending shots under fork F (b)/(c), and that state lives in Zone.
  • Inputs:
    • weapon stats Damage, Penetration, DamageSpread, MinRange, Range, Spread and Velocity (Weapon.cs:22-66), with LockWeapon.CanFire (:56) as a gate;
    • the firer's targeting system, or the unaided defaults when it has none;
    • sensor state, firer.EntityInfoGathered[target];
    • Target and TargetItem;
    • both entities' positions and directions;
    • the weapon's mount direction Behavior.Direction and GameplaySettings.FiringArc;
    • ItemManager.Random, seeded by Cut 1.
  • Outputs:
    • ShotOutcome { Source; Weapon; Target; bool Hit; bool Shielded; EquippedItem Aimed; int2 Cell; float FlightTime };
    • shield TakeHit, or Entity.DamageSchematic plus IncomingHit.

Rules (defaults unless a fork is named):

  • Engage gate. The probability is 0 and no draw is consumed when any of these holds:

    • there is no target;
    • the target is not in VisibleEntities;
    • the range is outside [MinRange, Range];
    • a LockWeapon is not locked;
    • the target is outside the firing arc (fork A, ruled): dot(normalize(weapon.Direction.xz), normalize(toTarget.xz)) < cos(radians(FiringArc / 2)).
      • The test is planar; target height does not enter it.
      • FiringArc is full width in degrees, default 120. The boundary is 60 degrees off the mount direction.
      • It applies to player and AI fire alike, and to guided weapons.
  • Hit probability. p = Accuracy * pSensor * pSpread, multiplied by pDeviation under fork F (c).

    • pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info)).
    • pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1.
    • angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range)).
    • Spread is already a cone in degrees (ProjectileManager.cs:18-23).
    • SchematicCellSize is the one new tuning constant. Hands measures it on the LonginusX prefab.
  • Reveal. IsRevealed(observer, item):

    1. Rank the target's non-hull equipment: hardpoint-mounted first, then size descending, then equipment index.
    2. Item i of N is revealed when info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1)).

    Ordering is fork V.

  • Roll. One draw against p. On a hit, a second draw against Precision decides where it lands:

    • on success, on Aimed's footprint cells;
    • otherwise, on one cell drawn uniformly over the hull Shape.

    Aimed is TargetItem only while that item belongs to Target and is revealed. It is derived when read; no loop clears it.

  • Damage. The rule moves verbatim from EntityInstance.cs:334-394:

    1. Expand the hit shape round(DamageSpread) times.
    2. If Penetration > .5, march cells along firer->target, rotated into the target's frame by -target.Direction. This replaces transform.InverseTransformDirection.
    3. Apply DamageSchematic(Damage, shape) (:277-314, moved to Entity).
    • An active shield that CanTakeHit takes the hit instead. Every effect repeats that rule today (Projectile.cs:60-71).
    • Damage stays raw. DamageCurve still feeds only DPS estimates.

Targeting system (new subsystem).

  • TargetingSystemData : BehaviorData takes union 39. A TargetingSystem behavior sits on a GearData item, mounted per fork T.
  • Its stats are PerformanceStat, so lot quality reaches them through EquippedItem.Evaluate, as it does for weapon stats:
    • Accuracy (0..1): the ceiling on hit probability.
    • Resolution (detection threshold..1): the info level at which sensor state stops limiting hits.
    • Precision (0..1): the chance a hit lands on the aimed item.
    • Tracking: how much deviation it forgives. Only under fork F (c).
  • Energy and heat come from the existing EnergyDraw and Heat behaviors on the item.
  • An entity with no targeting system, or with its system offline or destroyed:
    • fires with GameplaySettings.UnaidedAccuracy, Resolution = 1 and Precision = 0;
    • can still select a revealed item, but its hits scatter.
  • A schematic hit on the targeting system degrades fire.

Target item: one selection path.

  • Entity.TargetItem : ReactiveProperty<EquippedItem> sits beside Target (Entity.cs:40).
  • bool Entity.TrySelectTargetItem(EquippedItem) is the only writer. It accepts null, or an item of Target.Value that IsRevealed.
  • Changing Target nulls it.
  • Three callers use it:
    • the player, from a select/cycle command beside ActionGameManager.cs:366-397; the schematic HUD shows revealed items, which is presentation;
    • the AI, from CombatState;
    • the playground, from aim.

Per weapon kind.

  • InstantWeapon family:
    • Execute (InstantWeapon.cs:181-191) calls FireControl.Fire for each burst shot.
    • OnFire becomes event Action<ShotOutcome>.
    • Charge multipliers already fold into Damage and Spread.
  • Guided weapons (GuidedWeaponData, LauncherData) use the same roll.
    • Lock time survives as the existing LockWeapon.CanFire gate.
    • Flight is presentation.
  • Lightning (plight) makes one single-target roll. HitRadius is presentation.
  • ConstantWeapon rolls once per GameplaySettings.BeamResolveInterval for Damage * interval, raised as OnBeamShot(ShotOutcome). That is about 10 lines, and no design uses it yet.
  • Deferred, because no design needs them: airburst splash, MIRV splits, and Mine.
    • The earlier draft's ApplySplash is not added.
  • TractorBeam is not a hit. It stays Unity physics.

AI and turrets.

  • Combat.cs:100-102:
    • shouldFire = FireControl.HitProbability(testWeapon, target) >= Settings.AgentMinHitProbability.
    • The HardpointTransforms read is deleted.
    • AgentMinHitProbability replaces AgentFiringMinDot, which nothing reads.
    • :84-98 (looking at the predicted intercept) survives only under fork F (c).
  • Subsystem policy: through TrySelectTargetItem, select the revealed target Weapon item with the highest RangeDamagePerSecond(range), or null if there is none.
  • TurretController.cs:80-83 uses the same gate and threshold; its .99f dot is deleted.
  • HardpointTransforms is deleted everywhere:
    • Entity.HardpointTransforms (Entity.cs:52-53);
    • its writer, EntityInstance.cs:514-517;
    • its reader branch in Behavior.Direction (Behaviors.cs:31-34).
  • Behavior.Direction keeps only its item-rotation path (Behaviors.cs:37-38). It is the one mount-direction reader, the same in both lowerings, and nothing extracts direction from prefabs.
  • Barrels and ArticulationPoint stay in Unity for GetBarrel, crosshairs and barrel animation. A shot rolled 60 degrees off a fixed Longinus barrel still flies from that barrel; that is presentation.
  • Under the default arc, turrets (hull Direction never rotates; TurretController writes only LookDirection) engage only within 60 degrees of their spawn facing. That is fork W.

Death, loot and pickup (carried from the earlier draft). Loot into the simulation did not land with this cut; cut loot-1 of campaign aetheria-release owns it now (ruling jettison-shared-floating-items).

  • Zone subscribes to the Death of each entity it adds. On death it:
    • removes the entity and its agent;
    • drops each non-hull item with probability LootDropProbability, and all cargo, using ItemManager.Random;
    • adds the drops to Zone.Loot : ReactiveCollection<LootDrop { ItemInstance Item; float3 Position; float2 Velocity }>.
  • LootDropProbability and LootDropVelocity move from GameSettings.cs:19-20 to GameplaySettings (fork S).
  • Run.Died reads no zone membership, so subscription order does not matter.
  • bool Zone.TryPickUp(Entity, LootDrop) stores the item in the first bay that accepts it and removes the drop. Contact detection is fork L.

Unity becomes presentation.

  • InstantWeaponEffectManager.Fire (:7) takes the ShotOutcome.
    • Effects fly to the rolled cell's world point, or to a miss offset, and play the impact on arrival.
    • They apply nothing.
  • Hit loops, SendHit and TakeHit are deleted from the four used effects:
    • Projectile.cs:57-93,99-112;
    • GuidedProjectile.cs:152-189;
    • Laser.cs:40-66;
    • Lightning.cs:34-52; its endpoint capture stays as a visual.
  • HullCollider.cs:13-41,48-65: Hit, Splash, SendHit, SendSplash and their argument classes are deleted. OnCollisionEnter (ship collision) stays, deferred.
  • EntityInstance.cs:
    • :277-396 is deleted;
    • :405-437 keeps only the destroy effect.
  • ZoneRenderer.DropItem lowers Loot.ObserveAdd.
  • ShieldManager.cs:25-41 calls Zone.TryPickUp. Loot.ObserveRemove destroys the pickup.

Catalog and loadouts.

  • Add two targeting-system designs, 1-cell and 2-cell.

    • Each has products from at least the manufacturers that sell the capacitor.
    • The capacitor is the existing required interior item, so this lets IsAvailable find one in every galaxy.
  • LoadoutGenerator.FillInterior (after :250-260) picks one with required: true for every entity that has Weapons:

    • the starting LonginusX, which uses the same generator;
    • NPC ships and turrets.

    Zenith gets none.

  • Authoring follows the Cut C precedent:

    • a Hands scratch console upserts the records;
    • Unity and CultCache Studio are closed;
    • whether Studio renders union 39 is an operator check.
  • This lands after provenance Cut C. Cut C re-upserts every ItemData and deletes ItemData.Manufacturer.

  • AetherDb captures before and after:

    • census: 2 more designs;
    • loadout 1: 1 more interior item per armed entity;
    • dangling: 0;
    • on reopen: 0 schema reports.

Tests (FireControlTests, ZoneDeathTests). Each is paired with the mutation that must kill it:

Test Asserts Mutation that must kill it
RollsAreSeeded two identical zones, same seed, 50 shots: identical ShotOutcome sequences FireControl draws from new Random()
ProbabilityFollowsInputs p is 0 below detection and outside range; rises with info up to Resolution; falls with Spread; is capped at UnaidedAccuracy with no system drop pSensor; drop the range gate
AimedHitLandsOnSelectedItem with Precision 1 and p 1, only the aimed item's durability falls the roll ignores Aimed
SelectionNeedsReveal below the item's tier TrySelectTargetItem is false; above it, true; a target change nulls it; info decaying below the tier makes Aimed null IsRevealed returns true; the Target change skips the null
RevealOrder hardpoint items reveal before interior items, larger before smaller ordering dropped
HardpointHitDamagesItemThenHull damage above armor plus item durability zeroes the item, and the remainder hits the hull armor not subtracted; hull skipped
ShieldTakesHit an active shield fixture absorbs the hit; the schematic is untouched shield branch removed
ArcFollowsMountRotation fixture hull with one weapon on a None hardpoint and one on a Clockwise hardpoint; target 90 degrees to the right: only the Clockwise weapon has p > 0; target dead ahead: only the None weapon the gate uses Entity.Direction instead of weapon.Direction; Rotate's Clockwise/CounterClockwise cases swapped
ArcBoundaryIsHalfWidth FiringArc 120: a target 59 degrees off the mount has p > 0, one at 61 degrees has p == 0 and consumes no draw compare against cos(radians(FiringArc)); drop the arc gate
ArcIsPlanar a target inside the arc horizontally but well above the firer still has p > 0 the gate uses the 3D toTarget
CombatStateKillsHeadless a Minion with a targeting system and a target in range steps headless without throwing and lowers target durability restore Combat.cs:101; the threshold compares against 2
NpcDeathRemovesEntityAndAgent entity and agent are gone after death removal omitted
LootDropsAreSeeded same seed, identical drop lists the drop roll uses a clock RNG
PickUpStoresAndRemoves the item is stored and the drop removed; a full bay keeps the drop drop removed on failure
ShotResolvesOnArrival (F (b)/(c)) no damage before range / Velocity; a target removed before arrival takes none damage applied at fire time

Playground. Cut 3's hit command is withdrawn (fork D).

  • New command aim <item#>|none, and new fact target.revealed.

  • provenance-c.play kills by fire:

    1. target nearest
    2. pilot minion
    3. step <s>
    4. expect zone.entities == <n-1>
    5. pickup all
    6. expect cargo.count > 0

    Hands fixes <s> after the first seeded run.

  • In lifecycle.play, the pilot dies by parking unarmed at an armed NPC and stepping until expect ended == true.

Verification.

  • The headless build and tests are green, and each mutation above goes red.
  • Two playground runs give byte-identical traces.
  • These negative greps are each empty:
    • rg -n "HardpointTransforms" Assets/Scripts
    • rg -n "\.99f" Assets/Scripts/ServerShared/Agents/States/Combat.cs Assets/Scripts/ServerShared/Behaviors/TurretController.cs
    • rg -n "ArticulationPoint|FiringPoint" Assets/Scripts/ServerShared
    • rg -n "SendHit|SendSplash|DamageSchematic|TakeHit\(|Durability\s*[-+]?=" Assets/Scripts --glob '!ServerShared/**'
    • rg -n "Physics\." Assets/Scripts/Gameplay/Weapons Assets/Scripts/Gameplay/HullCollider.cs
    • rg -n "Random\.value|onUnitSphere" Assets/Scripts/Gameplay/EntityInstance.cs
    • rg -n "Entities\.Remove" Assets/Scripts --glob '!ServerShared/**'
  • Unity batchmode with the editor closed (docs/item-provenance-cut.md §6) reports no error CS.
  • Operator checks in Unity:
    • shots show rolled impacts and misses;
    • damage matches the HUD;
    • reveal and selection work;
    • kills drop loot, and pickup stores it;
    • the tractor beam still pulls.

Risks.

  • The Tractor Beam can take LonginusX's only Sensors slot; that NPC never sees or fights. Check loadout over several seeds. The fix is data or EquipHardpoints, not fire control.
  • Balance numbers (UnaidedAccuracy, SchematicCellSize, stat ranges) are first guesses; the playground is the tuning harness.

Ledger. EntityInstance about -155 +8; HullCollider -35; the four used effects -75 +35; ShieldManager -8 +3; Combat -5 +8; TurretController ±4; Behaviors -8; Entity -2 +70; InstantWeapon/ConstantWeapon ±15; FireControl.cs +165 (arc gate); TargetingSystem.cs +45; Zone +50; LoadoutGenerator +12; Settings +6 -1; tests +360; catalog 2 designs plus products. Fork E (b) deletes a further ~560. Net production about +80, or -480 with E (b). It buys the invariant: combat resolves without Unity and every hit rule has one owner.

Cut 2 forks, ranked by what they block:

F. When a shot resolves. Blocks the FireControl API and the tests.

Ruled (c) (operator, 2026-09-17: "I like c"): the shot resolves on arrival, and its hit chance falls with how far the target deviated from the predicted intercept.

  • The scope doc says projectiles "resolve on arrival against how far the target deviated". The ruling names only weapon, targeting and sensor inputs, and does not settle this either way.
  • (a) Roll and apply at fire time. The smallest change; visuals arrive late, and maneuvering does not evade.
  • (b) Roll at fire time, and apply after range / Velocity from a pending queue in Zone. Visuals and damage agree, but there is still no evasion.
  • (c) (b), plus a deviation factor at arrival: pDeviation = saturate(1 - distance(target, predicted intercept) / (hullRadius + Tracking)). It keeps the scope doc's evasion and the look-at-intercept code, for about 25 more lines.
  • Recommend (c). It is the scope doc's standing design, and it fits the ruling as one more factor. If "just roll the dice" meant dropping evasion, take (b).

A. Firing arc. Blocks CombatState and how the player's ship feels.

Ruled (operator, 2026-09-17), in two steps. First: "Firing arcs are determined per hardpoint, that data is unfortunately baked into the ship prefabs at the moment". Then: "I'd say default to 120 degrees, use existing item rotation direction to determine the mount direction, like how the reaction thrusters work". Then: "simulation is fundamentally 2D, all the 3D-ness is set dressing". So the arc check is planar by rule, not by approximation. Entity.Position is a float3, so ArcIsPlanar stays: it pins that height never decides a shot.

  • The mount direction is the item's ItemRotation applied to the ship's Direction, the thruster convention (Body facts above). It is per hardpoint because a mounted item takes HardpointData.Rotation.
  • The arc is GameplaySettings.FiringArc, 120 degrees full width, one authored value.
  • Nothing is extracted from prefabs. FiringPoint and ArticulationPoint become presentation. The prefabs carry no arc width a catalog field would preserve, except the turret's unlimited yaw (fork W).
  • No HardpointData field is added unless fork W takes (a).

Options as mapped before the ruling:

  • (a) No arc; facing is presentation.
  • (b) Gate on dot(weapon.Direction, toTarget) >= FireArcDot, one authored setting. The ship must roughly face its target, and player and AI share the gate.
  • (c) The arc as a probability factor.
  • Recommend (b), with a wide arc. It keeps piloting meaningful without precision aiming. (a) is right if articulated turrets are meant to cover every hardpoint.

W. Turret arcs under the 120 degree default. Blocks TurretController and the turret catalog record.

  • The turret prefab yaws without limit (Turret Yaw Pivot, yaw [-720, 720]). Headless, the turret hull's Direction stays at spawn facing, so a 120 degree arc on its None guns turns today's all-round turret into a forward-only one.
  • (a) Per-hardpoint override: HardpointData.FiringArc : float? (Key(6), absent means GameplaySettings.FiringArc). Author 360 on the turret's Left Gun and Right Gun in Cut 2's catalog upsert, after provenance Cut C. One field, with prefab evidence for its one consumer. AetherDb reopen still reports 0 schemas; test ArcOverrideReplacesDefault (a 360 hardpoint fires at a target behind; mutation: the override is ignored).
  • (b) TurretController rotates the turret entity's Direction toward LookDirection at a slew rate, so the default arc tracks. That adds headless articulation state, and turrets that can be flanked by speed.
  • (c) Accept forward-only turrets.
  • Recommend (a). It is the smallest shape that keeps the authored turret behaviour, and it leaves ships on the default.

Ruled (a) (operator, 2026-09-17): "a, turrets get 360 degrees; we want turrets on some ships too, after all". HardpointData.FiringArc is an optional per-hardpoint override, where empty means the GameplaySettings default. It is set to 360 on turret hardpoints. Because ship hulls will also carry turret hardpoints, the override belongs to the hardpoint, not to the turret entity type.

T. Where the targeting system mounts. Blocks the catalog and loadout work.

Ruled (a) (operator, 2026-09-17): "absolutely as interior gear, Tool is fine". It is interior GearData with HardpointType.Tool, placed by FillInterior, and can be revealed, targeted and refitted like any other item.

  • (a) Interior GearData (HardpointType.Tool), placed by FillInterior like the capacitor. No hull or prefab edits.
  • (b) A behavior on the control-module designs (Cockpit 2x2, Turret Control Module). No new item, but then it is not a separate subsystem to hit or refit.
  • (c) The Sensors hardpoint. It competes for the one sensor slot.
  • Recommend (a).

V. Reveal ordering. Blocks RevealOrder only; a default exists.

  • (a) Hardpoint-mounted, then size, then index. Static, so reveal does not flicker.
  • (b) Rank by each item's current share of VisibilitySources, so emitters reveal first. Reveal flickers as emissions change.
  • (c) Reveal every item at TargetGearInfoThreshold, with no ranking.
  • Recommend (a). Emissions already drive how fast info accumulates.

E. The six effect families no design uses. Blocks only the Unity-side ledger.

Ruled: keep every weapon and behavior type (operator, 2026-09-17). None of (a)-(c). Operator's words: "careful with the deletion beam, we had TONS of content in the game before the big breach, using every single behavior type. I rebuilt just the bare minimum content to make the game playable, that doesn't mean we want to delete all the weapon types we created".

  • Today's catalog is the minimum rebuilt after the breach, not the consumer set. The consumers are the pre-breach content. The consumer audit for weapon and behavior types reads that history, not the live catalog.

  • Fire control maps every weapon and behavior type: constant beams, hitscan, lightning, guided, airburst, missile splits, mines and shields. Cut 2 has nothing to delete or defer on the grounds that no current design uses a type.

  • (a) Strip their hit loops and keep them.

  • (b) Delete their scripts, managers and prefabs once a GUID grep shows no catalog or scene reference. That covers ConstantLaser*, ConstantLightning*, ConstantParticleWeapon*, Hitscan*, Mine*, and ShieldManager's mine branch (:42-45).

  • Recommend (b). About 560 lines that serve nothing.

L. Loot contact. Low-blocking.

Ruled (operator, 2026-09-17): pickup is a capability behavior, a timed grab.

  • The owner is simulation. A pickup is a timed grab of a selected item in range: extend, envelop, pull. When it completes, the zone commits the item. Range, duration and cancellation are rules in ServerShared, shared by the player, the AI and the playground.
  • No Unity collision decides a pickup. The collision path in ShieldManager is retired.
  • Field shields are not universal. Operator: "Each of the things that field shield can do should be individual capabilities, which can be tied to a distinct visual presentation." Impact absorption, pickup, melee blade and propulsion effect are separate capabilities.
  • The tractor beam item provides only pickup. Operator: "it might have some volumetric glowy vfx but it would behave the same as the field shield's pickup ability".
  • FieldDriver is one presentation of these capabilities, not an owner of any.
  • The tractor beam is a generic Tool item (operator, 2026-09-17: "Just put tractor behavior on a generic tool item"). It is interior HardpointType.Tool gear, like the targeting system, so it no longer competes with the sensor for LonginusX's single Sensors slot. The catalog change lands with the pickup capability.
  • Presentation is chosen per item, independently of capability (operator, 2026-09-17). One presentation, such as the field shield, can visualize any subset of an item's capabilities: only propulsion, only shielding, or several at once. Other presentations (an IFS shatter plane, flow particles) can visualize the same capability events. Simulation emits capability events; presentations subscribe and decide nothing.
  • Absorption has a damage type mask (operator, 2026-09-17). The absorb capability's data declares which DamageTypes it absorbs, and a hit whose type is outside the mask is not absorbed. DamageType (Enums.cs:110) is not a flags enum; the capability cut decides the mask's representation. Today Shield.CanTakeHit ignores the type entirely. The presentation seam's AbsorbEvent already carries the hit's DamageType.
  • Simulation owns the grabbed object and its pose (operator, 2026-09-17: "why would the grab object not exist in the engine-free sim? It was floating free before, and the grab transition moves it into the ship's local space").
    • A loot object is simulation state in zone space.
    • The grab phases move its pose from zone space into ship-local space. On completion the item enters cargo; on cancellation it floats free again with its current velocity.
    • GrabEvent names the loot object by a typed simulation identity, and presenters read the simulated pose.
    • Deletion line, in the pickup capability cut: the presentation seam's int target handle, IGrabPresenter's Transform and velocity parameters, the binder's grab resolver delegates, and FieldDriver moving the grabbed transform itself.
  • Bodies, and one detection interface (operator, 2026-09-17).
    • Kinematic bodies are moved by forces. ServerShared gets a Body (position, velocity, response to force, detection signature) that Zone steps under GetForce.
      • Entity has a Body.
      • Loot is a body plus an item instance.
      • A mine is a body plus a payload, and carries its launcher's transponder credential.
      • Hostility, stance, equipment and heat stay on Entity. A loot object or mine is not an Entity, because Entity keeps pairwise state with every other entity in the zone.
    • Rail bodies (planets, gas giants, suns, asteroids) keep analytic positions as a function of time. They generate the gravity field and are not integrated.
    • Rail and kinematic bodies share one detection interface: identity, position and signature. Sensors detect through it, so the detection-gated stance query and later electronic warfare cover every kind of body alike. Operator: "rail bodies and kinematic bodies should share a detection interface. With good enough signature masking I should be able to float along in a debris field pretending to be junk."
  • (a) Zone.TryPickUp is the one commit.
    • Contact detection stays Unity collision, per the scope doc's deferral.
    • The playground's pickup all matches drops by spawn position within DockingDistance.
  • (b) Zone owns drop motion and proximity pickup every step, and Unity renders it. That pulls TractorBeam headless too.
  • Recommend (a). Say plainly that contact has two detectors until the tractor beam moves.

Cut 3: AetherDb play

  • AetherDb.cs: Open takes dataDir. Run and player paths come from it. It defaults to GameData for the existing commands and is required for play.
  • Program.cs: case "play": return Play(args).
    • Usage: play <scenario-file|-> --data <dir> [--seed <u>].
    • play refuses a --data that resolves to <root>/GameData.
  • New tools/AetherDb/Playground.cs. It is a line interpreter over Run with no simulation rules of its own. One command per line; # starts a comment.
Command Calls
new [tutorial] [galaxy-seed=<u>] Galaxy(...) from authored settings, then Run.New
continue Run.Continue; fails the scenario when null
reopen dispose cache, AetheriaStores.Open again (quit/relaunch; the layer where §2.6 shows)
step <seconds> Run.Step(1/60) repeated
move <x> <y> / look <x> <z> / tractor <0..1> pilot entity fields, as ActionGameManager.cs:1189-1216
target nearest|none|<entity#> Pilot.Target.Value
fire <group> on|off WeaponGroups[g].weapons Activate/Deactivate, as ActionBarSlot.cs:190-204
gear <equipment#> on|off ActivatedBehavior Activate/Deactivate
pilot manual|minion|patrol|goto station <i>|goto wormhole <i> Run.PilotAgent
dock / undock / board <entity#> / warp / tow Run.TryDock / Undock / Board / TryEnterWormhole / Tow
hit <entity#> <damage> [hardpoint <k>] Entity.ApplyHit (Cut 2)
pickup all Zone.TryPickUp for drops within DockingDistance
save Run.Save(empty)
expect <fact> <op> <value> facts: ended, zone, docked, pilot.hull, zone.entities, run.savedgame, lots.absent, lots.mismatched, cargo.count
state trace one state line
  • goto needs MoveToPositionState : MoveToState in MoveTo.cs (about 6 lines), wrapped in a one-state Agent. It is the only new agent code.
  • The trace goes to stdout, one line per step command and per Run event: t=<zone time> zone=<name> <event> k=v…. state adds pilot position, velocity, hull, target, docked, entity and agent counts, lot count and run.cc bytes.
  • An exception prints its type, message and first frame, then ends the scenario.
  • Exit code is failed expectations plus escaped exceptions.
  • lots.absent and lots.mismatched reuse the checks in Program.cs:399-408. They are extracted to one method that save also calls, not copied.
  • First smoke, tools/AetherDb/scenarios/lifecycle.play, checked in:
new galaxy-seed=42
state
move 0 1
step 5
pilot goto station 0
step 60
dock
expect docked == true
undock
pilot goto wormhole 0
step 60
warp
step 5
expect run.savedgame == true
reopen
continue
expect zone == <the warp target, printed by the trace>
expect lots.absent == 0
expect lots.mismatched == 0
target nearest
pilot minion
step 30
hit pilot 100000
expect ended == true
expect run.savedgame == false
  • Hands fixes the literal zone name after the first run. The scenario checks that it reproduces, not what it is.
  • Verification:
    • dotnet run --project tools/AetherDb -- play tools/AetherDb/scenarios/lifecycle.play --data <scratch> exits 0.
    • Running it twice gives byte-identical traces.
    • Mutation: revert Cut 0, and the scenario exits non-zero at continue with the int2 message.
    • Mutation: Run.Died omits Clear, and expect run.savedgame == false fails.
    • Negative: rg -n "RunSave\.|ZoneGenerator|\.Position\s*=|Durability\s*=" tools/AetherDb/Playground.cs is empty.
  • Ledger: Playground.cs about +220; AetherDb.cs ±4; Program.cs +3 and ±10 (extraction); MoveTo.cs +6; scenario +26. No new target.

Cut 4: consumer scenarios

These are scenario files only; no code.

  • cultcache-smoke.play covers migration smoke steps 3-8 minus the rendering and UI parts:
    • new, warp, warp again with the SavedZone count constant (expect run.savedzones == <n>, a new fact);
    • reopen and continue on the same zone and entity;
    • reopen before any step and check every visited zone still holds contents;
    • die, then the run is cleared.
    • Steps 1-2 (player.cc from settings UI), 3's input screen and 9 (loadout UI) stay operator-only.
  • provenance-c.play covers the Cut C checks:
    • new; kill an NPC with hit; pickup all;
    • expect cargo.count > 0;
    • warp; reopen; continue;
    • expect lots.absent == 0, expect lots.mismatched == 0;
    • die; expect run.savedgame == false.
    • Tier colour, Manufacturer row, trade purchase and Studio rendering stay operator-only.

5. Forks

R. Reopen defect. Blocking: every Continue and two HEAD tests.

  • (a) PersistedBehaviors as a pairs array. This is local, matches the sibling pack fields, and saves are discarded anyway.
  • (b) CultLib: a hash-resistant comparer for CultMath value types in CultMessagePackSecurity. That is a foundation change with a new pin and a Unity package release, and it keeps a map for 0-12 entries.
  • (c) Key persisted behaviors by equipment index. It saves bytes but couples to equipment order, which Pack does not promise.
  • Recommend (a).

P. Where the playground lives.

  • (a) AetherDb play. It already has settings, stores and the save checks.
  • (b) Scenarios only as xunit tests. They cannot use authored settings without dragging YamlDotNet into tests, and agents cannot puppeteer interactively.
  • (c) A new tools/AetherPlay executable, which duplicates store and settings loading for no second consumer.
  • Recommend (a), with lifecycle rules tested in xunit (Cut 1 and 2 tests) and sessions in play.

H. How CombatState decides to fire.

  • (a) Read Weapon.Direction, the same reader the shot uses.
  • (b) A ServerShared articulation model owning barrel aim, with Unity rendering it. That is correct under gimbals but a new subsystem with no playground need yet.
  • (c) The playground writes HardpointTransforms. Rejected: a second writer compensating for a missing owner.
  • Recommend (a).
  • Superseded by the fire-control ruling (Cut 2). Nothing reads barrel aim for gameplay. CombatState fires when FireControl.HitProbability clears AgentMinHitProbability, and HardpointTransforms is deleted. Fork A (firing arc) replaces this question.

D. How far combat goes headless. Ruled (operator, 2026-09-17): none of (a)-(c). Hit detection goes away entirely, as docs/three-gates-scope.md "Fire control" already planned: "We wanted to get rid of hit detection anyway, remember? Just roll the dice based on weapon stats, targeting system stats (new subsystem) and sensor state." Cut 2 is re-mapped as fire control in ServerShared: a hit is a roll over weapon stats, the stats of a new targeting-system subsystem, and sensor state. The playground's hit stand-in is not needed. A second ruling the same day adds subsystem aim: "We want the player as well as the AI to be able to aim for specific subsystems once it has gathered enough target data to reveal those subsystems".

Consequences:

  • Cut 2 is fire control plus the parts of (a) that survive: DamageSchematic moves to Entity, and death removal, loot roll and the pickup commit move to Zone.
  • Effects and HullCollider stop deciding damage.
  • (b)'s "two truths about hits" cannot arise, because Unity detects nothing.
  • Cut 3's hit <entity#> command and its §3 forbidden-writer exception are withdrawn. Cut 3's command table, lifecycle.play (hit pilot 100000) and Cut 4's provenance-c.play ("kill an NPC with hit") still name it. They are to be re-mapped to fire (target, aim, fire/pilot minion, step) per Cut 2's Playground note.
  • §7's "hit is not ballistics" risk becomes "rolled balance is not tuned".
  • New forks F, A, T, V, E and L live in Cut 2. Fork H is superseded.

Options as mapped before the ruling:

  • (a) Move damage application, death removal, loot roll and pickup into ServerShared; Unity keeps hit detection; the playground's hit supplies hits.
  • (b) (a) plus headless ballistics. That is a projectile simulation Unity would have to adopt, or else two truths about hits. It needs its own map.
  • (c) Nothing moves; the playground's fights stop at OnFire, and the Cut C kill-and-loot check stays operator-only.
  • Recommend (a). The operator asked for fight and loot. Without (a) or (b) the playground cannot kill anything by the rules the game uses.

S. The two Unity-only lifecycle settings.

  • Superseded 2026-09-17 by docs/settings-globals-cut.md (operator: "All settings should be in globals"). Settings move to a catalog global read through CultCache; AuthoredSettings and the YAML reader are deleted, so the options below, the Run.New settings arguments and the AuthoredSettings uses above are history. Re-map this cut against that one before Hands.
  • (a) Move StartingHullName, WormholeDistanceRatio and (Cut 2) LootDrop* into GameplaySettings, with a text edit of Settings.asset while Unity is closed.
  • (b) Pass them as Run constructor arguments read from GameSettings; the playground reads them by name from YAML. That leaves two readers of one authored value.
  • (c) A new RunRules bundle, which is a type with no owner the existing settings class lacks.
  • Recommend (a).

N. Is the scene handoff static acceptable? Low-blocking.

  • (a) ActionGameManager.NewRun : (Galaxy, bool)?, consumed once by Start.
  • (b) MainMenu builds the whole Run on its generation thread. That moves zone generation's run-store upserts off the main thread, and the CultCache threading contract is not checked here.
  • Recommend (a).

6. Subtraction ledger

Cut Removed Added Targets, schemas, stores
0 Dictionary<int2,…> field and map-building pack code, ~6 pairs pack/unpack ~6; test ~25 EntityPack key 5 wire shape map -> array; no new schema
1 ActionGameManager ~190 (incl. dead IntroCutscene 28), 2 statics, MainMenu 4, ZoneRenderer 9, InventoryPanel 2, clock seed Run.cs ~190; Zone +10; AGM handlers ~45; 1 static; tests ~200 0 targets; Settings.asset two keys move
2 EntityInstance ~155, HullCollider ~35, used effects' hit loops ~75, HardpointTransforms and its readers ~15, AgentFiringMinDot; fork E (b) a further ~560 FireControl.cs ~160; TargetingSystem.cs ~45; Entity ~70; Zone ~50; effects ~35; LoadoutGenerator ~12; tests ~320 0 targets; BehaviorData union 39; 2 catalog designs plus products (after provenance Cut C); LootDrop* keys move; SchematicCellSize, UnaidedAccuracy, BeamResolveInterval, AgentMinHitProbability, FiringArc added; fork W (a) adds HardpointData key 6 and turret overrides
3 extracted duplicate of the save lot check Playground.cs ~220; MoveTo +6; scenario 26 0 targets; AetherDb gains one command
4 — 2 scenario files —

Net production code across Cuts 0-3 is about +340, or about -220 if fork E (b) deletes the unused effects. Tests are about +540. No executable, package, daemon or store is added. Rules move from MonoBehaviours to ServerShared. The genuinely new behavior is:

  • MoveToPositionState and the scenario interpreter;
  • fire control's roll, which replaces physics hit detection per the operator's ruling;
  • subsystem reveal and selection;
  • the targeting-system item. In exchange, Continue works again (Cut 0), and agents can falsify lifecycle, save and provenance claims without the editor.

7. Risks

  • Unity batchmode is the only proof for Cuts 1-2's Assembly-CSharp edits. It needs the editor closed. The operator's in-editor play remains the proof for the lowering (cameras, menus, action bar, pickup visuals).
  • Zone subscribing Death changes NPC death timing in Unity. Removal now happens inside the damage call rather than in EntityInstance's subscriber. The operator's fight check covers it.
  • Rolled combat is untuned. A passing scenario proves that the roll, damage, death, loot and save follow the rules. It does not prove the rules are fun. The balance numbers in Cut 2 are first guesses.
  • Cut B's recorded green is contradicted by §2.5. Soul should rerun the suite before trusting any provenance verification that reopens a store.