Aetheria/docs/aetheria-release-map.md
Meta Vens e0b123a98d Release map: the visibility rework (signature channels, paint modes, reveal record, emissions heard)
Body facts, model page, authority map, rules, rationale and cut order for
ruling visibility-per-capability-reveal folded with sensor-stat-set; seven
cuts pinned at 93b7ff8d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:59:17 +02:00

8253 lines
576 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Aetheria Release: Map
Date: 2026-10-03
This map holds body facts, the model page and rationale for campaign
`aetheria-release`. It has no progress section, no per-cut sections, no rulings
and no ledger: those are typed documents in the Eureka mind. Ends live in
`docs/aetheria-release-target.md`. The Eyes inventory this pass started from is
`F:\Projects\aetheria-release-inventory.md`, which is outside the repo.
Every anchor below is against `origin/master` `f1dee184` and was read in a sparse
scratch worktree on 2026-10-03, unless the line or its section says otherwise. The section "Demo scope" holds
the 2026-10-06 re-scope: the release this campaign plans is the demo.
## Body facts
**B1. No impact event carries a world point.**
- `Entity.IncomingHit` (`ServerShared/Entity.cs:138`) carries only the source.
- `ArmorDamage` (`:139`) carries a schematic cell. `ItemDamage` (`:140`) carries the
item. `HullDamage` (`:143`) carries the amount.
- `Zone.ShotCommitted` and `ShotResolved` (`Zone.cs:41-42`) carry a `ShotOutcome`
(`FireControl.cs:1902-1930`), which holds a schematic cell, bearing and lateral
offset but no world point.
- The world point of a direct hit is never computed. `ApplyDirectHit`
(`FireControl.cs:944-998`) walks lanes in the schematic frame. A
contact or delayed blast computes it at `:1030` and `:1043` and passes it to
`Detonate`. `Detonate` (`:1341-1414`) has the world point and the share each entity
took, and publishes neither.
- So nothing can show a hit at the place it landed without recomputing
`FireControl`'s geometry.
**B2. Weapon hits on a shield show nothing.** `ShieldManager.ShowHit`
(`Gameplay/ShieldManager.cs:88`) has one caller, ship-on-ship collision (`:80`).
`FireControl` calls `Shield.TakeHit` and `Break` (`FireControl.cs:952-956`,
`:1378-1385`) with no presentation hook. The inventory's "shield impact: present"
refers to the shield-panel rig, which `FieldTester` drives in `FieldShieldTest.unity`.
It does not run in the game.
**B3. A capability event contract already exists for this.** `CapabilityEvents`
(`ServerShared/CapabilityEvents.cs`) defines `AbsorbEvent` (position, direction,
magnitude, damage type) and one `Subject` per event kind.
`CapabilityPresentationBinder` (`Gameplay/CapabilityPresentationBinder.cs`) wires
`IAbsorbPresenter` components on an object and its children. The contract's own
comment says the simulation will own and publish these events, and presentations
will subscribe and decide nothing (ruling fork L, 2026-09-17). Today its only
publisher is `FieldTester`. `AbsorbEvent` has no way to tell a shield absorb from a
hull absorb.
**B4. The weapons already announce what they do.**
- `InstantWeapon.OnFire(shotId)` (`Behaviors/InstantWeapon.cs:272`) fires only for a
round that was not refused.
- `ChargedWeapon` has `OnStartCharging`, `OnStopCharging`, `OnCharged` and
`OnFailed` (`ChargedWeapon.cs:87-90`).
- `ConstantWeapon` has `OnStartFiring` and `OnStopFiring`.
- `EntityInstance.SetEntity` subscribes the effect managers to all of these
(`Gameplay/EntityInstance.cs:209-240`).
**B5. The Wwise-shaped audio path is dead at its source.**
- `EquippedItem.SoundBank` (`Entity.cs:1353`) is never assigned outside two tests
(`FireControlPerWeaponTests.cs:424`, `:672`).
- So every `FireAudioEvent` and `SetAudioParameter` call returns at its null check.
The calls sit in `InstantWeapon.cs:275`, `ChargedWeapon.cs:137,154,169,185`,
`Reactor.cs:97`, `Thruster.cs:96` and `AetherDrive.cs:170-172`.
- No production code subscribes to `EquippedItem.AudioEvents` (`:1375`).
- `WwiseSoundBinding` and `WwiseLoopingSoundBinding` (`WwiseSoundBinding.cs`) have
zero use sites.
- `GameData/SoundbanksInfo.json` (2,648 lines) is read by no code. Only
`docs/build-delivery-cut.md:231` mentions copying it.
- `EntityInstance.cs:243-251` reads `item.Data.SoundBank` and does nothing with it.
- Catalog fields that hold Wwise ids:
- `EquippableItemData.SoundBank` (`ItemData.cs:403`);
- `AetherDriveData.RpmAudioParameter` and `TorqueRatioAudioParameter`
(`AetherDrive.cs:44-48`, keys 11 and 12);
- `Faction.OverworldMusic`, `CombatMusic` and `BossMusic` (`Corporations.cs:52-59`,
keys 13 to 15).
**B6. Unity audio is switched off and has no listener.**
`ProjectSettings/AudioManager.asset` has `m_DisableAudio: 1`. A YAML grep found
`AudioListener:` count 0 in `ARPG.unity` and in `Main Menu.unity`; the two test
scenes have one each. The editor is Unity 6000.3.24f1
(`ProjectSettings/ProjectVersion.txt`).
**B7. The 2021 sound library is on disk and usable.**
- The Wwise project is `Aetheria-Economy_WwiseProject.wproj`, `WwiseVersion="v2021.1.1"`
and `SchemaVersion="103"`.
- Its 225 `.wav` are in LFS and present in the operator's checkout:
`Originals/SFX` holds 731 MB.
- The SFX include fire, hit and miss for cannon, ioncannon, lightingGun, machinegun
and railgun. They also include charge and charge-fail, ship destroyed (two), and
shield up, down and active. Overheat alarm, docking, undocking, wormhole, reactor
states and sensor ping are there too.
- Interface sounds are `blip`, `equip`, `mouseover`, `pickup`, `putdown`, and trade
buy and sell.
- Music: menu, Gen overworld and combat, Metal overworld, combat and boss, Daya
overworld, Kawaii Future Bass. There are also 30 ambience layers.
- The 20 `Originals/Plugins/SoundSeed Grain` sources feed a Wwise plugin, and
their procedural behaviour does not carry over outside Wwise.
- `*.wav` is an LFS pattern in `.gitattributes`.
**B8. The camera has the tooling for shake; nothing uses it.**
`com.unity.cinemachine` 2.10.7 is in `Packages/manifest.json`. It ships
`CinemachineImpulseSource` and `CinemachineImpulseListener`. A grep found no
`Impulse` and no `shake` in `Assets/Scripts`. The follow camera is
`ActionGameManager.FollowCamera` (`ActionGameManager.cs:122`).
**B9. Graphics settings are not saved.**
- `MainMenu.ShowGraphicsSettings`'s Back button (`UI/MainMenu.cs:258-263`) never calls
`ActionGameManager.SavePlayerSettings()`.
- Gameplay's Back button does call it (`:236-241`).
- `PlayerGraphicsSettings` (`ServerShared/PlayerSettings.cs:63-67`) holds keys 0 and 1.
- `PropertiesPanel.AddField(name, float read, write, min, max)` exists
(`UI/Properties Panel/PropertiesPanel.cs:290`).
**B10. The lanes overlap the feedback files in these hunks** (`git diff
origin/master...origin/<lane>` hunk headers):
- **mining** (`83d8371e`):
- `FireControl.cs` at `:72` and `:522`;
- `Entity.cs` at `:43`, `:198-240`, `:295-395` and `:1075`;
- `EntityInstance.cs` at `:236` and `:410`;
- `ActionGameManager.cs` across `:362-1432`, including `:1076-1100`;
- `ZoneRenderer.cs` and `Zone.cs`.
- **scenarios** (`aa3baf12`):
- `Entity.cs` at `:204`, `:601` and `:786-1003`;
- `ActionGameManager.cs` at `:583`, `:732` and `:801`.
- **moddable-ships** (`a93625c2`):
- `ActionGameManager.cs` at `:42-70`;
- `ZoneRenderer.cs`;
- `Packages/manifest.json`, which adds glTFast.
`feedback-1`'s edits are disjoint from these: `FireControl.cs:944-1045` and
`:1375-1412`, `Entity.cs:138-150`, and `EntityInstance.cs:159-170`.
**B11. The headless tests compile only `ServerShared`.** That covers
`Aetheria.Shared/Aetheria.Shared.csproj:20` and `tests/Aetheria.Shared.Tests`. A rule
that must be pinned headless must live in `ServerShared`.
## Model page
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Impact (`AbsorbEvent` with a layer) | Transient. The receiving entity's `Capabilities` stream, plus the shot. It is not stored. | Published once per landed hit, before the hull damage is applied. It is never replayed or persisted. | `FireControl` alone, in `ApplyDirectHit` and `Detonate`. Presenters only read it. |
| Impact cue | Derived from one impact and the receiver. | Computed at presentation time and discarded. | `Feedback` (engine-free, `ServerShared`). Visual and audio presenters render it and do not re-derive it. |
| Player feedback preferences (shake, volumes) | `PlayerSettings` global, player store, keys under `GraphicsSettings` (shake) and a new `AudioSettings` (volumes). | Edited in the settings menu and saved on Back. A new key defaults on load. | The player, through the menu. No runtime writer. |
| Presentation tuning (effect prefab, intensity scale, impulse shape, sound bank) | A Unity component or asset in `Assets/Content`. | Authored in the editor and versioned with the scene. | The operator. It stays Unity-serialized by fork M (a). |
| Sound assets | The file under `Assets/Audio`, copied from the Wwise `Originals`. Provenance is recorded per file. | Added per cut. The Wwise project stays as the source archive until `audio-route` and `audio-source-rights` are ruled. | The operator, on rights (`audio-source-rights`). |
| Ship content (`ShipAuthoring` with GLB) | `aetheria.ship_authoring` id. GLB nodes carry an `aetheria.id` extra. | Adopted by this campaign (ruling `adopt-moddable-ships`). Rows for the package, its anchors and the derived catalog are in "Content: the moddable-ships lane" below. | The moddable-ships rulings (MQ1-MQ5) and this campaign's content strand. |
No cell is empty for the two mapped cuts. The content rows are mapped in their own
section below.
## Rationale
**Why the impact is published by `FireControl` and not derived in Unity.** B1 shows
that the world point exists only inside `FireControl`'s lane walk and blast
resolution. If a presenter derived it, it would have to recompute lanes from the
`ShotOutcome`. That rebuilds the second hit authority that fire control Cuts 3 to
12.4(b) deleted. So the owner of where a round landed publishes where it landed.
**Why reuse `CapabilityEvents` rather than a new subject.** The contract was ruled
for exactly this (B3): the simulation publishes absorbs and presenters subscribe.
Its binder already exists. Reusing it adds one field (the layer) instead of a
second event family. It also brings the shield-panel presenters within reach of real
hits later, with no new wiring.
**Why the cue derivation lives in `ServerShared`.** Intensity and shake are rules
("in proportion to the damage", "zero at zero preference"). B11 says only
`ServerShared` is under headless test and Stryker.NET. Keeping the function
engine-free puts the rules where mutation testing reaches them. Audio then renders
the same cue (target: one cue, two channels).
**Why native Unity audio is recommended** (question `audio-route`):
- The simulation already announces fire, charge, impact and death (B4 and
`feedback-1`).
- The Wwise path is dead at its source (B5).
- Unity audio is only switched off (B6).
- Of the Wwise project, what carries over is the wav files (B7). The
2021.1 project, its events, banks and SoundSeed plugin sources would need
migrating to a Wwise release that supports Unity 6.
- Wwise adds three costs:
- a native plugin per platform;
- a second authoring application;
- a sound-bank build step. Its authoring console runs on Windows and macOS, not
on Yggdrasil's Linux.
Agents cannot easily author Wwise work units, and the operator's AquaSynth renders
would have to pass through it.
- Licensing is not the obstacle. Wwise is free under a $250K development budget,
with no asset limit (Audiokinetic pricing page and indie-license blog). FMOD is
free under $200K yearly revenue and a $500K budget.
- What the middleware would buy is interactive music and parameter-driven
mixing, authored by a sound designer. The release bar asks for neither yet.
- Unity's limits are known: 32 real voices by default (`AudioManager.asset`
`m_RealVoiceCount`), no authored containers or RTPC graph, and mixer assets that
cannot be created from script.
- Unity 6's `AudioRandomContainer` has no public scripting API for its clip list.
The first cut therefore uses a small `ScriptableObject` sound bank with random
pick and jitter.
Confidence: medium-high. It falls to medium if the operator expects a contracted
sound designer working in Wwise, or adaptive music with transitions and stingers
before release.
**Why content runs in parallel.** The release quota is met at the operator's pace,
one hull a day, and that clock starts only when the first Tripo mesh flies. The steps
in front of it are the operator's owed S2 play checks, the moddable-ships merge, and
the add-on's package action. Each is cheap and none depends on feedback or audio.
Serializing them behind audio would idle the slowest resource in the project.
**Sources consulted for the audio fork:**
- https://www.audiokinetic.com/en/wwise/pricing/
- https://www.audiokinetic.com/en/community/blog/free-wwise-for-indie-developers/
- https://www.gamedeveloper.com/audio/small-developers-and-creators-can-now-use-fmod-studio-for-free
- https://gamefromscratch.com/fmod-studio-now-free-for-indie-game-developers/
Two claims were not verified from primary sources on 2026-10-03: which Wwise
release first supports Unity 6000.x, and the scripting surface of
`AudioRandomContainer`.
## Demo scope
Re-scope of 2026-10-06 (rulings `first-release-is-a-demo`, `demo-cut-fixed-region`,
`demo-cast`, `miss-terris-belongs-to-emily-r3`, `ship-authors-need-no-unity`). The
release this campaign plans is the demo: one gate, one region, one boss, a fixed cast.
The design review that prompted it is `F:\Projects\aetheria-game-design-review-2026-10-05.md`,
outside the repo. Anchors in this section are against `origin/master` `df7c44f2` (the
mining merge), read with `git show` and `git grep` from the Aetheria object store on
2026-10-06.
### Body facts
- **D1. A fixed-cast generator already exists: the prelude.** `Galaxy.cs:150-262` builds a
galaxy from named factions: protagonist, antagonist, buffer, quest and neutrals,
resolved by name prefix (`ResolveFaction`, `:141-144`). It places homes so the
antagonist is far from the protagonist and the buffer between them, and sets the
entrance near the protagonist's home. Its settings are
`Assets/Resources/Settings.asset` `TutorialGenerationSettings` (an LFS object, read with
`git lfs smudge`): protagonist `Miss`, antagonist `Zhe`, buffer `Luc`, neutrals `Aero`
and `Finch`, quest `Adras`, 64 zones. `TutorialGalaxy` stages it. The main galaxy
(`Galaxy.cs:96-131`) draws `MegaCount` 12 factions at random from the catalog, places
`BossCount` 3 boss zones, and has 128 zones. A prelude galaxy guarantees a station at
its entrance (`ZoneGenerator.cs:223-224`) and lets generation use any product
(`LoadoutGenerator.cs:184`).
- **D2. Boss zones and the exit exist only in the main galaxy.** `PlaceFactionsMain`
(`Galaxy.cs:265-309`) fills `BossZones` on chokepoints; the main constructor sets
`Exit` to the most isolated zone (`:123`). The prelude sets neither. Both persist
(`SavedGame` keys 3 and 5).
- **D3. Nothing spawns a boss.** `Faction.BossHull` is key 10
(`Corporations.cs:43-44`) and is unset on all 12 catalog factions. Outside the field
and `Galaxy.BossZones`, `BossHull` is read only by `tools/AetherDb`. `ZoneGenerator`
generates stations, turrets, faction ships and wanderers (`:280-365`) and no boss.
- **D4. Wormholes are built in Unity, and the exit does nothing.** `ZoneRenderer.cs:225-236`
adds one `Wormhole { Position, Target }` per adjacent zone. Interact
(`ActionGameManager.cs:321-327`) enters any wormhole in range; `EnterWormhole`
(`:660`) populates the target zone. Nothing checks `Galaxy.Exit`.
- **D5. Station services are buy-only and credits are not saved.** `TradeMenu` has
`Buy(CraftedItemInstance)` (`:366`) and `Buy(SimpleCommodity, int)` (`:405`) and no
sell. "Repair" appears in no gameplay or UI script (only AetherDb comments).
Credits are `public int Credits = 15000000` on `ActionGameManager` (`:114`);
`SavedGame` (keys 0-12) does not carry them, so Continue resets them. The hull branch
of `Buy` checks `GetPrice` (`:369-370`) and charges `data.Price` (`:378`). Durability
is per instance (`ItemInstance.cs:53`) against the design's (`ItemData.cs:371`), and
falls under fire (`Entity.cs:645-664`).
- **D6. The catalog has no Pirates and the demo cast sells no ship hull.** Decoded from
`git show df7c44f2:GameData/Aetheria.cc | git lfs smudge` with Python `msgpack`, using
the store's own schema table for slot names. Factions (12): Zhestokost, Finch
Cybernetics, Lightsail Express, NiteLife Energy, Death Monkey Explosives, Aeronautics
Unlimited, Lucent Media, Alakrita, Ewan Hart Inc, Rossum & Douglas, Adrasteia, Miss
Terri's. Hulls (5): Longinus and LonginusX (Alakrita, ship), Djinni (Rossum &
Douglas, ship), Zenith (AU, station), Turret (Zhestokost, turret). 64 products; by
maker AU 11, Lightsail 9, NiteLife 9, Lucent 7, Death Monkey 6, Zhestokost 6,
Rossum & Douglas 6, Alakrita 5, Finch 2, Miss Terri's 1, Adrasteia 1, Ewan Hart 1.
### Why the demo is a prelude galaxy
D1 is already the demo's shape: named factions in fixed roles, a small galaxy, an
entrance station. The demo needs a different cast from the tutorial (the Pirates as the
ally, not Miss Terri's), a boss zone and an exit. So the demo is a compiled scenario
whose script carries its cast and calls the prelude constructor, then places the gate at
the antagonist's home (`cut-demo-galaxy`). The tutorial's settings and Miss Terri's
prelude role are untouched. A demo mode flag on the galaxy was rejected: the scenario
script is the owner the scenario rulings already name.
The gate is in the boss zone itself: an exit wormhole there opens when the zone's boss
is dead (`cut-boss-gate`). Locking every wormhole out of a boss zone, as Three Gates
item 2 describes, is the full game's three-section rule; the demo needs one gate and
lets the player retreat.
### Scope sort
Every cut spec and follow-up in force under root `aetheria-release` on 2026-10-06, after
this pass's admissions. "Landed" means the spec has a report; it stays in force as a
record.
| Kind | Id (local) | Scope | Reason |
|---|---|---|---|
| spec | `demo-galaxy` | demo | The demo scenario, its cast and the gate's placement. |
| spec | `boss-gate` | demo | The one boss and the sealed gate; the win. |
| spec | `station-services` | demo | Sell, repair and saved credits: the run pays. |
| spec | `feedback-1` | demo | Every hit seen where it lands (target line 5). |
| spec | `audio-1` | demo | Every action makes a sound (line 4). |
| spec | `faction-play-1` | demo | Doctrine and the flight for the cast; needs r2 (follow-up `faction-play-reanchor-demo`). |
| spec | `faction-play-2` | demo | Lucent's duel is the featured neutral's spectacle; needs r2. |
| spec | `faction-play-3` | demo | Support and anchors; the played proof moves here for the cast; needs r2. |
| spec | `faction-play-4` | full game | The tender loop on real rounds is depth past the demo's proof. |
| spec | `loot-1` | demo | Floating items in the simulation; loot is what the player sells. |
| spec | `loot-2` | demo | Pickup as a capability. |
| spec | `loot-3` | demo | The grab presented in Unity; merges the loot branch. |
| spec | `ballistic-ammo` | demo | Ruled for everyone and unblocked; ammunition is a cargo good in the Pirates' game. Needs a balance pass. |
| spec | `ships-player` | demo | A built player loads package hulls: every demo hull needs it. |
| spec | `ships-addon-frame` | demo | The Blender step from a Tripo mesh to a hull, no Unity. Stale against `ship-data-all-in-cc` if `demo-ship-cc-timing` rules before-hulls. |
| spec | `ships-addon-mounts` | demo | Mount helpers for demo hulls. Same caveat. |
| spec | `ships-hull-material` | demo | One hull shader painted from a mask: the demo's material under `demo-material-scope`'s recommended option. |
| spec | `ships-addon-paint` | demo | The mask authored in Blender for that shader. |
| spec | `ships-hull-livery` | demo | Faction liveries for the cast; its Faction key collides (follow-up `livery-key-collision`). |
| spec | `scenarios-smoke` | demo | Test designs and smoke scenarios: the verification path. |
| spec | `mining-merge-unity` | demo | The Unity half of the merge that landed. |
| spec | `material-graph` | full game | Graph record and judge; not demo-bearing under `demo-material-scope` (recommended). |
| spec | `material-library-export` | full game | Same. |
| spec | `material-lowering` | full game | Same. |
| spec | `hull-bakes` | full game | Same. |
| spec | `ships-studio-schematic` | full game | Authoring comfort in Studio; the demo's few hulls do not need it. |
| spec | `ships-addon-gizmos` | full game | Same. |
| spec | `mining-index-tree` | full game | Sublinear index: wanted, not critical (ruling). |
| spec | `mining-belt-cells` | full game | Same. |
| spec | `ships-merge`, `ships-mounts`, `ships-addon-package`, `ships-bind-path`, `cut-ship-render-fixes` | demo, landed | The package path the demo hulls use. |
| spec | `scenarios-adopt`, `scenarios-menu` | demo, landed | Scenarios: the demo is one. |
| spec | `mining-index`, `mining-target-queries`, `mining-index-pins`, `mining-merge` | demo, landed | Targeting through the index; the belt freeze is gone. |
| follow-up | `pirates-catalog-record` | demo | The ally needs a Faction record and products. |
| follow-up | `zhestokost-boss-hull` | demo | The boss hull and `BossHull`. |
| follow-up | `demo-cast-hulls` | demo | A hull per cast faction. |
| follow-up | `faction-play-reanchor-demo` | demo | Re-anchor faction play, add the Pirates, play the cast. |
| follow-up | `livery-key-collision` | demo | Two specs claim Faction key 16. |
| follow-up | `demo-ux-ledger` | demo | The bar's UX polish pass has no owner. |
| follow-up | `demo-difficulty-onramp` | demo | "Game is hard"; the region needs a ramp. |
| follow-up | `vault-docs-sweep` | demo | Stale vault notes mislead the faction-play revision. |
| follow-up | `controls-facing-and-aim` | demo | Under `demo-controls-rebuild`'s recommended option. |
| follow-up | `release-target-is-a-demo` | demo | This pass; Self closes it with target r3. |
| follow-up | `release-map-not-on-master` | demo | Land this map and the target doc on master. |
| follow-up | `wwise-catalog-fields` | demo | The audio sequel removes the Wwise ids; the boss music field is among them. |
| follow-up | `scenarios-batch4-minors` | demo | Inventory drag and station-reactor quality: UX ledger items. |
| follow-up | `hull-damage-wear` | demo | Damage visible on the hull (line 6). |
| follow-up | `mod-ship-single-existence-gate` | demo | A skipped package throws on spawn; demo hulls are packages. |
| follow-up | `ambiguous-hardpoint-origin` | demo | Save correctness for package hulls. |
| follow-up | `remove-sbsar-files` | demo | Clean break from Substance before a public build. |
| follow-up | `delaunay-exact-predicates` | demo | Galaxy links; a silently unlinked zone could strand a demo run. |
| follow-up | `failed-start-scene-halts` | demo | The New Game failure path the demo's missing-cast refusal uses. |
| follow-up | `stale-docs-after-scenarios-menu` | demo | Docs that describe the old New Game path. |
| follow-up | `scratch-carry-drafts` | demo | The ship `.cc` draft bears on `demo-ship-cc-timing`. |
| follow-up | `faction-lore-notes-r3` | full game | Vault setting notes; no demo cut reads them. |
| follow-up | `mining-impacts-through-contract` | full game | Mining Cut 4 is parked. |
| follow-up | `mining-cuts-4-5-7` | full game | Yield and content; no buyer for ore in the demo. |
| follow-up | `mining-target-pin-gaps` | full game | Low test pin gaps in landed code. |
| follow-up | `belt-render-reads-index` | full game | Belt rendering through the index. |
| follow-up | `c4-ship-element-ids` | full game | Variants campaign. |
| follow-up | `variants-python-raw-push` | full game | Variants campaign. |
| follow-up | `mod-hull-changed-under-run` | full game | Mod edits under a run; demo hulls are first-party. |
| follow-up | `ship-lods` | full game | Only if a profile shows cost. |
| follow-up | `hull-decals` | full game | Insignia and damage decals. |
| follow-up | `hull-player-paint` | full game | Player liveries. |
| follow-up | `blender-pin-operator` | full game | Pins Blender for the material judge. |
| follow-up | `material-mod-graph-kernels` | full game | Mod material kernels. |
| follow-up | `material-map-rescope` | full game | Re-scope of the material map, after `demo-material-scope`. |
| follow-up | `bodies-entity-and-mines` | full game | Bodies for entities and mines. |
| follow-up | `cultmath-mixed-seed` | full game | CultLib hygiene. |
| follow-up | `launcher-ammunition` | full game | Missiles and drones campaign. |
| follow-up | `drones-munitions-substrate` | full game | Same. |
| follow-up | `articulated-mounts` | full game | Links with arc and traverse. |
| follow-up | `colosseum-npc-lab` | full game | Its own campaign. |
| follow-up | `heat-flow-schematic-particles` | full game | Presentation depth past line 6. |
| follow-up | `scenarios-verification-ledger` | full game | Deferred by ruling. |
| follow-up | `authored-missions` | full game | Authored scenes and missions. |
| follow-up | `volumetrics-backport-map` | full game | Visual quality with no demo line. |
| follow-up | `ghostlight-readme-drift` | full game | Ghostlight, narrative. |
| follow-up | `faction-play-reanchor`, `faction-lore-notes` | obsolete | Superseded on 2026-10-06 by `faction-play-reanchor-demo` and `faction-lore-notes-r3`. |
Counts: demo 52 (31 specs, 11 of them landed, and 21 follow-ups); full game 34 (9 specs,
25 follow-ups); obsolete 2 follow-ups. No in-force spec lost its purpose in both scopes,
so none was withdrawn.
### Demo hulls, the Pirates and the boss
Anchors are against `origin/master` `4d664b53`, unless marked `@2a5fc00b`, the tip of the
unmerged `eureka/aetheria-release-boss-gate` branch (which contains demo-galaxy). Read by
`cut-pirates-record`, `cut-package-product`, `cut-boss-hull-derived` and `cut-demo-cast-hulls`;
question `pirates-record-content` ruled the record's content (see "The Pirates record" below).
- **H1. The catalog at `4d664b53` still has no Pirates, and no faction has a boss hull.** Probe:
`git show 4d664b53:GameData/Aetheria.cc | git lfs smudge`, decoded with Python `msgpack`
using the store's own schema table. Result: 12 `aetheria.faction` records, the same twelve
as D6, with no Pirates. `BossHull` (slot 10) is the empty key `''` on all twelve. Each
faction's `Allegiance` holds 11 entries, one for every other faction. Influence runs from
2 to 6. There are 12 `NameFile` records: Australia, Congo, Germany, India, Iran, Japan,
Mexico, Russia, Swahili, Thailand, USA and pleiades. There are 5 hulls: LonginusX, Longinus
and Djinni (`HullType` 0, ship), Zenith (1, station) and Turret (2, turret). Their prices
are 7.5M, 7.5M, 10M, 10M and 0.5M. There are 64 products. The catalog changed once after
`df7c44f2`: `3d77f2e0` (evasion-catalog tracking).
- **H2. "Pirates" appears only on the unmerged demo branches.** `git grep -i pirate` over
`*.cs` at `4d664b53` returns nothing. At `@2a5fc00b`, `DemoTerminus.cs:10` sets
`ProtagonistFaction = "Pirates"`, which `Galaxy.ResolveFaction` resolves by name prefix.
The test partial `RunStartTests` copies `GameData/Aetheria.cc` into a scratch directory
(`RunStartTests.cs:36-46 @2a5fc00b`). `AddPirates()` (`DemoTerminusTests.cs:20-28
@2a5fc00b`) upserts a stand-in Pirates record whose allegiance covers every faction. It is
called 21 times: DemoTerminusTests 9, RunGoalTests 10, ScenarioTests 2. Test
`DemoTerminus_missing_cast_refused` (`DemoTerminusTests.cs:86-95 @2a5fc00b`) asserts that
the shipped catalog has no Pirates. Once the record lands, both the stand-in and that
assertion are wrong: the stand-in duplicates the record and the assertion fails.
- **H3. demo-galaxy and boss-gate are held but unmerged.** `boss-gate` contains
`demo-galaxy`; its tip is `2a5fc00b`, 17 commits ahead of master and 94 behind. Verdicts
`cut-demo-galaxy.s4` and `cut-boss-gate.s2` hold. Finding
`cut-boss-gate.s1.boss-hull-is-a-strangers` records that the demo cannot be generated on
the bare catalog, because the Pirates record is missing.
- **H4. A package holds exactly two records, and no product.** `ShipAuthoringStore.LoadRecords`
(`ShipAuthoring.cs:155-167`) refuses any count other than one hull and one visual.
`ship_cc.py:85-86` refuses any file that does not hold exactly two envelopes.
`ShipModCatalog.Compose` (`ShipModCatalog.cs:48-133`) writes only those two records
(`:113-115`). Generation, station stock and the boss all choose from
`FactionProductData` (M5). A package hull is therefore reachable only through the
console's `give` command. Ruling `package-carries-product` is not implemented on master.
- **H5. Mod keys.** `IsModKey` (`ShipModCatalog.cs:25-26`) recognises `mod-hull:` and
`mod-ship:`. Its comment states the rule that the shipped catalog never references a mod
key, because a mod may be uninstalled. A shipped `Faction.BossHull` therefore cannot name
a package hull.
- **H6. Package hulls and top speed.** `ship-authoring create` without `--like`
(`ShipAuthoringCommands.cs:45-66`) writes a hull with no behaviours and `Price` 0.
`HullLike` copies the reference's members, `VelocityLimit` and `Price` included
(`ShipAuthoring.cs:91-106`). `Validate` (`ShipAuthoring.cs:186-293`) has no rule about top
speed or price. This is R6, still true. Generation silently skips price-0 products (M5).
- **H7. Where boss and hull type are read.** At `@2a5fc00b`:
- `LoadoutGenerator.GenerateBossLoadout` (`:178-196`) uses `Faction.BossHull` when it is
set and sold. Otherwise it falls back to the priciest ship hull on offer.
- `RandomHull` (`:131-136`) is the one hull chooser. `GenerateShipLoadout` reaches it at
`:37`.
- The station stock filter (`:112`) admits ship hulls only.
- `Galaxy.PlaceFactionsMain` picks its boss factions by `BossHull.IsSet()` (`:296-299`).
- AetherDb's `factions` command prints `BossHull` (`Program.cs:662-672`), and `dangling`
mentions it (`:544`).
No other reader exists. `HullType` is `Ship | Station | Turret` (`Enums.cs:140-145`).
Thrusters carry `EntityTypeRestriction(HullType.Ship)`, so a boss must be a `Ship`, not a
new `HullType`.
- **H8. HullData's free key.** Keys in use: ItemData 1, 2, 4-8; CraftedItemData 9;
EquippableItemData 10-16, 18-22, 30, 31; HullData 23-29, 32. Key 33 is free on
`HullData`. evasion-term's key 33 is on `WeaponData`, a sibling type.
- **H9. Emoji in names.** The TextMesh Pro default sprite asset
(`Assets/TextMesh Pro/Sprites/EmojiOne.json`) holds 16 face glyphs (1f600-1f923, 263a,
2639) and no others. The fire emoji in the Pirate Coalition's equipment copy would not
render without a new emoji asset (see "Full emoji").
- **H10. Pirates lore that the record reads from.** Sources:
- The vault note `Worldbuilding/Pre-Elysium/Factions/Powers/Major/Pirate Coalition.md`:
a loose network, no central command.
- `Brainstorming/Faction Flavor and Visual Identity.md:526-557`. Its points: stolen and
adapted equipment gives the faction its range rather than an exclusive technology;
products are named in emoji; hulls show several surviving finishes interrupted by primer,
with one crew accent colour.
- `Game Design/Faction Play.md:395` and `:416`: pirates buy everyone's gear.
- Corporate Roster: the pirate name is rolled per run. The demo's record is fixed.
- **H11. Packages in git and in builds.** At `4d664b53`, `.gitattributes` has no `*.glb`
rule, and `GameData/*.cc` does not match `GameData/Mods/<id>/ship.cc` (M11 still true).
There is no player build entry point; `ships-player` owns staging. `ActionGameManager`
composes from `GameData/Mods` (`:49`, `:63`), so editor play reads packages from the repo.
#### The Pirates record
Draft for question `pirates-record-content`.
| Field | Draft | Why |
|---|---|---|
| Name, ShortName | `Pirates`, `Pirates` | DemoTerminus's cast field and ruling `demo-cast` both say "Pirates" |
| Description | "A loose coalition of crews and hidden docks moving people and goods outside official control. No command, no common law: possession has a maintenance schedule." | Vault note; Faction Play's operating logic |
| PrimaryColor | (0.42, 0.40, 0.37), primer grey | "finishes interrupted by primer" |
| SecondaryColor | (1.00, 0.45, 0.05), the crew's accent colour | "a crew's chosen accent colour" |
| GeonameFile | `pleiades` | Star names suit hidden docks; it is not one of the cast's files |
| InfluenceDistance | 3 | Scattered bases, no continuous territory |
| Allegiance | 1.0 to each of the 12 other factions; no other faction's allegiance changes | Pirates buy everyone's gear. Only the Pirates field Pirates-made products, including their hull |
| Personality, music, BossHull | Empty or unset | Doctrine arrives with faction-play r2; the Pirates are not a boss |
Products: stolen-and-adapted rebrands over other makers' designs. The Pirates own no design;
their range is what they took (Faction Flavor `:532`). Each product's roles: one ProductRole per
role the design declares, mean .45, sd .22 (middling and inconsistent, against makers' .15).
Names are emoji; the text gloss is for the operator's review and is not stored.
| Product name | Gloss | Design (current maker) | Description |
|---|---|---|---|
| 🫳🔫 | finders keepers | Autocannon (Zhestokost) | "Serial number filed off. Still shoots." |
| 🪦🤠 | second owner | Earp (Aeronautics Unlimited) | "The previous owner had no further use for it." |
| 🧾🚫 | no receipt | FastBlast+- (NiteLife Energy) | "Warranty void where prohibited, which is everywhere." |
| 🎨🙈 | overpainted | 6k Shooter (Zhestokost) | "The logo underneath is somebody else's problem." |
| 🔥 | the fire | scorched void policy (Death Monkey Explosives) | "Turns out we did start the fire after all." (the roster's line, `Corporate Roster and Item Wishlist.md:85`) |
| 👀🎯 | borrowed eyes | Targeting Computer (generic) | "Somebody else's eyes. They still work." |
| 🔑🏃💨 | hotwired | Small Drive (Lightsail Express) | "Hotwired. Do not ask about the ignition." |
| 📦🤫 | not as declared | Store-All Plus (Lightsail Express) | "Contents not as declared." |
None is on a Miss Terri's design. Each (Pirates, design) pair is new, so `Brand` stays
unambiguous (CG4). The fire product moves to the flamethrower design if follow-up
`pirates-flamethrower` lands one.
The record's data does not depend on the emoji font: the simulation owns facts and the font is a
presenter (invariant `sim-owns-facts`). So the record waits on boss-gate (H2) and not on the emoji
cuts, and the demo's Pirates (finding `boss-hull-is-a-strangers`) are not held behind a Unity
presentation cut. Until the emoji font lands, editor play shows the names as missing-glyph boxes.
#### The boss hull is derived, not named
Follow-up `zhestokost-boss-hull` left this map to choose between two options. One was an
AetherDb command that names the package hull from `Faction.BossHull`. The other was a package
that declares which faction it is the boss of. The first is ruled out by H5: the shipped
catalog would name a mod key, and uninstalling the package would leave the reference
dangling.
The second option, built from parts that already exist:
- `HullData.Boss` (key 33, bool) marks a hull that only bosses fly.
- The package's own products name the faction as manufacturer (`package-carries-product`).
- A faction's boss hull is then the `Boss` hull it manufactures.
- `Faction.BossHull` has no writer in the catalog (H1), so it is deleted. Key 10 is retired,
as `ShipAuthoring` key 1 was.
Starsector is the prior art: a mod puts its ships into an existing faction by shipping a
faction file that the engine merges. The package therefore carries its own availability (map
"Why first-party hulls through the package path").
The consequence for the product: a `Boss` hull is never station stock, never ordinary traffic,
and never offered by `RandomHull` unless the caller asks for a boss. That keeps the boss
unique. If a faction makes no `Boss` hull, boss-gate's logged fallback stands.
**Why top speed is refused at `Validate`.** `Validate` is the one owner of top speed (follow-up
`package-hull-top-speed`): compose then excludes a speedless package with its name, and the
dock intro never sees one. Handling the absence at the intro was rejected because it would let
a hull fly with no cap.
Ruling `demo-content-bar` also needs NPC role hulls (haulers, tenders, miners) and a dockable
station; follow-up `demo-role-hulls` owns them.
### Full emoji
Anchors are against `origin/master` `4d664b53`. Read by `cut-emoji-font` and `cut-emoji-sequences`;
question `emoji-art-set` asks which pinned font input and fetch step the font build uses.
Rulings this rests on: `pirates-record-content` (emoji-names) and the operator's "I totally want
full emoji support".
- **E1. TextMesh Pro is the one inside `com.unity.ugui` 2.0.0, on Unity 6000.3.24f1.**
`ProjectSettings/ProjectVersion.txt`; `Packages/manifest.json:19` (`"com.unity.ugui": "2.0.0"`);
there is no separate `com.unity.textmeshpro` entry. Source read from
`F:\Projects\Aetheria\Library\PackageCache\com.unity.ugui@b996e7548785\Runtime\TMP` (read only).
- **E2. TMP Settings today** (`Assets/TextMesh Pro/Resources/TMP Settings.asset`, an LFS file):
`m_ActiveFontFeatures: 00000000` (:18), `m_fallbackFontAssets: []` (:36),
`m_defaultSpriteAsset` = EmojiOne (`c41005c1…`, :39-40), `m_enableEmojiSupport: 1` (:42),
`m_EmojiFallbackTextAssets: []` (:44). Default font asset is LiberationSans SDF (:27).
- **E3. EmojiOne is a 16-face sample and nothing uses it.** `Assets/TextMesh Pro/Sprites/EmojiOne.json`
holds 16 faces (H9). `git grep "<sprite"` outside the TMP folder: nothing. EmojiOne's guid is
referenced only by its own meta and TMP Settings; no prefab or scene sets `m_spriteAsset`. Its
attribution file says only "review their licensing terms" (EmojiOne/JoyPixels art is not openly
licensed).
- **E4. TMP's emoji path, by source read.**
- Lookup: `TextMeshProUGUI.cs:1932-1944` (and `TextMeshPro.cs:1619`): when a code point is an
emoji-presentation form not followed by U+FE0E, or an emoji followed by U+FE0F, TMP searches
`TMP_Settings.emojiFallbackTextAssets` first, through
`TMP_FontAssetUtilities.GetTextElementFromTextAssets`. That list takes font assets.
- Sprite assets resolve one code point each (`TMP_SpriteAsset.GetSpriteIndexFromUnicode`,
`:216`, a `Dictionary<uint, TMP_SpriteCharacter>`), so a sprite asset cannot match a ZWJ,
skin-tone, flag or keycap sequence. Sequences resolve only through a font asset's GSUB
ligature records.
- Ligatures: `TextMeshProUGUI.cs:2050-2118`. Gated by `bool ligature =
m_ActiveFontFeatures.Contains(OTL_FeatureTag.liga)` (`:1824`), per component. Matching skips
ZWJ and VS1-VS16 when they are not the expected component
(`TMP_TextParsingUtilities.IsIgnorableForLigature`, `:172-178`), consumes a trailing variation
selector, and collapses the span into one glyph. The ligature table read is the current font
asset's, which is the emoji font when the fallback found the first code point (`:2021-2023`).
- Records come from `FontEngine.GetLigatureSubstitutionRecords` as glyphs are added to a dynamic
font asset (`TMP_FontAsset.cs:3097-3131`).
- Colour glyphs: a font asset in `GlyphRenderMode.COLOR` gets an RGBA32 atlas and the
`TextMeshPro/Sprite` shader (`TMP_FontAsset.cs:606-625`); its vertices are forced white, so a
`<color>` tag around an emoji does not tint it (`TMP_Text.cs:5376-5385`).
- `TMP_Text.OnMissingCharacter` (`TMP_Text.cs:1389`) is a static event raised for every
missing code point: the smoke's probe.
- **E5. No text component has ligatures on.** 154 TMP components in 50 files (48 prefabs, 2
scenes), none legacy `UnityEngine.UI.Text`. Serialized feature lists: 24 x `00000000` (legacy:
converted on load to `[kern]` when `m_enableKerning`, `TMP_Text.cs:6082-6088`) and 38 x
`6e72656b` (`kern`), all in the two scenes (`ARPG.unity` 64 lines with the field,
`FieldShieldTest.unity` 1; `grep -rc "m_ActiveFontFeatures: 6e72656b$"` over prefabs and
scenes = 38). The 48 prefabs' components have no field and take the same legacy path. One component is
created at runtime (`ActionGameManager.cs:301`, the FACE AIM mark).
- **E6. Turning `liga` on changes no Latin text.** All ten project font assets (`Assets/Fonts/*/*.asset`,
LiberationSans SDF) are static (`m_AtlasPopulationMode: 0`) with zero ligature records. Only
the emoji font would supply ligatures.
- **E7. LFS.** `.gitattributes:33` `*.ttf` and `:50` `*.asset` are LFS; TMP Settings.asset,
EmojiOne.asset and EmojiOne.png are LFS objects today.
- **E8. Editor batch pattern.** `Assets/Scripts/Editor/EngineAssetCheck.cs:35-50`: a MenuItem plus
`-executeMethod EngineAssetCheck.Run`, exit 1 on failure in batchmode. The emoji builder and
smoke follow it.
- **E9. Prior art (web, 2026-10-08).**
- Unity's documented path for colour emoji is a colour font asset in TMP Settings' Emoji
Fallback Text Assets (TMP 3.2/4.0 manual "Color emojis"; Unity 6 manual
`UIE-color-emojis`). Unsupported: SVG colour glyphs, AAT fonts, COLRv1 (the manual names
"Noto Color Emoji" as the COLRv1 case), and chain-context and single-substitution features.
Ligature substitution (GSUB type 4), which carries ZWJ, skin-tone and flag sequences, is
supported. COLRv0 is supported (the manual's Android note).
- Unity forum thread 932305: the CBDT `NotoColorEmoji.ttf` from the googlefonts repo renders
through a Color font asset; the Google Fonts download (COLRv1) does not.
- Sets: Twemoji (`jdecked/twemoji`, community fork, Emoji 17.0, graphics CC-BY 4.0; distro
packages build COLRv0/COLRv1/CBDT TTFs from its SVGs with `nanoemoji`, version 17.0.2).
Noto Emoji (`googlefonts/noto-emoji`: fonts OFL 1.1, images Apache 2.0; the prebuilt
`NotoColorEmoji.ttf` is CBDT, bitmaps from `png/128`). OpenMoji (CC-BY-SA 4.0; COLRv0 font
shipped; outlined line-art).
- Not verified here: the exact jdecked release assets (its release page lists two source
archives and no TTF), so a Twemoji TTF is built, not downloaded.
- **E10. Visual identity.** AetheriaLore `Brainstorming/Faction Flavor and Visual Identity.md:532,
541, 548`: Pirates' products are named in emoji, a crew vocabulary; hulls carry "large emoji
marks". `Ship Design Language.md:222-226`: one large emoji crew emblem painted three times.
`Corporate Roster and Item Wishlist.md:85`: "Named entirely in emoji. The flamethrower is" the
fire emoji. `Game Design/Visual and Sensory Direction.md` says nothing about an emoji art set;
its interface register is "should feel like equipment" (:43).
**Why one colour emoji font asset.** One owner: a colour emoji font asset generated from a pinned
font file, set as TMP's emoji fallback and appended to TMP's global fallback list. Every TMP text
renders emoji through it with no per-component wiring, because TMP itself routes emoji code points
there (E4).
Rejected: a generated TMP sprite asset (the brief's first guess). Sprite lookup is one code point
per sprite (E4), so full emoji would also need a text preprocessor that rewrites every sequence to
a private-use code point, installed on every one of 154 components and every runtime text. That is
more code doing what TMP's font path already does.
Rejected: a runtime-built font asset (`TMP_FontAsset.CreateFontAsset` at boot). It needs
`Shader.Find("TextMeshPro/Sprite")` in a player (`TMP_FontAsset.cs:625`), which a build strips
unless the shader is always-included; an editor-built asset references its material and shader.
Sequences need `liga` on the rendering component (E4, E5). There is no global TMP switch, so the
sequences cut sets it on every serialized component through one editor command and on TMP
Settings' defaults for new components. It changes no Latin text (E6).
The atlas is dynamic and multi-atlas: glyphs rasterise on first use, so nothing is hand-made and
nothing is pre-baked. `m_ClearDynamicDataOnBuild: 1` (TMP Settings) already clears dynamic atlases
at build. The pinned font file is the only binary input, and its provenance is a lock file
(source, version, sha256 in and out). `emoji-art-set` decides only which pinned input and which
fetch step; the asset, settings, smoke and deletion are the same for every option. The font build
itself (option twemoji: nanoemoji) runs under `py -3` on Yggdrasil, not Starfire; the Unity batch
run is Starfire's. The sequences command touches only the `m_ActiveFontFeatures` line of each of
48 prefabs and 2 scenes, so it wants the freshest master and a quick merge.
### Retiring the prelude and the tutorial
Body: `origin/master` `4d664b53`; "merge tree M" as in the faction-play re-anchor (BS-probes), unless
noted. Read by `cut-retire-prelude` and `cut-gear-without-presence`; question `dev-scenarios-cast`
asks which galaxy the six test scenarios use once the authored cast goes. D1 and "Why the demo is a
prelude galaxy" describe the code before this retirement.
- **PR1.** `Galaxy.IsPrelude` (`Galaxy.cs:30`) is written by three constructors: the save
constructor from `SavedGame.IsTutorial` (`:53`), the main constructor `false` (`:107`), the
prelude constructor `true` (`:175`). Its readers (grep over Assets, tests, tools): `InfluenceOf`
(`:158-161`, halves influence rounding up), `LoadoutGenerator.IsAvailable` (`:211-214`, every
product on offer), `RunStart.GenerateArena` (`RunStart.cs:29`) and
`ActionGameManager.PopulateLevel` (`ActionGameManager.cs:721-727`) as ZoneGenerator's
`isTutorial`, `RunSave.Capture` (`SavedGame.cs:77`), and tests (`RunGoalTests.cs:22`,
`RunStartTests.cs:146`, `ScenarioTests.cs:137, :146, :554-557`).
- **PR2.** `InfluenceOf` feeds both generation and later zone generation: `Galaxy.cs:214-247`
(cast homes), `:314-324` (main homes), `:342-349` (influence), and `ZoneGenerator.cs:212`
(`factionPresence`, which sets station and enemy counts). A restored galaxy generates unvisited
zones through `PopulateLevel`, so the halving must persist with the run, today through
`SavedGame.IsTutorial` (key 11).
- **PR3.** `ZoneGenerator.GenerateZone(..., bool isTutorial = false, bool ambient = true)`
(`:40-46`): the entrance station floor at `:223-224`, the widened orbit pool at `:238` and
`:246-248`, and the throw at `:327-329` all apply only to `isTutorial && galaxyZone ==
galaxy.Entrance`. Source: operator 2026-09-25 "Tutorial station" (`docs/locomotion-cut.md:375`),
scoped to the tutorial level.
- **PR4.** The cast type is `TutorialGenerationSettings` (`Settings.cs:96-106`): five faction
names, LinkDensity, ZoneCount. Readers: `GameSettings.cs:30` (field, plus
`TutorialBackgroundSettings` `:31`), `GalaxyStage.cs:16, :29, :71-74` (`Prelude()` and
`Prelude(cast)`), `RunStart.cs:36-37`, `ScenarioStage.cs:21-35` (read only by
`TutorialGalaxy.cs:13`), `MainMenu.cs:178-179`, `ActionGameManager.cs:848`,
`DemoTerminus.cs:8` (the demo's cast), `tools/AetherDb/Program.cs:489-533` (`settings` and
`settings-dump`).
- **PR5.** `Assets/Resources/Settings.asset` (LFS, smudged at 4d664b53) `:43-52`: the authored cast
is protagonist `Miss`, antagonist `Zhe`, buffer `Luc`, neutrals `Aero` and `Finch`, quest
`Adras`, 64 zones; `TutorialBackgroundSettings` at `:53`.
- **PR6.** Six test scenarios generate `stage.Prelude()`, the authored cast: Arcs, DjinniShakedown,
Duel, LauncherAngles, LongHaul, StarvedReactor (`Scenarios/*.cs`, one line each).
`Scenarios.Development` is `{ TutorialGalaxy, MainGalaxy }` (`Scenarios.cs:8`). Their galaxy
includes Miss Terri's as protagonist.
- **PR7.** The prelude constructor's `playerSettings` and `narrativeDirectory` parameters feed only
the StoryProcessor lines commented out at `Galaxy.cs:258-260` (dead since ce1a0a46, 2021, per
locomotion-cut.md:375).
- **PR8.** `LoadoutGenerator.IsAvailable` (`:211-214`) and `ManufacturerPreference` (`:217-226`):
availability requires `Galaxy.ContainsFaction(manufacturer)` unless the galaxy is a prelude; an
absent manufacturer has no home zone, so `ManufacturerPreference` gives it distance 0, the
highest weight its allegiance allows. `Galaxy.ContainsFaction` (`:453-457`, cache
`_containedFactions` `:36`) has one game reader (IsAvailable); other readers are
`FireControlCut6cTests.cs:253` and the AetherDb `loadout exclude-sellers` mode
(`Program.cs:745-790`), which builds a galaxy without named factions precisely to exercise the
presence gate. `ScenarioStage.cs:29` builds its availability with Faction null, which today means
every present maker.
- **PR9.** `SavedGame` (`SavedGame.cs:9-52`) is an int-keyed MessagePack document
`aetheria.savedgame` v1, keys 0-13; `IsTutorial` is key 11.
- **PR10.** Tests touching the concept (grep -c "tutorial|prelude", case-insensitive):
RestoredHullsTests 65, ScenarioTests 30, DemoTerminusTests 29, RunStartTests 16, MiningCut3Tests
3, RunGoalTests 1. Most are the `TutorialGalaxySettings()` fixture family in RestoredHullsTests
and the `isTutorial:` argument.
- **PR11.** Open boss-gate finding `test-reads-isprelude` (verdict cut-boss-gate.s1) is
`RunGoalTests.cs:22`; retiring the flag removes it by construction.
**Why two cuts.** The follow-up asks to absorb gear-availability-without-presence and to keep the
subtraction separate from behaviour. Removing the presence gate changes what every galaxy
generates, so it is its own cut (`gear-without-presence`), landed first; `retire-prelude` then
deletes the flag with no behaviour change except where named.
**Why `IsPrelude` is split by rule, not renamed.** It bundles four unrelated rules, and each goes
to its owner:
- influence halving -> the cast's own `InfluenceScale` (the demo's cast sets .5, so its galaxy is
unchanged), carried by the Galaxy and the save (key 14) because later zones read it (PR2);
- the entrance station -> `Scenario.EntranceStation`, a declaration beside `Ambient`, default true,
false for MainGalaxy, so every scenario's arena is unchanged; RunStart.GenerateArena applies it;
- availability -> the general rule of gear-without-presence;
- the save flag -> deleted (key 11 retired; ruling `no-save-compatibility-before-players`).
A renamed flag on Galaxy was rejected: it would keep one switch deciding four things.
**Absent manufacturers.** Preference weights by distance to the maker's headquarters, and an absent
maker has none. The cut weighs it one jump beyond the farthest zone. Prior art: the gravity model
of trade (Tinbergen 1962; Anderson and van Wincoop 2003) weights flows by distance decay, and a
supplier with no local presence carries the largest trade cost, so it is reachable but least
preferred. Zero distance (today's accident) would make absent makers the most preferred, inverting
the rule.
**Old saves.** Key 14 absent decodes as 0 and the save constructor refuses a non-positive scale by
name. Per ruling `no-save-compatibility-before-players` nothing reads key 11.
### Model page rows
| Kind | What names it | Over time | Who decides |
|---|---|---|---|
| Pirates `Faction` | Catalog record, name `Pirates` | Authored once by `AetherDb pirates-faction apply`; doctrine is added by faction-play r2 | Ruling `pirates-record-content`; the operator sees the dry run |
| Package product | `mod-product:<id>:<maker key>` in the package's `ship.cc` | Written by `AetherDb ship-authoring product`; copied verbatim by compose; Blender never edits it | The package author |
| Boss hull | `HullData.Boss` (key 33) | Set by `ship-authoring boss`; a faction's boss hulls are derived per generation from the products it makes | The package author; `BossHullsOf` derives |
| `Faction.BossHull` (key 10) | Retired | Deleted by `boss-hull-derived` | none |
| Pirates products | 8 `FactionProductData`, emoji names, over other makers' designs | Authored with the faction in one commit | Ruling `pirates-record-content` |
| Package hull price | `HullData.Price` | Written by `ship-authoring price` (or `create --like`) | The package author |
| Emoji font file | `Assets/Fonts/Emoji/<set>.ttf` + `emoji-font.lock` | Rebuilt only by `tools/emoji` at a new pinned version | The operator's `emoji-art-set` ruling |
| Emoji font asset | `Assets/Fonts/Emoji/Emoji.asset` (dynamic, COLOR, multi-atlas) | Regenerated by `EmojiFont.Build`; atlas filled at runtime, cleared at build | `EmojiFont.Build` |
| TMP emoji routing | TMP Settings `m_EmojiFallbackTextAssets` and `m_fallbackFontAssets` | Set by `EmojiFont.Build` | `EmojiFont.Build`; no prefab overrides it |
| Ligatures per text | Each TMP component's `fontFeatures` | `liga` added once by `EmojiFont.EnableLigatures`; new components inherit TMP Settings | `EmojiFont.EnableLigatures`, then the component |
| EmojiOne sample | none | Deleted by `emoji-font` | none |
| Galaxy cast (replaces `TutorialGenerationSettings`) | A scenario's code constant (`DemoTerminus.Cast`) | Compiled with the game; no authored settings copy | The scenario |
| `Galaxy.InfluenceScale` | Its galaxy | Set by the constructor (the cast's value, 1 for main), saved as `SavedGame` key 14 | The cast |
| `Scenario.EntranceStation` | Its scenario | Compiled | The scenario; `RunStart` applies it |
| `SavedGame` key 11 (`IsTutorial`) | Retired | Not read, not written | none |
| `Galaxy.IsPrelude`, `TutorialGalaxy`, `TutorialGenerationSettings`, `GameSettings.TutorialGenerationSettings` | Deleted by `retire-prelude` | | none |
## Faction play: the NPC scripting
The operator ruled on 2026-10-03 that the faction dynamics may elaborate the NPC
scripting through the extension points she built into it (ruling
`npc-scripting-may-grow`). Legacy-first still forbids CultMesh and daemon work. This
section maps those extension points, names the smallest set of primitives that
carries every dynamic in `Game Design/Faction Play.md` (AetheriaLore `f3e0e6d`), and
gives the reasons for the four proof cuts (`faction-play-1` to `-4`), which are
typed specs in the mind. Anchors are against `origin/master` `f1dee184`, read with
`git show` on 2026-10-03, unless the line names a lane.
### Body facts
**F1. NPC behaviour is a code-composed state graph, not a tree, a DSL or assets.**
- `Agent` (`ServerShared/Agents/Agent.cs:14-80`) holds one current `BaseState`. Each
tick, `Update` (`:47-56`) runs the state, then takes the first `StateTransition`
whose `Condition()` holds (`:82-94`: target state, condition closure, optional
on-transition action).
- `BaseState.AddTransition(..., includeChildren, ignoreStates)`
(`States/BaseState.cs:19-58`) copies a transition onto every state reachable from
the source. That is the interrupt mechanism: one call makes "a target appeared"
fire from anywhere in the graph. It is the same idea as Halo 2's impulses.
- `Minion` (`Minion.cs:5-22`) is the only `Agent` subclass. It composes root, patrol
and combat in its constructor. Behaviour is authored in C#; there is no
data-driven layer.
- `codex/scenarios` makes `AddTransition`'s walk state per call, because zones are
built on several threads (its `BaseState.cs` hunk at `:12-58`).
**F2. Tasks are the command slot, and only one task kind is wired.**
- `Agent.Task` (`Agent.cs:22`) holds one `AgentTask` (`Tasks/AgentTask.cs:13-22`).
`Minion` enters patrol when `Task is PatrolOrbitsTask` (`Minion.cs:10-12`).
- `HaulingTask`, `Mining`, `StationTowing` and `Survey` are data with no state
that reads them. `AgentTask.Priority` and `Reserved` have no reader; they are the
remains of a task board that is no longer in the tree (`WanderTask` and
`MatchVelocity` were deleted in `cc7f2c88`).
- `PatrolOrbitsState` (`Tasks/PatrolOrbits.cs:10-31`) and `MoveToOrbitState`
(`States/MoveTo.cs:31-37`) are the only movement states. No state follows an
entity, holds a standoff, or flees.
- No task state returns to root when `Task` changes. Only combat's interrupt
leaves patrol.
**F3. Agents are made in one place and ticked before entities.**
- `Zone.CreateAgent` (`Zone.cs:137-144`) gives every non-player ship a `Minion`
with a patrol over four random orbits. `Zone.Update` ticks agents (`:185-186`),
then entities (`:188`), then `FireControl.Step` (`:193`).
- `codex/scenarios` adds `Zone.Admit(entity, piloted)` as the only writer of
`Entities.Add` and the only creator of agents (construction, run staging, warp
arrival, undock, spawned turrets).
- Agents are not persisted (`AgentTask.cs:11`). A loaded zone rebuilds them.
**F4. Each ship perceives alone.**
- `Entity` holds `VisibleEntities`, `VisibleEnemies`, `VisibleFriendlies`,
`EntityHostility` (`Entity.cs:34-37`) and `EntityInfoGathered` (`:64`).
- `Sensor.Execute` is the only writer of `EntityInfoGathered` (`Sensor.cs:157-189`).
`Sensor.Ping` (`:110-125`) boosts it and adds the pinger's own visibility.
- Crossing `TargetDetectionInfoThreshold` moves an entity into the visible lists
(`Entity.cs:237-254`).
- `FireControl.Designated` requires `source.VisibleEntities.Contains(target)`
(`FireControl.cs:232-239`). `IsRevealed` reads the observer's own
`EntityInfoGathered` (`:130-149`). A ship cannot fire at, or aim at gear on,
something its own sensors do not hold.
- No group, squad or faction-level knowledge exists. `Ship.HomeEntity`
(`Ship.cs:26`) has no reader.
**F5. Hostility has a derived rule, a per-entity override and a grudge.**
- `IsHostileTo` (`Entity.cs:535-553`): an override decides outright. Otherwise a
zone owner is hostile to trespassers (presence not permitted), and anyone is
hostile to whoever is hostile to them. `// TODO: Inter-faction hostility` is at
`:547`.
- `GetFactionRelationship` (`:556-563`) answers Beloved for the own faction and
Neutral for every other, except for the player's ship, which reads
`Galaxy.FactionRelationships`. Ruling `faction-relations-field` fills this.
- `SetIff` (`:529-533`) writes the runtime override. `WatchForGrudge`
(`:452-481`) sets a sticky hostile override back on anything that turns hostile
while detected. Leaving the zone clears overrides (`:207`). The override map is
private: nothing outside `Entity` can ask whether a grudge is held.
**F6. Target selection has four writers.**
- `Minion.cs:14`: the first enemy to become visible, when the slot is empty.
- `TurretController.cs:96`: the first visible enemy ship.
- The player's input (`ActionGameManager.cs:377-403`).
- `Entity` clears it when the target leaves the zone or visibility
(`Entity.cs:201`, `:227`, `:298-299`).
- `codex/mining` turns `Target` into a `TargetRef` (asteroid chunks can be targets)
written through `SetTarget`. It edits `Minion.cs:14-21` and `Combat.cs:28`.
**F7. Combat tuning is global.**
- `CombatState.SampleDps` biases range by `GameplaySettings.AgentRangeExponent`
(`Combat.cs:158`, `Settings.cs:242`). Movement reads `AgentForwardLerp` and
`AgentMaxForwardDistance` (`Combat.cs:95`, `Settings.cs:243-244`).
- `FireControl.AgentFires` (`FireControl.cs:93-99`) compares hit probability with
the global `AgentMinHitProbability` (`Settings.cs:260`). Its callers are
`Combat.cs:121` and `TurretController.cs:81`.
- `CombatState` aims at the target's most dangerous revealed weapon through
`TrySelectTargetItem` (`Combat.cs:40-53`).
**F8. `Faction.Personality` is economy vocabulary, not combat doctrine.**
- `Faction.Personality` (`Corporations.cs:28-29`) maps `PersonalityAttribute`
records (`ItemData.cs:902-914`: a name with low and high pole names) to floats.
- The same records key `CompoundCommodityData.DemandProfile` (`ItemData.cs:334-335`),
which is what a population with those traits wants to buy.
- Neither map has a reader. A string probe of the working-copy store found the
schema and no faction values; that probe is not proof.
- So the attributes are population traits shared with demand. Encoding
`engage_on` or `target_priority` as float bands in them, as Faction Play's lever
table proposed, would give one record two meanings.
**F9. Generation spawns individuals, not groups.**
- `ZoneGenerator` adds `enemyCount` ships of the nearest faction one at a time
(`ZoneGenerator.cs:334-340`), plus neutral wanderers of one other faction
(`:342-356`).
- `LoadoutGenerator.GenerateShipLoadout(hullFilter)` (`LoadoutGenerator.cs:35-50`)
fills every hardpoint (`:189-231`), so no generated ship is unarmed. Nothing marks
a ship as a tender or a hauler.
**F10. Hits carry their source only on the hull.** `IncomingHit` (`Entity.cs:138`)
fires with the shooter at `FireControl.cs:962` and `:1009`, after the shield
branches have returned, so a shot a shield absorbs reports no source. `feedback-1`
adds `Capabilities.Absorb` for both layers, without a source.
**F11. Cargo moves between entities; jettison has no owner.**
`Entity.TryTransferItems(target, item, quantity)` (`Entity.cs:693-725`) moves
commodities between cargo bays, and weapons draw ammunition from cargo through
`AmmoType` (`InstantWeapon.cs:187-192`). Dropped items exist only as Unity pickups
spawned on death (`EntityInstance.cs:304-320`), and the mining lane keeps pickups
Unity-only (its pickups question, recommendation A). The simulation has no
floating cargo to jettison into. The floating-item substrate is mapped as cuts
`loot-1` to `loot-3` (section "Lanes: scenarios, mining and the loot move", L6-L8).
**F12. Barks and pings already have a channel.** `Entity.SetMessage`
(`Entity.cs:1275-1278`) shows a line for `MessageDuration`. `Entity.Sensor.Ping()`
is callable by anything that holds the entity.
**F13. Agents are testable headless.** `IffAndCombatTests` builds a `Zone` with
ships and drives detection by writing `EntityInfoGathered`
(`tests/Aetheria.Shared.Tests/IffAndCombatTests.cs:51-92`). `Agents/` is under
`ServerShared`, so the whole graph compiles into `Aetheria.Shared` (B11).
**F14. Ammunition is real for three weapons, and nobody is stocked.**
- Only DeathCluster, FastBlast+- and pretty pretty bang bang carry an `AmmoType`;
their refs were repointed in `515859cf` (`docs/cultcache-migration-cut.md:161-163`).
Every other weapon, Zhestokost ballistics included, reloads for free.
- `LoadoutGenerator` puts no rounds in any cargo, and station stock is drawn from
`EquippableItemData` only (`LoadoutGenerator.cs:98-99`), so no station sells
rounds either. An NPC carrying one of the three ammunition weapons fires its
first magazine and then never reloads (`InstantWeapon.cs:186-197`).
- So Zhestokost's "magazines run low, back to the tender" has no substrate until
its weapons draw a commodity and generation stocks it.
### What the extension points can carry
| Extension point | Carries | Cannot carry |
|---|---|---|
| State graph with interrupt transitions (F1) | Every per-ship motor behaviour: follow at a standoff, hold, flee, return, drift, sweep. | Anything that needs more than one ship's knowledge: who may engage, shared tracks, duels. |
| `Agent.Task` (F2) | The order a ship is executing. The patrol precedent already maps a task type to a state. | Deciding the order. Nothing writes tasks except `CreateAgent`. |
| `Zone.CreateAgent`, then `Zone.Admit` (F3) | Where a ship joins whatever owns its orders, and where doctrine is applied. | Group state, which must outlive one admission. |
| Perception lists and `EntityInfoGathered` (F4) | A ship's own view, which `FireControl` trusts. | Shared knowledge. A second writer would let a ship aim at gear its own sensors never resolved (`IsRevealed`), breaking `instruments-bound` and fire control's single authority. |
| `SetIff` and grudges (F5) | Hostility memory, and spreading it: one call per member. | Faction-to-faction stance; that is the relations field. |
| `Ship.Target` (F6) | The engagement itself: `CombatState` runs while it is set. | Coordination, while four writers race for it. |
| `Faction.Personality` (F8) | Nothing in this strand; it belongs to demand. | Typed enums and per-role values. |
| `SetMessage`, `Sensor.Ping` (F12) | Hails and ping cadence. | Nothing needed here. |
The graph, the task slot and the factory were built to be extended, and they carry
the individual ship. What is missing is one layer: an owner of a group's intent.
Every cross-ship dynamic in Faction Play needs it.
### The primitives
Seven primitives carry the proof factions and leave room for the rest. Each says
where it plugs in, what it reads and which quirks it enables.
**1. Doctrine (data).** A typed `FactionDoctrine` on `Faction` (key 17; key 16 is
`Relations` by ruling), with flight-level fields and a `RoleDoctrine` per role
(combatant, support). Flight-level fields: `EngageOn` (detection, identified,
provoked, trespass, never), `Grace`, `Hail`, `ComplySpeed`, `Pack`, `StepInDelay`,
`PingInterval`. Per role: `BreakOffHull`, `BreakOffAmmo`, `RangeExponent`,
`MinHitProbability`, `HoldStandoff`, `Anchor`, `Leash`. A faction with no doctrine
gets one built from today's globals, so it fights exactly as `Minion` does now.
Inputs: catalog only. Enables the tuning half of every quirk (R&D range, Zhestokost
spray, Cryonix discipline). Why not `Personality`: F8. The shape is question
`faction-doctrine-shape`.
**2. Flight (the group's intent owner).** One runtime object per faction per zone,
holding that faction's piloted ships. It is formed at admission and rebuilt on
load, never persisted (F3). It is the only writer of each member's `Target` and
`Task`; the agent graph only executes them. It reads members' perception, hull
fraction, cargo and grudges, the doctrine, and the zone's stations. It ticks in
`Zone.Update` before the agents. This is RimWorld's Lord (a group state machine that
assigns duties to pawns) and Halo 3's objectives (squads fill prioritised tasks
with capacities), at the size this game needs. Why a new owner: no extension point
holds more than one ship's knowledge (table above).
**3. Engagement rule with grace and hail (in the flight).** Per hostile track, the
flight decides when it becomes engageable (`EngageOn` over the union of members'
views), hails once through the nearest member's `SetMessage`, holds for `Grace`,
then engages, unless the track has slowed below `ComplySpeed`, which counts as
compliance. A grudge on the track skips the grace. Enables Zhestokost's inspection
hail, Sol Dominion's "never before identification" (`identified`: any member's
info at the gear tier), Miss Terri's and Aya's "provoked", Megiddo's trespass, and
Odla's "never". F.E.A.R.'s squad dialogue is the precedent: the bark speaks a
decision already made.
**4. Engagement slots and the duel (in the flight).** `Pack` engagers per track;
the rest hold at `HoldStandoff` (default: just outside their own longest weapon
range). An engager that breaks off frees its slot after `StepInDelay`. With `Pack`
1 the slot is a duel. It is offered only to a challenger with no other hostile
track near it, and it breaks when anyone lands a hit on a holding member, or anyone
but the challenger lands one on the headliner. Broken, every slot opens and the
pack engages whoever broke the form. This carries ruling `lucent-duel-bait` whole:
honour the duel and pick the pack apart, or stage an interloper. The signal is a
landed hit with its source (F10), so the cut adds `Source` to `feedback-1`'s
absorb. Prior art: attack tokens (the "kung fu circle" of brawlers) and Halo 3's
task capacity.
**5. Anchor and leash (in the flight).** Each role's doctrine names an anchor: the
flight's support ship, the nearest own station, the patrol site, or none. An
engager past `Leash` from its anchor is ordered back, with hysteresis. Enables the
Zhestokost column that will not pass its tender, AU escorts bound to the worksite,
NiteLife guards that never leave the station, Dominion's jurisdiction, Megiddo's
perimeter, and Aya refuge across a border.
**6. Roles, break-off and rearm (in the flight).** A member with no weapon is
support; the rest are combatants. Doctrine says how many support ships generation
adds (`LoadoutGenerator` gains an unarmed option, F9). Break-off orders a flee to
the nearest own station, or away from the threat. It triggers on hull fraction
(Lucent headliners, AU haulers, Alakrita, Cryonix), or on ammunition fraction
against the count at admission. An ammunition break-off returns to the support
ship, which transfers rounds through `TryTransferItems` (F11) until the baseline is
restored; then the member rejoins. This carries the Zhestokost tender loop, once
Zhestokost weapons draw a commodity and generation stocks it (F14, question
`zhestokost-ammunition`). Support
ships use their own role doctrine: the tender follows the column's rear and flees
early, and the column, anchored to it, follows it home.
**7. Motor states (in the agent graph).** `FollowState` (an entity at a standoff,
matching its velocity) and `FleeState` (toward a refuge, or away from a threat).
They are new `BaseState`s on the existing graph, entered from tasks, and every task
state returns to root when the task changes. Follow serves escort, hold, return,
rearm and shadow; flee serves every break-off. They are two of the tutorial's four
shared states; drift-cold and sweep stay with the tutorial.
Beyond the proof, three more primitives use the same owners and need no new layer:
- **Relations** (ruling `faction-relations-field`). `GetFactionRelationship` reads
`Faction.Relations`, and `IsHostileTo` treats Hated and Hostile as hostile at the
TODO in F5. Every cross-faction pair starts here.
- **Picture sharing between flights.** A flight pushes track entries and grudges to
other flights in the zone by a `ShareTrack` policy: own faction, allies by
relation, or every flight hostile to the track (Finch). A shared entry lets a
recipient engage and steer; adopted grudges become `SetIff` on its own members.
Sharing never writes `EntityInfoGathered`, so a recipient fires only once its own
sensors hold the target. This is question `shared-track-cueing`.
- **Calls.** A zone-level distress subject `(caller, attacker, position)`, raised
by a flight when a member is hit, or as a bark with a false caller (pirates).
Flights with `AnswerCall` in range retask members. Navigator escorts pulled off a
Lightsail convoy, Aya cover and the corridor edge come from this plus relations.
Target priority (drives, cargo, structures) extends `CombatState`'s aim-item choice
by doctrine and the flight's track choice. Jettison is ruled (`jettison-shared-floating-items`)
and builds on `Zone.Release` from `loot-1`.
### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Doctrine | `Faction.Doctrine`, catalog, key 17. | Authored through an `AetherDb` catalog command, as the targeting migrations were. Null reads as the default doctrine built from `GameplaySettings`. | The catalog author. Read when a ship joins a flight. |
| Agent (`Zone.Agents`) | Its ship. | Created by `Zone.Admit`, removed on the `Entities` removal path (it was never removed before). | `Zone`. |
| Flight | Runtime: zone plus faction. | Created on first join, rebuilt on load; members leave on removal or death, and it goes with the zone. Never persisted. | Itself alone, for members' `Target`, `Task`, hails and pings. |
| Track (flight picture) | Runtime: flight plus hostile entity. | Lives while any member sees it, or a sharer lends it. | The flight, from members' perception. Never written into `EntityInfoGathered`. |
| Duel | Runtime: flight plus challenger. | Offered, then held or broken. Ends when the challenger leaves the picture or dies. | The flight, from landed hits with their source. |
| Ammunition baseline | Runtime: member. | Counted at admission. | The flight. Rearm moves real cargo through `TryTransferItems`. |
### Rationale
**Why the flight owns intent and the agent only executes.** Faction Play's
dynamics are decisions about several ships at once: who engages, who holds, who
knows what, who goes home. Spreading them across agents would keep four writers on
`Target` (F6), add more on `Task`, and make agents read each other's private state.
The RimWorld Lord and Halo 3's objectives both keep one group-level decider over
simple individual executors, and Starsector varies one AI by parameters. The
demotions: `Minion` no longer chooses a target. `Zone.CreateAgent` no longer
assigns a patrol; the flight does. The global combat tunables become the defaults
of a faction with no doctrine. `TurretController` keeps its own target writer,
because turrets are not piloted and belong to no flight; giving stations a flight
is a later step.
**Why one flight per faction per zone.** It needs no persistence and no grouping in
generation, and a zone rarely holds two separate groups of one faction. The cost:
two such groups would act as one. When generation learns groups, the key widens to
a group id assigned in `ZoneGenerator`; nothing else changes.
**Why shared tracks are cues, not fire-control data** (the recommendation in
`shared-track-cueing`). Fire control was rebuilt so that one owner decides a hit
from the shooter's own sensor data (`Designated`, `IsRevealed`, `PSensor`). A
remote track that fed `EntityInfoGathered` would undo that, and would show the
player hits from ships that never saw them. As cues, sharing still delivers the
dynamics Faction Play names: the Zhestokost column needs no identification and
finds the hot target the Dominion pointed it at; the Finch shadow is a countdown
because the hunters come, not because they fire from beyond their sensors. The US
Navy's Cooperative Engagement Capability fires on remote tracks, and it needs the
fused track picture that the target rules out of scope.
**Why the tutorial director goes through the flight.** The tutorial sketch's
director sets agents' tasks and targets directly. With the flight as their only
writer, the director becomes an input to the flight (a scripted order for named
members that outranks doctrine), not a second writer. The tutorial cut owns that
hook; this map records the boundary.
**Why the proof is four cuts.** One cut would be about 2,500 lines with tests, well
over a Hands budget, so it is split along the primitives:
- `faction-play-1` lands the owner: doctrine, the flight as the only writer of
`Target` and `Task`, the engagement rule with grace, hail and compliance, the
follow state, and per-doctrine range and fire threshold. Zhestokost already hails
before it sprays, Lucent announces itself and fires only on good odds, and AU
escorts shoot the first contact.
- `faction-play-2` adds slots and the duel, hull break-off with the flee state, and
ping cadence: Lucent whole. It needs `feedback-1`'s absorb.
- `faction-play-3` adds roles, support generation, anchor and leash: the tender the
column will not leave, and the AU haulers that run while their escorts stay close.
- `faction-play-4` makes ammunition real for Zhestokost and closes the tender loop
(question `zhestokost-ammunition`, F14). It ends with the operator's playtest of
the three factions.
Each is sized at roughly 550 to 720 lines with tests, under 200k Hands tokens. All
four wait for `codex/mining` (it changes `Target`'s type and edits `Minion.cs`) and
`codex/scenarios` (it rewrites admission in `Zone.cs`) to merge, because the first
cut rewrites exactly those lines.
### Re-anchor on master 4d664b53 (2026-10-08)
Body: `origin/master` `4d664b537f76029fbaee94392da3d8a122f81339`, read in a detached scratch
worktree. Where a probe says "merge tree M", it read `git merge-tree --write-tree origin/master
origin/eureka/aetheria-release-boss-gate` = tree `8387b47214ef6e44cae28a4e6a2b9b91d51464d9`
(boss-gate `2a5fc00b` contains demo-galaxy `85d0a148`; the merge is clean, no conflicts) with
`git show <tree>:<path>`. No commit was made. Read by the faction-play cuts, r2 and later.
- **BS1. Branch state.** `git merge-base --is-ancestor`: `codex/mining`, `codex/scenarios`,
`eureka/aetheria-release-mining` and `-scenarios` are ancestors of master.
`eureka/aetheria-release-demo-galaxy` (85d0a148) and `-boss-gate` (2a5fc00b) are NOT.
demo-galaxy's merge base with master is 228f241e; master is 94 commits ahead of it.
`-retire-longinusx` on origin equals master (4d664b53): its Hands had not pushed.
- **BS2.** `git merge-tree --write-tree --name-only origin/master <branch>`: demo-galaxy and
boss-gate each merge into master with no conflicting path (trees f3f69e84 and 8387b472).
- **BS3.** feedback-1, ballistic-ammo, controls-mount-aim, controls-ai-bearing, autofire-threshold
and ai-autofire had in-force specs and no report (specs-with-no-report query, as of ordinal 753).
`CapabilityEvents.PublishAbsorb` has no caller in `Assets/Scripts/ServerShared` (grep):
feedback-1's absorb publish sites do not exist yet.
- **FP1.** `Minion.cs:7-22`: constructor `Minion(Ship ship)`; `:14` is the one VisibleEnemies
subscription that writes `Ship.SetTarget(add.Value)` when `Target.Value.IsNone`; combat
transitions read `Ship.Target.Value.Entity` (`:18`, `:21`).
- **FP2.** `Zone.cs`: `Agents` `:40`; `Admit` `:167-172` is the only `Agents.Add` in Assets (grep)
and the comment names it the one admission; `CreateAgent` `:174-181` draws the circuit at `:178`
and assigns `agent.Task` at `:179`; the agent loop `:221-222`; the only Entities subscription is
the death subscription at `:107` (ObserveAdd -> Death -> Remove + Deactivate).
- **FP3. Nothing ever removes an agent.** `Agents.Remove`/`Clear` appear nowhere in Assets or tests
(grep). A dead or departed ship's Minion keeps running every tick. Removal from `Zone.Entities`
happens at `Zone.cs:107` (death), `Entity.cs:1268` (docking) and `ActionGameManager.cs:725` (the
player's zone transfer).
- **FP4.** `Entity.cs`: `_iffOverrides` `:53`; `Target` `:60` (TargetRef, one writer `SetTarget`
`:358`); `SetIff` `:811-815`; `IsHostileTo` `:817-836`; `GetFactionRelationship` `:838-845` reads
`Galaxy.FactionRelationships` only for the player's ship, so NPC-vs-NPC hostility is still only
trespass in an owned zone plus reciprocity ("TODO: Inter-faction hostility"). Consequence for the
demo: the allied Pirates and the antagonist Zhestokost do not fight each other; the Pirates'
doctrine is seen only against trespassers in Pirates zones. Relations (key 16) remain unmapped.
- **FP5.** `FireControl.cs:104-112` `AgentFires(weapon, shooter, target)` reads
`GameplaySettings.AgentMinHitProbability` at `:109`. Callers: `Combat.cs:121` and
`TurretController.cs:83` (grep). `Combat.cs:159` reads `AgentRangeExponent`; `_optimumRange`
`:13`. `Settings.cs:251` and `:274` hold the two globals. Ten FireControl test fixtures set
`AgentMinHitProbability = 0f` in their settings.
- **FP6.** `Agent.cs`: `Task` `:22`, `Settings` `:25`, constructor `:28-36`, `Update` `:47-59` zeroes
`Ship.Turn` each tick (controls-helm), `Accelerate` `:61-84`. `TaskType` `AgentTask.cs:24-33` has
no Follow or Flee. States live in `Agents/States` (BaseState, Combat, MoveTo); `PatrolOrbitsState`
is in `Agents/Tasks/PatrolOrbits.cs:10`.
- **FP7.** `Corporations.cs`: Faction keys run to `BossMusic` Key(15) at `:58-59`; keys 16 and 17
are free.
- **FP8.** `tools/AetherDb/Program.cs` command switch `:23-40`; `TargetingCatalog(bool apply)` at
`:1096` is the dry-run/apply precedent.
- **FP9.** Tests that construct `new Minion(ship)` directly: `FireControlPerWeaponTests.cs:276,
:291, :580, :597` and `FireControlTests.cs:214`; `RunStartTests.cs:100` and `SafeAssert.cs:44`
read `zone.Agents`.
- **FP10.** `LoadoutGenerator.cs`: `GenerateShipLoadout(Predicate<HullData>)` `:35-50`,
`EquipHardpoints` `:220-263`, `FillInterior` `:265`. `ZoneGenerator.cs`: faction ships
`:342-348`, wanderers `:350-364`, `return pack` `:366`. boss-gate adds a boss block to both files
(+38 and +18 lines), so anchors in them move by symbol after it merges.
- **FP11.** `Entity.TryTransferItems(Entity, CultRecordKey, int)` at `Entity.cs:977`. No
`GeneratedMagazines` or `AmmoType` exists in `LoadoutGenerator.cs` or `Settings.cs`:
ballistic-ammo has not landed.
- **FP12.** `Entity.Sensor` `:142`; `Sensor.Ping()` `Sensor.cs:110`.
- **FP13.** Catalog `GameData/Aetheria.cc` at 4d664b53 (LFS, smudged in the worktree): the string
"Pirates" occurs only inside geoname lists, no Faction record (H1). Follow-up
`pirates-catalog-record` is the owner.
- **FP14.** Lore source for the Pirates' doctrine: AetheriaLore `Aetheria/Game Design/Faction
Play.md` section "Pirate Coalition" (`:333-338`) and the summary row `:386`: crews want cargo,
not death; demand a drop, hit drives, leave fights they are losing. Of these, the flight
primitives express the demand (hail and grace) and leaving a losing fight (break-off). Demanding
a drop as compliance and aiming for drives need jettison (map item 10) and subsystem aim, which
no cut builds yet.
**What changed from the first anchor.** The first faction-play pass anchored on `f1dee184` and
waited for the mining and scenarios lanes. Both are merged (BS1), so the later specs branch from
master and anchor on 4d664b53. Target is a TargetRef written only through `SetTarget`, and
`Zone.Admit` is the only creator of agents, as the follow-up says.
Agents are also removed on entity removal (FP3). A flight whose membership follows removal while
the agent list does not would leave two answers to "who is in this zone"; one subscription now
decides both.
The Pirates' doctrine is authored in the doctrine-catalog command's table. While the catalog lacks
the Pirates record (FP13), the command names the missing faction and writes the rest; once the
record lands, re-running apply writes it. No doctrine, barks or levers for Miss Terri's.
The played proof sits in the DemoTerminus scenario with the demo cast, because the third cut
completes the behaviours the demo cast shows (Zhestokost's tender column, Lucent's duel, AU's
haulers); the fourth keeps the tender loop as full-game scope with no proof.
The ping cadence asks `Sensor.Ping()` and nothing more. Follow-up `sensor-stat-set-cut` is a
separate, larger mapping (per-sensor power, beam width, passive gear, shared tracks) and was not
mapped here; whatever Ping becomes, the flight only decides when to ask.
Key layout on Faction: Relations 16 (reserved, ruling `faction-relations-field`), Doctrine 17
(ruling `faction-doctrine-typed`). ships-hull-livery's Livery at 16 must move to 18 (follow-up
`livery-key-collision`, owner ships-hull-livery's next revision).
Collision: `ai-autofire` rewrites Combat's and TurretController's fire decision "at the agent
threshold". After faction-play-1 the agent threshold is the member's
`RoleDoctrine.MinHitProbability` (turrets keep the global). Whichever lands second re-anchors; the
next ai-autofire revision should read `_agent.Doctrine.MinHitProbability`.
### Prior art
- Damián Isla, "Handling Complexity in the Halo 2 AI", GDC 2005
(https://www.gamedeveloper.com/programming/gdc-2005-proceeding-handling-complexity-in-the-i-halo-2-i-ai):
a prioritised behaviour DAG, and impulses that raise a behaviour's priority from
anywhere. `includeChildren` is the legacy tree's impulse.
- Damián Isla, "Building a Better Battle: HALO 3 AI Objectives", GDC 2008
(https://gdcvault.com/play/497/Building-a-Better-Battle-HALO, slides at
https://web.cs.wpi.edu/~rich/courses/imgd4000-d09/lectures/halo3.pdf): designers
author tasks with priorities and capacities, and squads fill them; inside a task
the AI is autonomous. This is the slot model for the pack.
- Jeff Orkin, "Three States and a Plan: The A.I. of F.E.A.R.", GDC 2006
(https://www.gamedevs.org/uploads/three-states-plan-ai-of-fear.pdf): three FSM
states and a planner per agent, and a global coordinator that clusters agents
into squads by proximity and runs one squad behaviour at a time. Squad dialogue
voices decisions already made. A planner is more than these dynamics need; the
coordinator and the barks transfer.
- RimWorld's Lord, LordJob and LordToil (decompiled source,
https://github.com/josh-m/RW-Decompile/blob/master/Verse.AI.Group/LordMaker.cs):
a group controller with its own state machine assigns a duty to each pawn, and
each pawn's think tree runs its duty. That is the flight and its tasks.
- Starsector officer personalities (https://starsector.wiki.gg/wiki/Officer): timid
to reckless, each changing engagement range and retreat over one combat AI.
Faction doctrine and fleet assignments (patrol, escort, defend) are cited from
memory, not re-fetched. Lesson: parameters on one AI, as Faction Play already
says.
- Attack tokens, the "kung fu circle" of brawler games: only N enemies attack at
once while the rest posture. From memory; no primary source fetched.
- The US Navy's Cooperative Engagement Capability, which engages on remote fused
tracks. From memory; cited as the path not taken.
## Content: the moddable-ships lane
The operator ruled on 2026-10-03 that this campaign adopts `codex/moddable-ships`
whole (ruling `adopt-moddable-ships`). This section maps what the adoption takes:
S2's owed checks, the merge, the Blender package action and S3. S5 stays with the
variants campaign. The cuts are typed specs in the mind (`ships-merge`,
`ships-mounts`, `ships-addon-package`, `ships-addon-frame`, `ships-addon-mounts`,
`ships-player`), and the forks are questions (`ships-merge-gate`,
`release-hull-home`, `s3-player-build`). Two rulings of 2026-10-03 reshape the
add-on and player cuts and add a material strand: `hull-runtime-materials` and
`thrusters-radiators-are-meshes`. They are mapped in "Hull materials, mount
meshes and per-hull prep" below, with the cuts `ships-hull-material`,
`ships-hull-livery` and `ships-addon-paint` and the questions `hull-paint-mask`
(ruled: `hull-paint-mask-texture`) and `hull-livery-scope`.
Anchors are against the lane tip `a93625c2`, read with `git show` on 2026-10-03.
For files the lane does not touch, they hold on `origin/master` `f1dee184` too.
The lane's own docs (`docs/moddable-ships-cut.md`,
`docs/moddable-ship-authoring.md`) are body facts from here on.
### Body facts
**M1. The merge into master is trivial.** All 21 commits on `origin/master` since
the lane's base `65c63495` are `Scenarios map:` doc commits.
`git merge-tree --write-tree origin/master origin/codex/moddable-ships` is clean
(tree `7e034e04`). No file is changed on both sides. Fire control and the stack
reached the lane through its own merge `18e12d81` on 2026-09-30.
**M2. The lane conflicts with the two other open lanes, one line each.**
- `codex/mining` (`83d8371e`): `AetheriaStores.cs:9` (`CatalogTypes`; mining adds
`FieldKindData`, ships add `ShipAuthoring`) and `tools/AetherDb/Program.cs:39-41`
(a `case` line and the usage string).
- `codex/scenarios` (`aa3baf12`): `AetheriaStores.cs:9` (scenarios add `Scenario`).
`Program.cs`, `ItemData.cs`, `Loadout.cs` and `LoadoutTests.cs` merge on their own.
- The resolution is a union in both: keep every type and every command.
- Probe: `git merge-tree --write-tree origin/<lane> origin/codex/moddable-ships`.
**M3. The cuts already mapped in this campaign meet the lane in one file.**
- The lane edits `UI/MainMenu.cs` in `ShowMain` and inserts `EnterGame` and `Refuse`
just before `ShowSettings` (lane `:195-218`).
- `audio-1` edits `ShowSettings` (master `:186-200`), so expect an adjacent-hunk
conflict there. Resolve it by keeping both.
- `feedback-1` edits `ShowGraphicsSettings`, which the lane does not touch.
- Neither cut touches `ActionGameManager.cs`, `ZoneRenderer.cs` or the lane's new
files.
**M4. The mount ruling of 2026-09-30 is in the docs, not the code.**
- `a93625c2` records it: a mount anchor exists only for weapon, radiator and
thruster hardpoints.
- The validator still demands an anchor for every hardpoint
(`ShipAuthoring.cs:222-230`), and a weapon's mount must have the role
`articulation`.
- `ShipModPlan` copies every mount into `Equipment` (`ShipModPlan.cs:13-14,56`).
`ShipModShips` fills `EquipmentHardpoints` from it (`ShipModShips.cs:152`).
- The only reader of `EquipmentHardpoints` is the dead `SoundBank` block
(`EntityInstance.cs:244-253`), which `audio-1` also deletes. So the array has no
consumer.
- Role-versus-type checks are split. The validator checks thruster and radiator
mounts. `ShipModPlan.Build` checks that an emitter is a mount (`:30-35`) and that
muzzles hang off weapons (`:38-48`).
**M5. A package can reach the game only through the console.**
- Generation, station stock and the starting ship all choose from
`FactionProductData` (`LoadoutGenerator.RandomProducts`; `RandomHull` at
`LoadoutGenerator.cs:113`).
- A product names a design and a manufacturer `Faction`
(`FactionProduct.cs:15-31`). Generation also requires `Price > 0`.
- A package holds only `HullData` and `ShipAuthoring` (`ShipAuthoring.cs:126-140`
refuses a third record; `ship_cc.py:81-82` does the same).
- So the only way into a zone is the `give <hull name>` console command while
docked (`ActionGameManager.cs:527-556`). It commissions a bare hull at the
station.
- A grep of `ConsoleController` and `ConsoleView` found no debug-build gate. It is
assumed live in a player; `ships-player` checks it.
- `ship-authoring create` writes a `HullData` with default stats and `Price` 0
(`ShipAuthoringCommands.cs:38-56`).
- Question `release-hull-home` asks how a release hull gets its manufacturer and
price.
**M6. The model must sit on the schematic grid.**
- `Entity.ToWorldPoint` (`Entity.cs:426-433`) gives
`world = (cell − Shape.CenterOfMass) × SchematicCellSize` (2 m, `Settings.cs:255`),
with schematic +x to starboard and +y forward.
- Fire control decides hits in that frame. `feedback-1` places impact sparks at
`ToWorldPoint` of the lane entry.
- A GLB whose origin is not the grid's centre of mass, or whose scale is not 2 m
per cell, shows hits beside the hull.
- Nothing checks this today.
**M7. Axes, probed.**
- Blender 5.2.2 (`--background --factory-startup`, a cube ship exported with
`export_format='GLB', use_active_collection=True, export_extras=True,
export_yup=True`):
- An object custom property `aetheria.id` lands in the node's `extras`.
- Objects outside the active collection are left out.
- The GLB has a scene.
- Blender `(0, 1.5, 0)` exports as glTF `(0, 0, -1.5)`.
- An empty's scale `(2, 3, 1)` exports as `(2, 1, 3)`.
- With glTFast's handedness flip, Unity sees `(-x, z, -y)`
(`ShipModVisual.BuildLineMesh`, `ShipModVisual.cs:98`).
- So the nose of a ship points along Blender −Y, Blender's own Front, and the
ship's starboard side is Blender −X.
**M8. The operator's two Tripo hulls are already in one .blend.** A read-only probe
of a copy of `C:\Users\Meta\Desktop\Quiet.blend` (Blender 5.2.2):
| Collection | Object | Triangles | Size (Blender units) | Material |
|---|---|---|---|---|
| Headliner | `ff611c02…` (and `original`) | 50,008 | 0.68 × 1.00 × 0.35 | one; a 4096² JPG base colour |
| Quiet | Dexter Quiet | 9,614 | 0.98 × 1.05 × 0.41 | one; a 4096² JPG base colour |
| Quiet | Sinister Quiet | 10,880 | 1.07 × 1.05 × 0.41 | the same |
- Each collection also holds a Grease Pencil LineArt object. Quiet holds a scaled
icosphere, a hand-made shield ellipsoid.
- Tripo meshes arrive at about 1 unit long, one material, base colour only: no
normal, roughness or emissive map. The textures came from FBX imports
(`D:\Downloads\tripo_texture_*.fbm`) and are packed into the .blend.
- Schematic sketches sit beside the .blend: `HeadlinerSchematic.png`,
`DexterQuietSchematic.png` and `SinisterQuietSchematic.png`.
**M9. The template lacks a radiator material.**
- `EntityInstance` heats radiators with `material.SetFloat("_Emission", …)`
(`EntityInstance.cs:407-410`). Only Aetheria shaders define `_Emission`
(`Assets/Materials/Radiators.mat`).
- `ShipModTemplate` (`ShipModTemplate.cs:12-16`) carries shield, tractor, ping,
destroy effect, invisible and map icon, but no radiator material.
- A mod radiator therefore keeps its glTF material and never glows.
- Thruster emitters are particle emission shapes (`ShipInstance.cs:71`,
`particlesShape.meshRenderer`). Any mesh works.
**M10. A built player has never loaded a mod ship.** Every proof so far ran in the
Editor:
- `ShipModPlaySmoke` and `ShipModPreview.Smoke` passed in Unity 6000.3.24f1
(`moddable-ships-cut.md` S2 status).
- glTFast's manual says a Built-In pipeline player must carry glTFast's shader
variants: a `ShaderVariantCollection` in Graphics' Preloaded Shaders, or
placeholder materials in `Resources`. This is from the docs, not a probe
(https://docs.unity3d.com/Packages/com.unity.cloud.gltfast@6.0/manual/ProjectSetup.html).
- Whether glTFast keeps meshes readable in a player is also unprobed.
`ShipModShips.Assemble` throws if the collider mesh is not readable
(`ShipModShips.cs:103-104`).
- `origin/master` has no player build entry point. `docs/build-delivery-cut.md`
Cut 1 maps one (`Assets/Editor/AetheriaBuild.cs`, copying `GameData/Aetheria.cc`).
It does not copy `GameData/Mods`, and nobody owns it.
**M11. Git does not yet track packages well.** `.gitattributes` has no `*.glb` rule.
`GameData/*.cc` covers only direct children of `GameData`, so
`GameData/Mods/<id>/ship.cc` would be stored as a plain blob.
**M12. The add-on reads CultLib from wherever Brokkr points.** `_libraries` puts
`<cultlib_py_src>/cultcache-py/src` on `sys.path` (`ship_cc.py:46-52`). Brokkr's
preference is a working-tree path. Nothing pins the revision the add-on runs
against. The variants campaign is rewriting Python's write path (spec
`variants:cut_spec:cut-parity-py.r1`).
### S2's owed checks: what each one checks, and who can prove it
The lane owes six operator checks (`moddable-ships-cut.md`, S2 status). Each check
is a rule decided in one place, plus the Unity wiring that shows it. The rule is
agent-provable. The wiring is agent-provable unless it needs clicks through scenes.
| Check | Rule, and where it is decided | Agent proof | Needs the operator |
|---|---|---|---|
| C1. The menu names excluded mods | `ShipModCatalog.Compose` and `ResolveCatalog` exclude and name a bad or colliding package, and boot continues. Already headless: `ABadPackageIsExcludedAndNamedWhileTheGoodOnesCompose`, `ACollisionExcludesEveryPackageInvolvedAndNoneOfTheRest`, `TheBootCatalogSurvivesBadPackages`, `TheBootCatalogSurvivesAComposeThatFails`. | Built-player boot smoke (`ships-player`): with one bad package staged, `Player.log` holds `Some mods were not loaded: <dir>: <reason>`. `MainMenu.Refuse` logs it at `MainMenu.cs:213`. | No. |
| C2. New Game with a mod installed | `EnterGame` waits on `ShipModShips.Loading`, refuses a faulted preload, then loads ARPG (`MainMenu.cs:197-209`). | The preload half: the player smoke's `SHIP_MOD_PRELOAD` line, with no glTFast shader error. | Yes: clicking New Game, and the prototypes surviving the Main Menu → ARPG scene load (`DontDestroyOnLoad`, `ShipModShips.cs:39`). No mod hull appears on New Game anyway (M5). |
| C3. Continue with a mod | The run stores `mod-hull:<id>` keys, and the recomposed catalog keeps them. Headless: `ComposeAtBootIsDeterministic`. | New sim test `AModShipSurvivesSaveRecomposeAndReopen` (`ships-player`). | A Continue click in the S3 session. |
| C4. Zone entry | `LoadEntity` branches on `Visual` (`ZoneRenderer.cs:292-294`). `EntityInstance.SetEntity` binds barrels, radiators and thruster emitters by mount name. | `ShipModPlaySmoke` covers instantiation. Extending it to call `SetEntity` over an outfitted mod ship is a probe in `ships-player`. If `SetEntity` needs the live scene, record it as not yet reached. | Yes: the ship looks right, thrusters emit at the nozzles, guns fire from the muzzles, the shield fits, the map icon shows. |
| C5. Wormhole | Prototypes outlive zone changes, and `LoadEntity` clones again. | Instantiating twice from one prototype in the smoke. | Yes: one transit. |
| C6. Continue after removal | `RunSave.RequireDesigns` refuses and names the missing mod ids. Headless: `RunReferencingAMissingModRefusesContinue` and its siblings. | Covered by the rule tests. | A glance: the dialog appears on Continue. |
All the operator rows fit in the S3 session, on a built player with the first real
hull: about fifteen minutes, done once. With no package installed the mod path is
inert:
- `ResolveCatalog` returns the shipped catalog
(`TheBootCatalogIsTheShippedOneUntilAModIsInstalled`).
- `Preload` returns at once (`ShipModShips.cs:31`).
- `Loading` is a completed task.
Merging before the operator session therefore changes nothing a no-mod player sees.
Question `ships-merge-gate` asks the operator to confirm that.
### The merge
1. Hands creates `eureka/aetheria-release-ships` at `a93625c2`. The Codex branch is
never pushed to.
2. Hands runs `git merge --no-ff origin/master`, which is clean (M1), and verifies:
- the headless suite on Yggdrasil (684 at S2; master added no tests);
- the Unity compile on Starfire;
- both batch smokes against the generated skiff fixture.
This is cut `ships-merge`.
3. Soul passes the merge: a narrow pass, because no file changed on both sides.
4. Self merges the branch into `master` with `--no-ff`, gated by `ships-merge-gate`.
5. After that, every content cut branches from `master` in its own worktree, on
`eureka/aetheria-release-ships-<cut>`. Anchors stay valid, because the master
merge changes no lane file.
6. Mining and scenarios inherit the union resolutions in M2 when they merge
(follow-up `lane-merge-union-catalogtypes`).
The stale local ref `codex/moddable-ships` (`375d6bd4`) in the Codex worktree is
left alone.
### The Blender package action
*The GLB export and `ModelAsset` below are superseded by "Ship data all in the `.cc`": Package writes `ShipModel` into `ship.cc`.*
The add-on today binds a collection, captures LineArt, and edits the grid and
hardpoints. It writes no anchors and exports no GLB (`moddable-ship-authoring.md`
proof gate 2). The design aims at one thing: the operator's time per hull, at one
hull a day. Everything that can be derived from the mesh is generated. Everything
that needs her eyes takes one click at the 3D cursor.
**Per hull, after the add-on cuts:**
1. Tripo: image to model, textured. Export GLB. Tripo's smart low-poly option is
worth its 10 credits above about 60k triangles.
2. Blender: import the GLB into a new collection. Press **New Ship**:
- Enter the id, the name, a reference hull such as Djinni, and the length in
cells.
- AetherDb creates `GameData/Mods/<id>/ship.cc` with the reference hull's stats
(`create --like`). C# owns the schema, and Python never builds a `HullData`.
- The add-on binds the collection, turns the long axis to −Y, scales the mesh to
length × 2 m, rasterises a draft grid from the top-down silhouette, and shows
the grid as an overlay.
3. Rebuild, unwrap, rebake and mask: see "Hull materials, mount meshes and
per-hull prep" below (steps P3 to P7).
4. Mounts:
- Thrusters and radiators are meshes (ruling `thrusters-radiators-are-meshes`).
Select the nozzle-exit or radiator faces in Edit Mode and press **Add
Thruster** or **Add Radiator**, or tag a selected mesh object.
- Weapons stay points: with the 3D cursor on each gun, press **Add Weapon**
(Energy, Ballistic or Launcher).
- Each also creates the hardpoint row on the cell under it, with a 1×1 footprint.
5. Panel: toggle cells, add the internal hardpoints (reactor, shield, sensors and so
on, which carry no anchor), and set footprints.
6. **Package**, one button:
- Recentre the ship on the grid's centre of mass (M6).
- Regenerate the structural anchors unless she has marked one as kept:
- `hull-collider`: a convex hull of the render meshes, at most 255 faces;
- `map-icon`: a flat top-down outline;
- `shield`: an ellipsoid empty over the bounds with a margin;
- `tractor`: an empty at the nose.
- Cap textures at 2048 by default.
- Export `ship.glb` with extras.
- Write `ModelAsset`, `Anchors`, the layout, and the lines captured from the
collection's LineArt.
- Run `AetherDb ship-authoring validate`, and show the C# validator's errors by
anchor and hardpoint name.
7. Unity Editor play: dock, `give <name>`, equip, undock, fly.
Steps 2, 4 and 6 replace the hand work that M8 shows she has started. The
`aetheria.id` of every node is its anchor id, so `ModelNodeId` equals the anchor
`Id`. Node ids are written by the add-on and never typed by her.
**Material slots.** The material owners are in "Hull materials, mount meshes and
per-hull prep" below. Ruling `hull-runtime-materials` superseded the deferral of
livery recorded here before.
**Colliders.** Hits are fire control's (R8). The hull collider serves only
ship-on-ship contact and is convex (`ShipModShips.cs:110`), so a generated convex
hull is the whole job. Unity cooks at most 255 polygons for a convex collider, and
the generator emits no more.
**Split into three cuts.**
- `ships-addon-package` is the core: anchors from role-tagged objects, GLB export,
`create --like`, validate.
- `ships-addon-frame`: New Ship, fit, rasterise, the grid, recentring, structural
anchors.
- `ships-addon-mounts`: the mount helpers, texture cap, triangle warning, CultLib
revision guard, and the per-hull checklist in the authoring doc.
Each fits well under 200k Hands tokens. The core alone already makes a valid
package from hand-placed objects. Revision 2 of each (2026-10-03) carries the two
new rulings; the section below says what changed.
### Hull materials, mount meshes and per-hull prep
Three operator rulings of 2026-10-03 drive this section.
- `hull-runtime-materials`: "I expect I'll want to do quite some cleanup and prep
for the meshes. The geometry is still not ideal, and we want more runtime
material control than just a single baked map. See how MechWarrior does it, for
example."
- `thrusters-radiators-are-meshes`: "Note that both thrusters and radiators should
be meshes, the former doesn't get rendered but is used as an emission surface".
- `hull-paint-mask-texture`, answering question `hull-paint-mask` with option
`texture-sidecar`: "I want a mask map per ship for livery, I'll be rebuilding
the nonsense geometry and unwrapping anyway, I want these to stand up to my own
authoring standards even if tripo does a lot of the leg work."
The prior-art survey is `F:\Projects\aetheria-hull-materials-prior-art.md`
(Eyes, 2026-10-03). Anchors below are against `80de88f2`, the tip of the
in-flight `eureka/aetheria-release-ships-mounts`. The add-on is unchanged there
since `a93625c2`.
#### Body facts
**M13. Mod hulls render outside the game's ship lighting and fade.**
- `ShipModVisual.LoadAsync` builds `new GltfImport(...)` with no material
generator (`ShipModVisual.cs:45`). glTFast's default Built-In generator therefore
picks `glTF/PbrMetallicRoughness` for every hull material.
- Prefab hulls paint with `Aetheria/GlowFade` (`Assets/Shaders/GlowFade.shader`;
`Car Paint White.mat` and `Cockpit.mat` use it). It does two things a glTF
shader does not:
- it lights by the volumetric ambient (`noambient`, `VolumeSampleColorSimple` in
`vert`, `:35` and `:67-72`);
- it dithers in and out by `_Fade` (`:84-88`).
- `EntityInstance.Awake` gives each material with `_Fade` a per-entity instance it
fades. Every other submesh is swapped to `InvisibleMaterial` and only shown by
`ShowUnfadedElements` (`EntityInstance.cs:86-99`, `:120-131`). So a mod hull pops
where a prefab hull dissolves, and it is lit differently.
- Nothing tints any ship today. `Faction.PrimaryColor` and `SecondaryColor`
(`Corporations.cs:35-38`) are read only by the sector map (`SectorMap.cs`).
**M14. glTFast keeps meshes readable and exposes every core material slot.** Read
from the package source (`com.unity.cloud.gltfast@11ddc2436f97`):
- `MeshGenerator.cs:539` calls `UploadMeshData(false)`, so meshes stay readable.
The collider (`ShipModShips.cs:103`) and a particle mesh shape need that, so
M10's readability doubt is closed for the importer. `ships-player` still checks
it in a built player.
- `IMaterialGenerator.GenerateMaterial(MaterialBase, IGltfReadable, bool)` sees
every core slot: `PbrMetallicRoughness.BaseColorTexture` and
`MetallicRoughnessTexture`, `NormalTexture`, `OcclusionTexture`,
`EmissiveTexture`, each with `index` and `texCoord` (`Schema/Material.cs:48-77`,
`TextureInfo.cs:42-49`). `IGltfReadable.GetTexture(int)` returns the texture.
- glTFast decodes images only as parts of a glTF. It has no entry point for a
loose PNG.
**M15. Blender exports a named colour attribute exactly** (probed for the
rejected vertex-colour option, kept as a fact).
- A `FLOAT_COLOR` corner attribute exported with `export_vertex_color='NAME'`
becomes one exact `COLOR_0`.
- `'ACTIVE'` wrote every attribute, and its `COLOR_0` was all ones.
**M16. The Tripo hulls, measured.** A read-only probe of a copy of `Quiet.blend`:
| Hull | Triangles | Face-connected parts | Largest part's share of faces | Parts under 2% of hull length | Non-manifold edges | UV islands |
|---|---|---|---|---|---|---|
| Headliner | 50,008 | 189 | 33% | 23 (647 faces) | 6,145 | 6,237 |
| Dexter Quiet | 9,614 | 12 | 87% | 8 (124 faces) | 524 | 1,386 |
| Sinister Quiet | 10,880 | 12 | 88% | 7 (114 faces) | 356 | 1,525 |
- The UV atlases are confetti: 1,400 to 6,200 islands in one 4096² image. Nothing
is authored in that space. The operator's rebuild gives each hull a clean UV0,
and the Tripo mesh becomes a bake source.
- K-means (CIELAB, a 512² sample) of the albedos:
- Headliner's is colourful. With k = 6, the clusters are dark navy (43% of the
texels), mid grey (21%), beige (13%), lavender (11%), orange (7%) and blue
(4%).
- Quiet's is near-greyscale (a* and b* within ±6): light panels (44% at L 69 to
79), darks and teal accents.
- Within-cluster lightness spread is 3 to 12 L. That spread is baked shading and
detail.
- Probe scripts: `kmeans.py`, `meshstats.py`, `parts.py`, `vcol.py` and
`maskpng.py` in this pass's scratch. Their numbers are quoted here.
**M17. Thruster and radiator mounts are already meshes at runtime.**
- `ShipModShips.Assemble` requires a `MeshRenderer` under both
(`ShipModShips.cs:87-92`, `:128-137`).
- On `80de88f2`, `ships-mounts` hides the thruster's mesh behind the template's
`Invisible` material (`:130`, commit `da2a299b`).
- `ShipInstance.SetEntity` hands that renderer to the exhaust particle system as
its shape (`ShipInstance.cs:68-71`). Unity's documentation says a mesh shape
emits along the surface normal.
- The GLB rule is not checked before Unity. `ShipModCatalog.Bind` checks only that
each anchor's node exists (`ShipModCatalog.cs:176-180`), and
`ship-authoring validate` reads the records alone
(`ShipAuthoringCommands.cs:32-37`).
**M18. Blender saves a byte mask raw.** Probe, Blender 5.2.2: a 4×4 RGBA byte
image whose pixels are `(0.5, 0.25, 1.0, 0.5)`, saved as PNG.
- Tagged Non-Color or sRGB, it is written as 8-bit RGBA (colour type 6) with
bytes `128, 64, 255, 128`. The values are raw, with no transfer applied.
- A float image saves as 16-bit.
- The `.gitattributes` rule `*.png filter=lfs` already covers package masks.
#### The hull material model
*Superseded in part by "Hull materials as owned graphs" below: the mask semantics, `HullPaint`, the shader rules and the livery resolution. Kept as the record of revision 2.*
**What a package carries.**
- **The GLB's core PBR slots,** baked by the operator onto her clean UV0:
- base colour;
- metallic-roughness;
- normal;
- occlusion;
- emissive texture and factor.
Missing slots fall back to factors.
- **`ship.mask.png`, the livery mask:** RGBA8 on UV0, one per hull.
- R, G and B are the weights of the primary, secondary and accent paint. Black
is bare: the GLB's own surface shows.
- A is wear and grime: 0.5 is neutral, toward 1 the paint wears to the base
surface, toward 0 grime gathers.
- She authors it to her standard. The add-on offers starting points: a region
mask baked from albedo clusters, and a wear alpha baked from curvature and AO.
- **Blender material slots become glTF materials.** These are surface kinds, such
as bare metal, glass and lights, each with its own factors.
- **`ShipAuthoring.Paint`** (key 5, a `HullPaint`), typed in the package:
- `Mask`, the mask's relative path;
- the factory `Livery`: three colours, a metallic and smoothness per slot, wear
and grime;
- `RegionLuminance`, each region's mean albedo luminance under the mask, derived
at Package.
Null means an unpainted hull. It renders with the GLB's look, and still fades
and is lit like a prefab hull.
**What the game owns.**
- One shader, `Aetheria/Hull`, built on `GlowFade`'s fade, dither and volumetric
ambient. It samples the mask on UV0. Its rules:
- Painted albedo is the slot colour × saturate(lum(base) / region luminance).
- Wear: where alpha is above 0.5 and a tiling noise is under `_Wear`, paint
chips to the base.
- Grime: below 0.5, `_Grime` darkens and roughens.
- Painted metallic and smoothness come from the slot's finish.
- It has no `shader_feature` keywords, so build stripping cannot drop a variant.
- **Mask import: linear, DXT5, with mips.**
- Linear, because the channels are weights and not colours: an sRGB decode
would turn a 0.5 wear value into 0.21. M18 shows Blender writes the values
raw, so the decode must not apply a transfer either.
- DXT5 (BC3) is a quarter of RGBA32's memory: a 2048² mask with mips is about
5.3 MB instead of 21 MB.
- Its alpha block is the best-kept channel, and the alpha carries wear. Region
edges lose only within a 4×4 block, about 2 cm on a 40 m hull at 2048.
- **`ShipModShips.PreloadAsync`** reads the PNG with `File.ReadAllBytes` and
`ImageConversion.LoadImage` into a linear texture with mips, compresses it, and
makes it non-readable.
- **`HullMaterialGenerator`,** a glTFast `IMaterialGenerator` with one instance
per package, clones `ShipModTemplate.Hull` with the GLB's slots and the hull's
mask. No glTF shader is needed at runtime.
- **`ShipModShips.ApplyPaint`** sets the resolved livery through a
`MaterialPropertyBlock` in `ShipInstance.SetEntity`. Property blocks do not
survive `Instantiate`.
- **Livery resolution** is one pure function, `HullPaint.Resolve`: the owner
faction's `Livery` (`Faction` key 16, cut `ships-hull-livery`) when set, else
the factory livery. The mask and region luminance are always the hull's.
**Role materials and the two mount meshes** (ruling
`thrusters-radiators-are-meshes`):
- **Thruster:** a mesh carrying the invisible material. It is the exhaust's
emission shape, and its normals point aft.
- **Radiator:** a mesh rendered with the template's `Radiator` material, which
`EntityInstance` heats.
- **Weapons** stay point anchors.
**Out of release scope, as follow-ups:**
- `hull-decals`. With a mask texture, painted-in logos can live in the mask; only
separate insignia and damage decals remain.
- `hull-damage-wear`.
- `hull-player-paint`, unless `hull-livery-scope` rules otherwise.
#### Per-hull steps
Ruling `hull-paint-mask-texture` sets the time budget as hers. These are the steps
and who does each.
| Step | The operator | The add-on |
|---|---|---|
| P1 Tripo export | Generate; try PBR and delight once (below) | — |
| P2 New Ship | id, name, reference hull, length | `create --like`; the Tripo mesh moved into `Source` and oriented and scaled; a draft grid rasterised from it |
| P3 Rebuild | Retopologise the nonsense geometry under Ship Root, at its final scale, with her own tools | Rasterise again from her meshes |
| P4 Unwrap | A clean UV0 | Package refuses a mesh without UV0 or with UV0 outside [0, 1] |
| P5 Rebake | Check and fix the result | Bake From Source: Selected to Active from `Source`, albedo (colour only) and normal, wired into her material |
| P6 Mask | Paint the mask to her standard | New Mask; Suggest Regions; Bake Starting Mask; Bake Wear (curvature and AO into alpha); factory colours from the clusters |
| P7 Surface kinds | Material slots for glass and lights | — |
| P8 Mounts | The faces for each thruster and radiator, the cursor for each gun | Emitter normals aft; hardpoint rows on the cells beneath |
| P9 Layout | Internal hardpoints, cell touch-ups | — |
| P10 Package | — | Recentring, collider, map icon, shield, tractor, texture cap, GLB, `ship.mask.png`, region luminance, records, validation |
| P11 Look | Dock, `give`, fly | — |
Dropped with the rebuild:
- `Select Debris` and the mesh report: her new geometry replaces the debris.
- The triangle-budget warning: her topology is her standard.
The texture cap stays because it bounds runtime memory.
**P1, the Tripo export check: a step for the operator to try.**
- Tripo's API defaults to `pbr=true`, which yields base colour, metallic,
roughness and normal maps. Texture version `v3.5-20260815` adds `delight`
(default true).
- The hulls in `Quiet.blend` arrived with base colour only, and why is unknown.
- A delit, PBR source makes Bake From Source's albedo cleaner and gives a
metallic-roughness map to bake too.
- Try one hull on the site and through the API. Nothing waits on it.
#### Cuts
*Revised by "Hull materials as owned graphs" below: `ships-hull-material` r3, `ships-addon-paint` r3 and `ships-hull-livery` r2 replace the revisions named here.*
- **`ships-hull-material` r2:** the shader, the generator, `HullPaint` with
`Mask`, the mask's PNG checks in `Bind`, the linear DXT5 load and `ApplyPaint`.
- **`ships-hull-livery` r1** (unchanged): `Faction.Livery`. It waits on
`hull-livery-scope`.
- **`ships-addon-paint` r2:**
- the mask slot and New Mask;
- Suggest Regions and Bake Starting Mask;
- Bake Wear;
- the UV0 and mask checks;
- the mask export;
- `ship-authoring paint --mask`.
- **`ships-addon-package` r2** (unchanged): `validate` reads the GLB; `Bind`
refuses a thruster or radiator node without a mesh.
- **`ships-addon-frame` r3:** `Source`, rasterising from her meshes, the export
leaving out `Source`, and Bake From Source. No debris tools.
- **`ships-addon-mounts` r3:** face-based thruster and radiator mounts on her
meshes, and no triangle warning.
- **`ships-player` r2** (unchanged): the no-`glTF/`-shader check, radiator
material, emitter probe. `*.png` is already LFS.
Order: `ships-mounts`, then `ships-addon-package`, then `ships-addon-frame`, then
`ships-addon-mounts`. `ships-hull-material` runs beside the add-on cuts after
`ships-mounts`. `ships-addon-paint` needs both lines; `ships-hull-livery` and
`ships-player` need `ships-hull-material`.
#### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Hull paint | `ShipAuthoring.Paint` in the package | Mask path and factory livery written at Package from the panel; region luminance re-derived every Package | `ship-authoring paint` writes it; `ShipAuthoringStore.Validate` judges it |
| Livery mask | `ship.mask.png` in the package, named by `Paint.Mask` | Authored by the operator in Blender; capped copy exported every Package | The operator; `Bind` judges the PNG header |
| Bake source | The `Source` collection in the `.blend` | Imported once from Tripo; never exported | Input to Bake From Source and to the first rasterise only |
| Faction livery | `Faction.Livery` in `Aetheria.cc` | Authored in the inspector; null keeps factory paint | The catalog |
| Hull material | One clone of `ShipModTemplate.Hull` per glTF material | Built at preload with the hull's mask; cache only | `HullMaterialGenerator` |
#### Rationale
**Why a sidecar PNG loaded by `ImageConversion`, not a texture inside the GLB.**
- Inside the GLB, the mask would have to be referenced from `extras`. That means a
post-export rewrite of the GLB (Blender cannot write a texture index into
extras), glTFast's Newtonsoft `ImportAddon` to read it, and load-bearing JSON
where the package's other bindings are typed. Binding by image name instead is
the name-as-authority the assembler is forbidden.
- glTFast has no loose-image entry point (M14). `ImageConversion` is built into the
engine, so the sidecar costs one typed field, one header check and about a dozen
lines of load code.
- Blender saves the image directly (M18).
**Why a generator instead of post-import material swaps.**
- glTFast calls the generator once per glTF material, with the slots already
parsed.
- Swapping afterwards would leave glTF shaders as build inputs and would decide
materials by renderer.
**Why the tint still divides by region luminance.**
- Her rebaked albedo may keep Tripo's colours under painted areas.
- Dividing by each region's mean keeps panel shading at any livery colour without
asking her to author a greyscale albedo. The factory livery's colours,
suggested from the cluster means, reproduce the baked look.
**Why the bake helpers are offered at all.** Selected-to-Active needs a selection
order, a cage or ray distance, the colour-only contributions, new images in the
right colour space and node wiring, and that setup is repeated per hull and per
map. Clustering and the curvature-and-AO alpha are starting points she paints
over. Each `ships-addon-*` operator check asks which helper earned its place.
**Why thrusters duplicate faces but radiators separate them.**
- A thruster's mesh is never drawn, so the hull keeps its nozzle.
- A radiator's faces are drawn with the radiator material. Leaving them in the hull
would draw the surface twice.
### Hull materials as owned graphs
This section designs the hull material system as one piece. It replaces the
mask semantics, the `HullPaint` shape, the shader rules and the livery
resolution of "The hull material model" above. M13 to M18 and the per-hull steps
stand, except P6, which is restated below.
The operator's words, in order:
1. `hull-runtime-materials`: "we want more runtime material control than just a
single baked map. See how MechWarrior does it".
2. `hull-paint-mask-texture`: "I want a mask map per ship for livery, I'll be
rebuilding the nonsense geometry and unwrapping anyway".
3. `mask-channels-place-materials`: "If each channel determines placement of one
material, then factions can fly their colors by setting parameters on those
hull materials (primary, secondary, trim) and we'd ship eventual faction skins
as their own material map... If we also want procedural wear and buildup, we
can bake input maps for that, too."
4. `clean-break-from-substance`: "Let's make a clean break from substance, it's
dead".
5. Not yet a ruling: "Authoring materials as graphs in blender is pretty
intuitive, if we're owning material generation, could we not set things up so
that we can evaluate the same graph?"
6. Not yet a ruling: "We may not be allowed to pull blenders math into our
runtime, but nothing prevents us from putting our math in blender".
7. `materials-baked-per-ship-incremental`: "triggering texture bakes per ship
only when they spawn or change, and then also only recomputing the parts of
the graph that change, which would be just wear for most dynamic changes".
8. `shaders-own-every-pixel`: "our shaders own every pixel".
Surveys: `F:\Projects\blender-graph-runtime-prior-art.md`,
`F:\Projects\aetheria-texture-graph-prior-art.md` and
`F:\Projects\aetheria-hull-materials-prior-art.md` (Eyes, 2026-10-03). Anchors are
against Aetheria `239f3599` (origin/master, which contains `ships-mounts` and the
render fixes) and CultLib `a7966142`. Probe scripts are in this pass's scratch
(`matprobe/probe_bake.py`, `probe_dump.py`, `bench/Program.cs`).
#### Body facts
**M19. The operator's Blender is 5.2.2 LTS from Steam, and Steam updates it.**
- `C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe` reports
`5.2.2 LTS`, build hash `d13f752e3b9c`, built 2026-09-15. It is the only
Blender in the uninstall registry. A stale 5.0.1 sits in
`D:\Steam\SteamApps\common\Blender`.
- Steam replaces the build when a new one ships. Node semantics changed in 5.0
(Voronoi hashing), so a silent update can move her previews away from the game.
**M20. A Cycles Emit bake is an exact point oracle.** Probe `probe_bake.py`,
Blender 5.2.2, a 64×64 float input image of coordinates read with Closest
interpolation into a graph, baked by Emit into a 64×64 float image (margin 0):
| Probe | CPU | OptiX (GTX 1070) |
|---|---|---|
| P1 identity: the input texel reads back | 4096/4096 exact | 4096/4096 exact |
| P2 `a*b + c` in two Math nodes | 4096/4096 equal float32 `(a*b)+c`; fused would differ at 1011 | same |
| P3 CultMath's float-only `mod289((x*34+10)*x)` as Math nodes | 4096/4096 exact | 4096/4096 exact |
| P4 Noise Texture (3D, detail 0): two bakes | bit-identical; 0.5 at integer points | bit-identical |
| P5 an OSL Script node (`In[0]*3+1`) in the bake | 4096/4096 within 1e-6 | no result: killed after 4.5 minutes, apparently compiling OSL |
- CPU and OptiX noise agree at 2,764 of 4,096 texels, at most 1.19e-7 apart
(1 ulp near 1). Arithmetic agrees bit for bit. So the oracle is CPU Cycles,
and noise parity is a ULP tolerance, never bit equality, once a GPU is in the
chain.
- Cycles' CPU kernels do not contract multiply-add (P2), so float-only
arithmetic is reproducible from C#.
**M21. The add-on can dump a node group faithfully.** Probe `probe_dump.py`,
Blender 5.2.2:
- Group interfaces have panels. A socket reports its panel, type, default,
min, max and a stable identifier (`Socket_3`). Panels are typed structure, so
a socket's scope (Livery, Ship, Maps) needs no name parsing.
- `ColorRamp.evaluate(t)` and `CurveMapping.evaluate(curve, t)` run in Python,
so ramps and curves export as sampled tables in Blender's own evaluation (a
B-spline ramp gives 0.1667, not 0, at t = 0).
- A group instance is `ShaderNodeGroup` with `node_tree`. Mix exposes
`data_type`, `blend_type` and both clamps. Noise exposes dimensions, type and
normalize. Math has 41 operations.
- Shader nodes have no Bit Math or Integer Math in 5.2.2.
`FunctionNodeBitMath` exists for geometry nodes only. PCG hashes cannot be
built in shader nodes.
**M22. A C# tape interpreter is cheap for arithmetic and slow for noise.**
Probe `bench`, .NET 10 RyuJIT on Starfire (Ryzen 3 3100, 4 cores), a scalar SSA
tape interpreted over 256-texel batches at 2048²:
- 154 arithmetic instructions: 788 ms on one thread (1.2 ns per
instruction-texel).
- Adding 24 CultMath `snoise(float3)` calls: 23.5 s on one thread, 7.1 s on four
(about 230 ns per noise call per texel).
- Unity runs Mono, which is slower than RyuJIT. That was not measured.
A full-resolution noise graph costs seconds per bake on the CPU. Aetheria
already dispatches compute shaders at runtime (M23), where the same work takes
milliseconds.
**M23. Aetheria's compute and CultMath substrate.**
- Runtime compute already ships: `ShieldPanel.cs:293-439` dispatches
`ShieldSim.compute`; Stardust, Slime and Lightning have compute kernels under
`Assets/Shaders/Compute/`.
- `Packages/manifest.json:57` pins `org.gamecult.cultmath` at
`cultmath-unity-v0.2.4`. CultLib has tagged `cultmath-unity-v0.3.0`.
`Aetheria.Shared.csproj:27` compiles CultMath from source.
- No Shader Graph or SRP package is in the manifest.
**M24. Faction key 16 is taken twice.** `ships-hull-livery` r1 puts `Livery` at
`Faction` key 16. Rulings `faction-relations-field` and `faction-doctrine-typed`
put Relations at 16 and Doctrine at 17. `Corporations.cs` uses keys up to 15.
The livery takes key 18.
**M25. CultMath's seams for new shader code.**
- `CultMath.hlsl:509-510` includes Phacelle and Interval.
- `GlslLowering.SeparateFiles` (`GlslLowering.cs:19-24`) lowers a
separately-licensed include into its own GLSL file. Its one entry is MPL-2.0
Phacelle.
- C# has `asuint` and `asfloat` (`math.cs:390-391`) but no `uint2/3/4` types.
Cycles' hashes are scalar `uint`, so they port without new vector types.
#### The system in brief
```
Blender (author, preview) AetherDb (judge) Aetheria.cc
node group "Hull Standard" --> material-graph put ----------> MaterialGraph record
+ per-hull wrapper material whitelist, contract, cones (truth)
|
v lowering (Aetheria.Shared)
CultMath expression IR --> HLSL kernels: pair, frontier, wear
| (generated, committed, dxc-checked)
v reference evaluator (C#, tests only)
Unity: MaterialBakes -- dispatch per (hull, livery) and per wear set --> baked textures
Aetheria/Hull -- samples Surface, Worn, Wear, plus the GLB's normal, occlusion, emission
```
**A material graph is one per-texel function:**
- **Inputs:**
- image roles sampled on UV0 from two package PNGs:
- `ship.mask.png`: R, G, B place the primary, secondary and trim materials; A is
an authored wear hint.
- `ship.maps.png`: R ambient occlusion, G curvature (0.5 flat), B cavity, A grunge.
- parameters in two scopes. Livery parameters are set per faction, with a
factory default per hull. Ship parameters are set per ship, such as wear level.
- **Outputs**, a closed vocabulary:
- Surface Color, Surface Metallic and Surface Roughness: the finished livery;
- Worn Color, Worn Metallic and Worn Roughness: what wear reveals;
- Wear, from 0 to 1.
**Authoring in Blender.**
- A library `.blend` holds node groups built from whitelisted nodes. The interface
panels are `Livery`, `Ship` and `Maps`. A Maps socket's name is a role from the
closed vocabulary.
- The per-hull material her add-on sets up is a wrapper:
- the group instance;
- Image Texture nodes for her mask and maps;
- a preview that mixes Surface and Worn by Wear into a Principled BSDF, the same
combination the game's shader makes.
- The wrapper is display only. Only the group is exported.
**The release whitelist** is Blender's primitive and converter nodes, with
Cycles semantics ported into CultMath:
- Math (all 41 operations), Vector Math (component-wise operations), Clamp,
Map Range (Float, all four modes);
- Mix (Float, Vector, and Color with every blend mode);
- Color Ramp and Float Curve, as tables sampled in Python;
- Separate and Combine XYZ and Color (RGB mode), RGB to BW, Invert;
- Value, RGB, Reroute, Frame, Group Input and Output, and nested groups (flattened).
Procedural textures (Noise, White Noise, Voronoi, Wave, Gradient) follow the
release, as question `release-procedural-noise` proposes. Per ruling 3, release
wear comes from baked maps. Grunge is baked into `ship.maps.png` A.
**Bakes, dirty sets and the memory budget.**
- The judge computes each output's cone (the nodes it reads) and each node's
scope: hull (reads only images), livery, or ship. Surface and Worn cones may
not read a Ship parameter; the judge refuses that. So every per-ship cost is
the Wear output alone.
- The **frontier** is the set of values in Wear's cone that read no Ship
parameter but feed one that does. At most four scalars, or the judge refuses
the graph, so they fit one RGBA16F texture.
- Three kernels per graph, generated from the record:
- **pair**: Surface at R and Worn at R/2, for each (hull, skin, livery). R is
2048 by default and 1024 at low quality.
- **frontier**: the frontier values at wear resolution, once per pair.
- **wear**: only the ship-scope nodes of Wear's cone, reading the frontier,
per distinct set of Ship parameters.
- A Ship parameter change re-runs only the wear kernel. That is ruling 7: cost
in proportion to the nodes that changed. A livery change is a different pair.
- Caches are content-addressed. A pair key hashes the graph record, both PNGs,
the skin, the livery bindings and R. A wear key hashes the pair key and the
Ship parameter values, quantised to 1/64. Ships with identical inputs share
every texture. Entries are refcounted by live bodies and released at zero.
- Budget, at R = 2048, with BC1 (DXT1) through `Texture2D.Compress` after an async
readback:
| Texture | Per | Size with mips |
|---|---|---|
| Surface Color and Surface MR (R metal, G roughness) | pair | 2 × 2.7 MiB |
| Worn Color and Worn MR at 1024² | pair | 2 × 0.67 MiB |
| Frontier, 512² RGBA16F, no mips | pair | 2 MiB |
| Wear, 512² R8 | wear set | 0.33 MiB |
- That is about 8.7 MiB per pair and 0.33 MiB per ship. At R = 1024 a pair is
about 2.6 MiB.
- A zone with 20 ships in 6 (hull, livery) pairs holds about 59 MiB. Per-ship
full bakes would hold about 320 MiB.
- Each bake also needs about 70 MiB of transient upload and render textures,
released once its readback completes.
- Until the compressed copy exists, the ship samples the render textures directly.
**The runtime shader.**
- `Aetheria/Hull` is `GlowFade`'s surface shader: the same `noambient` volumetric
ambient, the same dither fade by `_Fade` and the same edge colour.
- The albedo, metallic and roughness are `lerp(Surface, Worn, Wear)`. The normal,
occlusion and emission come from the GLB's slots.
- It has no paint logic, no keywords beyond GlowFade's own, and nothing evaluated
per frame but that one lerp. The lerp is fixed by the output contract.
**The livery.**
- `HullPaint` (ShipAuthoring key 5) names:
- the graph;
- the two PNGs;
- the factory livery: bindings of the graph's Livery parameters.
- `Faction.Livery` (key 18) holds bindings of the same parameters and wins over the
factory livery when that faction flies the hull. `HullPaint.Resolve` is the one
resolution.
- A binding names a parameter by its interface identifier. The catalog test checks
every binding against the graph: it must exist, be in scope and be in range.
- Faction skins (another mask per hull) are a later key on `HullPaint`, chosen by
the livery.
#### Authority map
- **Owner.** The `MaterialGraph` record in `Aetheria.cc` owns what a hull material
computes. CultMath owns what each node means: Cycles semantics ported once, in
HLSL with a C# mirror. `MaterialBakes` owns when and at what resolution a graph
is evaluated. `HullPaint.Resolve` owns which livery a body shows.
`Aetheria/Hull` owns how the three outputs and the GLB slots become a pixel.
- **Inputs.**
- The judge reads the Blender dump.
- The lowering reads the record.
- `MaterialBakes` reads:
- the generated kernels;
- the package PNGs;
- the resolved livery;
- the body's Ship parameters.
- **Outputs.** The record; the generated kernels; the baked textures per pair and
per wear set; one property block per hull renderer.
- **Derived state.**
- The kernels are derived from the record and committed. A test fails when they
differ from the lowering.
- Baked textures are cache only.
- The Blender wrapper and its preview are display only.
- Cones, scopes and the frontier are recomputed by every judge and lowering.
- **Forbidden writers.**
- Unity Shader Graph, Mixture and any glTF/ shader on a mod hull.
- Any per-frame graph evaluation in the hull shader.
- Python encoding a `MaterialGraph` or `HullPaint`.
- Blender's GPL EEVEE (`gpu_shader_material_*`) or blenlib noise code in any
CultLib or Aetheria file.
- A material, role or output chosen by a name outside the closed vocabularies.
- A second livery resolution.
- `Faction.PrimaryColor` and `SecondaryColor`, which stay map colours.
- **Shared paths.** Editor play, the preview smoke, the play smoke and the built
player all bake through `MaterialBakes` from the same kernels. Every AetherDb
path (`put`, `check`, the catalog test) judges through one `MaterialGraphJudge`.
- **Deletion line.**
- r2's region tint (`_PaintStrength`, `RegionLuminance`, a tiling `_WearNoise`)
and its `Livery` of three colours are never built.
- Revision 1's vertex mask is gone.
- The 41 `.sbsar` files go with follow-up `remove-sbsar-files`.
#### Parity
1. **Node semantics, in CultMath.**
- Each ported function's HLSL mirrors its C# with bit equality, through the
existing mirror tests.
- It compiles under dxc and lowers to GLSL through the existing
`GlslLowering`. Each new licence file gets a `SeparateFiles` entry.
- The C# matches a **Cycles oracle fixture**. A GPL-headed tool script, run only
under the pinned Blender (5.2.2 LTS, hash `d13f752e3b9c`), bakes each family
on the CPU the M20 way. It refuses any other version. The fixture records the
version and hash.
- Tolerance is per family: exact for arithmetic (M20 P2, P3), and a stated ULP
bound wherever Cycles uses library transcendentals.
2. **Lowering, in Aetheria.**
- A test graph uses every whitelisted node and blend mode. The add-on's test
script dumps it and bakes all seven outputs on a 64² fixture under the pinned
Blender. The fixture (dump, inputs, outputs) is committed.
- The test lowers the dump and runs CultMath's reference evaluator, then
compares per output within the family tolerances.
- This pins the node-to-function mapping. Neither side's unit tests can see that
mapping.
3. **Kernels.**
- The generated HLSL is committed and must equal the lowering's output.
- Each kernel compiles under dxc.
- A Unity batch smoke on Starfire dispatches the fixture graph on the GPU and
reads back. It compares with the reference evaluator within 2 ulp plus the
family tolerance. M20 shows a GPU and a CPU Cycles already differ by 1 ulp.
4. **Drift.**
- The add-on stamps the Blender version into each dump.
- The judge refuses a dump from a different major and minor version than the
pinned semantics, naming both.
- Updating Blender is a deliberate cut: regenerate the oracle fixtures, read the
diff, and move the pin.
- Before then, the operator stops Steam updating Blender, or keeps a pinned
portable 5.2.2 for authoring.
#### The tape
The material evaluator is not a second tape if it owns no evaluation semantics.
- It lowers into CultMath's scalar expression IR: SSA values, operations that
are CultMath functions, named inputs, parameters and several outputs, with each
instruction's input-dependence mask.
- Two backends exist now: an HLSL emitter, and a reference evaluator for tests.
- The tape target's step 2 adds bytecode with point, interval and gradient
evaluators over the same IR. Step 4 adds an HLSL interpreter of that bytecode.
Neither is built now. Ruling `tape-target-unparks-when-asura-stable` stands
for both.
- What lands now is the IR the tape will consume. Its point evaluator is only a
test reference, not a runtime path.
A minimal evaluator is needed now, because ruling 7 requires bakes at spawn and
at change, and the release hulls need faction colours (`content-bar-one-per-concept`).
Mod-authored graphs need the tape's GPU interpreter, because Unity cannot compile
a shader at runtime. Until then a mod package uses the shipped graphs with its
own PNGs and livery values: the MWO model, per the operator's ruling 3.
Question `material-ir-in-cultmath` asks whether this IR may land in CultMath
while the tape stays parked.
#### Cuts
CultLib, campaign `cultmath-tapes`:
- **`cycles-converters`.** The Cycles oracle tool and fixture harness. Ports of
Cycles' Math, Map Range, Clamp, Mix (every blend mode), RGB to BW and ramp-table
lookup, in an Apache-2.0 file pair.
- **`expr-ir`.** The scalar expression IR, dependence masks, cones and frontier,
the reference evaluator and the HLSL emitter.
- **`cycles-noise`.** Ports of Cycles' hash, Perlin, fBM, the Noise Texture node
and White Noise (BSD-3 and Apache file pairs). This follows the release.
- The CultMath Unity release that ships the new HLSL is a release cut (follow-up
`cultmath-unity-material-release`). Aetheria's Unity pin must move to it before
`hull-bakes`, whose kernels include those files.
Aetheria, campaign `aetheria-release`:
- **`material-graph`.** The record, the dump, `MaterialGraphJudge` (whitelist,
vocabularies, scopes, the contract, the version pin), and AetherDb
`material-graph put|check`.
- **`material-lowering`.** Lowering to the IR, the generated kernels and their
equality test, and the whole-graph Blender oracle.
- **`material-library-export`.** The add-on's dumper, Export and Check calling
AetherDb, and the library `.blend` convention.
- **`hull-bakes`.** `MaterialBakes`: dispatch, the content-addressed tiers,
refcounts, readback and compression, and a GPU parity smoke.
- **`ships-hull-material` r3.** `HullPaint` reshaped, the PNG checks in Bind,
`Aetheria/Hull`, the generator and `ApplyBakes` from `SetEntity`.
- **`ships-addon-paint` r3.** The maps bake into `ship.maps.png`, the mask tools,
Set Up Hull Material (the wrapper), and Package writing both PNGs and `Paint`.
- **`ships-hull-livery` r2.** `Faction.Livery` at key 18, as bindings checked
against the graph.
Order:
- `cycles-converters`, then `expr-ir`, then the CultMath Unity release.
- Then `material-graph` (it may start at once; it needs neither CultLib cut),
`material-lowering`, `hull-bakes`, `ships-hull-material` r3 and
`ships-hull-livery` r2.
- `material-library-export` follows `material-graph`.
- `ships-addon-paint` r3 needs `ships-addon-mounts`, `material-library-export`
and `ships-hull-material` r3.
- `ships-player` keeps its dependency on `ships-hull-material`.
**What blocks the release hulls:** every cut above except `cycles-noise`.
**What can follow:**
- `cycles-noise` and Voronoi, Wave and Gradient;
- CultMath-native nodes in Blender;
- the tape's GPU interpreter for mod graphs;
- faction skins;
- follow-ups `hull-damage-wear` (the first real Ship parameter), `hull-decals`
and `hull-player-paint`;
- detail normals per material;
- a GPU BC encoder, if the readback-and-compress hitch measures badly.
#### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Material graph | `MaterialGraph` in `Aetheria.cc`, keyed by its stable id (the group's name lowered, as ship ids are) | Exported from the library `.blend` by `material-graph put`. Re-exported whole on change, never edited in place. The kernels are regenerated in the same command | `MaterialGraphJudge` admits it. The record is truth and the kernels are derived |
| Hull paint | `ShipAuthoring.Paint` (key 5) in the package | Written at Package. A graph id, two PNG paths and the factory livery bindings | `ship-authoring paint`; Validate, and the catalog test for bindings |
| Mask and maps PNGs | `ship.mask.png`, `ship.maps.png` in the package, named by `Paint` | Authored and baked in Blender; exported every Package | The operator. `Bind` judges the headers |
| Faction livery | `Faction.Livery` (key 18) | Authored in the inspector; null keeps the factory livery | The catalog; checked against the hull's graph |
| Pair bake | Content hash of graph, PNGs, skin, livery and R | Baked when the first body needs it; released when the last is gone | `MaterialBakes`. Cache only |
| Wear bake | Content hash of the pair key and quantised Ship parameters | Re-baked when a body's Ship parameters change | `MaterialBakes`. Cache only |
| Cycles oracle fixture | A file per family in CultMath tests, stamped with the Blender version | Regenerated only by a pin-moving cut | The tool script under the pinned Blender |
#### Rationale
**Why generated compute kernels and not a CPU interpreter.** M22 puts a
noise-bearing full-resolution bake at seconds on the operator's CPU, under a
faster JIT than Unity's. Aetheria already ships runtime compute (M23). The graphs
are game-owned, so kernels can be generated ahead of time and compiled into the
build, where a runtime shader compile is impossible. The cost is that mods cannot
bring graphs until the tape's GPU interpreter exists. That is question
`material-evaluator-backend`.
**Why the per-ship cost is the Wear output alone.** Prior art bounds memory one of
two ways:
- a shared base with a small per-instance mask, as in Destiny's gearstack wear
mask, MW5's wear masks and Star Citizen's vertex-alpha wear;
- a cap on instance count.
Making it a rule of the output contract, enforced by the judge, makes the budget
structural rather than a convention. The lerp in the shader is the contract's one
fixed line, so the shader owns no material meaning.
**Why the frontier is cached at wear resolution.** Ruling 7 asks that a wear change
recompute only what depends on wear. Caching every node at full resolution is
Mixture's documented failure: memory linear in node count. One RGBA16F texture at
512² per pair costs 2 MiB and makes a wear bake a few dozen instructions over
262k texels.
**Why Cycles semantics are ported, not reimplemented from the manual.**
- The manual leaves Mix's blend modes, Map Range's smooth modes, ramp
interpolation and Smooth Min unspecified (Eyes §3d).
- The Cycles kernel files are Apache-2.0, and `noise.h` is BSD-3.
- Porting them into licence-headed CultMath files, as Phacelle is kept under
MPL-2.0, gives the exact semantics with provenance.
- Ramps and curves need no port: Python samples Blender's own evaluation into
tables (M21).
**Why the oracle is a CPU bake.** It is exact for arithmetic and reproducible
(M20). A GPU bake differs by an ulp on noise, and OSL on OptiX did not finish.
**Why the record holds the authored graph, not the IR.** The lowering will change
as ports are added and the tape arrives. The authored graph is what she made, and
the IR is a cache of one lowering of it.
**Rejected.**
- Unity Shader Graph: its Lit target adds Unity's ambient, and ruling 8 makes our
shaders own every pixel.
- Mixture: it adds Shader Graph and SRP Core, and its memory grows with node count.
- Material Maker: a second authoring application with no runtime parameter path.
- MaterialX: it has no HLSL generator, Blender's export is partial, and its noise
is not Blender's.
- OSL as an authoring route: Cycles only, no EEVEE preview, and no result on OptiX
(M20 P5).
- Region-luminance tint (r2): every channel now places a whole material.
### Ship data all in the `.cc`
The operator, 2026-10-03, admitted as ruling `ship-data-all-in-cc`: "I want all
the data to live in the .cc. there's no reason our schemas can't hold everything
we need and it means we benefit from the cross runtime guarantees CultLib
offers". It restates MQ1 (2026-09-30), "a ship .cc holds everything", which the
GLB path contradicted.
This section moves the hull's geometry, node tree, surfaces and images out of
`ship.glb` and the planned PNG sidecars into one typed record in `ship.cc`. It
changes the package format, the Package action, the validator, the runtime
loader and five specs. Anchors are against Aetheria `origin/master` `4895c752`
and CultLib `main` `a7966142`, read on 2026-10-03.
#### Body facts
**M26. The GLB is read in three places, and only for its scene graph and
meshes.**
- The add-on exports it at Package with `export_extras` and `export_yup`
(`aetheria_ships/__init__.py:343-366`, called at `:385`). The anchors and the
layout already go into the `.cc` (`:386`, `ship_cc.py:116-140`).
- `ShipModCatalog.Bind` reads only the JSON chunk's node `extras` and whether each
node names a mesh (`ShipModCatalog.cs:168-218`, through Newtonsoft). AetherDb
`validate` reaches it through `ReadPackage` when the file exists
(`ShipAuthoringCommands.cs:34-45`).
- `ShipModVisual.LoadAsync` imports it with glTFast and maps anchors to node
indices (`ShipModVisual.cs:33-60`). The assembler then reads three things per
node (`ShipModShips.cs:85-120`):
- the `MeshRenderer`, for the map icon, the thrusters and the radiators;
- the readable `MeshFilter` mesh, for the hull collider;
- the shield marker's local scale.
- glTFast has no other consumer:
- `ShipModVisual.cs` is its only `using GLTFast`, and `Packages/manifest.json:9`
is its only pin.
- `ShipFixture.cs:29-85` hand-builds a GLB for the tests.
- `ShipModPreview.cs:28,125` goes through `LoadAsync`.
**M27. No package is committed.**
- `git ls-tree` of `4895c752` holds no `ship.cc`, `.glb` or `GameData/Mods` path.
Only the operator's local drafts exist, and Package landed today (`7d649d5c`).
- `.gitattributes` puts `GameData/*.cc` and `*.png` in LFS.
`GameData/Mods/<id>/ship.cc` matches neither rule.
**M28. CultCache sets no record size limit, but the single-file store works on
the whole file.**
- C#: `SingleFileBackingStore` reads the snapshot whole and deserializes every
record (`CultCache.cs:3290-3320`). It writes through a temp file and
`File.Replace`. It refuses a schema that is not registered (`ToStoredDocument`,
`:3209`). So every reader of a ship file must register every record type in it.
- Python: the MessagePack store's `push` pulls the whole store, replaces one
envelope and rewrites the file (`stores.py:103-121`). A record it did not
touch is carried as raw bytes.
- The binding limits are MessagePack's bin32 (4 GiB per value) and .NET's 2 GiB
array. A ship file at tens of MiB is far inside both. The real cost is that
every Save Layout rewrites the whole file.
- When Python writes a record kind first, it gets `_default_catalog_entry`: an
empty member list (`stores.py:356-368`). So Python may only replace payloads of
records whose catalog entry C# wrote. That matches the standing rule that
Python never builds a `HullData`.
**M29. CultLib's typed mesh is a chunk payload, not a model.**
- `GameCult.Geometry` has `CultGeometryTriangleMesh`: positions, normals, UVs,
indices and a material per triangle (`CultGeometryDocuments.cs:370-395`).
- It has no tangents, no submesh ranges, no node tree and no images.
- Its fingerprint keys the `chunk_artifact` record, so a field added to it
changes chunk keys.
- It has no Python, TypeScript or Rust mirror.
- `GameCult.Geometry.dll` is not in the CultLib Unity package
(`unity/org.gamecult.cultlib/Runtime/Plugins`).
**M30. Blender's conventions already line up with the runtime.**
- `ShipPolyline` stores Blender's right-handed Z-up space, and the runtime
converts with (−x, z, −y) (`ShipModVisual.cs:85-112`).
- Blender's UV origin and `Image.pixels` row order are bottom-left, and so are
Unity's mesh UVs and `Texture2D.LoadRawTextureData`. glTF is top-left, which is
why the exporter and glTFast each flip V.
- Both facts come from the documentation. A probe in `ships-cc-model` proves
them.
#### The design in brief
```
Blender collection ship.cc (one file, the only truth)
objects, evaluated meshes --+ HullData mod-hull:<id> (C# writes; Python edits layout)
material slots, images +--> ShipAuthoring mod-ship:<id> (anchors, lines, later Paint)
(Package, Python, raw slots) | ShipModel mod-model:<id> (nodes, meshes, surfaces, images)
|
AetherDb ship-authoring +-- seed-model (C# writes the record's catalog entry), validate
Unity preload: ShipModel -> GameObjects, Mesh, Texture2D (no importer)
Compose: HullData + ShipAuthoring into the derived catalog; ShipModel stays in the package
```
**`ShipModel`** is `[CultDocument("aetheria.ship_model", "1")]`, stored at
`mod-model:<id>`.
- One record holds the whole body.
- Its space is Blender's: right-handed, Z up, metres, UV origin bottom-left.
| Key | Member | Type | Meaning |
|---|---|---|---|
| 0 | `Id` | string, `[CultName]` | The ship id |
| 1 | `Nodes` | `List<ShipNode>` | Parents before children |
| 2 | `Meshes` | `List<ShipMesh>` | Indexed by `ShipNode.Mesh` |
| 3 | `Surfaces` | `List<ShipSurface>` | Indexed by `ShipSubmesh.Surface` |
| 4 | `Images` | `List<ShipImage>` | Indexed by the surface and paint slots |
- **`ShipNode`:**
- `Id`: the node's `aetheria.id`, or null for an unanchored render node;
- `Parent`: an index, or −1 for the root;
- `Translation` (float[3]), `Rotation` (float[4], xyzw) and `Scale` (float[3]);
- `Mesh`: an index, or −1 for none.
- **`ShipMesh`:**
- one typed array per attribute: `Positions` (float[3n]), `Normals` (float[3n]),
`Tangents` (float[4n], w is the bitangent sign) and `Uv0` (float[2n]);
- `Indices` (uint[]);
- `Submeshes`, each with `IndexStart`, `IndexCount` and `Surface`.
Triangles only. Bounds are not stored, because every runtime computes them.
- **`ShipSurface`:**
- `Name`: the Blender material's name, for display;
- `BaseColor`: an image index, or −1;
- `BaseColorFactor` (float[4]).
`ships-hull-material` r3 appends the slots its shader reads, such as the normal
map and emission.
- **`ShipImage`:**
- `Width` and `Height`;
- `Format`, an enum open to later members: `Rgba8Srgb` and `Rgba8Linear` now,
plus the block formats if question `ship-image-encoding` rules them;
- `Levels` (`List<byte[]>`): mip 0 first, rows bottom to top, tightly packed.
An image has no role of its own: the slot that names it gives it one.
- `ships-addon-paint` r3 appends `Mask` and `Maps` to `ShipModel` as image
indices. They are not PNG files.
**`ShipAuthoring`.**
- Key 2 (`ModelAsset`) is retired and never reused, as key 1 was.
- Nothing in it names the model. The model sits at its deterministic key beside
the hull and the visual, and `LoadRecords` already enforces the keys of those
two.
- A file that still carries key 2 loads with no model record, so it is a draft.
Nothing is lost silently, because the GLB beside it was never the truth.
**Why the model stays out of the derived catalog.**
- `Compose` rewrites `Aetheria.modded.cc` every boot (M28). Copying ten hulls at
30 to 80 MiB each into it would rewrite hundreds of MiB per boot.
- So `Compose` keeps writing only the two semantic records.
- `PackageOf` opens `<modsRoot>/<id>/ship.cc` read-only and takes `ShipModel`
from it. The game reads the body from the same file the operator authored.
**Writer: Package, with no GLB.**
1. `AetherDb ship-authoring seed-model <ship.cc>` adds an empty `ShipModel` at
`mod-model:<id>` when none is there.
- It is idempotent.
- C# writes the catalog entry, so Python only ever replaces a payload (M28).
- Drafts made before this cut take the same path, so no migration branch is
needed.
2. Python packs the body by slot in `ship_cc.replace_model`, pinned like the
anchor and hardpoint slots, and pushes it. The add-on builds the body:
- **Nodes** come from the bound collection, leaving out `Source` and Grease
Pencil. A node's TRS is relative to its nearest ancestor in the collection.
- **Meshes** come from the evaluated depsgraph, with modifiers applied:
- numpy, which Blender bundles, splits corners into vertices by position,
normal, tangent and UV;
- `calc_tangents` runs on UV0;
- each material slot becomes a submesh.
- **Surfaces** come from the material slots, and **images** from the images
those slots use:
- capped at 2048 (the cap moves here from `ships-addon-mounts`);
- read with `pixels.foreach_get`;
- written as RGBA8 in the image's colour space.
- The body is packed with `msgpack.packb(..., use_single_float=True)`. Without
that flag each float costs 9 bytes instead of 5.
3. `replace_visual` writes the anchors only.
4. `AetherDb ship-authoring validate` judges all three records.
**Reader: Unity builds from records.** `ShipModVisual.Load` becomes synchronous.
- It makes a `GameObject` per node and converts TRS with the line mesh's
(−x, z, −y). That conversion moves into one `ShipSpace` helper shared by lines
and meshes.
- For each mesh it builds a `Mesh`:
- UInt32 indices above 65,535 vertices;
- positions, normals and tangents converted the same way;
- tangent w negated and each triangle's winding reversed, because the map is a
reflection;
- UVs passed through;
- the mesh kept readable for the collider.
- For each image it builds a `Texture2D` with `LoadRawTextureData`, generates
mips, runs `Compress`, and makes the texture non-readable.
- Until `ships-hull-material` r3, every surface takes one interim template
material with its base colour. r3 replaces it with `Aetheria/Hull`.
- These go: glTFast, `UninterruptedDeferAgent`, the `Nodes` instantiator
subclass, `NodeIndices` and `ModelPath`.
**Validation moves onto records.** `ShipAuthoringStore.Validate(hull, ship,
model)` is the one judge. `model` is null for a draft. It checks:
- node ids are unique, and every anchor's `ModelNodeId` names one;
- parents come before children, and every index is in range;
- each `thruster-emitter`, `radiator-mesh`, `map-icon` or `hull-collider` node
has a mesh. Today the first two are checked at `Bind` and the last two only in
Unity;
- the streams:
- their lengths match the vertex count;
- every float is finite;
- every index is below the vertex count;
- submesh ranges lie inside the index buffer;
- the images:
- each level is sized exactly for its format;
- no side is over 2048;
- each image is in a colour space its slot allows.
`Bind` keeps the directory and refs checks, and loses `ReadNodeIds`. AetherDb
`validate` always reads records, and says "draft, no model yet" when the model
is missing.
**Sizes.** These are estimates for Headliner (50,008 triangles).
`ships-cc-package` measures them.
| Part | Raw RGBA8, mip 0 | Block formats with mips |
|---|---|---|
| Mesh: ~30k vertices × 12 floats at 5 bytes, plus indices | ~2.3 MB (9.5 MB if every triangle were split) | same |
| One 2048² image | 16 MiB | 5.3 MiB (BC7 or BC5) |
| First cut: base colour only | ~19 MiB | ~8 MiB |
| After paint and r3: base, normal, emission, mask, maps | ~83 MiB | ~29 MiB |
- No CultCache limit binds here (M28).
- What matters is that every Save Layout rewrites the file. At 80 MiB that is
roughly a second in Python, against milliseconds today. That is one input to
question `ship-image-encoding`.
- `GameData/Mods/*/ship.cc` needs an LFS rule (M27). `ships-cc-package` adds it,
so `ships-player` no longer has to.
#### Authority map
- **Owner.**
- `ship.cc` owns everything about a mod ship.
- `ShipModel` owns its body: nodes, meshes, surfaces and images.
- `ShipAuthoringStore.Validate` is the one judge of all three records.
- C# owns every schema and catalog entry. Python (`ship_cc`) is a pinned writer
of payloads that C# seeded.
- **Inputs.**
- Package reads the bound collection's evaluated meshes, materials and images.
- The validator reads the three records.
- `PackageOf` reads the package's `ship.cc`.
- `ShipModVisual` reads the `ShipModel` record.
- **Outputs.**
- The records.
- The prototypes' GameObjects, `Mesh` and `Texture2D`. These are cache only and
rebuilt every boot.
- **Derived state.**
- Bounds, the Unity-space conversion, mips and compressed textures are derived
at load.
- The `.blend` is the operator's source. It never decides a semantic.
- **Forbidden writers.**
- Any GLB, glTF or PNG file in a package.
- glTFast, or any other importer, on the mod-ship path.
- Newtonsoft reading a package.
- Python writing a record kind that C# has not seeded.
- A second validator, in Python or in Unity.
- `Compose` copying `ShipModel` into the derived catalog.
- **Shared paths.** Each of these reads one `ShipModel` through one `Validate`
and one `ShipModVisual.Load`:
- the add-on's Package;
- AetherDb `validate` and `compose`;
- the Editor preview and smoke;
- the play smoke;
- the built player.
- **Deletion line,** before the reader lands:
- `_export_glb` and `MODEL_ASSET`;
- `ModelAsset` and `MODEL_ASSET_SLOT`;
- `ReadNodeIds` and its Newtonsoft use;
- `Package.ModelPath` and `NodeIndices`;
- the `GltfImport` path and the glTFast pin;
- `ShipFixture.Glb` and `GlbJson`, and the GLB-shape tests
(`ShipModCatalogTests.cs:107-140`).
#### Questions
**`ship-model-owner`: who owns the model schema?**
- **a. Aetheria, now,** as `aetheria.ship_model`. It is shaped after glTF so that
a later lift into CultLib is a schema move with a migration. The lift happens
when a second consumer needs it (follow-up `cultlib-model-schema`).
- **b. CultLib, now:** `GameCult.Geometry` gains `gamecult.geometry.model`, ships
it in the CultLib Unity package, and Aetheria wraps it.
- **c. Extend `CultGeometryTriangleMesh`.** That changes chunk keys (M29) and
still has no node tree or images. Not recommended.
- **Recommendation: a,** with medium confidence.
- The guarantees the ruling names are CultCache's: typed envelopes, the schema
catalog, slot parity across runtimes, and Python writing pinned slots. They
hold for an Aetheria record exactly as they do for a CultLib one.
- A CultLib model type would have to serve a general consumer. That means more
UV sets, colours, skinning, morph targets and instancing: a glTF-sized
surface, mirrored in every runtime. Aetheria needs about a sixth of it.
- It would also put a CultLib cut and a Unity release in front of the release
hulls.
- **Prior art.** No engine adopts a neutral interchange format as its runtime
format. Each owns a binary form fitted to its loader.
- glTF 2.0 keeps one typed accessor per attribute and one primitive per
material.
- Unity's `Mesh` takes one array per attribute, and `SubMeshDescriptor` ranges
over one index buffer.
- Godot's `ArrayMesh` keeps per-surface arrays.
- Bevy's `Mesh` keeps an attribute map, with U16 or U32 indices.
- Unreal splits positions from tangents and UVs into separate vertex buffers.
- `ShipMesh` takes their common subset: one array per attribute, which
MessagePack types element by element, and submesh ranges.
- These are from the engines' documentation and were not re-fetched for this
pass.
**`ship-image-encoding`: how are images stored?**
- **a. RGBA8, mip 0 only.** Unity generates mips and compresses at load
(`Texture2D.Compress`, DXT5).
- **b. Block formats with mips, encoded at Package:** BC7 for colour, mask and
maps, BC5 for normals.
- Unity loads them with `LoadRawTextureData`, with no decode and no compress
hitch.
- This needs a native encoder in Blender's Python, supplied by Brokkr: bc7enc
(MIT or public domain), etcpak (BSD-3) or Intel's ISPC Texture Compressor
(MIT).
- **c. PNG bytes inside the record,** decoded by `ImageConversion`.
- **Recommendation: `raw-then-bc`.** `Format` and `Levels` go in the schema from
day one; a for the first cut; b as the release encoding, in cut
`ships-cc-image-bc`. Confidence is medium-high. The other options are
`bc-now`, `raw-only` and `png`.
- b makes the file about a third the size, so every save writes less.
- b matches the VRAM budget the material plan already assumes (BC).
- b removes the load-time compress, which costs hundreds of milliseconds per
2048² image.
- c puts a xenos codec inside a typed record. Its meaning becomes "whatever a
PNG decoder says", not a layout every runtime reads the same way. It still
pays the decode and the compress at load.
- KTX2 is the prior art for b's shape: a format, a size and an index of level
byte ranges, with block formats as ordinary formats. Unity's `Texture2D` and
Godot's `.ctex` store the same thing.
- If she rules b first, `ships-cc-package` takes the encoder and
`ships-cc-image-bc` disappears.
#### Cuts
| Cut | Repo | Depends on | Hands budget | What |
|---|---|---|---|---|
| `ships-cc-model` r1 | Aetheria | — | ~130k | The schema; `seed-model`; `Validate` on records; `PackageOf` reading the package; `ShipModVisual.Load` from records; glTFast removed; fixtures as records; the coordinate probe; the doc |
| `ships-cc-package` r1 | Aetheria | `ships-cc-model` | ~110k | `ship_cc.replace_model`; Package without a GLB; the image cap; Blender tests and the headless smoke; the LFS rule; sizes measured |
| `ships-cc-image-bc` r1 | Aetheria | `ships-cc-package`, `ship-image-encoding` | ~90k | BC7 and BC5 with mips at Package; `LoadRawTextureData` |
**The smallest first cut.** The smallest step that gets her authoring onto the
`.cc` alone is `ships-cc-model` followed by `ships-cc-package`, merged to master
together.
- `ships-cc-model` alone would leave the add-on writing a GLB that nothing reads.
- No package is committed (M27), so the pair breaks nothing in the repo. The
operator's drafts go through `seed-model` on their next Package.
Spec and question drafts for admission, bounds-checked:
`F:\Projects\eureka-scratch-carry\ship-cc\` (`model.json`, `package.json`,
`questions.json`).
**Specs this changes.** The Superseded resolutions are Self's to admit.
- **`ships-hull-material` r2, superseded by r3.** r3 folds this section into
"Hull materials as owned graphs":
- no glTFast generator: `ShipModVisual` builds materials from `ShipSurface`;
- `HullPaint` keeps the graph and the livery, and drops the PNG paths;
- Bind's PNG checks become image checks in `Validate`;
- surfaces gain the normal and emission slots.
- **`ships-addon-paint` r2, superseded by r3.** Package writes the mask and maps
as `ShipModel` images. There is no PNG export and no `paint --mask <path>`.
- **`ships-player` r2, superseded by r3:**
- "glTFast's shader variants in the player" and the no-`glTF/`-shader check
are dropped;
- the LFS rule moves to `ships-cc-package`;
- the preload log line sums image memory from the records;
- it depends on `ships-cc-package`.
- **`ships-addon-frame` r3, superseded by r4.** "The export leaving out `Source`"
becomes "the model writer leaves out `Source`".
- **`ships-addon-mounts` r3, superseded by r4.** The texture cap leaves it.
- **The material-system plan,** which is not admitted:
- `hull-bakes`' pair key hashes the mask and maps image bytes instead of PNG
files;
- "the GLB's slots" becomes "the model's surfaces".
- **`ships-addon-package` r2 needs no revision,** because it has landed.
`ships-cc-package` deletes its GLB export, and `ships-cc-model` deletes its GLB
read in `validate`.
**Order:**
- `ships-cc-model` can start now.
- `ships-addon-frame` and `ships-addon-mounts` touch `__init__.py`, as
`ships-cc-package` does. Land the pair before those two.
- `ships-hull-material` r3 and `ships-addon-paint` r3 then build on records from
the start.
- Follow-up `variants-python-raw-push` gains a consumer: `replace_model` is a
raw push, like the other two.
#### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Ship model | `mod-model:<id>` in the package's `ship.cc` | Seeded by `seed-model`; rewritten whole by every Package | Package writes it and `Validate` judges it. It is never copied into the derived catalog |
| Ship image | An entry of `ShipModel.Images`, named by a surface or paint slot | Captured from Blender at Package, capped at 2048 | The operator's images; `Validate` judges size and format |
| Mod body prototype | Hull record key | Built at boot from `ShipModel` | `ShipModVisual.Load`. Cache only |
#### Rationale
**Why one record and not a record per image.**
- Separate image records would need typed refs from `ShipModel` and from
`HullPaint`, plus an orphan check.
- `HullPaint` sits in the derived catalog and `ShipModel` does not, so a ref from
one to the other would dangle there.
- Every Package writes all the images anyway, and the C# store decodes every
registered record either way.
- One record has no refs to keep and nothing to orphan.
**Why Python packs the body instead of handing it to C#.** Any handoff from
Blender to AetherDb needs a transport. A temporary file in another format would
be the GLB again. Python writing slots pinned by `ShipSchemaPinTests` is the
contract the hull layout and the anchors already use, and C# stays the one judge.
**Why not keep the GLB as a cache beside the record.** A second copy of the body,
with its own reader, is a second owner. Nothing in the game needs glTF. The
loader from records is about the size of the glTFast glue it replaces.
### Ship authoring across Blender and Studio
The operator, 2026-10-03, ruling `studio-is-the-authoring-pane`: "I think
bridging the data to a place we already control the UI is the smart compromise.
Studio will need to update its schematic rendering though since we'll be
shipping the stroke paths directly now." It answers her earlier ask for "a
dedicated CultCache Studio pane in Blender", after the Eyes pass
(`F:\Projects\blender-tooling-surface-prior-art.md`) found that Python cannot
register an editor type. Later the same day she added: "Honestly, I don't love
the fact that CultCache Studio runs in Unity. We have all this UI lowering infra
(Eve, now Thing) and our best inspection tool lives in, of all things, IMGUI".
Question `ship-authoring-host` puts that to her.
Rulings also in force here: `ship-data-all-in-cc`, `ship-model-aetheria-owned` and
`thrusters-radiators-are-meshes`. Anchors are against Aetheria `origin/master`
`4895c752`, CultLib `main` `a7966142` and Eve `main` `8e270d6`, all read on
2026-10-03.
#### Body facts
**M31. Studio draws a hull's schematic as a texture under a checkbox grid, and
a mod ship has no texture.**
- `InspectableSchematicShapeDrawer` (`Assets/Scripts/Editor/CultCacheDrawers.cs:93-146`)
loads `EquippableItemData.Schematic` as an asset GUID and draws it with
`EditorGUI.DrawPreviewTexture` under one `GUILayout.Toggle` per cell. The
grid's height is locked to the texture's aspect, and hardpoint cells are
tinted.
- `ShipAuthoringStore` sets `hull.Schematic = null` for a mod hull
(`ShipAuthoring.cs:103`). So Studio shows a mod hull's cells over nothing.
- The strokes live in `ShipAuthoring.SchematicLines` (key 4, `ShipPolyline`:
points in Blender space, radii, opacities, colour; `ShipAuthoring.cs:19,40-48`)
at `mod-ship:<id>`, in the same `ship.cc` as the hull at `mod-hull:<id>`.
- A drawer can see the sibling record. `CultInspector.Records` is every stored
document in the open store, and `CultInspector.Record` is a read-only copy of
the one being drawn (CultLib `CultCacheStudioDrawers.cs:46-49`). A drawer
returns a new value only for the member it draws.
- A hardpoint's footprint is a plain `Shape` (`ItemData.cs:563`). It carries no
drawer attribute, so Studio shows it with the default drawing.
**M32. Neither writer guards the file against the other, and they write
differently.**
- Python `push` pulls the whole store, replaces one envelope and rewrites the
file. Records it did not touch are carried as raw bytes (M28). The add-on's
`replace_layout` also checks a hash of the hull body (`ship_cc.py:155-161`).
So a Blender write is safe record by record.
- Studio's Save is `_cache.FlushAsync()` (`CultCacheStudioWindow.cs:423-425`).
It rewrites every record from memory. Its only refresh is a manual Reload
button (`:94`). There is no file watch and no stale-file check, and CultCache
keeps no file version (`CultCache.cs`: no revision or timestamp check on the
single-file store).
- So if Studio has `ship.cc` open while Package writes the model, anchors and
lines, its next Save silently restores the old ones.
**M33. Blender already turns a Grease Pencil object into the strokes, and Line
Art can make that object from the mesh.**
- Package captures the collection's one Grease Pencil object, evaluated by
default, into `SchematicLines` (`__init__.py:387-391`,
`ship_cc.capture_grease_pencil` `:198`).
- Blender's Line Art modifier makes Grease Pencil strokes from mesh feature
lines: contour, silhouette, crease, material border, edge marks and
intersections, seen from a camera. It is evaluated through the depsgraph like
any modifier. So `capture_grease_pencil(..., evaluated=True)` reads its output
unchanged.
**M34. Thing cannot carry the ship authoring surface yet.** Thing is the rename
of Eve (`Eve/docs/thing-campaign.md`). That campaign renames and publishes; it
adds no primitives.
- **Component kinds** (`Eve/docs/surface-contract-v1.md:96-106,309-333`):
- layout: `surface`, `grid`, `panel`, `card` and similar;
- `text`, `image.*`, `graph`, `tree` and `inspector.kv`;
- controls: button, toggle, slider, stepper, segmented, colour, select and
input;
- `inventory.grid`, `inventory.item` and `inventory.drag_session`. These are
spatial items with footprints and rotation, and drop operations whose fit is
left to the provider.
- There is no kind for vector strokes or polylines. The browser lowering's only
SVG is for `graph` diagrams (`eve-browser-lowering/src/index.ts:1653-1685`).
- There is no cell-painting kind. Occupancy can be drawn as a `grid` of
`control.toggle`, which means 1,024 bindings for a 32 x 32 hull.
- **Provider side:**
- C# `GameCult.Eve.Surface` with CultMesh `OperationBinding` and state
bindings (CultLib `src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md`);
- `cultmesh-browser`, a WebSocket client.
- **Lowerings** (`Eve/docs/renderer-parity.md:32-41`):
- The web reference renders fixtures and local advertisements. Its stated gap
is "live Odin/CultMesh provider feed and command round-trip tests".
- Flutter (Windows, Linux, Android) and iOS are screenshot parity targets on
fixtures.
- EveUnity has a live CultMesh provider path.
- `CultInspectorModel` is engine-free C#, but nothing projects it into an Eve
surface. The Unity window is its only lowering.
#### The design in brief
```
Blender (add-on) ship.cc (one file, the only truth) Studio (host per ship-authoring-host)
model, materials, UVs HullData mod-hull:<id> <-- cells, hardpoint values, stats (Studio edits)
Bake Schematic: Line Art GP --Package-> ShipAuthoring mod-ship:<id> <-- anchors, SchematicLines (Blender writes)
mount gizmos, cell overlay --Package-> ShipModel mod-model:<id> <-- nodes, meshes, surfaces, images (Blender writes)
Rasterise (proposes cells) --replace_layout, revision-checked--> HullData.Shape
mount helpers (paired rows) --replace_layout--> HullData.Hardpoints (type, Transform, Position)
overlay reads mod-hull on file change (bpy.app.timers mtime poll) Studio reloads on file change; refuses a stale Save
```
**Who owns what.**
- **Blender owns** the body and everything spatial:
- the model;
- the strokes, baked from the model;
- anchors and mount meshes;
- the `Position` of a hardpoint that has a mount object.
- **Studio owns** the hull's semantics:
- cell occupancy after any Rasterise;
- each hardpoint's type, footprint, rotation, armour and firing arc;
- internal hardpoints and stats.
- Each record has one writer, except two narrow, revision-checked Blender writes
into `HullData`:
- Rasterise proposes `Shape`, only when she presses it;
- the mount helpers create and remove paired rows and write the mounted
`Position`.
**Strokes: Line Art, then Package's existing capture.**
- `Bake Schematic` creates or refreshes a Grease Pencil object `Schematic` in
`Generated`. It carries a Line Art modifier whose source is the render meshes,
so `Source` and `Generated` are excluded.
- The edge types are contour, crease, material border, edge marks and
intersection, seen from a top-down orthographic camera that the bake also
places in `Generated`.
- Package already captures the one evaluated Grease Pencil object (M33). So the
bake adds no write path.
- Two ways to change the result:
- **Edge marks:** she marks a feature with Mark Freestyle Edge and rebakes.
- **Freeze Schematic:** it applies the modifier so she can redraw strokes by
hand. This is the Grease Pencil-assisted path, and a frozen object is marked
`aetheria.keep`.
- Silhouette alone loses panel lines. A hand-drawn-only path costs her time on
every hull. Line Art gives the feature lines with the hand edit as a fallback.
**Studio: strokes under the grid, footprints painted.**
- `ShipSchematicProjection` (ServerShared, engine-free) maps `SchematicLines`
into cell space:
- cell = Blender XY / 2 m, mirrored on both axes, plus `Shape.CenterOfMass`;
- schematic +x is Blender −X, and schematic +y is Blender −Y.
This is `ships-addon-frame`'s grid rule, run backwards.
- The shape drawer finds `mod-ship:<id>` in `inspector.Records` when the hull
has no texture. It draws the projected strokes with `Handles.DrawAAPolyLine`,
using each line's colour and radius, and unlocks the height.
- A new `InspectableFootprint` drawer paints a hardpoint's footprint as a toggle
grid with the hull's occupied cells shown around it.
- The rules live in ServerShared, so a Thing host reuses them and replaces only
the drawers.
**The bridge: the shared file, with a stale-write guard and a file watch.**
- Studio's window gets a `FileSystemWatcher` on the open path:
- when the store is not dirty, a change on disk reloads it on the next editor
tick;
- when it is dirty, Save compares the file's SHA-256 with the one recorded at
the last load or flush. If they differ, Save refuses and offers Reload,
naming the file.
- The guard sits in Studio, not in CultCache. The operator's
CultCache scope restraint (2026-10-02) keeps CultCache from growing into a database, and
Studio is the only writer that flushes whole stores.
- The add-on polls the file's mtime from a `bpy.app.timers` callback. Blender
forbids threads, so there is no watcher thread. On a change it rereads
`mod-hull` for its overlay.
- **Prior art:**
- Send2UE ships exported files and remote commands, not live state.
- Multi-User replicates Blender datablocks for many editors at once. That is
the wrong shape for one operator whose two tools own disjoint records.
- CultCache's own write rules are already per record on the Python side (M32).
- A CultMesh live link earns its place only when a provider process holds the
store, which is the Thing host. Then Blender connects through `cultmesh-py`
and stops writing the file. Until then it adds a transport and no invariant.
This is not a separate fork: it follows `ship-authoring-host`.
**Gizmos and the overlay in Blender.** These go in a `WorkSpaceTool` "Ship
Mounts" with a `GizmoGroup`, and replace N-panel text entry.
- **Overlay:** a `gpu` and `blf` draw handler in the 3D view, at Ship Root's
placement:
- the cell grid, with occupied cells filled;
- each hardpoint's footprint, tinted by type;
- each weapon's firing arc as a fan.
It is read-only and replaces `ships-addon-frame` r3's generated Grid object,
so there is nothing left to exclude from export.
- **Mount gizmos:** one per mount object (weapon empty, muzzle, thruster mesh,
radiator mesh), coloured by role.
- Clicking selects the object and names its hardpoint row in the tool header.
- Dragging a weapon snaps it to cell centres. On release its row's `Position`
is written through `replace_layout`.
- Package re-derives every mounted `Position` from the object (the cell under
its bounds centre). So the r3 warning becomes a derivation.
- **Studio's side:** it shows `Position` read-only on a row whose `Transform`
names a mount object.
- **Not built:**
- a Sprytile-style cell painter in Blender (Studio owns cells);
- a firing-arc dial gizmo (Studio owns the arc; the overlay only shows it).
- **Deleted:** the N-panel hull editor:
- the cell checkbox grid and the hardpoint boxes;
- `AETHERIA_PG_cell`, `AETHERIA_PG_hardpoint` and `AETHERIA_PG_layout`;
- Load Layout, Resize Grid, Add Hardpoint, Remove Hardpoint and Save Layout.
#### Authority map
- **Owner.**
- `ship.cc` is the only truth.
- `HullData` semantics belong to Studio's edits, judged by
`ShipAuthoringStore.Validate`.
- `ShipAuthoring` and `ShipModel` belong to Package.
- Spatial placement belongs to Blender's objects.
- **Inputs.**
- Blender reads its scene and the file's `mod-hull` (for the overlay and the
revision).
- Studio reads the whole store, and the sibling `mod-ship` for strokes.
- **Outputs.**
- Blender writes `mod-ship`, `mod-model`, a proposed `Shape`, paired
hardpoint rows and mounted `Position`s.
- Studio writes `mod-hull`.
- **Derived state.**
- The Blender overlay is display-only.
- Studio's stroke underlay is display-only.
- A mounted hardpoint's `Position` is derived from its object.
- The `Schematic` Grease Pencil object is derived from the meshes unless
frozen.
- **Forbidden writers.**
- The N-panel layout editor, which is deleted.
- A Studio Save over a file that changed on disk since it loaded.
- Studio editing a mounted `Position`.
- Blender editing a hardpoint's type, footprint, rotation, armour or arc.
- Python computing `CenterOfMass`.
- **Shared paths.**
- Every Blender write to `HullData` goes through `replace_layout` and its
revision check.
- Rasterise, the mount helpers and the gizmo release write through that one
path.
- Package writes the other two records.
- **Deletion line.** The N-panel layout editor is cut in `ships-addon-frame` r4,
before any overlay or gizmo is added.
#### Questions
**`ship-authoring-host`: where does the hull editor and stroke view run?**
Context:
- Thing cannot carry it today (M34). It has no stroke kind and no cell-painting
kind.
- No lowering has proven the live CultMesh round trip.
- Nothing projects `CultInspectorModel` into a surface.
Prior art: the Language Server Protocol and Jupyter's kernel and front-end
split. In both, one process owns the model and the editors are interchangeable
clients. This is the target shape doctrine names. Both reached it after a
working single-host editor existed.
- **a. `thin-imgui-now-thing-next`.**
- The Studio drawers above, kept thin: every rule lives in engine-free
ServerShared (`ShipSchematicProjection`, the footprint rule) and in
`CultInspectorModel`.
- The Studio disk guard.
- A Thing host for Studio as its own campaign after the release, with the
missing pieces named here: a stroke kind, a cell-paint kind, the browser
live loop, and a `CultInspectorModel`-to-surface projection.
- About 160k Hands tokens now, in two cuts. The IMGUI added is about 150
lines of drawer.
- **b. `thing-now`.**
- An Aetheria provider: AetherDb, which already owns `ship-authoring`, holds
`ship.cc` and publishes a surface over CultMesh.
- New contract kinds `schematic.strokes` and `cells.paint`, in the contract,
the browser lowering and conformance fixtures.
- The browser live-loop proof.
- A `CultInspectorModel` projection limited to the field kinds `HullData`
uses.
- Blender connects through `cultmesh-py`.
- About 5 cuts, roughly 600-800k Hands tokens, across Eve, CultLib and
Aetheria. Hull authoring waits on all of it, ahead of the release.
- **c. `thing-now-minimal`.**
- As b, but no new kinds:
- the provider renders the strokes server-side as an `image` asset;
- cells are a `grid` of `control.toggle`.
- About 3 cuts, roughly 300-400k Hands tokens.
- It leaves 1,024 bindings per hull and a stroke view that cannot zoom
crisply. The Thing campaign would then rebuild that surface.
Recommendation: **a**, confidence medium-high.
- The release needs hull authoring now. Studio already edits every `HullData`
field.
- The IMGUI cost is held to one drawer file, because the rules sit where a
Thing host will read them.
- b is the right destination, but it would put three unproven pieces (two new
kinds and the live loop) on the release's critical path.
- c builds a surface that b would then throw away.
Depends on: nothing. The Blender cuts are independent of this answer.
#### Cuts
| Spec | Repo | Depends on | Hands budget | What it does |
|---|---|---|---|---|
| `ships-studio-schematic` r1 | Aetheria | `ship-authoring-host` | ~90k | `ShipSchematicProjection`; strokes under the grid; the `InspectableFootprint` drawer; tests on the projection |
| `studio-disk-guard` r1 | CultLib | `ship-authoring-host` | ~70k | File watch, auto-reload when clean, stale-Save refusal by SHA-256, in the Studio window; a CultLib release |
| `ships-addon-schematic-bake` r1 | Aetheria | — | ~70k | Bake Schematic (Line Art and the ortho camera in `Generated`), Freeze Schematic, its own module; smoke |
| `ships-addon-frame` r4 | Aetheria | `ships-cc-package` | ~150k | r3 with the `.cc` change, the N-panel layout editor cut first, the Grid object replaced by the cell overlay, Rasterise kept |
| `ships-addon-mounts` r4 | Aetheria | `ships-addon-frame` | ~110k | r3 without the texture cap and without internal-hardpoint editing; rows are pairs only |
| `ships-addon-gizmos` r1 | Aetheria | `ships-addon-mounts` | ~120k | The Ship Mounts tool, mount gizmos, the footprint and arc overlay, mounted `Position` derived on release and at Package, Studio's read-only `Position` |
Admitted on 2026-10-03, along with question `ship-authoring-host`:
`ships-studio-schematic` r1, `ships-addon-schematic-bake` r1 and
`ships-addon-gizmos` r1. Two batches wait on Self, bounds-checked, in
`F:\Projects\eureka-scratch-carry\studio-pane\`:
- `pending-after-ships-cc-package.json`: `ships-addon-frame` r4,
`ships-addon-mounts` r4 and their Superseded resolutions of r3. Admission
refuses r4 until `ships-cc-package`, which it depends on, is admitted.
- `pending-after-cultlib-in-campaign.json`: `studio-disk-guard` r1. Admission
refuses it because `GameCult/CultLib` is not a repo of the campaign. Self adds
the repo (a campaign revision) or routes the cut to a CultLib campaign.
**Specs this changes.**
- **`ships-addon-frame` r3, superseded by r4.**
- It folds in the `.cc` section's change: the model writer leaves out
`Source`.
- The Grid object becomes the overlay, and the layout editor is cut first.
- **`ships-addon-mounts` r3, superseded by r4.**
- It folds in the `.cc` section's change: the texture cap leaves.
- "The layout panel stays the editor" becomes "Studio is the editor".
- Add and Remove Hardpoint for internal types leave Blender.
- **`ships-cc-model` (draft).** It should mark `ShipModel`'s bulk arrays
`[CultInspectorHidden]` and show their counts read-only. Otherwise Studio,
opening `ship.cc`, tries to draw millions of floats in IMGUI. Self amends the
draft before admitting it.
**Order against the `.cc` move on 2026-10-06.**
- **Before the 6th:**
- `ships-studio-schematic` and `studio-disk-guard`, once
`ship-authoring-host` is ruled. They read only `HullData` and
`ShipAuthoring.SchematicLines`, both on master.
- `ships-addon-schematic-bake`. It lives in its own module with a one-line
registration, so its merge with `ships-cc-package`'s `__init__.py` edits is
trivial. It feeds Package's existing capture.
- **After `ships-cc-package` lands:** `ships-addon-frame` r4, then
`ships-addon-mounts` r4, then `ships-addon-gizmos`. Each builds on
`ShipModel`-in-`.cc`, not on the GLB.
- If she rules b or c, `ships-studio-schematic` and `studio-disk-guard` are
withdrawn and the Thing cuts are mapped then. The Blender cuts do not change.
#### Rationale
**Why Studio owns semantics and Blender owns space.**
- The operator's ruling puts editing where GameCult controls the UI.
- What Blender knows better than any grid editor is where things are on the
mesh. So placement is the one hull value Blender writes, and it is derived
from objects, not typed.
**Why Line Art and not silhouette edges or a custom edge extractor.**
- Line Art is Blender's own feature-line generator. Its output is the Grease
Pencil object that Package already captures.
- A Python edge walker would re-implement occlusion and chaining, and own them
forever.
**Why the guard sits in Studio, not in CultCache.**
- Python already writes safely record by record.
- Only a whole-store flush from memory can clobber. The editor that does that
flush owns the check.
**Rejected.**
- A Blender fork with a native editor (the Eyes pass, section 2).
- A Sprytile-style cell painter in Blender: a second editor of cells.
- A CultMesh link before a provider holds the store.
### S3: the first playable mod ship in a built player
- **First hull: Headliner.**
- It is a single hull. Quiet comes as two variants (Dexter and Sinister), and
MQ2 A makes them two full packages.
- Its 50k triangles exercise the heavy path: collider generation and texture
memory.
- It is Lucent's duel ship (ruling `lucent-duel-bait`), so `faction-play-2` will
want it in Lucent flights.
- Its mesh and texture are already in `Quiet.blend`, so no new GLB is needed.
- The operator authors it with the add-on, because placing the mounts needs her
eyes. She commits the package with the add-on cuts' tooling.
- **`ships-player` does the agent-side work**, and does not wait on the hull:
- the template radiator material;
- glTFast's shader variants in the player;
- one preload log line with hull count, shader names and texture memory;
- LFS rules for packages (M11);
- `GameData/Mods` staged into the player;
- the C3 sim test;
- the `SetEntity` probe;
- the built-player boot smoke on Starfire, with the skiff fixture and a bad
package.
- **What it needs to build a player** is question `s3-player-build` (M10).
- **The operator's S3 session** is gate 4, gate 5 (S4) and C2 to C6: Headliner in a
built player, about fifteen minutes.
### S5 and the variants campaign: what this strand needs, and when
- **Nothing before release for S5 itself.**
- The bar is 3 shipped prefab hulls plus 10 package hulls
(`content-bar-one-per-concept`). The prefab hulls keep working through the
`Prefab` branch.
- Packages are complete hulls (MQ2 A), so no family needs variants.
- S5, which migrates the shipped hulls, retires the FBX builder and collapses
families, waits on variants C3 and C4, and stays theirs.
- **Before the operator's CultLib checkout moves past the C2a merge:**
- Python must still write a raw record into a single-file store. `ship_cc` pushes
`HullData` and `ShipAuthoring` envelopes (`ship_cc.py:108,163`), and `parity-py`
rewrites that write path.
- Follow-up `variants-python-raw-push` asks `parity-py` to keep that path or name
its replacement, with `tools/blender/tests` as its consumer check.
- `ships-addon-mounts` adds a guard: the add-on refuses an untested
`cultcache_py` revision (M12).
- **When C4 brings element ids to Aetheria**, they arrive on `ShipAnchor`,
`HardpointData` and `ShipPolyline`.
- `ship_cc` already carries hardpoint slots past `FiringArc`, keyed by mount id.
`ships-addon-package` does the same for anchors, keyed by anchor id.
- Lines are replaced whole, which Q7 allows.
- Key 32 (`HullData.Visual`) is taken, and C4 must not reuse it.
- The lane's own S5 text puts hull families "in `Aetheria.cc`". MQ1's confirmed
reading puts a family in its ship's `.cc`. S5's owner should resolve which one
holds before C4 maps it.
- This is follow-up `c4-ship-element-ids`.
### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Ship package | `GameData/Mods/<id>/`; `<id>` is `ShipAuthoring.Id`, lower-case and stable across renames. | Created by `ship-authoring create`, edited daily in Blender, validated by C#. Removing it makes Continue refuse runs that used it (MQ4). | The package's two records. The `.blend` in `Asset Sources/` is the operator's source and never the owner of semantics. |
| Hull record | `mod-hull:<id>` in the package. | Stats seeded from a reference hull at create; layout written by Blender's Save and Package. | `HullData` in the package: cells, hardpoints, stats. How it gets a manufacturer is question `release-hull-home`. |
| Anchor | `ShipAnchor.Id`, equal to the mount id for mounts and to the GLB node's `aetheria.id`. | Written whole by Package from role-tagged objects. Unknown tail slots are carried by id. | Blender objects tagged `aetheria.role` propose; the C# validator decides. |
| GLB | Retired by "Ship data all in the `.cc`": the body is `ShipModel` at `mod-model:<id>`. | Was re-exported by every Package; `ships-cc-package` deletes the export. | None. `ShipAuthoring` key 2 is retired. |
| Derived catalog | `Aetheria.modded.cc` in the persistent data path. | Recomposed every boot, and disposable. | `ShipModCatalog.Compose`. Never authored. |
| Mod prototypes | Hull record key. | Built at boot, kept across scenes, gone at exit. | `ShipModShips.Preload`. Cache only. |
### Rationale
**Why the operator checks move to the S3 session.**
- They need a mod ship in a zone, which today means the console and a docked
player (M5). Done in the Editor with the skiff fixture, they prove the wiring on
a tetrahedron.
- Done once in a built player with Headliner, the same fifteen minutes also give
gates 4 and 5, which are owed anyway.
- The merge is safe without them because the path is inert with no package
installed.
**Why mounts before the add-on.** The add-on writes anchors. If it learned
`articulation` as the weapon role, the first real package would need migrating when
the ruling's code landed. `ships-mounts` also collapses the role checks into the
validator (M4), so that the add-on's Validate button and compose refuse the same
things.
**Why the add-on calls AetherDb instead of growing a Python validator.** The doc's
shared-path claim is true only for C# (`moddable-ship-authoring.md`, correction 4).
A second validator in Python would be a second semantic authority. A subprocess
call costs a few seconds per Package and keeps one owner. The same reasoning makes
`create --like` a C# command: `HullData` has some 30 slots that Python would
otherwise copy by hand.
**Why generated structural anchors.** The collider, map icon, shield and tractor
are functions of the mesh's bounds and silhouette. Hand-making them per hull is the
work that M8's hand-scaled icosphere already shows. Generating them, with a "keep
mine" flag, removes about half of the per-hull steps without taking a decision from
her.
**Why first-party hulls through the package path** (the recommendation in
`release-hull-home`). In KSP, RimWorld, Factorio and Starsector, the base game's
content uses the same format and the same loading path as mods:
- KSP stock parts live in `GameData/Squad`, beside mod folders.
- Factorio's base game is the `base` mod, read before every other mod and merged
into one prototype table.
- Starsector mods put their ships into an existing faction's fleets and markets with
their own `.faction` file. The engine merges that file into the core faction's
known-hull lists, so the package carries its own availability.
- The RimWorld claim is from memory and was not re-fetched.
Dogfooding the mod path means the release exercises the path players' mods will
take. A package that carries its own product needs no edit to `Aetheria.cc`.
Sources:
- https://forum.kerbalspaceprogram.com/topic/144858-squad-folder/
- https://forums.factorio.com/viewtopic.php?t=12564
- https://spmatlas.com/guides/mods-explained/
- https://fractalsoftworks.com/forum/index.php?topic=12970.0
- https://docs.blender.org/manual/en/latest/addons/scene_gltf2.html
## Lanes: scenarios, mining and the loot move
The operator ruled on 2026-10-03 that this campaign adopts `codex/scenarios` and
`codex/mining` from where they stand ("Adopt mining and scenarios", ruling
`adopt-mining-and-scenarios`). This section maps:
- both adoptions;
- the loot move into `ServerShared`, which jettison rests on (ruling
`jettison-shared-floating-items`);
- the merge order of every lane and the faction-play cuts;
- the re-map of `faction-play-4` to ruling `ballistics-real-ammo`.
The cuts are typed specs in the mind: `scenarios-adopt`, `mining-index`,
`mining-index-pins`, `mining-target-queries`, `mining-merge`, `mining-index-tree`,
`mining-belt-cells`, `loot-1` to `loot-3`, `ballistic-ammo` and `faction-play-4` r2.
The forks are questions `fastblast-ammo`, `reticle-exactness` and `belt-size-tiers`.
The lane tips were read with `git show` in a scratch clone on 2026-10-03:
- `codex/scenarios` is at `aa3baf12`: 30 ahead of master and 20 behind, base `702b454b`.
- `codex/mining` is at `83d8371e`: 32 ahead and 21 behind, base `65c63495`.
- Everything master gained since either base is `Scenarios map:` doc commits.
The operator's records for both lanes live in `docs/scenarios-cut.md` on master: the
Soul passes on scenarios batches 2 to 4 (lines 72-140), and the mining Cut 3 Soul and
belt-freeze rulings (lines 830-876).
### Body facts
**L1. The scenarios blocker is real, latent, and wider than one lookup.**
- Soul's batch-4 probe P30 exists only in a dead session scratchpad
(`...\F--Projects-CultLib\2a6aec4d-...\scratchpad\soul5\SoulScen5Probes.cs`, log
`probesC.log`). At `aa3baf12` it returned:
- (a) An L-shaped gun in an L-shaped hardpoint equips at origin `(1, 1)`.
`Hardpoints[origin]` is NULL, while the hardpoint found by the item's cells is the
right one. `ArcFor` returns the default 120 instead of the authored 360.
- (b) A design with an empty leading column: `ItemFits(-1, 4)` is true but
`TryFindSpace` is false, and `ArcFor` throws `IndexOutOfRangeException`.
- (c) Over the catalog with that design, 380 placements are accepted. In 9 the origin
is outside every hardpoint, in 7 it is inside another hardpoint (a radiator), and
14 offsets are never tried by `TryFindSpace`.
- 18 readers take an item's hardpoint from its origin cell
(`Hardpoints[item.Position.x, item.Position.y]`):
- `FireControl.ArcFor` (`:38`);
- eleven Unity weapon managers;
- `EntityInstance.cs:245,260` and `ShipInstance.cs:70`;
- `ActionGameManager.cs:1044,1048,1049`.
This is a grep at `aa3baf12`. The pattern in `scenarios-adopt`'s negative check
matches exactly these 18.
- `Entity.HardpointAt` (`Entity.cs:820-835`) already finds the right hardpoint at equip.
`TryEquip` (`:904-935`) throws that answer away.
- The defect is latent. A catalog probe at `aa3baf12` with catalog `70a7b0a9` (an xUnit
probe run through `ygg-verify`, job `aeth@aa3baf12e4`, 2026-10-03) found:
- every catalog hardpoint is a full rectangle;
- the only gear designs whose shape lacks its origin cell are the Vulcan reactor and
the Industrial Thermostatic Heater (Tool).
So nothing in the shipped catalog trips it today. The first non-rectangular hardpoint
or mount would.
- The "held fix" was never written. The fix batch was queued and not dispatched under
the 2026-10-01 drain (scenarios-cut.md:105-106). P30's harness survives only in that
scratchpad, so `scenarios-adopt` commits its fixtures as tests.
**L2. Merge-tree probes between the lane tips** (`git merge-tree --write-tree
--name-only`, 2026-10-03):
| Pair | Conflicts |
|---|---|
| master x scenarios | none |
| master x mining | none |
| scenarios x moddable-ships | `AetheriaStores.cs` (`CatalogTypes`, line 9) |
| mining x moddable-ships | `AetheriaStores.cs`, `tools/AetherDb/Program.cs` |
| scenarios x mining | `AetheriaStores.cs`; `Zone.cs` (one region: scenarios' `MineAsteroid` against mining's deletion); `GameData/Aetheria.cc`; `FireControlCut124Tests.cs` |
- The scenarios x mining set matches the merge rule recorded in scenarios-cut.md S4
(lines 133-140).
- Only mining changes `Settings.asset` (`347752dd` to `b2e346f5`, `MiningDifficulty`
removed), so it merges without a conflict.
**L3. The belt freeze's cause is mapped** (mining Cut 3 Soul F1). Historical: the scan, `ChunksNear` and `ActionGameManager.TargetCandidates` were deleted by `mining-index` and `mining-target-queries`; see "Mining index: as built".
- Every reticle, next or previous press builds `ActionGameManager.TargetCandidates`
(`:1174` at `83d8371e`).
- That calls `Entity.VisibleChunksInReach` (`Entity.cs:377-385`), which calls
`Zone.ChunksNear` (`Zone.cs:326-339`).
- `ChunksNear` loops over every rock of every belt whose annulus reaches, posing each
rock and then testing its visibility.
- The handlers then sort the whole list by distance and search it with
`Array.IndexOf` (`:393-407`).
- Soul measured 63 ms at 30k rocks, 449 ms at 300k and 4.3 s at 3M. At 3M, 633k rocks
are in reach and 2,240 are visible.
**L4. The operator's index ruling, and the visibility bound.**
- The operator, verbatim: "Just don't specialize the indexing too much towards asteroid
belts, because we'll want a bunch of subsystems feeding targeting data"
(scenarios-cut.md:871-875).
- Detection of a rock is the settled sensor rule (Q14 A):
`ChunkInfo = saturate(sum_i v * s_i * curve_i(angle) / d / (n k))`. The sources are
`Sensor.Gain` (`Sensor.cs:187-199`) and `Entity.ChunkInfo` (`Entity.cs:355-368`).
- The rule is linear in visibility `v` and falls as `1/d`. A Bezier curve lies inside
the hull of its control points. So for a region with a known maximum visibility and
a known nearest distance, an upper bound on `ChunkInfo` is cheap to compute.
- Rock visibility is `CrossSection * pi * cells^2 * light` (`Zone.ChunkVisibility`,
`:314-321`).
- Together these let a provider prune whole regions without deciding detection.
**L5. Hands' unbuilt work in progress** (superseded by `Targeting/` on master; the patch was not adopted) is
`F:\Projects\HANDOFF-mining-index-wip-2026-10-01.patch`: 257 lines against
`83d8371e`'s `Zone.cs`.
- It groups rocks into bands of 256 by orbit distance, each sorted by orbital phase at
a key time.
- It widens the query arc by the band's rate spread, re-keys at most 8 bands per query,
bounds the light per band, and counts `ChunksExamined`.
- Its structure is belt-shaped (`ChunksNear(position, range, distancePerVisibility)` on
`Zone`), and its queries still build eager lists.
- Kept: the kinetic bands. Not kept: the belt-shaped API and the eager queries.
**L6. Nothing of the loot move has landed, on master or on any lane.**
- `git grep` for `Zone.Loot`, `TryPickUp` and `LootDrop` over every local and remote
ref found nothing in code.
- On master `f1dee184`, the loot path is all Unity:
- the roll uses `UnityEngine.Random.value` and `onUnitSphere`
(`EntityInstance.cs:302-321`);
- pickups are `GridObject`s, moved by Unity (`GridObject.cs`);
- collection is `ShieldManager.OnCollisionEnter` (`ShieldManager.cs:40-55`);
- the tractor beam pulls by `Physics.SphereCastAll` (`TractorBeam.cs:14-30`).
- No lane touches these hunks. Mining edits `EntityInstance.cs:236,410` and
`ZoneRenderer.cs:72-483`, away from the loot lines. Moddable-ships edits
`ZoneRenderer.cs:289-304`.
**L7. Today's loot tuning, read from the assets.**
- `GameSettings.cs:14-16`: `PickupLifetime` 30, `LootDropProbability` .25,
`LootDropVelocity` 25.
- All five pickup prefabs (`Assets/Prefabs/RPG/Pickups/*.prefab`) carry the same
`GridObject` values: `Drag` .05, `LaunchDrag` .2, `Gravity` 1, `GridAttraction` 10,
`RotationSpeed` 1.
- `Tractor Beam.prefab`: `Radius` 25, `Traction` 25, `Distance` 75.
- `GridObject` also moves mines (`Mine.cs:12`, `MineManager.cs:21-32`).
**L8. Fork L already decides the pickup, and the Tractor Beam has no capability yet.**
- Fork L was ruled by the operator on 2026-09-17 (headless-playground-cut.md:924-970):
- pickup is a simulation-owned timed grab: extend, envelop, pull;
- no Unity collision decides a pickup;
- the tractor beam is a generic Tool item that provides only pickup;
- the simulation owns the grabbed object and its pose;
- `GrabEvent` names the object by a typed simulation identity;
- loot is a body plus an item instance.
- Two readings are therefore not the design:
- option (a) of that section, "contact detection stays Unity collision", which was
recommended before the ruling;
- the brief's "`Zone.TryPickUp` from `ShieldManager`".
The jettison ruling's own words agree: jettisoned cargo "can be picked up without
unity's involvement".
- Scenarios rulings of 2026-09-30 (scenarios-cut.md:46-56): picking up requires a
pickup behaviour, which the Tractor Beam carries, and every generated ship and the
player's starting ship carry one.
- The catalog probe (L1's job) found the Tractor Beam as `GearData` on a **Sensors**
hardpoint, with **no behaviours** and one product. Nothing in the simulation can grab
today.
- The presentation seam exists, but grabs nothing real:
- `GrabEvent` carries an `int TargetHandle` (`CapabilityEvents.cs:53-67`);
- `CapabilityPresentationBinder` resolves it to a live `Transform` through delegates
(`:16-45`), and only `FieldTester` supplies them.
**L9. The ammunition substrate, by catalog probe** (same job, catalog `70a7b0a9`):
| Hardpoint | Weapon | Behaviour | Magazine | Ammo | Energy |
|---|---|---|---|---|---|
| Ballistic | 6k Shooter | Instant | 6 | none | 5 |
| Ballistic | Autocannon | Auto | 50 | Ammo | 5 |
| Ballistic | ClearPath | Auto | 0 | none | 1..0.5 |
| Ballistic | DeathCluster | Auto | 8 | Ammo | 25..5 |
| Ballistic | Earp | Instant | 6 | none | 5 |
| Ballistic | pretty pretty bang bang | Charged | 8 | Ammo | 25..50 |
| Energy | FastBlast+- | Auto | 12 | Ammo | 0 |
| Energy | ChargeBlast SG, ChargeBlast+-, ColdFire, CShot RainbowLite Lazer, plight, Spectra | mixed | 0 or 1 | none | 0 to 150 |
| Launcher | GT 3K, LRMM72, pswarm, scorched void policy, SRMM72 | mixed | 2 to 288 | none | 0.1 to 50 |
- The catalog has one ammunition commodity, `Ammo`, at price 1000.
- One cargo unit is one **magazine**. `InstantWeapon.UseAmmo` removes 1 unit per reload
and refills `MagazineSize` rounds (`InstantWeapon.cs:176-202`). `ConstantWeapon` does
the same (`:129-160`). So `faction-play-4` r1's "`AmmoMagazines x MagazineSize`
rounds" over-stocked by a factor of `MagazineSize`.
- `UseAmmo` returns true at once when `MagazineSize <= 1` (`:178`), so a one-round gun
fires free even with an `AmmoType`.
- Firing energy reaches a weapon through the power bus:
- `PowerBus.cs:122` takes every active `IPowerConsumer`;
- an item's `PowerSupply` is its tier's grant ratio (`:196-205`), which is 0 below a
starved tier;
- so a weapon that requests nothing still reads its tier's ratio, and a brownout
still reaches it.
That is why `ballistic-ammo` takes ammo-fed weapons off the bus instead of zeroing
their request.
- `ChargedWeapon`'s charge is time-based (`_charge += dt / ChargeTime`). Only firing
spends the capacitor, so bypassing the spend does not stall a charge.
### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Equipped item's hardpoint | `EquippedItem.Hardpoint`, a reference into the hull's `Hardpoints` list; null for Tool gear. | Set once at equip from the placement rule, and derived again on every load (load re-equips). Never stored. | `Entity.HardpointAt`, through `TryEquip`. No reader derives it from the origin cell. |
| Targeting index | `Zone.Targets`, one per zone. | Built with the zone. Providers register as belts are built and entities admitted. Rebuilt on load; never saved. | `TargetingIndex` for candidate sets and bounds; each provider for its own motion; `Entity` for detection. |
| Belt band (provider region) | Belt key plus band index; arc segments by key order. | Derived from `AsteroidBeltData`, `PlanetSettings` and time. Re-keyed lazily, a bounded number per query. Cache only. `mining-belt-cells` replaces it with rings and cells. | `BeltTargets`. |
| Belt ring and cell (after `mining-belt-cells`) | Belt key, ring index, cell index. A ring is a run of rocks by orbit distance; its cells are equal sectors of a frame turning at the ring's mean rate. | Derived when the belt is built, never saved. A ring is refreshed (its rocks re-bucketed) when its drift passes one cell, within a rock budget per query. | `BeltTargets`. |
| Target choice | The `TargetRef` a key press writes. | Made per press; no list is kept between presses. | `Entity` (reticle, next, previous, nearest), through `SetTarget`. |
| Floating item | `FloatingItemId`: a zone counter, never reused in a zone. | Released at death (and later by jettison); drifts; held during a grab; removed into cargo by `TryPickUp`, or at `FloatingItemLifetime`. Not saved: gone when the zone unloads, as Unity loot is today. | `Zone`: release, step, expiry and commit. |
| Loot dice | Zone `CombatSeed`, loot stream, death ordinal. | One generator per death, discarded after the roll. | `Zone.DropLoot`, on `SimulationDice`. Never `UnityEngine.Random`, never a shared stream. |
| Grab | Holder entity plus `FloatingItemId`. | Started, Extend, Envelop, Pull, then Completed or Cancelled. One holder per item, one grab per Pickup. | The `Pickup` behaviour owns the timeline; `Zone` owns the held pose and the commit. |
| Pickup capability | `PickupData` (BehaviorData union key 40) on the Tractor Beam design. | Authored by an AetherDb one-shot, then edited in the catalog. Every generated ship carries one. | The catalog author. |
| Ammunition | An `AmmoType` commodity; one unit is one magazine. | Stocked at generation and in station inventories; consumed one unit per reload; moved by `TryTransferItems` when rearming. | Cargo. `WeaponData.DrawsRounds`, derived from `AmmoType`, decides reactor or magazine. |
### Merge order
Each lane becomes a `eureka/aetheria-release-*` branch; nothing is pushed to
`codex/*`. In order of landing on master:
1. **`ships-merge`.** Its spec exists; ruling `ships-merge-on-agent-proof`.
2. **`scenarios-adopt`.**
- Master (with ships) is merged into scenarios first. The only conflict is
`AetheriaStores.cs:9`. The blocker fix follows.
- Scenarios goes before mining for two reasons: its remaining work is one small cut,
and mining's merge rule was written to resolve scenarios' side.
3. **`mining-index`, then `mining-index-pins`, then `mining-target-queries`**, on the
mining branch. They touch only mining's own code, so they run in parallel with
steps 1 and 2. The pins land first, so the same tests guard `Best` when
`mining-target-queries` gives it callers.
4. **`mining-merge`.** Master (with ships and scenarios) is merged into mining under the
recorded rule, and the field kinds are applied again to scenarios' catalog.
5. **`feedback-1` and `audio-1`** may land at any time: their hunks are disjoint from
all three lanes (B10). `feedback-1` must land before `loot-2`.
6. **`loot-1` to `loot-3`**, on one branch from master after step 4, merged together.
They wait for step 4 because mining's belt code rewrites `Zone.cs`, and the loot
cuts edit `Zone`'s constructor and `Update`.
7. **`ballistic-ammo`**, after step 4: it needs scenarios' `LoadoutGenerator` and
mining's `Weapon.cs`. It is independent of the loot cuts.
8. **`faction-play-1` to `-3`**, re-anchored first (follow-up `faction-play-reanchor`).
`faction-play-1` rewrites lines both lanes changed, so it starts only after step 4:
- mining's targeting lines: `Minion.cs:14-21`, `Combat.cs:28`, and `Target` as a
`TargetRef` written through `SetTarget`;
- scenarios' admission lines: `Zone.Admit`.
9. **`faction-play-4`** r2, after `faction-play-3` and `ballistic-ammo`.
10. **Jettison, then pirate collection**, on `Zone.Release` and the grab, after step 6.
11. **`mining-index-tree`, then `mining-belt-cells`**, at any time after step 4, on a
branch from master. They gate nothing, neither the merge nor the release (ruling
`targeting-sublinear-wanted-not-critical`). They inherit the tests of
`mining-index-pins` and `mining-target-queries`, and tighten the cost pins.
### Mining index: as built (master `df7c44f2`)
Status, read this before L3 to L5 and the cuts above. They are the plan and the pre-cut
Body. `eureka/aetheria-release-mining` merged to master as `df7c44f2`. Landed: `mining-index`
(`cut-mining-index.h1`), `mining-index-pins` (`cut-mining-index-pins.h1`),
`mining-target-queries` (`cut-mining-target-queries.h1` and `.h2`) and `mining-merge`
(`cut-mining-merge.h1`). Not landed: `mining-index-tree` and `mining-belt-cells`. L3's
scan, L5's patch and the belt task machine no longer exist.
**Owner, by question.**
- *Which things can lie in a region, and how visible at most:* `TargetingIndex`
(`ServerShared/Targeting/TargetingIndex.cs`), one per zone as `Zone.Targets`. It names
no belt, rock or chunk. It owns region ordering and reach.
- *Where rocks are and which regions bound them:* `BeltTargets`, one provider per belt,
registered in `Zone`'s constructor beside `EntityTargets`.
- *Is this target detected, and which one does a press pick:* `Entity`
(`ChunkVisible`, `VisibleEntities`, `TargetUnderReticle`, `TargetNext`,
`TargetPrevious`, `TargetNearestEnemy`), through `PickTarget` and `Eligible`.
- *Who writes the choice:* `Entity.SetTarget`, the one writer.
- *Rock wear and respawn:* `Zone`'s wear store (`ChunkWear`, `BrokenUntil`). Belts have
no task, timer list or update thread: `EvaluateBelt` computes poses on demand for
rendering, and `BeltTargets` derives bounds from time.
**Inputs and outputs.**
- `TargetSearch` in: searcher position, reach, optional observer, and
`ReachPerVisibility` (`Entity.DetectionReachPerVisibility`, from sensor gain bounds and
the curve's hull).
- Providers out: `TargetRegion`s (distance interval, bearing interval, visibility
ceiling) and `TargetCandidate`s (a `TargetRef` and a planar position).
- Index out: `Within` returns every candidate within exact reach whose region can be
detected, unordered. `Best` takes an `ITargetKey` (`Bound`, `Key`, `Precedes`) and
returns the least-key eligible candidate: regions are sorted by bound and opened until
a bound exceeds the best key.
- `Entity` presses: `AngleKey` (reticle: least planar angle to the look direction) and
`DistanceKey` (next and previous: the successor or predecessor in (distance, target)
order, wrapping, with the held target as pivot). Ties order a ship before a rock,
then by chunk field and index.
- `Entity.VisibleChunksInReach` keeps its contract (visible chunks within mining reach,
by index then field) on `Within`.
**Derived state, never saved.** The index and every provider's bands, keys and `Rekeys`
count are derived from `AsteroidBeltData`, `PlanetSettings` and zone time and rebuilt
with the zone. Bands re-key lazily, at most 8 per query. `Examined` is a diagnostic
counter, not state. No candidate list survives a press. The only persisted belt state is
`ZonePack` key 6 (`ChunkWear`).
**Forbidden writers.**
- Unity code picks no target. `ActionGameManager` binds four input actions to the four
`Entity` methods and does nothing else (`ActionGameManager.cs:389-392`). It builds no
candidate list, sorts nothing and calls neither `Within` nor `Best`.
- Nothing enumerates a belt's rocks to answer a targeting question. The only loop over
a belt's rocks outside `BeltTargets` is `Zone.EvaluateBelt` (rendering).
- The index decides reach and nothing about detection. A provider's bounds must hold for
every thing in the region; a provider that loosens them costs speed, one that tightens
them past a real rock drops targets.
- Only `SetTarget` writes `Target`; presses return false and keep the target when no
candidate exists.
- No `UnityEngine.Random` or shared stream in target choice (it has none).
**Shared paths.** Reticle, next, previous and nearest-enemy presses; `VisibleChunksInReach`;
fire control's use of `Target` (`TargetRef`); the AI's `Minion` and `Combat` target
writers (F6, still `SetTarget`); load (`Zone` rebuilds providers).
**Verification layer.** `tests/Aetheria.Shared.Tests/MiningIndexTests.cs`: index
equals brute force (`WithinIsExactlyTheBruteForceSet`, `BestIsTheBruteForceNearest`),
region bounds hold per rock (`BeltRegionBoundsHoldForEveryRock`,
`ACurveStaysWithinItsHullRange`), no visible rock dropped, cost pins (`Examined`,
`DarkBandsAreSkippedWhole`, `ReKeyingIsBoundedPerQuery`, `RegionsStayNearTheSearchArc`,
`VisibilityBoundPrunesADimBelt`) and `ARockExactlyAtReachIsFound`.
`MiningTargetQueriesTests.cs`: press rules against a brute-force order, perception
(`ShipsTheObserverHasNotDetectedAreNeverPicked`), tie orders, empty presses, and
per-press cost ceilings (`AKeyPressDoesNotGrowWithTheBelt`,
`PressPruningKeepsEachPressNearItsMeasuredCost`). Both are partial `MiningCut3Tests`
and compile only `ServerShared` (B11). The Unity side was compiled separately: a
Unity 6000.3.24f1 batchmode compile at `a83aa54e` on 2026-10-06 (`mining-merge-unity`)
exited 0 with no `error CS` in any of the five assemblies. Both ships smokes passed, and it
needed no fix commit, so the cut has no report. Stryker and `tools/mutation-table.sh` covered `Entity.cs` spans fully; survivors in
`BeltTargets` and `TargetingIndex` are equivalent or float boundaries.
**Live seams and debt.**
- `EntityTargets` is registered on every zone but no production query reaches it with
an observer: `Entity.PickTarget` offers `VisibleEntities` itself, and `MiningSearch`
carries no observer. The index owns no ship answer today. Finding
`orphaned-index-surfaces`, deferred to `mining-index-tree`.
- `Entity.VisibleChunksInReach` has tests as its only callers; `Within` has no
production caller.
- A press still costs O(rocks in a fixed belt): `Best` enumerates and sorts the flat
region set. Deferred to `mining-index-tree` (hierarchy, heap, `Accepts` split) and
`mining-belt-cells` (rings and cells); findings `cut-mining-index-tree.r1` and
`cut-mining-belt-cells.r1` carry them. Neither gates the release.
- The index comment headers (`TargetingIndex.cs:10`, `Zone.cs:26`, both test files)
cite `docs/aetheria-release-map.md`, which exists only on
`eureka/aetheria-release-target`, not on master.
### Mining index: rings, cells and best-first search
**Why the press still grows.** At `a4e8e147`, after `mining-target-queries`, a press is
one `Best` query. Its cost has two linear terms in the rock count of a fixed belt:
- **Region enumeration.** `Best` asks every provider for its whole flat region set,
bounds each region and sorts the set, before opening one. A band holds a fixed 256
rocks, so at 3M rocks a band is 0.05 units wide and a full ring. A 400-unit search
crosses about 8,000 of them, and each contributes its arc segments: 30k-64k regions,
38-61 ms.
- **Sliver regions.** A segment of 32 rocks in such a band is a 0.05 x 470-unit
sliver. A sliver near the searcher has a small bound but holds rocks spread along
hundreds of units, so best-first opens many slivers for few useful rocks.
**The floor.** The press queries are exact: the smallest angle to the look direction
(reticle), and the successor in (distance, target) order (next, previous). In linear
space these are thin-cone and thin-ring range queries. Any tree of cells must open
every cell that the cone's ray or the ring's circle crosses: about the boundary's
length over the cell size. That is O(sqrt(n)) for n rocks in reach, the kd-tree
line-query bound (Lee and Wong 1977), and no linear-space structure beats it for
simplex ranges (Chazelle 1989). The target is therefore sqrt(n), not flat. A flat
cost needs an approximate answer (question `reticle-exactness`).
**Target growth.** In a fixed belt, regions opened plus rocks examined per press grow
as the square root of the rock count: at most 4.5x per tenfold rocks (sqrt(10) is
3.16), against 10x at `a4e8e147`. Visibility tests per press (`ChunkVisible`, the
expensive part) stay within 2x per tenfold. Building stays O(n) per belt, and a
refresh costs O(ring).
**Proof by measurement.** Soul's F1 fixture: rocks 300..900 about the sun, light radius
1000, kind cross-section 5, sensor Eye, guns 150 and 400.
- Run it at 30k, 300k and 3M rocks, with the eye at chunk 0 + (5, 5), at (310, 0) and
at (600, 0).
- Presses: five `TargetNext` from no target, one `TargetPrevious` from no target, and
`TargetUnderReticle` looking along the belt and across it.
- Record `Opened`, `Examined`, `Tested` and time. Repeat after +300 s (stale rings)
and after +1e7 s.
- The suite pins 30k against 300k. The 3M point is a Soul probe, too slow for the
suite.
- Expected at 3M, with 16-rock cells about 3.5 units across: low thousands examined
and hundreds of regions opened per press. Today it is 37k-108k examined and 30k-64k
regions.
**The design.** The index owns search order. The belt owns rock motion and cell bounds.
- **`mining-index-tree` (index only).** The provider seam becomes a hierarchy, `Roots`
and `Open`. Opening a region yields child regions, candidates, or both.
- `Best` keeps a binary heap of regions by their bound (netstandard2.1 has no
`PriorityQueue`). It stops when the next bound exceeds the best key. This is
Hjaltason and Samet's incremental distance browsing, now over a tree.
- `Within` walks the tree depth-first, pruning with `CanHold`.
- `ITargetKey` splits into a cheap geometric `Key` and an expensive `Accepts`
(detection). `Accepts` runs only for a candidate that would beat the best so far.
- New diagnostics beside `Examined`: `Opened` (regions opened) and `Tested`
(`Accepts` calls).
- `BeltTargets` and `EntityTargets` are adapted mechanically: today's flat regions
become roots that open into candidates. Answers and costs do not change.
- **`mining-belt-cells` (belt only).** `BeltTargets` is rebuilt as rings of square
cells.
- Rocks sorted by orbit distance are cut into rings. A ring closes once its width
reaches its cell arc (2 pi r x 16 / count), so cells are about square at any
density. A ring also closes at a cap of 8,192 rocks, which bounds one refresh.
- Each ring turns at its mean rate. Its cells are equal sectors of that turning
frame, about 16 rocks each, held as one index array with cell offsets.
- A rock drifts against its frame by at most the ring's rate half-spread times the
time since the ring was refreshed. That drift widens every cell's sector, so the
bounds stay sound without touching a rock.
- When the drift passes one cell, the ring is refreshed: every rock is re-bucketed
by its current turn in one O(ring) counting pass, with no sort.
- A refresh happens only when a ring node is opened, before any of its cells exist
in the query, so a query never sees a rock twice.
- Each query refreshes at most 16,384 rocks. A ring left stale is searched through
its wider sectors, which stays correct.
- The tree is implicit: a balanced range tree over ring indices (annulus bounds),
whose ring leaves open into halving cell ranges (sector bounds), down to single
cells. Today's `Sector` bounds and light bound carry over.
- Drift in cell units grows at about pi r |d rate / d r| per second, whatever the
density.
- With period = distance, a ring goes stale every r / pi seconds: about 190 s at
r = 600.
- Refresh work per unit time is O(n) per belt, amortised over the presses that touch
it.
**Prior art**, cited from memory and not re-fetched:
- Hjaltason and Samet, "Distance browsing in spatial databases" (ACM TODS 1999): the
heap-ordered search that `Best` becomes.
- Šaltenis, Jensen, Leutenegger and Lopez, "Indexing the positions of continuously
moving objects" (SIGMOD 2000), the TPR-tree. Its bounds widen with elapsed time by a
velocity bound and are tightened on update. Here that is the drift-widened sector
and the refresh.
- Basch, Guibas and Hershberger, "Data structures for mobile data" (SODA 1997). An
event-driven kinetic order is rejected: every pair of rocks with different rates
eventually swaps, so events grow as the square of a ring's size.
- Arya, Mount, Netanyahu, Silverman and Wu, "An optimal algorithm for approximate
nearest neighbor searching in fixed dimensions" (JACM 1998). A cost flat in density
is reachable only for an approximate answer (question `reticle-exactness`).
- Space games: EVE's belts are static, and Elite Dangerous generates ring rocks per
cell around the player. Neither simulates per-rock orbital shear, so they bear on
the visual budget, not on this index.
**Forks.**
- `reticle-exactness`: keep the reticle exact at sqrt(n), or accept an angle tolerance
for a flat cost. Next and previous stay exact either way, because cycling must visit
every target once.
- `belt-size-tiers`: give each size class its own provider, so small rocks are pruned
at their shorter detection reach. This is a constant factor on `Tested`, not a
growth fix.
### Rationale
**Why every item carries its hardpoint, rather than keeping the origin inside it.**
Soul named both fixes.
- Keeping the origin inside the hardpoint forbids legal placements. An L-shaped gun
could never fill an L-shaped hardpoint whose corner is empty, because the gun's own
origin cell is that corner (P30 (a)).
- Looking the hardpoint up by the item's cells at every reader would put the placement
rule in 18 places.
The placement rule already knows the answer at equip. Keeping that answer deletes 18
re-derivations; it adds nothing.
**Why the index is generic and the belt is a provider.**
- The operator ruled it (L4).
- The seam is the smallest that carries both providers in the tree today and the ones
the operator named, EW and microfauna. A provider yields regions with a distance
interval, a bearing interval and a visibility ceiling, and the candidates for a
region.
- The index owns no motion model. A provider for orbiting rocks, one for moving ships
and one for a slime field each keep their own.
- Prior art, cited from memory and not re-fetched:
- best-first incremental search over a region hierarchy: Hjaltason and Samet,
"Distance browsing in spatial databases" (ACM TODS 1999);
- kinetic sorted orders for moving points: Basch, Guibas and Hershberger, "Data
structures for mobile data" (SODA 1997).
**Why the index and the key-press rules are two cuts.**
- The index alone removes the 633k-rock scan, but leaves the handlers sorting every
visible rock (2,240 at 3M) on each press.
- The operator's rule is that a press must not grow with belt size. Only best-first
answers meet it. After Soul's first pass on the index, the operator accepted the
remaining growth with rock density (see below).
- Together the two are about 900 lines with tests, over one Hands budget. The seam
between them, the index's query API, is where Soul can falsify each.
**Why the index fix before the merge is only pins, and the cells come after.**
- Soul (verdict `cut-mining-index.s1`) measured the index at `a4e8e147`. It is correct,
and the 3M-rock press fell from 4.3 s to 150-600 ms.
- Press cost still grows linearly with rock count in a fixed belt (finding
`f1-press-linear-in-density`).
- The operator ruled that this is not critical, but that the improvement is wanted
(ruling `targeting-sublinear-wanted-not-critical`, which supersedes
`targeting-density-scaling-accepted`): "O(sqrt(n)) is way better than O(n), my
ruling says it's not critical, not that it's undesired".
- So the merge waits only for `mining-index-pins`: three tests for findings
`cost-loosening-unpinned` and `exact-reach-unpinned`, which the suite could not see.
- The share of regions past reach catches a widened arc window or a loosened
`Nearest`.
- Examined against an exact per-rock bound catches a loosened visibility bound.
- A rock exactly at reach, searched from beyond its belt's outer edge too, catches
a slack sign slip.
- `mining-target-queries` r1 stands as specced. Its `AKeyPressDoesNotGrowWithTheBelt`
scales area at constant density, which both rulings leave in force.
- The sublinear structure is the section "Mining index: rings, cells and best-first
search" above, as two cuts after `mining-merge`.
**Why loot is three cuts on one branch, merged together.**
- The ruled design (L8) needs three things:
- floating items in the simulation (`loot-1`);
- the grab as a capability, with its catalog entry and generation (`loot-2`);
- the presenters (`loot-3`).
- Together they are about 1,000 lines.
- Merging after `loot-1` alone would ship a game where loot cannot be collected,
because `loot-1` deletes the Unity collection that the ruling retires.
One branch keeps master playable, and three cuts keep each Soul pass small.
**Why floating items are not saved.** Today's Unity loot is not saved either, and a
floating item lives 30 seconds. Saving them would add a `ZonePack` field and a
migration for a window shorter than a save cycle. Jettison may want longer lifetimes;
its cut can revisit this with a reason.
**Why the loot dice are the zone's, keyed by death ordinal.**
- Fire control's Cut 6b rule is that a roll belongs to the thing rolled for, not to a
shared stream, so a UI or another system drawing first cannot change it.
- The loot roll follows the same rule with its own stream, so it cannot disturb shot
dice either (test `LootDiceDoNotTouchShotDice`).
- The mixing function moves to one owner (`SimulationDice`) instead of being copied.
The owner-level fix is CultMath's (follow-up `cultmath-mixed-seed`).
**Why ammo-fed weapons leave the power bus.** By L9, a weapon on the bus reads its
tier's grant ratio even when it requests nothing. A request of zero would still let a
brownout silence a gun whose energy comes from its rounds. Off the bus, its supply is
1 and the reactor cannot touch it. That makes the ruling's trade-off literal: energy
weapons compete for reactor power, and ballistic weapons compete for cargo.
**Why one ammunition commodity.**
- The catalog has one (`Ammo`).
- The mechanism is data (`AmmoType` per weapon), so a split by calibre is a catalog edit
that needs no code.
- A tender carrying one commodity is also the simplest rearm loop to prove.
This is a default, not a ruling. The operator can ask for calibres at any time.
**Why mining's Q6a should be asked again.** Mining ruled that ore is lost when a hold
is full (Q6a A), because pickups were Unity-only (mining-cut.md:286-287). After
`loot-1`, overflow could float through `Zone.Release` like any other loot. Follow-up
`mining-cuts-4-5-7` carries the question to that map.
## Controls
The control rebuild of ruling `facing-separate-from-aim`, which ruling
`demo-controls-rebuild` puts before the demo. The pilot steers the hull's facing with
its own input, the cursor aims, fixed mounts fire when the aim is inside their arc and
turrets track. Articulated mounts (follow-up `articulated-mounts`, ruling
`arc-and-traverse-on-the-link`) are full game; this section keeps every arc read behind
the seam they will plug into. Anchors are against `origin/master` `228f241e`, read in a
detached scratch worktree on 2026-10-06.
### Prior art
Sources are marked: **primary** (the game's own source, manual or developer post, read
in full), **wiki** (a community wiki page read in full), **snippet** (a search-result
summary only, not read at source; treat as unverified).
- **Starsector** (wiki: the fandom `Piloting` page, read through the MediaWiki API; the
Fractal Softworks manual PDF could not be fetched). WASD is ship-relative: A and D
turn, Ctrl-A and Ctrl-D strafe. Holding Shift makes the ship "automatically point
towards the mouse"; a setting makes that the default. The mouse aims the selected
weapon group, which tracks the cursor and fires on the left button. Shift+1-5 puts a
group on autofire: those weapons pick and attack their own targets, and the HUD marks
the group with a filled square. R designates the ship under the mouse, and weapons
"preferentially fire at the target". The wiki's own advice is to put everything except
missiles on autofire so that the mouse governs only shields. That advice is the
community's answer to the core failure of a cursor-aimed, arc-limited model: holding
the cursor inside a narrow arc while manoeuvring is hard (snippet, forum). Vanilla
draws weapon arcs, and a popular mod redraws them (snippet).
- **Endless Sky** (primary: `source/Preferences.cpp` and `PreferencesPanel.cpp` on
`master`). Fixed guns fire along the hull; turrets track. Player options: "Automatic
aiming" off / always on / when firing (default when firing), "Automatic firing" off /
on / guns only / turrets only (default off), "Aim turrets with mouse", "Turrets focus
fire" (default on), "Control ship with mouse", and "Turret overlays" off / always on /
blindspots only (default blindspots only). So the arc display defaults to showing only
where the guns cannot reach.
- **Escape Velocity Nova** (primary: the Nova Bible, weapon `Guidance` field). Keyboard
turning; fixed guns fire ahead. Guidance 7 is a "front-quadrant turret" that can fire
plus or minus 45 degrees off the nose and "fires straight ahead if no target";
guidance 8 is the rear quadrant. A designated target pulls a limited-arc turret's
rounds onto it, and without one the mount fires down its axis. This is the closest
precedent for Aetheria's designation-based fire control.
- **Cosmoteer** (primary: developer blog, "Cosmoteer 0.14.11 - Arcade Style Ship
Controls"). Direct Control: W and S thrust, A and D rotate, Q and E strafe; the mouse
aims and fires. Turreted weapons fire on the left button and fixed weapons on the right
by default, rebindable per weapon. Rotating to face the cursor is an opt-in setting.
- **Nebulous: Fleet Command** (snippet). Weapons engage designated targets on their own
within their arcs; ships auto-orient to bring mounts to bear. Its known failures are
arc failures: narrow-arc guns idle while the ship searches for an angle that brings
every turret to bear, and turrets report "target masked by hull".
- **Battlestar Galactica Deadlock** (snippet). Broadside, bow and stern batteries have
about 90-degree arcs; dorsal and ventral guns cover 360 degrees with limited elevation.
Arcs show when a turret is clicked in the radial menu, and a key toggles them on the
selected ship. Players asked for more visible arcs (a Steam thread title).
- **SubSpace and Continuum** (snippet). Arrow keys rotate, up thrusts, guns fire along
the nose: the pure nose-aim model this ruling retires.
- **World of Warships** (snippet), added because Aetheria's camera is a chase camera, not
top-down (body fact C7). The mouse orbits the camera and aims; the keyboard is the helm.
Guns traverse toward the aim at a turret traverse speed. The classic complaint is "the
guns won't fire though the view looks on target": the turret has not finished
traversing or cannot bear, and the reticle's gun markers carry that state.
**Failure modes to design against.**
1. "My guns don't fire": the aim or target is outside a mount's arc, or a turret has not
traversed yet, and nothing on screen says so (Starsector forum, Nebulous, World of
Warships). Answer: the HUD shows each group's state where the eye already is (the
group crosshair and the arc fan), and per-group autofire exists for players who would
rather manoeuvre.
2. Holding the cursor inside a narrow arc while also flying the ship is hard (Starsector).
Answer: a hold-to-face modifier for nose guns, and autofire for side guns.
3. Over-eager auto-orientation fights the player (Nebulous ships hunting for an angle).
Answer: the player's hull never turns itself; only an AI hull turns to bring a group to
bear, and it chooses one group.
4. Arcs drawn everywhere become clutter. Answer: draw only arcs below 360 degrees, the
Endless Sky "blindspots only" default.
### Body facts
- **C1. The input layout.** `Assets/Resources/Aetheria.inputactions`, map Player: `Move`
(Vector2: W/S as y, A/D as x, gamepad left stick), `Look` (pointer delta, right stick),
and `Turn` (1D axis, Q negative, E positive), which no script reads. Fire is the action
bar: `ActionGameManager.cs:101-103` binds the left, right and middle mouse buttons to
action-bar slots, and `ActionBarWeaponGroupBinding.Activate` (`ActionBarSlot.cs:189-195`)
activates every weapon in its group. No Shift binding exists in the Player or Global maps.
- **C2. The player's steering is the aim.** `ActionGameManager.cs:1277-1281`: the pointer
delta (cursor locked, `:648`) integrates `_entityYawPitch`, which becomes
`_viewDirection`, which is written to `CurrentEntity.LookDirection`. `:1290` writes
`Move` to `ship.MovementDirection` (x strafe, y thrust).
- **C3. `LookDirection` is the facing command.** `Ship.Update` (`Ship.cs:278-291`) sets
`deltaRot = dot(look, right)`, shapes it `sqrt(|d|) * sign(d)`, feeds it to the rotation
thrusters and the aether drives' `Axis.z`, and below `|d| < .01` writes
`Direction = lerp(Direction, look, ...)` directly. So aim and facing are one field.
- **C4. Turning is kinematic.** No angular velocity exists. A thruster rotates
`Entity.Direction` by `input * Torque * Thrust * TorqueMultiplier / Mass * dt`
(`Thruster.cs:106-107`); an aether drive by `force.z * axis.z * AetherTorqueMultiplier /
Mass` (`AetherDrive.cs:154-155`). `Settings.asset` (LFS): `TorqueFloor 0.5`,
`TorqueMultiplier 0.1`, `AetherTorqueMultiplier 0.1`. `Ship.TurnTime` (`Ship.cs:61-66`)
estimates a turn. Turn rate is therefore torque over mass times one global multiplier.
- **C5. FireControl's arc.** `ArcFor` (`FireControl.cs:36-41`) is
`HardpointData.FiringArc` (`ItemData.cs:569`, key 6) when above zero, else
`GameplaySettings.FiringArc` (`Settings.cs:214`, initializer 120; the field is absent
from `Settings.asset`, so 120 holds). `InArc` (`:50-58`) compares a planar bearing with
`MountDirection` (hull `Direction` rotated by the item's rotation); 360 or more passes,
and a bearing under 1e-6 passes. `AimDirection` (`:116-126`) clamps `LookDirection` to
the arc's nearer edge. The Turret hull's hardpoints author 360 (`AetherDb
firing-arc-migrate`, `Program.cs:1037`).
- **C6. The trigger gate reads the target, not the aim.** `ArcPermitsFire` (`:76-88`):
with no target it passes; with a target it tests the target's bearing, and a fused
weapon passes anyway (operator ruling 2026-09-30, `fire-control-cut.md:2628-2640`).
`PFire` (`:256-270`) prices a shot at zero out of arc. `Solve` (`:440-473`) flies a
round to the target's intercept when it is engaged (`TravelDirection`, `:396-400`), and
along `AimDirection` otherwise. So the cursor today decides only facing, reticle
targeting (`Entity.TargetUnderReticle`, `Entity.cs:407-412`), no-lock fused flight,
the lock cone (`LockWeapon.cs:100`), the tractor (`ShipInstance.cs:116`), guided
rounds' aim point (`GuidedProjectileManager.cs:79`) and loot's view direction
(`ZoneRenderer.cs:440`).
- **C7. The camera is a chase camera that looks at the aim.** `FollowCamera` follows the
ship and looks at `EntityInstance.LookAtPoint`, set each frame to ship position plus
`LookDirection` times the target range, or 10,000 with no target
(`EntityInstance.cs:401-402`, `ActionGameManager.cs:1039-1040`). The scene's two
framing transposers (`ARPG.unity:4963`, `:21276`) position the camera and do not rotate
it, so the camera's heading comes from the aim and survives the split. Which of the two
belongs to `FollowCamera` was not resolved.
- **C8. What the HUD draws.** `UpdateTargetIndicators` (`ActionGameManager.cs:1390-1422`):
`ViewDot` at `LookAtPoint`; one crosshair per articulation group at the mean of its
barrels' forward rays (`_articulationGroups`, `:1041-1057`, grouped by the barrel's
`ArticulationPoint.Group`); the target indicator; lock indicators. `Update` fills the
target panel's bars and `UpdateFireControlDebug` (`:1319-1380`) prints the fire-control
gates as text, including `arc {d.InArc}`. No arc, no lead marker and no predicted path
is drawn.
- **C9. The barrel picture has its own arc.** `ArticulationPoint` (Unity) slews a barrel
toward `Target` (every point's target is `LookAtPoint`, `EntityInstance.cs:278-283`)
at `Speed` and clamps yaw to `YawMin..YawMax`, authored by `ShipPrefabAuthoring.cs:141-148`
and unrelated to `FiringArc`. Package hulls have none (`ShipModShips.cs:164`): their
weapons are points, so every package-hull weapon lands in group -1 and one crosshair.
- **C10. AI writers treat `LookDirection` as facing.** `Agent.Accelerate`
(`Agent.cs:59-79`) writes it to turn toward the velocity error. `Combat` (`:98-128`)
calls `Accelerate(noTurn: true)` and then writes `LookDirection = toTarget` (the
intercept), which turns the nose to the target. `MoveTo.cs:26` writes it.
`TurretController.cs:74-77` writes it; a turret hull has no rotation thrusters, so it
only aims. Faction-play r1's new `FollowState` "looks at the anchor" and `FleeState`
flies through `Accelerate`, so they inherit the same assumption; their r2 (follow-up
`faction-play-reanchor-demo`) must use the helm port below.
- **C11. The Arcs scenario assumes nose aim.** `Scenarios/Arcs.cs`: forward mounts, a bare
hull off the bow and one off the stern, a hostile 360 turret. Its check "nothing fires at
the stern one through the ship" could only be reached by turning, because turning the
view turned the hull. Checklist item 11 (`merge-to-master-checklist.md:49`) also wants
side mounts firing abeam.
- **C12. Weapon groups persist.** `Entity.WeaponGroups` (`Entity.cs:69`) is saved as
`EntityPack` key 16 (`EntitySerializer.cs:189`) and `Loadout` key 3 (`Loadout.cs:19`).
### Authority map
- **Owner.** `Ship` owns facing: `Ship.Update` turns the hull from one demand,
`Ship.Turn` (-1..1, positive clockwise), and from nothing else. `Entity.Aim`
(`LookDirection` renamed) owns where the pilot points. `FireControl` owns every mount
question: its arc (`ArcFor`), its axis (`MountDirection`), whether its trigger is free
(`ArcPermitsFire`), and the one direction its next round flies (`MountAim`, new).
- **Inputs.** The player's `Turn` axis and hold-to-face button, the pointer delta, the AI
states' chosen headings, the hardpoint's `FiringArc` and item rotation, the designated
target.
- **Outputs.** Hull rotation through the thrusters and drives; the trigger gate; each
round's flight direction; the facts the HUD draws.
- **Derived state.** `Steering.Toward(ship, heading)` derives a turn demand from a
heading (the C3 law, moved). The barrel picture, the group crosshairs, the arc fans,
the lead marker and the predicted path are display-only derivations of FireControl and
`Ship.Coast`.
- **Demotions.** `LookDirection` is no longer an owner of facing; it is renamed `Aim` and
steers nothing. `ArticulationPoint` is no longer an owner of arcs; its yaw derives from
`MountAim`. The barrel transforms are no longer the source of the group crosshair.
- **Forbidden writers.** Any write of `Entity.Direction` outside the thrusters, the aether
drives, wormhole exit, load and staging (the `lerp` snap in `Ship.Update` goes). Any
read of `Aim` in `Ship.Update`. Any read of `FiringArc` outside `FireControl.ArcFor`,
hardpoint validation and AetherDb. Any arc, bearing or intercept arithmetic in
`Assets/Scripts/Gameplay` or `UI`.
- **Shared paths.** The player and every AI state reach the hull only through `Ship.Turn`;
player and AI rounds leave through the same `MountAim`; the HUD, the barrel picture and
`Solve` read the same `MountAim`.
- **Deletion line.** Before any new behaviour: the facing block of `Ship.Update`
(`Ship.cs:278-291`) is replaced, the `LookDirection` field is renamed so that every old
writer fails to compile until it chooses aim or helm, and `ArticulationPoint`'s yaw
clamp is deleted.
### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Turn demand (`Ship.Turn`) | Per ship, runtime only. | Written every frame by the player input or the ship's agent; never saved. | The pilot (input or agent), through `Steering.Toward` for headings. |
| Aim (`Entity.Aim`) | Per entity, runtime only. | Written every frame by the player's view or the agent; never saved (as `LookDirection` was not). | The pilot. |
| Mount aim | Derived per weapon from aim, target, arc and mount. | Computed on read. | `FireControl.MountAim`. |
| Gun solution | Derived per weapon from its subject, arc, mount and aim. | Computed on read. | `FireControl.Solution`. |
| Face-the-aim state | Per player session, runtime only. | Held by Left Shift, latched by Caps Lock; never saved. | `HelmInput`. |
| Arc-display preference | `PlayerSettings` player store. | Edited by a toggle; persists. | The player. |
### Rationale
**Why rename `LookDirection`.** A rename makes every old writer stop compiling until
someone decides whether it meant aim or helm. Keeping the name would leave AI states that
mean "turn there" compiling as "aim there", silently, which is exactly the split-brain the
rebuild removes. The Gameplay and test call sites are mechanical.
**Why a turn demand rather than a desired heading.** The player's keys command a rate;
the AI and the hold-to-face button command a heading. A demand is the narrower port: a
heading converts to it through one function (`Steering.Toward`), and a rate needs no
conversion. A heading port would force the keys to invent a far-off heading.
**Why the cursor gates and the target lands.** The ruling's words make the aim the
trigger gate: a fixed mount fires when the aim is inside its arc. FireControl's lock
model, its hit pricing and its tests are built on a designated target. Question
`controls-target-pulls-rounds` asks whether a designated target inside the arc pulls the
rounds and turrets onto it (Escape Velocity's front-quadrant turret), or whether rounds
always fly along the clamped aim, which would reopen FireControl's pricing.
**Why traverse is not simulated in the demo.** Traverse speed is a stat of the moving
part (ruling `arc-and-traverse-on-the-link`), and that model is full game. `MountAim` is
stateless now; articulated mounts make it stateful behind the same signature, and every
caller (Solve, the trigger, the HUD, the barrel) already reads it, so nothing else moves.
**Why the predicted path is a coast.** It answers "where does this hull go if I let go",
which is the question gravity wells and slower turns make hard. It reuses the hull's own
drag and gravity step (extracted to `Ship.Coast`), so the line cannot disagree with the
simulation. Thrust is not projected: it changes every frame.
**Cut order.** `controls-helm` first: the split itself, and the rename forces every writer
to be ported in the same cut. `controls-mount-aim` second: the trigger and the one round
direction (revised by "Firing on solutions" below into the per-gun solution). Then
`controls-hud` and `controls-ai-bearing`, which are independent of each other.
`controls-autofire` was withdrawn under ruling `fire-on-solutions`.
### Firing on solutions
Revision of 2026-10-06 after the operator's rulings on this section's questions:
`controls-target-pulls-rounds` (target-pulls), `controls-facing-input` (A/D turn, Q/E
strafe, Left Shift held or Caps Lock toggled faces the aim), `controls-turn-inertia`
(kinematic, tuned) and the direction `fire-on-solutions`: "Fire control should surface a
firing solution for any gun that's in arc, and the player should be able to fire on any
solution without aiming." The aim stops being the only trigger gate. Question
`controls-autofire` was withdrawn with it. Anchors are against `origin/master` `e1296f1f`,
whose code is unchanged from `228f241e`.
#### Prior art: firing without manual aim
Source marks as above.
- **Elite Dangerous** (wiki: the fandom `Category:Weapons` page, read in full through the
MediaWiki API). Three mounts of every weapon trade effort for output. Fixed mounts are
the strongest and shoot straight ahead with a few degrees of convergence. Gimballed
mounts track a locked target inside a cone and "act as a Fixed mount", firing straight
ahead, when nothing is locked. Turrets cover 360 degrees and take one global mode:
Forward Fire (manual, as fixed), Target Only (track and fire on the locked target inside
their arcs once the trigger is pulled) and Fire at Will (any hostile that is firing at
you). Chaff breaks gimbal and turret tracking but not fixed mounts. So assisted fire is
paid for in damage and is deniable by countermeasures, and manual aim keeps its value.
- **MechWarrior Online and 5** (snippet). Weapons sit in numbered groups on triggers;
a group can chain-fire. Lock-on weapons (LRMs) cannot fire without a lock, which a
teammate or your own sensors must hold; Artemis shortens the lock.
- **Nebulous: Fleet Command** (snippet). Weapons are tasked on designated targets; ships
turn to bear. WCON "Free" lets untasked weapons return fire on any contact that attacks
the ship; "Tight" fires only on command; Hold Fire keeps the orders. Shift groups like
weapons into batteries such as fore and rear.
- **Battlestar Galactica Deadlock** (snippet, earlier pass). Batteries fire on ordered
targets within their arcs; arcs show on demand.
- **Freelancer** (snippet). The reticle leads the target, and fixed and turret guns are
all slaved to the mouse crosshair; the lead cross is the aim aid.
- **Starsector** (wiki, earlier pass). Autofire groups pick and attack their own targets,
preferring the designated one.
**Failure modes.**
1. Solutions nobody can read in a fight: a marker per gun per contact swamps the screen
(Elite's Fire at Will turrets spraying at whatever shoots, Starsector's autofire
picking its own targets). Answer: solutions are drawn per weapon group, against one
subject, on the group crosshair the player already reads.
2. No skill left: if every gun fires itself on everything, the player only steers.
Answer: the player still designates, chooses which group to fire and when, and steers
the arc onto the target. A solution needs targeting data (FireControl's `Designated`
and reveal tiers), so a contact you cannot see well gives no solution and free aim keeps
its value, as Elite's chaff keeps fixed mounts valuable.
3. Guns that silently refuse (the old "my guns don't fire"): a group whose trigger is
held and does nothing. Answer: each group crosshair shows one of three states, and the
arc fans use the same colours.
#### Body facts
- **C13. A solution type already exists.** `FireSolution` (`FireControl.cs:1809-1824`) is
`Solve`'s per-shot answer: outcome, `PFire`, `Designated`, the engaged entity and the
travel direction. `HitProbability` (`:316-324`) is the one live price, and `Inspect`
(`:331`) is its presentation-only breakdown. `PFire` (`:256-270`) holds the two gates a
solution needs: `Designated` (visible, in range, locked) and `InArc` of the target.
- **C14. A target is one entity per shooter.** `Entity.Target` holds one `TargetRef`
(an entity or a chunk); reticle, nearest, next and previous are its only player writers
(`ActionGameManager.cs:389-392`).
#### The design
- **A solution is per gun and has one owner.** `FireControl.Solution(Weapon, Entity
shooter)` answers, for one gun: the subject it is drawn against, whether it bears (the
subject is `Designated` and inside this mount's arc, the same two gates `PFire` already
applies, now one extracted function `Bears` that `PFire` also calls), and the direction
its round would fly (`MountAim`: the subject's intercept when it bears, else the
arc-clamped aim). The price stays `HitProbability`; the HUD asks it, never a copy.
- **What a solution is drawn against** is question `controls-solution-subject`. The specs
are written for the recommended option, the designated target.
- **The trigger.** A gun fires when its trigger is held and one of three holds, checked
in this order by one gate (`ArcPermitsFire`): it is fused (the 2026-09-30 exemption); it
has a solution, and fires on it without the aim; or the aim is inside its arc (free fire
along the aim). Otherwise it holds. Free fire with no solution is the
`facing-separate-from-aim` rule unchanged: a fixed mount fires along the aim when the aim
is inside its arc.
- **How the player fires on a solution** is question `controls-solution-trigger`. The
specs are written for the recommended option: the existing group triggers, with no new
input.
- **The HUD.** One crosshair per weapon group, in one of three states: on solution (it
sits on the subject's intercept, since `MountAim` is the intercept, and shows how many
of the group's guns bear and the best `HitProbability` among them); free (it sits on the
aim ray); holding (dimmed at the arc edge nearest the aim). Arc fans take the same
colours per gun. The separate lead marker of `controls-hud` r1 is dropped: a group
crosshair on solution is the lead.
- **Autofire is not mapped.** Its job was to let side guns fire without the aim; a
solution now does that on the group trigger. Firing with no input held is not ruled, so
`cut-controls-autofire` is withdrawn.
- **The AI is unchanged by this.** Agents already fire on what is in arc and worth it
(`AgentFires` over `HitProbability`), and their aim is their intercept, so `Solution`
and `AgentFires` agree for them. `cut-controls-ai-bearing` still holds.
- **The face-the-aim state** has one owner, `HelmInput` (engine-free, ServerShared): a
held flag fed by Left Shift, a latched flag toggled by Caps Lock, and `FaceAim` as their
union. While it is on, the hull turns toward the aim and the A/D axis is ignored. The
latch is the game's own state, not the keyboard's Caps Lock light, so the HUD shows a
small "face aim" mark while it is latched.
#### Authority map changes
- `FireControl.Solution` is the one owner of a gun's solution; `MountAim` is its
direction and is no longer a separate decision. `PFire` and `Solution` share `Bears`.
- `ArcPermitsFire` is no longer the aim's gate alone; it is the one trigger gate, in the
order above.
- `HelmInput` owns face-the-aim. Shift and Caps Lock are its inputs; `ActionGameManager`
reads `HelmInput.Demand` and decides nothing.
- Forbidden: a second hit price or arc test in Gameplay or UI (the HUD reads `Solution`
and `HitProbability`); a face-aim flag outside `HelmInput`.
## Missiles and point defense
Ruling `missiles-sim-side-light`: "We need missiles sim side, and we can cut that so as to
enable PDC without requiring full entities. Kind of like the asteroids." Ruling
`thresholded-autofire`: "I think we need thresholded autofire as an option, otherwise PDC
would require too much micro." Ruling `missiles-fuel-and-seekers`: "a missile knows where
it's going when it's fired but relies on its own sensors for terminal maneuvering.
Maneuvering which should expend limited fuel. A missile that spends fuel evading PD might
not have enough left to catch up to a target that is itself also dodging." Ruling
`missile-stats-now-gear-later`: "For now these stats will belong to the missile, later they
will be derived from gear." Full munition entities stay later (ruling
`drones-and-munitions-are-entities`, follow-up `drones-munitions-substrate`); nothing here
may foreclose them.
The third pass answers four more rulings. `missiles-resolution-model`, geometry-sets-odds: "I
think geometry should set the odds, though. We're not simulating realistic scale and
collisions. Going sideways really fast can help you evade both guns and missiles, but the
extent should be based on stats. If it's only geometry then the missile's terminal
maneuvering fuel, gathered targeting data and piloting skill don't exist at all as far as hit
resolution is concerned." `missile-cognition`: "I actually did mean the skill of the missile
itself. Cognition is a commodity in Aetheria, and a smarter missile can better spend its
delta v". `manoeuvre-penalises-solutions`: "There is currently no firing solution penalty for
target maneuvering afaik, which is a real gap for the speed demon hot doctrine glass cannon
play style." `evasion-not-only-flight-time`: "Deviation from predicted intercept means you
have to spend your delta as the projectile is in flight. Doesn't help you dodge a laser at
all." `missiles-seeker-retarget`, hostile-in-cone: "I like the idea of letting them reacquire
targets. Makes turning invisible and firing decoys a plausible strategy."
Anchors are against `origin/master` `7f51d46d` (nothing under `Assets/Scripts` or `tools`
changed since `c7432f39`, where the second pass read them), read in a detached scratch
worktree on 2026-10-06. Cuts: `missile-stats` (r2), `evasion-term` (r1), `missile-records`
(r3, flight only), `missile-odds` (r1), `missile-presenter` (r2), `munition-shots` (r3),
`autofire-threshold` (r3), `ai-autofire` (r1, unchanged).
### Prior art
Source marks as in Controls.
- **Nebulous: Fleet Command** (wiki: the community `mechanics:missiles` page, read in full).
Missiles are tracked contacts with hit points by template (10 to 150) and armour as wall
thickness, and a radar cross-section that varies with aspect. Point-defense turrets and
defensive missiles engage them automatically; decoys soak PD attention (turrets take
decoys first). Launch rate is bounded by programming channels, which caps salvo
density. Defensive missiles cannot engage other defensive missiles, so two ships cannot
dump their magazines at each other's interceptors (snippet, glossary search).
- **Starsector** (wiki: `Weapon_data.csv`, read in full). A missile is a projectile with
"proj hitpoints". Weapon hints decide targeting, not whether a round can physically
connect: `PD` targets missiles but prefers missiles in range over ships, `PD_ONLY`
targets only missiles (and fighters with `ANTI_FTR`), `PD_ALSO` takes missiles only when
nothing else is available. Autofire runs per-weapon target AI (snippet, mod README).
Forum consensus (snippet): missile power is non-linear, weak until it saturates PD, then
strong until magazines run dry.
- **FreeSpace 2** (snippet, hard-light wiki `Weapons.tbl` and `Ai_profiles.tbl`). Bombs are
"interceptable": weapons with hit points and an enlarged collision radius. Turrets shoot
bombs; AI profiles stop turrets targeting bombs beyond their own weapon range; bombs are
invulnerable for a short delay after launch.
- **Children of a Dead Earth** (snippet, forum threads). PD is a saturation contest: either
saturate it with many small rounds or send few heavily armoured missiles; small lasers
are enough for PD. The counterplay is economic, not a hard counter.
- **Homeworld** (snippet). Flak and defense-field frigates are the anti-missile and
anti-strike roles; no technical detail found.
**Fuel, seekers and terminal guidance** (second pass, for ruling `missiles-fuel-and-seekers`).
- **Nebulous: Fleet Command** (wiki: the community `mechanics:missiles` page, read in full).
A missile's engine trades burn duration, top speed and manoeuvrability in one fuel-mix
editor; engine size adds fuel and range only. During the burn it accelerates and turns at
its G rating; after the burn it coasts at its final velocity and cannot manoeuvre.
Terminal manoeuvres (weave, corkscrew) start when the seeker finds the target and exist to
survive point defense. Launch on a track plots an intercept from the reported position and
velocity; the seeker then re-selects its target every 0.5 s by signal strength, and a
command-guided missile that loses its track cruises to the last known position. Seekers
have a range and a cone (active radar 2 km, 50 degrees). Decoys draw PD.
- **Children of a Dead Earth** (forum: the missile construction and tuning guide, read in
full). Delta-v is split into boost, midcourse and terminal phases; against fast or evasive
targets 50-60% goes to boost, against slow ones 70-80%. A missile whose terminal delta-v
runs out against a manoeuvring target cannot close; high acceleration lowers the terminal
delta-v needed. Guidance is proportional navigation or augmented PN, and a target that
burns briefly then coasts makes APN waste delta-v chasing an acceleration that stopped.
- **DCS World** (simtuts defence guide, read in full; Falcon BMS not fetched). A missile's
motor burns out and it then bleeds energy, so a long shot arrives slow and is defeated by
basic manoeuvring; turning cold makes it fly farther; a hard break 2-3 s before impact
exploits proportional navigation's turn limit; beaming (notching) breaks a Doppler
seeker's lock. The lethal envelope is smaller than maximum range.
- **Starsector** (wiki: Harpoon MRM and ECCM Package pages). Missiles have a flight time
(Harpoon 12.5 s) and hit points (Harpoon 150); light PD and flak intercept them; a nimble
frigate can evade a first run, and ECCM (more acceleration and turn rate) is the answer.
Missiles that run out of flight time still collide (snippet).
- **HighFleet** (gameplay.tips guide, snippet). A blind-fired missile flies ballistic and
switches on its own radar 100 km before the aim point, a 75 km, 90-degree cone; it locks
the first fleet, friend or foe, that enters the cone and guides to impact.
- **Homeworld** (fandom wiki, snippet). Missile stats are per missile type (1,250 m/s,
1,650 m/s^2, 120 HP for the missile destroyer's round); no fuel model found.
- **Guidance law** (Cranfield, "Zero-Effort-Miss Shaping Guidance Laws", snippet). PN commands
lateral acceleration proportional to the line-of-sight rate; the zero-effort miss over
time-to-go is the acceleration a missile still needs. PN needs only the bearing and its
rate, which is what ruling `shared-track-bearing-only` allows a missile in flight.
**Odds from geometry and stats** (third pass, for rulings `missiles-resolution-model`,
`manoeuvre-penalises-solutions` and `evasion-not-only-flight-time`; recalled, not fetched this
pass).
- **EVE Online.** A turret's hit chance falls with the target's angular velocity against the
turret's tracking stat and with its signature against the turret's resolution, whatever the
round's flight time; a missile's damage falls with the target's speed against the missile's
explosion velocity. Going sideways fast helps against both, by an amount the stats set: the
operator's sentence, shipped as a tracking term rather than as flight-time dodging.
- **Zero-effort miss** (Cranfield, above). The miss a guided round would make if neither side
manoeuvred from now on is the standard terminal quantity; what the missile can still correct
is its lateral acceleration and remaining delta-v over the time to go. The odds below price
that endgame instead of asking a coarse simulation to decide contact.
**Evasion from agility and unpredictability** (fourth pass, ruling `evasion-is-unpredictability`,
fetched 2026-10-06).
- **Singer's manoeuvre model** (Singer 1970, as summarised in the tracking literature, e.g.
ResearchGate "Comparison of several maneuvering target tracking models"). Target acceleration
is a zero-mean first-order Markov process set by two parameters: its variance (bounded by the
target's maximum acceleration) and the manoeuvre frequency, the inverse of its time constant.
Long time constants make the target a constant-velocity target, short ones a
constant-acceleration one. The split this pass uses is the same: what the target *could* do
(capability) and how much it *is* changing its acceleration (behaviour).
- **Naval fire control against zigzags** (Pacific War Online Encyclopedia, "Fire Control";
Wikipedia "Ship gun fire-control system"). Analogue fire-control computers assumed a steady
course, so a course change during the time of flight defeated the solution. A target on a
steady course or a steady turn is predictable; a target that keeps changing its vector is not.
"Danger space" (NavWeaps definitions) is the along-range interval a ship can occupy and still
be hit: the target's extent, not its motion.
- **Weaving targets in missile guidance** (Zarchan, "Proportional navigation and weaving
targets"; RMS miss against a sinusoidal target). A weave at the right frequency maximises a PN
missile's miss; a weave much faster than the guidance loop averages out. High-frequency
dithering is not evasion. Hence the behaviour statistic below is low-passed.
- **EVE Online** (EVE University wiki, "Turret mechanics", fetched). Hit chance is
`0.5 ^ ((angular velocity * 40000 / (tracking * signature))^2 + falloff term)`: doubling tracking,
doubling signature or halving angular velocity are equivalent. A stationary target is always
hit. EVE prices transversal speed because its turrets slew. Aetheria's lead cancels a constant
sideways speed as a prediction problem (M20), but a mount must still follow the line of sight,
so `evasion-term` r4 adopts EVE's angular form for the mount (below, "Tracking per gun").
- **Starsector** (wiki, "Movement" and "Auxiliary Thrusters"). Manoeuvrability is acceleration,
deceleration and turn rate together; the hull mod that buys it raises all three. Dodging is
done by turning and burning, not by a strafe stat.
- **Elite Dangerous** (player guides, Steam discussions). Pitching the main drive onto a new
vector beats lateral thrusters for evasion; lateral thrust matters most on ships built around
it. Gimbal jitter makes evasive targets hard to hold even for hitscan lasers.
**Angular velocity against a mount's rate** (fifth pass, ruling `evasion-angular-velocity`; Eyes
pass of 2026-10-07, source marks as in Controls; the EVE formula is the player wiki's, not CCP's).
- **EVE turrets** (EVE University wiki, "Turret mechanics" and "Tracking"). The tracking part of the
hit chance is `0.5 ^ ((omega * sigRes / (tracking * sigRadius))^2)`, with `omega` the transversal
velocity over range in rad/s and tracking in rad/s; radial velocity contributes nothing, and at
`omega = tracking` (equal signatures) half the shots hit. The range part sits in the same
exponent, so the price is a product of per-term factors. Smaller turrets track faster. The wiki
states the failure mode: a close orbit raises `omega` until big guns cannot hit, and the
counters lie outside the gun (webifiers, target painters, tracking computers, smaller guns,
drones). CCP's 2004 balance note made signature matter so frigates survive guns built for
larger ships. EVE has no term for changes of vector; manoeuvre counts only through the angular
velocity it makes.
- **Naval directors** (Naval Ordnance and Gunnery vol. 2, chapters 19 and 24 to 26, transcribed at
eugeneleeslover.com). The deflection lead is the line-of-sight angular rate times the time of
flight, and the computer assumes constant rates; a change of course or speed during the flight
spoils the solution. A steady crosser is a rate problem the director solves; a manoeuvre is the
unmodelled term. Two errors, two owners.
- **Kerrison predictor** (Wikipedia). Low crossing aircraft meant high angular rates and short
engagements; the hard part was mount slew, not ballistics. Its straight-line assumption fails
against a manoeuvring target.
- **CIWS** (Wikipedia, Phalanx and Goalkeeper). Point-defense mounts slew at 80 to 115 degrees per
second (sources disagree); a battleship turret trains at a few degrees per second. The authored
table below spans the low part of that range: a PD-class gun near 40 degrees per second (none is
authored yet), the catalog's guns from 12 down to 1.5.
- **Servo tracking** (arXiv 2408.16870). A proportional-derivative mount following a constant-rate
target keeps a steady-state error. A mount's lag grows with the rate it is asked to follow,
which is the shape `PMount` gives.
**Failure modes to design against.**
1. PD trivialises missiles: perfect interception makes launchers dead weight. Answer: a
PD round is priced and rolled like any shot (spread against a small target, range,
sensor info), a round that cannot arrive before the missile does has no solution, and
heat and ammunition bound how much a group can spend. Saturation then beats PD, as in
Starsector and CDE.
2. PD that never works: interception left to luck the player cannot read. Answer: the HUD
marks detected inbound munitions and the ones an autofire gun is engaging, and an
intercept shows an effect at the munition's simulated position.
3. Unreadable salvos: thirty rounds and thirty tracers. Answer: markers only for detected
hostile munitions within weapon reach, one shape, no per-gun lines.
4. Cost blowups: per-missile physics engines, per-missile agents, per-gun O(contacts) scans
every frame. Answer: a missile is a few floats on its pending shot stepped by one pure
function (a PN command, a fuel debit, one settled seeker reading and one
closest-approach test against its own target); reacquisition queries the targeting index
only on a seeker interval; an autofire gun keeps its subject while it stays worth firing.
The per-missile step cost is measured and pinned (`missile-records`).
5. Recursion: PD missiles shooting PD missiles (Nebulous's explicit rule). Answer: a
munition weapon never takes a munition as its subject.
6. Unreadable missile states (burning, coasting dry, seeker blind). Answer: the presenter's
flame follows the live throttle and goes dark when the tank is empty; the HUD diamond is
filled while a munition can still manoeuvre and hollow once it coasts; a missile that
self-destructs at the end of its life shows a fizzle, not a hit.
7. PD-or-nothing balance: evasion that is free makes PD useless; no evasion makes PD
decisive. Answer: evasion and pursuit draw on the same fuel, so PD pressure that a
missile survives still costs it terminal authority downrange (the operator's sentence),
and a dodging target spends that authority faster.
8. Two hit models drifting apart. Answer: one roll and one price owner for every shot. A gun
round is priced from its frozen shooter data, the target's realized deviation in flight and
the tracking term; a guided round flies until its terminal gate and is priced there from its
own seeker data, its zero-effort miss beyond reach, the same tracking term and its terminal
correction. Both commit through `Commit` and apply through `Apply`, read the one
`PDeviation` shape and the one `Evasion` term, and nothing after the roll decides again.
9. Determinism and replay. The zone steps by the frame's delta time (M15), so a stepped
missile is exactly as replayable as a ship's motion and no less. Answer: no shared random
stream (the evasion phase comes from the shot id, as the roll's seed does), and the
terminal gate tests closest approach of straight motions, so a long frame cannot step past it.
### Body facts
- **M1. A guided round is an ordinary shot in the simulation.** `LauncherData`
(`Behaviors/Launcher.cs:10`, a `LockWeaponData`, so a `LockWeapon`) and `GuidedWeaponData`
(`:37`, an `InstantWeapon`) fire through `InstantWeapon.Execute` into `FireControl.Fire`
(`InstantWeapon.cs:271`) like any gun. Flight time is `FlightDistance / weapon.Velocity`
(`FireControl.cs:559`); `CommitTime` is `ArrivalTime - CommitHorizon` (0.5 s,
`Settings.cs:250`), or the fire time for a fused round with no target (`:588`); `Commit`
rolls at that horizon (`:707`) and `Step` applies at arrival (`:645`). No ServerShared
code reads `MissileVelocity`, `Thrust`, `DodgeFrequency` or the three curves.
- **M2. The flight is Unity's and decides nothing.** `GuidedProjectile.Update`
(`Gameplay/Weapons/GuidedProjectile.cs:127-212`) homes with `first_order_intercept`, a
noise dodge and the guidance, lift and thrust curves. `GuidedProjectileManager.Bind`
(`GuidedProjectileManager.cs:14-26`) subscribes to `ShotCommitted` (retarget to the
burst point) and `ShotResolved` (burst or fade). The split branch (`GuidedProjectile.cs:166-194`)
spawns visual children that no shot owns. Between fire and arrival a missile is a
`PendingShot` with no position and a Unity transform nothing simulated can reach.
- **M3. The `GuidedWeaponData` branch homes on the aim** (`GuidedProjectileManager.cs:62-80`, the aim at `:79`,
`LookDirection`, renamed by `controls-helm`). An unfused round with no engaged target has
`FlightDistance` 0 (`FireControl.cs:494`) and so resolves the tick it fires.
- **M4. The pending shot.** `PendingShot` (`FireControl.cs:1748-1798`) is a struct in
`Zone.PendingShots` (`Zone.cs:46`), never saved; `TryGetShot` is a linear scan
(`Zone.cs:55-65`). Its `Target` is an `Entity`. `Step` resolves a shot whose target left
the zone as a fresh `Miss`, at any stage, without rewriting the committed outcome
(`:619-629`): the precedent an intercepted missile follows. `ShotResult` is `Miss`,
`Hit`, `Burst` (`:1944-1949`).
- **M5. Mines are not simulation records.** `Mine` moves by a Unity `GridObject`, arms by
`Physics.OverlapSphere` and damages through `FireControl.Detonate`
(`Gameplay/Weapons/Mine.cs`, `MineManager.cs`). `Detonate` (`FireControl.cs:1350`) iterates
`zone.Entities` only. Follow-up `bodies-entity-and-mines` owns mines; no cut here moves them.
- **M6. No point defense exists.** A case-insensitive search for `point defen`, `PDC`,
`\bPD\b` and `anti-?missile` over `Assets/Scripts` and `tests` returns nothing. Nothing can
take a shot as its target.
- **M7. Targets.** `TargetRef` (`Zone.cs:595-621`) is an `Entity` or a `ChunkId`. The zone
registers `EntityTargets` and one `BeltTargets` per belt (`Zone.cs:101`, `:119`). Shots
at chunks are not routed yet (`FireControl.cs:533`, mining Cut 4).
- **M8. A weapon's only subject is its entity's one target.** `Fire` reads
`source.Target` (`FireControl.cs:534`), `Refuses` reads it (`:94`), and
`Weapon.StanceAllowsFire` reads it (`Weapon.cs:107`).
- **M9. AI fire is per weapon.** `Combat` (`Agents/States/Combat.cs:119-124`) and
`TurretController` (`Behaviors/TurretController.cs:83-96`) activate each weapon when
`FireControl.AgentFires` (`FireControl.cs:101-109`) passes: `HitProbability >=
AgentMinHitProbability` (0.2, `Settings.cs:259`) for an unfused weapon, designated and not
refused for a fused one.
- **M10. Presenters fly along the barrel.** `ProjectileManager.Fire` launches along
`barrel.forward`, not the shot's frozen `TravelDirection`; package hulls have no
articulation (C9), so their rounds leave along the hull whatever the simulation decided.
- **M11. EntityPack keys.** The base `EntityPack` uses keys 0 to 16; `ShipPack` uses 17 to
19 and `OrbitalEntityPack` 17 to 20 (`EntitySerializer.cs:153-189`). The next free base key
is 21.
- **M12. Shift is already an action-bar slot.** `GetDefaultPlayerSettings`
(`ActionGameManager.cs:95-105`) binds `<Keyboard>/leftShift` to action-bar slot 0, which
`controls-helm` r2 also binds to Face Aim. Reported to Self; not this section's cut.
- **M13. Launcher content.** GT 3K, pswarm and scorched void policy carry guidance-system,
thruster and warhead roles; LRMM72 and SRMM72 are flat (`docs/stats-power-cut7-roles.md:139-146`).
The Launcher Angles scenario fits a GT 3K and a pswarm (`Scenarios/LauncherAngles.cs`).
- **M14. Cost.** The first pass's derived pose cost nothing per tick. A missile that spends
fuel by events cannot be a closed form of its shot and time (what it spent depends on
what PD and its target did), so the revision steps it: per live munition per tick, one PN
command, one fuel debit, one settled seeker reading (`Sensor.Gain`, no stored info), one
closest-approach test against its own target, and, while it has no lock, one index query
per seeker interval. No ServerShared scratch build was run for this pass;
`missile-records` r3 measures the step and pins it.
- **M15. Zone time is the frame's delta time.** `ActionGameManager` calls
`Zone.Update(Time.deltaTime)` (`Gameplay/ActionGameManager.cs:1308`); `Zone.Update`
(`Zone.cs:202-221`) advances time, orbits, agents and entities by that dt, then
`FireControl.Step`. There is no fixed tick. Rolls are seeded per (zone, shot id)
(`FireControl.cs:707` onward), so an outcome does not depend on what else drew, but no
zone state is frame-exact replayable today. Headless tests step with a fixed dt.
- **M16. What a seeker can reuse.** `Sensor.Gain` (`Behaviors/Sensor.cs:187-190`) is the one
gain rule: a passive tick adds `visibility * sensitivity * response * dt / distance`.
Entities hold stepped info (`Sensor.Execute`, `EntityInfoGathered`, `:137-180`); a chunk's
info is the settled value `saturate(sum(rate) / (n * TargetInfoDecay))` computed when asked
(`Entity.ChunkInfo`, `Entity.cs:357-370`). Detection is info above
`TargetDetectionInfoThreshold` for entities (`Entity.cs:245-256`) and chunks (`:374`). A
target's brightness is `Entity.Visibility`, the sum of its `VisibilitySources`
(`Entity.cs:139`). A seeker therefore needs no stored info: its reading of its target is
the settled value from its own sensitivity and cone, the chunk rule with one sensor.
- **M17. Launcher data.** `LauncherData` keys 26-31 and `GuidedWeaponData` keys 21-26 hold, in
order, `GuidanceCurve`, `ThrustCurve`, `LiftCurve`, `Thrust`, `DodgeFrequency`,
`MissileVelocity` (`Behaviors/Launcher.cs:10-61`). `LockWeaponData`'s `LockSpeed`,
`SensorImpact` and `LockAngle` are the launcher's lock-on, the shooter's sensors, not the
round's (`Behaviors/LockWeapon.cs:13-19`, `:104`). The warhead is `WeaponItemData`'s
`Damage`, `DamageSpread`, `DamageType`, `BlastRadius` (key 29) and `Fuse` (key 32)
(`ItemData.cs:477-506`), read through `FireControl.FuseOf` (`FireControl.cs:63-68`). The
catalog is `GameData/Aetheria.cc`; each catalog change has its own AetherDb
`*-migrate apply` command (`tools/AetherDb/Program.cs:32-40`). The roles doc assigns
`Thrust`, `MissileVelocity` and `Velocity` to the thruster role (`docs/stats-power-cut7-roles.md:139-146`).
- **M18. What the flight fields meant.** `GuidedProjectile.Update` (`GuidedProjectile.cs:127-212`)
aims at `first_order_intercept` at `TopSpeed` (`MissileVelocity`), turns its velocity
toward the desired one at `Thrust * ThrustCurve(progress)` per second, and blends a noise
dodge at `DodgeFrequency` by `GuidanceCurve(progress)`. There is no fuel, no seeker, and the
round fades past `Range`.
- **M19. Blasts.** `Apply` detonates a `Burst` or `Proximity` round through `Detonate` at its
`BurstPoint` (`FireControl.cs:920-930`); `Detonate` overlaps the disc with the cells of
every entity in the zone (`:1350`), the path mines use (M5). `HullEntry` (`:839`) gives the
distance along a line to an entity's first metal cell.
- **M20. No manoeuvre enters the price, and none reaches an instant weapon** (probe for
rulings `manoeuvre-penalises-solutions` and `evasion-not-only-flight-time`, source read at
`7f51d46d` on 2026-10-06). `HitProbability` (`FireControl.cs:316-323`) is
`PFire * PSpread * POnHull`: shooter accuracy and info, barrel spread, silhouette. The
target's velocity enters only the lead (`PredictedIntercept`, `:202-208`), which cancels a
constant sideways speed exactly. The one motion term is the roll's `DeviationProbability`
(`:425-431`): the target's realized departure from the fire-time straight-line prediction,
forgiven by the shooter's `Tracking`, measured at `CommitTime`. `Fire` sets `CommitTime` to
`now + max(0, flightTime - CommitHorizon)` (`:588`, `CommitHorizon` 0.5 s), so a round whose
flight is under 0.5 s commits at fire and measures nothing. Beams fire through `Fire` per
`BeamResolveInterval` with flight time 0 (`Behaviors/ConstantWeapon.cs:176-188`), as instant
weapons do (`InstantWeapon.cs:271`). Result: the solution the HUD, `AgentFires` and autofire
read carries no manoeuvre penalty; at the roll only long-flight rounds see manoeuvres, only
ones made in flight, and never a sideways speed. A laser cannot be dodged at all. The
operator's reading holds.
- **M21. What agility exists.** No `Agility` stat exists anywhere. `Ship` aggregates
`LeftStrafeThrust` and `RightStrafeThrust` from its active lateral thrusters and aether drives
(`Ship.cs:52-53`, `:181-220`); a thruster accelerates its entity by `Thrust / Mass`
(`Behaviors/Thruster.cs:104`). A hull's strafe acceleration is therefore
`min(LeftStrafeThrust, RightStrafeThrust) / Mass`, derived from fitted gear; an entity that is
not a `Ship` has none. No entity stores its acceleration. The shooter's tracking is
`TargetingSystem.Tracking` (`Behaviors/TargetingSystem.cs:43`, `:80`), falling back to
`UnaidedTracking` (10, `Settings.cs:228`) through `FireControl.Tracking` (`:190`): metres of
deviation forgiven.
- **M22. The shot pipeline can carry a guided round.** `Step` (`FireControl.cs:609-656`)
commits when `now >= CommitTime` and applies once committed and `now >= ArrivalTime`;
`Commit` rolls `CommitProbability` with the per-shot seed (`:707-760`). A munition whose
`CommitTime` stays infinite until its terminal gate, which then sets it to the gate's time and
`ArrivalTime` to the closest approach, goes through the same commit, events and application
as a gun round.
- **M23. What the ships can actually reach in the window** (probe for ruling
`evasion-is-unpredictability`: `tests/Aetheria.Shared.Tests/EvasionEnvelopeProbe.cs`, a scratch
file never merged, run headless on Yggdrasil through `ygg-verify` on probe commit `7ef47d72`
over `646057f2`, 2026-10-06, the shipped catalog and authored settings, fixture seed 1).
Each ship was fitted by the loadout generator, settled for 2 s, then flown from rest for 0.5 s
in the real `Ship.Update` (dt 1/60) under fifteen control policies (strafe, forward, reverse,
corner, turn-and-burn at 45, 90, 135 and 180 degrees, each mirrored). The reach is the largest
displacement from the idle run along each axis; the half-width is the mean of the two sides.
| Fit | Mass | Thrust (N) and turn | Nose-on half-width | Broadside half-width |
|---|---|---|---|---|
| Longinus (generated) | 3,835 | 2 Large Drive forward 250,000 each; 2 Talaria on the flanks at lever 0.998, which cancel to zero net strafe and only turn; no reverse. Forward 130.4 m/s^2, turn 1.95 rad/s by `Thruster.Execute`'s arithmetic | 7.50 m | 8.15 m |
| Djinni (generated) | 13,835 | 8 Talaria (3 forward, 1 reverse, 4 lateral at lever 0.73 and 0.87): forward 16.3, reverse 5.4, strafe 10.8 m/s^2 each way, turn 0.86 rad/s | 2.00 m | 2.19 m |
| LonginusX (generated) | 3,810 | one Traction aether drive, omnidirectional: measured 109 to 154 m/s^2 on the four axes | 16.81 m | 18.06 m |
A second generated fit of each agreed within 30% (Longinus 9.77 / 8.13, Djinni 1.65 / 1.80,
LonginusX 16.50 / 17.71); the Duel's LonginusX fit gave 11.62 / 12.97. The catalog has three
ship hulls (Djinni, Longinus, LonginusX); there is no Gemini, and the LonginusX with its aether
drive is the catalog's omnidirectional ship. On the Longinus, turning right also pushes it
right: the turning Talaria translate the hull 2.4 m in 0.5 s while turning 59.5 degrees.
- **M24. Ship's thrust aggregates are not an envelope.** `Ship.LeftStrafeThrust` and
`RightStrafeThrust` count the Longinus's two flank Talaria (75,000 N each, 19.6 m/s^2), yet a
pure strafe input moves it 0.000 m: the torque compensation in `Ship.Update` (`Ship.cs:257-275`)
cancels a lone off-axis strafer completely. Its lateral push exists only while it turns. r1's
`min(LeftStrafeThrust, RightStrafeThrust) / Mass` would have rated the Longinus at 19.6 m/s^2 of
agility and the Djinni at 10.8, the operator's ships in roughly the wrong proportion for the
wrong reason. Aether drives add `drive.Thrust` (`AetherDrive.cs:140`), a per-update impulse
times mass, into the same sums as thruster newtons, so `ForwardThrust / Mass` reads 1.9 m/s^2
for a LonginusX that accelerates at about 150. `ClockwiseTorque` sums lever arms without thrust
(`Ship.cs:225-245`), so `TurnTime` (`Ship.cs:61`) is not in the units `Thruster.Execute` turns
by (`Behaviors/Thruster.cs:106-107`: `input * Torque * Thrust * TorqueMultiplier / Mass` rad/s).
- **M25. The Djinni moves about 1.6 times faster than its stats say.** Full forward for 0.5 s
moved the Djinni 3.27 m where `ForwardThrust / Mass` (16.3 m/s^2) predicts 2.03 m; strafe gave
2.00 m against 1.36, and it turned 40.8 degrees against 24.7 by the `Execute` arithmetic. The
Longinus matches its stats (15.48 m against 16.3 predicted, the gap being drag). Not explained
by this pass: thruster `Thrust` read 75,000 after settling and `Mass` 13,835. Whatever the cause,
an envelope that reads the stats would misprice the Djinni by that factor, so
`evasion-term` r2 pins the envelope against motion measured in `Ship.Update` and stops on a
mismatch.
- **M26. A cheap reach is exact enough.** For a body-frame envelope (forward, reverse, left and
right acceleration, a turn rate each way) under kinematic turning (ruling
`controls-turn-inertia`), the largest displacement along a direction in a window, taken over
eight faces (the four thrust axes and the four corners of the thrust box), each turned toward the
direction at full rate while thrusting whenever it points within 90 degrees, then held, matched a
brute-force search over turn strategies (one-degree steps, 400-step integration) to within 0.1%
on four test envelopes, at 0, 45 and 90 degrees (scratch script, 2026-10-06). Against the real
`Ship.Update`, with the Longinus's flank push counted as strafe, it gave 7.31 m nose-on (probe
7.50) and 8.61 m broadside (probe 8.15). Cost: one box support and eight faces of two
trigonometric calls each, per side.
- **M27. Tracking is one number per ship, read in three places** (source read at `46b28501`,
the `evasion-term` head Soul held, 2026-10-07, for ruling `tracking-per-gun`).
`FireControl.Tracking(Entity)` (`FireControl.cs:190-194`) returns the active
`TargetingSystem.Tracking` (`Behaviors/TargetingSystem.cs:43`, `:80`), else
`UnaidedTracking` (10, `Settings.cs:228`). Its readers are `HitProbability` (`:327`,
`PDeviation(evasion, Tracking(source))`), `Inspect` (`:344`, and `PEvasion` at `:368`) and
`Fire`, which freezes it into `PendingShot.Tracking` (`:676`, field `:1868`).
`DeviationProbability` (`:444`) forgives realized plus evasion deviation with that frozen
value, so the in-flight drift and the evasion term share one tolerance. Nothing outside
`FireControl` calls `Tracking(`; the debug HUD only prints `shot.Tracking` and `d.Tracking`
(`Gameplay/ActionGameManager.cs:1356`, `:1380`). No weapon field carries tracking.
- **M28. The catalog's weapons by size class** (probe: `cultcache_py`
`SingleFileMessagePackBackingStore` over `GameData/Aetheria.cc` at `46b28501`, payloads
decoded by MessagePack key, 2026-10-07; the Sep 30 and Oct 3 AetherDb builds refuse that
catalog on the `aetheria.fieldkinddata` schema). 18 `WeaponItemData` records;
`WeaponItemData.WeaponCaliber` (`ItemData.cs:483`, key 25: Small, Medium, Large,
ExtraLarge) is the only authored size class. Ballistic: Small 6k Shooter, ClearPath, Earp;
Medium Autocannon, DeathCluster, pretty pretty bang bang. Energy: Small CShot RainbowLite Lazer,
ColdFire, FastBlast+-, Spectra; Medium ChargeBlast SG, ChargeBlast+-; Large plight. Launcher:
Medium GT 3K, pswarm, scorched void policy; Large LRMM72; ExtraLarge SRMM72. No ballistic or
beam gun is Large or ExtraLarge except plight, so in the demo the heavy guns are Medium. The two
targeting designs author `Tracking` 15-25 (Targeting Computer) and 30-45 (Fire Control Array).
- **M29. Where a weapon stat can live.** `WeaponData` (`Behaviors/Weapon.cs:20`) holds the
per-weapon `PerformanceStat`s at keys 1-16 (`Spread` key 15, `Velocity` key 16), evaluated in
`Weapon.UpdateStats` (`:131-140`) through the item's heat, durability and quality terms.
Subclasses use keys 17 to 32 (`InstantWeaponData` 17-20; `ChargedWeaponData` to 32;
`LockWeaponData` 21-25; `LauncherData` 26-31; `GuidedWeaponData` 21-26), so 33 is the first
key free across the whole hierarchy. CultCache opens a record missing a local slot as
`defaulted_missing_slot` and uses the local default (`CultLib src/GameCult.Caching/CultCache.cs`,
schema comparison), so a field initializer is what unmigrated records and code-built fixtures
read. A `PerformanceStat` with no terms resolves to `Max`, and a bare `new PerformanceStat()`
resolves to 0.
- **M30. A weapon's footprint is its item `Shape`** (source read and catalog probe at `46b28501`,
2026-10-07; method as M28). Footprint is evidence about a gun, not a key to its tracking (ruling
`weapon-tracking-authored`, which superseded `weapon-tracking-defaults`). `ItemData.Shape`
(`ItemData.cs:288-289`, key 5, `[InspectableSchematicShape]`, the cells Studio paints) holds a
`bool[,]`; `Shape.Coordinates` (`:84-95`) lists its true cells, so the cell count is
`Shape.Coordinates.Length`. A hardpoint's `HardpointData.Shape` (`:563`, key 2) is the slot, not
the gun: `Takes` (`:580-581`) fits any design of the slot's type whose shape lies within it.
`new Shape()` is one true cell (`:25-29`). The catalog's weapons by cell count:
| Cells | Weapons (hardpoint, caliber) |
|---|---|
| 1 | plight (Energy, Large), pswarm (Launcher, Medium) |
| 2 | 6k Shooter, ClearPath, Earp (Ballistic, Small); ColdFire, FastBlast+-, Spectra (Energy, Small) |
| 3 | GT 3K, scorched void policy (Launcher, Medium) |
| 4 | Autocannon, DeathCluster, pretty pretty bang bang (Ballistic, Medium); ChargeBlast SG, ChargeBlast+- (Energy, Medium) |
| 6 | LRMM72 (Launcher, Large), SRMM72 (Launcher, ExtraLarge) |
| 9 | CShot RainbowLite Lazer (Energy, Small) |
plight's one cell is authored, not a default, and it is a content error (ruling
`plight-not-one-cell`; its provenance is M35). Caliber and footprint disagree in
both directions (plight Large in one cell, CShot Small in nine). The hulls' weapon hardpoints:
Longinus and LonginusX two Energy 1x2 and two Launcher 1x3; Djinni two Ballistic 1x2, two
Launcher 1x3 and two Launcher 3x2; the station Turret two Ballistic 2x4 at arc 360. No weapon
hardpoint is one cell or 3x3, so CShot fits no hull.
- **M31. Nothing in the price reads velocity across the line of sight, and nothing models a
mount's rate** (source read at `46b28501`). Target velocity enters the lead
(`PredictedIntercept`, `FireControl.cs:202-208`, without the shooter's velocity) and the frozen
`FireTargetVelocity` that `DeviationProbability` (`:439-445`) projects; `Evasion` (`:452-470`)
reads envelope, heading, track, line of sight and window, never a velocity. A steady crosser
therefore costs only the in-flight drift, and an instant weapon nothing. No weapon or hardpoint
field carries a slew rate: `HardpointData.FiringArc` (`ItemData.cs:567-569`) is an arc in
degrees, and the targeting gear's `Tracking` is metres (M27). `PendingShot.PFire` (`:1867`) and
`Tracking` (`:1868`) are the shooter facts `Fire` freezes (`:663-677`); `CommitProbability`
(`:792-806`) multiplies `shot.PFire * pDeviation`, then spread and hull.
- **M32. Top speed is a hull behaviour: the Longinus and the Djinni carry one, the LonginusX does
not** (source read and catalog probe at `46b28501`, method as M28, 2026-10-07; corrects an
earlier M32 that probed only gear and weapons and concluded top speed was drag's).
`VelocityLimitData` (union 11, `Behaviors/VelocityLimit.cs`, one `PerformanceStat TopSpeed` at
key 1) is a behaviour on `HullData.Behaviors` (`EquippableItemData`, key 11). Its `Execute`
clamps `Entity.Velocity` to `TopSpeed` and books the removed part in `Entity.LimitClamp`, which
`Entity.Acceleration` puts back so the clamp is not a manoeuvre (`2cf980e3`, Soul finding
`limit-clamp-evasion`, fixed at `46b28501`). The catalog's five hulls: Longinus `TopSpeed`
100..100 (terms heat 1), Djinni 50..50 (terms heat 1, quality 1.5; `Min == Max`, so both
evaluate flat), LonginusX, Zenith and Turret none. History (`git log -S VelocityLimit`;
`docs/locomotion-cut.md` lines 75-81 and 440-460): the code has carried the behaviour since 2020;
the Longinus and Djinni records were absent from the catalog until locomotion Cut 1's
`restore-hulls` (`3cb6b154`, 2026-09-25, deleted after landing in `819bd6b8`) rebuilt them from
the decoded 2021-04-14 legacy record, each with its own legacy `VelocityLimitData`. The legacy
LonginusX carried none, and neither does today's, so nothing was dropped that is still missing.
Readers: `Agent.TopSpeed` (`Agents/Agent.cs:26`, falling back to 100 for a hull without one, so
AI LonginusX steer at 100), and the dock intro (`Gameplay/ActionGameManager.cs:849`), which
dereferences `GetBehavior<VelocityLimit>()` with no null check and so throws for a LonginusX
player. The cap is the hull's own behaviour, gated by the hull's `Active` (locomotion map, Soul
hazard 2026-09-26). The LonginusX's ceiling is physics: drag (`Ship.cs:307-310`, steady thrust
`a` settles near `a / Drag`, about 1,300 m/s at 130 m/s^2 and drag 0.1) and its aether drive,
whose push falls to zero as speed along an axis reaches the rotor speed (`AetherDrive.cs:140-148`;
forward rotor speed is `Rpm * 3 / 100`, 1,200 to 2,400 m/s at the Traction drive's 40,000 to
80,000 maximum rpm). Together they put it several hundred m/s up; arithmetic, not a flight probe.
- **M33. The table fit** (probe: a scratch script over the catalog decode of M30 and M34, not
committed, 2026-10-07, for `evasion-term` r5). It evaluates the mount factor
`0.5 ^ ((omega / rate)^2)` and the vector factor `PDeviation(E, Tracking)` for each authored rate
under three targeting fits (unaided 10, Targeting Computer 20 and Fire Control Array 37.5, the
designs' midpoints, M28), at the geometries of the table in "Tracking per gun", with crossing
speeds at the hulls' real top speeds (M32). `omega` comes from speed and range; `E` is the
window-0.75 figure the r2 pass measured (the table under "Evasion is how unpredictably", and the
speed demon's 9.63 m). The results are in that section.
- **M34. The catalog's guns by role** (catalog probe at `46b28501`, method as M28, 2026-10-07; the
weapon behaviour's evaluated range of `Damage` key 2, `Range` key 6 and the shot interval
`Cooldown` key 19, with `Count` key 17 pellets per shot):
| Weapon | Kind, caliber, cells | Mass | Damage x count | Interval (s) | Range (m) |
|---|---|---|---|---|---|
| ClearPath | Ballistic auto, Small, 2 | 50 | 2-6 | 0.1-0.033 | 250-600 |
| Spectra | Energy beam, Small, 2 | 25 | 20-85 | 0.5-0.25 | 800-1,750 |
| FastBlast+- | Energy auto, Small, 2 | 125 | 20-80 x 12 | 0.33-0.1 | 500-1,250 |
| Earp | Ballistic single, Small, 2 | 75 | 5-25 | trigger | 1,000-2,500 |
| 6k Shooter | Ballistic single, Small, 2 | 95 | 4-30 | trigger | 1,000-2,000 |
| ColdFire | Energy beam, Small, 2 | 150 | 40-160 | 2.5-1 | 750-2,250 |
| Autocannon | Ballistic auto, Medium, 4 | 75 | 3 | 0.5 | 1,000 |
| ChargeBlast+- | Energy charged, Medium, 4 | 225 | 25-110 (x3 charged) | 0.75-0.4 | 800-1,900 |
| DeathCluster | Ballistic auto, Medium, 4 | 250 | 100-250 x 12-15 | 1-0.25 | 500-1,000 |
| ChargeBlast SG | Energy charged, Medium, 4 | 250 | 50-175 x 5 | 0.75-0.4 | 450-950 |
| pretty pretty bang bang | Ballistic charged, Medium, 4 | 250 | 64-256 x 5 | 0.67-0.33 | 500-1,250 |
| plight | Electrostatic charged, Large, 1 (M35) | 100 | 75-400 (x3 charged) | 4-2, after a 3-2 s charge | 500-2,000 |
| CShot RainbowLite Lazer | Energy beam, Small, 9 | 500 | 100-600 | 3-1 | 1,000-3,000 |
| GT 3K, pswarm, scorched void policy, LRMM72, SRMM72 | Launcher or guided | 100 | | | |
"Trigger" is an `InstantWeaponData` whose cooldown is 0.01 s: it fires as fast as the trigger.
plight is sold (product DragOnBreath, Lucent Media), so it spawns in generated loadouts.
- **M35. plight has been one cell since it was created** (git history and vault read,
2026-10-07). It first appears in `GameData/WeaponItemData/plight.json` at `85483319` (2021-06-23,
"Modular Database Upgrade") with `"shape":{"name":[[true]]}`, caliber Large, the TaranisLG
effect prefab, and no other shape in any later revision; the 2021-03 `AetherDB.msgpack` has no
plight record. `docs/content-batch-one.md:822` copied the catalog's 1x1. The AetheriaLore vault
names it only as Lucent's "TaranisLG lightning gun with vape tie-in" (`Corporate Roster and Item
Wishlist.md`, `Faction Flavor and Visual Identity.md`) and gives no size; `Weapon Category
Codes.md` defines the ESD lightning-gun code and size letters but no TaranisLG label. Every Energy
hardpoint on a flyable hull is 1x2 (M30): a 1x2 plight fits the Longinus and LonginusX as it does
today, and any larger shape fits no hull, like CShot. Nothing in the record settles its intended
shape; question `plight-shape` asks it.
### Body facts: evasion core, mount term and autofire seams (2026-10-08)
Body: `origin/master` `4d664b53`, read in a detached worktree. The in-flight branch
`origin/eureka/aetheria-release-controls-mount-aim` (e103b90a, 538b11b4) was read with `git diff
4d664b53 <branch>`, never checked out. Read by `cut-munition-shots`, `cut-munition-jink`,
`cut-autofire-threshold` and `cut-autofire-controls`.
- **MA1. Master's evasion is two factors in one price** (source read, `FireControl.cs`).
`HitProbability` (:350-357) is `PFire * PMount(AngularVelocity(source, target),
TrackingRate(weapon, source)) * PSpread * POnHull * PDeviation(evasion, Tracking(source))`.
`Forecast` (:326) computes `evasion = Evasion(target, TravelDirection)`. The mount factor reads
the gun's rate times the ship's gear (`TrackingRate`, :221-224); the jink factor reads the ship's
gear alone in metres (`Tracking`, :195-199), as rulings `tracking-per-gun` and `jink-per-ship`
say.
- **MA2. The evasion core is already subject-free** (`FireControl.cs:496-516`).
`Evasion(Entity, los)` returns 0 for a non-Ship and otherwise calls the pure
`Evasion(in ManoeuvreEnvelope, float2 heading, in ManoeuvreTrack, float2 los, float window)`,
whose comment calls it "the one call guns and munitions make". A munition can answer it with its
own envelope, heading and track without a second evasion function.
- **MA3. The envelope and track types** (`FireControl.cs:2049-2115`). `ManoeuvreEnvelope` is six
accelerations (Forward, Reverse, Left, Right in m/s^2 along body axes; Clockwise,
CounterClockwise turn rates). `ManoeuvreTrack.Observe(float2 acceleration, float dt, float
window)` keeps trend, innovation and second moments and forgets a window after the last change
of vector. `Reach` (:521-546) with both turn rates zero reduces to the box's no-turn support
`0.5 * (face along) * T^2` plus faces whose turn is zero (`ReachFace` returns 0 for a turned face
when its rate is below 1e-4, :552). So a point-mass munition with no turn rate is priced by its
box alone, which is what a vector-commanded munition can do.
- **MA4. Who writes a ship's track** (grep over `Assets/Scripts/ServerShared`). One writer:
`Entity.Update` at `Entity.cs:1478` (`Manoeuvre.Observe(Acceleration, delta, SolutionWindow)`),
acceleration being the base update's velocity change with the limit clamp added back (:1477).
Thrusters supply the envelope (`Thruster.Manoeuvre()`, `Thruster.cs:98`); `Ship.Envelope` sums
them (`Ship.cs:153-159`).
- **MA5. Fire freezes the mount for an entity subject only** (`FireControl.cs:688`). `pFire =
solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged),
TrackingRate(weapon, source)) : 1f)`. `CommitProbability` (:864-878) multiplies the frozen
`PFire` by `DeviationProbability` (:483-489), which adds `Evasion(shot.Target, ...)` to the
realised drift. A munition subject needs both: the mount frozen at fire from its own position and
velocity, and the evasion at commit from its own track.
- **MA6. `AngularVelocity` has a planar overload** (`FireControl.cs:209-213`):
`AngularVelocity(float2 offset, float2 relativeVelocity)`, range floored at 1 m. A munition's
offset is `Flight.Position - source.Position.xz` and its relative velocity `Flight.Velocity -
source.Velocity`; no new geometry is needed.
- **MA7. No munition code is on master** (`ls Assets/Scripts/ServerShared`, grep `MunitionId`).
`Munitions.cs`, `MunitionFlight` and `MunitionId` do not exist; `TargetRef` (`Zone.cs:646-670`)
has only Entity and Chunk. missile-stats, missile-records and missile-odds had no report
(specs-with-no-report query, as of ordinal 750). munition-shots' anchors into that code are by
name.
- **MA8. controls-mount-aim, in execution, changes the seams these cuts touch** (diff of
4d664b53..origin/eureka/aetheria-release-controls-mount-aim, FireControl.cs and Weapon.cs): adds
`Solution(Weapon, Entity shooter, TargetRef subject)` after `AimDirection` returning `GunSolution
{ Entity Subject; bool Bears; bool Free; float2 Direction }`; adds `Bears(weapon, source, Entity
target, out range)` and routes `PFire` through it; rewrites `ArcPermitsFire` to `fused || mine
layer || Solution(weapon, shooter, shooter.Target.Value)` bears or free; Solve's travel direction
becomes `Solution(weapon, source, target).Direction`. The branch dropped the Broadside scenario
(538b11b4: no catalog hull has side-rotated gun hardpoints).
- **MA9. PendingShot construction sites** (grep `new PendingShot` over Assets/Scripts and tests):
two, `FireControl.Fire` and `FireControlCut9Tests.cs`. Readers of `shot.Target` in FireControl:
:487-488, :808, :867-876, :884, :920-950, :1132-1140, :1190, :1483; outside:
`EntityInstance.cs:331-333`.
- **MA10. Autofire seams on master** (source reads): `Entity.WeaponGroups` `Entity.cs:82`, sized
:791-793, removal path :1050; `PowerBus.Step` :1453 then the equipment loop :1455-1458 (the only
place equipped behaviours execute in `Entity.Update`); `Weapon.Activate/Deactivate` set `_firing`
only (`Weapon.cs:156-164`), so a weapon draws no power by being triggered. `StanceAllowsFire`
`Weapon.cs:113`, `ArcAllowsFire` :123. `Refuses` `FireControl.cs:93-95`, `AgentFires` :104-112
(its first branch is the mine layer's: worth it when `Solve` says Designated). `Fire` reads
`source.Target.Value.Entity` at :683. `EntityPack` keys 0-16 on the base class, subclasses use
17-20, key 21 unused (`EntitySerializer.cs`). `ActionBarWeaponGroupBinding`
`ActionBarSlot.cs:177-210`; slot input actions are created in `ActionGameManager.createBinding`
(:434-442, `started` activates, `canceled` deactivates). `ControlsHud.cs` does not exist on master
(controls-hud creates it). `Zone.Targets.Within(in TargetSearch, List<TargetCandidate> into)`
(`TargetingIndex.cs:99`) fills a caller's list. `Scenarios.Tests` (`Scenarios.cs:7-10`) ends with
`LauncherAngles, LongHaul`.
- **MA11. The close-crosser fixture already exists**
(`tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs`). `EvMountRange(gunRate, laserRate)` sets
two guns on one shooter to authored rates through `EvSetRate` (:23-29), and
`LightAndHeavyGunsSplitOnAFastCloseCrosser` (:139-160) with rates 40 and 3 and `EvCross(range,
jinking: false, float2(100, 0))` at 200 m measures PMount >= 0.85 and <= 0.01. The steady
crosser's evasion is zero (EvCross settles the track), so the split is the mount term alone. The
fixture places the target with `ScenarioStance.Neutral` (default of `ScenarioStage.Place`,
`ScenarioStage.cs:109`); `Entity.SetIff(other, true)` (`Entity.cs:810`) or
`ScenarioStance.Hostile` makes it hostile.
- **MA12. No PD-class gun is in the catalog** (tracking table). The highest authored rates are
ClearPath 12 and Spectra 10 deg/s; launchers are +infinity. Against inbound missiles the angular
velocity is small (mostly radial approach), so these suffice to engage salvos in a scenario;
against a fast close crosser ClearPath prices 0.37 / 0.76 (TC / FCA, map table). The catalog
growth section's PD guns (CG-probes) are the content that closes this.
### The design
**The resolution model** is ruled: `missiles-resolution-model`, geometry-sets-odds. The second
pass recommended pure geometry (a fuze decides contact) and rejected both roll variants as two
answers to one question. The operator overturned it for a reason that pass did not weigh: the
simulation is not at a scale where contact means anything, so a fuze makes terminal fuel,
targeting data and the missile's own skill irrelevant to the hit. The split-brain worry is
answered by one commit point, after which the flight obeys the roll, which is the gun's own
contract (M22):
- The flight steps as before (PN, fuel, seeker) and decides *whether and when* a missile
reaches its terminal gate, and with what left. A missile that runs dry, loses its seeker or is
outmanoeuvred arrives with a large miss, or never arrives.
- At the gate the roll decides *whether it connects*, once, at a price `FireControl` owns. After
the roll the flight presents the outcome and decides nothing.
**Evasion is how unpredictably the target can and does change its motion** (rulings
`manoeuvre-penalises-solutions`, `evasion-not-only-flight-time` and `evasion-is-unpredictability`).
M20 shows the price has no manoeuvre term and a laser cannot be dodged. r1 answered with one strafe
figure; the operator ruled that incomplete: "A ship like Longinus has almost zero strafe thrust but
should still be plenty agile while the Djinni has dedicated strafing thrusters but is too heavy to
get very far with them. Unpredictability of motion should matter, too." M24 adds that the strafe
figure is not even what the hull can do. The term (`evasion-term` r2) is the product of two parts,
each with one owner, and it adds to the in-flight deviation rather than replacing it:
- **Capability: the reachable spread.** `Ship.Envelope` is a `ManoeuvreEnvelope`: body-frame
acceleration forward, reverse, left and right (m/s^2) and turn rate clockwise and
counter-clockwise (rad/s), summed from what each live propulsor reports with the arithmetic its
own `Execute` uses (a `Thruster` its thrust over mass along its mount and its lever times thrust
times `TorqueMultiplier` over mass). An aether drive reports nothing: the drive is parked out of
the demo (ruling `retire-longinusx`), and evasion-term r6 deletes the `AetherDrive.Manoeuvre`
report that r2 landed (see "Retiring the LonginusX and parking the aether drive"). A thruster
that only turns counts as lateral push, because it pushes whenever it turns (M23). Not a `Ship`, no envelope. `FireControl.Reach(envelope, heading, direction,
window)` is the largest displacement from the coasting path along `direction` in `window`, by
the eight-face form of M26. The capability across a line of sight is the half-width of the
reachable interval, `C = (Reach(+n) + Reach(-n)) / 2`, with `n` perpendicular to the line of
sight. An omnidirectional hull has the same `C` from any bearing; a Longinus has its largest
crossing the line of sight and a smaller one nose-on, because it must turn first.
- **Behaviour: how much it is changing its vector.** `Entity.Manoeuvre` is a `ManoeuvreTrack`,
written once per `Entity.Update` from the measured acceleration (the velocity change across the
base update, so thrust and not drag or gravity): a trend `a_bar` (exponential average, time
constant `SolutionWindow`), the innovation `a - a_bar` low-passed with time constant
`SolutionWindow / 4`, and the exponential average of that innovation's outer product (three
floats). `LateralRms(n)` is the root of its projection on `n`. A steady burn is predictable
(its innovation decays to zero), coasting is predictable, a weave at the window's scale is not,
and dithering much faster than the window averages out (Zarchan, above).
- **The term.** `FireControl.Evasion(target, lineOfSight)` reads the target's envelope, heading,
track and `SolutionWindow` `T`: `u = 0.5 * LateralRms(n) * T^2`, zero when `u` is zero (no
envelope work for a coasting target), else `min(C, u)`. Behaviour realises the evasion,
capability caps it. A coasting ship evades nothing whatever it could do; a jinking ship evades
up to what its envelope reaches across that line of sight. Sideways speed does not enter this
term: the lead cancels it as a prediction problem (M20). It enters the mount's term (ruling
`evasion-angular-velocity`, "Tracking per gun" below), which `Evasion` does not compute, so a
missile's gate price never sees it.
- **Pure core, one entity entry.** `Reach` and the pure `Evasion(envelope, heading, track,
lineOfSight, window)` are public and allocation-free; the entity overload is what guns and
missiles call. `PDeviation(deviation, tolerance)` stays the one deviation shape.
`DeviationProbability` adds `Evasion(target, TravelDirection)` at the commit to the realized
deviation; `HitProbability` multiplies `PDeviation(Evasion(...), Tracking(source))`. A laser
commits at fire, so its whole vector penalty is this term. A target moving at constant velocity
has `Evasion` 0.
Measured with this form (scratch model of `Reach` and `ManoeuvreTrack` at dt 1/60, each ship's
best of a fixed jink set: lateral square wave, a new random direction, and a turn-and-burn weave
of plus or minus 60 degrees, half-periods 0.5 and 1 s, read over the last 4 s of 8):
| Ship, stats | Window 0.5 s | 0.75 s | 1.0 s | Coasting |
|---|---|---|---|---|
| Gemini-like test hull: the Longinus's 130.4 m/s^2 on all four axes, its turn | 11.0 m | 20.9 m | 31.2 m | 0 |
| Longinus (catalog, M23, flank push as strafe) | 6.4 m | 14.7 m | 24.9 m | 0 |
| Djinni (catalog stats, M23) | 0.9 m | 1.7 m | 2.6 m | 0 |
The Gemini-like hull is most evasive from every bearing; the Longinus is about seven times the
Djinni, mostly by turning its drive (nose-on 6.4 m against a strafe-only jink's 1.7); the Djinni
is limited by its mass. The Longinus crossing the line of sight and only weaving reads 1.5 m, and
pulsing its drive instead reads about 5.5: its evasion is in its throttle and its turn, not its
strafe.
**The speed demon's counterplay** (ruling `speed-demon-counterplay`): "Decent chance that unless
you specced specifically to counter them, the only gun you have that'll hit a speed demon is
stuff you intended to use for PD. That's what they're counting on, since they sacrificed all
their armor for that mobility." The vector term delivers part of that: the shooter's `Tracking`
forgives the evasion, and the target's envelope is large exactly when it carries little mass. The
per-gun part is the mount's term in "Tracking per gun" below. Re-measured without r1's speed share: a speed demon with 60 m/s^2 on every
axis, jinking a lateral square wave at half-period 0.5 s nose-on, opens 5.07 m at window 0.5 s,
so a shooter with `Tracking` 10 keeps 0.49 and one with `Tracking` 50 keeps 0.90; a heavy hull
with 10 m/s^2 opens 0.84 m (0.92 and 0.98). At window 0.75 s the speed demon opens 9.63 m (0.04
and 0.81). r1's numbers (22.5 m, 0 and 0.55) came mostly from the
150 m/s crossing speed this revision no longer counts. Window 0.75 s is what makes heavy guns
"mostly miss" a well-flown speed demon while PD-class tracking still lands, so `evasion-term` r2
authors `SolutionWindow` 0.75; the catalog's shooters track 17 to 38 (M23 probe), and the play
check owns the final value. The autofire chooser lets PD guns divert: with no worth-firing
munition, an autofire gun takes the designated target or the best-priced hostile when its price
clears the threshold. A worth-firing hostile munition preempts an entity subject even while that
subject stays worth firing (`autofire-threshold` r3), so a PD group that diverted onto a speed
demon still turns back to the missiles.
**Tracking per gun: a mount's rate against line-of-sight angular velocity** (`evasion-term` r4,
revised by r5). Ruling `tracking-per-gun`: "gun times ship". Ruling `evasion-angular-velocity`,
which replaces `evasion-speed`: "now that I think about it, relative velocity should be an evasion
term, shouldn't it? ... yes, angular velocity is the term I was reaching for". Ruling
`weapon-tracking-authored`, which supersedes `weapon-tracking-defaults`: "I never said footprint
directly determines tracking." Each gun's rate is authored, on a steep scale: PD-class guns hit a
speed demon reliably, the current smallest guns struggle, big guns rarely hit something fast.
Ruling `jink-per-ship`: "Jink per ship is fine, the point is to confuse attacker's fire control
after all." Soul's finding
`cut-evasion-term.s1.tracking-per-ship` is the gap this closes: with one `Tracking` per ship
(M27), a light and a heavy gun on one hull were forgiven alike.
Evasion has two terms, each failing the shot for a different reason, each with one owner:
- **Changes of vector defeat the prediction.** This is r2's term, unchanged: `Evasion(target,
lineOfSight)` in metres, forgiven by the ship's targeting gear `Tracking(source)` in metres
through `PDeviation`, and frozen as `PendingShot.Tracking`, which also forgives the in-flight
drift. Predicting where a target will be is the fire-control computer's job, so this tolerance
stays per ship.
- **Line-of-sight angular velocity defeats the mount.** `FireControl.AngularVelocity(offset,
relativeVelocity)` is `degrees(|offset x relativeVelocity| / max(|offset|, 1)^2)`, planar, in
degrees per second; the entity overload passes the target's position minus the shooter's and
the target's velocity minus the shooter's. Radial velocity contributes nothing and a shooter
matching the target's velocity sees none. The shot's tracking rate is
`FireControl.TrackingRate(weapon, source) = weapon.Tracking * Tracking(source) /
UnaidedTracking`: the gun's own rate in degrees per second (`WeaponData.Tracking`, key 33, a
`PerformanceStat` evaluated like `Spread`), times the targeting gear's `Tracking` as a multiple
of unaided (1 unaided, 1.5 to 2.5 for the Targeting Computer, 3 to 4.5 for the Fire Control
Array). That is ruling `tracking-per-gun`'s product, and EVE's tracking computers work the
same way. The factor is `PMount(omega, rate) = 0.5 ^ ((omega / rate)^2)`: 1 for a still
line of sight, one half at the mount's rate, a sixteenth at twice it, never a cliff.
`HitProbability` is `PFire * PSpread * POnHull * PDeviation(Evasion, Tracking(source)) *
PMount(AngularVelocity(source, target), TrackingRate(weapon, source))`. `Fire` freezes the mount
factor into the shot's shooter-side probability, `PendingShot.PFire = solution.PFire * PMount`:
the mount follows the target until the round leaves, a fired round cannot be re-laid, and the
mount is the shooter's, like accuracy and sensor data (M31). `CommitProbability` is unchanged and
the HUD's estimate at fire is still the commit price. No new shot field: the one hand-built
`PendingShot` (`FireControlCut9Tests.cs:160`) and every test that sets `PFire` keep their meaning.
A beam fires through `Fire` every resolve interval, so it is priced afresh each time. A round
fired at nothing has nothing to follow and multiplies by 1. `Inspect` reports `AngularVelocity`,
`TrackingRate` and `PMount`, and the debug HUD prints them beside `Tracking`.
`Evasion` never computes the mount term. A guided round's gate price (`missile-odds` r2) reads
`Evasion(subject, line of sight)` and its own correction, so the angular term does not touch it:
a missile steers itself, and no mount follows it. The table authors every launcher and guided
weapon at positive infinity on purpose: no mount follows a missile. Until `missile-records` and
`missile-odds` land, launchers fire as ordinary shots (M1), and their price is exactly what it is
at `46b28501`.
**The table.** Each weapon's rate is authored on its record (`WeaponData.Tracking`, degrees per
second, `Min == Max`, no terms), and nothing derives it: not footprint, caliber or hardpoint type
(ruling `weapon-tracking-authored`). The initial values are written once by a one-shot AetherDb
migration that carries the table below as a literal list keyed by record, then is deleted, as
locomotion's `restore-hulls` was (M32), so afterwards the catalog is the table's only copy. The
scale is steep and anchored on the ruled outcomes, with each gun placed by its role (M34): weight,
damage per shot, rate of fire and size. Footprint is evidence that agrees with most of it, never
the key.
| Rate (deg/s) | Weapons | Why there |
|---|---|---|
| about 40 | none yet: PD class, content to author | Hits a speed demon reliably: 0.82 with the Targeting Computer at 150 m/s crossing 200 m. |
| 12 | ClearPath | Lightest ballistic (50 kg), the lowest damage (2-6) at the highest rate (10-30 shots a second), 600 m reach: a cargo-defence hose, the catalog's nearest thing to PD, still a struggling small gun. |
| 10 | Spectra | Lightest weapon (25 kg), a light beam (20-85) at 2-4 shots a second. |
| 9 | FastBlast+- | "Do less damage, faster": 12-pellet bursts of 20-80 at 3-10 a second, but 125 kg. |
| 8 | Earp | Single shots (5-25) as fast as the trigger, 75 kg, reach to 2,500 m: a precision gun, not a hose. |
| 7 | 6k Shooter | Earp's role, heavier (95 kg) and shorter. |
| 6 | ColdFire | Heaviest small gun (150 kg), a 40-160 laser every 1 to 2.5 s to 2,250 m: a small sniper. |
| 4 | Autocannon | Medium and four cells but light (75 kg) with 3 damage a shot: the quickest of the medium guns. |
| 3.5 | ChargeBlast+- | 225 kg, one charged knockout shot (25-110, tripled when charged) to 1,900 m. |
| 3 | DeathCluster, ChargeBlast SG, pretty pretty bang bang | 250 kg each, heavy volleys of 5 to 15 pellets of 50-256: the medium shotguns. |
| 2 | plight | Large charged lightning, 75-400 (tripled when charged), one shot every 2 to 4 s after a 2 to 3 s charge: a heavy gun (ruling `plight-not-one-cell`). |
| 1.5 | CShot RainbowLite Lazer | The heaviest gun (500 kg, nine cells), 100-600 to 3,000 m; fits no hull today. |
| infinity | GT 3K, pswarm, scorched void policy, LRMM72, SRMM72 | No mount follows a missile; the round steers itself (`missile-odds`). |
A field left unauthored defaults to positive infinity, a mount that follows anything, so
code-built fixtures price exactly as before. That default is harmless only while every shipped gun
carries a finite rate, which a test over the catalog pins; a weapon authored later without a rate
would follow anything, and that test is what catches it. Fitted by M33 at the TC / FCA midpoints,
with crossing speeds at the hulls' real top speeds (M32):
| Geometry (degrees per second) | PD 40 | 12 | 8 | 6 | 4 | 3 | 2 |
|---|---|---|---|---|---|---|---|
| Fast crosser close: Longinus at its 100 m/s top speed, 200 m (28.6) | 0.91 / 0.98 | 0.37 / 0.76 | 0.11 / 0.53 | 0.02 / 0.33 | 0.00 / 0.08 | 0.00 / 0.01 | 0 / 0 |
| The same at range: 100 m/s at 1,000 m (5.7) | 1.00 / 1.00 | 0.96 / 0.99 | 0.91 / 0.98 | 0.85 / 0.96 | 0.70 / 0.90 | 0.53 / 0.84 | 0.24 / 0.67 |
| Heavy ship at range: Djinni at its 50 m/s top speed, 1,000 m (2.9) | 1.00 / 1.00 | 0.99 / 1.00 | 0.98 / 0.99 | 0.96 / 0.99 | 0.91 / 0.98 | 0.85 / 0.96 | 0.70 / 0.90 |
| Heavy ship close: Djinni at 50 m/s, 200 m (14.3) | 0.98 / 0.99 | 0.78 / 0.93 | 0.57 / 0.85 | 0.37 / 0.76 | 0.11 / 0.53 | 0.02 / 0.33 | 0.00 / 0.08 |
| Jinking target: Longinus weaving (14.7 m), 60 m/s at 500 m (6.9) | 0.26 / 0.61 | 0.25 / 0.60 | 0.23 / 0.59 | 0.21 / 0.57 | 0.16 / 0.53 | 0.11 / 0.47 | 0.03 / 0.34 |
| Speed demon (no hull authored): 150 m/s at 200 m (43.0) | 0.82 / 0.94 | 0.11 / 0.53 | 0.01 / 0.24 | 0.00 / 0.08 | 0 / 0 | 0 / 0 | 0 / 0 |
| Speed demon jinking close: 9.63 m, 150 m/s at 200 m (43.0) | 0.42 / 0.70 | 0.06 / 0.40 | 0.00 / 0.18 | 0.00 / 0.06 | 0 / 0 | 0 / 0 | 0 / 0 |
| Uncapped LonginusX: 800 m/s at 1,000 m (45.8) | 0.80 / 0.94 | 0.08 / 0.49 | 0.00 / 0.20 | 0.00 / 0.06 | 0 / 0 | 0 / 0 | 0 / 0 |
Each cell is `PDeviation(E, Tracking) * PMount`, the whole evasion share of the price. Unaided
(`Tracking` 10), the fast crosser close leaves a PD gun 0.70 and ClearPath 0.02. Crossing at 200 m
with the Targeting Computer, by speed, for PD, 12 and 4: 50 m/s gives 0.98, 0.78 and 0.11; 100 m/s
gives 0.91, 0.37 and 0; 150 m/s gives 0.82, 0.11 and 0; 300 m/s gives 0.45, 0 and 0. The ruled
outcomes hold: a PD-class gun hits a fast hull close reliably (0.82 to 0.91 with the common gear),
the smallest guns struggle (0.37 at best, 0.02 to 0.11 for most), the big guns rarely hit
something fast (0.08 at most), and the big guns hit a big ship at range (0.70 to 0.91). Against a
jinking target at middle range the guns barely differ, because the jink is a prediction problem
the ship's gear forgives (ruling `jink-per-ship`), not the mount; they part company when the
target is close and fast. The individual rates are the play check's levers; the anchors are the
ruled outcomes.
**Not imported from EVE.** Signature resolution and signature radius: the probe meets every ruled
outcome without them, and a target's size already enters the price through its silhouette
(`PSpread`, `POnHull`). Falloff: range is priced by `PSpread` and the weapon's range gates.
**Counters to the close orbit.** EVE's documented failure mode, the small fast ship orbiting close
while big guns go useless, is the play shape ruling `speed-demon-counterplay` asks for, so the
counters must exist and lie outside the big gun:
- PD-class guns, authored near 40 deg/s. The catalog has none yet; they are content to author.
plight, the one-cell gun, is a heavy gun whose footprint is a content error (M35).
- Gear: the Fire Control Array multiplies every mount by 3 to 4.5, so ClearPath on a dedicated fit
keeps 0.76 against the fast crosser close.
- Range: angular velocity falls with range, so a ship that holds the crosser off sees it at a
fifth of the rate at 1,000 m; the fast ship has to come close to use its own guns, which face
the same term.
- Velocity: a shooter that matches the target's velocity sees no angular velocity, so a fast
hull of one's own is a counter.
- Missiles: a guided round has no mount (`missile-odds`), so launchers counter it as drones do
in EVE.
- Armour: the speed demon gave its up, so the rounds that land hurt.
**Top speed is a hull stat, and so an evasion stat** (M32). The Longinus is capped at 100 m/s and
the Djinni at 50 by their own `VelocityLimit` behaviour, and the table above is fitted at those
speeds. The uncapped LonginusX row records what an uncapped hull would do; the hull itself is
retired (ruling `retire-longinusx`), question `longinusx-top-speed` is withdrawn as moot, and after
the retirement every ship hull in the shipped catalog carries a cap ("The dock intro", below).
**When articulated mounts arrive** (ruling `arc-and-traverse-on-the-link`, follow-up
`articulated-mounts`), the link's traverse speed and the gun's tracking compose by the slower of
the two inside `TrackingRate`: a heavy gun on a fast link is still slow, and a pod of small guns on
a slow link slews slowly, as that ruling says. Until then a fixed mount follows at the gun's own
rate within its arc.
**The missile's price** (`missile-odds`). At the gate, the same model with the missile's
inputs:
| Gun factor | Missile factor |
|---|---|
| `PFire` = shooter `Accuracy` x `PSensor(shooter info)` | data: locked, `PSensor(settings, 1, seeker reading)`, its own gathered data; unlocked, the frozen launch `PFire` |
| realized deviation in flight | zero-effort miss beyond reach, `max(0, ZEM - reach)`, with `reach = max(BlastRadius, SchematicCellSize / 2)` plus half the target's silhouette span across the line of sight |
| `Evasion` against the gun's bearing | `Evasion(target, line of sight)`: the same entity call a gun makes |
| tolerance `Tracking` | correction: locked, `Cognition * Closable(Lateral, DeltaV, time to go)`; unlocked, 0 |
| `PSpread`, `POnHull` | 1: a guided round has no barrel spread and steers onto metal |
`Closable(a, dv, t)` is the sideways distance a missile can still cover in `t` with acceleration
`a` and delta-v `dv`: `0.5 * a * t^2` when `dv >= a * t`, else `dv * t - dv^2 / (2a)`. Terminal
fuel, lateral authority and cognition widen the tolerance; seeker data scales the whole; the
target's motion and agility and the flight's own miss eat into it. An unguided rocket (no
seeker, no lateral authority) has tolerance 0, so geometry alone decides it: it connects only
when its straight line passes within reach.
**Cognition** (ruling `missile-cognition`) is `Control.Cognition`, 0 to 1, authored per missile
and later derived from guidance gear: the missile's skill at spending delta-v, in exactly four
places. (1) *Efficiency*: the debit is `|command| * dt * (2 - Cognition)`, so a mindless missile
wastes as much as it uses on corrections. (2) *Reserve*: before the gate, pursuit and evasion
may not spend below `Cognition * Lateral * CommitHorizon`, the delta-v a full-authority endgame
needs, so a smart missile arrives with its terminal and a dumb one may arrive dry. (3) *When to
jink*: evasion against inbound PD draws only on fuel above the reserve, so a dumb missile
(reserve 0) jinks itself dry while a smart one stops jinking to keep its terminal. (4) *The
price*: the correction scales with it.
**The missile's stats** (ruling `missile-stats-now-gear-later`). One `MunitionData` object on
`LauncherData` and on `GuidedWeaponData`, grouped by the gear each group will later come
from, so the entity cut replaces a group with a derivation from that gear without
reshaping anything that reads it:
| Group (gear later) | Fields (`PerformanceStat`, evaluated through the item) | Replaces |
|---|---|---|
| `Hull` (missile hull) | `Durability`, `Radius`, `Signature` | the first pass's global settings |
| `Thrusters` (missile thruster) | `Acceleration` (main motor), `Lateral` (manoeuvre authority: the sideways acceleration cap), `MaxSpeed` | `Thrust`, `MissileVelocity` |
| `Sensors` (seeker) | `Sensitivity`, `ConeHalfAngle`, `Range` | nothing (new) |
| `Control` (guidance, its cognition) | `Navigation` (PN constant), `Evasion` (share of `Lateral` spent jinking), `EvasionFrequency`, `Lifetime`, `Cognition` (skill at spending delta-v, 0 to 1, ruling `missile-cognition`) | `DodgeFrequency`, `GuidanceCurve` |
| `Fuel` (tank) | `DeltaV` (m/s) | nothing (new) |
| `Payload` (warhead) | no new field: `Munitions.Payload(item)` reads the weapon's existing `Damage`, `DamageSpread`, `DamageType`, `BlastRadius`, `Fuse` | nothing; one reader |
Payload is an accessor, not copied fields: the warhead already has one owner on
`WeaponItemData` (M17), and a copy would be a second. `LiftCurve` stays presentation outside
the groups. `ThrustCurve` dies once the presenter reads the live throttle. The
`missile-stats` cut lands the object, migrates the catalog (`Thrust` to
`Thrusters.Acceleration`, `MissileVelocity` to `Thrusters.MaxSpeed`, `DodgeFrequency` to
`Control.EvasionFrequency`), authors the new fields for GT 3K, pswarm, scorched void policy,
LRMM72 and SRMM72 (M13; the flat dumbfire pair get no seeker and no lateral authority, so
they fly as rockets), and deletes the three migrated fields before anything reads the
stats. `missile-presenter` deletes `GuidanceCurve` and `ThrustCurve` once the presenter stops
reading them. Together they close follow-up `launcher-flight-fields`.
**A missile is still its pending shot.** A shot is a munition when its weapon data is
`LauncherData` or `GuidedWeaponData` (`Munitions.IsMunition`, the one test). Its identity is
its `ShotId`; its flight state is one inline struct on the shot, `MunitionFlight`: planar
`Position` and `Velocity`, `DeltaV` remaining, `Command` (the last step's commanded
acceleration; the presenter's throttle is its length over `Acceleration`, and its sideways part
is the munition's acceleration as a target), `Wear`, its `Seeker` subject (an
`Entity`, null when it has none), `Locked`, and the source's faction frozen at fire. No second
list, dictionary or class of missiles exists beside `Zone.PendingShots`. This is still the
asteroid shape: a light record with a little state, simulated where the simulation lives, no
Entity, gear or agent behind it.
**Launch.** `Fire` freezes, as today, the launch data the shooter's own sensors gave it
(`FireTargetPosition`, `FireTargetVelocity`; a `LauncherData` round still needs the
launcher's lock, M17). The flight starts at the fire origin with the shooter's velocity plus
`weapon.Velocity` along the mount (`LaunchDirection`), full `DeltaV`, and the shot's target as
its seeker subject, unlocked (none for a round fired along the aim, whose launch data is
the aim point or burst position). A munition shot's `CommitTime` is infinite until its
terminal gate opens; `ArrivalTime` is `FireTime + Control.Lifetime`, its self-destruct time,
until the gate replaces it.
**The step.** `FireControl.Step` advances each live munition once per tick, before its
resolution tests, through one function `Munitions.Advance(Zone zone, ref PendingShot shot, float dt)`:
1. *Seek.* The seeker's reading of its subject is the settled sensor value (M16) with one
sensor: `Sensor.Gain(subject.Visibility, Sensitivity, cone(angle off the velocity),
distance, 1)` over `TargetInfoDecay`, saturated; zero beyond `Sensors.Range` or outside
`ConeHalfAngle`. `Locked` while it exceeds `TargetDetectionInfoThreshold`. Nothing else is
stored. The settled-value arithmetic moves out of `Entity.ChunkInfo` into one function
both call.
2. *Aim point.* Locked: the subject's live position and velocity (terminal guidance on its
own sensor). Unlocked: the launch track extrapolated, `FireTargetPosition +
FireTargetVelocity * (now - FireTime)` (midcourse: it knows where it was sent). A missile
in flight is told nothing else (ruling `shared-track-bearing-only`).
3. *Command.* Proportional navigation at the aim point: lateral acceleration `Navigation *
closing speed * line-of-sight rate`, clamped to `Thrusters.Lateral`, plus along-track
acceleration toward `MaxSpeed`, clamped to `Acceleration`. PN needs only the bearing and
its rate, so terminal guidance uses no data the ruling forbids.
4. *Evade.* While PD rounds are inbound at it (`InboundRounds > 0`, which arrives with
`munition-shots`, the cut that makes it shootable), it adds a jink of `Evasion * Lateral`
whose sign follows a phase seeded from its shot id at `EvasionFrequency`, drawing only on
fuel above its reserve (cognition, above). Evasion and pursuit share the clamp and the tank.
5. *Pay.* `DeltaV -= |command| * dt * (2 - Cognition)`. Before the gate a command that would
take `DeltaV` below the reserve `Cognition * Lateral * CommitHorizon` is scaled down to what
the reserve allows; after the gate the whole tank is usable. A dry missile commands nothing
and coasts on its velocity (Nebulous).
6. *Move and gate.* Integrate position. `Munitions.Terminal` holds when the missile has a seeker
subject and the time to its closest approach with that subject (both straight motions) is
within `CommitHorizon` and before its lifetime ends. `missile-odds` opens the gate there
(`CommitTime = now`, `ArrivalTime = now + time to go`) and the generic `Commit` rolls. Until
`missile-odds` lands nothing opens the gate, and every missile ends at its lifetime.
**Resolution.** At the gate the generic `Commit` rolls the munition price with the per-shot
seed and publishes `ShotCommitted`: `Hit` or `Miss`. Between commit and arrival (at most
`CommitHorizon`) the flight presents the outcome and decides nothing: after a hit it closes on
the target's live position with whatever sideways acceleration the closure needs, without
debit; after a miss it commands nothing and coasts past. At `ArrivalTime` the generic `Apply`
resolves it. A hit detonates through `Detonate` at the hull entry point on the missile's line to
the target (`HullEntry`, M19), or at the target's position when the line finds no cell, with
radius `max(BlastRadius, SchematicCellSize / 2)`. A miss damages nothing: a fused missile's near
miss is the presenter's flash, not a blast, so the roll is the only thing that decides the
target's damage. A munition that reaches its lifetime without a gate resolves `Miss` at its
position (a fizzle). Interception keeps the first pass's rule at every stage, committed
included: wear at `Hull.Durability` resolves a fresh `Intercepted` outcome, no damage. A seeker
subject that leaves the zone before the gate makes the missile blind (retargeting, below); after
the gate it resolves a fresh `Miss`, the target-gone precedent (M4).
**Retargeting** is ruled: `missiles-seeker-retarget`, hostile-in-cone. A missile with no
subject, or one outside its cone, queries the targeting index every
`GameplaySettings.SeekerInterval` (0.5 s, Nebulous's re-selection period) for entities hostile
to the frozen faction, within `Sensors.Range` and the cone, and takes the brightest reading
above threshold. Turning invisible works through the same reading: a target whose visibility
falls below what the seeker needs is lost, and the missile seeks again. Decoys need something a
seeker can read that is not a hostile ship; no cut here makes one (follow-up
`missile-decoys-signature`).
**Seen and shot like a rock.** A munition's detectability uses the passive balance with its
own `Hull.Signature`; the index provider `MunitionTargets` offers every live munition at its
stepped position; `TargetRef` gains `MunitionId`; `Entity.SetTarget` refuses a munition. PD
rounds at a munition are priced and rolled by `FireControl` as the first pass said; lead now
reads the missile's real velocity, and the deviation term reads its real stepped position
against the frozen prediction, so jinking is what beats PD. As a target a munition takes the
shared `Evasion` term with its own envelope (`Lateral` each side while it has delta-v, no turn) and a
`ManoeuvreTrack` fed its commanded acceleration (`munition-shots` must be revised to this interface), so a jinking missile is harder to hold and a dry one is
easy prey. The "round arrives too late"
gate reads `Munitions.TimeToGo` (distance to its aim point over closing speed) instead of a
fixed arrival.
**The presenter.** `GuidedProjectile` places itself at the stepped position (height from
`LiftCurve` over its life), faces the velocity, and drives its flame from the length of
`Command`, dark when dry. Between commit and arrival it follows the record's post-commit
flight. It plays a hit at a `Hit` (at the burst point), a kill at `Intercepted`, a near-miss
flash at a committed `Miss` and a fizzle at a lifetime `Miss`.
**Thresholded autofire.** A weapon group can be set to autofire with a threshold (default
`AgentMinHitProbability`, 0.2). Each tick, before behaviours, `Autofire.Update` gives each
weapon of an autofire group a subject from `FireControl.ChooseSubject` and activates it
when one is chosen, deactivating it otherwise. `ChooseSubject` keeps the current subject while it is still worth firing, except that a
worth-firing hostile munition preempts an entity subject; otherwise it takes, in order, the most urgent detected
hostile munition in reach (least `Munitions.TimeToGo`), then the designated target, then the
detected hostile entity in reach with the highest price, each only if worth firing.
"Worth firing" is one function, `FireControl.Worth(weapon, shooter, subject, threshold)`:
the price against the threshold for an unfused weapon, designated and not refused for a
fused one. `AgentFires` becomes `Worth` at the agent threshold. `FireControl.SubjectOf(weapon,
shooter)` is the one answer to "what does this weapon fire at": its autofire subject in an
autofire group, else the entity's designated target. `Fire`, `Refuses`, `Solution`,
`ArcPermitsFire` and the stance gate read it.
**What makes a gun point defense** is ruled: `pd-who-engages`, any-gun ("anything can be
PD"). Nothing in data; any autofire gun can engage a munition, and the price decides: a
fast, tight, short-flight gun clears a threshold against a small fast target, a heavy gun
does not. Heat is the waste guard.
**Manual fire against autofire** is ruled: `controls-solution-subject`, designated ("manual
guns fire only on selected target when solution is available, autofire guns seek their own
firing solutions"). Manual fire is `controls-mount-aim` r2 unchanged (`SubjectOf` returns the
designated target); autofire's own choice is `ChooseSubject`. The trigger is ruled too:
`controls-solution-trigger`, group-trigger ("Group triggers only"): holding a group's trigger
fires each gun on its solution, else along the aim when the aim is in its arc, else holds.
A launcher fired along the aim with no solution launches at the aim point as its launch
data and its seeker may acquire what it finds there (HighFleet's blind fire).
**The AI.** Agents fire through the same chooser (`ai-autofire`): `Combat` and
`TurretController` treat every group as autofire at the agent threshold, with their chosen
target as the designated one. One owner of "which subject, and is it worth it" for player
and AI alike, and AI point defense against the player's missiles falls out of it. The
revision leaves `ai-autofire` r1 as it is: it reads only `ChooseSubject` and `Worth`.
**The HUD.** An autofire group's action-bar slot shows a filled mark and its threshold.
`ControlsHud` draws a small diamond on each detected hostile munition within the ship's
longest weapon range: filled while it can still manoeuvre, hollow once it coasts dry, ringed
while one of your autofire guns has it as subject.
### Authority map
- **Owner.** `FireControl` owns the one hit price for every shot, gun, beam or guided
(`HitProbability`, `CommitProbability`, `Evasion`, `PDeviation`, `Reach`), every munition
transition (fire, gate, commit, application, interception, lifetime, resolution) and every
subject question (`SubjectOf`, `ChooseSubject`, `Worth`). `Munitions` owns the pure facts and
the flight arithmetic (`IsMunition`, `Payload`, `Advance`, `TimeToGo`, `Terminal`, `Reserve`,
the seeker reading); `FireControl.Step` is its only caller that writes a shot. `MunitionData`
owns a missile type's stats, cognition included, until gear replaces each group.
`Entity.Update` owns `Entity.Manoeuvre` (and the acceleration it observes); `Ship` owns `Ship.Envelope`, summed from what each propulsor reports. The player owns which groups are autofire and
their thresholds.
- **Inputs.** The frozen launch data, the flight state, zone time and dt, the seeker subject's
live position, velocity, acceleration and visibility, the munition's stats, the count of PD
rounds inbound; the weapon's stats and the shooter's sensors, accuracy and tracking; the
target's strafe thrust and mass; `Entity.Autofire`.
- **Outputs.** Munition positions, velocities and commands for the index, the HUD and the
presenter; `Hit`, `Miss` and `Intercepted` outcomes; blasts through `Detonate`; prices that
carry the tracking term for the HUD, the AI and autofire; weapon activation for autofire
groups; the saved autofire settings.
- **Derived state.** Seeker reading (every step, never stored beyond `Locked`), time to go, the
terminal fact, reserve, zero-effort miss, correction, agility, evasion, throttle (from
`Command`), detectability, the index's munition region.
- **Demotions.** The first pass's derived pose is gone: a munition's position is stepped state,
written only in `Munitions.Advance` under `FireControl.Step`. The second pass's geometric fuze
never lands: the flight decides only whether and with what a missile reaches its gate; the
roll at the gate decides contact, and the post-commit flight only presents it. Flight time is
no longer the only way a manoeuvre reaches a price: the tracking term reaches every shot.
`GuidedProjectile` owns only how a round looks. `Thrust`, `MissileVelocity`, `DodgeFrequency`,
`GuidanceCurve` and `ThrustCurve` no longer exist as loose launcher fields. `Entity.Target` is
the designated target, the subject of manual fire only. `PendingShot.Target` derives from
`Subject`.
- **Forbidden writers.** Any write of a `MunitionFlight` field outside `Munitions.Advance` and
`Fire`, except `Wear` (only `FireControl.DamageMunition`) and `InboundRounds` (only
`FireControl`'s shot transitions). Any contact, damage or dice in `Munitions`. A commit point
for a munition other than its terminal gate; a second price for guided rounds beside
`CommitProbability`'s munition branch. An evasion, reach or deviation-shape formula outside
`FireControl.Evasion`, `Reach` and `PDeviation`. Any write of `Entity.Manoeuvre` outside
`Entity.Update`. Homing, intercept or guidance arithmetic in `Gameplay` or `UI`. A copy of
warhead fields inside `MunitionData`. A global setting standing in for a per-missile stat. Any
read of `Entity.Target` as a weapon's subject outside `SubjectOf`. A second worth-it test
beside `Worth`. A second record type, list or dictionary of missiles beside
`Zone.PendingShots`.
- **Shared paths.** Guns, beams, instant weapons, PD rounds at munitions and guided rounds all
price through `PDeviation` and `Evasion`, commit through `Commit` and apply through `Apply`.
Missile hits, mines and fused rounds all damage through `Detonate`. The seeker and
`Entity.ChunkInfo` read the same settled-sensor function over `Sensor.Gain`. Player autofire,
`Combat` and `TurretController` choose through `ChooseSubject`; the presenter, the HUD and the
index read the same flight state.
- **Deletion line.** Before new behaviour: the loose launcher flight fields go in
`missile-stats` (migrated, then deleted); `DeviationProbability`'s inline
`saturate(1 - deviation / Tracking)` becomes `PDeviation` in `evasion-term` before the term is
added; `GuidedProjectile`'s homing, dodge and split code and the two curves go in
`missile-presenter`; the stored `PendingShot.Target` field becomes `Subject` in
`munition-shots`.
### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Munition (light record) | Its `PendingShot`'s `ShotId`, as `MunitionId` in a `TargetRef`. | From fire through its terminal gate (commit) to arrival, or until interception or lifetime; never saved, as pending shots are not. | `FireControl` for transitions and the roll; `Munitions.Advance` for flight. |
| Munition flight | `PendingShot.Flight` (position, velocity, delta-v, command, wear, seeker, lock, faction, inbound rounds, `Track`). | Stepped every tick; gone with its shot. `Flight.Track` observes the command Advance applied each step; it is runtime, never saved, and discarded with the shot. | `Munitions.Advance` under `FireControl.Step`, the one writer of the track (as `Entity.Update` is for a ship); `Munitions.Envelope` derives what it can reach from its stats and fuel, and `FireControl` prices it through the same evasion core as a ship; wear by `DamageMunition`. |
| Entity manoeuvre track | `Entity.Manoeuvre` (`ManoeuvreTrack`: trend, filtered innovation, its second moment). | Observed every update from the velocity change across the base update; never saved. | `Entity.Update`. |
| Ship envelope | `Ship.Envelope` (`ManoeuvreEnvelope`: four body-frame accelerations, two turn rates). | Recomputed every update from the live propulsors; never saved. | `Ship`, from each propulsor's report. |
| Missile stats | `MunitionData` on `LauncherData` / `GuidedWeaponData`, six groups. | Catalog data in `Aetheria.cc`. | The catalog now; gear once munitions are entities. |
| Autofire setting | Per weapon group, `Entity.Autofire` (on, threshold). | Set by the player; saved in `EntityPack` key 21. | The player. |
| Autofire subject | Per weapon, `Weapon.AutofireSubject`, runtime only. | Re-picked every tick, and when the current one stops being worth firing. | `FireControl.ChooseSubject`. |
### Rationale
**Why stepping now.** The first pass refused stepping because a stepped missile would hold
a second clock of arrival beside `FireControl`'s frozen timing. Ruling
`missiles-fuel-and-seekers` makes the outcome depend on what happens in flight, which no
frozen time can know; so the frozen timing and the roll leave guided rounds and the flight
becomes their one clock. Stepping costs a few dozen operations per missile per tick, pinned
by measurement in `missile-records`.
**Why PN on a bearing.** Ruling `shared-track-bearing-only` lets a missile in flight hold a
bearing and nothing more. Proportional navigation is the textbook guidance law and needs
only the line-of-sight angle and its rate, so the law and the ruling agree, and the
textbook's failure modes (a late hard break, burn-and-coast against augmented PN, low energy
at long range) become the dodging the operator asked for.
**Why odds at one gate.** Ruling `missiles-resolution-model` reverses the second pass's
geometry-only recommendation, and the reason is the operator's: at this simulation's scale a
fuze is not physics, it is a coarse proxy that throws away terminal fuel, seeker data and
cognition. The second pass's objection to rolls (flight and dice answering one question twice)
is met by placing the roll where guns place theirs, at a commit before arrival, and making the
flight after it presentation. The flight still matters: it is what spends fuel, loses seekers
and builds the miss the price reads. The entity cut keeps this shape: gear replaces the stats
the price reads, not the price.
**Why evasion is envelope times unpredictability.** Ruling `evasion-not-only-flight-time`:
in-flight deviation only charges a dodge the round's flight time allows, so a laser cannot be
dodged (M20); a tracking term charges every weapon. Ruling `evasion-is-unpredictability` says what
the term measures: "The Gemini is evasive because it can floor it in any direction at any time
(with each new vector fouling the targeting solution), whereas the Longinus needs to aim itself
where it wants to go". That is Singer's split, capability and manoeuvre frequency, and the naval
lesson that fire control holds a steady course and loses a changing one. The reachable spread is
the capability because it is the only measure that sees turn time, mass and every thruster at
once (M24 shows the strafe figure sees none of them correctly). The innovation statistic is the
behaviour because a steady burn and a coast are both predictable and dithering is not evasion.
`min` is the product of a saturated use fraction and the capability, written without a tuning
constant. Rejected: r1's strafe-over-mass agility (M24); transversal speed inside this term (the
lead cancels it as prediction; ruling `evasion-angular-velocity` prices it in the mount's own
term instead); instantaneous jerk (frame-dependent, rewards dithering); a per-hull agility stat in data
(the envelope derives from fitted gear, so refits and damage change evasion with no authoring).
**Why the two evasion terms multiply.** Ruling `evasion-angular-velocity` keeps changes of vector
and adds the line of sight's angular velocity. They fail a shot for different reasons with
different owners: a perfect predictor on a slow mount still lags a fast crosser, and a fast mount
still loses a jink it could not predict. The naval directors split them the same way (the rate
term solved, the manoeuvre unmodelled), and EVE's exponent sum is itself a product of per-term
factors. The price was already a product of independent factors (`PFire`, `PSpread`, `POnHull`,
`PDeviation`), so the mount's factor joins it with no tuning constant between the two terms.
Rejected: turning the angular velocity into metres (`omega * range * window`) and adding it to
the deviation. That makes one tolerance answer two questions, couples range into the vector term,
and would let a mount forgive in-flight drift, which no mount can re-lay once the round has left.
Rejected: `PDeviation(omega, rate)`, the linear shape. It reaches zero at the mount's rate, so a gun
goes from fine to never within a factor of two in speed, and "rarely" becomes "never". The
Gaussian keeps a tail, and its rate reads as the angular velocity at which the gun hits half its
shots, which a designer can author in the inspector.
**Why the vector tolerance stays per ship and the ship multiplies the mount.** Ruling
`tracking-per-gun` asked that a shot be forgiven per gun, "gun times ship", when evasion had one
term. With two terms, the per-gun part belongs where the gun's mount is the limit: the angular
term, forgiven by the gun's rate times the gear. The jink is a prediction error, which is the
targeting gear's job (Singer's split, the naval directors), so its tolerance stays the gear's
metres, and r3's dimensionless multiplier on those metres, never built, is dropped. A heavy gun
therefore loses to a fast close crosser, not to a weave it cannot predict any worse than a light
gun can. The gear keeps both of its effects: metres of prediction forgiven, and as a multiple of
unaided, the rate every mount on the ship follows, as EVE's tracking computers multiply turret
tracking. Rejected: a per-gun multiplier on the vector tolerance as well, which needs a reference
rate constant to turn degrees per second into a dimensionless factor, and charges the mount for
the director's error.
**Why reactive evasion.** The operator's sentence is "a missile that spends fuel evading
PD". Evasion triggered by inbound rounds makes PD fire cost the missile fuel even when it
misses, so a saturating salvo and a well-placed PD group both change what arrives. A
constant terminal weave (Nebulous) would charge every missile the same and make PD
irrelevant to the fuel story.
**Why stats per missile type, in gear-shaped groups.** Ruling `missile-stats-now-gear-later`.
The first pass's global durability, radius and visibility are replaced. Each group is what
one gear category will supply, so the entity cut swaps a group's source and nothing that
reads `Munitions` changes.
**Why the player cannot designate a missile.** Presses that land on a swarm of rounds
instead of the ship behind them are a classic irritation, and the operator asked for
autofire precisely so that PD needs no micro.
**Cut order.** `missile-stats` (r2) first: the stat object with `Cognition` and the catalog
migration, no behaviour change. `evasion-term` (r1) needs nothing and may run beside it: the
tracking term in every gun price. `missile-records` (r3) after `missile-stats`: flight, seeker,
reacquisition, cognition's efficiency and reserve, the terminal fact, lifetime, interception,
the provider and the cost pin, headless; missiles fly but never hit. `missile-odds` (r1) after
`missile-records` and `evasion-term`: the gate, the price, the commit, the post-commit flight and
the blast. `missile-presenter` (r2) after `missile-odds` and `controls-helm`. `munition-shots`
(r3) after `missile-odds` and `controls-mount-aim`. `autofire-threshold` (r3) after it and
`controls-hud`; `ai-autofire` r1 last, after `controls-ai-bearing`. Splitting the old
`missile-records` keeps each Hands pass inside its budget: the flight alone sat near the
ceiling, and the price is a second subject with its own tests.
**Left for later.** Full munition entities (follow-up `drones-munitions-substrate`), whose gear
replaces `MunitionData` group by group; midcourse bearing updates from the launcher over a
datalink (ruling `shared-track-bearing-only` allows a bearing; nothing sends one yet); seeker
kinds, decoys (follow-up `missile-decoys-signature`: a decoy needs a signature the seeker's
reading can take), jamming and soft kill; boost and midcourse fuel planning; mines as records
(`bodies-entity-and-mines`); whether launchers draw rounds (`launcher-ammunition`). Whether
the demo cast fields launchers for the player's PD to meet is content: the cuts add a
`Point Defense` scenario for the operator check.
**Why a munition's evasion goes through the ship's core.** Follow-up
`munition-shots-evasion-interface` (absorbed by `cut-munition-shots`) asked that a munition answer
`FireControl.Evasion` "with its own manoeuvre envelope (from its missile-stats thruster group) and
its own course-change record, like ships do". The earlier price went through a direct agility
(`Stats.Lateral` while fuelled) and the command vector, which is the strafe-only reading ruling
`evasion-is-unpredictability` overturned for ships. The munition now gives the core the two inputs
it reads: `Munitions.Envelope(flight)` (faces equal to Advance's own command clamps; no turn rates,
because a munition commands any planar vector at once, MA3) and `Flight.Track` (observed by
Advance, MA4's pattern). Then `Evasion(flight, los)` is one line over the shared core, a jinking
missile is evasive because its track sees changes of vector, and a dry one is not because its
envelope is zero. A missile pursuing a weaving target also shows innovation from its PN commands;
that is evasion by the ruling's own measure ("how much the ship is actually changing its vector"),
not a defect.
**Why the mount term is the munition's own geometry.** Follow-up `munition-shots-mount-term`:
`PMount(AngularVelocity(Flight.Position - source.Position.xz, Flight.Velocity - source.Velocity),
TrackingRate(weapon, source))` (MA6), frozen at fire like an entity's (MA5), with the jink forgiven
per ship by `Tracking(source)` (ruling `jink-per-ship`). Inbound missiles approach mostly radially,
so their angular velocity is small and PD guns hold them; a missile crossing close is hard to
follow, which is the CIWS geometry and EVE's tracking formula `0.5^((omega/rate)^2)` the mount term
already is.
**Why the box support is enough.** `Reach`'s no-turn support assumes the lateral and along-track
faces fire independently (a box), while Advance clamps the total command to `Stats.Acceleration` (a
disc with a lateral cap). Across the line of sight the binding limit is the lateral face, which the
box and the disc share; the corners overstate reach only on oblique lines of sight, and the
observed track caps the evasion before the envelope does in every case the tests build. A disc
support would be a second reach function for one corner case; not bought.
**Why munition-shots is split.** The earlier estimate was 465 lines and 12 tests across Solve,
Fire, Step, Commit, Apply, Detonate, Solution and Advance, and the envelope, track and mount term
add to it. The evasion-term cut of similar reach took 190 calls and overran (memory
`hands-batch-sizing-and-stryker-offsets`). The jink (inbound-round count, Advance step 4 and the
fuel-spend tests) is a separate behaviour with its own rulings (`missiles-fuel-and-seekers`,
`missile-cognition`) and its own failure modes, and it reaches the PD price only through the track.
So `munition-shots` is the subject, price and resolution (about 380 lines) and `munition-jink` is
the jink (about 170 lines), each falsifiable alone.
**Why the autofire divert test uses a steady close crosser.** Under ruling
`evasion-angular-velocity-r2` a steady fast crosser close in evades through the mount term even
with no jink, and that is exactly where ruling `speed-demon-counterplay` wants light guns to hold a
ship heavy guns cannot. MA11's fixture gives the split (PMount >= 0.85 against <= 0.01) with zero
evasion, so the test proves the chooser reads the per-gun price and nothing else. The threshold is
set inside the interval between the heavy gun's price and the lower of the light gun's price and
the heavy gun's price without the mount, so a Worth that drops the mount, or reads the ship's gear
in place of the gun's rate, lets the heavy gun fire and fails.
**Why autofire-threshold is split too.** The earlier version (about 520 lines) mixed the headless
chooser, Worth, SubjectOf, the saved settings and 13 tests with Unity work (action-bar modifiers,
HUD diamonds, a new scenario) whose every loop needs a Starfire compile. `autofire-threshold` keeps
the sim half (about 360 lines) and gives the settings one writer, `Entity.SetAutofire`, so the
presentation half, `autofire-controls` (about 190 lines), writes nothing else.
**Unowned checks.**
- `missile-odds` was written against evasion-term. It prices a missile's hit on a ship with the
target's evasion; whether a missile as shooter takes any mount term was never ruled (the tracking
table lists launchers at +infinity: "the round steers itself"). Worth a check before missile-odds
is dispatched, if no parallel pass already owns it.
- No PD-class gun existed (MA12). The Point Defense scenario and the speed-demon play check could
run on ClearPath and Spectra, but the ruled outcome "PD-class guns hit a speed demon reliably"
has no catalog gun to show it until one is authored (see "Catalog growth").
## Catalog growth: generic designs, branded products
Ruling `catalog-grows-generic-designs-branded-products` (operator, 2026-10-08): designs are generic
and branding lives in products. A design is expected to carry several products. `Title` is the one
owner of an item's displayed title (`ItemManager.Title`, landed by `product-titles`); there is no
`DisplayName`. Body: `GameCult/Aetheria` `origin/master` `4d664b537f76029fbaee94392da3d8a122f81339`,
unless a fact names another anchor (LP facts read the `product-titles` head `7f700d3c`). Read by
`cut-pd-gear`, `cut-lot-product`, `cut-generic-design-names`, `cut-product-lines-weapons`,
`cut-product-titles` and `cut-pirates-record`; question `minor-power-brands` and follow-up
`gear-product-lines` own the rest of the gear.
### Body facts
Catalog decode method for CG1-CG4: `cultcache_py` `SingleFileMessagePackBackingStore.
pull_all()` over `GameData/Aetheria.cc` at `4d664b53` (LFS pulled into the worktree), each payload
`msgpack.unpackb`ed and read by MessagePack key (the M28 method); the store is `cultcache.store.v1`
with 13 schemas and 217 records. Scratch scripts were not committed.
- **CG1. The catalog's record census at 4d664b53.** 64 FactionProductData, 51 CompoundCommodityData,
32 GearData, 18 WeaponItemData, 13 SimpleCommodityData, 12 Faction, 12 NameFile, 5 HullData,
4 CargoBayData, 1 FieldKindData, 1 DockingBayData, 1 InputLayout, 0 Loadout. Factions (short
names): AU, Adrasteia, Alakrita, DME, Ewan Hart, Finch, Lightsail, Lucent, Miss Terri's,
NiteLife, R&D, Zhestokost. No Pirates record yet (H1).
- **CG2. The data model already separates design from product; the content does not** (source read
plus CG1). `FactionProductData` (`ServerShared/FactionProduct.cs:14-31`: Name 1, Description 2,
Design 3 `CultRecordRef<CraftedItemData>`, Manufacturer 4 `CultRecordRef<Faction>`, Roles 5
`ProductRole{Role, Mean, StandardDeviation}`) is a manufacturer's branded variant of a design; its
own comment says a market segment is "a second product with a role pumped up". Price is on the
design, not the product (`HullData.Price`, H1). Generation and station stock pick products, not
designs (`LoadoutGenerator.RandomProducts`, `:142-171`; `GenerateStationLoadout` stocks 16 random
products, `:107-114`). Presets build a design by its first available product in record-key order
(`Loadout.Resolve`, `Loadout.cs:160-191`). But of the 59 sold designs only Targeting Computer and
Fire Control Array carry more than one product (3 each: Panopticon Prime by Finch, ClapBack Ultra
by Lucent, LockOn Pro by NiteLife on the array, and Panopticon, ClapBack, LockOn on the
computer); every other design has exactly one product, and in 49 of them the design's own name is
the brand (product name equals design name: ClearPath, Spectra, Earp, Manhattan, Store-All Plus
...). Three designs already have generic names under a brand product (Core Power -> Steadfast,
plight -> DragOnBreath, pswarm -> Leonid); seven generic-named designs sell a product of the same
generic name (Autocannon, Cockpit 2x2, Turret Control Module, Industrial Thermostatic Heater,
Medium Drive, Small Drive, Medium Docking Bay). Most designs' `Description` is the brand's voice
("Defend your precious cargo and have fun doing it"); 20 products carry an empty or `[TODO]`
description. 64 products over 60 designs; 0 duplicate (design, maker) pairs.
- **CG3. Where the player sees a name** (source read, master). `PropertiesPanel.GetTitle`
(`UI/Properties Panel/PropertiesPanel.cs:431-437`, `data.Name`) and the trade list
(`UI/Menu/TradeMenu.cs:193-197`, the Name column, tier-coloured) and buy dialog (`:355`) show the
design `Name`. The product name and description appear only as extra property lines after `Brand`
(`PropertiesPanel.cs:370-380`); `Inspect(ItemData)` shows the design as `Type` (`:530`). So today
the brand reaches the headline only because design names are brand names, and a product named in
emoji would be listed under its design's name. `product-titles` moves the headline to the
product (see LP3).
- **CG4. A lot does not record its product** (source read, master). `Lot`
(`ServerShared/Provenance.cs:64-70`) holds Design 0, Origin 1, Quality 2, Roles 3.
`ItemManager.CreateLot(product)` (`ItemManager.cs:134-155`) writes the maker into `Origin` and
drops the product. `ItemManager.Brand` (`:206-222`) re-derives the product as the lowest-key
`FactionProductData` with that maker and design. Two products of one design by one maker (the
market segment `ProductRole`'s comment designs for) are therefore indistinguishable after
creation: the player sees the lower-key product's name and text whatever was bought. Today no
maker sells two products of one design (CG2), so nothing shows it yet.
- **CG5. Weapon fields and the reference small guns** (source read `Behaviors/Weapon.cs:20-72`,
`Behaviors/InstantWeapon.cs:10-20`, `ItemData.cs:477-509`; catalog decode). WeaponData keys:
DamageType 1, Damage 2, Penetration 3, DamageSpread 4, MinRange 5, Range 6, DamageCurve 7,
EffectPrefab 8, Energy 9, Heat 10, Visibility 11, AmmoType 12, MagazineSize 13, ReloadTime 14,
Spread 15, Velocity 16, Tracking 33; InstantWeaponData Count 17, BurstTime 18, Cooldown 19;
WeaponItemData WeaponRange 24, Caliber 25, WeaponType 26, FireTypes 27, Modifiers 28,
BlastRadius 29, Fuse 32; GearData Hardpoint 23 (enum order Hull..AetherDrive: Energy 9,
Ballistic 10, Launcher 11). Shape is `[width, height, cells]`. Evaluated (Min-Max):
| Design | HP | Mass | Price | Dmg | Range | Cooldown | Count | Spread | Velocity | Energy | Ammo/Mag | Tracking |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ClearPath | Ballistic 2 cells | 50 | 50,000 | 2-6 | 250-600 | 0.1-0.033 | 1 | 1.25-0.25 | 700-1250 | 1-0.5 | none / 0 | 12 |
| Spectra | Energy 2 | 25 | 125,000 | 20-85 | 800-1750 | 0.5-0.25 | 1 | 0.1-0 | 0 (beam) | 20-40 | none | 10 |
| FastBlast+- | Energy 2 | 125 | 150,000 | 20-80 | 500-1250 | 0.33-0.1 | 12 | 3-1 | 400-900 | 0 | cells / 12 | 9 |
| Autocannon | Ballistic 4 | 75 | 250,000 | 3 | 1000 | 0.5 | 1 | 0.5 | 500 | 5 | Ammo / 50 | 4 |
| DeathCluster | Ballistic 4 | 250 | 450,000 | 100-250 | 500-1000 | 1-0.25 | 12-15 | 3-0.5 | 750-1500 | 25-5 | Ammo / 8 | 3 |
Damage, Heat and Energy are divided by the burst count per pellet (`InstantWeapon.cs:128-134`).
The ammunition commodity is the SimpleCommodity that Autocannon's AmmoType names (`3085fc8a...`).
- **CG6. Homes for one-cell guns** (source read `LoadoutGenerator.HasHome`, `:173-176`;
`HardpointData.Takes`, M30). A design is offered only if some catalog hull has a hardpoint that
`Takes` it, which is any design of the slot's type whose shape lies within the slot. Longinus has
two Energy 1x2 hardpoints, Djinni two Ballistic 1x2, the station Turret two Ballistic 2x4. So a
1x1 Ballistic or Energy gun and a 1x2 Ballistic gun all have homes on master.
- **CG7. Munitions on master.** `missile-records`, `missile-stats`, `munition-shots`,
`autofire-threshold` and `autofire-controls` had no report: master has no munition record yet
(MA7). missile-stats authors munition `Durability 20`. `Detonate` is the one blast path and
damages entities, not munitions (missile-odds, "Detonate is the one blast path").
- **CG8. A shipped weapon may not carry a fuse today.**
`FireControlCut124Tests.ShippedCatalogIsNeutralOnBlastRadiusAndFuse`
(`tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs:2591-2610`) asserts every shipped weapon's
BlastRadius and Fuse are null. An airburst flak gun would break it; the flak below is a pellet gun.
- **CG9. The catalog tracking test** (`tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs:290-313`,
`EveryCatalogGunTracks`) requires every non-launcher gun's Tracking finite, Min = Max > 0. New
guns must author it.
- **CG10. Design names in code at 4d664b53** (`git grep -F` over `*.cs` for the quoted names of
the generic-names table). 61 hits in 12 files: scenarios Arcs.cs 14, Duel.cs 11,
LauncherAngles.cs 8, StarvedReactor.cs 6; tests EvasionTermTests 10, BrownoutTests,
EvasionTrackingTests, PowerCurveTests, RestoredHullsTests, RunStartTests, ScenarioTests 1 each;
tools/AetherDb/Program.cs 6. Loadout and product references are by record key, unaffected by a
rename.
- **CG11. Behaviour index 31 is CapacitorData** (`ServerShared/Behaviors/Behaviors.cs:178`), so
PotaT+- is NiteLife's capacitor. The wishlist's "capacitors: no products" line is stale.
- **CG12. Vault sources read** (AetheriaLore `33cd06a`): `Aetheria/Game Design/Faction Play.md`
(loadout and quirk lines 118-266, 307-357), `Aetheria/Brainstorming/Faction Flavor and Visual
Identity.md` (Aya point defense and interceptors :458, Megiddo decoys and interception :566),
`Aetheria/Brainstorming/Corporate Roster and Item Wishlist.md` (item ideas :57-87, gaps :97-146;
its "What this is for" names the design/product text split). Zhestokost's loadout names "flak and
shrapnel (MFer, DeathCluster)" against missiles; Lightsail's names ClearPath as its machine gun
and Cetacean Navigators' Waykeeper escorts carry point defense with Lightsail haulers; Aya and
Megiddo are the heavy-PD and decoy factions but are not catalog factions. `Ship Play Concepts.md`
was not found in either repo at these heads.
- **LP1. `Lot` keys** (`Assets/Scripts/ServerShared/Provenance.cs:63-80`, at 7f700d3c). Keys 0-3
(Design, Origin, Quality, Roles); key 4 is free. Lots and origins already carry
`CultRecordRef<ItemData|Faction|SimpleCommodityData>` through MessagePack (:66, :101, :108, :119),
so a fourth `CultRecordRef<T>` needs no formatter work.
- **LP2.** `ItemManager.Brand` (ItemManager.cs:206-222) derives the product at :217-220 with
`GetAll<FactionProductData>().Where(maker and design).OrderBy(key).FirstOrDefault()`. It is the
only such derivation in Assets/ (git grep `GetAll<FactionProductData>`: ItemManager :217, plus
Loadout :194 and LoadoutGenerator :145, which choose a product to mint, not read an item's).
- **LP3.** `Title` (ItemManager.cs:226-227) reads `Brand(crafted).Product?.Name`. Brand's readers:
Title, PropertiesPanel.cs:374 (Manufacturer and description), tests LoadoutTests
:336/:359/:395/:844 and RunStartTests :136/:678. Nothing else reads an item's brand, so fixing
Brand fixes every reader.
- **LP4.** Lot writers in Assets/: only `CreateLot(product)` (:136-158) and `CreateLot(design,
maker, quality)` (:161-170). Production callers of the second: ActionGameManager.cs:544/:548
(console give, no maker) and Loadout.cs:182 (unbranded preset, no maker). No `new Produced` in
Assets/ or tools/; Produced lots exist only in tests.
- **LP5.** Behaviour change: a lot from `CreateLot(design, maker, q)` whose maker sells that design
used to brand as the maker's key-first product; once the lot records its product it brands
(maker, null) and titles by design. No production path mints such a lot (LP4).
- **LP6.** Tests pinning the derivation: LoadoutTests
`BrandPicksFirstProductInKeyOrderForSameMakerAndDesign` (:375-397) pins the tie-break that goes
away; its comment says the census's duplicate detector exists because of it. Stale comment in
LoadoutTests :315-317 ("Brand() requires at most one product per (maker, design)").
- **LP7.** AetherDb census (tools/AetherDb/Program.cs:124-138) prints "(maker, design) pairs with
more than one product" as a fault, justified by Brand's tie-break. Under the ruling a design
carries several products, and a maker may sell two of one design; with `Lot.Product` the pair is
no longer ambiguous.
- **LP8.** Run save: `RunSave.Commit` (SavedGame.cs:138) writes the reachable ledger;
`RunSave.Lots(cache)` (:132) reads it back. RunSaveTests :382-400 shows the fixture shape (a
ledger plus a SavedZone whose pack references the lot).
- **LP9.** ItemTitleTests (tests/Aetheria.Shared.Tests/ItemTitleTests.cs) builds Array with
products Panopticon Prime (Finch, key array-finch) and ClapBack Ultra (Lucent); Soul's probe added
a second Finch product of Array and saw Title return the first (finding `title-key-first-product`).
### Content tables
Drafted content that specs cite. Names are drafts the operator may rename.
**Maker profiles** (one Mean / StandardDeviation applied to every role of the design, from Faction
Play's loadout lines and the existing products' roles):
| Maker | Mean | Dev | Why |
|---|---|---|---|
| Zhestokost | .50 | .08 | armour and consistency, no finesse |
| AU | .50 | .12 | "mid-quality, consistent: the all-rounder" |
| Lightsail | .55 | .10 | freight reliability |
| R&D | .60 | .15 | compute, contractor quality |
| Lucent | .60 | .15 | premium, loud |
| NiteLife | .55 | .12 | energy specialist |
| Finch | .65 | .20 | "high variance in role quality" |
| Alakrita | .60 | .14 | light, fragile, excellent |
| DME | .55 | .20 | runs hot, uneven |
| Adrasteia | .60 | .10 | exacting |
No product is authored for Miss Terri's (ruling `miss-terris-belongs-to-emily-r3`) or the Pirates
(the Pirates record owns their products, ruling `pirates-record-content`).
**PD products** (`pd-gear`; the three new designs are the Point Defense Gun, Point Defense Laser and
Flak Gun, whose stats follow under the rationale):
| Design | Product | Maker | Description |
|---|---|---|---|
| Point Defense Gun | Waykeeper | Lightsail | Freight first, questions later. Keeps a hauler's paint unscratched through a full salvo. |
| Point Defense Gun | Rampart | Zhestokost | A column does not dodge. It does not need to. |
| Point Defense Gun | Swatter | AU | Keeps the rocks and the rockets off your back while you work. |
| Point Defense Gun | Catch-All | R&D | Guaranteed to stop the missile!* *Missile defined as whatever it stops. |
| Point Defense Laser | Glare | Lucent | Every missile gets its moment in the spotlight. A short one. |
| Point Defense Laser | Nightlight | NiteLife | Lights out for anything with a warhead. Shares the bus with your blasters, so mind the meter. |
| Point Defense Laser | Wren | Finch | Small, quiet and quicker than it looks, most days. |
| Point Defense Laser | Gnat | Alakrita | Weighs less than your conscience and keeps the fast pass fast. |
| Flak Gun | MFer | Zhestokost | Our shrapnel turns your enemies into scrapnel. (the operator's own line, Item Wishlist :57) |
| Flak Gun | Hailstorm | AU | A wall of steel for crews who would rather not count missiles. |
| Flak Gun | sky full of teeth | DME | light it up/ shake it loose/ nothing gets through tonight |
**Generic design names** (`generic-design-names`; hulls, Traction and already generic names
excluded: hull names are the model names other passes are revising, retire-longinusx deletes
Traction):
| Old design name | Generic design | Functional description |
|---|---|---|
| ClearPath | Light Machine Gun | Light ballistic hose: many small rounds at short range. |
| 6k Shooter | Slug Rifle | Single heavy slugs, fired as fast as the trigger. |
| Earp | Long Slug Rifle | A lighter slug rifle with the longest ballistic reach. |
| DeathCluster | Cluster Cannon | Medium shotgun: a heavy volley of pellets. |
| pretty pretty bang bang | Charged Scattergun | Ballistic shotgun that charges for a heavier volley. |
| Spectra | Light Laser | The lightest beam: low damage, quick cycle, long reach. |
| FastBlast+- | Cell Blaster | Rapid energy bursts fed by cells that cut its reactor draw. |
| ColdFire | Long Laser | Heavy small beam: slow, hard-hitting, long reach. |
| CShot RainbowLite Lazer | Heavy Beam Laser | The heaviest beam: nine cells, enormous damage and reach. |
| ChargeBlast+- | Charged Blaster | One charged knockout shot. |
| ChargeBlast SG | Charged Scatter Blaster | Charged energy shotgun. |
| plight | Lightning Projector | Charged chain lightning. |
| GT 3K | Guided Missile Launcher | Fires guided missiles. |
| LRMM72 | Long-Range Missile Rack | Large missile rack for long engagements. |
| SRMM72 | Short-Range Missile Rack | Extra-large rack of short-range missiles. |
| pswarm | Swarm Rocket Pod | Dumbfire rockets in a swarm. |
| scorched void policy | Incendiary Rocket Pod | Incendiary dumbfire rockets. |
| Arctica | Adaptive Radiator | Radiator whose output scales widely with conditions. |
| Iapyx | Radiator | Standard two-cell radiator. |
| Skiron | Long Radiator | Four-cell radiator with a large surface. |
| OK Disperser | Heat Pump | Moves heat to where it can be shed. |
| cold like my heart | Cryogenic Heat Pump | Heavy heat pump that drives temperatures far down. |
| Core Power | Compact Reactor | Small reactor. |
| Manhattan | Heavy Reactor | Large three-by-three reactor. |
| Notorious | High-Yield Reactor | Large reactor with a wide output range. |
| Vulcan | Ring Reactor | Ring-shaped reactor, four by four. |
| MoveOnPro | Muon Fusion Reactor | Compact fusion reactor that draws a reservoir. |
| Core Power Station Reactor | Station Reactor | A reactor sized for a station's core. |
| Manhattan Station Reactor | Heavy Station Reactor | A heavy reactor sized for a station's core. |
| Notorious Station Reactor | High-Yield Station Reactor | A high-yield reactor sized for a station's core. |
| Vulcan Station Reactor | Ring Station Reactor | A ring reactor sized for a station's core. |
| MoveOnPro Station Reactor | Muon Fusion Station Reactor | A muon fusion reactor sized for a station's core. |
| RevvITup 2.0 | High-Thrust Drive | Large drive: huge thrust, little torque. |
| deep space burnout | Burn Drive | Large drive that runs very hot. |
| Talaria | Rugged Drive | Two-cell working drive. |
| Victoire | Racing Drive | Feather-light two-cell drive with extreme response. |
| The Bat | Sensor Array | Two-by-two long-range sensor array. |
| not if i see you first | Compact Sensor | One-cell sensor. |
| PotaT+- | Capacitor | Stores charge for burst draw. |
| SafeStuff II | Secure Cargo Bay | Three-by-three secured hold. |
| Store-All Enterprise Edition | Large Cargo Bay | Six-by-six hold. |
| Store-All Plus | Cargo Bay | Two-by-two hold. |
| Store-All Plus Mini | Small Cargo Bay | One-cell hold. |
The product keeps the old name, so the brand survives as the product. Products whose name equals a
generic design get a brand: Autocannon (Zhestokost) -> Hammerfall; Cockpit 2x2 (Lightsail) ->
Wheelhouse; Industrial Thermostatic Heater (Lightsail) -> Hearth; Medium Drive (Lightsail) -> Steady
Haul; Small Drive (Lightsail) -> Short Hop; Turret Control Module (AU) -> Watchtower; Medium Docking
Bay (AU) -> Open Berth. The Bat's product is Miss Terri's: its record is not touched; only the
generic design under it is renamed. Description move: the design's old text goes to its product
when the product's is empty, `[TODO]` or identical; otherwise the product keeps its own and the
dropped design text is printed in the dry run and the commit message.
**Weapon product lines** (`product-lines-weapons`):
| Design | New product | Maker | Description |
|---|---|---|---|
| Light Machine Gun | Rattler | AU | Cheap, loud, and there when the rocks start moving. |
| Slug Rifle | SM-6 Marksman | R&D | Standardised, certified, guaranteed to hit the target.* |
| Long Slug Rifle | Longshot | Finch | Light, quiet and dead accurate, when it feels like it. |
| Autocannon | Workhorse | AU | Feeds from anything, fires at anything. |
| Cluster Cannon | everything at once | DME | why aim/ when you can be everywhere |
| Charged Scattergun | Grapeshot | Zhestokost | The old answer to boarders, sized for the void. |
| Light Laser | Sparrow | Finch | Barely registers on the scale, or on their sensors. |
| Light Laser | Spotlight | Lucent | Make sure they know who got them. |
| Cell Blaster | Strobe | Lucent | Fast, bright, and very hard to ignore. |
| Long Laser | Kestrel | Finch | Hovers at the edge of range, then stoops. |
| Heavy Beam Laser | Afterglow | NiteLife | Keeps glowing long after the fight is over. |
| Charged Blaster | Encore | Lucent | One more, for the fans. |
| Charged Scatter Blaster | nobody leaves early | DME | doors are locked/ lights are low/ stay for the finale |
| Lightning Projector | Static Shock | NiteLife | A jolt for every ship in reach. |
| Guided Missile Launcher | Homer | Finch | Finds its way, mostly. |
| Long-Range Missile Rack | Long Haul | AU | Delivers to the far side of the zone. |
| Short-Range Missile Rack | Short Fuse | AU | For problems already too close. |
| Swarm Rocket Pod | Confetti | Lucent | Every launch is a celebration. |
| Incendiary Rocket Pod | Trebuchet | Zhestokost | Siege fire for a column that does not stop. |
### Model page rows
| Kind | What names it | Over time | Who decides |
|---|---|---|---|
| `FactionProductData` (catalog) | Its record | Every sold design carries at least one product whose name differs from the design's, and is expected to carry several | The catalog author: the brand a player sees |
| Item title | `ItemManager.Title(item)` | Derived on each read from the lot's brand; the design name stays as `Type` | `ItemManager.Title`, the one owner; there is no `DisplayName` |
| `Lot` (run save) | `Lot.Product` (key 4) | Written once by `CreateLot(product)`; a lot made without a product (console give, extraction) has none and shows its maker only. A product that no longer resolves reads as unbranded | `CreateLot`; `Brand` reads it and nothing re-derives a product from maker and design |
| `WeaponItemData` (catalog) | Three new generic designs: Point Defense Gun, Point Defense Laser, Flak Gun | Authored once by `pd-gear` | The catalog author |
### Rationale
**Titles name the product.** One engine-free derivation in ServerShared, `ItemManager.Title(
CraftedItemInstance)`: the brand's product name, else the design name. Every title site reads it.
This is what makes "branding lives in products" visible, and what shows the Pirates' emoji names.
`lot-product` moves the brand's authority from a catalog query (LP2) into the lot, so that two
products of one design by one maker stay distinct (CG4, LP9). A first plan for it added a
`DisplayName` over the same hunks as `Title`; Soul verdict `cut-product-titles.s1` found two title
owners in force (`title-vs-displayname-split`) and Self ruled Title the one owner. A lot whose
product no longer resolves (a removed mod's product) reads as unbranded: `Brand` returns (maker,
null) and `Title` falls back to the design; `RequireDesigns` is not widened (ruling
`no-save-compatibility-before-players`). The census duplicate-brand block (LP7) goes away.
**Why three PD designs and not five.** The missile-defence rulings in force are `pd-who-engages`
(any gun, priced by its own stats), `missiles-sim-side-light` (missiles are lightweight records PD
can engage), `weapon-tracking-authored` (a PD class near 40 deg/s exists to be authored) and
`speed-demon-counterplay`. They need guns with PD stats, which the catalog lacks: the PD Gun
(ballistic hose, the Djinni's slots), the PD Laser (energy, the Longinus's slots, so the demo's
starting hull can fit PD) and the Flak Gun (Zhestokost's flak doctrine as a pellet gun, which needs
no new rule: each pellet rolls). An interceptor launcher needs missiles that take munition subjects
and a decoy launcher needs a signature the seeker can read that is not a ship; both are new
mechanics, which the target's not-in-scope line excludes until ruled. They are follow-ups
`interceptor-launcher` and `missile-decoys-signature`, not catalog content. An airburst flak (a
blast that wears munitions in its disc) is follow-up `flak-airburst-at-munitions`.
**Derivation of the PD stats** (from CG5 and the tracking table). Tracking 40 is the table's PD
anchor (0.82 / 0.94 against the speed demon at TC / FCA). PD Gun is ClearPath's role pushed to its
extreme: one cell, lighter (40 kg), less damage per round (1.5-4) at a higher rate (0.06-0.025 s)
and shorter reach (300-550 m), because a munition has 20 durability (CG7): 5 to 14 rounds kill one.
It draws ammunition (`ballistics-real-ammo`) and a small cycling energy, 0.4-0.2
(`ballistics-cycle-power`). PD Laser is Spectra's role: instant beam, 5-12 per shot every 0.2-0.1 s
to 400-750 m, energy-limited at 6-10 a shot, 2 to 4 hits a missile. Flak Gun is DeathCluster's
shotgun at small calibre, 1x2: 6-8 pellets sharing 30-70 damage a volley, 350-800 m, Tracking 20
(between PD and ClearPath, because pellets cover what tracking misses).
## Retiring the LonginusX and parking the aether drive
Ruling `retire-longinusx`, in the operator's words: "I want to retire the LonginusX, the aether
drive concept needs a rework and it needn't land in the demo." Ruling `aether-drive-niche` keeps
the design intent for the rework: "Like, it has a decent niche. It's the only zero-visibility
mobility option besides velocity conversion (also esoteric gear), and the rotor mechanic limits its
mobility to bursts, so it's currently pretty trivial to outmaneuver an all aether drive ship if you
get it to spend its momentum. I think we want to establish the thruster meta before seeing where it
fits in, though." Follow-up `aether-drive-rework-rotor` carries why it is frustrating to fly today:
the rotors spin down after the first manoeuvre, so that manoeuvre is the only one a pilot gets.
Two specs carry it: `evasion-term` r6 takes the drive out of the unbuilt evasion work, and
`retire-longinusx` r1 deletes the hull, the drive and every consumer from master. The drive's code
and data are kept at the tag `parked/aether-drive`, not on any live path.
### Body facts
- **R1. Catalog consumers** (catalog decode of `GameData/Aetheria.cc` at master `516af3e5`,
2026-10-07: Python `msgpack` over the store's own schema table, rows decoded by slot; 217
documents in 13 schemas). Exactly four records carry the hull or the drive: `HullData`
`LonginusX` (`445d911a-9951-40a3-874e-0b7ce8b35129`, Ship, price 7,500,000; hardpoints Control,
AetherDrive (2,3) 2x2, Energy x2, Launcher x2, Radiator x2, Reactor (2,1), Sensors; behaviours
Reflector only, no `VelocityLimit`), its `FactionProductData` (maker Alakrita), `GearData`
`Traction` (`42d1ffaa-f764-4a65-ba03-43cc4e6fd2bc`, the catalog's only `AetherDriveData`, union 12,
plus Wear; hardpoint AetherDrive; price 250,000; its `Particles` is the GUID of
`Assets/Content/Prefabs/Thrusters/Aether Drive.prefab`), and its `FactionProductData`
(`007ed2b1-8fdf-473d-9ebf-e55fcd1a6903`, maker Aeronautics Unlimited). A byte scan of every row for
the two record ids and for the strings `LonginusX` and `Traction` finds only those four. No
faction's `BossHull` is set; no station stock record names either, because stations stock from
products. No other hull has an AetherDrive hardpoint and no other gear has union 12.
- **R2. The LonginusX is the Longinus with a drive.** Both hulls share the 6x17 `Shape`, mass 2,500,
prefab `4a3db609...` and schematic `51702555...`; Control (2,6), Energy (1,8) and (4,8), Launcher
(0,5) and (5,5) and Sensors (3,10) sit in the same cells. The Longinus has four Thruster
hardpoints ((1,0) and (3,0) 2x2, (2,14) and (3,14) 1x2), its Reactor at (2,4) and Radiators at
(2,2) and (3,2), and a `VelocityLimit` of 100. A bare LonginusX and a bare Longinus differ only by
that cap, so a bare-hull target moves to the Longinus unchanged. A fitted LonginusX's Fire Control
Array at (2,5) collides with the Longinus's Reactor (2,4)-(3,5), so fits are relaid.
- **R3. Code consumers on master** (source read at `516af3e5`). Simulation:
`Behaviors/AetherDrive.cs` (184 lines), `Behaviors.cs:160` union 12, `Enums.cs:53`
`HardpointType.AetherDrive` (last member, so removing it shifts nothing), `Ship.cs` (the
`_aetherDrives` sets, `RemoveAetherDrive`, six thrust aggregates and the `drive.Axis` write in
`Update`), `ItemData.cs:895-899` (five request-field registry rows), `Settings.cs:211-212`
(`AetherTorqueMultiplier`, `AetherHeatMultiplier`, read only by the drive), comments in
`PowerTiers.cs:16` and `Entity.cs:1803,1819`. Presentation: `Gameplay/ShipInstance.cs`
(`AetherDriveInstance`, drive particles), `UI/HUD/SchematicDisplay.cs` (`AetherDriveUi` and the
three RPM label and fill pairs), `Gameplay/ActionGameManager.cs:934` (the undock refusal's drive
clause), `Scenes/ARPG.unity` (the `RPM Display` object `1735948311`, active by default, so it
shows for every ship once nothing hides it), the `Aether Drive.prefab` and its only material
`Dithered Particle Trail AetherDrive.mat`. Tools: `tools/AetherDb/Program.cs` `AetherDriveRoles`
in `roles-migrate`, the `AetherDriveData.Torque` row in `brownout-migrate`, comments at `:938` and
`:1409`; `tools/blender/aetheria_ships/ship_cc.py:36` `HARDPOINT_TYPE_NAMES`. Settings data:
`Assets/Resources/Settings.asset` line 15 `StartingHullName: LonginusX` (read by the Tutorial and
Main Galaxy modes through `ActionGameManager.cs:818`; `GameSettings.cs:13` defaults to
`Longinus`) and lines 310-311.
- **R4. Scenario and test consumers.** Scenarios Duel, Arcs, Launcher Angles and Starved Reactor fit
a LonginusX with `Traction` and place bare LonginusX targets. Tests: `BrownoutTests` (three drive
tests and `RunAetherDrive`), `ShipAuthoringTests:254` and `ShipValidationTests:36` (AetherDrive as
an internal hardpoint type), `FireControlCut124Tests:475` (a LonginusX row), `FireControlCut7Tests:215`,
`RunStartTests:673`, `ScenarioTests:132` (asserts the starting hull is `LonginusX`) and `:288-340`,
`tests/mutation_tests_addressables_cut.py` (a LonginusX record as the known-good mutant), and
comments in `FireControlCut9Tests:363`, `FireControlCut12Tests:1327` and `ConditionRatioTests:15`.
Docs that describe the live game: `docs/merge-to-master-checklist.md` lines 36, 47, 53 and 59.
- **R5. Open branches** (read through `origin/*` refs from `C:\ar-retire-map`, 2026-10-07).
`eureka/aetheria-release-evasion-term` (tip `e4944549`, fix batch B on top of `46b28501`) adds
`AetherDrive.Manoeuvre` (24 lines, `da2957b8`), sums it into `Ship.Envelope`
(`RecalculateEnvelope`), and flies a `LonginusX` with `Traction` (`EvGemini`) as the "gemini"
in `EvFleet` and the gunner and target fixture. Batch B's `5f404d50` adds
`TheDrivesReportReadsItsStateAndTheShipsSpeed`, pinning the drive half of finding
`envelope-inputs-unpinned` (offline gate and speed efficiency). `controls-helm` (tip `8c5eb5b7`)
rewrites `Ship.Update`'s turn block, including the `drive.Axis` line, and `ScenarioTests:317-322`.
`demo-galaxy` and `boss-gate` stage the demo player as `Generated("Longinus")` and touch
`ScenarioTests` only at lines 44, 96 and 507; the boss fallback picks the priciest ship hull,
the Djinni at 10,000,000, so the retirement changes no boss.
- **R6. Package hulls and top speed.** `ShipAuthoringStore.HullLike` copies every member of a
shipped reference hull, `VelocityLimit` included, for `ship-authoring create --like <hull>`;
`create` without `--like` writes a hull with no behaviours, and nothing in `ShipModCatalog` or
validation requires one. The dock intro (`ActionGameManager.cs:849`) dereferences
`GetBehavior<VelocityLimit>()` unguarded.
### What goes, what is parked
Deleted, because nothing that ships consumes it once the hull is gone: the four catalog records
(R1), the drive behaviour, union 12 (retired with a do-not-reuse comment, as union 26 is),
`HardpointType.AetherDrive`, the drive's settings, presenters, HUD, prefab, material and scene
object, every test and tool row of R3 and R4, and the `StartingHullName` value. The Longinus takes
the LonginusX's place in every scenario and test, because R2 makes it the same frame without the
drive. Velocity conversion (union 10) is separate gear and stays.
Parked: the annotated tag `parked/aether-drive` on the retirement's base, the last master commit
that holds the drive, the Traction record and the drive scenarios. Its message is the park note:
```
parked/aether-drive: the aether drive and the LonginusX, retired from the demo
(ruling aetheria-release:ruling:retire-longinusx).
Design intent for the rework (ruling aetheria-release:ruling:aether-drive-niche, the operator,
2026-10-07): "Like, it has a decent niche. It's the only zero-visibility mobility option besides
velocity conversion (also esoteric gear), and the rotor mechanic limits its mobility to bursts, so
it's currently pretty trivial to outmaneuver an all aether drive ship if you get it to spend its
momentum. I think we want to establish the thruster meta before seeing where it fits in, though."
Why it frustrates today (follow-up aetheria-release:follow_up:aether-drive-rework-rotor): the
rotors spin down after the first manoeuvre, so the first manoeuvre is the only one you get; judge
the rework against the game's overall pace, which the operator also finds too fast.
What is here: Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs (rotors, coupling, torque,
heat), its union 12 and HardpointType.AetherDrive, Ship's drive aggregates, ShipInstance's drive
particles, SchematicDisplay's RPM display, Aether Drive.prefab, GameplaySettings
AetherTorqueMultiplier and AetherHeatMultiplier, the LonginusX hull and the Traction drive in
GameData/Aetheria.cc (LFS), and the Duel, Arcs, Launcher Angles and Starved Reactor fits.
The drive's evasion envelope report (AetherDrive.Manoeuvre) is on the evasion-term branch at
da2957b8, tested by 5f404d50; evasion-term r6 removed it before merge.
Return after the thruster meta is established.
```
### Ordering
`evasion-term` r5 kept r2's envelope as landed, drive included, and named the LonginusX three
times: plight's fit, the `evasion-catalog` step that would have capped it, and the test
`TheLonginusXTopSpeedIsAsRuled`. Its Hands would build and test code this retirement deletes, so r6
supersedes it now: the drive's report and its sum leave the envelope, the gemini fixtures fly a
Djinni (the catalog's only ship that strafes, M24 and M25) or a fitted Longinus, and the LonginusX
steps are gone. r6 does not touch `AetherDrive` itself, `Ship`'s thrust aggregates or the
scenarios: those are master's, and the retirement deletes them whole.
Between r6 merging and the retirement merging, a LonginusX on master has an empty envelope and so
no evasion. Nothing ships in that window, and the retirement is the next subtraction, so the
transient is harmless; it is cheaper than teaching r6 a drive that dies a cut later.
`retire-longinusx` depends on `evasion-term` (its `RecalculateEnvelope` sits beside the aggregates
the retirement deletes, and both rewrite the LFS catalog, which does not merge) and on
`controls-helm` (it rewrites the `drive.Axis` line and `ScenarioTests:317-322`, next to the
retirement's `StanceAndPilot` edit). It need not wait for `demo-galaxy` or `boss-gate` (R5). The
catalog is rewritten by a one-shot command run on master's catalog as it stands when the branch is
last rebased, never merged as a binary.
The retirement is a pure subtraction and adds no rule except the catalog test of R6's first half.
The behaviour change that the drive's absence makes in evasion belongs to `evasion-term` r6, so
Soul can falsify each alone.
### The dock intro
After the retirement every ship hull in the shipped catalog, the Longinus (100) and the Djinni
(50), carries a `VelocityLimit`, so for the shipped catalog follow-up
`dock-intro-velocitylimit-null` reduces to a test, "every ship hull in the shipped catalog has a top
speed", which the retirement adds. Package hulls (R6) keep the follow-up alive: a hull created
without `--like` has no cap and throws at the intro. What is left is one owner for package hulls,
either ship-authoring validation refusing a ship hull with no `VelocityLimit` or the intro handling
its absence; the demo cast hulls (`demo-cast-hulls`) meet it first.
### Model page rows
| Kind | What names it | Over time | Who decides |
|---|---|---|---|
| `parked/aether-drive` tag | The tag name; its message is the park note. | Fixed at the retirement's base; read by the rework, never merged back. | The operator's rework, after the thruster meta. |
## Sim speed
Rulings `sim-speed-knob`, `sim-speed-presentation`, `sim-speed-surface`, `sim-speed-tenth`,
`tick-rate-follows-design-speed` and `unity-presents-only-r3`, for the pace named in
`pace-after-primitives`. The operator: "We don't want Unity having any authority over the sim
whatsoever. The sim is authoritative and Unity should only be presenting." Anchors are against
`origin/master` `6d427b7a`, read on 2026-10-07 in the read-only worktree `C:\ar-simspeed-map`.
`controls-helm` r2 is in flight on `ActionGameManager.cs`, `EntityInstance.cs`, `ShipInstance.cs`
and `Ship.cs`, so line numbers there move when it lands. None of `loot-1`..`loot-3`,
`missile-records`, `missile-odds`, `missile-presenter` or `munition-shots` has landed at this base.
The cuts: `sim-speed` r2 (the fixed step and drawing ahead, at speed 1), the census cuts
`ballistic-flight`, `sim-contact`, `retire-unity-mines`, `loot-2` r2, `missile-presenter` r3 and
`munition-shots` r4, and `sim-speed-scale` r2 (the design speed, the tick rate and the player's
factor), which waits on all of them.
### Body facts
- **T1. One caller steps the simulation, by the frame's dt.** The only `Zone.Update` call
in `Assets/Scripts` is `Zone.Update(Time.deltaTime)` at the end of
`ActionGameManager.Update` (`Gameplay/ActionGameManager.cs:1311`), inside
`if(!_paused)` (`:1244`). `Zone.Update` (`ServerShared/Zone.cs:202-222`) adds dt to
`_time` (a `double`, `:35`), recomputes orbits and divides their displacement by dt
(`:210`, so a zero dt gives an infinite velocity), steps agents, then entities, then
`FireControl.Step`. ServerShared reads no Unity clock: its only clock reads are
`DateTime.Now` seeds (`ItemManager.cs:20`, `Galaxy.cs:111,167`, `GalaxyStage.cs:49`). There is
no fixed tick (M15), and sim history depends on the frame rate today.
- **T2. Nothing touches Unity's time scale in the game.** The one `Time.timeScale` write is
the `Time Scale` field of `UI/FieldTester.cs:80`, the shield-field lab scene.
`ProjectSettings/TimeManager.asset` has Fixed Timestep 0.02 and Maximum Allowed Timestep
0.1; no script has a `FixedUpdate`.
- **T3. Unity physics writes sim velocity in two places.** `HullCollider.OnCollisionEnter`
(`Gameplay/HullCollider.cs:14-17`) adds `impulse / Time.deltaTime` to `Entity.Velocity`;
the impulses come from non-kinematic loot and mine bodies. `ShieldManager.OnCollisionEnter`
(`Gameplay/ShieldManager.cs:32-81`) decides three facts: an item pickup (`TryStore` on
collision, `:42-56`), a mine detonation (`mine.Explode()`, `:57-60`), and the ship-on-ship
bounce, an elastic exchange of normal velocity written to `Entity.Velocity` (`:70-79`, with
`PostCollisionVelocity` at `:83-86`).
- **T4. Unity-owned world objects keep their own clocks.** Loot drift and attraction
(`GridObject.cs:37-49`), the tractor pull (`TractorBeam.cs:21-27`, a
`Physics.SphereCastAll` that pushes `GridObject.Velocity`), the player's tractor ramp
(`ActionGameManager.cs:1307-1309`, `Time.deltaTime * 2` onto `Entity.TractorPower`), the
loot roll on death (`EntityInstance.cs:293-308`, `UnityEngine.Random`), and the mine
(`Weapons/Mine.cs:46-100`: real-time arming, `Physics.OverlapSphere` proximity, range from the
source transform, lifetime, blast delay, then `FireControl.Detonate` called from Unity).
- **T5. The drawn rounds fly themselves.** `Projectile.Update`
(`Weapons/Projectile.cs:43-50`) integrates gravity, drag and position by `Time.deltaTime`
and dies at a Unity distance from its start; `ProjectileManager.Fire` (`:16-28`) picks the
drawn direction from `barrel.forward` with `UnityEngine.Random` spread, offsets the spawn by
`Random.value * Time.deltaTime` and adds the shooter's velocity. The simulation's round
(`PendingShot`, `FireControl.cs:563-590`, `:1748-1798`) flies straight from `FireOrigin`
along `TravelDirection` at the weapon's velocity, inheriting nothing, and arrives at
`ArrivalTime`. FireControl decides every hit (fire control Cuts 3-4); the two flights
disagree, and Unity ends its own at a distance it chose.
- **T6. The guided round flies itself and times its binding in real time.**
`GuidedProjectile` integrates thrust and position by `Time.deltaTime` (`:143,205,210`), dodges
with `UnityEngine.Random` (`:118,173`), and ends its binding when
`Time.time - _spawnTime` passes `BindingLifetime` (`:116,232`), which
`GuidedProjectileManager.cs:24` sets from `shot.ArrivalTime - zone.Time + 1`: a sim interval
compared against real time.
- **T7. Presenters that read sim state and animate in real time.** Charge effects, beams
and hitscan flashes (`ConstantLaser.cs`, `ConstantLightning.cs`, `Laser.cs`,
`HitscanEffect.cs`: start and stop on sim events, no physics query), shield panels
(`ShieldInterceptor.cs:63-146`), fades (`EntityInstance.cs:363,381`), mount slew
(`ArticulationPoint.cs:36-47`), field ripples (`FieldDriver.cs:231-264`), sun, volumes, wave
scroll, HUD blinks, the hit marker, the death transition and every menu tween. The gas
giant's wave phase reads `Zone.Time` (`ZoneRenderer.cs:503`). `FieldDriver.cs:307-323` moves
a grabbed transform itself (loot-3 removes it).
- **T8. Input and commands.** Each frame `ActionGameManager.Update` writes the view, `Aim`
(`LookDirection` before `controls-helm`), `MovementDirection` and, after `controls-helm`,
`Ship.Turn`. Weapon slots call `Activate` and `Deactivate` from input callbacks
(`:424-425`). `UI/ClickRaycaster.cs:26` picks drawn objects with `Physics.Raycast`: input
choosing a target, not physics deciding a fact. The console's give-item command
(`ActionGameManager.cs:531`) is an operator tool.
- **T9. Pause and saving.** `_paused` (`ActionGameManager.cs:187`) gates the whole of
`Update`, the step included. `Zone.Pack` saves `Time` (`Zone.cs:184`).
`PlayerGameplaySettings` (`ServerShared/PlayerSettings.cs:56`) is saved in `player.cc`;
designer tuning lives in `GameplaySettings` (`ServerShared/Settings.cs:159`, values in
`Assets/Resources/Settings.asset`).
- **T10. Headless tests step zones directly** with chosen dts, `0f` included
(`FireAuthorityTests.cs:192`, `FireControlCut11Tests.cs:192,225,411`); three fixtures
hard-code 1/60 (`MiningCut1Tests.cs`, `RestoredHullsTests.cs`) and choose their own dt.
- **T11. Kinematic contact pairs are on.** `ProjectSettings/DynamicsManager.asset`
`m_ContactPairsMode: 1` enables kinematic-kinematic pairs, so the kinematic shield colliders
(`Content/Prefabs/Shield.prefab`, `m_IsKinematic: 1`) do raise `OnCollisionEnter`: T3's
ship-on-ship bounce is live.
- **T12. Which effect prefabs the catalog fires.** A byte search of `GameData/Aetheria.cc`
for each effect prefab's GUID (as hex text and as raw bytes) finds ProjectileManager
prefabs AC5, 6k Shooter, DeathCluster, prettypretty, ClearPath, ChargeBlast SG,
ChargeBlast+- and FastBlast+-, and GuidedProjectileManager prefabs Micromissile, Regular
Missile, SRM and scorchedvoid.
- **T13. Nothing fires the mine.** The same search finds the Mine Launcher prefab
(GUID `24eddacc819f20542962f2395930f261`, the only prefab carrying `MineManager`) zero
times. `IntroCutscene` (`ActionGameManager.cs:839-866`), which writes a ship's position,
direction and velocity from a real-time coroutine, is never started (its call at `:791` is
commented out).
- **T14. What depends on step count rather than sim seconds.** Measured by source read for
the design-speed step. `Zone._time` is a `double`, so time does not drift with more steps.
`Entity.Position` is a `float3` and zones reach `ZoneRadius` 10000 (`Settings.asset`), where
a float's spacing is about 0.001 units: a per-step displacement of v * Step rounds to it,
so slow movement far from the origin loses precision as the step shrinks (at Step 1/300,
5 u/s is about 17 spacings per step, a few percent of error; at 1/60 it was 85). The
manoeuvre track (`ManoeuvreTrack.Observe`, evasion-term branch) measures acceleration as
the per-step velocity change over dt and calls it changed above `ChangeThreshold` 0.01;
float rounding of that difference grows as 1/dt (about 0.005 at 100 u/s and dt 1/600). Its
exponentials and its quiet timer use dt and are exact. Cooldowns charge the firing tick
(`InstantWeapon.cs:254`) and drop the remainder, so a gun's real period rounds up to whole
steps: a smaller step brings it nearer the authored cadence. Agents decide every step
(`Agent.Update`), and `Combat` draws one jittered range sample per step (`Combat.cs:145`);
a smaller step means more decisions per sim second, the same per real second. No ruling
fixes a per-step count.
**Body facts for ballistic-flight r2** (source reads at e56f9c8b, 2026-10-09, imagination-aeth-ballistic-r2):
- B-r2-1. FireControl.Step writes a committed shot back (`shots[i] = shot`, FireControl.cs:868) before `ShotCommitted` fires (:869). A no-lock fused round stopped by a hull has its `ArrivalTime` shortened at that commit (:867; CommitBurst :1024). A presenter's fire-time copy of the shot therefore carries a stale arrival unless it re-reads the record at `ShotCommitted`.
- B-r2-2. `FireControl.Designated` measures `range` to any non-null target before the in-range test (:293-297), and a direct round's FlightDistance is that range. A direct round can therefore be fired at a target beyond Range and arrive after `FireTime + MaxRange/Speed`.
- B-r2-3. `InstantWeapon.Execute` calls `FireControl.Fire` and then `OnFire` (:271-272) inside the entity loop, and `FireControl.Step` runs after it (Zone.cs:232). A presenter spawned from `OnFire` binds `ShotCommitted` and `ShotResolved` before its shot can commit or resolve, including a round fired at nothing that commits and misses in the same step.
- B-r2-4. Solve aims a direct or engaged fused round at the predicted intercept (`Solution().Direction`, :135-156) but sets FlightDistance to the current planar range (:654-666), so on a moving target the arrival point on the frozen line is not where the target is. Open as question direct-arrival-at-intercept.
**Why a round's end is one sim statement.** The sim resolves a round at `ArrivalTime`. A drawn round that ends anywhere else needs a second owner of the end, which is what ballistic-flight s1 found in Projectile. `FireControl.RoundEnd(shot, known)` states the end for what the sim has published about the outcome: arrival for a hit, a burst or an unknown outcome, and the frozen weapon range for a miss, never short of arrival. `RoundAt` clamps there. A round whose outcome is not yet published waits at its arrival point for at most one step instead of overshooting. The drawing rules (placement, barrel blend, stop, done) are pure statics in DrawAhead, beside the ships' draw-ahead, so the headless suite tests them by behaviour rather than by grep.
**Ballistic flight is presentation (r3, 2026-10-09).** After rulings projectile-flight-is-presentation, sim-hooks-presentation-agnostic and direct-arrival-keep-timing, FireControl owns only a shot's facts: the outcome, when damage lands (ArrivalTime, a contact burst's shortened arrival, the commit tick), the impact cell with its target, and a fused round's burst point. DrawAhead owns the drawn round, as pure statics tested headless.
Probes at d98818ea:
(1) The impact cell is already on the outcome: on a hit, Commit stores the first cell of the shot's lane (FireControl.cs:960-988), MakeOutcome copies Cell and Target (:1527-1544), and ShotCommitted and ShotResolved publish the outcome (:881, :894). No sim hook was needed.
(2) Entity.ToWorldPoint (Entity.cs:717-725) is the one schematic-to-world frame. The step turns Direction by `mul(Direction, float2x2.Rotate(TurnRate*dt))` (Thruster.cs:129), so the cell on the drawn pose is `Position(target, lead).xz + Rotate(ToWorldPoint(cell) - Position.xz, TurnRate*lead)`, exactly.
(3) The commit tick equals `record.ArrivalTime - outcome.ArrivalIn` for every result (:877-881, :1541).
(4) An engaged proximity round is Hit or Burst, never Miss; it bursts at BurstPosition frozen at Fire (:1009-1017, :1148-1150), and it is drawn there.
(5) A target that leaves the zone mid-flight resolves as a fresh Miss (:857-868).
Rationale: a hit follows its frozen line until the commit tick, then blends onto its impact cell on the target's drawn pose, so it lands on the hull rather than at the frozen line's arrival point. The sim's damage timing is unchanged. A miss flies on to the weapon's frozen range, which is DrawAhead.RoundEnd's choice. A kink at the commit frame (at most one step's share of the line-to-hull gap) and a snap back to the line after a target-gone Miss are accepted presentation. Rejected: publishing the lane's entry point as a new sim field; the ruling names the cell, so a hit effect may sit up to half a cell inside the hull skin. Risk: a hull mesh not aligned with its schematic makes hits land beside the mesh; that is a content fault, and the operator's moving-target check shows it.
### Census
Every Unity-side place that integrates, times or decides a sim fact at `6d427b7a`, and the cut
that moves it. Presenters that only read sim state (T7) and input (T8) are not entries.
| Row | Site | Fact Unity decides | Owner |
|---|---|---|---|
| C1 | `ActionGameManager.cs:1311` | The step's length (frame dt) | `sim-speed` r2 |
| C2 | `ActionGameManager.cs:839-866` (dead) | A ship's position, direction, velocity | `sim-speed` r2 deletes it |
| C3 | `HullCollider.cs:14-17` | Velocity from physics impulses | `sim-contact` |
| C4 | `ShieldManager.cs:70-86` | Ship-on-ship bounce | `sim-contact` (new rule in Zone) |
| C5 | `GuidedProjectile.cs`, `GuidedProjectileManager.cs:24-83` | A missile's flight; when its binding ends | `missile-records`, `missile-odds`, `missile-presenter` r3 |
| C6 | `ActionGameManager.cs:1307-1309` | Tractor spin-up | `loot-2` r2 |
| C7 | `Projectile.cs:43-50`, `ProjectileManager.cs:16-28` | A round's drawn flight and end | `ballistic-flight` (new) |
| C8 | `Mine.cs`, `MineManager.cs`, `ShieldManager.cs:57-60` | Mine flight, arming, lifetime, detonation | `retire-unity-mines` r2 deletes it (unreachable per T13); `sim-mines` brings it back as a sim body (section 'Mines in the sim') |
| C9 | `GridObject.cs`, `EntityInstance.cs:293-308`, `ZoneRenderer` loot, `ShieldManager.cs:42-56` | Loot drop, drift, expiry, pickup | `loot-1` |
| C10 | `TractorBeam.cs:21-27`, `FieldDriver.cs:307-323`, binder resolvers | Tractor pull, grabbed pose | `loot-3` |
| C11 | `ActionGameManager.cs:705-711` | Executes a zone transfer the sim decided | follow-up `zone-transfer-owner` |
| C12 | `EntityInstance.cs:403`, `ShipInstance.cs:141` | None (draws the latest pose, so a fixed step hops) | `sim-speed` r2 draws ahead |
`sim-speed-scale` runs the census patterns as negative checks before any code, so the setting
cannot ship while a row is open.
### The design
One clock, `SimClock` (ServerShared, engine-free), owns the step and its pacing.
- `Step` is fixed for the clock's life. `sim-speed` builds it at 1/60 (speed 1);
`sim-speed-scale` builds it at `SimSpeed / TickRate` from game data.
- `StepsPerRealSecond` is `TickRate` times the player's factor (`sim-speed-scale`; 60 before
it). The factor never reaches `Step`, so one set of game data gives one history at any
player setting.
- `Advance(realSeconds, step)` adds the clamped real seconds (at most 0.1) to an accumulator
and calls `step(Step)` each time it holds one tick. A hitch slows the world instead of
spiralling.
- `Lead` is the sim seconds since the last step (below one `Step`); `Steps` counts steps.
There is no `SimDelta`: nothing in Unity integrates on the clock.
`ActionGameManager` calls `Clock.Advance(_paused ? 0 : Time.deltaTime, Zone.Update)` every frame,
outside the pause gate. Pause stays the menu's.
Presenters draw the latest sim state ahead by `Lead`, through `DrawAhead` (ServerShared, pure,
never called by a rule): an entity's position plus velocity times `Lead`, a ship's rotation
turned by `Entity.TurnRate` times `Lead` (the sim writes `TurnRate` from the actuators each step;
staging and wormhole writes leave it zero), and `Zone.Time + Lead` for presenters that evaluate a
sim function of time. A ballistic round is drawn at `FireControl.RoundAt(shot, Zone.Time + Lead)`,
the line the simulation flies it; a missile at its record's position plus velocity times `Lead`.
The presenter API carries the latest state and `Lead`; nothing a presenter receives can feed back.
| Presenter | Reads | Clock |
|---|---|---|
| Entity pose (`EntityInstance`, `ShipInstance`) | `DrawAhead` | `Lead` |
| Ballistic round (`Projectile`) | its `PendingShot` copy, `RoundAt`, `ShotResolved` | `Zone.Time + Lead`; trail fade real |
| Guided round (`GuidedProjectile`) | its munition record, `ShotResolved` | `Lead`; lift curve on sim seconds; fades real |
| Floating item (`ItemPickup`, loot-1) | its body | latest state (ahead by `Lead` when loot-1 lands after `sim-speed`) |
| Charge effects, beams, flashes, shields, fades, slew, ripples, sun, volumes, HUD, menus | sim events and state | real time (ruling `sim-speed-presentation`) |
| Thruster flames, particles, audio pitch | current state | real time |
### Authority map
- **Owner.** `SimClock` owns sim time's relation to real time. `Zone` owns sim time and the
step. Game data owns `SimSpeed` and `TickRate`; the player owns the factor.
- **Inputs.** Real frame seconds and pause; `SimSpeed`, `TickRate`, `SimSpeedFactor`.
- **Outputs.** `Zone.Update(Step)` calls; `Lead` and `Steps` for presenters.
- **Derived state.** `Lead`, `Steps` and every drawn pose are presentation-only.
`Entity.TurnRate` is sim-written and read only by `DrawAhead`. Sim seconds per real second
(`SimSpeed * SimSpeedFactor`) is derived.
- **Forbidden writers.** Any other `Zone.Update` caller; `Time.timeScale` outside
`FieldTester`; any Unity code integrating, timing or deciding a census fact; a presenter
given a sim-time delta; the player's factor reaching `Step`.
- **Shared paths.** The game and every scenario reach the simulation through the one
`Advance`; every presenter draws with the same `Lead`.
- **Deletion line.** Each census cut deletes its Unity owner before adding the sim owner;
`sim-speed-scale` adds no code until every census pattern is empty.
### Model page rows
| Kind | Named by | Over time | Decided by |
|---|---|---|---|
| Design speed, tick rate | `GameplaySettings.SimSpeed`, `TickRate` | Set by designers in game data | game data; read by `SimClock` at zone entry |
| Player slow-down | `PlayerGameplaySettings.SimSpeedFactor` | Set by the player, saved in `player.cc` | the settings menu |
| Zone time | `Zone.Time`, saved in `ZonePack.Time` | Advances by `Step` per step | `Zone.Update`, called only by `SimClock` |
| Clock accumulator | none | Under one tick, dropped on load and zone change | `SimClock` |
| Turn rate | `Entity.TurnRate` | Rewritten every step | the rotation actuators |
| Ship contact | `Zone.Contacted` events | Published at each closing contact | `Contacts.Step` |
### Prior art
From documentation and talks already read, not re-fetched for this pass.
- **Gaffer On Games, "Fix Your Timestep!" (Glenn Fiedler, 2004).** A fixed dt fed from an
accumulator of real time, a cap on frame time against the spiral of death, and drawing
between steps with `alpha = accumulator / dt`. The design is this article, with the factor
applied where real time enters the accumulator.
- **Unity `Rigidbody.interpolation`.** Interpolate draws one physics step behind;
Extrapolate draws ahead from velocity and can show a body briefly past a collider before it
is corrected. Unity recommends Interpolate for the camera's subject.
- **Client-side prediction (Quake 3, Source).** Remote entities interpolate a little in the
past; the player's own avatar is drawn ahead because input lag on it is what players feel.
- **Factorio, Paradox, StarCraft II replays.** Speed changes the tick rate, never the tick,
because lockstep and replays need identical histories. Supreme Commander simulates at
10 Hz and draws in between; Source servers tick at 66 Hz, Overwatch at about 63.
- **Celeste Assist Mode.** Game speed from 50 to 100 percent, a saved accessibility option;
the precedent for the factor and for its 50 percent floor.
### Rationale
**Why the step follows the design speed.** Ruling `tick-rate-follows-design-speed`. At a design
speed of 0.1 and a 1/60 step the world ticks at 6 Hz; drawing ahead hides the motion, but not
the input: a trigger or a turn waits up to a tick (167 ms) to enter the simulation. With
`Step = SimSpeed / TickRate` the real tick rate is a chosen constant, and the player's factor
alone changes ticks per real second, so `APlayerFactorIsTheSameHistory` holds for one set of
game data. A design-speed change is a balance change: it changes the step, so the pace pass
measures at the shipped values. Per T14 no ruled behaviour depends on a step count; what moves
is closer to the continuous model (cooldowns nearer their authored cadence, agents deciding more
often per sim second). The two costs are float precision and the manoeuvre track's threshold,
both pinned as measurements in `sim-speed-scale`.
**The tick rate: 30 Hz at design speed, with the factor floored at 0.5.** Recommendation,
moderate confidence; `TickRate` is game data, so the play check can move it without code.
| Tick rate at design speed | 20 Hz | 30 Hz | 60 Hz |
|---|---|---|---|
| Step at design 0.1 | 1/200 sim s | 1/300 sim s | 1/600 sim s |
| Input latency, factor 1 (worst) | 50 ms | 33 ms | 17 ms |
| Input latency, factor 0.5 (worst) | 100 ms | 67 ms | 33 ms |
| Steps per real second vs today | about a third | about half | today's at 60 fps |
| Float spacings per step at 5 u/s, 9000 units out | 25 | 17 | 8 |
Today's game steps once per frame, so 30 Hz costs about half of today's simulation per real
second at 60 fps and less at higher frame rates. 60 Hz buys latency nobody needs in a game
slowed tenfold and halves the float headroom (T14). 20 Hz puts the slowed factor at 10 Hz, near
the rate the operator called noticeable. A factor floor of 0.25 at 30 Hz would tick at 7.5 Hz,
so the floor is 0.5 (Celeste's), keeping the slowest tick at 15 Hz.
**Drawing between steps: extrapolate everything from the latest state.** Interpolation shows only
states the simulation produced, but draws one step behind; extrapolation has no lag but can
overshoot a turn or a contact and snap at the next step. At 30 Hz and design speed 0.1:
| | Factor 1 (30 Hz) | Factor 0.5 (15 Hz) |
|---|---|---|
| Interpolation lag | 33 ms real (one step) | 67 ms real |
| Extrapolation snap, a turn stopping at 3 rad/s | 0.57 degrees | 0.57 degrees, half as often |
| Extrapolation snap, a contact reversing 100 u/s | 0.33 units | 0.33 units |
| Extrapolation error, steady acceleration of 100 u/s2 | under 0.001 units | under 0.001 units |
The player's ship is extrapolated (the operator's acceptance, and where lag is felt). The rest
are extrapolated too, for three reasons: one draw time keeps the geometry the player aims at
consistent with their own ship (interpolating the others would set them a step behind it, up to
0.67 units at 200 u/s closing); the analytic presenters (`RoundAt`, a missile's record) are
exact under it; and it needs no previous-state buffer, so no presentation store has to be owned
or kept out of the simulation's reach. If the play check shows visible snaps on contact, the
fallback is interpolating non-player entities through a presentation-only buffer written by the
step wrapper, not a change to the simulation.
**Why rounds are drawn on the sim line.** FireControl already owns every hit. A drawn round that
inherits velocity, jitters, drops under gravity and dies at its own distance is a second flight
that disagrees with the first; at a slowed design speed the disagreement is visible for
seconds. `RoundAt` states the simulation's line; the presenter blends from the barrel into it
in the first tenth of a sim second and ends where the outcome says.
**Why contact moves into the simulation instead of being deleted.** The bounce is live (T11) and
the game has it today; deleting it would be a product change no ruling asks for. The rule moves
verbatim (the same elastic exchange, once per contact, no push-out) onto a disc that bounds the
hull, so ships may come a little closer than the shield ellipsoid let them.
**Why the Unity mine is deleted, not moved.** No catalog item fires it (T13) and it owns its whole
life in Unity; moving it would keep Unity deciding. It returns as a simulation body (ruling
`mines-back-in-sim-asap`; section 'Mines in the sim').
**Why the split.** `sim-speed` lands the fixed step at speed 1 now: at speed 1 the integrators
still on `Time.deltaTime` run at the sim's average rate, so nothing desyncs, and replays become
possible (M15). The setting waits for the census, because any open row runs at real speed in a
slowed world. Ordering: `sim-speed` (after `controls-helm`); then `ballistic-flight` and
`sim-contact` (independent, small); `missile-presenter` r3 after `missile-odds`; the loot branch
(`loot-1` r2, `loot-2` r2, `loot-3`); `retire-unity-mines` r2 at any time (it no longer waits on
`loot-3`); `sim-speed-scale` last,
before the `combat-pace-pass`.
**Why the clock does not own pause.** Pause is the menu's; the menu feeds the clock zero real
time.
## Mines in the sim
Rulings `mines-back-in-sim-asap` ("yes delete the mines but I want them back in the sim asap"),
`mines-float-like-loot` ("Mines are free floating just like loot") and `unity-presents-only-r3`.
Anchors are against `origin/master` `35ea0da4`, read on 2026-10-07 in the read-only worktree
`C:\ar-mines-map`. None of `loot-1`..`loot-3`, `retire-unity-mines`, `sim-speed`, `sim-contact`,
`missile-stats` or `missile-records` has landed at this base; `mining-merge` has (the
`Targeting/` providers are on master).
The cuts: `floating-bodies` (the drifting body loot and mines share), `retire-unity-mines` r2 (the
Unity mine deleted now, no longer waiting on the loot branch), `sim-mines` (the mine in `Zone`,
its gear and the catalog item, headless) and `sim-mines-presenter` (Unity draws it). `loot-1` r2
rebuilds on `floating-bodies`. Questions `mine-trigger-iff` and `mine-owner-leash` gate
`sim-mines`.
### Body facts
- **N1. How a mine is fired.** `MineManager` (`Gameplay/Weapons/MineManager.cs:13-32`) is an
`InstantWeaponEffectManager`, so the launcher is an `InstantWeaponData` item: `InstantWeapon`
pays cooldown, ammo, energy, heat and wear and calls `FireControl.Fire`, which queues a
`PendingShot` (`InstantWeapon.cs:271`); `OnFire` then makes `MineManager.Fire` spawn the mine
at the barrel transform. Launch velocity: `barrel.forward * weapon.Velocity` turned by
`UnityEngine.Random` angles in +-`Spread`/2 on all three axes, plus the ship's velocity
(`InheritVelocity: 1` in the prefab). The mine copies `weapon.Damage`, `weapon.Range` and
`WeaponData.DamageType`. So one trigger pull also queued an ordinary shot at the target.
- **N2. Flight and drag.** `GridObject` (`Gameplay/GridObject.cs`; prefab values at
`Mine Launcher.prefab:173-178`): the launch velocity decays by `1 - LaunchDrag*dt` per frame
(`LaunchDrag` 0.2); a separate drift velocity is accelerated down the zone's gravity well by
`normalize(f) * GravityStrength * (1/(1-|f|^2) - 1) * Gravity` with no `dt`, so it is
frame-rate dependent (`Gravity` 1), and decays by `Drag` 0.05; a vertical spring
(`GridAttraction` 10) holds it at the grid height. The pickup prefabs carry the same `Drag`,
`LaunchDrag` and `Gravity` (map L7): loot and mines already fly one body.
- **N3. Arming.** `Mine.Update` (`Mine.cs:53-87`) checks nothing until `ActivationDelay` 2 s of
`Time.time` after the mine is enabled (`Mine Launcher.prefab:158`).
- **N4. Triggering.** Once armed and not yet counting down, `Physics.OverlapSphere(position,
BlastRange 25, layer mask 1)`: any collider carrying `HullCollider` starts a countdown of
`BlastDelay` 2 s, which never cancels. There is no faction test: the launcher's own hull and
its allies trigger it. Mask 1 is the Default layer. The shipped hulls and the turret carry
`HullCollider` on layer 0 (`Longinus.prefab`, `Djinni.prefab`, `Turret.prefab`), but
`ShipPrefabAuthoring.cs:161` puts mod-package hulls on `Combat`, and station prefabs have no
`HullCollider`, so mod ships and stations never trigger a mine. That exclusion is a layer
accident, not a rule.
- **N5. Shield contact is dead.** `ShieldManager.cs:57-60` explodes a mine on shield contact,
but the mine object (layer 21, `GridObjects`) has no collider in the prefab, so the branch
never runs.
- **N6. Ends.** Once armed, the mine explodes when its countdown ends, when `Lifetime` 30 s has
passed, or when it is farther than `weapon.Range` from its launcher's transform
(`Mine.cs:79-84`). Every end is an explosion; none fizzles. A destroyed launcher makes
`Source.transform` throw.
- **N7. The blast.** `Mine.Explode` calls `FireControl.Detonate(zone, position.xz, BlastRange,
Damage, DamageType)` (`Mine.cs:97-104`), spawns `HitEffect` (BigExplosion) and returns to the
pool. `Detonate` (`FireControl.cs:1350-1422`) spreads `Damage` over the disc's area: each
occupied hull cell takes `damage * overlap / (pi r^2)`, so an entity takes damage in
proportion to how much of its hull the disc covers, with no radial falloff; an active shield
takes the entity's whole share or breaks; armour, then item pools, absorb per cell. It reads
no source or faction, so everyone in the disc is harmed, the launcher included. The trigger
radius and the blast radius are the one `BlastRange`.
- **N8. Catalog.** T13 holds at `35ea0da4`: no catalog record names the Mine Launcher prefab.
The item existed in the 2021 catalog (`79b0c036` "Mines and Flamethrowers", 2021-02-06); the
daemon-era `8c71a637` recovered it from `GameData/Legacy/AetherDB.2021-03-05.msgpack`, an LFS
object, and pinned its 2 s activation and 30 s lifetime. `WeaponType.Mine` exists
(`Enums.cs:66`). `WeaponItemData` carries `BlastRadius` (key 29) and `Fuse` (key 32), read
only through `FireControl.FuseOf`. The behaviour union's next free index is 40
(`Behaviors.cs:188`).
- **N9. Presentation.** `Mine` pulses emission on submesh 2 by `EmissionCurve`: none before
arming, `ActiveEmission` on a 1 s cycle when armed, `ArmedEmission` on a 0.25 s cycle while
counting down. `GridObject` spins it by `sin` and `cos` of time. All of it presents the
arming and countdown facts.
### Prior art
A short check, for the two forks only. EVE Online's bombs are unguided and untargeted, drift
along the launch line, and hit every ship in the area, the launcher's fleet and the launcher
included ([EVE University, Bombs](https://wiki.eveuniversity.org/Bombs)). Starsector's Stinger
mines use a friend-or-foe trigger and detonate when a target comes within range
([Starsector wiki](https://starsector.fandom.com/wiki/Stinger-class_Proximity_Mine),
[Mine Strike](https://starsector.wiki.gg/wiki/Mine_Strike)). Freelancer's mines seek hostile
ships, and FreeSpace minefields hit anything (from memory, unchecked). The common shape is an
IFF trigger with a blast that does not ask whose it is. No reference leashes a mine to its
launcher.
### The design
**What a mine is.** A free-floating body (ruling `mines-float-like-loot`) with a frozen payload:
the `KinematicBody` loot drifts on, plus the time it was laid, its arming delay, fuse delay and
lifetime, its blast radius, damage and damage type, and its layer's identity (entity and
faction, frozen at lay). It is not a munition record: it has no seeker, thrust, fuel or shot id,
and nothing prices or rolls it. It is not an `Entity`.
**What is shared with loot, and what is not.** `floating-bodies` lands what both need:
`FloatingBodyId` (one zone counter for every free body), `KinematicBody` and its `Step`, the
three motion settings both already use (N2), and `SimulationDice` (moved out of `FireControl`,
as `loot-1` r1 planned). Each kind keeps its own collection: `Zone.FloatingItems` (loot,
`loot-1` r2) and `Zone.Mines`. One collection with a payload switch would let `TryPickUp` see a
mine and make every reader branch on kind; two typed collections make "a tractor grabs a mine"
unrepresentable instead of forbidden. Neither kind gets a targeting provider: nothing detects
or shoots them today, and one detection interface for bodies stays with the follow-up.
**Laying.** `MineLayerData : InstantWeaponData` carries the mine's own stats (arming delay, fuse
delay, lifetime), as missile stats belong to the missile until gear supplies them (ruling
`missile-stats-now-gear-later`). `InstantWeapon` keeps paying for the round.
`FireControl.Fire` and `FireControl.Refuses` route a mine layer to `FireControl.Lay` before
`Solve`: no target, no arc gate, no `PendingShot`. `Lay` freezes the payload and has `Zone.Lay`
add the mine at the shooter's position with velocity `shooter velocity +
rotate(MountDirection.xz, a dice angle in +-Spread/2) * Velocity`, the dice drawn from
`SimulationDice.For(CombatSeed, MineStream, id)`. This also closes N1's double fire.
**Arming, triggering and the blast.** `Zone.Update` steps mines after the entities and before
`FireControl.Step`, in lay order. A mine is armed at `LaidAt + ArmingDelay`. While armed and not
triggered, it triggers on the first eligible entity whose hull the blast disc touches, through
`FireControl.Touches(entity, point, radius)`, the coverage test extracted from `Detonate`, so
what triggers a mine and what its blast reaches are one geometry and every hull counts (N4's
layer accident is not kept). Which entities are eligible is question `mine-trigger-iff`. A
triggered mine detonates at `TriggeredAt + FuseDelay` whatever moves; an untriggered one at
`LaidAt + Lifetime`; the leash is question `mine-owner-leash`. Detonation calls
`FireControl.Detonate` unchanged with the frozen payload and then removes the mine, so every
removal of a mine is its blast, and that is the fact the presenter draws.
**Presentation.** `MineInstance` replaces `Mine` and `GridObject` on a mine prefab. `ZoneRenderer`
creates one per `Zone.Mines` add and plays the blast on remove. It draws the body at the grid
height (ahead by the sim clock's lead once `sim-speed` has landed), spins as `GridObject` did,
and pulses from `Armed` and `Triggered` in real time (ruling `sim-speed-presentation`). A mine
layer has no fire effect manager: the mine it lays is its presentation.
### Authority map
- Owner: `Zone` owns every mine's existence (`Lay` the one creator, the mine step the one
remover). `FireControl` owns laying's payload (`Lay`) and the blast (`Detonate`, `Touches`).
`KinematicBody` owns the drift arithmetic for loot and mines alike.
- Inputs: the mine layer's evaluated stats at the fire step, the item's `BlastRadius`, the
shooter's position, velocity, mount and identity, zone time and force, the zone's entities
and hulls.
- Outputs: `Zone.Mines` add and remove; `Detonate`'s damage.
- Derived: `Armed` and `Triggered` are read by presenters; the drawn pose and the pulse are
presentation only.
- Forbidden writers: any MonoBehaviour moving, arming, timing or detonating a mine; a creator of
mines besides `Zone.Lay`; a blast path besides `Detonate`; `TryPickUp` reaching a mine; a
`PendingShot` for a mine layer.
- Shared paths: player, AI and scenario mine layers all lay through `InstantWeapon` and
`FireControl.Fire`; loot and mines drift through one `KinematicBody.Step`.
- Deletion line: `Mine`, `MineManager`, the Mine Launcher prefab and `ShieldManager`'s mine
branch go first (`retire-unity-mines` r2), before any simulated mine exists.
### Model page rows
| Kind | Named by | Over time | Decides |
|---|---|---|---|
| Mine | `FloatingBodyId` in its zone, never reused; not saved | Laid, drifts, arms, triggers, detonates, removed | `Zone` (life), `FireControl` (lay, blast) |
| `KinematicBody` | Its floating item or mine | Stepped each sim step under zone force and drag | `KinematicBody.Step` |
| `MineLayerData` | A behaviour on a `WeaponType.Mine` catalog item | Authored | Catalog |
### Rationale
**Why mines first, and what "asap" costs either way.** After the whole of `loot-1`, the mine
waits for the loot branch to merge: `loot-1`, `loot-2` r2 (which also waits on `feedback-1`,
unreported) and `loot-3` merge together, because between them the player cannot pick anything
up. That is three or four cuts nobody has started. With `floating-bodies` split out (about 200
headless lines, no Unity change, no behaviour change), the mine waits only for that:
`retire-unity-mines` r2 lands now in parallel, then `sim-mines`, then `sim-mines-presenter`. The
price is one more small cut and a revision of `loot-1` (r2 takes the body from
`floating-bodies` and inherits the deletion of `GridObject`). The body's shape is fixed before
loot uses it, a small risk, because loot and mines already fly the same constants (N2).
**Why `retire-unity-mines` no longer waits on `loot-3`.** r1 waited so it could delete
`GridObject` once the pickups were off it. r2 leaves `GridObject` to `loot-1` r2, whose pickups
are its last users once the mine is gone.
**Why the trigger shares `Detonate`'s geometry.** The old trigger asked Unity's colliders and the
blast asked the hull cells, so a mine could trigger on a hull its blast then missed, or the
reverse. One coverage test makes "triggered by" and "harmed by" the same disc.
**Why no targeting provider or point defense against mines.** The old mine was neither
detectable nor shootable, and ruling `pd-who-engages` prices munitions, which a mine is not.
Detection of floating bodies stays with follow-up `bodies-detection`.
## Catalog breadth: the weapon matrix, roles backfill and stat-boosting gear
Pass: Imagination `imagination-catalog-breadth`, session `self-2026-10-08b`. Self merges this into
`docs/aetheria-release-map.md`; nothing here is committed. Body: `GameCult/Aetheria` origin/master
`a8f71d463bf1160de0b5f23caa56dc7957252345` (fetched 2026-10-08), detached worktree in the session
scratchpad, removed at the end. Rulings applied: `catalog-breadth-weapons-gear-consumables`,
`catalog-grows-generic-designs-branded-products`, `tech-lineage-per-component-role`,
`weapon-tracking-authored`, `plight-not-one-cell`, `plight-shape`. Operator, 2026-10-08, relayed by
Self: "All this comes with roles and components too, right?" (not yet admitted as a ruling; Self
should admit it so specs can cite it by id).
Part (c), consumables, is not mapped here. The component-role rule (CB-R1) and the stacking rule
(question `boost-stacking`) are written so that pass reuses them unchanged.
### Body facts
Decode method: `cultcache_py` `SingleFileMessagePackBackingStore.pull_all()` over
`GameData/Aetheria.cc` at a8f71d46 (LFS pulled into the worktree), payloads `msgpack.unpackb`ed and
read by MessagePack key. Scratch scripts `cat.py`, `wm.py`, `stats.py`, not committed.
- **CB1. Census at a8f71d46.** 74 FactionProductData, 51 CompoundCommodityData, 31 GearData,
22 WeaponItemData, 13 SimpleCommodityData, 12 Faction, 12 NameFile, 4 CargoBayData, 4 HullData,
3 PersonalityAttribute, 1 FieldKindData, 1 DockingBayData, 1 InputLayout. pd-gear and the Mine
Launcher are on master; the Pirates (pirates-record 8e51016f), lot-product (ec081b9b),
generic-design-names, product-lines-weapons, maker-only-faction (5dce222b), mods-in-tree
(cc83f5ad) and faction-play-1 (b3d1e929) are not. Design names are still the branded ones.
- **CB2. The catalog already has a category axis and a size axis on weapons** (source read
`ItemData.cs:477-487`, `Enums.cs:55-83`). `WeaponItemData.WeaponType` (key 26: ElectromagneticallyPropelled,
ExplosivelyPropelled, Laser, Electrostatic, ParticleProjection, Missile, MicroMissile, SplitMissile,
Mine, Jet) and `WeaponCaliber` (key 25: Small, Medium, Large, ExtraLarge). The operator's vault note
`AetheriaLore Aetheria/Worldbuilding/Post-Elysium/Reference/Weapon Category Codes.md` (33cd06a)
defines the same taxonomy: caliber S/M/L/XL, weapon types EMPS, XPS, L, PL, ESD, PPC, launchers
M, MM, MIRV with guidance D/G/S. The enum lacks PL (pulse laser) and adds Mine and Jet. The only
code readers are the action-bar icon (`Gameplay/ActionBarSlot.cs:154`, `GameSettings.cs:52-54`).
- **CB3. The matrix at a8f71d46** (type x authored caliber, with cell footprint):
| Type | Small | Medium | Large | ExtraLarge |
|---|---|---|---|---|
| XPS (Ballistic) | ClearPath 2, Earp 2, 6k Shooter 2, PD Gun 1 | Autocannon 4, DeathCluster 4, pretty pretty bang bang 4, Flak Gun 2 | - | - |
| EMPS | - | - | - | - |
| Laser (Energy) | Spectra 2, ColdFire 2, PD Laser 1, CShot RainbowLite Lazer 9 | - | - | - |
| ESD (Energy) | - | - | plight 2 | - |
| PPC (Energy) | FastBlast+- 2 | ChargeBlast+- 4, ChargeBlast SG 4 | - | - |
| Missile (Launcher) | - | GT 3K 3 | - | - |
| MicroMissile (Launcher) | - | scorched void policy 3, pswarm 1 | LRMM72 6 | SRMM72 6 |
| SplitMissile | - | - | - | - |
| Mine (Launcher) | - | Mine Launcher 1 | - | - |
| Jet | - | - | - | - |
Caliber and footprint disagree in six designs: CShot (Small, 9 cells), plight (Large, 2),
pswarm (Medium, 1), SRMM72 (ExtraLarge, 6 like LRMM72's Large), Mine Launcher (Medium, 1),
Flak Gun (Medium, 2). Map M30 already recorded CShot and plight.
- **CB4. Weapon homes on master** (`HullData.Hardpoints`, key 23). Longinus: Energy 1x2 x2,
Launcher 1x3 x2. Djinni: Ballistic 1x2 x2, Launcher 1x3 x2, Launcher 3x2 x2. Turret (station):
Ballistic 2x4 x2. Zenith: none. `LoadoutGenerator.HasHome` (`:201`) offers a design only if some
hull's hardpoint `Takes` it, so a Large or ExtraLarge Energy gun, an ExtraLarge launcher and a
2x2 Energy gun have no home until a hull carries such a mount. No mod package is in the tree yet.
- **CB5. Roles and components today** (source read `ItemData.cs:315-331`, `FactionProduct.cs:14-40`,
`Provenance.cs:73`, `ItemData.cs:640-720`). A design declares `Roles` (`ItemRole`, a name only;
its comment: "the part itself earns a record when crafting needs one to exist"). A product
authors `ProductRole{Role, Mean, StandardDeviation}` per role; `ItemManager.CreateLot`
(`ItemManager.cs:149`) rolls each role's quality into the lot; a stat reads it through a
`StatTerm{Source: Quality, Role}`. No record type for a component item exists; the 51
CompoundCommodityData (Circuit Board, Reaction Chamber, Heat Sink Tile, Trigger ...) carry no
roles and nothing links a role to them. Lineage (`tech-lineage-per-component-role`) has no field.
- **CB6. Roles are live only where a stat reads them.** `AetherDb roles-migrate`
(`tools/AetherDb/Program.cs:255-378`) assigned roles per kind from fixed field maps
(`:156-216`) and skipped flat stats (`Min == Max`) and kinds with no quality-reading behaviour.
The census prints, but no test fails on, products that author no spread for a role
(`Program.cs:110-127`).
- **CB7. The audit of in-flight content for roles** (decode plus pirates-record diff read):
- pd-gear (merged): all three designs declare their kind's roles, each read by non-flat stats;
every product authors ProductRole for each. No gap.
- Pirates (pirates-record 8e51016f, `Program.cs` diff lines 66-82): every rebrand mirrors its
design's roles at apply time (mean .45, sd .22). It inherits the design's gaps: its Autocannon
rebrand carries no roles because Autocannon declares none.
- Mine Launcher (merged): no roles, every MineLayerData stat flat with no terms at all; its DME
product authors none.
- Also role-less with flat stats: Autocannon, LRMM72, SRMM72 (every stat Min == Max, Quality term
with no role). Large Drive reads Quality with no role and declares none. Cargo bays, docking
bay, Cockpit 2x2, Turret Control Module and Tractor Beam carry no quality-reading stat.
- **CB8. Stat modifiers exist and work for passive gear** (source read
`Behaviors/StatModifier.cs`, `StatResolver.cs:108-145`, `Entity.cs:1960-1980`).
`StatModifierData` (behaviour union 4) names a target `StatReference{Target type name, Stat
field}`, a magnitude `PerformanceStat` (so per-role quality works), `Constant` or `Multiplier`,
and an optional `RequireBehavior`. While the gear is Active its behaviour groups execute in order
and stop at the first that fails (`Entity.cs:1969-1976`), so an `EnergyDraw` before the modifier
gates it on power; the modifier attaches to every matching stat on the same entity and detaches
when it stops executing. `PerformanceStat.Evaluate` applies `lerp * Scale + Constant`, then the
power factor (`ItemData.cs:700-722`). The sim reads every boosted stat through `Evaluate`
(Thruster.cs:43, Sensor.cs:168, Radiator.cs:118, Weapon.cs:133-143, VelocityLimit.cs:35,
TargetingSystem.cs:80). The catalog's one modifier: MoveOnPro (both sizes) scales
`CapacitorData.Capacity` by 1.1-1.25.
- **CB9. Gap: a modifier cannot target a base behaviour type.** `TargetsOf`
(`StatModifier.cs:91-114`) matches `GetType() == targetType` at `:98`, `:102` and `:104`, while
`ResolveStatField` resolves any `BehaviorData` subtype by name, abstract ones included
(`ItemData.cs:785-799`, `Extensions.cs:36-44`). A modifier on `WeaponData.Damage` validates and
then attaches to nothing, because every gun's behaviour is a concrete subtype (AutoWeaponData,
InstantWeaponData, ChargedWeaponData, ConstantWeaponData, LauncherData, MineLayerData).
- **CB10. Stacking today is unbounded.** `StatResolver.ScaleModifier` multiplies every attached
scale and `ConstantModifier` sums every constant (`StatResolver.cs:108-122`). Ten 1.15
multipliers compound to 4.0x. Consumable effects attach through the same resolver
(`Entity.cs:857-881`, `StatModifier.cs:63`), so the stacking rule is shared with part (c).
- **CB11. Where stat gear lives.** Tool gear has no hardpoint and fits any free interior cell
(`Entity.cs:1076-1090`); Targeting Computer (Tool 1x1: TargetingSystem, EnergyDraw 2, Heat 15) is
the closest template for a powered booster.
- **CB12. Consumables already have a sim mechanism** (for part (c), not mapped here):
`ConsumableItemData` (`ItemData.cs`, keys 10-14: Behaviors, Stackable, Duration, Icon,
Effectiveness) is a CraftedItemData, so it declares roles; `Entity.TryActivateConsumable`
(`:872`) and `ConsumableItemEffect` (`:1582`) run its behaviours, including StatModifier, with a
`ConsumableProgress` stat source. The catalog holds no consumable record.
### Model page row changes
- **WeaponItemData (catalog)**: who decides gains "the matrix cell": `WeaponType` and
`WeaponCaliber` are the authored category and size (question `weapon-matrix-axes`); footprint
lies in its caliber's band (Small 1-2 cells, Medium 3-4, Large 5-6, ExtraLarge 7-9).
- **CraftedItemData.Roles / FactionProductData.Roles**: a design declares a role only if a non-flat
stat reads it; every product authors one ProductRole per role (rule CB-R1).
- **StatModifierData**: targets a behaviour type and its subtypes (after modifier-subtypes); the
resolver's stacking rule is the one for gear and consumables alike (question `boost-stacking`).
### Rules (cited by the specs)
- **CB-R1, roles and components (the whole ruling, consumables included).** A crafted design
(weapon, gear, consumable) declares roles iff it has a stat that varies with quality. Each
declared role is read by at least one non-flat stat's `Quality` term; each non-flat stat reads
exactly one declared role; every product that sells the design authors one ProductRole per
role, from its maker's profile (catalog-growth addenda table; Pirates .45/.22 per pirates-record).
Roles come from the kind's field map in `Program.cs:156-216`, extended for new kinds as named in
each spec. A consumable's roles govern its effect magnitude stats the same way. Under the
recommended option of question `component-items`, the component is the (design role, product
ProductRole) pair: no component item record until crafting reads one, and the lineage mark of
`tech-lineage-per-component-role` lands on ProductRole when the first aethertech product does.
- **CB-R2, authoring a new design.** Copy a template by record key (never by name, so it holds
across generic-design-names), set the shape, caliber, type and name, set every authored stat by
the ladder below as a Min-Max range of value x (0.8, 1.2) (benefit stats rise with quality, cost
stats fall), keep the template's non-quality terms, point each Quality term at its role, author
Tracking per weapon with no terms (`weapon-tracking-authored`), carry no BlastRadius or Fuse
(`FireControlCut124Tests.cs:2589`), no AmmoType (the ballistic-ammo revision owns rounds), and a
description with no maker name. Two or more products from distinct makers, never Miss Terri's or
the Pirates, product name never the design name.
- **CB-R3, caliber ladder** (per step from the template's caliber): Damage per shot x2.0, Range
x1.2, Mass x2.0, Price x2.0, Energy and Heat x1.8, Cooldown x1.25, Spread x0.85, Visibility x1.5.
Tracking (authored, steep): 2-cell Small 10-12, Medium 5-6, Large 3, ExtraLarge 1.5; flame guns
double those (the cone covers what tracking misses).
- **CB-R4, footprints for new designs.** Small 1x2, Medium 2x2 (launchers 1x3), Large 2x3
(launchers 3x2), ExtraLarge 2x4 (fits the station Turret's Ballistic 2x4).
- **CB-R5, catalog blob.** Each content cut lands through one idempotent AetherDb command; under
follow-up `catalog-blob-merge-order` its merge takes master's catalog, reruns the command's
`apply`, commits the regenerated blob in its own LFS commit with the dry-run text and reruns the
suite. No cut picks a side's blob.
### Design tables
Product names are drafts in each maker's voice (profiles: catalog-growth addenda); Hands writes the
one-line descriptions in that voice.
**matrix-coilguns** (EMPS, Ballistic hardpoint, template ClearPath `0271bb97` for Small and
Autocannon `13116166` above; EMPS vs XPS at one caliber: Velocity x2.5, Penetration authored high,
Energy x3 per `ballistics-cycle-power`, Damage x1.2, Cooldown x1.6; roles barrel, feed mechanism):
| Design | Caliber | Shape | Products (maker) |
|---|---|---|---|
| Light Coilgun | Small | 1x2 | RC-1 Needle (R&D), Pin (Finch) |
| Coilgun | Medium | 2x2 | RC-4 Spindle (R&D), Plumb Line (Adrasteia) |
| Railgun | Large | 2x3 | True Meridian (Adrasteia), RX-9 Lancer (R&D), Gavel (Zhestokost) |
| Heavy Railgun | ExtraLarge | 2x4 | Axis (Adrasteia), Long Reach (AU) |
**matrix-cannons** (XPS, template Autocannon, AutoWeaponData; barrel, feed mechanism):
| Heavy Autocannon | Large | 2x3 | Bulwark (Zhestokost), Foreman (AU) |
|---|---|---|---|
| Siege Cannon | ExtraLarge | 2x4 | Last Word (Zhestokost), big red problem (DME) |
**matrix-beams** (Energy; Laser from ColdFire `2933c4ce`, PPC from ChargeBlast+- `499f7891`;
focusing array, power coupling):
| Medium Beam Laser | Medium | 2x2 | Filament (Alakrita), Limelight (Lucent) |
|---|---|---|---|
| Large Beam Laser | Large | 2x3 | Marquee (Lucent), Clear Sight (Adrasteia) |
| Heavy Ion Cannon | Large | 2x3 | Blackout (NiteLife), short circuit (DME) |
| Ion Lance | ExtraLarge | 2x4 | Lights Out (NiteLife), IX-12 Breaker (R&D) |
**matrix-lightning** (ESD, ChargedWeaponData from plight `632420a6`; focusing array, power coupling):
| Arc Thrower | Small | 1x2 | Sparkler (Lucent), Live Wire (NiteLife) |
|---|---|---|---|
| Storm Projector | Medium | 2x2 | Thunderhead (NiteLife), everybody dance (DME) |
| Tempest Projector | ExtraLarge | 2x4 | Grand Finale (Lucent), Mains (NiteLife) |
**matrix-missiles** (Launcher; Missile from GT 3K `5ee2ff03`, MicroMissile from LRMM72 `32157982`;
guidance system, thruster, warhead):
| Light Missile Launcher | Small | 1x2 | Fledgling (Finch), Courier (AU) |
|---|---|---|---|
| Heavy Missile Launcher | Large | 3x2 | GT 6K (R&D), Freight Forward (AU) |
| Torpedo Launcher | ExtraLarge | 2x4 | Battering Ram (Zhestokost), GT 12K (R&D) |
| Micromissile Battery | ExtraLarge | 2x4 | the whole show (DME), Fireworks (Lucent) |
**matrix-mines** (Launcher, MineLayerData from Mine Launcher `1b4a2bb3`; new role map: warhead
reads Damage, Penetration, DamageSpread; dispenser reads Range, Cooldown, Spread, Velocity, Energy,
Heat, Visibility; ArmingDelay, FuseDelay and Lifetime stay authored per design, not ladder-scaled):
| Mine Rack | Medium | 1x3 | party favours (DME), Caltrop (Zhestokost) |
|---|---|---|---|
| Heavy Mine Layer | Large | 3x2 | surprise party (DME), Wake Warden (Lightsail) |
| Minefield Projector | ExtraLarge | 2x4 | Exclusion Zone (Zhestokost), do not enter (DME) |
**matrix-flamers** (Jet, Ballistic hardpoint, InstantWeaponData pellet gun from Flak Gun
`6af6446e`: DamageType Thermal, Count 8-12, Spread 25-35 deg, Range 150-300 at Small, Velocity
300; roles nozzle (Range, Spread, Velocity) and fuel pump (Damage, Cooldown, Energy, Heat,
Visibility)). Absorbs follow-up `pirates-flamethrower`'s design; the Pirates' own 🔥 product moves
in their own record, not here.
| Light Flamethrower | Small | 1x2 | hot take (DME), Scorch (Zhestokost) |
|---|---|---|---|
| Flamethrower | Medium | 2x2 | keep it lit (DME), Brushfire (AU) |
| Heavy Flamethrower | Large | 2x3 | Purifier (Zhestokost), burn the boats (DME) |
| Inferno Projector | ExtraLarge | 2x4 | Ashfall (Zhestokost), Afterparty (NiteLife) |
After these, every cell of CB3 is filled except the SplitMissile row (follow-up
`mirv-split-munition`): 24 new designs, 49 new products.
**Stat boosters** (Tool 1x1, template Targeting Computer `c07ea205`'s EnergyDraw and Heat, then a
StatModifierData Multiplier in the same group so power gates it; roles amplifier (the magnitude)
and regulator (EnergyDraw, Heat); magnitude per question `boost-strength`). Superseded 2026-10-09 by
ruling `boost-strength-tiered-by-size`: each stat gets a modest 1x1 and a strong 2x2 design, tabled in
'Spec refresh 2026-10-09' below):
| Design | Targets | Products (maker) | Cut |
|---|---|---|---|
| Thrust Amplifier | ThrusterData.Thrust | Tailwind (Finch), Second Wind (Lightsail) | boost-gear-systems |
| Signal Amplifier | SensorData.Sensitivity | Earful (Finch), Lookout (AU) | boost-gear-systems |
| Emissivity Booster | RadiatorData.Emissivity | Cold Read (Adrasteia), Cool Head (Lightsail) | boost-gear-systems |
| Reactor Overclock | ReactorData.Charge | Afterhours (NiteLife), redline (DME) | boost-gear-systems |
| Damage Amplifier | WeaponData.Damage | Showstopper (Lucent), Heavy Hand (Zhestokost) | boost-gear-combat |
| Range Extender | WeaponData.Range | Far Sight (Adrasteia), Long Look (Finch) | boost-gear-combat |
| Shield Booster | ShieldData.Capacity | Thick Skin (AU), SB-2 Bastion (R&D) | boost-gear-combat |
### Rationale
**Why the category is WeaponType and the size is WeaponCaliber.** The operator's own taxonomy
(CB2) names exactly these two axes, and the catalog already carries both fields. Footprint is mount
space, which the brief's "sizes (cell footprints)" reads as the size; the two disagree in six
records (CB3). Recommended: caliber is the one authored size, and a catalog test holds every
footprint inside its caliber's band, so neither can drift; the six are fixed as content. plight is
the exception the operator holds open (ruling `plight-shape`, Defaulted, revisit after play): it
stays Large, and the band test names that ruling as its one exemption. Prior art: Starsector mounts
are type x size (small/medium/large; ballistic/energy/missile) and every type exists at every size;
Elite's hardpoints are class 1-4 with weapon families not filling every class.
**Why MIRV waits.** A split missile with no split is a mislabelled missile. Missiles become stepped
munition records only after missile-records (r3, no report); splitting belongs on that record.
**Why components are the role and its ProductRole.** No system reads a part record (crafting is not
in the demo; the target's not-in-scope line excludes new mechanics), so a component item now would
be inert data. X4 and EVE give components item records because their economies produce and consume
them; the role-quality pair already carries what the operator's examples need (a laser better at
what its focusing role governs). Question `component-items` asks the operator.
**Why the stacking question recommends diminishing returns.** Interior cells are cheap compared with
hardpoints, so unbounded multiplication (CB10) makes a hold full of amplifiers the dominant fit.
EVE University, Stacking penalties (fetched 2026-10-08): the n-th percentage modifier on one
attribute is multiplied by S(n-1), S(u) = e^-(u/2.67)^2, strongest first, so 100%, 86.9%, 57.1%,
28.3%, 10.6%, 3.0%; absolute effects are never penalized; positive and negative effects are
penalized separately. Starsector hullmods are on or off per ship and priced in ordnance points
(starsector.wiki.gg/wiki/Hullmods, fetched 2026-10-08: Unstable Injector raises speed and cuts
weapon range 25%); the exact bonus values were not on that page. Nebulous jammers stack on a similar
diminishing curve (consumables prior-art file, "Stacking and exclusivity"). Because consumables
attach through the same resolver, whichever rule the operator picks covers part (c) too.
**Cut order.** roles-backfill and modifier-subtypes are independent of everything here. The seven
matrix cuts are independent of one another and of generic-design-names (templates by key, new
designs born generic); they wait only on the axis ruling. caliber-bands lands last: it recalibers
six records and adds the band and coverage tests that would fail before the matrix fills.
product-lines-weapons r2 adds roles-backfill to r1's dependencies so its new products of Autocannon,
LRMM72, SRMM72 and Mine Launcher author ProductRole for real roles. product-lines-gear turns
follow-up `gear-product-lines` into a spec (Self withdraws the follow-up as absorbed when it
admits nothing else over it). boost-gear-combat waits on modifier-subtypes; modifier-stacking and
both boost cuts wait on the operator.
**Record keys.** No cut here adds a MessagePack key to any type, so none touches the Faction key
collision (maker-only-faction 19, faction-play-1 Doctrine, livery 18).
### Admitted (mind receipts 2026-10-08 19:48-19:50 UTC)
Questions: `weapon-matrix-axes` (raised in matrix-coilguns, blocks every matrix cut and
caliber-bands), `component-items` (raised in nothing: the specs follow its recommended option and
stay valid under the others), `boost-strength` (raised in boost-gear-systems), `boost-stacking`
(raised in modifier-stacking). Cut specs r1: roles-backfill, modifier-subtypes, modifier-stacking,
boost-gear-systems, boost-gear-combat, matrix-coilguns, matrix-cannons, matrix-beams,
matrix-lightning, matrix-missiles, matrix-mines, matrix-flamers, caliber-bands, product-lines-gear;
product-lines-weapons r2 with resolution `cut_spec.cut-product-lines-weapons.r1.n1` (Superseded).
Follow-ups: `mirv-split-munition`, `homeless-calibers`, `matrix-rounds`. Left for Self:
follow-up `gear-product-lines` is absorbed by product-lines-gear (close it when that merges);
follow-up `pirates-flamethrower`'s design half is absorbed by matrix-flamers (its product move
stays the Pirates' record's).
## Consumables: the host fix, supply, throttle lock and vapour cloud
Pass: Imagination `imagination-consumables`, session `self-2026-10-08b`. Self merges this into
`docs/aetheria-release-map.md`; nothing here is committed. Body: `GameCult/Aetheria` origin/master
`a8f71d463bf1160de0b5f23caa56dc7957252345` (fetched 2026-10-08 ~20:00 UTC; the pirates-record merge had
not landed), detached worktree in the session scratchpad, removed at the end. Catalog: LFS blob
`GameData/Aetheria.cc` sha256 `5ecf375f...a011`, decoded with `cultcache_py`
`SingleFileMessagePackBackingStore.pull_all()` and `msgpack` (payloads are arrays indexed by MessagePack
key). Scratch probes `q1_thrust_speed.py`, `q2_templates.py`, not committed.
Rulings applied: `catalog-breadth-weapons-gear-consumables` (operator: "consumables (goofy stuff like
juicing your thrusters for a few seconds but locking the throttle at max, or a consumable coolant that
turns into a cloud of vapor to break locks)"), `catalog-breadth-roles-and-components` ("All this comes
with roles and components too, right?"), `catalog-grows-generic-designs-branded-products`,
`sensor-stat-set`. Reused unchanged from parts (a) and (b)
(`F:\Projects\aetheria-map-addenda-catalog-breadth-2026-10-08.md`): CB-R1 (roles), CB-R2 (authoring),
CB-R5 (catalog blob), and the stacking rule of question `boost-stacking` through cut `modifier-stacking`.
Prior art: `F:\Projects\aetheria-consumables-prior-art-2026-10-08.md` (cited below as PA section n).
### Body facts
- **CC1. The consumable mechanism exists end to end in code, and no record uses it.** Census: no
`aetheria.consumableitemdata` record in the catalog (decode). `ConsumableItemData : CraftedItemData`
(`ItemData.cs:342-358`, keys 10 Behaviors, 11 Stackable, 12 Duration, 13 Icon, 14 Effectiveness;
Roles at key 9 from CraftedItemData). Instance `ConsumableItem` (`ItemInstance.cs:65`, union 3 and 2).
Activation: `Entity.TryActivateConsumable` (`Entity.cs:872-884`) refuses a second active effect of a
non-Stackable design (`CanActivateConsumable`, `:867-870`), takes the first instance of that design
from cargo, builds a `ConsumableItemEffect` and removes the instance. Each tick
(`Entity.cs:1443-1454`) the effect updates its behaviours in order, stopping at the first that fails
(`ConsumableItemEffect.Update`, `:1604-1619`), and at `RemainingDuration < 0` is dropped after
`Resolver.Forget(effect)`. Catalog load validates a consumable's modifiers and role usage
(`AetheriaStores.cs:77-80`).
- **CC2. Player input exists; AI input does not.** The player drags a consumable from cargo onto an
action-bar slot (`ActionGameManager.cs:484-485`), the slot calls `TryActivateConsumable`
(`ActionBarSlot.cs:118-121`), shows the count in cargo and the remaining-duration fill
(`:128-134`), and the binding is saved (`SavedGame.cs:221,230`). Nothing in `Agents/` calls it
(grep: the only caller is `ActionBarSlot.cs:120`).
- **CC3. Defect: a consumable's stat modifier never reaches anything, and would throw.**
`StatModifier.Initialize` (`StatModifier.cs:82-89`) is what computes `_targets`; the only caller of
`IInitializableBehavior.Initialize` is `Entity.Activate` over `Equipment` (`Entity.cs:202-207`).
`ActivateConsumable` (`Entity.cs:857-860`) never calls it, so `_targets` stays null. The effect's
`Update` runs `IAlwaysUpdatedBehavior.Update` before `Execute`; on the second tick `_executed && !_applied`
calls `ApplyModifier` (`:224-230`), which iterates the null `_targets`: a NullReferenceException.
Source read; no test covers it (the expiry test `StatResolverTests.cs:363` uses a consumable with no
behaviours). Body fact CB10's claim that consumable effects attach through the resolver holds for the
resolver's API, not for this host path.
- **CC4. Defect: an expired consumable's modifiers would outlive it.** Attachments are keyed by the
target item as owner and the `StatModifier` instance as modifier key (`StatModifier.cs:224-237`).
Expiry calls `Resolver.Forget(effect)` (`Entity.cs:1451`), which removes only entries whose owner is
the effect (`StatResolver.cs:154-170`); nothing calls `StatModifier.Dispose` (`:245-249`). Once CC3 is
fixed, a boost would stay attached to the thrusters forever.
- **CC5. Defect: a bought or generated consumable is not a ConsumableItem.**
`ItemManager.CreateInstance(int lot)` (`ItemManager.cs:173-189`) returns `EquippableItem` or else
`CompoundCommodity`; `TryActivateConsumable` then casts the cargo instance to `ConsumableItem`
(`Entity.cs:880`), an InvalidCastException. The tests build `ConsumableItem` by hand for this reason
(`LoadoutTests.cs:787-791`).
- **CC6. Nobody sells consumables, and nothing restocks.** Station stock is
`RandomProducts<EquippableItemData>(16, 1, ...)` into the station's cargo at generation
(`LoadoutGenerator.cs:110-119`); `RandomProducts` and `AvailableProducts` are constrained to
`EquippableItemData` (`:142`, `:154`) because the availability rule includes `HasHome` (`:164`).
Stations are generated once per zone (`ZoneGenerator.cs:303,324`); no code restocks a station
(grep `Restock`, `GenerateStationLoadout`). The trade menu sells out of station cargo
(`StationServices.TryBuy`, `StationServices.cs:73`; `BuyPrice` prices any CraftedItemInstance, `:13-24`).
- **CC7. Cargo is the inventory.** A crafted instance occupies its design's `Shape` cells in a cargo bay
(`Entity.cs:2187-2207`); there is no item stack for crafted items. A consumable design therefore needs
a Shape (ItemData key 5); CompoundCommodity records have none (decode: Shape None, Price 0), so they are
not templates. Targeting Computer `c07ea205` is the 1x1 template for mass, specific heat and
conductivity (CB11).
- **CC8. How locks are decided.** Per observer, `EntityInfoGathered[target]` rises by `Sensor.Gain`
(`Sensor.cs:187-189`: passive `visibility x sensitivity x curve(bearing) x dt / distance`, ping
`visibility x sensitivity x PingBoost x distance` once) and decays by `TargetInfoDecay` each tick
(`Sensor.cs:155-178`). Crossing `TargetDetectionInfoThreshold` adds or removes the target from
`VisibleEnemies` (`Entity.cs:261-277`); leaving `VisibleEnemies` clears the observer's target
(`Entity.cs:248-250`), which zeroes every `LockWeapon` lock (target changed, `LockWeapon.cs:85-90`).
Lock rate is `LockSpeed x info^SensorImpact` inside `LockAngle` (`:98-105`); launchers are
LockWeapons (`Launcher.cs:7`, `CreateInstance` returns `LockWeapon`). FireControl's sensor factor
also reads info (`FireControl.cs:439-442`). Minions retarget when an enemy re-enters
`VisibleEnemies` (`Minion.cs:13`). A missile already in flight is a Unity `GuidedProjectile` that
follows its `Target` transform (`GuidedProjectile.cs:44,142`); the simulation does not re-decide it.
The same `Gain` serves chunk detection through `PassiveRate` (`Sensor.cs:194-199`, `Entity.cs:384`),
and `MiningCut3Tests.cs:254` pins the entity info trace bit for bit.
- **CC9. How thrust and the throttle work.** `Ship.MovementDirection` (`Ship.cs:27`) has two writers, the
player's input (`ActionGameManager.cs:1339`) and the agent (`Agent.cs:70-80`); `Ship.Update`
(`Ship.cs:231-257`) turns it into each thruster's `Axis` (strafe at `:241-252`, forward and reverse at
`:255-256`) and `Turn` into rotation. A thruster pushes by `Thrust` and adds heat and visibility
proportional to its input, not to thrust (`Thruster.cs:116-137`), so a Thrust multiplier alone costs no
heat. Top speed is a hull's `VelocityLimitData.TopSpeed` (decode: Djinni 50, Longinus 100), clamped in
`VelocityLimit.Execute`; the hull is in `Equipment` (`Entity.cs:886`, MapEntity), so a modifier on
`VelocityLimitData.TopSpeed` reaches it. Catalog thrusters: Small Drive 75k, Talaria 75k-250k, Medium
Drive 100k-300k, deep space burnout and RevvITup 250k-1M, Victoire 200k-1.5M (Thrust Min-Max).
- **CC10. Zone bodies.** A mine is a runtime-only `KinematicBody` in `Zone.Mines` (`Mines.cs`,
`Zone.cs:22`), added only by `Zone.Lay` (`:235-239`), stepped and removed only by `StepMines`
(`:245-260`) after the entity loop, never saved, and drawn by one `ZoneRenderer` subscription
(`ZoneRenderer.cs:225-264`, `MinePrefab` `:47`). This is the pattern a vapour cloud follows.
- **CC11. A consumable's own heat does nothing.** `Behavior.AddHeat` is a no-op for a consumable host
(`Behaviors.cs:60`, "TODO: Heat for Consumables"). The first set takes its heat cost through the
thrusters' own Heat stat instead.
- **CC12. Record keys.** `BehaviorData` union ids run to 40 (`MineLayerData`, `Behaviors.cs:189`); 12 and
26 are retired. No remote branch (71 refs, `git show <ref>:.../Behaviors.cs`) claims 41 or above. This
pass claims **41 `ThrottleLockData`** and **42 `VapourDumpData`**. No key is added to Faction,
ConsumableItemData or any saved type; the Faction 16-19 collision is untouched. `VapourCloud` is
runtime only, like `Mine`.
### Model page row changes
- **ConsumableItemData (catalog)**: new live row. Named by record key; a design with roles (CB-R1),
Shape 1x1, a Price, Stackable false, a Duration, and behaviours. Decided by its AetherDb command.
- **ConsumableItem (run save, cargo)**: created by `ItemManager.CreateInstance` for a consumable design;
lives in a cargo bay; consumed by `TryActivateConsumable`.
- **ConsumableItemEffect (runtime)**: one per activation; owns its behaviours' lifetime: initialised at
activation, disposed at expiry, then forgotten by the resolver. Not saved.
- **Entity.ThrottleLocked (derived)**: true while any active effect carries a `ThrottleLock`. Nobody
writes it. `Ship.Update` is the one place that turns intent (`MovementDirection`) into thruster axes,
and it reads the lock there.
- **VapourCloud (runtime, Zone.Clouds)**: added only by `Zone.Vent`, stepped and removed only by
`Zone.StepClouds`. `Zone.Obscuration(a, b)` is derived from the live clouds; `Sensor.Gain` is its one
reader.
### Rules (cited by the specs)
- **CS-R1, the consumable mechanic.** A consumable is a cargo item, single use, activated from the action
bar (player) or by an agent rule (AI). Its effect is timed (`Duration`) and carries its own drawback in
the same record. A non-Stackable design allows one active effect at a time, so its Duration is also its
cooldown; no second cooldown mechanism. Boosts from a consumable multiply with gear under the one resolver
rule (ruling `boost-stacking-multiply`; pinned by cut `modifier-stacking` r3).
- **CS-R2, roles.** Per CB-R1, every non-flat effect stat reads one declared role and every product
authors one ProductRole per role. Benefit magnitudes rise with quality, cost magnitudes fall.
- **CS-R3, the throttle lock.** While locked, the effective movement intent is full forward, no strafe,
no reverse; turning stays with the pilot (question `overdrive-steering`). Intent writers keep writing;
they are not owners of the thruster axes.
- **CS-R4, obscuration.** A cloud is a disc with an opacity that fades linearly to 0 over its lifetime.
Every sensor gain (passive, ping and chunk) is multiplied by the product of `(1 - opacity)` over the
clouds the sight line crosses, endpoints inside counting as crossing; with no cloud crossed no
multiplication happens, so the info trace is bit-identical. Symmetric: the venting ship sees out no
better than others see in. Lock loss is the existing rule (CC8): info decays below the detection
threshold and the target is dropped. A sensor strong or close enough still burns through.
### Design tables
**Thruster Overdrive** (cut `consumables-first-set`; ConsumableItemData; Shape 1x1; Mass 10; Price
6,000; Stackable false; Duration 5 s; roles **propellant**, **regulator**; behaviours in order:
ThrottleLock, then four StatModifierData Multipliers):
| Target stat | Magnitude Min-Max | Role |
|---|---|---|
| ThrusterData.Thrust | 2.0-3.0 | propellant |
| VelocityLimitData.TopSpeed | 1.5-2.0 | propellant |
| ThrusterData.Heat | 4.0-2.5 | regulator |
| ThrusterData.Visibility | 4.0-2.5 | regulator |
Products: **send it** (Death Monkey Explosives), **Updraft** (Finch Cybernetics). The heat and plume cost
is real because thrusters heat by input, not thrust (CC9): a locked throttle runs every forward thruster
at full input for 5 s at up to 4x heat and plume. Prior art: Starsector Burn Drive (+200 speed for 5 s,
no steering, PA 1), Plasma Jets (+375% acceleration, 3 s, PA 1), EVE overheating (+50% propulsion, heat
damage, PA 5).
**Coolant Vent** (same cut; ConsumableItemData; Shape 1x1; Mass 15; Price 4,000; Stackable false;
Duration 12 s; roles **nozzle**, **coolant**; behaviours in order: VapourDump, then one StatModifierData
Multiplier):
| Stat | Min-Max | Role |
|---|---|---|
| VapourDumpData.Radius | authored so the cloud encloses the largest ship hull (Djinni, 14x17 cells) at Min; Max 1.5x Min | nozzle |
| VapourDumpData.Opacity | 0.80-0.95 | nozzle |
| VapourDumpData.Lifetime | 10-14 s | coolant |
| RadiatorData.Emissivity (penalty, the drawback: the coolant is gone) | 0.4-0.6 | coolant |
Products: **Smoke Machine** (NiteLife Energy), **Morning Fog** (Lightsail Express). Prior art: Nebulous
jamming against seeker return (PA 8), Elite heat sink and silent running (PA 3), Star Citizen chaff as a
field effect (PA 9), EVE ECM chance as strength against sensor strength (PA 5).
### Rationale
**Fix the host before adding content.** The mechanism the brief names (CB12) exists but is broken in
three places (CC3, CC4, CC5): a consumable with a stat modifier throws on its second tick, would leak its
boost if it did not, and can never be bought. `consumable-host` is the smallest cut that makes the
existing path true; no new owner. Cut `modifier-stacking`'s test `AConsumableBoostStacksWithGear` cannot
pass before it, so its r2 adds the dependency and nothing else.
**Why the throttle lock is derived, not latched.** Two writers already set `MovementDirection` (CC9). A
third writer (the consumable) would race them; a latch set by a behaviour and cleared each tick is a
second owner of time. `Entity.ThrottleLocked` derived from the active effects, read once in
`Ship.Update`, cannot outlive its effect and needs no reset path.
**Why obscuration and not a flat break.** The sim already decides locks by information against a
threshold (CC8), and lock loss already clears targets and zeroes locks. Scaling gain along the sight line
makes the cloud a contest of signal against obscurant, the Nebulous shape (PA 8: a seeker's return must
exceed the jamming it feels; burnthrough exists), and EVE's lesson that a guaranteed area break is
unreliable or oppressive is avoided (PA 5). A flat break would add a second rule that drops targets,
beside the one that exists. The cloud rides `Sensor.Gain`, which survives the `sensor-stat-set` rebuild
(every emitter still produces a gain). In-flight missiles are not re-decided by the sim (CC8): the cloud
denies the lock before launch; seekers in flight are follow-up `cloud-vs-missiles-in-flight`.
**Why the cloud is a zone body that inherits velocity.** Vented gas keeps the ship's velocity
(KinematicBody, CC10), so a coasting ship stays inside it and a ship that burns leaves it: the two
consumables combine (vent, then overdrive out of the cloud) and their drawbacks bite each other (the
overdrive plume is bright; leaving the cloud ends the cover). The symmetric rule makes hiding blind.
**Hoarding** (written for the abundant option; the operator ruled `consumables-scarce` on 2026-10-09 and
accepted the hoarding risk, so the cheap, everywhere-stocked half of this paragraph no longer holds: see
'Spec refresh 2026-10-09'). The elixir literature (PA 10) names scarcity, unlimited inventory and unclear purpose as
causes, and caps, abundance and situational need as cures. Here: cargo cells and mass are the cap (CC7),
both designs are cheap (6,000 and 4,000 against 25,000-250,000 for a drive) and stocked at every station
under question `consumable-supply`, AI pilots use them (cut `consumable-ai`) so the player sees what
they do, and each solves a moment (being locked, needing distance) rather than adding a stat the player
could save for a boss. Neither is a permanent advantage: each carries its drawback inside the effect.
X4's lesson (PA 9: flares strong in AI hands, worthless in the player's) is answered by giving the player
and the AI the same rule and the same information (a painted target always knows, `sensor-stat-set`).
**Magnitudes** are authored ranges grounded in the prior art above; follow-up `combat-pace-pass`
measures time-to-kill and engagement distance and tunes them with everything else. No question is
raised on them.
**Cut order.** `consumable-host`, `throttle-lock` and `vapour-cloud` are independent. `consumable-supply`
needs the host (instances). `vapour-cloud-presenter` needs the cloud. `consumables-first-set` needs the
host, the lock and the cloud. `consumable-ai` needs all of those and supply. `modifier-stacking` r2 now
waits on the host.
### Admitted
Receipt `mind-commit-06b3cb76...` (2026-10-08 20:05 UTC). Questions: `consumable-supply` (raised in
consumable-supply), `vapour-cloud-model` (raised in vapour-cloud), `overdrive-steering` (raised in
throttle-lock). Cut specs r1: consumable-host, consumable-supply, throttle-lock, vapour-cloud,
vapour-cloud-presenter, consumables-first-set, consumable-ai; modifier-stacking r2 (adds depends_on
consumable-host) with resolution `cut_spec.cut-modifier-stacking.r1.n1` (Superseded). Follow-ups:
`cloud-vs-missiles-in-flight`, `modifier-magnitude-frozen-at-attach`, `consumable-heat-noop`,
`station-restock`. Claimed keys: BehaviorData union 41 ThrottleLockData, 42 VapourDumpData (keys 1-3).
## Ships add-on frame r5: authoring without the repo
Imagination, agent `imagination-addon-frame`, session `self-2026-10-08b`. Campaign
`aetheria-release`. Builds on `F:\Projects\aetheria-map-addenda-addon-refresh-2026-10-08.md`
(A1 to A14); A14 recorded the tension this page resolves. Numbering continues at B1.
Pinned heads: Aetheria `07dcb580` (branch `eureka/aetheria-release-ships-addon-frame-r4`,
local and on origin; the brief's name `eureka/aetheria-release-ships-addon-frame` does not
exist), CultLib `05e67014` (main), CultLib `45c2f400` (Aetheria's pinned
`CultLibRevision`, `Directory.Build.props:6`). Every line number below is at `07dcb580`.
### Body facts
**B1. What New Ship needs from the repo (source read).** `_new_ship`
(`tools/blender/aetheria_ships/__init__.py:556-605`) finds the repo by walking up for
`tools/AetherDb` (`_aetheria_repo`, 314-325), writes to `<repo>/GameData/Mods/<id>/ship.cc`,
and runs `dotnet run --project <repo>/tools/AetherDb -- ship-authoring create` (`_aetherdb`,
328-336). `create` (`tools/AetherDb/ShipAuthoringCommands.cs:47-67`) reads the reference
hull from `GameData/Aetheria.cc` through `AetherDb.Open()`, whose `FindRoot`
(`tools/AetherDb/AetherDb.cs:26-32`) keys on `Aetheria.Shared/Aetheria.Shared.csproj`, and
builds the hull with `ShipAuthoringStore.HullLike`
(`Assets/Scripts/ServerShared/ShipAuthoring.cs:95-106`): copy every member, reset Name,
Shape (`new Shape()`), Hardpoints, Prefab, Schematic and Visual. `create` does not run the
ship-ID rule; only `Validate` (`ShipAuthoring.cs:189-192`) does.
The same `_aetherdb` port also carries Package's `validate` (`__init__.py:650`), and the
specs in force add `centre` (package-frame r1) and `paint` (paint r3). So the repo and
dotnet dependency is the port's, not New Ship's alone: Package already needs both today.
**B2. Where an author's machine gets the shipped catalog (source read).** The game reads
`<install>/GameData/Aetheria.cc` and `<install>/GameData/Mods`
(`Assets/Scripts/Gameplay/ActionGameManager.cs:40,48-49`), with `GameData` beside the
player's data folder. A ship author who has the game has both. No build or release script
in the repo copies `GameData` into a player build (searched `Assets/Scripts/Editor`,
`tools`, workflow files): the runtime contract is the only evidence that a release carries
it.
**B3. Studio needs Unity (source read).** CultCache Studio is
`CultCacheStudioWindow : EditorWindow` (`CultLib src/GameCult.Unity/Assets/Caching/Editor/
CultCacheStudioWindow.cs`), opened from a Unity menu item. Ruling
`authoring-host-thin-imgui-then-thing` keeps it an IMGUI skin until a Thing host after the
release. Studio therefore cannot be the repo-free and Unity-free creator before then.
**B4. The shipped catalog's schema catalog (probe `probes/p1_catalog.py` on the
`07dcb580` LFS object `9e75d9a4...`).** 13 catalog entries, 219 records, 5 hulldata
records (LonginusX, Zenith, Turret, Djinni, Longinus; Djinni, Longinus and LonginusX are
`HullType` 0, Ship). It carries an `aetheria.hulldata` entry and **no
`aetheria.ship_authoring` entry**: the C# store writes catalog entries only for types it
holds records of.
**B5. cultcache-py cannot derive a C# catalog identity (probe `p3_schema_id.py` and source
read).** For all 13 C#-written entries, `content_hash == sha256(canonical_schema_json)`
but `schema_id != sha256(canonical_schema_json)`: C# derives the id from a semantic
fingerprint over its member descriptors (`CultLib src/GameCult.Caching/CultCache.cs:
655-658`), with type names from `CultSchemaTypeNames.FromType`. cultcache-py's
`DocumentDefinition.catalog_entry` (`packages/cultcache-py/src/cultcache_py/documents.py:
105-143`) defaults the schema id and content hash to the type name, and its store's
default entry for an unregistered envelope is a stub with no members
(`stores.py:356-368`). Python can carry a C#-written entry through a write; it cannot
author one.
**B6. Encode and decode probe on Yggdrasil (`ygg-verify.sh`, image
mcr.microsoft.com/dotnet/sdk:10.0, Aetheria `07dcb580`, CultLib `45c2f400` staged,
logs `scratchpad/probes/ygg-create-1.log` and `ygg-publish-2.log`).** Reference:
`dotnet run ... ship-authoring create /tmp/o/dn/ship.cc probe.a "Probe A" --like Djinni`.
| Candidate | catalog hulldata | catalog ship_authoring | hull record | ship record | C# `inspect` |
|---|---|---|---|---|---|
| cultcache-py, no template (`py_create.py`) | identical (copied from the shipped catalog) | **differs**: stub, schema id `aetheria.ship_authoring`, version `aetheria.ship_authoring.v1`, members `[]` | equal values | identical bytes | loads: `probe.a: Probe A, 0 hardpoints` |
| cultcache-py with the C# file's ship_authoring entry | identical | identical | **equal values, different bytes**: 20 `float32` (`0xca`) slots re-encoded `float64` (`0xcb`), 204 to 284 bytes | identical | loads |
| published AetherDb (B7) | identical | identical | identical | identical | loads |
`stored_at` is ignored throughout. `new Shape()` encodes `[[1, 1, [true]]]`. The C# reader
accepted the stub catalog by schema name (compatible drift, `CultCache.cs:518-522,
803-830`), reporting every slot as `defaulted_missing_slot` while still decoding the
payload's values: that warning text and the observed behaviour disagree, so a file that
relies on it relies on a CultLib behaviour nobody owns. The float widening is the same in
today's `ship_cc.replace_layout`, which re-packs the whole hull body on every Save; C#
reads `float64` into `float` (smoke_frame's Package validated after a Rasterise write), so
it is a wire-parity gap, not a corruption (follow-up `py-float-width-parity`).
**B7. A published AetherDb needs neither dotnet nor the repo (Yggdrasil, log
`ygg-nodotnet-5.log`).** `dotnet publish tools/AetherDb -c Release -r linux-x64
--self-contained -p:PublishSingleFile=true` gave one 77,287,607-byte executable (75 MB
folder with pdbs). Run in `debian:bookworm-slim` with no dotnet (`command -v dotnet`:
none), from a folder holding only `GameData/Aetheria.cc` plus an empty
`Aetheria.Shared/Aetheria.Shared.csproj` (the current `FindRoot` marker), `create ...
--like Djinni` took 1.1 s and wrote a file whose catalog and both records are byte-identical
to the dotnet-run reference. The first run aborted with `No usable version of libssl was
found`; with `libssl3` and `libicu72` installed it ran. Windows needs neither package; the
win-x64 publish was not probed.
**B8. The reserved-name gap is the owner's (same log).** The published tool created
`/out/con/ship.cc` for ID `con` and `inspect` loaded it: the C# rule (`ShipAuthoring.cs:
190-192`) admits Windows device names, and `create` does not run it at all (B1).
**B9. Downstream specs read (mind, 2026-10-08).** package-frame r1 says "after frame r4 has
merged" and its recentre test edits cells and Packages with no Save; mounts r4's Package
writes derived Positions straight to the .cc through `replace_layout`; gizmos r2's drag
release writes through `replace_layout` from an interactive (undoable) tool. bake r1,
guards r1, mesh-mounts r1, mask-bakes r1 and paint r3 do not depend on what r5 changes
(paint's `paint` command goes through the `_aetherdb` port, which the tool cut moves).
### The owner answer
**What can create and write a ship .cc on an author's machine without the repo or
dotnet?** Two things, by probe: cultcache-py, if a C#-written `aetheria.ship_authoring`
catalog entry is handed to it (B5, B6); or AetherDb itself, published self-contained
(B7). Studio cannot before the Thing host (B3).
Recommendation: the published AetherDb (question `authoring-tool-without-repo`, option
`published-aetherdb`). It keeps C# the single owner of every ship-package rule the add-on
calls (HullLike, the ID rule, Validate, CenterOfMass, Paint) and removes the repo and the
SDK from all four commands at once, which the Python route does not: Package, centre and
paint would still need the repo. The Python route needs a second copy of HullLike's reset
list and a pinned catalog entry, and widens floats until CultLib fixes B6. The repo-free
reference hull is the author's own game install (`GameData/Aetheria.cc`, B2).
Prior art (from knowledge; no Eyes pass was run in this budget, so confidence is medium):
game modding kits that keep one rule owner ship the game's own code compiled to modders
rather than re-implementing it in the content tool: Valve's Source SDK ships compiled
studiomdl, vbsp and vrad; Bethesda's Creation Kit is built on the engine's record code;
games that ship no tool (Factorio, KSP, Minecraft data packs) validate content at game
load, which Aetheria also does (`ShipModCatalog.ResolveCatalog` excludes a bad package and
names it). Blender add-ons that drive external compilers (the Source Engine tools add-on,
io_scene_valvesource, calling studiomdl) take the executable's path as a preference.
### Authority map, r5
- **Owner of the .cc on the author's machine:** two explicit, non-undo writers. Save
Layout owns cells and hardpoints (and the first Save of a new ship creates the file
through the published AetherDb `create`). Package owns the visual (model asset, anchors,
lines) and, from mounts r5, saves the layout buffer through Save's writer. C# (through
the published AetherDb) owns HullLike, the ship-ID rule, Validate, CenterOfMass and Paint.
- **Owner of the frame:** the .blend. The layout buffer (`scene.aetheria_layout`),
`aetheria.grid_origin` with the grid size it was computed for, Ship Root and the objects
under it all live in the .blend, so Blender's undo moves them together.
- **Demotions.** Rasterise and Flip Nose are no longer .cc writers; they propose cells
into the buffer. New Ship is no longer a .cc writer; it binds a pending ship. The repo is
no longer an input; the game data folder is. `dotnet run` is no longer a path.
- **Package and an unsaved buffer.** Package refuses while the bound collection's buffer
differs from the .cc ("Save Layout first"), so it never packages cells the author has
not saved. The comparison is ship_cc's own layout revision of the buffer against the
revision Load or Save recorded. Mounts r5 derives Positions into the buffer after that
check and saves them through Save's writer.
- **One chooser** decides which collection is the ship; Bind and New Ship accept an
unbound collection through it, everything else requires a bound one.
- **One frame predicate:** the ship's meshes are Ship Root's descendants outside Source
and Generated with no `aetheria.role`. Rasterise reads them; Flip Nose turns Ship Root's
children; package-frame's recentre moves Ship Root. A mesh in the bound collection that
is not under Ship Root is refused by Rasterise, Flip Nose and Package with a count, never
silently left out.
### Rationale
**Why Save, not the UNDO operators, writes the .cc.** Blender's memfile undo restores the
.blend and cannot restore a file on disk. Any undoable operator that writes the .cc splits
the grid's truth (finding `undo-splits-origin-from-cells`), and dropping the UNDO flag does
not help: the next undo push still restores the scene to the step before. So no undoable
operator writes the disk, and the .cc changes only on an explicit Save or Package. This is
the editor-buffer pattern Blender itself follows for external files (images are edited in
memory and saved with Image > Save; the .blend is saved explicitly).
**Why New Ship creates nothing on disk (`new-ship-orphans-cc`).** Creation is deferred to
the first Save, which creates and fills the file in one operator: `create`, then the
buffer through `replace_layout`, and on any failure after `create` it removes the file and
the folder it made. Nothing persists unless the ship is valid enough to save, and an undone
New Ship leaves nothing to orphan.
**Grid placement against outside edits.** Studio can resize the grid. `grid_origin` is
stored with the width and height it was computed for, and the Grid is drawn only while the
buffer has that size; otherwise the panel asks for Rasterise again. This keeps a stale
origin from placing cells, without moving the origin into the .cc (the runtime derives
alignment from the centre of mass after package-frame and never needs it).
**Errors (`create-failure-echoes-inputs`).** Operator reports carry fixed text per failure;
AetherDb's own output goes to the system console, not the report.
**Ship IDs (`ship-id-allows-reserved-names`).** The rule stays C#'s
(`ShipAuthoringStore`), gains the Windows device names (con, prn, aux, nul, com1-9,
lpt1-9, alone or before a dot) and is run by `create`. New Ship checks it before anything
moves through `ship_cc.valid_ship_id`, whose constants ShipSchemaPinTests pins to the C#
rule, as it already pins the slot numbers.
**Rejected.** Studio owning creation (needs Unity until the Thing host, B3). A local
encoder or catalog entry in the add-on derived by hand (B5: Python cannot derive C#'s
schema id; the brief forbids it). Relying on cultcache-py's stub catalog entry (B6: the C#
reader's behaviour contradicts its own warning). A framework-dependent publish (it needs
the .NET runtime installed, which is the install the ruling removes).
### Model page rows (changed)
| Kind | Named by | Over time | Decides |
|---|---|---|---|
| Ship .cc | `GameData/Mods/<id>/ship.cc` under the author's game data folder | created by the first Save of a pending ship; changed by Save, Package, Capture Lines | Save (layout), Package (visual), C# rules through the published AetherDb |
| Layout buffer | `scene.aetheria_layout` | filled by Load or New Ship, edited by Rasterise, Flip Nose, Resize and hardpoint ops, undoable | the author; persisted only by Save |
| Grid placement | `aetheria.grid_origin` = [x, y, width, height] on the collection | written by Rasterise only, undoable | Rasterise |
| Pending ship | `aetheria.pending` = [name, reference] on the collection | set by New Ship, cleared by the Save that creates the file | New Ship, then Save |
## Flight control allocates thrust
Pass: Imagination, session `self-2026-10-09-morning`. Body: `GameCult/Aetheria` origin/master `80d2c7f3`,
detached worktree, removed at the end. Ruling: `flight-control-allocates-thrust` (operator words in its
`operator_quote`). Prior art: `F:\Projects\aetheria-thrust-allocation-prior-art-2026-10-09.md` (Eyes; cited
as PA). This section supersedes the body facts, consumer audit and Cuts 2-3 of `docs/locomotion-cut.md`
(2026-09-22), which predate the aether drive's deletion (`2cfdc728`) and the LookDirection-to-Turn change.
That file's Cut 4 (AI heading planner) and Cut 5 (combat facing) are carried as follow-ups, not as specs.
### Body facts
- **TA1. The plant.** 2D translation plus kinematic yaw. `Thruster` is the only propulsor
(`Ship.cs:138-139`). Per unit throttle a thruster adds `-push * Thrust / Mass` to velocity along its mount
and turns `Direction` by `Torque * Thrust * TorqueMultiplier / Mass` rad/s (`Thruster.cs:127-130`).
`Torque` is the sine of the moment arm (`Thruster.cs:65-70`). `Axis` saturates to [0,1]
(`Thruster.cs:46-50`). Authored `TorqueFloor` 0.5, `TorqueMultiplier` 0.1 (`Settings.asset`, read by
the probe through `AuthoredSettings`).
- **TA2. The mixer and its satellites** (`Ship.cs` at `80d2c7f3`): bucket sets `:33-40`, built in
`Activate` `:97-116` from mount rotation and from `Torque` against `TorqueFloor`; aggregates `:51-60`;
`TurnTime` `:62-67` (no caller); `ItemDestroyed` pruning `:121`, `:124-134`; `RecalculateThrust` and six
`Recalculate*` `:150-227`; the mixer `:236-259` (strafe trim with an unguarded division by the trim
count at `:237`, `:246`). Readers outside `Ship.cs`: the aggregates only in
`EvasionTermTests.cs:708-709` and `:997-1056`; `TurnTime` none; `IAnalogBehavior`
(`Behaviors.cs:96-99`) only `Thruster.cs:40` and a comment at `Entity.cs:2343`. `Thruster.Manoeuvre`
(`Thruster.cs:98-114`) is the envelope's per-thruster term and the only other `TorqueFloor` reader in
source (`:109`); tests read the floor at `EvasionTermTests.cs:331-332,383-384` and
`SteeringTests.cs:304-305`.
- **TA3. The Duel ship, probed** (scratch test in `RunStartTests`, Yggdrasil job, CultMath `614fd445`,
2026-10-09). Longinus, mass 4235, four thrusters. Per unit throttle (starboard m/s^2, forward m/s^2,
clockwise deg/s):
| Thruster | Cell, mount | Thrust | Column |
|---|---|---|---|
| Large Drive | (1,0) Reversed | 228441 | (0, 53.94, +49.24) |
| Large Drive | (3,0) Reversed | 259158 | (0, 61.19, -55.86) |
| Talaria | (2,14) CounterClockwise | 123144 | (+29.08, 0, +166.26) |
| Talaria | (3,14) Clockwise | 92312 | (-21.80, 0, -124.63) |
The two drives share a design and a lot quality (0.300) and still differ by 13% in live thrust, so
held forward thrust is a 6.6 deg/s turn. The two Talarias have equal torque-to-sideways ratios, so
together they cannot make a pure torque: any turn on this hull pushes it sideways.
- **TA4. Allocation probe** (same job; `player.Update` stepped alone at 1/60 s; a scratch hook replaced
the mixer with a `BoundedLeastSquares` solve; rows normalised per axis by the box extreme, ridge 1e-3;
"prio" is a yaw row weight of 1000; heading in degrees, clockwise positive; axes are the four throttles
in the table's order):
| Case | mixer | WLS w=1 | WLS w=3 | prio |
|---|---|---|---|---|
| forward, intact, 2 s heading | -12.09 | -6.41 | -1.38 | **0.017** |
| forward, strong drive off, 2 s | 97.41 | 59.06 | 14.24 | **-0.014** (axes .92 0 0 .36) |
| forward, weak drive off, 2 s | -109.65 | -56.22 | -11.47 | **0.026** (axes 0 .90 .29 0) |
| forward, nose Talaria (2,14) off, 2 s | -12.09 | -8.83 | -2.97 | **0.012** |
| strafe right, 2 s | 0 (no strafe at all) | 181.39 | 39.29 | **0.023** (axes 0 .85 .28 0) |
| forward + Turn 1, mean deg/s | 159.9 | 94.2 | 189.0 | **214.9** (axes 1 0 1 0) |
| Turn 1 in place, mean deg/s | 166.1 (axes 0 0 1 0) | 97.8 (.57 0 .42 0) | 188.9 (1 0 .84 0) | **214.9** (1 0 1 0) |
| Turn -1 in place | -124.6 | -81.8 | -158.8 | **-179.6** |
| Turn 1, Talaria (2,14) off | 0 (stuck) | 12.4 | 36.8 | **48.8** (drive only) |
| intent zero | 0 | 0 | 0 | 0 |
Box extremes: clockwise 215.5 deg/s, counter-clockwise 180.5 deg/s, forward 115.1 m/s^2, starboard
29.1, port 21.8. A yaw-first solve holds heading to hundredths of a degree whatever is lost; a weighted
solve at the old map's default (w = 3) leaves a drift the ruling forbids. Solve cost: 14.2 us mean
(including per-call allocation, n = 4), at most 11 active-set iterations over 2000 random intents.
- **TA5. The Execute gate drops small balancing throttles.** `Thruster.Execute` and `PowerRequest` act only
above throttle .01 (`Thruster.cs:87,124`). Probe: weighted solve, full forward, 10 s: heading -7.49 deg
with the gate, -4.56 without; forward 0.3, 10 s: -0.213 deg with, -0.028 without. A balancing throttle
below .01 on a 166 deg/s Talaria leaves up to 1.7 deg/s unbalanced.
- **TA6. A power-gated column deadlocks.** `PowerRequest` is proportional to the throttle
(`Thruster.cs:87`) and `PowerBus.Step` grants after `Ship.Update` (`Entity.cs:1463`), so a column zeroed
for `PowerSupply <= 1e-4` gets throttle 0, requests nothing, and is never granted. The column's thrust is
therefore `EvaluateNominalPower(Thrust)` (full grant; heat, durability, quality and modifiers live,
`Entity.cs:1856`) and its liveness is `Item.Active` only. A browned-out thruster still under-delivers
after the solve (follow-up `allocator-brownout-columns`).
- **TA7. The Duel arena moves the player by itself.** With forward thrust the player's velocity swings
between about +28 and -28 m/s along the nose every ~60 ticks with no `VelocityLimit` clamp (limit 100,
drag 0.1), while intent zero leaves it at rest. Translation measured in the Duel is not thrust; tests that
measure translation use `RestoredHullsTests.BuildThrustedShip`. Traced 2026-10-10 (TW1 below): a
harness artifact of stepping `player.Update` without `Zone.Update`, which leaves every orbit at the
origin and stacks the zone's gravity wells on the spawn.
- **TA8. CultMath 0.3.0 re-rolls generated content.** Full suite at `80d2c7f3` with only
`CultMathRevision` moved to `614fd445` (tag `cultmath-unity-v0.3.0`): 1057 pass, 5 fail.
`TutorialGalaxy_unchanged` (faction homes differ), three `RestoredHullsTests` that pick zones by name
(`RestoredHullsTests.cs:624,740,807`: "EAC-7089", "EAC-2733" no longer exist) and
`ACoastingAgileShipIsEasierToHitThanTheSameShipJinking` (hits 95 jinking against 78 coasting; its target
is `stage.Generated("Djinni")`, `EvasionTermTests.cs:520`, so the target's fit follows the galaxy). The
cause is 0.3.0's breaking `snoise` kernel (its CHANGELOG), read in C# by `Settings.cs:72-73` (galaxy
cloud density), `GlobalData.cs:30` and `Environment.cs:132,144`. Shaders use the GPU Noise plugin, not
CultMath (`Background.shader:39`), so nothing on the GPU moves. Every other DemoTerminus test passes.
Between the tags CultMath also adds `phacelle` (MPL-2.0 files), `cellular`, `smin_grad`, the `*_grad`
noises and `BoundedLeastSquares`; no existing signature changes.
- **TA9. The pin guard.** `Directory.Build.targets:21` conditions `VerifyCultLibRevision` on a
backslash-spelled project identity; `docs/mining-cut-refresh.md:155-157` infers it never fires on Linux.
Yggdrasil's `~/eureka-verify/pins` holds `cultlib-45c2f40` and `cultlib-6d5e209` only;
`~/eureka-verify/repos/CultLib.git` contains `614fd445`.
### Authority map
- **Owner:** `ThrustAllocator` (new, one per `Ship`, stepped in `Ship.Update`) turns intent into every
thruster's throttle.
- **Inputs:** intent (`MovementDirection`, or full forward while `Entity.ThrottleLocked`; `Turn`), and one
column per thruster: its body push and turn per unit throttle at full grant, zero when the item is not
`Active`.
- **Outputs:** `Thruster.Axis` for the tick, read only by `Execute`, `PowerRequest` and presentation.
- **Derived:** the box extremes (what intent is a fraction of) are derived from the columns each tick; the
envelope becomes the same extremes (cut `envelope-from-columns`). The warm start is command state, never
truth.
- **Demoted or dead:** the bucket sets, aggregates, strafe trim, `TurnTime`, `RemoveThruster` pruning and
`IAnalogBehavior` are deleted. `TorqueFloor` is no longer an owner of anything; it dies with
`Thruster.Manoeuvre`. A thruster's capability is no longer classified by mount or by a floor; it is its
column.
- **Forbidden writers:** any `Axis` write outside `Ship.Update`; any rule that classifies a thruster as
"for" rotation or translation; any second solve or fallback mixer; any liveness set kept across ticks.
- **Shared paths:** player helm, agents, the throttle lock and tests all reach the throttles only through
intent.
### Model page rows (changed)
| Kind | Named by | Over time | Decides |
|---|---|---|---|
| Thruster column (runtime) | the `Thruster` behaviour | rebuilt every tick from live stats and `Item.Active` | `ThrustAllocator` reads; item state stays `EquippedItem`'s and `PowerBus`'s |
| Allocator warm start (runtime) | the ship's `ThrustAllocator` | last tick's throttles; reset when the thruster count changes | `ThrustAllocator`; never saved |
| `GameplaySettings.TorqueFloor` (authored) | field name | deleted by `envelope-from-columns`; the `Settings.asset` line is left for Unity to drop (it lands in `AuthoredSettings.Unplaced`) | nobody after the cut |
### Rationale
- **Yaw before translation for a hold.** The ruling forbids yaw from asymmetry, and TA4 shows a weighted
solve cannot deliver that at any weight that also lets a turn trade: holding is exact only when the yaw
row dominates. Prior art agrees for spacecraft-like plants: MechJeb weights torque 200 times translation,
and the Space Engineers allocation proposal serves rotation authority first (PA A3). Multirotors do the
opposite (PX4 and ArduPilot sacrifice yaw, PA A2) because yaw is not their stability axis. Translation
first was rejected: it reproduces the mixer's drift. What gives under saturation is translation, so a
damaged ship crabs rather than spins; the operator expected exactly that ("crab walking just became
optimal", `docs/locomotion-cut.md:25-29` at `80d2c7f3`).
- **What a full turn spends is the operator's call** (question `turn-authority`), because the operator's
2026-09-22 words on rotation demand (`docs/locomotion-cut.md:82-87` at `80d2c7f3`) and TA4's numbers pull different
ways.
- **One solve with a large yaw weight, not two sequential solves.** The large-weight form approximates
sequential least squares (PA A1, Schofield) with one call, deterministic and allocation-free; a yaw
weight of 1000 against a ridge of 1e-4 keeps the conditioning near 1e5, inside the range the solver
documents as exact (`BoundedLeastSquares.cs` remarks).
- **The ridge** makes the optimum unique, so the warm start never changes the answer and two equal
thrusters share a demand. It costs 0.01% of full thrust.
- **No actuator interface.** One kind of actuator exists. The allocator takes columns and returns
throttles, so a vectored row (follow-up `vectored-thruster-rows`) is more columns: a row tilting within
+-theta becomes an axial variable in [0,1] and a lateral variable in [-sin theta, sin theta], and its slew
rate narrows the lateral variable's bounds each tick. Bounds stay boxes, which is what the solver takes;
the row maps its two solved variables back to a throttle and an angle.
- **The pin bump is its own cut.** The solver exists only from 0.3.0, and 0.3.0 re-rolls generated content
(TA8). Backporting the solver to a 0.2.5 was rejected: CultLib releases from main, and a branch release
would carry the old noise kernel's seam bug forward. `no-save-compatibility-before-players` covers the
re-rolled galaxy.
- **Order.** `cultmath-0-3-pin`, then `thrust-allocator-core`; `throttle-lock` lands on the mixer as
specified, then `thrust-allocator` deletes the mixer, keeps throttle-lock's one intent line as the
allocator's input and rewrites throttle-lock's two mixer-shaped tests in allocator terms. One owner of
`Ship.Update`'s thruster block at every step.
### Revision: translation error costs linearly (core r2)
Pass: Imagination, session `self-2026-10-09-morning`, after Hands stopped on `cut-thrust-allocator-core.r1`
(question `allocator-translation-cost-l2-has-no-onset`, ruling `allocator-l1-translation-cost`, operator
ruling `turn-authority-full`). Probes ran the real `CultMath.BoundedLeastSquares` (CultLib `614fd445`, the
pinned 0.3.0) in a scratch console on Yggdrasil, logs `l1probe1.log` to `l1probe3.log` in the session
scratchpad (`imag-allocator-r2`). Column sets: TA3 (Duel Longinus); TA3 with the clockwise Talaria zeroed;
a symmetric Djinni-like hull (mains (0,50,+-20), fore laterals (+-20,0,+-80), aft laterals (+-20,0,-+60),
bow (0,-30,0)); drives 0.3% apart for the tiny-imbalance hold.
- **TA10. A squared translation cost has no onset** (Hands' probe, confirmed): r1's TA-R2 mixes the
drive into a turn from Turn 0 for any weights, so it cannot meet `turn-authority-full`.
- **TA11. The split form works only when the solver resolves it.** Variables: throttles and four error
variables e+x, e-x, e+y, e-y in [0,2]; rows H*(B_r u / s_r - e+ + e-) = H d_r / s_r, the yaw row
Wy * B_z u / s_z = Wy d_z / s_z, a cost row w*e = -w*kappa per error variable (linear slope
L = 2 w^2 kappa, quadratic part at most e/(2 kappa) of it), a ridge row rho*u per throttle.
At the solver's fixed KKT tolerance (1e-6 of the gradient at the origin) every one of 84 settings in two
grids failed somewhere: Hands' (H 300, Wy 1, w .01, kappa 2, rho .01) and the first choice (H 100, Wy 10,
w .01, kappa 10, rho 1e-3) gave all-zero throttles for move (0,1) and for a strafe, because the cost
signal L is below the tolerance against the H^2 and Wy^2 terms; larger L settings served translation
but gave throttles that depended on the warm start by up to 0.62 (ties the ridge cannot resolve below
the tolerance). The chain the form needs, tolerance << rho^2 << L << H^2, Wy^2, with L/H^2 about 1e-4
for a sharp onset and L/Wy^2 about 1e-6 for the hold, spans more than the 1e6 that tolerance leaves.
- **TA12. With the tolerance at 1e-12 everything holds** (`l1probe3.log`, same grid, scratch change of
the constant): warm-start difference 0 in every setting, IterationLimit never reached, at most 10
iterations. Chosen H 100, Wy 1000, w .3, kappa 10, rho .1:
| Check | Result |
|---|---|
| Duel onset, clockwise / counter-clockwise | .772 / .691 (columns: 166.26/215.50 = .7715, 124.63/180.49 = .6905) |
| Duel, clockwise Talaria lost | drive from Turn > 0, Turn .1 gives drive .0999; Turn 1 gives 49.23 deg/s |
| Symmetric hull | no translation up to Turn .88 (pure-torque pairs, then mains cancelled by the bow); strafe .95 of the extreme |
| Hold, forward, yaw rate (deg/s) | intact -.0003, strong drive off .0003, weak drive off -.0002, drives .3% apart .0003 |
| Forward served, move (0,1) | 1.000 of the forward extreme |
| Largest throttle step per .001 of Turn over [-1,1] | .0133 (the steepest column ratio; Duel's drive slope is .0044) |
| Largest yaw residual in the sweeps | .0006 deg/s |
| cond(A), estimated from A^T A | 1.0e4; the solver documents exact results through 3e5 |
Neighbouring settings fail one check each: H 30 leaks the drive at Turn .001; w 1 or 3 leaks it at
.0014 throttle before the onset (L/H^2 too large); rho .03 makes throttles jump up to .12 per .001 of
Turn (near-ties resolved too steeply); Wy 300 lets the hold drift .003 deg/s.
- **TA13. The Duel Longinus does not strafe.** Under the linear cost a starboard strafe would need the
counter-turning drive at full to hold heading, which costs more forward error than the strafe gains,
so move (1,0) gives no throttle. Today's mixer gives none either (TA4). The symmetric hull strafes.
Rationale changes:
- **The solve keeps one call; the tolerance is the solver owner's to expose.** CultMath's
`BoundedLeastSquares.Solve` gets an optional relative KKT tolerance (default unchanged, 1e-6), filled in
CultLib, never worked around in Aetheria; the allocator passes 1e-12 and relies on the solver's
progress rule to stop at its noise floor. Follow-up `cultmath-bls-kkt-tolerance` carries it; Aetheria
takes it by a pin bump in the core cut's first commit.
- **The onset is an ordering, not a label.** Under a linear cost the actuator with the least normalised
translation per unit of turn serves first and the next joins only when it saturates; on the Duel that
is the Talaria, so the onset is the Talaria's share of the turn extreme and moves with the columns
(damage, heat, brownout). Nothing classifies a thruster as attitude or drive.
- The bullets "One solve with a large yaw weight" and "The ridge" above describe r1 and are superseded
by this revision: the yaw weight is 1000 against a ridge of .1, and uniqueness of the answer depends on
the tolerance as well as the ridge.
### Wiring refresh at `08d064c8` (2026-10-10)
Pass: Imagination `imagination-aeth-allocator-wiring`, session `self-2026-10-10-ag`. Body:
`GameCult/Aetheria` origin/master `08d064c8` (thrust-allocator-core merged, CultMath pinned at 0.4.0,
`CultMathRoot` `cultlib-252e226c` on Yggdrasil), detached worktree `C:\aeth-wt\map-articulated`. Probes:
scratch partial `RunStartTests` facts `ImagProbeDuelOscillation` and `ImagProbeCatalogThrusters`, run on
Yggdrasil through the stopgap (logs `j2.log`, `j3.log` in the session scratchpad
`imagination-aeth-allocator-wiring`). The allocator cases patched the scratch clone's `Ship.Update` only:
after the mixer, a static flag overwrote every `Axis` with `ThrustAllocator.Allocate` over columns built
from live `Thrust` (equal to `NominalThrust` on the fully powered Duel ship). Each case is a fresh Duel
launch stepped 240 ticks at 1/60 s; heading in degrees from the launch heading.
- **TW1. The Duel oscillation is a harness artifact** (follow-up `duel-velocity-oscillation`, TA7).
`Orbit.Position` starts at `float2.zero` (`Zone.cs:726`) and is set only by `Zone.Update`
(`Zone.cs:218-223`). Stepping `player.Update` alone leaves all 38 bodies of the Duel's arena zone (4 suns,
4 gas giants, 30 planets, wells 546-9420 m wide) at the origin, which is the player's spawn
(`Duel.cs`: `stage.Player(fighter, float2(0, 0))`). Their wells stack there: `Zone.GetForce` is 0 at the
origin, -64 m/s^2 at (0,5), -982 at (0,20), -8082 at (0,80). Under thrust the ship climbs out and falls
back through the stack, which is the swing TA7 saw:
| Case (Duel player, 240 ticks) | Along-nose speed, max / min (m/s) | Heading at tick 120 / 240 |
|---|---|---|
| A mixer, forward, `player.Update` | 31.5 / -30.6 (first below -1 at tick 36) | -12.09 / -21.94 |
| B mixer, forward, `arena.Update` | 101.7 / 1.9 | -12.09 / -21.94 |
| C mixer, forward, `player.Update`, `GravityStrength` 0 | 101.7 / 1.9 (B to the digit) | as B |
| D mixer, intent zero, `player.Update` | 0 / 0 | 0 |
| E allocator, forward, `player.Update` | 31.5 / -30.8 | .018 / .036 |
| F allocator, forward, `player.Update`, `GravityStrength` 0 | 101.9 / 0 | .018 / .036 |
| I mixer, forward .3, `player.Update` | 12.7 / -12.3 | -3.88 / -7.56 |
| J allocator, forward .3, `player.Update` | 12.7 / -12.3 | .001 / .002 |
The first `Zone.Update` moves the bodies to their orbits: the spawn's height goes from -3316 to -69 and
the field at the player stays under 4.3 m/s^2 for the whole run (case B). The game ticks the zone before
any ship (`Zone.Update` sets orbits first), so play never sees the stack. Neither the mixer, damping, the
tick nor `Steering.Toward` is involved (no `Turn` in A to J; D and C rule out drift and drag).
- **TW2. Wiring is irrelevant to the oscillation and removes the drift.** E and J match A and I in speed
(the well decides it) and hold heading to hundredths of a degree where the mixer turns 6.4 deg/s (TA3,
reproduced at `08d064c8`: -12.09 deg in 120 ticks). Full forward: both drives at 1 and the Talaria (2,14)
at .039 falling to .027 as heat moves the drives, so the ship crabs to starboard at 1.6 m/s at top speed.
Forward .3: drives at .319 and .283, no Talaria. With `Turn` .5 (LaunchDuel's intent) the allocator
turns 107 deg/s (.5 of the 215.5 extreme, Talaria .68, both drives 1); the mixer 77.
- **TW3. Top speed overshoots the limit by one tick of thrust.** Case B settles at 101.4-101.7 m/s against
`VelocityLimit` 100 with `LimitClamp` about 1.7 m/s each tick: the limit clamps before the drives push in
the same tick. Mixer and allocator alike; nothing in this campaign's flight cuts owns the order.
- **TW4. No shipped hull reaches the allocator's large-hull regime.** The catalog holds two Ship hulls:
Djinni, 8 thruster hardpoints (Reversed 3, CounterClockwise 2, Clockwise 2, None 1), and Longinus, 4.
`GameData` holds only `Aetheria.cc`, `Narrative/` and `SoundbanksInfo.json`; no package hull is in the
tree. Finding `large-hull-degradation` starts at about 32 thrusters.
- **TW5. Every thruster's Thrust carries PowerSupply^1.** All seven catalog thruster designs (Large,
Medium, Small Drive, Talaria, Victoire, RevvITup 2.0, deep space burnout) carry `PowerSupply` with
exponent 1 on `Thrust`; none carries it on `EnergyUsage`. All thrusters share `PowerTiers.Medium`
(`Thruster.cs:91`). Under a brownout that grants one fraction to the whole tier every column scales
alike and the solved balance survives; it breaks only where grants differ within the tier. That is the
question follow-up `allocator-brownout-columns` keeps.
- **TW6. What moved since `80d2c7f3`.** `Ship.cs` gained only throttle-lock's two lines (`:236-237`): the
mixer is now `:238-261` inside the gate `:233-262`. `Thruster.cs`, `Behaviors.cs:96-99`,
`Settings.cs:214`, `SteeringTests.cs`, `BrownoutTests.cs`, `ConditionRatioTests.cs`, `SimClockTests.cs`
and `RunStartTests.cs` are unchanged. `Entity.cs`'s commented `IAnalogBehavior` field is `:2360`.
`EvasionTermTests.cs` moved +16: `TheAggregatesReadLiveThrust` `:716-734` (the `ForwardThrust` asserts
`:724-725`), `EveryAggregateAndTheCompensationReadLiveThrust` `:1013-1072`. `ThrottleLockTests.cs`
exists (274 lines, 7 facts and theories); every assertion is an exact mixer `Axis` (1 or 0) on a fixture
whose thrusters are found by mount rotation. `ThrustAllocatorTests.TheStartDoesNotChangeTheAnswer`
(`:253-265`) asserts start independence at 1e-5 on one point while finding `solver-converged-early`
stands; the CultMath stop fix (`cultlib-gaps:cut_spec:cut-bls-release-stop.r1`) owns what happens to it.
Refresh decisions (specs `cut-thrust-allocator.r3`, `cut-envelope-from-columns.r2`):
- **Translation may be measured in the Duel if the arena is stepped.** TA7's rule is replaced by TW1's
cause: a test that reads velocity steps `arena.Update`, or uses `BuildThrustedShip`; a test that reads
only heading may step `player.Update` (heading in A equals B to the digit). The unplaced-orbit defect
is the zone's, not flight's (follow-up `zone-orbits-unplaced-before-first-tick`).
- **`Column(thrust)` from the first cut.** The wiring cut adds `Column(float thrust)` and passes
`NominalThrust`; envelope-from-columns passes live thrust to the same method. r1 had the envelope cut
change the signature, which was churn on one owner.
- **Folded:** `allocator-residual-pins` (a micro-intent test in `ThrustAllocatorTests`). **Left, with
reasons:** `allocator-large-hull` (TW4: no shipped hull is near 32 thrusters; it gates on package hulls);
`allocator-brownout-columns` (TW5: a design question, not the wiring); `steering-feasible-direction`
(reads the envelope's clockwise and counter-clockwise terms, so it follows envelope-from-columns);
`allocator-ai-planner` (plans through the wired allocator, so it follows this cut). Answered by the
wiring when it holds: `asymmetric-drive-torque` (flight control balances the drives, TW2).
## Spec refresh 2026-10-09: scarce consumables, the fog bank, multiply, tiered boosters, the shipped AetherDb
Pass: Imagination `imagination-spec-refresh`, session `self-2026-10-09-morning`, follow-up
`rulings-2026-10-09-spec-refresh`. Body: `GameCult/Aetheria` origin/master `439d488a` (and `07dcb580`, the
frame-r4 branch, for the authoring tool), detached worktree `C:\aeth-wt\imag-refresh`, removed at the end.
Rulings applied (read their `operator_quote` in the mind): `consumables-scarce`, `vapour-cloud-obscuration`,
`boost-stacking-multiply`, `boost-strength-tiered-by-size`, `components-are-roles-for-demo`,
`published-aetherdb-with-game`, `overdrive-keep-steering`; composed with `flight-control-allocates-thrust`
and `turn-authority-full` (section 'Flight control allocates thrust' above). No question is raised: every
choice below is a project default or authored content that follow-up `combat-pace-pass` tunes.
### Body facts
- **RF1. The cloud patch layer (scene and source read).** `ARPG.unity` holds a camera named `Patch`
(GameObject 559840914, local y 10 under the fog camera parent, culling mask 2048 = layer 11 `Patch`
in `ProjectSettings/TagManager.asset`) whose target is `Assets/Resources/Fog Patch.renderTexture`
(guid `c618a6b2...`). `VolumeSampling.cs:69` publishes it as `_NebulaPatch`; `Volumetric.cginc:95-98`
reads it as the patch's vertical thickness: `saturate((-abs(y + disp - FloorOffset) + patch) /
PatchBlend) * PatchDensity`. The one environment is `Settings.DefaultEnvironment`
(`ActionGameManager.cs:216-222`): PatchDensity .35, FloorOffset -20, PatchBlend 25
(`Assets/Resources/Settings.asset:389-392`). What paints the layer today: two quads under
`SectorBrushes`, `Depth Micro` and `Depth Macro`, with `Sector Patch Depth Small/Large.mat` on
`Brushes/Simplex Brush` (additive `Blend One One`, world-space noise, `_Depth` 10 and 20). A fog-bank
brush already exists and nothing references it: `Assets/Prefabs/RPG/Fog Patch.prefab` (layer 11, a quad
rotated to face up, `Fog Brush.mat` on `Brushes/Power Brush`, `_Depth` 80, `_Power` 16, `_Cutoff` 1;
prefab guid `4084aa3d...`, grep of every scene, prefab and asset: no reference). A quad of side 2R
carries the brush's pulse out to radius R (`PowerBrush.shader`: `dist = length(uv - .5) * 2`).
- **RF2. Drawing a zone body ahead.** `DrawAhead.cs:15-18` draws an entity at `Position + Velocity *
Lead`; `KinematicBody.TotalVelocity` (`FloatingBodies.cs:31-32`) is commented "What a presenter draws
ahead with". `MineInstance.Place` draws the latest body with no lead. The mine presenter's hooks at
`439d488a`: `ZoneRenderer.cs:47` (MinePrefab), `:97` (`_mines`), `:227-235` (load and subscribe),
`:259-264` (LoadMine), `:289-291` (ClearZone).
- **RF3. Generation draws (source read).** `LoadoutGenerator` holds its own copy of the caller's
`Random` (`LoadoutGenerator.cs:27`), and the wanderer generator is reused across ships
(`ZoneGenerator.cs:378`), so one extra draw in `GenerateShipLoadout` shifts every later wanderer.
`ItemManager.CreateInstance` rolls lot quality on `ItemManager.Random` (`ItemManager.cs:123`), so
every extra instance shifts later lots. The catalog at `439d488a` holds no consumable record (CC1), so
a consumable step that draws only when a consumable is on offer leaves every current golden untouched.
- **RF4. Death drops carry.** Today `EntityInstance.cs:296-316` drops each cargo item on death; cut
`loot-1` r2 moves this into `Zone.DropLoot` ("every cargo stack drops"). A consumable an AI ship
carries is therefore loot when it dies, through the owner that already decides loot.
- **RF5. Multiply is the rule and nothing pins it.** `StatResolver.ScaleModifier`
(`StatResolver.cs:108-114`) multiplies every attached scale; `ConstantModifier` (`:116-122`) sums.
`StatResolverTests.cs` (455 lines) has no test with two scales on one stat. No spec in force names
`modifier-stacking` in `depends_on` (specs-with-no-report recipe, 2026-10-09).
- **RF6. No player build entry point exists.** No `BuildPipeline`, `BuildPlayerOptions` or
`IPostprocessBuild` anywhere in `Assets/Scripts` or `Assets/Editor` (which holds only
`Epiphany/EpiphanyEditorBridge.cs`). Ruling `adopt-build-cut-1` (2026-10-03) ordered
`docs/build-delivery-cut.md` Cut 1 mapped as one cut; none was admitted, and `ships-player` r2 adds its
Mods staging to "the committed entry point". The game reads `<dataPath>/../GameData`
(`ActionGameManager.cs:37-41`, `:48-49`). `GameData` at `439d488a` holds `Aetheria.cc`, `Narrative/`
(read by `MainMenu.cs:183`) and `SoundbanksInfo.json`, which no C# file reads (grep) and which
`audio-1` retires with Wwise. Addressables: `m_BuildAddressablesWithPlayerBuild: 0`
(`AddressableAssetSettings.asset:61`, PreferencesValue), so whether content builds with the player is
a per-machine preference today. Build-delivery 0.2 (probe, 2026-09-17) found a read-only
`AetheriaStores.Open` of a missing catalog silent; not re-probed at `439d488a` (`AetheriaStores.cs:16-31`
is unchanged in kind), so its test is written first and must fail first.
- **RF7. The install's tool folder cannot be `Tools`.** The repo has `tools/`; Windows paths are
case-insensitive, so a developer publishing AetherDb into `<repo>/Tools` would write into `tools/`.
The install folder is `ModTools/`, gitignored at the repo root.
- **RF8. The add-on anchors at `07dcb580` are r1's** (`__init__.py:29` timeout, `:302-311`
preference, `:314-325` `_aetheria_repo`, `:328-336` `_aetherdb`, `:576-577` New Ship's target;
`AetherDb.cs:26-32` FindRoot, identical on `439d488a`).
### Model page rows (changed)
| Kind | Named by | Over time | Decides |
|---|---|---|---|
| Consumable stock (runtime, station cargo) | the station | rolled once at generation: a station stocks with chance `StationConsumableChance`, then one product, 1 to `StationConsumableUnits` units | `LoadoutGenerator`, through `AvailableProducts` |
| Carried consumable (runtime, ship cargo) | the ship | rolled once at generation with chance `ShipConsumableChance`, one unit; spent by `consumable-ai` or dropped as loot on death | `LoadoutGenerator`; then the consumable host or `Zone.DropLoot` |
| Fog bank (presentation) | its `VapourCloud` | one patch brush per cloud, depth = authored depth x the cloud's opacity fraction, destroyed when the cloud leaves `Zone.Clouds` | `ZoneRenderer`'s one subscription; writes nothing to the sim |
| Player directory | the build output root | made by one `AetheriaBuild` run: player, `GameData/Aetheria.cc`, `GameData/Narrative/**`, `GameData/Mods/**`, `ModTools/AetherDb.exe` | `AetheriaBuild` alone |
| Ship author's game folder | the add-on's one preference | the folder whose `GameData` holds `Aetheria.cc`; `ModTools/AetherDb` is found under it | the author |
### Design tables
**Stat boosters, tiered by size** (ruling `boost-strength-tiered-by-size`). Shape and template per CB-R2
and the r1 table: Tool gear, Targeting Computer `c07ea205`'s EnergyDraw and Heat, then a StatModifierData
Multiplier in the same group; roles amplifier (magnitude) and regulator (EnergyDraw, Heat). The 1x1 keeps
the template's EnergyDraw, Heat, Mass and Price; the 2x2 takes 3x EnergyDraw and Heat and 4x Mass and
Price. Magnitude Min-Max (rises with amplifier quality): 1x1 **1.03-1.08**, 2x2 **1.20-1.40**. Both
designs on one stat multiply (ruling `boost-stacking-multiply`), so a 1x1 and a 2x2 at best quality give
1.512.
| Stat | 1x1 design: products | 2x2 design: products | Cut |
|---|---|---|---|
| ThrusterData.Thrust | Thrust Amplifier: Tailwind (Finch), Second Wind (Lightsail) | Thrust Amplifier Array: Jetstream (Finch), Trade Wind (Lightsail) | boost-gear-systems |
| SensorData.Sensitivity | Signal Amplifier: Earful (Finch), Lookout (AU) | Signal Amplifier Array: Big Ears (Finch), Watchtower (AU) | boost-gear-systems |
| RadiatorData.Emissivity | Emissivity Booster: Cold Read (Adrasteia), Cool Head (Lightsail) | Emissivity Booster Array: Dead Calm (Adrasteia), Cold Storage (Lightsail) | boost-gear-systems |
| ReactorData.Charge | Reactor Overclock: Afterhours (NiteLife), redline (DME) | Reactor Overclock Array: Last Call (NiteLife), meltdown (DME) | boost-gear-systems |
| WeaponData.Damage | Damage Amplifier: Showstopper (Lucent), Heavy Hand (Zhestokost) | Damage Amplifier Array: Encore (Lucent), Iron Fist (Zhestokost) | boost-gear-combat |
| WeaponData.Range | Range Extender: Far Sight (Adrasteia), Long Look (Finch) | Range Extender Array: Horizon Line (Adrasteia), Far Flight (Finch) | boost-gear-combat |
| ShieldData.Capacity | Shield Booster: Thick Skin (AU), SB-2 Bastion (R&D) | Shield Booster Array: Hard Shell (AU), SB-8 Rampart (R&D) | boost-gear-combat |
Both boost cuts are held behind follow-up `role-targeted-adjacent-boosts`: if the operator rules that
boosters address a role (and adjacency), the Target column changes and these specs get another revision.
**The first consumables, strengthened** (ruling `consumables-scarce`: rare, stronger). Changes from the
'Consumables' tables above; everything not named is unchanged.
| Design | Field | Was | Now |
|---|---|---|---|
| Thruster Overdrive | Duration | 5 s | 6 s |
| | Price | 6,000 | 18,000 |
| | Thrust (propellant) | 2.0-3.0 | 3.0-4.5 |
| | TopSpeed (propellant) | 1.5-2.0 | 2.0-3.0 |
| | Thruster Heat, Visibility (regulator, falling) | 4.0-2.5 | 5.0-3.0 |
| Coolant Vent | Duration | 12 s | 15 s |
| | Price | 4,000 | 12,000 |
| | Radius Min | half the Djinni diagonal x 1.25 | x 1.5 (Max stays 1.5x Min) |
| | Opacity (nozzle) | .80-.95 | .90-.98 |
| | Lifetime (coolant) | 10-14 s | 14-20 s |
| | Radiator Emissivity penalty (coolant) | .4-.6 | .3-.5 |
**Supply, scarce.** `StationConsumableChance` .25, `StationConsumableUnits` 2 (one product, 1-2 units),
`ShipConsumableChance` .10, `ShipConsumableUnits` 1. Named constants in `LoadoutGenerator` beside the gear
stock's literal 16, as r1 had them.
### Rationale
**Scarcity through the owners that exist.** "Rare loot and a few stations" needs no loot table: a ship
that carries a consumable drops it on death through the one loot owner (RF4), and only a quarter of
stations stock any. The same roll makes AI use rare, so when the player meets a vent or an overdrive in
an AI's hands it is an event, and killing that ship before it fires is a reason to loot it. Stronger
effects at a higher price follow the ruling; the hoarding risk is the operator's, accepted. The consumable
step draws nothing when no consumable is on offer (RF3), so this cut re-rolls no current fixture; once
`consumables-first-set` lands the generated galaxy re-rolls once, which
`no-save-compatibility-before-players` covers.
**The fog bank is a patch brush.** The operator's words in `vapour-cloud-obscuration`: painting into the
patch layer is the presenter. The volumetric pass already turns patch thickness into fog the ship flies
inside (RF1), so a cloud is one more additive brush on layer `Patch`, and `Fog Patch.prefab` is that brush,
already authored. Its depth is the authored depth times `OpacityAt / Opacity`, so the fog thins exactly as
the sim's obscuration does and is gone when the cloud is removed (opacity reaches 0 at Lifetime). r1's
particle system is dropped: a second renderer for a thing the nebula already draws.
**Multiply needs a test, not code.** The ruling keeps today's arithmetic (RF5), so `modifier-stacking`
shrinks to the tests that pin it, gear with gear and consumable with gear. A rule nobody pins is a rule a
later "balance" patch removes silently.
**The boost tiers.** A strong 2x2 and a modest 1x1 per stat lets cell budget, not a stacking curve, bound a
ship: four cells buy x1.20-1.40 from one array or x1.13-1.36 from four 1x1s (1.03^4 to 1.08^4) at 4x the
power and heat, so at equal quality the array is the better use of cells and power and a hold full of
smalls is the expensive fit. Under multiply nothing else bounds stacking, so these two ranges are the
bound; combat-pace-pass tunes them.
**How the boosts and the overdrive compose with flight control.** A Thrust multiplier (Thrust Amplifier,
Thruster Overdrive) changes only the resolved `ThrusterData.Thrust`. From `thrust-allocator` on, the
allocator's columns are the thrusters' `EvaluateNominalPower(Thrust)` each tick (TA6), so a boosted drive
is a stronger column and the yaw-first solve still holds heading when one drive is boosted and its twin
is not; the evasion envelope reads the same columns (`envelope-from-columns`). The overdrive's lock is
intent (throttle-lock), not throttles: under the allocator "throttle at max" means full forward intent,
and a drive may be trimmed below 1 to hold heading. So no booster or consumable spec writes an `Axis`,
touches `Ship.Update` or `Thruster.cs`, or asserts per-thruster throttles; their tests observe resolved
stats, `ThrottleLocked` and motion, which hold before and after the allocator lands.
**The shipped tool and the player build are two owners, in order.** `ships-authoring-tool` owns where the
add-on finds AetherDb (the game folder's `ModTools/`) and what AetherDb treats as its root (a folder whose
`GameData` holds `Aetheria.cc`). `player-build` owns what a player directory contains, the published
AetherDb included. A self-contained single-file publish was probed at 77 MB for linux-x64 (B7); the
Windows player carries the win-x64 one. One preference replaces r1's two: the tool's path is derived from
the game folder, so the two cannot disagree. `ships-player` r3 drops its own Mods staging line and builds
through `player-build`, so one cut decides the staging list.
**Component records** are owed after the demo cut (ruling `components-are-roles-for-demo`); follow-up
`component-records-after-demo` carries the mapping so it outlives this refresh.
### Admitted
Receipts `mind-commit-d41d568c...`, `mind-commit-dc5c5a2a...`, `mind-commit-c2edfe6d...` (2026-10-09
13:24-13:28 UTC). Revisions, each with the resolution superseding its predecessor: consumable-supply r2,
vapour-cloud-presenter r2, modifier-stacking r3 (shrunk to tests), consumables-first-set r2 (not named in the
follow-up; reshaped by `consumables-scarce`), boost-gear-systems r2 and boost-gear-combat r2 (held),
ships-authoring-tool r2, ships-player r3. New: cut player-build r1, follow-up
`component-records-after-demo`. No question raised.
## Articulated mounts
Mapped 2026-10-09 by Imagination (`imagination-aeth-articulated`, session `self-2026-10-09-ag`) for follow-up
`articulated-mounts`, against `origin/master` `6d83597b`. Re-mapped 2026-10-10 by Imagination
(`imagination-aeth-articulated-r2`, session `self-2026-10-10-ag`) against `origin/master` `acea1aa7`, after the
operator's rulings `mount-arc-gun-traverse` (arc on the hardpoint, traverse on the weapon, the link presentation
only; it supersedes `arc-on-the-hardpoint`, which superseded `arc-and-traverse-on-the-link`) and
`solver-in-gamecult-animation`. Rulings in force: `mount-arc-gun-traverse`, `rig-is-presentation`,
`solver-in-gamecult-animation`, `sim-hooks-presentation-agnostic`, `projectile-flight-is-presentation`,
`ship-roles-are-tags`, `thrusters-radiators-are-meshes`. AM1-AM11 were probed at `6d83597b`; AM12-AM17 at
`acea1aa7`. Every anchor in the specs is exact at `acea1aa7`. Where AM1-AM11 speak of links, read them as the
2026-10-09 model; AM12-AM17 and everything after them are the live model.
**Scope tension for Self.** Target r4's `not_in_scope` lists "articulated mounts". The operator asked for this work
(her hermit-crab hull carries its big gun on a jointed arm). The target needs a revision that takes articulated
mounts out of `not_in_scope` before Hands is briefed.
### Body facts
**AM1. Arc is per hardpoint today, not per weapon.** `FireControl.ArcFor` (`FireControl.cs:36-41`) reads
`HardpointData.FiringArc` (`ItemData.cs:567-569`, key 6) when above zero, else `GameplaySettings.FiringArc`
(`Settings.cs:222`, initializer 120). `InArc` (`:50-58`) passes 360 or more unconditionally. The only shipped writer
was `AetherDb firing-arc-migrate` (`tools/AetherDb/Program.cs:998-1047`), which set 360 on the Turret hull's
Ballistic hardpoints. Twenty-three hardpoint initializers author `FiringArc`, all in tests (`git grep -nE
"[{,] *FiringArc = |^ +FiringArc = "` over tests, Assets/Scripts and tools: 23), and eight accessors read or write
it (`hardpoint\??\.FiringArc|Mount\(s\)\.FiringArc|Hardpoints\[0\]\.FiringArc`: 8). The brief's "per weapon,
default 120, turret 360" is this per-hardpoint rule.
**AM2. Traverse is not simulated; it only prices.** `Solution` (`FireControl.cs:128-152`) says turrets traverse
instantly in the simulation and that link traverse "will make this stateful behind the same signature".
`TrackingRate` (`:238-245`) is the gun's `Weapon.Tracking` times the ship's targeting gear over `UnaidedTracking`,
+infinity for an unauthored gun, and only `PMount` reads it (`:383`, `:427`, `:720`). Follow-up
`articulated-mount-traverse-min` puts the link's traverse inside `TrackingRate` as the slower of the two. These cuts
add no slew state: arc and aim stay derived on every read.
**AM3. The visual record reserves a pivot role nobody uses.** `ShipAuthoringStore.Roles` (`ShipAuthoring.cs:50-58`)
includes `articulation`, "a pivot, never a mount". No add-on code tags it, and `ShipModShips.Assemble` gives every
mod ship `ArticulationPoints = Array.Empty<ArticulationPoint>()` (`ShipModShips.cs:164`). The authoring doc says no
pivot is planned "until pivots move onto `HullData`" (`docs/moddable-ship-authoring.md:188-189`).
**AM4. `ArticulationPoint` is one yaw/pitch pivot with no yaw clamp.** `ArticulationPoint.cs:9-48` slews yaw and
pitch toward `Target` at `Speed` and clamps only pitch. `ShipPrefabAuthoring.cs:139-149` parses
`Pivot.Group.YawMin.YawMax.PitchMin.PitchMax.Speed` but stores no yaw limit, so the Controls section's C9 line
("clamps yaw to YawMin..YawMax") is wrong on this base. A single pivot needs no IK. `EntityInstance.cs:282-289`
gives each articulation group an aim transform, and `LateUpdate` places it (`:410-417`) from
`FireControl.Solution(...).Direction` of the group's first weapon. A link presenter follows that read. The Eyes IK
report (AM11) found the same two facts.
**AM5. The GLB path maps every node that carries `aetheria.id`.** `ShipModCatalog.ReadNodeIds`
(`ShipModCatalog.cs:195-218`) reads node extras, refuses a duplicate id and records whether the node has a mesh; it
keeps no parent relation. `Bind` (`:168-191`) checks each anchor's node. `ShipModVisual.LoadAsync` maps anchors to
Transforms through glTFast's instantiator (`ShipModVisual.cs:51-54`). A joint is an ordinary glTF node, so the same
map reaches it once it carries an id.
**AM6. Probe: the add-on's export keeps an armature, its skin and data-bone extras.** Run on Starfire 2026-10-09
with Blender 5.2.2 LTS at `C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe`
(`--background --factory-startup`). Script `probe_rig_export.py` in the session scratchpad
(`...\scratchpad\imagination-aeth-articulated\`). It builds a collection with a hull cube; an armature
Shoulder > Elbow > Wrist; a cylinder skinned to Wrist through an Armature modifier; an empty `weapon-mount`
parented to the Wrist bone (`parent_type = BONE`) with a muzzle child; a Limit Rotation constraint on Elbow; and
custom properties on the Wrist data bone (`aetheria.role=link`, `aetheria.id=link.claw`) and on the Wrist pose bone.
It exports with the add-on's options (`__init__.py:359-361`: GLB, `use_active_collection`, `use_selection`,
`export_extras`, `export_yup`, `export_apply`). Result:
- One skin with joints `Shoulder, Elbow, Wrist`; the barrel node has `skin: 0` and a mesh. `export_apply` did not
bake the armature away.
- Joints are ordinary nodes in the hierarchy (`Arm > Shoulder > Elbow > Wrist > ClawGun > ClawGun.Muzzle`): the
bone-parented empty is a child of the Wrist joint node and keeps its extras.
- Data-bone custom properties export as the joint node's `extras` (`Wrist` carried `aetheria.role` and
`aetheria.id`). The pose-bone property did **not** export. A tag set by hand in Pose Mode's custom-properties
panel is lost; the add-on must write data-bone properties.
- The Limit Rotation constraint left no trace in the GLB (no extension, no extras). glTF carries no joint limits,
so the add-on must package them.
- The armature object's own custom property exported as extras on the `Arm` node.
**AM7. The ship file carries unknown slots through.** `ship_cc.replace_layout` (`ship_cc.py:161-195`) and
`replace_visual` (`:116-140`) append each row's slots past the known members by mount or anchor id, and edit only
their own slots. `ShipSchemaPinTests` pins the Python slot numbers and member orders to the C# keys and asserts
`HARDPOINT_MEMBERS` has 7 names. A C# cut that adds `HardpointData` key 7 or `HullData` key 33 therefore needs no
Python change: the add-on carries both untouched until the add-on cut names them.
**AM8. The schema version string is not a gate.** CultCache resolves a persisted record by schema name when the
local type has one version (CultLib `src/GameCult.Caching/CultCache.cs:518-523`). `aetheria.hulldata` and
`aetheria.ship_authoring` are both `"1"` (`ItemData.cs:510`, `ShipAuthoring.cs:12`). Bumping either buys no
refusal. A retired slot is guarded by a raw-payload refusal instead, as `RefuseLegacyEmbeddedHull`
(`ShipAuthoring.cs:124-151`) guards ShipAuthoring key 1. Every cut here keeps both versions at `"1"`.
**AM9. Calibre is a footprint band, held only in a test so far.** `WeaponItemData.WeaponCaliber`
(`ItemData.cs:482-483`) runs Small to ExtraLarge (`Enums.cs:77-83`). Cut `caliber-bands` (unlanded) states the bands
(Small 1-2 cells, Medium 3-4, Large 5-6, ExtraLarge 7-9) only inside its test. No balance source names an arc cap
per calibre: AetheriaLore `Game Design/Ship Play Concepts.md` fork 2 (line 24, read in the ship-language
worktree) gives the trade without numbers.
**AM10. In-flight add-on specs that touch arcs.** Among in-force specs with no report, only `ships-addon-gizmos` r3
(arc fan from the row's arc) and `ships-addon-mounts` r5 (row defaults include `FiringArc`; a negative grep on
`.firing_arc =`) name a hardpoint arc. `controls-hud` r2, `controls-ai-bearing` r1 and `munition-shots` r5 read
`ArcFor` or `TrackingRate`, whose signatures these cuts keep.
**AM11. Eyes prior art on IK** (`C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\f24b705c-99b0-4915-8cd0-d31d652edbbf\scratchpad\eyes-ik-prior-art\ik-prior-art.md`).
Damped least squares, min `||[J; lambda I] dtheta - [e; 0]||^2` with the joint bounds as the box, has exactly the
form of CultMath's existing `BoundedLeastSquares`. Every engine Eyes checked (Unity Animation Rigging, Godot 4.6,
Unreal Control Rig) keeps IK in the animation layer; none was found in a math library. Aim-an-axis leaves twist
about the aim free, so a chain aims with two effective degrees of freedom.
**AM12. The sim already matches ruling `mount-arc-gun-traverse`; no sim cut is owed.** Probed at `acea1aa7`.
Arc: `HardpointData.FiringArc` (`ItemData.cs:567-569`, key 6) is the per-hardpoint, per-hull arc, and
`FireControl.ArcFor` (`FireControl.cs:34-41`) reads it, falling back to `GameplaySettings.FiringArc` (120) at 0.
Traverse: `WeaponData.Tracking` (`Behaviors/Weapon.cs:70-73`, key 33, degrees per second, +infinity when
unauthored; ruling `weapon-tracking-authored`) is a weapon stat, and `FireControl.TrackingRate`
(`FireControl.cs:239-245`) is that rate times the ship's targeting gear. Nothing reads a link, and no `LinkData`,
`HullLinks` or `HardpointData.Link` exists (`git grep` over Assets, tests and tools: 0). The only stale text is the
comment at `FireControl.cs:133-134`, which says traverse belongs to the link and cites the superseded ruling.
`ShipAuthoring.cs`, `ShipModCatalog.cs`, `ShipModPlan.cs`, `ItemData.cs`, `ShipModVisual.cs`, `ShipModShips.cs`,
`EntityInstance.cs`, `CultCacheDrawers.cs`, `tools/blender`, `docs/moddable-ship-authoring.md` and the ship tests
are byte-identical between `6d83597b` and `acea1aa7` (`git diff --stat`); `FireControl.cs` gained 9 lines, all
below line 755.
**AM13. Shipped traverse falls with calibre already.** Probe `probe_track.py` (scratchpad
`imagination-aeth-articulated-r2`, cultcache-py over `GameData/Aetheria.cc` at `acea1aa7`), `WeaponData.Tracking`
per weapon item: Small guns 1.5 to 40 degrees per second (ClearPath 12, Earp 8, Spectra 10, ColdFire 6, the two
point-defence guns 40, RainbowLite 1.5); Medium guns 3 to 20 (Autocannon 4, ChargeBlast 3 and 3.5, Flak 20); the
one Large gun (plight) 2; launchers and the mine launcher +infinity. Twenty-two weapon items in all.
**AM14. Shipped hardpoints and arcs.** Probe `probe_arcs.py` (same scratchpad): the catalog holds four hulls. Only
the Turret hull authors an arc: its two Ballistic hardpoints, 8 cells each (2x4, the ExtraLarge band), at 360. Ship
hulls carry weapon hardpoints of 2 cells (Energy, Ballistic) and 3 or 6 cells (Launcher), all at `FiringArc` 0, so
the default 120 applies. Cut `caliber-bands` (unlanded) states the bands Small 1-2 cells, Medium 3-4, Large 5-6,
ExtraLarge 7-9 only in its test (AM9).
**AM15. What CultMath has for the rig today.** `F:\Projects\CultLib\packages\cultmath\src\CultMath`, read
2026-10-10: `quaternion` (`quaternion.cs`) is a plain struct with `identity`, `LookRotation(forward, up)` and float4
conversions; `math.normalize(quaternion)` (`math.cs:225-235`) is the only quaternion operation. There is no
quaternion product, conjugate, axis-angle constructor, vector rotation by a quaternion, slerp, swing-twist split or
joint clamp. `BoundedLeastSquares.Solve(m, n, a, b, lo, hi, x, workspace, out iterations, ...)` minimises
`||A x - b||^2` in a box, dense and allocation-free, with `WorkspaceLength(n)`. That is the solve AM11 names.
**AM16. A moving part needs no record of its own.** The joint tree (AM6: bones export as ordinary nodes with their
data-bone extras) already says which moving part a weapon mount rides: the nearest joint above the mount's node.
A tag on that bone adds nothing the hierarchy does not say, and the arc and traverse the link record carried now
live on the hardpoint and the weapon (AM12).
**AM17. The add-on chain under these cuts is unlanded.** Specs `ships-addon-frame` r5, `ships-addon-mounts` r5 and
`ships-addon-mesh-mounts` r1 have no report; `tools/blender` at `acea1aa7` is the pre-r5 add-on (`_save_layout`
absent; Save Layout writes `FiringArc` from the panel's `firing_arc`, `__init__.py:43`, `:111`, `:174`, `:458`).
The add-on specs below are anchored at `acea1aa7` and name the region to re-find once that chain merges.
### Model page
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Hardpoint arc (`HardpointData.FiringArc`, key 6, unchanged) | The hardpoint's mount id (`Transform`) on its hull. 0 is a fixed mount at `GameplaySettings.FiringArc`. | Authored per hull in the add-on's panel (or Studio for a shipped hull) and written by Save or Package. A package edited after a save changes the arc a run's guns use on Continue; it moves no cell, so it adds nothing to `mod-hull-changed-under-run`. | The ship author. `ShipAuthoringStore.Validate` judges it finite and non-negative and, for a mount on a joint, positive and within the joint chain's yaw reach. `FireControl.ArcFor` is its one reader. |
| Traverse (`WeaponData.Tracking`, key 33, unchanged) | A stat of the weapon item. | Authored per weapon design; any gun goes on any mount (ruling `mount-arc-gun-traverse`). | The weapon designer. `FireControl.TrackingRate` prices it; the rig presenter slews the picture at the same rate. |
| Joint (`ShipJoint` in `ShipAuthoring.Joints`, key 5) | Its bone's `aetheria.id`, which is its GLB node id. Package assigns `joint.<n>` once to a data bone on a weapon mount's chain and keeps it there. | Re-rigging changes only the visual record; every Package and compose judges it again. No save, sim fact or catalog record names a joint. | The author's Blender bone IK settings (lock, limit, min, max), copied by Package. Validate judges the tree and the reach; Bind judges it against the GLB. No joint feeds a sim fact (ruling `rig-is-presentation`). |
| A mount's joint (`ShipAnchor.Joint`, key 5) | On a `weapon-mount` anchor only: the nearest joint above the mount's node; empty for a fixed mount. | Derived at Package from the mount's bone ancestry, as mounted `Position` is derived from the object. Re-parenting in Blender changes it at the next Package. | The Blender hierarchy; Package derives it; Bind refuses a GLB whose hierarchy disagrees. No panel or Studio edit writes it. |
| Rig (the Unity `MountRig`, one per driven joint) | The driven joint's id. | Cache-only: built with the prototype at boot from `ShipModPlan.Rigs` and the rest pose. | Writes only its joints' Transforms. Reads the lead weapon's `GunSolution.Direction` and `TrackingRate`. |
A driven joint is a joint some weapon-mount anchor names. Its chain is that joint and its ancestors up to, not
including, the nearest ancestor that is itself driven, so a turret on a turret solves as two chains and the outer
chain never moves under the inner gun's solve.
**C4 (follow-up `c4-ship-element-ids`).** No link element kind exists for variants: a variant that narrows a gun's
arc is a hardpoint tail-slot patch on `FiringArc`, by mount id, which C4 already plans.
### Authority map
- **Owner.** The hardpoint owns its arc (`FiringArc`), per hull. The weapon owns its traverse (`Tracking`). The
Blender hierarchy owns which joint a mount rides, derived into `ShipAnchor.Joint` at Package. The bone IK settings
own the joint limits, packaged into `ShipAuthoring.Joints`. FireControl owns the aim (`GunSolution.Direction`).
- **Sim inputs.** `HardpointData.FiringArc` and `WeaponData.Tracking`, as on `acea1aa7`. Nothing the rig adds.
- **Outputs.** `ArcFor`, `InArc`, `AimDirection`, `Solution` and `TrackingRate`, unchanged. Presentation reads the
lead weapon's `GunSolution.Direction` and `TrackingRate`.
- **Derived.** `ShipAnchor.Joint`, from the hierarchy. A driven joint's chain, from the joint tree. Yaw reach, from
the chain's degrees of freedom. The rigs and their `AimChain`s (cache-only).
- **No longer owners.** The link is not a record: `LinkData`, `HullData.Links`, `HardpointData.Link`, the Tag Link
operator and link rows are not built (cuts `links-record`, `links-arc`, `ships-addon-link-rows` withdrawn).
`FiringArc` is not retired (cut `retire-firing-arc` withdrawn). The `articulation` role is cut; a joint is not an
anchor.
- **Forbidden writers.** No joint limit, bone length, IK result or Unity transform feeds `ArcFor`, `TrackingRate`
or any sim fact. No panel or Studio edit writes `ShipAnchor.Joint`. No helper invents an arc. The add-on keeps no
copy of `GameplaySettings.FiringArc`.
- **Shared paths.** Validate's reach check and `ShipModPlan.Rigs` define a chain through one helper. Package and
the gizmo drag derive a mount's place through the same hull_grid functions.
### Rationale
**A moving part is a joint, not a record.** The operator ruled the link carries no sim stat (AM12). What remained
of it was a tag and a row holding nothing but an id. The joint tree already says which part a mount rides (AM16),
so the record keeps only what the presenter and the judge need: the joints with their limits, and on each weapon
mount the joint it rides. Moving parts come from armature bones only. A turret built from plain objects is a
one-bone armature; there is no object-link path, so joints have one source.
**A bone with no free axis is not a joint.** Package turns a chain bone into a joint only when its IK settings
leave at least one degree of freedom. A gun on a fully locked bone has no joint above it and is a fixed mount at
its own arc, the default included.
**A gun on a joint states its arc.** The judge checks the chain's yaw reach against the arc, and the judge has no
`GameplaySettings` (the default is a game setting, and the add-on keeps no copy of it either). So a weapon whose
mount rides a joint must author `FiringArc` above 0. Weapons on one joint share one `Rotation` and one `FiringArc`,
because the lead weapon's aim moves them all.
**The reach check is yaw only, and conservative** (unchanged from 2026-10-09). The sim is planar. A chain covers the
arc when the summed ranges of its degrees of freedom whose axis lies within 5 degrees of Ship Root up reach the
arc, capped at 360. Serial parallel axes add, so the sum is exact for the arms the add-on makes. A chain whose yaw
comes only from tilted axes is refused with a message naming the joint.
**Joint limits come from Blender's bone IK settings, not from Limit Rotation constraints** (unchanged). Blender's
own IK honours `lock_ik_*`, `use_ik_limit_*` and `ik_min_*`/`ik_max_*`; glTF carries no constraint (AM6).
**The presenter slews at the weapon's traverse.** It turns its aim toward the lead weapon's
`GunSolution.Direction` (already arc-clamped by the sim) at most `FireControl.TrackingRate(lead, entity)` degrees
per second, the rate the sim prices the angular term with. +infinity is no slew. The sim stays stateless.
**No Aetheria-side solver port.** Ruling `solver-in-gamecult-animation` puts the solve in a pure CultLib package.
The presenter calls that package's API; an Aetheria interface with one implementation would be a second surface
buying nothing a pure, headless-testable library does not already give.
**Calibre and arc.** The operator's own examples put a heavy gun on a wide arc (the crab's claw) and a light gun in
a narrow port, and her ruling says calibre caps are enforced "if at all". The shipped Turret's ExtraLarge
hardpoints sweep 360 (AM14), and the weapon's traverse already falls with calibre (AM13), which prices a heavy gun
on a wide arc through `TrackingRate`. Question `hardpoint-arc-caps` puts the choice to her with no-cap recommended.
Under no-cap, follow-up `caliber-bands-one-table` is moot: the band table stays in `caliber-bands`' test, its only
reader. Under either cap option the judge reads the bands, so they become one table in ServerShared and that test
reads it.
**Thrusters on joints stay a follow-up.** A gimballed thruster changes where thrust points, which is a sim fact the
thrust allocator does not model. Validate refuses `ShipAnchor.Joint` on any non-weapon anchor and Bind refuses a
thruster or radiator node under a joint, both naming follow-up `gimballed-thrusters`.
### The rig's interface to GameCult.Animation and CultMath
This is what `link-rig-presenter` calls; a later cultlib-gaps pass maps the cuts that provide it. Types and
operations only. Angles are radians at this API (the record's degrees convert once, when the presenter builds its
chain).
CultMath (`quaternion`, `float3`; pure and allocation-free):
- `math.mul(quaternion a, quaternion b) -> quaternion`, composing in the Unity.Mathematics convention (`mul(a, b)`
applies `b` first).
- `math.rotate(quaternion q, float3 v) -> float3`.
- `math.conjugate(quaternion q) -> quaternion` and `math.inverse(quaternion q) -> quaternion`.
- `quaternion.AxisAngle(float3 unitAxis, float radians) -> quaternion`.
- `math.slerp(quaternion a, quaternion b, float t) -> quaternion` (shortest arc).
- Swing-twist and joint clamps, which the ruling also gives CultMath. The presenter does not call them (its degrees
of freedom are per-axis boxes); GameCult.Animation may: `math.swing_twist(quaternion q, float3 unitTwistAxis, out
quaternion swing, out quaternion twist)`, `math.clamp_hinge(quaternion q, float3 unitAxis, float min, float max)
-> quaternion`, `math.clamp_cone(quaternion swing, float3 unitAxis, float maxAngle) -> quaternion`,
`math.clamp_twist(quaternion twist, float3 unitAxis, float min, float max) -> quaternion`.
GameCult.Animation (a new CultLib package, netstandard2.1, depending only on CultMath, and published as a UPM package
`org.gamecult.animation` beside `org.gamecult.cultmath` so Aetheria's `Packages/manifest.json` pins it the same way):
- `AimJoint { float3 RestPosition; quaternion RestRotation; }`: rest offset and rotation relative to the previous
joint (the first relative to the chain root's frame).
- `AimDof { int Joint; float3 Axis; float Min; float Max; }`: a unit axis in that joint's local frame and its bounds.
- `AimEffector { float3 Offset; float3 Axis; }`: the muzzle's offset and unit aim axis in the last joint's frame.
- `AimChain.Create(ReadOnlySpan<AimJoint> joints, ReadOnlySpan<AimDof> dofs, AimEffector effector) -> AimChain`:
validates (finite values, unit axes within a tolerance, `Min <= Max`, `Joint` ascending and in range) and throws
`ArgumentException` naming the field, never the value. Exposes `JointCount` and `DofCount`.
- `AimChain.WorkspaceLength(in AimChain chain) -> int`.
- `AimChainSolver.Solve(in AimChain chain, ReadOnlySpan<float> current, float3 targetDirection, Span<float> next,
Span<float> workspace) -> AimSolveStatus` (`Converged`, `IterationCap`, `InvalidInput`): turns the effector's aim
axis toward `targetDirection` (chain root frame) by damped least squares over `BoundedLeastSquares`, every angle
in `next` inside its `[Min, Max]`, twist about the aim left free; deterministic, allocation-free, warm-started from
`current`.
- `AimChain.Pose(in AimChain chain, ReadOnlySpan<float> angles, Span<quaternion> localRotations)`: each joint's
local rotation, its rest rotation times the product of its degrees of freedom's axis-angle rotations in DOF order.
- `AimChain.EffectorDirection(in AimChain chain, ReadOnlySpan<float> angles) -> float3`: the aim axis in the chain
root's frame (forward kinematics), for tests and the presenter's settled check.
Aetheria keeps the glTF-to-Unity axis conversion (`JointAxes.ToLocal`, ServerShared, pure), the slew (planar angle
arithmetic over the existing `math.atan2` and `math.rotate(float2, radians)`), and the `UnityEngine.Quaternion`
conversion at the Transform write.
### Cut order
1. `link-rig-record` r3, no dependency: the joint record, the judge's rig rules, Bind's GLB checks,
`ShipModPlan.Rigs`, and the stale FireControl comment. **First.**
2. `ships-addon-gizmos` r6 after `ships-addon-mesh-mounts`: arc fans from the row's `FiringArc`.
3. `ships-addon-link-package` r3 after `link-rig-record`, `ships-addon-mounts` and `ships-addon-mesh-mounts`:
Package writes the joints and each weapon mount's joint.
4. `link-rig-presenter` r2 after `link-rig-record`, `ships-addon-link-package`, and the CultLib release carrying the
interface above (cultlib-gaps), pinned.
5. `link-calibre-judge` r2: blocked on question `hardpoint-arc-caps`; withdrawn if she rules no-cap.
Withdrawn: `links-record` r1, `links-arc` r1, `retire-firing-arc` r1, `ships-addon-link-rows` r1, and question
`calibre-arc-caps`.
### Admitted
2026-10-09 (`imagination-aeth-articulated`): receipts `mind-commit-b0c6b373...`, `mind-commit-c00a16b5...`,
`mind-commit-08fce00f...`, `mind-commit-06e09a45...`.
2026-10-10 (`imagination-aeth-articulated-r2`): receipts `mind-commit-0b2e932e...` (withdrew links-record r1, links-arc r1,
retire-firing-arc r1, ships-addon-link-rows r1; link-rig-record r3; ships-addon-gizmos r6),
`mind-commit-0c23fa01...` (ships-addon-link-package r2, link-rig-presenter r2, link-calibre-judge r2, question
`hardpoint-arc-caps`, withdrew question `calibre-arc-caps`) and `mind-commit-40788d58...` (ships-addon-link-package r3,
r2's arm smoke pins fixed). context-pack read every anchor SAME at `acea1aa7`.
## Vectored thruster rows
Pass: Imagination (`imagination-aeth-vectoring`), session `self-2026-10-10-ag`. Body: `GameCult/Aetheria` origin/master
`30d02719`; the allocator at `1fc98bdc` (branch `eureka/aetheria-release-thrust-allocator-core`, read-only, a fix
batch in flight); the rig record at `562de34c` (branch `eureka/aetheria-release-link-rig-record`, Soul pass running);
CultMath `BoundedLeastSquares` at CultLib origin/main `50512e6e`. One design owns follow-ups `vectored-thruster-rows`
(operator: "if that dine and dash could tilt that vector for each row of thrusters it would be even more hyper
maneuverable"; a gimbal per row, not per thruster) and `gimballed-thrusters`. Prior art:
`docs/research/thrust-vectoring-prior-art.md` at `30d02719` (cited as VP, by section).
### Body facts
- **V1. A column is linear in the push direction.** The allocator's column is xy = push * thrust / mass and
z = `Torque` * thrust * `TorqueMultiplier` / mass (spec `cut-thrust-allocator.r1`, adds; `ThrustAllocator.cs:8-18`
at `1fc98bdc`). `Torque` is `-dot(normalize(toCenter), float2(1,0).Rotate(rotation))` (`Thruster.cs:62-71`), linear
in the direction vector. So a thruster tilted by angle a has column c(a) = cos a * c(0) + sin a * c(pi/2): two fixed
columns per thruster carry every tilt. This is VP 1.1's virtual-actuator trick, exact for Aetheria's planar plant.
- **V2. BoundedLeastSquares has box bounds only** (`BoundedLeastSquares.cs`, CultLib `50512e6e`: `lo <= x <= hi`,
dense primal active set, iteration cap 100, warm start in `x`). A gimbal wedge |v| <= tan(theta) u is not a box
(VP 2). The design below never needs the wedge: the per-tick slew window in the row's own frame is narrow (3 deg at
90 deg/s and 30 Hz, 12 deg at 360 deg/s), so v is boxed by the sine of the window and the recovered angle is
clamped to the window and the gimbal. No CultMath capability is missing, so no cultlib-gaps follow-up.
- **V3. The sim already has the data seam.** `HardpointData.FiringArc` (key 6) is the hardpoint's arc
(`ItemData.cs:567-569`); `EquippedItem.Hardpoint` gives a behaviour its hardpoint (`Entity.cs:1688`, read by
`FireControl.ArcFor`, `FireControl.cs:36-41`). `HardpointData` uses keys 0-6; `ThrusterData` keys 1-5
(`Thruster.cs:12-27`; `BehaviorData` owns key 0). At `562de34c` the judge refuses a `thruster-emitter` anchor on a
joint, naming follow-up `gimballed-thrusters` (`ShipAuthoring.cs:360-363`), and checks that weapon mounts on one
joint share `Rotation` and `FiringArc` and that the chain's yaw reach covers the arc (`:370-385`).
- **V4. The gimbal is a sim fact.** By V1 the angle changes push and turn, which the allocator and the evasion
envelope read. In V6, fixed rows leave forward and reverse at E 0.361 on the small hull; tilting rows take both to
0.000. So the row angle, its limit and its slew are simulation state and stats (target invariant `sim-owns-facts`);
the joint only draws `Thruster.Angle` (rulings `rig-is-presentation`, `mount-arc-gun-traverse`).
- **V5. Probe harness.** Scratch C# console at `scratchpad/imagination-aeth-vectoring/harness` (local commit
`b5162ab`, then a v2 that fixed M4's scoring and added M3c and M5), `Program.cs` plus a verbatim copy of
`BoundedLeastSquares.cs` from `50512e6e`, run on Yggdrasil through `ygg-verify.sh` (`dotnet run -c Release`, image
`mcr.microsoft.com/dotnet/sdk:10.0`), 2026-10-10, exit 0 both runs (`run1.log`, `run2.log` beside it). The core
solve copies `ThrustAllocator` at `1fc98bdc` exactly (weights 100 and 1000, L1 split error variables, ridge 0.1,
KKT 1e-12, cap 100); every method shares it and shares one demand scale (V7). Hulls: *small*, two mains (accel 10)
and two side rows of four (accel 2, pushing across the hull, gimbal +-45 deg); *large*, four mains and four rows of
eight (36 thrusters). 30 Hz, 90 ticks per intent from rows at 0, 12 intents (full strafes, forward, reverse, two
diagonals, full turns, forward plus turn, strafe plus half turn, two partial). E is the realized wrench's error
against the demand, normalised per axis by the box extreme. REF is a brute-force grid over both row angles
(37 x 37, no slew limit) with the fixed-column solve inside, minimum weighted error.
- **V6. Results** (mean E over the 12 intents after 90 ticks; worst single solve's iterations; us per tick on
Yggdrasil):
| Method | small 90 deg/s | small 360 deg/s | large 90 deg/s | solves/tick | worst iters (large) | us/tick (large) |
|---|---|---|---|---|---|---|
| REF grid (static optimum, shared row angle) | 0.134 | 0.134 | n/a | 1369 | n/a | n/a |
| M0 fixed rows (today) | 0.298 | 0.298 | 0.310 | 1 | 95 | 36 |
| M1 row as one actuator: shared throttle, (u, v) in the row frame, slew-boxed | 0.142 | 0.138 | 0.133 | 1 | 20 | 3 |
| M2 cascade: per-thruster (u, v), one angle per row, re-solve | 0.137 | 0.131 | 0.131 | 2 | 95 | 267 |
| M3 angle step linearised at last tick's throttles, one solve | 0.201 | 0.196 | 0.215 | 1 | 95 | 74 |
| M3c as M3, idle rows linearised at throttle .25 | 0.138 | 0.136 | 0.132 | 1 | 100 (2 IterationLimit) | 79 |
| M5 M1's angle, then per-thruster re-solve | 0.142 | 0.138 | 0.131 | 2 | 95 | 44 |
| M4 per-thruster gimbals (no row constraint; upper bound) | 0.138 | 0.135 | 0.129 | 1 | 89 | 328 |
No method produced NaN. M1, M2, M3c, M4 and M5 reach E 0.000 on forward and reverse (M0 and M3: 0.361). The large
hull's 95-iteration solves are the fixed per-thruster solve itself (M0 is also 95): finding
`cut-thrust-allocator-core.s1.large-hull-degradation`, follow-up `allocator-large-hull`. M1 collapses each row to
two variables (12 on the large hull instead of 36) and stays at 20 iterations, which is the "group identical
columns" remedy that follow-up names, arrived at for free.
- **V7. The demand scale must be the gimbal reach.** The allocator scales intent by box extremes it sums from the
columns it is given (`ThrustAllocator.cs:44-61`). With rows tilting, current-column extremes move every tick, so
"full forward" would change meaning as the rows swing. The probe scales by the reach: per thruster and half-axis,
the maximum of A cos a + B sin a over the gimbal (closed form: sqrt(A^2 + B^2) when atan2(B, A) lies inside the arc,
else the better end). Small hull: +y 31.3 (mains 20, eight row thrusters 2 sin 45 deg each), -y 11.3, +-x 8.0,
+-z 12.53. The same reach is the envelope's box (cut `envelope-from-columns`), so intent and evasion read one
geometry.
- **V8. Zero thrust: hold the angle, but never linearise through the throttle.** Schedule strafe right, idle, strafe
left, forward, idle, reverse, 30 ticks each, both hulls, 90 deg/s. M1 and M5: every step within the window (3.00
deg max), idle drift 0.000 deg, idle throttle 0, no sign flips in the late half of any phase, and reverse ends at
-45/45 (correct). M2: one or two late sign flips, and 3 deg of idle drift on the large hull (its stage one aims
idle rows with v alone). M3 is trapped: a row at zero throttle has a zero angle column, so it never tilts, forward
stays at E 0.361 and reverse ends at 45/-45 (the wrong way). M3c unsticks it but chatters (15 late flips), fires
at full throttle while idle on the large hull and hits the cap. Holding the previous angle when the row's (u, v)
is near zero (|(u, v)| < 1e-6) is enough once (u, v) are free variables: v can aim an idle row without thrust, and
the box's overcount is useful there.
### Model page rows
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Gimbal limit (`HardpointData.FiringArc`, key 6, on a `Thruster` hardpoint; question `vector-row-data-home`) | The hardpoint's mount id (`Transform`) on its hull. Full width in degrees, as for a gun; the row swings +-FiringArc/2 about `Rotation`. 0 is a fixed thruster; thrusters get no `GameplaySettings.FiringArc` fallback. | Authored per hull with the other hardpoint fields; Save or Package writes it; a package edit changes it on Continue (moves no cell). | The ship author. The judge requires every member of a row to share it and the row's joint chain to reach it. The thruster reads it at activation. |
| Row (`HardpointData.Row`, new key 7, string) | Unique within its hull; empty means not in a row. A `Thruster` hardpoint with `FiringArc` > 0 and no `Row` is a row of one. | Authored per hull; renaming a row changes no save (no save names a row; ruling `no-save-compatibility-before-players`). | The ship author. Judge: only on `Thruster` hardpoints; members share `Rotation` and a `FiringArc` > 0. Never derived from a joint (ruling `rig-is-presentation`). |
| Slew rate (`ThrusterData.Slew`, new key 6, `PerformanceStat`, deg/s) | A stat of the thruster design. | Authored per design; heat, durability and quality move it live like `Thrust`. A row slews at its slowest live member. | The gear designer, as a weapon's traverse is (ruling `mount-arc-gun-traverse`). |
| Live row angle (`Thruster.Angle`, runtime) | The thruster behaviour; every member of a row holds the same value. Radians from the hardpoint's `Rotation`. | 0 at `Activate`; moves at most slew x dt per tick; held while the row is idle; never saved (a load restarts at 0). | `ThrustAllocator` decides it; `Ship.Update` is its one writer. `Execute`, the envelope and presentation read it. The rig draws it and never writes it. |
| Row actuator (runtime) | The row, built at `Activate` from its members' hardpoints. | Rebuilt when the thruster set changes (the allocator's resize rule). A dead member's columns are zero, so the row keeps steering with the rest. | `ThrustAllocator` reads the summed along and across columns and the slew window, and decides one throttle and one angle step per row. |
### Authority map
- **Owner.** `ThrustAllocator` decides every fixed thruster's throttle and every row's throttle and angle step, in one
`BoundedLeastSquares` solve (question `vector-row-formulation`, recommended `shared-throttle-row`).
- **Inputs.** Intent (unchanged); each fixed thruster's column; per row, the summed column along and across its live
angle and its slew window [max(-arc/2 - angle, -slew dt), min(arc/2 - angle, slew dt)]; the reach (V7).
- **Outputs.** `Thruster.Axis` for every thruster (a row's members share the row's) and `Thruster.Angle` for every
row member, read by `Execute`, `PowerRequest`, the envelope and presentation.
- **Derived.** The reach and the envelope (one helper); a row's summed columns; the slew window.
- **No longer owners.** `Allocate` no longer sums its box extremes from the current columns; they come from the reach
helper. A joint, its limits and the IK pose decide nothing in the sim.
- **Forbidden writers.** Any `Angle` write outside `Ship.Update`; any read of `ShipJoint`, `ShipAnchor.Joint` or a
Unity transform in `Thruster`, `Ship` or `ThrustAllocator`; a second throttle solve or fallback mixer; an angle step
linearised through last tick's throttles (M3, V8).
- **Shared paths.** Player helm, agents, the throttle lock and tests reach rows only through intent.
`Thruster.Column(angle)` is the one geometry function `Execute`, the allocator and the envelope call.
### Rationale
- **Why one actuator per row.** M1 matches M5 (per-member throttles after the same angle) within 0.002 mean E on all
three sweeps, is within 0.008 of the static optimum, keeps the allocator's one solve (ruling
`one-weighted-solve-no-rotation-demand`; the thrust-allocator authority map forbids a second solve) and is the
cheapest in iterations and time. M2 is the most accurate (0.131) but doubles the variables, reaches 95 of 100
iterations on 36 thrusters and drifts idle rows. What M1 gives up is differential throttle inside a row: on these
hulls it measured nothing, and a row that needs it can be authored as two rows. The operator decides: question
`vector-row-formulation`.
- **Why the limit is the hardpoint's arc and the slew the gear's.** It is ruling `mount-arc-gun-traverse` applied to
thrusters: how far a mount swings is ship authoring; how fast is the part's own stat. Reusing `FiringArc` adds no
field that the add-on, Studio and C4 must learn; its meaning (how far the mounted item may point from `Rotation`)
already fits. Question `vector-row-data-home` holds the alternatives.
- **Why the envelope counts the whole gimbal, not the slew.** The envelope already ignores the time to swing the
hull (cut `envelope-from-columns` sums box extremes); counting slew time only for rows would be a second,
inconsistent time model. If the pace pass finds slow gimbals over-credited, slew time enters the envelope for hull
turn and rows together.
- **Sequence.** Both cuts land after `thrust-allocator` and `envelope-from-columns`, whose `Ship.Update` wiring and
`RecalculateEnvelope` they change, and after `link-rig-record`, whose judge they extend. The rig and the add-on are
follow-up `vector-rows-presentation`: nothing authors a `Row` until the add-on does, so the record cut is inert
data until then.
### Cut order
1. `vector-rows-record` after `link-rig-record`, `thrust-allocator` and `envelope-from-columns`:
`HardpointData.Row`, `ThrusterData.Slew`, the judge's row rules, thruster anchors allowed on joints with the
reach check.
2. `vector-rows-allocator` after `vector-rows-record`, `thrust-allocator-core`, `thrust-allocator` and
`envelope-from-columns`: rows as one actuator each in the solve, `Thruster.Angle`, `Column(angle)`, the reach
helper for demand and envelope. Its `Ship.cs` anchors are written against the merged wiring at Hands time
(the spec says where).
### Admitted
2026-10-10 (`imagination-aeth-vectoring`): receipt `mind-commit-2829a7e1...` (cut_specs `cut-vector-rows-record.r1`,
`cut-vector-rows-allocator.r1`, questions `vector-row-formulation` and `vector-row-data-home`, follow-up
`vector-rows-presentation`) and `mind-commit-e1337a9c...` (`cut-vector-rows-record.r2` superseding r1: its
ShipValidationTests anchor named the wrong class). context-pack read every anchor SAME: record r2 at `562de34c`,
allocator r1 at `1fc98bdc`.
# Lock warning (cut lock-warning) and consumable-ai r2
Imagination imagination-aeth-lock-warning, session self-2026-10-10-ag, 2026-10-10.
Pinned to Aetheria origin/master 08d064c8dce6081bb48e1922a3090c855db2e7c6 (lock-warning)
and to branch eureka/aetheria-release-consumable-ai head f2ecc96a (consumable-ai r2,
not merged to master). For Self to fold into docs/aetheria-release-map.md.
## Body facts (probes and source reads)
LW1. The sim has no fact a ship owns about locks on it. `Entity.TargetedBy`
(Subject) and `Entity.TargetedByCount` (Entity.cs:174-175 at 08d064c8) count ships
whose *selected* target is this one (Entity.cs:255-259). Selecting a target emits
nothing, so that count is not something the targeted ship can sense. Its one
reader is the HUD's music switch (ActionGameManager.cs:1063).
LW2. The HUD's lock bar is the player's own *outgoing* lock: one `LockIndicator`
per own `LockWeapon` (ActionGameManager.cs:1139-1144), driven by `targetLock.Lock`
(noise, frequency, spin; :1440-1452). No incoming-lock presenter exists.
LW3. A lock is built only inside `LockWeapon.Execute` (LockWeapon.cs:81-110): the
lock resets when the target changes (86-90); it grows only on a hostile entity
target while the angle between the shooter's Aim and the target is under
LockAngle (100-105); outside that it decays (106). The lock speed scales with
`Entity.EntityInfoGathered[target]` raised to SensorImpact, the shooter's private
sensor state. `IsLocked` is `_lock > .99` (66).
LW4. Behaviours execute only while their item is Active
(EquippedItem.Update, Entity.cs:1982-1998; consumable effects at 1626-1635).
A behaviour that stops executing leaves its fields as they were, so any
"painting" flag stored on the weapon goes stale when the launcher goes offline.
A warning must therefore be re-asserted each tick by the emitter, never read
from emitter state that might be stale.
LW5. `Entity.Update` returns at once when the entity is inactive
(Entity.cs:1376-1379) and already walks `Zone.Entities` every tick for
hostility (1406-1414). `Deactivate` (772-787) disposes subscriptions and clears
the visibility collections. `Entity.VisibilitySources` (88, decayed in Update
1395-1400) is the existing precedent for state other entities push onto a ship.
LW6. consumable-ai h1 (f2ecc96a) reads `e.Weapons.OfType<LockWeapon>().Any(w =>
w.Lock > .5)` over `ship.VisibleEnemies` filtered to enemies targeting this ship
(Combat.cs:147-148), after the `target == null` early return (Combat.cs:33-34).
Finding reads-enemy-weapon-state: private emitter state, and the unseen-locker
case inverted (a locker the ship cannot see never warns).
LW7. `Entity.TryActivateConsumable` (Entity.cs:895-907 at f2ecc96a) has no
liveness guard, and `CanActivateConsumable` (883-886) admits a Stackable design
every call. A dead or docked ship's agent still ticks (finding
deactivated-ship-spends-cargo); a stack of vents drains one per locked tick
(finding stackable-vent-drained-per-tick).
LW8. ConsumableAiTests (f2ecc96a) holds a lock steady by turning lockers away
after one tick (`HoldLocks`, 204-210). Once warnings come from paints, a locker
looking away is the no-paint case, so every vent fixture must keep its lockers
painting.
## Authority map: the incoming-lock warning
- Owner: `Entity` (ServerShared) owns `IncomingLocks`, the ship's list of
`LockWarning` facts, published once per Entity.Update.
- Emission owner: `LockWeapon.Execute` is the one place that decides a beam is
on a ship (the LockAngle test it already makes). Each tick it paints, it
pushes one paint to the target: `target.ReceivePaint(Entity.Position,
Item?.Data, Lock)`. Nothing else calls ReceivePaint.
- Inputs: paints received since the ship's last Update (emitter position,
emitting gear design, lock strength). Never the emitter's weapon list, target
slot or EntityInfoGathered.
- Outputs: `IReadOnlyList<LockWarning> IncomingLocks`, each with Bearing (unit
xz direction from this ship to the emitter at paint time), EmitterClass (the
emitting gear's `EquippableItemData`, null for a consumable-borne emitter)
and Strength (0..1; content is question lock-warning-content).
- Derived state: the paint inbox is a one-tick buffer, swapped into
IncomingLocks at the top of the receiver's Update and cleared. The list is
empty on an inactive ship and cleared by Deactivate.
- Forbidden writers and readers: CombatState and every Agents/ file read no
`LockWeapon`, `.Lock` or `IsLocked` of another ship; ActionGameManager reads
enemy lock state only through `CurrentEntity.IncomingLocks`.
`TargetedBy`/`TargetedByCount` are not lock facts and nothing new reads them.
- Shared paths: the AI's vent rule (CombatState) and the player's HUD
indicator read the same list: same ship, same information (ruling
same-ship-same-inputs, finding reads-enemy-weapon-state).
- Timing: an emitter that executes after the receiver in tick N is published at
the receiver's tick N+1, so each publish window holds each emitter's one
Execute; a warning lags its paint by at most one tick and never outlives it by
more than one (LW4 is why: no stale flags).
- Deletion line: none in this cut (additive); consumable-ai r2 deletes the
`VisibleEnemies`/`LockWeapon.Lock` read at Combat.cs:147-148.
## Rationale
- Push from the beam, not a scan by the receiver. A receiver-side scan of
`Zone.Entities` for lock weapons aimed at it would re-derive LockWeapon's arc
test (two owners of "the beam is on you") or read a stored painting flag that
goes stale offline (LW4). The push re-asserts the paint every tick it is true,
in the code that already decides it, and the receiver owns only what it
learns.
- Why the receiver is still the owner: the warning is a fact about what this
ship senses (ruling sensor-stat-set item 4), and a later receiver stat
(passive listening gear, the sensor-stat-set cut) belongs here, not on the
emitter.
- Search versus track is not in this cut. The only emitter in the sim is the
LockWeapon, a narrow track beam by nature (item 1). A Mode field would have
one producer and no reader that could ever see the other value. The
sensor-stat-set cut adds Mode when it lands the first wide search emitter.
This cut is the first slice of follow-up sensor-stat-set-cut: the receiver
half of item 4 for the emitter that exists. That follow-up stays open for
power, beam width, intercept factor, passive listening, shared tracks and
search mode.
- What the ship learns about lock progress is a product fork: item 4 lists
bearing, emitter class and search versus track, not progress. Question
lock-warning-content. Recommendation `strength`; see the question.
- Prior art for that fork (from general knowledge, no Eyes pass this time;
confidence moderate): a real radar warning receiver shows emitter type,
bearing, a relative signal strength (closeness and threat, not the shooter's
lock progress) and mode (search, track, launch). Flight games mostly reduce
this to stages: a "locking" tone, a "locked" tone, then a missile alert.
A continuous lock-progress readout is a game abstraction, chosen for the
reaction window it gives.
- The music switch (LW1) is a separate instruments-bound leak and a separate
product choice (gun-only enemies never paint), so it is a follow-up, not part
of this cut.
## consumable-ai r2
- The vent rule reads `ship.IncomingLocks`: vent when any one warning's
Strength passes .5 (the strongest, never a sum), before the `target == null`
return so a ship painted by a locker it cannot see or has not targeted still
vents. The unseen-locker test flips from "does not vent" to "vents".
- `TryActivateConsumable` refuses on an inactive entity: the one path guards
player and AI alike (finding deactivated-ship-spends-cargo).
- The AI does not activate a consumable whose kind already has an active
effect, Stackable or not (finding stackable-vent-drained-per-tick). The
player may still stack by hand; `CanActivateConsumable` is unchanged.
- Tests pin the vent edge at .49/.51, the arc at 14/16 degrees, and an optimum
range distinct from the shortest gun's range (findings
lock-and-arc-bands-unpinned, optimum-range-not-pinned).
- Order: the consumable-only edits land on the branch at f2ecc96a, then Hands
merges origin/master (with lock-warning merged), then rewrites the vent rule;
Combat.cs is untouched by lock-warning, so its anchors read the same after
the merge.
## Admitted (receipt mind-commit-35d69407...)
- cut_spec cut-lock-warning.r1 (base 08d064c8), question lock-warning-content
(raised in it, recommended `strength`), cut_spec cut-consumable-ai.r2 (base
f2ecc96a, depends_on lock-warning), resolution superseding consumable-ai r1,
follow-ups music-reads-targeted-count and lock-warning-audio-cue.
- context-pack: lock-warning 9/9 anchors SAME at 08d064c8; consumable-ai r2
12/12 SAME at f2ecc96a (temporary detached worktrees, removed).
# Visibility rework: signature channels, reveals and sensor stats
Imagination imagination-aeth-visibility, session self-2026-10-11-ag, 2026-10-11.
Pinned to Aetheria origin/master 93b7ff8da0f67d4958cbe4b6e96f1d824c62a629 (lock-warning and
envelope-from-columns merged). Rulings: visibility-per-capability-reveal, sensor-stat-set,
illuminator-beamforming, lock-and-visibility-rethink, launcher-lock-solution-quality,
lock-warning-strength, shared-track-bearing-only, missiles-seeker-retarget, vapour-cloud-obscuration.
Prior art: docs/research/lock-launch-and-graded-visibility-prior-art.md (cited below as PA:line).
This section owns follow-up sensor-stat-set-cut.
## Body facts (probes and source reads at 93b7ff8d)
Paths under Assets/Scripts/ServerShared unless noted.
V1. One signature scalar. `Entity.VisibilitySources` is `Dictionary<object, float>` keyed by the
emitting object (Entity.cs:88); `Entity.Visibility` is the plain sum (161). Writers, each keyed by
itself: hull thermal radiation, keyed by the entity (Entity.cs:1619, summed over border cells
1598-1608); Radiator (Radiator.cs:121, every Update); Reflector cross-section x light
(Reflector.cs:46); Visibility gear (Visibility.cs:44); ConstantWeapon each beam tick
(ConstantWeapon.cs:173); InstantWeapon each round, launchers included because LauncherData creates a
LockWeapon : InstantWeapon (InstantWeapon.cs:280; Launcher.cs:30-33); Thruster input x Visibility,
max-in-place (Thruster.cs:121-123); Sensor ping (`PingVisibility`, Sensor.cs:117). Grep: 8 writer
files, plus tests VapourCloudTests:153, FireControlPerWeaponTests (2), MineTests (1).
V2. Decay. Entity.Update decays every source by `GameplaySettings.VisibilityDecay` and removes it
below 0.1 (Entity.cs:1424-1429). Deactivate does not clear VisibilitySources (780-794).
V3. Heat feeds visibility twice: hull border cells radiate T^HeatRadiationExponent x multiplier into
the entity's own source (Entity.cs:1598-1619); radiators radiate their own temperature x Emissivity
(Radiator.cs:118-121). Thrusters and weapons AddHeat, which reaches visibility only later, through
radiation. Weapons and thrusters also write their own instant source (V1).
V4. One track scalar per pair. `Entity.EntityInfoGathered[other]` (Entity.cs:89), seeded 0 on
activate and zone add (218, 224), removed on zone remove (231), cleared on Deactivate (788). Its only
writer is `Sensor.Execute` (Sensor.cs:156-179): a passive gain from `entity.Visibility` through
Sensitivity x curve(angle) x dt / distance, or a ping gain once per ping when the expanding radius
passes the entity (`_pingedEntities`, 64, 166-170), then decay (176). Both scale by
`Zone.Obscuration` (163). `Sensor.Gain` is the one gain rule (190-193).
V5. Readers of the track scalar: `VisibleEntities/Enemies/Friendlies` cross at
`TargetDetectionInfoThreshold` (Entity.cs:269-285); `FireControl.Designated` needs VisibleEntities
(FireControl.cs:291-298); hit odds `PSensor` (323) and Inspect (415); `IsRevealed` gear tiers
(164-183); `LockWeapon` lock rate (LockWeapon.cs:104); `TargetingSystem` resolution doc
(TargetingSystem.cs:23); ZoneRenderer perspective (ZoneRenderer.cs:552-553); the HUD (V9). About 80
test sites read or write it (grep across 25 test files).
V6. Reveal today is one tier ladder on the track scalar. `FireControl.IsRevealed(observer, item)`
ranks the target's non-hull gear (hardpoint first, then size) and reveals item i of N at
lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i/(N-1)) (FireControl.cs:164-183). Readers:
Entity.TrySelectTargetItem and ResolvedTargetItem (Entity.cs:353, 364), Combat target-item choice
(Agents/States/Combat.cs:45), the HUD subsystem list (ActionGameManager.cs:435), SchematicDisplay's
enemy weapon list (UI/HUD/SchematicDisplay.cs:102). The two thresholds are read only there and in
test fixtures (15 test files set them).
V7. Paints (lock-warning, merged e386e1c4). `LockWeapon.Execute` paints the target each tick it
builds lock inside LockAngle: `target.ReceivePaint(Entity.Position, Item?.Data, Lock)`
(LockWeapon.cs:98-106). `LockWarning {Bearing, EmitterClass, Strength}` (114-130). Entity owns
`_paintInbox`, `_incomingLocks`, `IncomingLocks` (Entity.cs:176-181), `PublishPaints` (799-804) and
`ReceivePaint` (807-811); Zone.Update publishes every entity's paints before any entity updates
(Zone.cs:229-231). A ping paints nothing; no warning carries a mode.
V8. A ping is an omnidirectional expanding radius (Sensor.cs:143-152), heard by nobody: its only
outward effect is the `PingVisibility` bump to the pinger's summed Visibility (117), which every
observer's passive gain then reads like heat. No beam width, no intercept stat, no ally share.
`Sensor.Ping` callers: the player's input (AetheriaInput, ActionGameManager) and, after
faction-play-2, Flight.
V9. The HUD reads other ships' private knowledge. Hostile and friendly indicators fill from
`indicator.Key.EntityInfoGathered[CurrentEntity]`, the other ship's track of the player
(ActionGameManager.cs:1285-1302); the target panel's `VisibilityToTargetFill` reads
`target.EntityInfoGathered[CurrentEntity]` (1330); music switches on `TargetedByCount` (1068-1071),
which counts selection (Entity.cs:174-175, 261-266). The target panel also reads the target's exact
hull and shield (1331-1332). Invariant instruments-bound forbids all four.
V10. The AI reads own-ship facts plus `VisibleEnemies` (Agents/Minion.cs:14,
Behaviors/TurretController.cs:98) and `IsRevealed` (Combat.cs:45). On master no Agents/ code reads
another ship's weapons; the violation in finding cut-consumable-ai.s1.reads-enemy-weapon-state lives
on the unmerged consumable-ai branch (f2ecc96a, Combat.cs:147-148).
V11. `Entity.ChunkInfo` (Entity.cs:383-395) is the settled passive info for a signature at a point:
saturate(sum of `Sensor.PassiveRate(v, p)` over `ActiveSensors()` / (n x TargetInfoDecay)). It is
linear in the signature below saturation. `DetectionReachPerVisibility` (607-619) bounds it.
missile-records r3 plans to move that arithmetic into a static `Sensor.SettledInfo` (its deletes,
Entity.cs:368-369 at 7f51d46d).
V12. Stats and keys. SensorData keys 3-11 (Sensitivity 3, curve 4, PingBoost 5, PingEnergy 6,
PingVisibility 7, PingRange 8, PingCooldown 9, PingDuration 10, PingRadiusExponent 11; Sensor.cs:15-40).
LockWeaponData keys 21-25 (LockWeapon.cs:12-25). VisibilityData keys 1-2 (Visibility.cs).
GameplaySettings is keyAsPropertyName MessagePack (Settings.cs:158-159); VisibilityDecay,
TargetInfoDecay and the three info thresholds sit at Settings.cs:215-219. Brand flavour reaches a
stat through product role quality (FactionProduct.cs roles, Key 5; boost-gear-systems' "magnitude
reads amplifier").
V13. Zone clock: `Zone.Time` (Zone.cs:75). Entity.Update order: inactive return (1408), hostility,
targets, source decay (1424-1429), temperature (1431), PowerBus.Step (1510), the equipment loop
(1512-1515).
V14. Still unmerged and touching these files: missile-stats/records/odds, munition-shots,
autofire-threshold (FireControl, Entity 82/791/1050/1453), controls-hud (ActionGameManager
1041-1057, 1390-1410), faction-play-1/2 (Flight, Sensor.Ping callers), consumable-ai (Combat.cs).
The cuts below keep their hunks out of those regions.
## Model page
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Signature channel (`SignatureChannel`: Thermal, Reflection, DrivePlume, LaunchFlash, Emission) | A closed enum, the five channels of ruling visibility-per-capability-reveal. Saved only as `VisibilityData.Channel` (catalog, by value; append-only, a value is never reused). | Fixed set; a new channel is a code change and a ruling. | The ruling. Code names each writer's channel; the catalog names a Visibility gear's. |
| Light signature (`Signature {Channel, Strength, Item}` in `Entity.VisibilitySources`) | (emitting entity, source object). The source is the writing behaviour, or the entity for hull radiation; the key is a reference, so injective. `Item` is derived from the behaviour's `Item` (null for hull radiation and consumable effects). Channel Emission is never a light signature (after cut emissions-heard). | Written each tick its source acts (thermal, radiator, reflector, visibility gear continuously; weapon fire and plume when they act). Decayed by VisibilityDecay in the owner's Update and removed below 0.1. Runtime only, never saved. | One writer per entry: its source, through `Entity.SetSignature`. Entity.Update the only decayer. Forbidden: another entity, any presenter, a direct dictionary write. `Visibility` (the sum) stays the track input. |
| Active emission (`ActiveEmission {Item, HearRange, Mode}` in `Entity.ActiveEmissions`) | (emitting entity, emitting item, tick). No persistent id. | Posted to the emitter's inbox each tick it emits (a ping wavefront in flight: Search; a lock paint inside LockAngle: Track); published in the zone's pre-phase with paints; lives exactly one tick. Never saved. | Sensor.Execute (search) and LockWeapon.Execute (track) through `Entity.Emit`; Entity publishes. HearRange = the emitter's range / its InterceptFactor, fixed at the emit. |
| Paint warning (`PaintWarning {Bearing, EmitterClass, Mode, Strength}` in `Entity.IncomingPaints`; was LockWarning / IncomingLocks) | (painted entity, tick, painter beam). No id. | One tick after its last paint (unchanged from lock-warning). Search paints carry Strength 0. | LockWeapon.Execute (Track) and a ping wavefront crossing (Search, Sensor.Execute) call ReceivePaint; Entity publishes. Painted targets are always warned (sensor-stat-set 4). |
| Track (`EntityInfoGathered[subject]`) | (observer, subject entity). | Seeded 0 on zone entry, revised each tick by each active sensor (passive gain, decay), plus one gain per ping crossing from the observer's own pings and its allies' (bistatic); removed when the subject leaves; cleared on Deactivate. Runtime only. | Sensor.Execute only, reading the observer's own receivers. Forbidden: shared-track writes (ruling shared-track-bearing-only), presenters, other ships. |
| Reveal (`Reveal {Item, Channel, Bearing, Time}` in the observer's reveal book, keyed (subject, item)) | (observer, subject entity, revealed item), by reference. | Created on the first act the observer's instruments receive; Channel, Bearing and Time refreshed by each later act; filtered on read when the item is destroyed (Durability < .01) or off the subject; removed when the subject leaves the zone; cleared on Deactivate. Not decayed: memory of the sitting. Never saved. | `Entity.Sense` (once per Entity.Update) for light acts and heard emissions; `ReceivePaint` for paints. Forbidden: Sensor's track loop, presenters, another ship, shared tracks. |
| Contact (`Contact`, derived by `Entity.ContactOf(subject)`) | (observer, subject). | Derived on read from track, reveals, the zone clock and `BearingMemory`; stored nowhere. Flags: Detected (tracked, or a reveal younger than BearingMemory), Tracked (track > TargetDetectionInfoThreshold, which is VisibleEntities), Classified (detected and any reveal), Identified (track >= TargetGearInfoThreshold, name kept, now the identify threshold). Bearing: the true bearing while tracked, else the freshest reveal's. | `Entity.ContactOf` is the one derivation. HUD and AI read it; VisibleEntities stays the tracked set. |
| Sensor stats (SensorData Sensitivity key 3 = detection power, BeamWidth key 12, InterceptFactor key 13; LockWeaponData.InterceptFactor key 26; ListenerData.Multiple key 1) | Fields of a catalog design; brand flavour through product role quality. | Authored by AetherDb (cut sensor-catalog); resolved per item through StatResolver. PingVisibility (key 7) is retired, never reused. | The catalog. Readers: Sensor (BeamWidth, InterceptFactor), LockWeapon (InterceptFactor), Entity.HearingMultiple (ListenerData). |
No empty cell. Two decisions are defaults, not rulings: reveals never decay within a sitting (a ship
you saw fire a railgun still has one), and a reveal carries a bearing only; position comes only from
the track, as passive imaging and pings give it today (ruling text: "range only where the channel
gives it").
## Authority map
- Owner: the observer's Entity owns what it knows: its track (written by its own Sensors), its
reveals and paint warnings (written by `Entity.Sense` and `ReceivePaint`), and the derived Contact.
The emitter's Entity owns what it emits: light signatures (`SetSignature`) and active emissions
(`Emit`).
- Inputs: other entities' published light signatures and active emissions (physical facts about
energy leaving them), positions, the zone's obscuration and clock; the observer's own sensors,
listeners and settings.
- Outputs: EntityInfoGathered and the Visible sets (unchanged meaning), reveals, IncomingPaints,
`ContactOf`, `IsRevealed`.
- Derived state: Visibility (sum of light signatures); Contact and its levels; IsRevealed (a reveal
of a live item, after cut reveals-replace-tiers); HearRange (range / alpha at the emit);
HearingMultiple (1 with an active sensor, else 0, raised by listener gear).
- Demotions: "Visibility is no longer the whole signature; it is the light-channel sum, the track
input only." "IsRevealed is no longer derived from track tiers; it is derived from reveals."
"A ping is no longer a brightness bump; it is a search emission heard within PingRange / alpha."
"TargetedBy and TargetedByCount are not owners of anything; they are deleted (cut contact-hud)."
- Forbidden writers: any code writing `VisibilitySources` except `Entity.SetSignature`; any reveal
write outside `Entity.Sense` and `ReceivePaint`; Sensor's track loop writing reveals; any
presenter or Agents/ code reading another ship's `EntityInfoGathered`, weapons, lock or
`IncomingPaints`; shared tracks writing track or reveals.
- Shared paths: the player's HUD and the AI read the same `ContactOf`, `VisibleEnemies`,
`IsRevealed` and `IncomingPaints`. Own pings and allied pings credit track through the same
crossing rule. Light acts and heard emissions reach the reveal book through the one `Sense` step;
paints through ReceivePaint, which also records the emitter's reveal.
- Deletion line: `_pingedEntities` (stateless crossing replaces it), `PingVisibility`, the IsRevealed
tier ranking and `TargetArmorInfoThreshold`, `TargetedBy`/`TargetedByCount`, and the HUD's reads
of other ships' track are deleted before the behaviour that replaces each is added.
Structural limit, stated plainly: one C# assembly holding `Entity` references cannot make
`other.EntityInfoGathered` unreachable from Agents/ or the HUD. These cuts remove every need to read
it (every fact another ship causes on you arrives on your own Entity), delete the existing reads,
and pin the boundary with negative checks. Making it unreachable by type (agents and the HUD hold
contact handles, never another Entity) is follow-up opaque-contact-handles.
## The rules
R1. Channels. Hull radiation, radiators and Visibility gear (default) write Thermal; reflectors
Reflection; thrusters DrivePlume; every weapon discharge (gun round, beam tick, launch) LaunchFlash.
Emission is active emission only (pings, paints) and never sums into Visibility (from cut
emissions-heard; cut signature-channels labels the ping's interim bump Emission to stay
behaviour-preserving).
R2. Track. Unchanged arithmetic over `Visibility`. A ping wavefront crosses subject T this tick when
PreviousRadius <= distance(emitter, T) < Radius and T lies within BeamWidth/2 of the emitting
sensor's facing (BeamWidth >= 360 is omnidirectional). A crossing adds the existing ping gain once to
the emitter's track of T, and once to each ally's track of T computed with the ally's own
sensitivity and its distance to T (bistatic, sensor-stat-set 3, illuminator-beamforming). Allies:
same Faction, not hostile. The ally emits nothing and is not revealed.
R3. Paints. A crossing paints T with Mode Search, Strength 0. A lock paint is Mode Track, Strength =
the lock (lock-warning-strength). The painted ship always learns bearing, emitter class and mode
(sensor-stat-set 4), whatever obscuration or hearing range.
R4. Reveal by act (light). Observer O records a reveal of item i on subject E when the light
signature of i alone would settle O's passive info above TargetDetectionInfoThreshold:
Strength x `PassiveInfoPerSignature(E.position)` > threshold, where PassiveInfoPerSignature is
ChunkInfo's arithmetic per unit signature over O's active sensors (obscuration included). Hull
radiation (no item) reveals nothing; it only feeds the track.
R5. Reveal by hearing (emission). O records a reveal of the emitting item when
distance(O, E) <= HearRange x O.HearingMultiple x Obscuration(O, E). HearRange = PingRange /
InterceptFactor for a ping, weapon Range / InterceptFactor for a lock paint. HearingMultiple is 1
when O has an active sensor, else 0, and the largest ListenerData.Multiple of O's active listeners
when that is larger (sensor-stat-set 2). Bearing only; nothing feeds the track.
R6. Paint reveals. ReceivePaint also records the emitter's reveal (Channel Emission) on the painted
ship.
R7. Contact levels (visibility-per-capability-reveal). As in the model page row. BearingMemory is a
GameplaySettings value (default 6 s). TargetGearInfoThreshold keeps its name and stored value and
becomes the Identified threshold; TargetArmorInfoThreshold is deleted.
R8. IsRevealed(O, item) is true exactly when O holds a reveal of item, item is on its subject and
Durability >= .01 (cut reveals-replace-tiers, under question gear-reveal-by-dwell's recommended
option).
## Rationale
- Channels and per-capability knowledge: warning receivers and missile-warning sensors are
channel-specific, each giving emitter class and bearing or launch angle (PA:70-76); option b of
question visibility-model was recommended because no game found models fire, burn and emission as
separate reveals (PA:89-95). Reusing the detection arithmetic for the reveal threshold (R4) means an
act is revealed exactly when that act alone would make its emitter detectable: no new tuning
constant, and DetectionReachPerVisibility still bounds it.
- Range-gated hearing (R5) is the ruling's own definition of alpha ("how far it sees to how far
others hear it") and of passive gear ("a multiple of their own range"); emission detectable at
about twice its usable range is the USNI rule of thumb (PA:80-82). Counter-detection when emitting
is how Cold Waters and Command treat radar (PA:80-85).
- Bistatic ally credit (R2): one emitter illuminates, silent allies receive passively; the target
still knows it is painted (ruling illuminator-beamforming's reading). It is the ally's own receiver,
so ruling shared-track-bearing-only (a shared track never writes the track) still holds.
- Stateless crossing: the ping set `_pingedEntities` exists only to stop double credit; the
PreviousRadius <= d < Radius test does that with no state and serves allied pings too.
- Reveals do not decay because ELINT accumulates into an order of battle over many events (PA:89-95);
the track still decays, so a remembered railgun on a ship you cannot see is a Classified bearing
marker only while the memory is fresh.
- Hearing and reveals live in one per-entity `Sense` step rather than in Sensor.Execute so that a
ship with two sensors or with listener gear and no sensor senses once; Sensor.Execute keeps the
track arithmetic byte-for-byte, which the 80 test sites pin.
- Emissions publish in the zone pre-phase beside paints because lock-warning's fix batch found order
dependence when a ship read state another ship wrote mid-tick (LW fix 1, 1c5c6020).
Further prior art not gathered, flagged rather than guessed: how Nebulous and CMO display bearing-only
contacts (PA:96-101 lists the wiki as unread); the HUD's bearing marker below follows the
incoming-lock indicator already in tree.
## Cut order
signature-channels -> paint-modes -> contact-reveals -> emissions-heard -> sensor-catalog;
contact-reveals -> reveals-replace-tiers (waits on question gear-reveal-by-dwell);
emissions-heard + paint-modes -> contact-hud. consumable-ai's re-map follows paint-modes.
## Admitted (receipts mind-commit-7a633910..., mind-commit-b158ffb9..., mind-commit-2892979b...)
Specs, all based at 93b7ff8d: cut-signature-channels.r1, cut-paint-modes.r2 (r1 superseded: an
anchor past the end of LockWeapon.cs), cut-contact-reveals.r1, cut-emissions-heard.r1,
cut-reveals-replace-tiers.r1 (blocked on question gear-reveal-by-dwell), cut-contact-hud.r1,
cut-sensor-catalog.r1. Question gear-reveal-by-dwell (recommended acts-only). Follow-ups
consumable-ai-reads-paints, missile-records-settled-info, launcher-lock-gate-cut,
opaque-contact-handles, target-panel-reads-enemy-hull, two-listener-fix. context-pack read every
anchor SAME at 93b7ff8d. contact-hud folds follow-up music-reads-targeted-count.
What the other specs in force take from this map: consumable-ai's re-map reads IncomingPaints with
Mode Track (follow-up consumable-ai-reads-paints); missile-records re-anchors its settled-info
extraction on PassiveInfoPerSignature (follow-up missile-records-settled-info); faction-play-2's
ping cadence keeps calling Sensor.Ping, and Lucent's loud doctrine is the low InterceptFactor the
sensor-catalog cut authors; autofire-threshold and controls-hud are unchanged (VisibleEnemies,
Designated and the crosshair regions keep their meaning and lines).