Aetheria/docs/aetheria-release-map.md
Meta Vens d2f0ee8933 Aetheria release map: the mining index fix is pins only
Ruling targeting-density-scaling-accepted accepts the index's growth with
rock density, so the sublinear region structure is recorded as rejected
under it. mining-index-pins adds the three tests for Soul's
cost-loosening-unpinned and exact-reach-unpinned findings and lands before
mining-target-queries, which stands as specced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:19:32 +02:00

1590 lines
95 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 says otherwise.
## 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`.
## 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. | The catalog author. Read when a ship joins a flight. |
| Flight | Runtime: zone plus faction. | Formed at admission, rebuilt on load, gone 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.
### 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 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
**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
- **`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.
### 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 | `ShipAuthoring.ModelAsset`, relative, in the package. | Re-exported by every Package. | Derived from the collection. The package directory supplies it to the game, and the game reads nothing else from there. |
| 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`, `loot-1` to `loot-3`,
`ballistic-ammo` and `faction-play-4` r2. The fork is question `fastblast-ammo`.
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):
- 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** 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. | `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.
### 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 after Soul's first pass is only pins.**
- 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 with rock count in a fixed belt (finding
`f1-press-linear-in-density`). Two things drive it: the press path returns every
visible rock, and groups hold a fixed 256 rocks.
- The operator accepted that growth (ruling `targeting-density-scaling-accepted`):
"That targeting scaling is acceptable to me, I'm not planning on having ten thousand
entities in range, that's already gonna make the UI layer melt."
- Rejected under that ruling: a sublinear region structure, meaning square polar cells
that co-rotate per ring, refreshed lazily, with best-first search over a ring tree.
Two reasons. Exact angle and distance-order queries in linear space cost about the
square root of the local rock count anyway: this is the line-query bound for
kd-trees and Chazelle's lower bound for simplex range searching. And the in-range
counts that design allows are far below where the linear term matters.
- What is left is `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. The one finding that names it (f1:
`Best` still enumerates every region) is the growth the ruling accepts; the other
two touch only `BeltTargets`' tests. Its `AKeyPressDoesNotGrowWithTheBelt` scales
area at constant density, which the ruling leaves in force.
**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.