diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index b714b1ef0..b3cdd5eea 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -5,7 +5,8 @@ Date: 2026-06-12 **Parked (2026-09-11).** This migration was built and then rolled back by commit `7006a6b0`. `F:\Projects\AetheriaEve` preserves that rebuild as a specimen; it is not direction or precedent. Current -direction for this repo is [three-gates-scope.md](three-gates-scope.md). Read +direction for this repo is [aetheria-release-target.md](aetheria-release-target.md) (the +demo), with [three-gates-scope.md](three-gates-scope.md) as the full game's run. Read this document as a record of the deferred plan, not as current direction. The cache move alone is now [cultcache-migration-target.md](cultcache-migration-target.md). diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md new file mode 100644 index 000000000..5be7f94a4 --- /dev/null +++ b/docs/aetheria-release-map.md @@ -0,0 +1,3189 @@ +# Aetheria Release: Map + +Date: 2026-10-03 + +This map holds body facts, the model page and rationale for campaign +`aetheria-release`. It has no progress section, no per-cut sections, no rulings +and no ledger: those are typed documents in the Eureka mind. Ends live in +`docs/aetheria-release-target.md`. The Eyes inventory this pass started from is +`F:\Projects\aetheria-release-inventory.md`, which is outside the repo. + +Every anchor below is against `origin/master` `f1dee184` and was read in a sparse +scratch worktree on 2026-10-03, unless the line or its section says otherwise. The section "Demo scope" holds +the 2026-10-06 re-scope: the release this campaign plans is the demo. + +## Body facts + +**B1. No impact event carries a world point.** +- `Entity.IncomingHit` (`ServerShared/Entity.cs:138`) carries only the source. +- `ArmorDamage` (`:139`) carries a schematic cell. `ItemDamage` (`:140`) carries the + item. `HullDamage` (`:143`) carries the amount. +- `Zone.ShotCommitted` and `ShotResolved` (`Zone.cs:41-42`) carry a `ShotOutcome` + (`FireControl.cs:1902-1930`), which holds a schematic cell, bearing and lateral + offset but no world point. +- The world point of a direct hit is never computed. `ApplyDirectHit` + (`FireControl.cs:944-998`) walks lanes in the schematic frame. A + contact or delayed blast computes it at `:1030` and `:1043` and passes it to + `Detonate`. `Detonate` (`:1341-1414`) has the world point and the share each entity + took, and publishes neither. +- So nothing can show a hit at the place it landed without recomputing + `FireControl`'s geometry. + +**B2. Weapon hits on a shield show nothing.** `ShieldManager.ShowHit` +(`Gameplay/ShieldManager.cs:88`) has one caller, ship-on-ship collision (`:80`). +`FireControl` calls `Shield.TakeHit` and `Break` (`FireControl.cs:952-956`, +`:1378-1385`) with no presentation hook. The inventory's "shield impact: present" +refers to the shield-panel rig, which `FieldTester` drives in `FieldShieldTest.unity`. +It does not run in the game. + +**B3. A capability event contract already exists for this.** `CapabilityEvents` +(`ServerShared/CapabilityEvents.cs`) defines `AbsorbEvent` (position, direction, +magnitude, damage type) and one `Subject` per event kind. +`CapabilityPresentationBinder` (`Gameplay/CapabilityPresentationBinder.cs`) wires +`IAbsorbPresenter` components on an object and its children. The contract's own +comment says the simulation will own and publish these events, and presentations +will subscribe and decide nothing (ruling fork L, 2026-09-17). Today its only +publisher is `FieldTester`. `AbsorbEvent` has no way to tell a shield absorb from a +hull absorb. + +**B4. The weapons already announce what they do.** +- `InstantWeapon.OnFire(shotId)` (`Behaviors/InstantWeapon.cs:272`) fires only for a + round that was not refused. +- `ChargedWeapon` has `OnStartCharging`, `OnStopCharging`, `OnCharged` and + `OnFailed` (`ChargedWeapon.cs:87-90`). +- `ConstantWeapon` has `OnStartFiring` and `OnStopFiring`. +- `EntityInstance.SetEntity` subscribes the effect managers to all of these + (`Gameplay/EntityInstance.cs:209-240`). + +**B5. The Wwise-shaped audio path is dead at its source.** +- `EquippedItem.SoundBank` (`Entity.cs:1353`) is never assigned outside two tests + (`FireControlPerWeaponTests.cs:424`, `:672`). +- So every `FireAudioEvent` and `SetAudioParameter` call returns at its null check. + The calls sit in `InstantWeapon.cs:275`, `ChargedWeapon.cs:137,154,169,185`, + `Reactor.cs:97`, `Thruster.cs:96` and `AetherDrive.cs:170-172`. +- No production code subscribes to `EquippedItem.AudioEvents` (`:1375`). +- `WwiseSoundBinding` and `WwiseLoopingSoundBinding` (`WwiseSoundBinding.cs`) have + zero use sites. +- `GameData/SoundbanksInfo.json` (2,648 lines) is read by no code. Only + `docs/build-delivery-cut.md:231` mentions copying it. +- `EntityInstance.cs:243-251` reads `item.Data.SoundBank` and does nothing with it. +- Catalog fields that hold Wwise ids: + - `EquippableItemData.SoundBank` (`ItemData.cs:403`); + - `AetherDriveData.RpmAudioParameter` and `TorqueRatioAudioParameter` + (`AetherDrive.cs:44-48`, keys 11 and 12); + - `Faction.OverworldMusic`, `CombatMusic` and `BossMusic` (`Corporations.cs:52-59`, + keys 13 to 15). + +**B6. Unity audio is switched off and has no listener.** +`ProjectSettings/AudioManager.asset` has `m_DisableAudio: 1`. A YAML grep found +`AudioListener:` count 0 in `ARPG.unity` and in `Main Menu.unity`; the two test +scenes have one each. The editor is Unity 6000.3.24f1 +(`ProjectSettings/ProjectVersion.txt`). + +**B7. The 2021 sound library is on disk and usable.** +- The Wwise project is `Aetheria-Economy_WwiseProject.wproj`, `WwiseVersion="v2021.1.1"` + and `SchemaVersion="103"`. +- Its 225 `.wav` are in LFS and present in the operator's checkout: + `Originals/SFX` holds 731 MB. +- The SFX include fire, hit and miss for cannon, ioncannon, lightingGun, machinegun + and railgun. They also include charge and charge-fail, ship destroyed (two), and + shield up, down and active. Overheat alarm, docking, undocking, wormhole, reactor + states and sensor ping are there too. +- Interface sounds are `blip`, `equip`, `mouseover`, `pickup`, `putdown`, and trade + buy and sell. +- Music: menu, Gen overworld and combat, Metal overworld, combat and boss, Daya + overworld, Kawaii Future Bass. There are also 30 ambience layers. +- The 20 `Originals/Plugins/SoundSeed Grain` sources feed a Wwise plugin, and + their procedural behaviour does not carry over outside Wwise. +- `*.wav` is an LFS pattern in `.gitattributes`. + +**B8. The camera has the tooling for shake; nothing uses it.** +`com.unity.cinemachine` 2.10.7 is in `Packages/manifest.json`. It ships +`CinemachineImpulseSource` and `CinemachineImpulseListener`. A grep found no +`Impulse` and no `shake` in `Assets/Scripts`. The follow camera is +`ActionGameManager.FollowCamera` (`ActionGameManager.cs:122`). + +**B9. Graphics settings are not saved.** +- `MainMenu.ShowGraphicsSettings`'s Back button (`UI/MainMenu.cs:258-263`) never calls + `ActionGameManager.SavePlayerSettings()`. +- Gameplay's Back button does call it (`:236-241`). +- `PlayerGraphicsSettings` (`ServerShared/PlayerSettings.cs:63-67`) holds keys 0 and 1. +- `PropertiesPanel.AddField(name, float read, write, min, max)` exists + (`UI/Properties Panel/PropertiesPanel.cs:290`). + +**B10. The lanes overlap the feedback files in these hunks** (`git diff +origin/master...origin/` hunk headers): +- **mining** (`83d8371e`): + - `FireControl.cs` at `:72` and `:522`; + - `Entity.cs` at `:43`, `:198-240`, `:295-395` and `:1075`; + - `EntityInstance.cs` at `:236` and `:410`; + - `ActionGameManager.cs` across `:362-1432`, including `:1076-1100`; + - `ZoneRenderer.cs` and `Zone.cs`. +- **scenarios** (`aa3baf12`): + - `Entity.cs` at `:204`, `:601` and `:786-1003`; + - `ActionGameManager.cs` at `:583`, `:732` and `:801`. +- **moddable-ships** (`a93625c2`): + - `ActionGameManager.cs` at `:42-70`; + - `ZoneRenderer.cs`; + - `Packages/manifest.json`, which adds glTFast. + +`feedback-1`'s edits are disjoint from these: `FireControl.cs:944-1045` and +`:1375-1412`, `Entity.cs:138-150`, and `EntityInstance.cs:159-170`. + +**B11. The headless tests compile only `ServerShared`.** That covers +`Aetheria.Shared/Aetheria.Shared.csproj:20` and `tests/Aetheria.Shared.Tests`. A rule +that must be pinned headless must live in `ServerShared`. + +## Model page + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Impact (`AbsorbEvent` with a layer) | Transient. The receiving entity's `Capabilities` stream, plus the shot. It is not stored. | Published once per landed hit, before the hull damage is applied. It is never replayed or persisted. | `FireControl` alone, in `ApplyDirectHit` and `Detonate`. Presenters only read it. | +| Impact cue | Derived from one impact and the receiver. | Computed at presentation time and discarded. | `Feedback` (engine-free, `ServerShared`). Visual and audio presenters render it and do not re-derive it. | +| Player feedback preferences (shake, volumes) | `PlayerSettings` global, player store, keys under `GraphicsSettings` (shake) and a new `AudioSettings` (volumes). | Edited in the settings menu and saved on Back. A new key defaults on load. | The player, through the menu. No runtime writer. | +| Presentation tuning (effect prefab, intensity scale, impulse shape, sound bank) | A Unity component or asset in `Assets/Content`. | Authored in the editor and versioned with the scene. | The operator. It stays Unity-serialized by fork M (a). | +| Sound assets | The file under `Assets/Audio`, copied from the Wwise `Originals`. Provenance is recorded per file. | Added per cut. The Wwise project stays as the source archive until `audio-route` and `audio-source-rights` are ruled. | The operator, on rights (`audio-source-rights`). | +| Ship content (`ShipAuthoring` with GLB) | `aetheria.ship_authoring` id. GLB nodes carry an `aetheria.id` extra. | Adopted by this campaign (ruling `adopt-moddable-ships`). Rows for the package, its anchors and the derived catalog are in "Content: the moddable-ships lane" below. | The moddable-ships rulings (MQ1-MQ5) and this campaign's content strand. | + +No cell is empty for the two mapped cuts. The content rows are mapped in their own +section below. + +## Rationale + +**Why the impact is published by `FireControl` and not derived in Unity.** B1 shows +that the world point exists only inside `FireControl`'s lane walk and blast +resolution. If a presenter derived it, it would have to recompute lanes from the +`ShotOutcome`. That rebuilds the second hit authority that fire control Cuts 3 to +12.4(b) deleted. So the owner of where a round landed publishes where it landed. + +**Why reuse `CapabilityEvents` rather than a new subject.** The contract was ruled +for exactly this (B3): the simulation publishes absorbs and presenters subscribe. +Its binder already exists. Reusing it adds one field (the layer) instead of a +second event family. It also brings the shield-panel presenters within reach of real +hits later, with no new wiring. + +**Why the cue derivation lives in `ServerShared`.** Intensity and shake are rules +("in proportion to the damage", "zero at zero preference"). B11 says only +`ServerShared` is under headless test and Stryker.NET. Keeping the function +engine-free puts the rules where mutation testing reaches them. Audio then renders +the same cue (target: one cue, two channels). + +**Why native Unity audio is recommended** (question `audio-route`): +- The simulation already announces fire, charge, impact and death (B4 and + `feedback-1`). +- The Wwise path is dead at its source (B5). +- Unity audio is only switched off (B6). +- Of the Wwise project, what carries over is the wav files (B7). The + 2021.1 project, its events, banks and SoundSeed plugin sources would need + migrating to a Wwise release that supports Unity 6. +- Wwise adds three costs: + - a native plugin per platform; + - a second authoring application; + - a sound-bank build step. Its authoring console runs on Windows and macOS, not + on Yggdrasil's Linux. + + Agents cannot easily author Wwise work units, and the operator's AquaSynth renders + would have to pass through it. +- Licensing is not the obstacle. Wwise is free under a $250K development budget, + with no asset limit (Audiokinetic pricing page and indie-license blog). FMOD is + free under $200K yearly revenue and a $500K budget. +- What the middleware would buy is interactive music and parameter-driven + mixing, authored by a sound designer. The release bar asks for neither yet. +- Unity's limits are known: 32 real voices by default (`AudioManager.asset` + `m_RealVoiceCount`), no authored containers or RTPC graph, and mixer assets that + cannot be created from script. +- Unity 6's `AudioRandomContainer` has no public scripting API for its clip list. + The first cut therefore uses a small `ScriptableObject` sound bank with random + pick and jitter. + +Confidence: medium-high. It falls to medium if the operator expects a contracted +sound designer working in Wwise, or adaptive music with transitions and stingers +before release. + +**Why content runs in parallel.** The release quota is met at the operator's pace, +one hull a day, and that clock starts only when the first Tripo mesh flies. The steps +in front of it are the operator's owed S2 play checks, the moddable-ships merge, and +the add-on's package action. Each is cheap and none depends on feedback or audio. +Serializing them behind audio would idle the slowest resource in the project. + +**Sources consulted for the audio fork:** +- https://www.audiokinetic.com/en/wwise/pricing/ +- https://www.audiokinetic.com/en/community/blog/free-wwise-for-indie-developers/ +- https://www.gamedeveloper.com/audio/small-developers-and-creators-can-now-use-fmod-studio-for-free +- https://gamefromscratch.com/fmod-studio-now-free-for-indie-game-developers/ + +Two claims were not verified from primary sources on 2026-10-03: which Wwise +release first supports Unity 6000.x, and the scripting surface of +`AudioRandomContainer`. + +## Demo scope + +Re-scope of 2026-10-06 (rulings `first-release-is-a-demo`, `demo-cut-fixed-region`, +`demo-cast`, `miss-terris-belongs-to-emily-r3`, `ship-authors-need-no-unity`). The +release this campaign plans is the demo: one gate, one region, one boss, a fixed cast. +The design review that prompted it is `F:\Projects\aetheria-game-design-review-2026-10-05.md`, +outside the repo. Anchors in this section are against `origin/master` `df7c44f2` (the +mining merge), read with `git show` and `git grep` from the Aetheria object store on +2026-10-06. + +### Body facts + +- **D1. A fixed-cast generator already exists: the prelude.** `Galaxy.cs:150-262` builds a + galaxy from named factions: protagonist, antagonist, buffer, quest and neutrals, + resolved by name prefix (`ResolveFaction`, `:141-144`). It places homes so the + antagonist is far from the protagonist and the buffer between them, and sets the + entrance near the protagonist's home. Its settings are + `Assets/Resources/Settings.asset` `TutorialGenerationSettings` (an LFS object, read with + `git lfs smudge`): protagonist `Miss`, antagonist `Zhe`, buffer `Luc`, neutrals `Aero` + and `Finch`, quest `Adras`, 64 zones. `TutorialGalaxy` stages it. The main galaxy + (`Galaxy.cs:96-131`) draws `MegaCount` 12 factions at random from the catalog, places + `BossCount` 3 boss zones, and has 128 zones. A prelude galaxy guarantees a station at + its entrance (`ZoneGenerator.cs:223-224`) and lets generation use any product + (`LoadoutGenerator.cs:184`). +- **D2. Boss zones and the exit exist only in the main galaxy.** `PlaceFactionsMain` + (`Galaxy.cs:265-309`) fills `BossZones` on chokepoints; the main constructor sets + `Exit` to the most isolated zone (`:123`). The prelude sets neither. Both persist + (`SavedGame` keys 3 and 5). +- **D3. Nothing spawns a boss.** `Faction.BossHull` is key 10 + (`Corporations.cs:43-44`) and is unset on all 12 catalog factions. Outside the field + and `Galaxy.BossZones`, `BossHull` is read only by `tools/AetherDb`. `ZoneGenerator` + generates stations, turrets, faction ships and wanderers (`:280-365`) and no boss. +- **D4. Wormholes are built in Unity, and the exit does nothing.** `ZoneRenderer.cs:225-236` + adds one `Wormhole { Position, Target }` per adjacent zone. Interact + (`ActionGameManager.cs:321-327`) enters any wormhole in range; `EnterWormhole` + (`:660`) populates the target zone. Nothing checks `Galaxy.Exit`. +- **D5. Station services are buy-only and credits are not saved.** `TradeMenu` has + `Buy(CraftedItemInstance)` (`:366`) and `Buy(SimpleCommodity, int)` (`:405`) and no + sell. "Repair" appears in no gameplay or UI script (only AetherDb comments). + Credits are `public int Credits = 15000000` on `ActionGameManager` (`:114`); + `SavedGame` (keys 0-12) does not carry them, so Continue resets them. The hull branch + of `Buy` checks `GetPrice` (`:369-370`) and charges `data.Price` (`:378`). Durability + is per instance (`ItemInstance.cs:53`) against the design's (`ItemData.cs:371`), and + falls under fire (`Entity.cs:645-664`). +- **D6. The catalog has no Pirates and the demo cast sells no ship hull.** Decoded from + `git show df7c44f2:GameData/Aetheria.cc | git lfs smudge` with Python `msgpack`, using + the store's own schema table for slot names. Factions (12): Zhestokost, Finch + Cybernetics, Lightsail Express, NiteLife Energy, Death Monkey Explosives, Aeronautics + Unlimited, Lucent Media, Alakrita, Ewan Hart Inc, Rossum & Douglas, Adrasteia, Miss + Terri's. Hulls (5): Longinus and LonginusX (Alakrita, ship), Djinni (Rossum & + Douglas, ship), Zenith (AU, station), Turret (Zhestokost, turret). 64 products; by + maker AU 11, Lightsail 9, NiteLife 9, Lucent 7, Death Monkey 6, Zhestokost 6, + Rossum & Douglas 6, Alakrita 5, Finch 2, Miss Terri's 1, Adrasteia 1, Ewan Hart 1. + +### Why the demo is a prelude galaxy + +D1 is already the demo's shape: named factions in fixed roles, a small galaxy, an +entrance station. The demo needs a different cast from the tutorial (the Pirates as the +ally, not Miss Terri's), a boss zone and an exit. So the demo is a compiled scenario +whose script carries its cast and calls the prelude constructor, then places the gate at +the antagonist's home (`cut-demo-galaxy`). The tutorial's settings and Miss Terri's +prelude role are untouched. A demo mode flag on the galaxy was rejected: the scenario +script is the owner the scenario rulings already name. + +The gate is in the boss zone itself: an exit wormhole there opens when the zone's boss +is dead (`cut-boss-gate`). Locking every wormhole out of a boss zone, as Three Gates +item 2 describes, is the full game's three-section rule; the demo needs one gate and +lets the player retreat. + +### Scope sort + +Every cut spec and follow-up in force under root `aetheria-release` on 2026-10-06, after +this pass's admissions. "Landed" means the spec has a report; it stays in force as a +record. + +| Kind | Id (local) | Scope | Reason | +|---|---|---|---| +| spec | `demo-galaxy` | demo | The demo scenario, its cast and the gate's placement. | +| spec | `boss-gate` | demo | The one boss and the sealed gate; the win. | +| spec | `station-services` | demo | Sell, repair and saved credits: the run pays. | +| spec | `feedback-1` | demo | Every hit seen where it lands (target line 5). | +| spec | `audio-1` | demo | Every action makes a sound (line 4). | +| spec | `faction-play-1` | demo | Doctrine and the flight for the cast; needs r2 (follow-up `faction-play-reanchor-demo`). | +| spec | `faction-play-2` | demo | Lucent's duel is the featured neutral's spectacle; needs r2. | +| spec | `faction-play-3` | demo | Support and anchors; the played proof moves here for the cast; needs r2. | +| spec | `faction-play-4` | full game | The tender loop on real rounds is depth past the demo's proof. | +| spec | `loot-1` | demo | Floating items in the simulation; loot is what the player sells. | +| spec | `loot-2` | demo | Pickup as a capability. | +| spec | `loot-3` | demo | The grab presented in Unity; merges the loot branch. | +| spec | `ballistic-ammo` | demo | Ruled for everyone and unblocked; ammunition is a cargo good in the Pirates' game. Needs a balance pass. | +| spec | `ships-player` | demo | A built player loads package hulls: every demo hull needs it. | +| spec | `ships-addon-frame` | demo | The Blender step from a Tripo mesh to a hull, no Unity. Stale against `ship-data-all-in-cc` if `demo-ship-cc-timing` rules before-hulls. | +| spec | `ships-addon-mounts` | demo | Mount helpers for demo hulls. Same caveat. | +| spec | `ships-hull-material` | demo | One hull shader painted from a mask: the demo's material under `demo-material-scope`'s recommended option. | +| spec | `ships-addon-paint` | demo | The mask authored in Blender for that shader. | +| spec | `ships-hull-livery` | demo | Faction liveries for the cast; its Faction key collides (follow-up `livery-key-collision`). | +| spec | `scenarios-smoke` | demo | Test designs and smoke scenarios: the verification path. | +| spec | `mining-merge-unity` | demo | The Unity half of the merge that landed. | +| spec | `material-graph` | full game | Graph record and judge; not demo-bearing under `demo-material-scope` (recommended). | +| spec | `material-library-export` | full game | Same. | +| spec | `material-lowering` | full game | Same. | +| spec | `hull-bakes` | full game | Same. | +| spec | `ships-studio-schematic` | full game | Authoring comfort in Studio; the demo's few hulls do not need it. | +| spec | `ships-addon-gizmos` | full game | Same. | +| spec | `mining-index-tree` | full game | Sublinear index: wanted, not critical (ruling). | +| spec | `mining-belt-cells` | full game | Same. | +| spec | `ships-merge`, `ships-mounts`, `ships-addon-package`, `ships-bind-path`, `cut-ship-render-fixes` | demo, landed | The package path the demo hulls use. | +| spec | `scenarios-adopt`, `scenarios-menu` | demo, landed | Scenarios: the demo is one. | +| spec | `mining-index`, `mining-target-queries`, `mining-index-pins`, `mining-merge` | demo, landed | Targeting through the index; the belt freeze is gone. | +| follow-up | `pirates-catalog-record` | demo | The ally needs a Faction record and products. | +| follow-up | `zhestokost-boss-hull` | demo | The boss hull and `BossHull`. | +| follow-up | `demo-cast-hulls` | demo | A hull per cast faction. | +| follow-up | `faction-play-reanchor-demo` | demo | Re-anchor faction play, add the Pirates, play the cast. | +| follow-up | `livery-key-collision` | demo | Two specs claim Faction key 16. | +| follow-up | `demo-ux-ledger` | demo | The bar's UX polish pass has no owner. | +| follow-up | `demo-difficulty-onramp` | demo | "Game is hard"; the region needs a ramp. | +| follow-up | `vault-docs-sweep` | demo | Stale vault notes mislead the faction-play revision. | +| follow-up | `controls-facing-and-aim` | demo | Under `demo-controls-rebuild`'s recommended option. | +| follow-up | `release-target-is-a-demo` | demo | This pass; Self closes it with target r3. | +| follow-up | `release-map-not-on-master` | demo | Land this map and the target doc on master. | +| follow-up | `wwise-catalog-fields` | demo | The audio sequel removes the Wwise ids; the boss music field is among them. | +| follow-up | `scenarios-batch4-minors` | demo | Inventory drag and station-reactor quality: UX ledger items. | +| follow-up | `hull-damage-wear` | demo | Damage visible on the hull (line 6). | +| follow-up | `mod-ship-single-existence-gate` | demo | A skipped package throws on spawn; demo hulls are packages. | +| follow-up | `ambiguous-hardpoint-origin` | demo | Save correctness for package hulls. | +| follow-up | `remove-sbsar-files` | demo | Clean break from Substance before a public build. | +| follow-up | `delaunay-exact-predicates` | demo | Galaxy links; a silently unlinked zone could strand a demo run. | +| follow-up | `failed-start-scene-halts` | demo | The New Game failure path the demo's missing-cast refusal uses. | +| follow-up | `stale-docs-after-scenarios-menu` | demo | Docs that describe the old New Game path. | +| follow-up | `scratch-carry-drafts` | demo | The ship `.cc` draft bears on `demo-ship-cc-timing`. | +| follow-up | `faction-lore-notes-r3` | full game | Vault setting notes; no demo cut reads them. | +| follow-up | `mining-impacts-through-contract` | full game | Mining Cut 4 is parked. | +| follow-up | `mining-cuts-4-5-7` | full game | Yield and content; no buyer for ore in the demo. | +| follow-up | `mining-target-pin-gaps` | full game | Low test pin gaps in landed code. | +| follow-up | `belt-render-reads-index` | full game | Belt rendering through the index. | +| follow-up | `c4-ship-element-ids` | full game | Variants campaign. | +| follow-up | `variants-python-raw-push` | full game | Variants campaign. | +| follow-up | `mod-hull-changed-under-run` | full game | Mod edits under a run; demo hulls are first-party. | +| follow-up | `ship-lods` | full game | Only if a profile shows cost. | +| follow-up | `hull-decals` | full game | Insignia and damage decals. | +| follow-up | `hull-player-paint` | full game | Player liveries. | +| follow-up | `blender-pin-operator` | full game | Pins Blender for the material judge. | +| follow-up | `material-mod-graph-kernels` | full game | Mod material kernels. | +| follow-up | `material-map-rescope` | full game | Re-scope of the material map, after `demo-material-scope`. | +| follow-up | `bodies-entity-and-mines` | full game | Bodies for entities and mines. | +| follow-up | `cultmath-mixed-seed` | full game | CultLib hygiene. | +| follow-up | `launcher-ammunition` | full game | Missiles and drones campaign. | +| follow-up | `drones-munitions-substrate` | full game | Same. | +| follow-up | `articulated-mounts` | full game | Links with arc and traverse. | +| follow-up | `colosseum-npc-lab` | full game | Its own campaign. | +| follow-up | `heat-flow-schematic-particles` | full game | Presentation depth past line 6. | +| follow-up | `scenarios-verification-ledger` | full game | Deferred by ruling. | +| follow-up | `authored-missions` | full game | Authored scenes and missions. | +| follow-up | `volumetrics-backport-map` | full game | Visual quality with no demo line. | +| follow-up | `ghostlight-readme-drift` | full game | Ghostlight, narrative. | +| follow-up | `faction-play-reanchor`, `faction-lore-notes` | obsolete | Superseded on 2026-10-06 by `faction-play-reanchor-demo` and `faction-lore-notes-r3`. | + +Counts: demo 52 (31 specs, 11 of them landed, and 21 follow-ups); full game 34 (9 specs, +25 follow-ups); obsolete 2 follow-ups. No in-force spec lost its purpose in both scopes, +so none was withdrawn. + +## 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/ 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 ` 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//ship.cc` would be stored as a plain blob. + +**M12. The add-on reads CultLib from wherever Brokkr points.** `_libraries` puts +`/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: : `. `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:` 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-`. Anchors stay valid, because the master + merge changes no lane file. +6. Mining and scenarios inherit the union resolutions in M2 when they merge + (follow-up `lane-merge-union-catalogtypes`). + +The stale local ref `codex/moddable-ships` (`375d6bd4`) in the Codex worktree is +left alone. + +### The Blender package action + +*The GLB export and `ModelAsset` below are superseded by "Ship data all in the `.cc`": Package writes `ShipModel` into `ship.cc`.* + +The add-on today binds a collection, captures LineArt, and edits the grid and +hardpoints. It writes no anchors and exports no GLB (`moddable-ship-authoring.md` +proof gate 2). The design aims at one thing: the operator's time per hull, at one +hull a day. Everything that can be derived from the mesh is generated. Everything +that needs her eyes takes one click at the 3D cursor. + +**Per hull, after the add-on cuts:** +1. Tripo: image to model, textured. Export GLB. Tripo's smart low-poly option is + worth its 10 credits above about 60k triangles. +2. Blender: import the GLB into a new collection. Press **New Ship**: + - Enter the id, the name, a reference hull such as Djinni, and the length in + cells. + - AetherDb creates `GameData/Mods//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 `, equip, undock, fly. + +Steps 2, 4 and 6 replace the hand work that M8 shows she has started. The +`aetheria.id` of every node is its anchor id, so `ModelNodeId` equals the anchor +`Id`. Node ids are written by the add-on and never typed by her. + +**Material slots.** The material owners are in "Hull materials, mount meshes and +per-hull prep" below. Ruling `hull-runtime-materials` superseded the deferral of +livery recorded here before. + +**Colliders.** Hits are fire control's (R8). The hull collider serves only +ship-on-ship contact and is convex (`ShipModShips.cs:110`), so a generated convex +hull is the whole job. Unity cooks at most 255 polygons for a convex collider, and +the generator emits no more. + +**Split into three cuts.** +- `ships-addon-package` is the core: anchors from role-tagged objects, GLB export, + `create --like`, validate. +- `ships-addon-frame`: New Ship, fit, rasterise, the grid, recentring, structural + anchors. +- `ships-addon-mounts`: the mount helpers, texture cap, triangle warning, CultLib + revision guard, and the per-hull checklist in the authoring doc. + +Each fits well under 200k Hands tokens. The core alone already makes a valid +package from hand-placed objects. Revision 2 of each (2026-10-03) carries the two +new rulings; the section below says what changed. + +### Hull materials, mount meshes and per-hull prep + +Three operator rulings of 2026-10-03 drive this section. +- `hull-runtime-materials`: "I expect I'll want to do quite some cleanup and prep + for the meshes. The geometry is still not ideal, and we want more runtime + material control than just a single baked map. See how MechWarrior does it, for + example." +- `thrusters-radiators-are-meshes`: "Note that both thrusters and radiators should + be meshes, the former doesn't get rendered but is used as an emission surface". +- `hull-paint-mask-texture`, answering question `hull-paint-mask` with option + `texture-sidecar`: "I want a mask map per ship for livery, I'll be rebuilding + the nonsense geometry and unwrapping anyway, I want these to stand up to my own + authoring standards even if tripo does a lot of the leg work." + +The prior-art survey is `F:\Projects\aetheria-hull-materials-prior-art.md` +(Eyes, 2026-10-03). Anchors below are against `80de88f2`, the tip of the +in-flight `eureka/aetheria-release-ships-mounts`. The add-on is unchanged there +since `a93625c2`. + +#### Body facts + +**M13. Mod hulls render outside the game's ship lighting and fade.** +- `ShipModVisual.LoadAsync` builds `new GltfImport(...)` with no material + generator (`ShipModVisual.cs:45`). glTFast's default Built-In generator therefore + picks `glTF/PbrMetallicRoughness` for every hull material. +- Prefab hulls paint with `Aetheria/GlowFade` (`Assets/Shaders/GlowFade.shader`; + `Car Paint White.mat` and `Cockpit.mat` use it). It does two things a glTF + shader does not: + - it lights by the volumetric ambient (`noambient`, `VolumeSampleColorSimple` in + `vert`, `:35` and `:67-72`); + - it dithers in and out by `_Fade` (`:84-88`). +- `EntityInstance.Awake` gives each material with `_Fade` a per-entity instance it + fades. Every other submesh is swapped to `InvisibleMaterial` and only shown by + `ShowUnfadedElements` (`EntityInstance.cs:86-99`, `:120-131`). So a mod hull pops + where a prefab hull dissolves, and it is lit differently. +- Nothing tints any ship today. `Faction.PrimaryColor` and `SecondaryColor` + (`Corporations.cs:35-38`) are read only by the sector map (`SectorMap.cs`). + +**M14. glTFast keeps meshes readable and exposes every core material slot.** Read +from the package source (`com.unity.cloud.gltfast@11ddc2436f97`): +- `MeshGenerator.cs:539` calls `UploadMeshData(false)`, so meshes stay readable. + The collider (`ShipModShips.cs:103`) and a particle mesh shape need that, so + M10's readability doubt is closed for the importer. `ships-player` still checks + it in a built player. +- `IMaterialGenerator.GenerateMaterial(MaterialBase, IGltfReadable, bool)` sees + every core slot: `PbrMetallicRoughness.BaseColorTexture` and + `MetallicRoughnessTexture`, `NormalTexture`, `OcclusionTexture`, + `EmissiveTexture`, each with `index` and `texCoord` (`Schema/Material.cs:48-77`, + `TextureInfo.cs:42-49`). `IGltfReadable.GetTexture(int)` returns the texture. +- glTFast decodes images only as parts of a glTF. It has no entry point for a + loose PNG. + +**M15. Blender exports a named colour attribute exactly** (probed for the +rejected vertex-colour option, kept as a fact). +- A `FLOAT_COLOR` corner attribute exported with `export_vertex_color='NAME'` + becomes one exact `COLOR_0`. +- `'ACTIVE'` wrote every attribute, and its `COLOR_0` was all ones. + +**M16. The Tripo hulls, measured.** A read-only probe of a copy of `Quiet.blend`: + +| Hull | Triangles | Face-connected parts | Largest part's share of faces | Parts under 2% of hull length | Non-manifold edges | UV islands | +|---|---|---|---|---|---|---| +| Headliner | 50,008 | 189 | 33% | 23 (647 faces) | 6,145 | 6,237 | +| Dexter Quiet | 9,614 | 12 | 87% | 8 (124 faces) | 524 | 1,386 | +| Sinister Quiet | 10,880 | 12 | 88% | 7 (114 faces) | 356 | 1,525 | + +- The UV atlases are confetti: 1,400 to 6,200 islands in one 4096² image. Nothing + is authored in that space. The operator's rebuild gives each hull a clean UV0, + and the Tripo mesh becomes a bake source. +- K-means (CIELAB, a 512² sample) of the albedos: + - Headliner's is colourful. With k = 6, the clusters are dark navy (43% of the + texels), mid grey (21%), beige (13%), lavender (11%), orange (7%) and blue + (4%). + - Quiet's is near-greyscale (a* and b* within ±6): light panels (44% at L 69 to + 79), darks and teal accents. + - Within-cluster lightness spread is 3 to 12 L. That spread is baked shading and + detail. +- Probe scripts: `kmeans.py`, `meshstats.py`, `parts.py`, `vcol.py` and + `maskpng.py` in this pass's scratch. Their numbers are quoted here. + +**M17. Thruster and radiator mounts are already meshes at runtime.** +- `ShipModShips.Assemble` requires a `MeshRenderer` under both + (`ShipModShips.cs:87-92`, `:128-137`). +- On `80de88f2`, `ships-mounts` hides the thruster's mesh behind the template's + `Invisible` material (`:130`, commit `da2a299b`). +- `ShipInstance.SetEntity` hands that renderer to the exhaust particle system as + its shape (`ShipInstance.cs:68-71`). Unity's documentation says a mesh shape + emits along the surface normal. +- The GLB rule is not checked before Unity. `ShipModCatalog.Bind` checks only that + each anchor's node exists (`ShipModCatalog.cs:176-180`), and + `ship-authoring validate` reads the records alone + (`ShipAuthoringCommands.cs:32-37`). + +**M18. Blender saves a byte mask raw.** Probe, Blender 5.2.2: a 4×4 RGBA byte +image whose pixels are `(0.5, 0.25, 1.0, 0.5)`, saved as PNG. +- Tagged Non-Color or sRGB, it is written as 8-bit RGBA (colour type 6) with + bytes `128, 64, 255, 128`. The values are raw, with no transfer applied. +- A float image saves as 16-bit. +- The `.gitattributes` rule `*.png filter=lfs` already covers package masks. + +#### The hull material model + +*Superseded in part by "Hull materials as owned graphs" below: the mask semantics, `HullPaint`, the shader rules and the livery resolution. Kept as the record of revision 2.* + +**What a package carries.** +- **The GLB's core PBR slots,** baked by the operator onto her clean UV0: + - base colour; + - metallic-roughness; + - normal; + - occlusion; + - emissive texture and factor. + + Missing slots fall back to factors. +- **`ship.mask.png`, the livery mask:** RGBA8 on UV0, one per hull. + - R, G and B are the weights of the primary, secondary and accent paint. Black + is bare: the GLB's own surface shows. + - A is wear and grime: 0.5 is neutral, toward 1 the paint wears to the base + surface, toward 0 grime gathers. + - She authors it to her standard. The add-on offers starting points: a region + mask baked from albedo clusters, and a wear alpha baked from curvature and AO. +- **Blender material slots become glTF materials.** These are surface kinds, such + as bare metal, glass and lights, each with its own factors. +- **`ShipAuthoring.Paint`** (key 5, a `HullPaint`), typed in the package: + - `Mask`, the mask's relative path; + - the factory `Livery`: three colours, a metallic and smoothness per slot, wear + and grime; + - `RegionLuminance`, each region's mean albedo luminance under the mask, derived + at Package. + + Null means an unpainted hull. It renders with the GLB's look, and still fades + and is lit like a prefab hull. + +**What the game owns.** +- One shader, `Aetheria/Hull`, built on `GlowFade`'s fade, dither and volumetric + ambient. It samples the mask on UV0. Its rules: + - Painted albedo is the slot colour × saturate(lum(base) / region luminance). + - Wear: where alpha is above 0.5 and a tiling noise is under `_Wear`, paint + chips to the base. + - Grime: below 0.5, `_Grime` darkens and roughens. + - Painted metallic and smoothness come from the slot's finish. + - It has no `shader_feature` keywords, so build stripping cannot drop a variant. +- **Mask import: linear, DXT5, with mips.** + - Linear, because the channels are weights and not colours: an sRGB decode + would turn a 0.5 wear value into 0.21. M18 shows Blender writes the values + raw, so the decode must not apply a transfer either. + - DXT5 (BC3) is a quarter of RGBA32's memory: a 2048² mask with mips is about + 5.3 MB instead of 21 MB. + - Its alpha block is the best-kept channel, and the alpha carries wear. Region + edges lose only within a 4×4 block, about 2 cm on a 40 m hull at 2048. +- **`ShipModShips.PreloadAsync`** reads the PNG with `File.ReadAllBytes` and + `ImageConversion.LoadImage` into a linear texture with mips, compresses it, and + makes it non-readable. +- **`HullMaterialGenerator`,** a glTFast `IMaterialGenerator` with one instance + per package, clones `ShipModTemplate.Hull` with the GLB's slots and the hull's + mask. No glTF shader is needed at runtime. +- **`ShipModShips.ApplyPaint`** sets the resolved livery through a + `MaterialPropertyBlock` in `ShipInstance.SetEntity`. Property blocks do not + survive `Instantiate`. +- **Livery resolution** is one pure function, `HullPaint.Resolve`: the owner + faction's `Livery` (`Faction` key 16, cut `ships-hull-livery`) when set, else + the factory livery. The mask and region luminance are always the hull's. + +**Role materials and the two mount meshes** (ruling +`thrusters-radiators-are-meshes`): +- **Thruster:** a mesh carrying the invisible material. It is the exhaust's + emission shape, and its normals point aft. +- **Radiator:** a mesh rendered with the template's `Radiator` material, which + `EntityInstance` heats. +- **Weapons** stay point anchors. + +**Out of release scope, as follow-ups:** +- `hull-decals`. With a mask texture, painted-in logos can live in the mask; only + separate insignia and damage decals remain. +- `hull-damage-wear`. +- `hull-player-paint`, unless `hull-livery-scope` rules otherwise. + +#### Per-hull steps + +Ruling `hull-paint-mask-texture` sets the time budget as hers. These are the steps +and who does each. + +| Step | The operator | The add-on | +|---|---|---| +| P1 Tripo export | Generate; try PBR and delight once (below) | — | +| P2 New Ship | id, name, reference hull, length | `create --like`; the Tripo mesh moved into `Source` and oriented and scaled; a draft grid rasterised from it | +| P3 Rebuild | Retopologise the nonsense geometry under Ship Root, at its final scale, with her own tools | Rasterise again from her meshes | +| P4 Unwrap | A clean UV0 | Package refuses a mesh without UV0 or with UV0 outside [0, 1] | +| P5 Rebake | Check and fix the result | Bake From Source: Selected to Active from `Source`, albedo (colour only) and normal, wired into her material | +| P6 Mask | Paint the mask to her standard | New Mask; Suggest Regions; Bake Starting Mask; Bake Wear (curvature and AO into alpha); factory colours from the clusters | +| P7 Surface kinds | Material slots for glass and lights | — | +| P8 Mounts | The faces for each thruster and radiator, the cursor for each gun | Emitter normals aft; hardpoint rows on the cells beneath | +| P9 Layout | Internal hardpoints, cell touch-ups | — | +| P10 Package | — | Recentring, collider, map icon, shield, tractor, texture cap, GLB, `ship.mask.png`, region luminance, records, validation | +| P11 Look | Dock, `give`, fly | — | + +Dropped with the rebuild: +- `Select Debris` and the mesh report: her new geometry replaces the debris. +- The triangle-budget warning: her topology is her standard. + +The texture cap stays because it bounds runtime memory. + +**P1, the Tripo export check: a step for the operator to try.** +- Tripo's API defaults to `pbr=true`, which yields base colour, metallic, + roughness and normal maps. Texture version `v3.5-20260815` adds `delight` + (default true). +- The hulls in `Quiet.blend` arrived with base colour only, and why is unknown. +- A delit, PBR source makes Bake From Source's albedo cleaner and gives a + metallic-roughness map to bake too. +- Try one hull on the site and through the API. Nothing waits on it. + +#### Cuts + +*Revised by "Hull materials as owned graphs" below: `ships-hull-material` r3, `ships-addon-paint` r3 and `ships-hull-livery` r2 replace the revisions named here.* + +- **`ships-hull-material` r2:** the shader, the generator, `HullPaint` with + `Mask`, the mask's PNG checks in `Bind`, the linear DXT5 load and `ApplyPaint`. +- **`ships-hull-livery` r1** (unchanged): `Faction.Livery`. It waits on + `hull-livery-scope`. +- **`ships-addon-paint` r2:** + - the mask slot and New Mask; + - Suggest Regions and Bake Starting Mask; + - Bake Wear; + - the UV0 and mask checks; + - the mask export; + - `ship-authoring paint --mask`. +- **`ships-addon-package` r2** (unchanged): `validate` reads the GLB; `Bind` + refuses a thruster or radiator node without a mesh. +- **`ships-addon-frame` r3:** `Source`, rasterising from her meshes, the export + leaving out `Source`, and Bake From Source. No debris tools. +- **`ships-addon-mounts` r3:** face-based thruster and radiator mounts on her + meshes, and no triangle warning. +- **`ships-player` r2** (unchanged): the no-`glTF/`-shader check, radiator + material, emitter probe. `*.png` is already LFS. + +Order: `ships-mounts`, then `ships-addon-package`, then `ships-addon-frame`, then +`ships-addon-mounts`. `ships-hull-material` runs beside the add-on cuts after +`ships-mounts`. `ships-addon-paint` needs both lines; `ships-hull-livery` and +`ships-player` need `ships-hull-material`. + +#### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Hull paint | `ShipAuthoring.Paint` in the package | Mask path and factory livery written at Package from the panel; region luminance re-derived every Package | `ship-authoring paint` writes it; `ShipAuthoringStore.Validate` judges it | +| Livery mask | `ship.mask.png` in the package, named by `Paint.Mask` | Authored by the operator in Blender; capped copy exported every Package | The operator; `Bind` judges the PNG header | +| Bake source | The `Source` collection in the `.blend` | Imported once from Tripo; never exported | Input to Bake From Source and to the first rasterise only | +| Faction livery | `Faction.Livery` in `Aetheria.cc` | Authored in the inspector; null keeps factory paint | The catalog | +| Hull material | One clone of `ShipModTemplate.Hull` per glTF material | Built at preload with the hull's mask; cache only | `HullMaterialGenerator` | + +#### Rationale + +**Why a sidecar PNG loaded by `ImageConversion`, not a texture inside the GLB.** +- Inside the GLB, the mask would have to be referenced from `extras`. That means a + post-export rewrite of the GLB (Blender cannot write a texture index into + extras), glTFast's Newtonsoft `ImportAddon` to read it, and load-bearing JSON + where the package's other bindings are typed. Binding by image name instead is + the name-as-authority the assembler is forbidden. +- glTFast has no loose-image entry point (M14). `ImageConversion` is built into the + engine, so the sidecar costs one typed field, one header check and about a dozen + lines of load code. +- Blender saves the image directly (M18). + +**Why a generator instead of post-import material swaps.** +- glTFast calls the generator once per glTF material, with the slots already + parsed. +- Swapping afterwards would leave glTF shaders as build inputs and would decide + materials by renderer. + +**Why the tint still divides by region luminance.** +- Her rebaked albedo may keep Tripo's colours under painted areas. +- Dividing by each region's mean keeps panel shading at any livery colour without + asking her to author a greyscale albedo. The factory livery's colours, + suggested from the cluster means, reproduce the baked look. + +**Why the bake helpers are offered at all.** Selected-to-Active needs a selection +order, a cage or ray distance, the colour-only contributions, new images in the +right colour space and node wiring, and that setup is repeated per hull and per +map. Clustering and the curvature-and-AO alpha are starting points she paints +over. Each `ships-addon-*` operator check asks which helper earned its place. + +**Why thrusters duplicate faces but radiators separate them.** +- A thruster's mesh is never drawn, so the hull keeps its nozzle. +- A radiator's faces are drawn with the radiator material. Leaving them in the hull + would draw the surface twice. + +### Hull materials as owned graphs + +This section designs the hull material system as one piece. It replaces the +mask semantics, the `HullPaint` shape, the shader rules and the livery +resolution of "The hull material model" above. M13 to M18 and the per-hull steps +stand, except P6, which is restated below. + +The operator's words, in order: +1. `hull-runtime-materials`: "we want more runtime material control than just a + single baked map. See how MechWarrior does it". +2. `hull-paint-mask-texture`: "I want a mask map per ship for livery, I'll be + rebuilding the nonsense geometry and unwrapping anyway". +3. `mask-channels-place-materials`: "If each channel determines placement of one + material, then factions can fly their colors by setting parameters on those + hull materials (primary, secondary, trim) and we'd ship eventual faction skins + as their own material map... If we also want procedural wear and buildup, we + can bake input maps for that, too." +4. `clean-break-from-substance`: "Let's make a clean break from substance, it's + dead". +5. Not yet a ruling: "Authoring materials as graphs in blender is pretty + intuitive, if we're owning material generation, could we not set things up so + that we can evaluate the same graph?" +6. Not yet a ruling: "We may not be allowed to pull blenders math into our + runtime, but nothing prevents us from putting our math in blender". +7. `materials-baked-per-ship-incremental`: "triggering texture bakes per ship + only when they spawn or change, and then also only recomputing the parts of + the graph that change, which would be just wear for most dynamic changes". +8. `shaders-own-every-pixel`: "our shaders own every pixel". + +Surveys: `F:\Projects\blender-graph-runtime-prior-art.md`, +`F:\Projects\aetheria-texture-graph-prior-art.md` and +`F:\Projects\aetheria-hull-materials-prior-art.md` (Eyes, 2026-10-03). Anchors are +against Aetheria `239f3599` (origin/master, which contains `ships-mounts` and the +render fixes) and CultLib `a7966142`. Probe scripts are in this pass's scratch +(`matprobe/probe_bake.py`, `probe_dump.py`, `bench/Program.cs`). + +#### Body facts + +**M19. The operator's Blender is 5.2.2 LTS from Steam, and Steam updates it.** +- `C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe` reports + `5.2.2 LTS`, build hash `d13f752e3b9c`, built 2026-09-15. It is the only + Blender in the uninstall registry. A stale 5.0.1 sits in + `D:\Steam\SteamApps\common\Blender`. +- Steam replaces the build when a new one ships. Node semantics changed in 5.0 + (Voronoi hashing), so a silent update can move her previews away from the game. + +**M20. A Cycles Emit bake is an exact point oracle.** Probe `probe_bake.py`, +Blender 5.2.2, a 64×64 float input image of coordinates read with Closest +interpolation into a graph, baked by Emit into a 64×64 float image (margin 0): + +| Probe | CPU | OptiX (GTX 1070) | +|---|---|---| +| P1 identity: the input texel reads back | 4096/4096 exact | 4096/4096 exact | +| P2 `a*b + c` in two Math nodes | 4096/4096 equal float32 `(a*b)+c`; fused would differ at 1011 | same | +| P3 CultMath's float-only `mod289((x*34+10)*x)` as Math nodes | 4096/4096 exact | 4096/4096 exact | +| P4 Noise Texture (3D, detail 0): two bakes | bit-identical; 0.5 at integer points | bit-identical | +| P5 an OSL Script node (`In[0]*3+1`) in the bake | 4096/4096 within 1e-6 | no result: killed after 4.5 minutes, apparently compiling OSL | + +- CPU and OptiX noise agree at 2,764 of 4,096 texels, at most 1.19e-7 apart + (1 ulp near 1). Arithmetic agrees bit for bit. So the oracle is CPU Cycles, + and noise parity is a ULP tolerance, never bit equality, once a GPU is in the + chain. +- Cycles' CPU kernels do not contract multiply-add (P2), so float-only + arithmetic is reproducible from C#. + +**M21. The add-on can dump a node group faithfully.** Probe `probe_dump.py`, +Blender 5.2.2: +- Group interfaces have panels. A socket reports its panel, type, default, + min, max and a stable identifier (`Socket_3`). Panels are typed structure, so + a socket's scope (Livery, Ship, Maps) needs no name parsing. +- `ColorRamp.evaluate(t)` and `CurveMapping.evaluate(curve, t)` run in Python, + so ramps and curves export as sampled tables in Blender's own evaluation (a + B-spline ramp gives 0.1667, not 0, at t = 0). +- A group instance is `ShaderNodeGroup` with `node_tree`. Mix exposes + `data_type`, `blend_type` and both clamps. Noise exposes dimensions, type and + normalize. Math has 41 operations. +- Shader nodes have no Bit Math or Integer Math in 5.2.2. + `FunctionNodeBitMath` exists for geometry nodes only. PCG hashes cannot be + built in shader nodes. + +**M22. A C# tape interpreter is cheap for arithmetic and slow for noise.** +Probe `bench`, .NET 10 RyuJIT on Starfire (Ryzen 3 3100, 4 cores), a scalar SSA +tape interpreted over 256-texel batches at 2048²: +- 154 arithmetic instructions: 788 ms on one thread (1.2 ns per + instruction-texel). +- Adding 24 CultMath `snoise(float3)` calls: 23.5 s on one thread, 7.1 s on four + (about 230 ns per noise call per texel). +- Unity runs Mono, which is slower than RyuJIT. That was not measured. + +A full-resolution noise graph costs seconds per bake on the CPU. Aetheria +already dispatches compute shaders at runtime (M23), where the same work takes +milliseconds. + +**M23. Aetheria's compute and CultMath substrate.** +- Runtime compute already ships: `ShieldPanel.cs:293-439` dispatches + `ShieldSim.compute`; Stardust, Slime and Lightning have compute kernels under + `Assets/Shaders/Compute/`. +- `Packages/manifest.json:57` pins `org.gamecult.cultmath` at + `cultmath-unity-v0.2.4`. CultLib has tagged `cultmath-unity-v0.3.0`. + `Aetheria.Shared.csproj:27` compiles CultMath from source. +- No Shader Graph or SRP package is in the manifest. + +**M24. Faction key 16 is taken twice.** `ships-hull-livery` r1 puts `Livery` at +`Faction` key 16. Rulings `faction-relations-field` and `faction-doctrine-typed` +put Relations at 16 and Doctrine at 17. `Corporations.cs` uses keys up to 15. +The livery takes key 18. + +**M25. CultMath's seams for new shader code.** +- `CultMath.hlsl:509-510` includes Phacelle and Interval. +- `GlslLowering.SeparateFiles` (`GlslLowering.cs:19-24`) lowers a + separately-licensed include into its own GLSL file. Its one entry is MPL-2.0 + Phacelle. +- C# has `asuint` and `asfloat` (`math.cs:390-391`) but no `uint2/3/4` types. + Cycles' hashes are scalar `uint`, so they port without new vector types. + +#### The system in brief + +``` +Blender (author, preview) AetherDb (judge) Aetheria.cc + node group "Hull Standard" --> material-graph put ----------> MaterialGraph record + + per-hull wrapper material whitelist, contract, cones (truth) + | + v lowering (Aetheria.Shared) + CultMath expression IR --> HLSL kernels: pair, frontier, wear + | (generated, committed, dxc-checked) + v reference evaluator (C#, tests only) +Unity: MaterialBakes -- dispatch per (hull, livery) and per wear set --> baked textures + Aetheria/Hull -- samples Surface, Worn, Wear, plus the GLB's normal, occlusion, emission +``` + +**A material graph is one per-texel function:** +- **Inputs:** + - image roles sampled on UV0 from two package PNGs: + - `ship.mask.png`: R, G, B place the primary, secondary and trim materials; A is + an authored wear hint. + - `ship.maps.png`: R ambient occlusion, G curvature (0.5 flat), B cavity, A grunge. + - parameters in two scopes. Livery parameters are set per faction, with a + factory default per hull. Ship parameters are set per ship, such as wear level. +- **Outputs**, a closed vocabulary: + - Surface Color, Surface Metallic and Surface Roughness: the finished livery; + - Worn Color, Worn Metallic and Worn Roughness: what wear reveals; + - Wear, from 0 to 1. + +**Authoring in Blender.** +- A library `.blend` holds node groups built from whitelisted nodes. The interface + panels are `Livery`, `Ship` and `Maps`. A Maps socket's name is a role from the + closed vocabulary. +- The per-hull material her add-on sets up is a wrapper: + - the group instance; + - Image Texture nodes for her mask and maps; + - a preview that mixes Surface and Worn by Wear into a Principled BSDF, the same + combination the game's shader makes. +- The wrapper is display only. Only the group is exported. + +**The release whitelist** is Blender's primitive and converter nodes, with +Cycles semantics ported into CultMath: +- Math (all 41 operations), Vector Math (component-wise operations), Clamp, + Map Range (Float, all four modes); +- Mix (Float, Vector, and Color with every blend mode); +- Color Ramp and Float Curve, as tables sampled in Python; +- Separate and Combine XYZ and Color (RGB mode), RGB to BW, Invert; +- Value, RGB, Reroute, Frame, Group Input and Output, and nested groups (flattened). + +Procedural textures (Noise, White Noise, Voronoi, Wave, Gradient) follow the +release, as question `release-procedural-noise` proposes. Per ruling 3, release +wear comes from baked maps. Grunge is baked into `ship.maps.png` A. + +**Bakes, dirty sets and the memory budget.** +- The judge computes each output's cone (the nodes it reads) and each node's + scope: hull (reads only images), livery, or ship. Surface and Worn cones may + not read a Ship parameter; the judge refuses that. So every per-ship cost is + the Wear output alone. +- The **frontier** is the set of values in Wear's cone that read no Ship + parameter but feed one that does. At most four scalars, or the judge refuses + the graph, so they fit one RGBA16F texture. +- Three kernels per graph, generated from the record: + - **pair**: Surface at R and Worn at R/2, for each (hull, skin, livery). R is + 2048 by default and 1024 at low quality. + - **frontier**: the frontier values at wear resolution, once per pair. + - **wear**: only the ship-scope nodes of Wear's cone, reading the frontier, + per distinct set of Ship parameters. +- A Ship parameter change re-runs only the wear kernel. That is ruling 7: cost + in proportion to the nodes that changed. A livery change is a different pair. +- Caches are content-addressed. A pair key hashes the graph record, both PNGs, + the skin, the livery bindings and R. A wear key hashes the pair key and the + Ship parameter values, quantised to 1/64. Ships with identical inputs share + every texture. Entries are refcounted by live bodies and released at zero. +- Budget, at R = 2048, with BC1 (DXT1) through `Texture2D.Compress` after an async + readback: + +| Texture | Per | Size with mips | +|---|---|---| +| Surface Color and Surface MR (R metal, G roughness) | pair | 2 × 2.7 MiB | +| Worn Color and Worn MR at 1024² | pair | 2 × 0.67 MiB | +| Frontier, 512² RGBA16F, no mips | pair | 2 MiB | +| Wear, 512² R8 | wear set | 0.33 MiB | + + - That is about 8.7 MiB per pair and 0.33 MiB per ship. At R = 1024 a pair is + about 2.6 MiB. + - A zone with 20 ships in 6 (hull, livery) pairs holds about 59 MiB. Per-ship + full bakes would hold about 320 MiB. + - Each bake also needs about 70 MiB of transient upload and render textures, + released once its readback completes. + - Until the compressed copy exists, the ship samples the render textures directly. + +**The runtime shader.** +- `Aetheria/Hull` is `GlowFade`'s surface shader: the same `noambient` volumetric + ambient, the same dither fade by `_Fade` and the same edge colour. +- The albedo, metallic and roughness are `lerp(Surface, Worn, Wear)`. The normal, + occlusion and emission come from the GLB's slots. +- It has no paint logic, no keywords beyond GlowFade's own, and nothing evaluated + per frame but that one lerp. The lerp is fixed by the output contract. + +**The livery.** +- `HullPaint` (ShipAuthoring key 5) names: + - the graph; + - the two PNGs; + - the factory livery: bindings of the graph's Livery parameters. +- `Faction.Livery` (key 18) holds bindings of the same parameters and wins over the + factory livery when that faction flies the hull. `HullPaint.Resolve` is the one + resolution. +- A binding names a parameter by its interface identifier. The catalog test checks + every binding against the graph: it must exist, be in scope and be in range. +- Faction skins (another mask per hull) are a later key on `HullPaint`, chosen by + the livery. + +#### Authority map + +- **Owner.** The `MaterialGraph` record in `Aetheria.cc` owns what a hull material + computes. CultMath owns what each node means: Cycles semantics ported once, in + HLSL with a C# mirror. `MaterialBakes` owns when and at what resolution a graph + is evaluated. `HullPaint.Resolve` owns which livery a body shows. + `Aetheria/Hull` owns how the three outputs and the GLB slots become a pixel. +- **Inputs.** + - The judge reads the Blender dump. + - The lowering reads the record. + - `MaterialBakes` reads: + - the generated kernels; + - the package PNGs; + - the resolved livery; + - the body's Ship parameters. +- **Outputs.** The record; the generated kernels; the baked textures per pair and + per wear set; one property block per hull renderer. +- **Derived state.** + - The kernels are derived from the record and committed. A test fails when they + differ from the lowering. + - Baked textures are cache only. + - The Blender wrapper and its preview are display only. + - Cones, scopes and the frontier are recomputed by every judge and lowering. +- **Forbidden writers.** + - Unity Shader Graph, Mixture and any glTF/ shader on a mod hull. + - Any per-frame graph evaluation in the hull shader. + - Python encoding a `MaterialGraph` or `HullPaint`. + - Blender's GPL EEVEE (`gpu_shader_material_*`) or blenlib noise code in any + CultLib or Aetheria file. + - A material, role or output chosen by a name outside the closed vocabularies. + - A second livery resolution. + - `Faction.PrimaryColor` and `SecondaryColor`, which stay map colours. +- **Shared paths.** Editor play, the preview smoke, the play smoke and the built + player all bake through `MaterialBakes` from the same kernels. Every AetherDb + path (`put`, `check`, the catalog test) judges through one `MaterialGraphJudge`. +- **Deletion line.** + - r2's region tint (`_PaintStrength`, `RegionLuminance`, a tiling `_WearNoise`) + and its `Livery` of three colours are never built. + - Revision 1's vertex mask is gone. + - The 41 `.sbsar` files go with follow-up `remove-sbsar-files`. + +#### Parity + +1. **Node semantics, in CultMath.** + - Each ported function's HLSL mirrors its C# with bit equality, through the + existing mirror tests. + - It compiles under dxc and lowers to GLSL through the existing + `GlslLowering`. Each new licence file gets a `SeparateFiles` entry. + - The C# matches a **Cycles oracle fixture**. A GPL-headed tool script, run only + under the pinned Blender (5.2.2 LTS, hash `d13f752e3b9c`), bakes each family + on the CPU the M20 way. It refuses any other version. The fixture records the + version and hash. + - Tolerance is per family: exact for arithmetic (M20 P2, P3), and a stated ULP + bound wherever Cycles uses library transcendentals. +2. **Lowering, in Aetheria.** + - A test graph uses every whitelisted node and blend mode. The add-on's test + script dumps it and bakes all seven outputs on a 64² fixture under the pinned + Blender. The fixture (dump, inputs, outputs) is committed. + - The test lowers the dump and runs CultMath's reference evaluator, then + compares per output within the family tolerances. + - This pins the node-to-function mapping. Neither side's unit tests can see that + mapping. +3. **Kernels.** + - The generated HLSL is committed and must equal the lowering's output. + - Each kernel compiles under dxc. + - A Unity batch smoke on Starfire dispatches the fixture graph on the GPU and + reads back. It compares with the reference evaluator within 2 ulp plus the + family tolerance. M20 shows a GPU and a CPU Cycles already differ by 1 ulp. +4. **Drift.** + - The add-on stamps the Blender version into each dump. + - The judge refuses a dump from a different major and minor version than the + pinned semantics, naming both. + - Updating Blender is a deliberate cut: regenerate the oracle fixtures, read the + diff, and move the pin. + - Before then, the operator stops Steam updating Blender, or keeps a pinned + portable 5.2.2 for authoring. + +#### The tape + +The material evaluator is not a second tape if it owns no evaluation semantics. +- It lowers into CultMath's scalar expression IR: SSA values, operations that + are CultMath functions, named inputs, parameters and several outputs, with each + instruction's input-dependence mask. +- Two backends exist now: an HLSL emitter, and a reference evaluator for tests. +- The tape target's step 2 adds bytecode with point, interval and gradient + evaluators over the same IR. Step 4 adds an HLSL interpreter of that bytecode. + Neither is built now. Ruling `tape-target-unparks-when-asura-stable` stands + for both. +- What lands now is the IR the tape will consume. Its point evaluator is only a + test reference, not a runtime path. + +A minimal evaluator is needed now, because ruling 7 requires bakes at spawn and +at change, and the release hulls need faction colours (`content-bar-one-per-concept`). + +Mod-authored graphs need the tape's GPU interpreter, because Unity cannot compile +a shader at runtime. Until then a mod package uses the shipped graphs with its +own PNGs and livery values: the MWO model, per the operator's ruling 3. + +Question `material-ir-in-cultmath` asks whether this IR may land in CultMath +while the tape stays parked. + +#### Cuts + +CultLib, campaign `cultmath-tapes`: +- **`cycles-converters`.** The Cycles oracle tool and fixture harness. Ports of + Cycles' Math, Map Range, Clamp, Mix (every blend mode), RGB to BW and ramp-table + lookup, in an Apache-2.0 file pair. +- **`expr-ir`.** The scalar expression IR, dependence masks, cones and frontier, + the reference evaluator and the HLSL emitter. +- **`cycles-noise`.** Ports of Cycles' hash, Perlin, fBM, the Noise Texture node + and White Noise (BSD-3 and Apache file pairs). This follows the release. +- The CultMath Unity release that ships the new HLSL is a release cut (follow-up + `cultmath-unity-material-release`). Aetheria's Unity pin must move to it before + `hull-bakes`, whose kernels include those files. + +Aetheria, campaign `aetheria-release`: +- **`material-graph`.** The record, the dump, `MaterialGraphJudge` (whitelist, + vocabularies, scopes, the contract, the version pin), and AetherDb + `material-graph put|check`. +- **`material-lowering`.** Lowering to the IR, the generated kernels and their + equality test, and the whole-graph Blender oracle. +- **`material-library-export`.** The add-on's dumper, Export and Check calling + AetherDb, and the library `.blend` convention. +- **`hull-bakes`.** `MaterialBakes`: dispatch, the content-addressed tiers, + refcounts, readback and compression, and a GPU parity smoke. +- **`ships-hull-material` r3.** `HullPaint` reshaped, the PNG checks in Bind, + `Aetheria/Hull`, the generator and `ApplyBakes` from `SetEntity`. +- **`ships-addon-paint` r3.** The maps bake into `ship.maps.png`, the mask tools, + Set Up Hull Material (the wrapper), and Package writing both PNGs and `Paint`. +- **`ships-hull-livery` r2.** `Faction.Livery` at key 18, as bindings checked + against the graph. + +Order: +- `cycles-converters`, then `expr-ir`, then the CultMath Unity release. +- Then `material-graph` (it may start at once; it needs neither CultLib cut), + `material-lowering`, `hull-bakes`, `ships-hull-material` r3 and + `ships-hull-livery` r2. +- `material-library-export` follows `material-graph`. +- `ships-addon-paint` r3 needs `ships-addon-mounts`, `material-library-export` + and `ships-hull-material` r3. +- `ships-player` keeps its dependency on `ships-hull-material`. + +**What blocks the release hulls:** every cut above except `cycles-noise`. + +**What can follow:** +- `cycles-noise` and Voronoi, Wave and Gradient; +- CultMath-native nodes in Blender; +- the tape's GPU interpreter for mod graphs; +- faction skins; +- follow-ups `hull-damage-wear` (the first real Ship parameter), `hull-decals` + and `hull-player-paint`; +- detail normals per material; +- a GPU BC encoder, if the readback-and-compress hitch measures badly. + +#### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Material graph | `MaterialGraph` in `Aetheria.cc`, keyed by its stable id (the group's name lowered, as ship ids are) | Exported from the library `.blend` by `material-graph put`. Re-exported whole on change, never edited in place. The kernels are regenerated in the same command | `MaterialGraphJudge` admits it. The record is truth and the kernels are derived | +| Hull paint | `ShipAuthoring.Paint` (key 5) in the package | Written at Package. A graph id, two PNG paths and the factory livery bindings | `ship-authoring paint`; Validate, and the catalog test for bindings | +| Mask and maps PNGs | `ship.mask.png`, `ship.maps.png` in the package, named by `Paint` | Authored and baked in Blender; exported every Package | The operator. `Bind` judges the headers | +| Faction livery | `Faction.Livery` (key 18) | Authored in the inspector; null keeps the factory livery | The catalog; checked against the hull's graph | +| Pair bake | Content hash of graph, PNGs, skin, livery and R | Baked when the first body needs it; released when the last is gone | `MaterialBakes`. Cache only | +| Wear bake | Content hash of the pair key and quantised Ship parameters | Re-baked when a body's Ship parameters change | `MaterialBakes`. Cache only | +| Cycles oracle fixture | A file per family in CultMath tests, stamped with the Blender version | Regenerated only by a pin-moving cut | The tool script under the pinned Blender | + +#### Rationale + +**Why generated compute kernels and not a CPU interpreter.** M22 puts a +noise-bearing full-resolution bake at seconds on the operator's CPU, under a +faster JIT than Unity's. Aetheria already ships runtime compute (M23). The graphs +are game-owned, so kernels can be generated ahead of time and compiled into the +build, where a runtime shader compile is impossible. The cost is that mods cannot +bring graphs until the tape's GPU interpreter exists. That is question +`material-evaluator-backend`. + +**Why the per-ship cost is the Wear output alone.** Prior art bounds memory one of +two ways: +- a shared base with a small per-instance mask, as in Destiny's gearstack wear + mask, MW5's wear masks and Star Citizen's vertex-alpha wear; +- a cap on instance count. + +Making it a rule of the output contract, enforced by the judge, makes the budget +structural rather than a convention. The lerp in the shader is the contract's one +fixed line, so the shader owns no material meaning. + +**Why the frontier is cached at wear resolution.** Ruling 7 asks that a wear change +recompute only what depends on wear. Caching every node at full resolution is +Mixture's documented failure: memory linear in node count. One RGBA16F texture at +512² per pair costs 2 MiB and makes a wear bake a few dozen instructions over +262k texels. + +**Why Cycles semantics are ported, not reimplemented from the manual.** +- The manual leaves Mix's blend modes, Map Range's smooth modes, ramp + interpolation and Smooth Min unspecified (Eyes §3d). +- The Cycles kernel files are Apache-2.0, and `noise.h` is BSD-3. +- Porting them into licence-headed CultMath files, as Phacelle is kept under + MPL-2.0, gives the exact semantics with provenance. +- Ramps and curves need no port: Python samples Blender's own evaluation into + tables (M21). + +**Why the oracle is a CPU bake.** It is exact for arithmetic and reproducible +(M20). A GPU bake differs by an ulp on noise, and OSL on OptiX did not finish. + +**Why the record holds the authored graph, not the IR.** The lowering will change +as ports are added and the tape arrives. The authored graph is what she made, and +the IR is a cache of one lowering of it. + +**Rejected.** +- Unity Shader Graph: its Lit target adds Unity's ambient, and ruling 8 makes our + shaders own every pixel. +- Mixture: it adds Shader Graph and SRP Core, and its memory grows with node count. +- Material Maker: a second authoring application with no runtime parameter path. +- MaterialX: it has no HLSL generator, Blender's export is partial, and its noise + is not Blender's. +- OSL as an authoring route: Cycles only, no EEVEE preview, and no result on OptiX + (M20 P5). +- Region-luminance tint (r2): every channel now places a whole material. + +### Ship data all in the `.cc` + +The operator, 2026-10-03, admitted as ruling `ship-data-all-in-cc`: "I want all +the data to live in the .cc. there's no reason our schemas can't hold everything +we need and it means we benefit from the cross runtime guarantees CultLib +offers". It restates MQ1 (2026-09-30), "a ship .cc holds everything", which the +GLB path contradicted. + +This section moves the hull's geometry, node tree, surfaces and images out of +`ship.glb` and the planned PNG sidecars into one typed record in `ship.cc`. It +changes the package format, the Package action, the validator, the runtime +loader and five specs. Anchors are against Aetheria `origin/master` `4895c752` +and CultLib `main` `a7966142`, read on 2026-10-03. + +#### Body facts + +**M26. The GLB is read in three places, and only for its scene graph and +meshes.** +- The add-on exports it at Package with `export_extras` and `export_yup` + (`aetheria_ships/__init__.py:343-366`, called at `:385`). The anchors and the + layout already go into the `.cc` (`:386`, `ship_cc.py:116-140`). +- `ShipModCatalog.Bind` reads only the JSON chunk's node `extras` and whether each + node names a mesh (`ShipModCatalog.cs:168-218`, through Newtonsoft). AetherDb + `validate` reaches it through `ReadPackage` when the file exists + (`ShipAuthoringCommands.cs:34-45`). +- `ShipModVisual.LoadAsync` imports it with glTFast and maps anchors to node + indices (`ShipModVisual.cs:33-60`). The assembler then reads three things per + node (`ShipModShips.cs:85-120`): + - the `MeshRenderer`, for the map icon, the thrusters and the radiators; + - the readable `MeshFilter` mesh, for the hull collider; + - the shield marker's local scale. +- glTFast has no other consumer: + - `ShipModVisual.cs` is its only `using GLTFast`, and `Packages/manifest.json:9` + is its only pin. + - `ShipFixture.cs:29-85` hand-builds a GLB for the tests. + - `ShipModPreview.cs:28,125` goes through `LoadAsync`. + +**M27. No package is committed.** +- `git ls-tree` of `4895c752` holds no `ship.cc`, `.glb` or `GameData/Mods` path. + Only the operator's local drafts exist, and Package landed today (`7d649d5c`). +- `.gitattributes` puts `GameData/*.cc` and `*.png` in LFS. + `GameData/Mods//ship.cc` matches neither rule. + +**M28. CultCache sets no record size limit, but the single-file store works on +the whole file.** +- C#: `SingleFileBackingStore` reads the snapshot whole and deserializes every + record (`CultCache.cs:3290-3320`). It writes through a temp file and + `File.Replace`. It refuses a schema that is not registered (`ToStoredDocument`, + `:3209`). So every reader of a ship file must register every record type in it. +- Python: the MessagePack store's `push` pulls the whole store, replaces one + envelope and rewrites the file (`stores.py:103-121`). A record it did not + touch is carried as raw bytes. +- The binding limits are MessagePack's bin32 (4 GiB per value) and .NET's 2 GiB + array. A ship file at tens of MiB is far inside both. The real cost is that + every Save Layout rewrites the whole file. +- When Python writes a record kind first, it gets `_default_catalog_entry`: an + empty member list (`stores.py:356-368`). So Python may only replace payloads of + records whose catalog entry C# wrote. That matches the standing rule that + Python never builds a `HullData`. + +**M29. CultLib's typed mesh is a chunk payload, not a model.** +- `GameCult.Geometry` has `CultGeometryTriangleMesh`: positions, normals, UVs, + indices and a material per triangle (`CultGeometryDocuments.cs:370-395`). +- It has no tangents, no submesh ranges, no node tree and no images. +- Its fingerprint keys the `chunk_artifact` record, so a field added to it + changes chunk keys. +- It has no Python, TypeScript or Rust mirror. +- `GameCult.Geometry.dll` is not in the CultLib Unity package + (`unity/org.gamecult.cultlib/Runtime/Plugins`). + +**M30. Blender's conventions already line up with the runtime.** +- `ShipPolyline` stores Blender's right-handed Z-up space, and the runtime + converts with (−x, z, −y) (`ShipModVisual.cs:85-112`). +- Blender's UV origin and `Image.pixels` row order are bottom-left, and so are + Unity's mesh UVs and `Texture2D.LoadRawTextureData`. glTF is top-left, which is + why the exporter and glTFast each flip V. +- Both facts come from the documentation. A probe in `ships-cc-model` proves + them. + +#### The design in brief + +``` +Blender collection ship.cc (one file, the only truth) + objects, evaluated meshes --+ HullData mod-hull: (C# writes; Python edits layout) + material slots, images +--> ShipAuthoring mod-ship: (anchors, lines, later Paint) + (Package, Python, raw slots) | ShipModel mod-model: (nodes, meshes, surfaces, images) + | +AetherDb ship-authoring +-- seed-model (C# writes the record's catalog entry), validate +Unity preload: ShipModel -> GameObjects, Mesh, Texture2D (no importer) +Compose: HullData + ShipAuthoring into the derived catalog; ShipModel stays in the package +``` + +**`ShipModel`** is `[CultDocument("aetheria.ship_model", "1")]`, stored at +`mod-model:`. +- One record holds the whole body. +- Its space is Blender's: right-handed, Z up, metres, UV origin bottom-left. + +| Key | Member | Type | Meaning | +|---|---|---|---| +| 0 | `Id` | string, `[CultName]` | The ship id | +| 1 | `Nodes` | `List` | Parents before children | +| 2 | `Meshes` | `List` | Indexed by `ShipNode.Mesh` | +| 3 | `Surfaces` | `List` | Indexed by `ShipSubmesh.Surface` | +| 4 | `Images` | `List` | Indexed by the surface and paint slots | + +- **`ShipNode`:** + - `Id`: the node's `aetheria.id`, or null for an unanchored render node; + - `Parent`: an index, or −1 for the root; + - `Translation` (float[3]), `Rotation` (float[4], xyzw) and `Scale` (float[3]); + - `Mesh`: an index, or −1 for none. +- **`ShipMesh`:** + - one typed array per attribute: `Positions` (float[3n]), `Normals` (float[3n]), + `Tangents` (float[4n], w is the bitangent sign) and `Uv0` (float[2n]); + - `Indices` (uint[]); + - `Submeshes`, each with `IndexStart`, `IndexCount` and `Surface`. + + Triangles only. Bounds are not stored, because every runtime computes them. +- **`ShipSurface`:** + - `Name`: the Blender material's name, for display; + - `BaseColor`: an image index, or −1; + - `BaseColorFactor` (float[4]). + + `ships-hull-material` r3 appends the slots its shader reads, such as the normal + map and emission. +- **`ShipImage`:** + - `Width` and `Height`; + - `Format`, an enum open to later members: `Rgba8Srgb` and `Rgba8Linear` now, + plus the block formats if question `ship-image-encoding` rules them; + - `Levels` (`List`): mip 0 first, rows bottom to top, tightly packed. + + An image has no role of its own: the slot that names it gives it one. +- `ships-addon-paint` r3 appends `Mask` and `Maps` to `ShipModel` as image + indices. They are not PNG files. + +**`ShipAuthoring`.** +- Key 2 (`ModelAsset`) is retired and never reused, as key 1 was. +- Nothing in it names the model. The model sits at its deterministic key beside + the hull and the visual, and `LoadRecords` already enforces the keys of those + two. +- A file that still carries key 2 loads with no model record, so it is a draft. + Nothing is lost silently, because the GLB beside it was never the truth. + +**Why the model stays out of the derived catalog.** +- `Compose` rewrites `Aetheria.modded.cc` every boot (M28). Copying ten hulls at + 30 to 80 MiB each into it would rewrite hundreds of MiB per boot. +- So `Compose` keeps writing only the two semantic records. +- `PackageOf` opens `//ship.cc` read-only and takes `ShipModel` + from it. The game reads the body from the same file the operator authored. + +**Writer: Package, with no GLB.** +1. `AetherDb ship-authoring seed-model ` adds an empty `ShipModel` at + `mod-model:` when none is there. + - It is idempotent. + - C# writes the catalog entry, so Python only ever replaces a payload (M28). + - Drafts made before this cut take the same path, so no migration branch is + needed. +2. Python packs the body by slot in `ship_cc.replace_model`, pinned like the + anchor and hardpoint slots, and pushes it. The add-on builds the body: + - **Nodes** come from the bound collection, leaving out `Source` and Grease + Pencil. A node's TRS is relative to its nearest ancestor in the collection. + - **Meshes** come from the evaluated depsgraph, with modifiers applied: + - numpy, which Blender bundles, splits corners into vertices by position, + normal, tangent and UV; + - `calc_tangents` runs on UV0; + - each material slot becomes a submesh. + - **Surfaces** come from the material slots, and **images** from the images + those slots use: + - capped at 2048 (the cap moves here from `ships-addon-mounts`); + - read with `pixels.foreach_get`; + - written as RGBA8 in the image's colour space. + - The body is packed with `msgpack.packb(..., use_single_float=True)`. Without + that flag each float costs 9 bytes instead of 5. +3. `replace_visual` writes the anchors only. +4. `AetherDb ship-authoring validate` judges all three records. + +**Reader: Unity builds from records.** `ShipModVisual.Load` becomes synchronous. +- It makes a `GameObject` per node and converts TRS with the line mesh's + (−x, z, −y). That conversion moves into one `ShipSpace` helper shared by lines + and meshes. +- For each mesh it builds a `Mesh`: + - UInt32 indices above 65,535 vertices; + - positions, normals and tangents converted the same way; + - tangent w negated and each triangle's winding reversed, because the map is a + reflection; + - UVs passed through; + - the mesh kept readable for the collider. +- For each image it builds a `Texture2D` with `LoadRawTextureData`, generates + mips, runs `Compress`, and makes the texture non-readable. +- Until `ships-hull-material` r3, every surface takes one interim template + material with its base colour. r3 replaces it with `Aetheria/Hull`. +- These go: glTFast, `UninterruptedDeferAgent`, the `Nodes` instantiator + subclass, `NodeIndices` and `ModelPath`. + +**Validation moves onto records.** `ShipAuthoringStore.Validate(hull, ship, +model)` is the one judge. `model` is null for a draft. It checks: +- node ids are unique, and every anchor's `ModelNodeId` names one; +- parents come before children, and every index is in range; +- each `thruster-emitter`, `radiator-mesh`, `map-icon` or `hull-collider` node + has a mesh. Today the first two are checked at `Bind` and the last two only in + Unity; +- the streams: + - their lengths match the vertex count; + - every float is finite; + - every index is below the vertex count; + - submesh ranges lie inside the index buffer; +- the images: + - each level is sized exactly for its format; + - no side is over 2048; + - each image is in a colour space its slot allows. + +`Bind` keeps the directory and refs checks, and loses `ReadNodeIds`. AetherDb +`validate` always reads records, and says "draft, no model yet" when the model +is missing. + +**Sizes.** These are estimates for Headliner (50,008 triangles). +`ships-cc-package` measures them. + +| Part | Raw RGBA8, mip 0 | Block formats with mips | +|---|---|---| +| Mesh: ~30k vertices × 12 floats at 5 bytes, plus indices | ~2.3 MB (9.5 MB if every triangle were split) | same | +| One 2048² image | 16 MiB | 5.3 MiB (BC7 or BC5) | +| First cut: base colour only | ~19 MiB | ~8 MiB | +| After paint and r3: base, normal, emission, mask, maps | ~83 MiB | ~29 MiB | + +- No CultCache limit binds here (M28). +- What matters is that every Save Layout rewrites the file. At 80 MiB that is + roughly a second in Python, against milliseconds today. That is one input to + question `ship-image-encoding`. +- `GameData/Mods/*/ship.cc` needs an LFS rule (M27). `ships-cc-package` adds it, + so `ships-player` no longer has to. + +#### Authority map + +- **Owner.** + - `ship.cc` owns everything about a mod ship. + - `ShipModel` owns its body: nodes, meshes, surfaces and images. + - `ShipAuthoringStore.Validate` is the one judge of all three records. + - C# owns every schema and catalog entry. Python (`ship_cc`) is a pinned writer + of payloads that C# seeded. +- **Inputs.** + - Package reads the bound collection's evaluated meshes, materials and images. + - The validator reads the three records. + - `PackageOf` reads the package's `ship.cc`. + - `ShipModVisual` reads the `ShipModel` record. +- **Outputs.** + - The records. + - The prototypes' GameObjects, `Mesh` and `Texture2D`. These are cache only and + rebuilt every boot. +- **Derived state.** + - Bounds, the Unity-space conversion, mips and compressed textures are derived + at load. + - The `.blend` is the operator's source. It never decides a semantic. +- **Forbidden writers.** + - Any GLB, glTF or PNG file in a package. + - glTFast, or any other importer, on the mod-ship path. + - Newtonsoft reading a package. + - Python writing a record kind that C# has not seeded. + - A second validator, in Python or in Unity. + - `Compose` copying `ShipModel` into the derived catalog. +- **Shared paths.** Each of these reads one `ShipModel` through one `Validate` + and one `ShipModVisual.Load`: + - the add-on's Package; + - AetherDb `validate` and `compose`; + - the Editor preview and smoke; + - the play smoke; + - the built player. +- **Deletion line,** before the reader lands: + - `_export_glb` and `MODEL_ASSET`; + - `ModelAsset` and `MODEL_ASSET_SLOT`; + - `ReadNodeIds` and its Newtonsoft use; + - `Package.ModelPath` and `NodeIndices`; + - the `GltfImport` path and the glTFast pin; + - `ShipFixture.Glb` and `GlbJson`, and the GLB-shape tests + (`ShipModCatalogTests.cs:107-140`). + +#### Questions + +**`ship-model-owner`: who owns the model schema?** +- **a. Aetheria, now,** as `aetheria.ship_model`. It is shaped after glTF so that + a later lift into CultLib is a schema move with a migration. The lift happens + when a second consumer needs it (follow-up `cultlib-model-schema`). +- **b. CultLib, now:** `GameCult.Geometry` gains `gamecult.geometry.model`, ships + it in the CultLib Unity package, and Aetheria wraps it. +- **c. Extend `CultGeometryTriangleMesh`.** That changes chunk keys (M29) and + still has no node tree or images. Not recommended. +- **Recommendation: a,** with medium confidence. + - The guarantees the ruling names are CultCache's: typed envelopes, the schema + catalog, slot parity across runtimes, and Python writing pinned slots. They + hold for an Aetheria record exactly as they do for a CultLib one. + - A CultLib model type would have to serve a general consumer. That means more + UV sets, colours, skinning, morph targets and instancing: a glTF-sized + surface, mirrored in every runtime. Aetheria needs about a sixth of it. + - It would also put a CultLib cut and a Unity release in front of the release + hulls. +- **Prior art.** No engine adopts a neutral interchange format as its runtime + format. Each owns a binary form fitted to its loader. + - glTF 2.0 keeps one typed accessor per attribute and one primitive per + material. + - Unity's `Mesh` takes one array per attribute, and `SubMeshDescriptor` ranges + over one index buffer. + - Godot's `ArrayMesh` keeps per-surface arrays. + - Bevy's `Mesh` keeps an attribute map, with U16 or U32 indices. + - Unreal splits positions from tangents and UVs into separate vertex buffers. + - `ShipMesh` takes their common subset: one array per attribute, which + MessagePack types element by element, and submesh ranges. + - These are from the engines' documentation and were not re-fetched for this + pass. + +**`ship-image-encoding`: how are images stored?** +- **a. RGBA8, mip 0 only.** Unity generates mips and compresses at load + (`Texture2D.Compress`, DXT5). +- **b. Block formats with mips, encoded at Package:** BC7 for colour, mask and + maps, BC5 for normals. + - Unity loads them with `LoadRawTextureData`, with no decode and no compress + hitch. + - This needs a native encoder in Blender's Python, supplied by Brokkr: bc7enc + (MIT or public domain), etcpak (BSD-3) or Intel's ISPC Texture Compressor + (MIT). +- **c. PNG bytes inside the record,** decoded by `ImageConversion`. +- **Recommendation: `raw-then-bc`.** `Format` and `Levels` go in the schema from + day one; a for the first cut; b as the release encoding, in cut + `ships-cc-image-bc`. Confidence is medium-high. The other options are + `bc-now`, `raw-only` and `png`. + - b makes the file about a third the size, so every save writes less. + - b matches the VRAM budget the material plan already assumes (BC). + - b removes the load-time compress, which costs hundreds of milliseconds per + 2048² image. + - c puts a xenos codec inside a typed record. Its meaning becomes "whatever a + PNG decoder says", not a layout every runtime reads the same way. It still + pays the decode and the compress at load. + - KTX2 is the prior art for b's shape: a format, a size and an index of level + byte ranges, with block formats as ordinary formats. Unity's `Texture2D` and + Godot's `.ctex` store the same thing. +- If she rules b first, `ships-cc-package` takes the encoder and + `ships-cc-image-bc` disappears. + +#### Cuts + +| Cut | Repo | Depends on | Hands budget | What | +|---|---|---|---|---| +| `ships-cc-model` r1 | Aetheria | — | ~130k | The schema; `seed-model`; `Validate` on records; `PackageOf` reading the package; `ShipModVisual.Load` from records; glTFast removed; fixtures as records; the coordinate probe; the doc | +| `ships-cc-package` r1 | Aetheria | `ships-cc-model` | ~110k | `ship_cc.replace_model`; Package without a GLB; the image cap; Blender tests and the headless smoke; the LFS rule; sizes measured | +| `ships-cc-image-bc` r1 | Aetheria | `ships-cc-package`, `ship-image-encoding` | ~90k | BC7 and BC5 with mips at Package; `LoadRawTextureData` | + +**The smallest first cut.** The smallest step that gets her authoring onto the +`.cc` alone is `ships-cc-model` followed by `ships-cc-package`, merged to master +together. +- `ships-cc-model` alone would leave the add-on writing a GLB that nothing reads. +- No package is committed (M27), so the pair breaks nothing in the repo. The + operator's drafts go through `seed-model` on their next Package. + +Spec and question drafts for admission, bounds-checked: +`F:\Projects\eureka-scratch-carry\ship-cc\` (`model.json`, `package.json`, +`questions.json`). + +**Specs this changes.** The Superseded resolutions are Self's to admit. +- **`ships-hull-material` r2, superseded by r3.** r3 folds this section into + "Hull materials as owned graphs": + - no glTFast generator: `ShipModVisual` builds materials from `ShipSurface`; + - `HullPaint` keeps the graph and the livery, and drops the PNG paths; + - Bind's PNG checks become image checks in `Validate`; + - surfaces gain the normal and emission slots. +- **`ships-addon-paint` r2, superseded by r3.** Package writes the mask and maps + as `ShipModel` images. There is no PNG export and no `paint --mask `. +- **`ships-player` r2, superseded by r3:** + - "glTFast's shader variants in the player" and the no-`glTF/`-shader check + are dropped; + - the LFS rule moves to `ships-cc-package`; + - the preload log line sums image memory from the records; + - it depends on `ships-cc-package`. +- **`ships-addon-frame` r3, superseded by r4.** "The export leaving out `Source`" + becomes "the model writer leaves out `Source`". +- **`ships-addon-mounts` r3, superseded by r4.** The texture cap leaves it. +- **The material-system plan,** which is not admitted: + - `hull-bakes`' pair key hashes the mask and maps image bytes instead of PNG + files; + - "the GLB's slots" becomes "the model's surfaces". +- **`ships-addon-package` r2 needs no revision,** because it has landed. + `ships-cc-package` deletes its GLB export, and `ships-cc-model` deletes its GLB + read in `validate`. + +**Order:** +- `ships-cc-model` can start now. +- `ships-addon-frame` and `ships-addon-mounts` touch `__init__.py`, as + `ships-cc-package` does. Land the pair before those two. +- `ships-hull-material` r3 and `ships-addon-paint` r3 then build on records from + the start. +- Follow-up `variants-python-raw-push` gains a consumer: `replace_model` is a + raw push, like the other two. + +#### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Ship model | `mod-model:` in the package's `ship.cc` | Seeded by `seed-model`; rewritten whole by every Package | Package writes it and `Validate` judges it. It is never copied into the derived catalog | +| Ship image | An entry of `ShipModel.Images`, named by a surface or paint slot | Captured from Blender at Package, capped at 2048 | The operator's images; `Validate` judges size and format | +| Mod body prototype | Hull record key | Built at boot from `ShipModel` | `ShipModVisual.Load`. Cache only | + +#### Rationale + +**Why one record and not a record per image.** +- Separate image records would need typed refs from `ShipModel` and from + `HullPaint`, plus an orphan check. +- `HullPaint` sits in the derived catalog and `ShipModel` does not, so a ref from + one to the other would dangle there. +- Every Package writes all the images anyway, and the C# store decodes every + registered record either way. +- One record has no refs to keep and nothing to orphan. + +**Why Python packs the body instead of handing it to C#.** Any handoff from +Blender to AetherDb needs a transport. A temporary file in another format would +be the GLB again. Python writing slots pinned by `ShipSchemaPinTests` is the +contract the hull layout and the anchors already use, and C# stays the one judge. + +**Why not keep the GLB as a cache beside the record.** A second copy of the body, +with its own reader, is a second owner. Nothing in the game needs glTF. The +loader from records is about the size of the glTFast glue it replaces. + +### Ship authoring across Blender and Studio + +The operator, 2026-10-03, ruling `studio-is-the-authoring-pane`: "I think +bridging the data to a place we already control the UI is the smart compromise. +Studio will need to update its schematic rendering though since we'll be +shipping the stroke paths directly now." It answers her earlier ask for "a +dedicated CultCache Studio pane in Blender", after the Eyes pass +(`F:\Projects\blender-tooling-surface-prior-art.md`) found that Python cannot +register an editor type. Later the same day she added: "Honestly, I don't love +the fact that CultCache Studio runs in Unity. We have all this UI lowering infra +(Eve, now Thing) and our best inspection tool lives in, of all things, IMGUI". +Question `ship-authoring-host` puts that to her. + +Rulings also in force here: `ship-data-all-in-cc`, `ship-model-aetheria-owned` and +`thrusters-radiators-are-meshes`. Anchors are against Aetheria `origin/master` +`4895c752`, CultLib `main` `a7966142` and Eve `main` `8e270d6`, all read on +2026-10-03. + +#### Body facts + +**M31. Studio draws a hull's schematic as a texture under a checkbox grid, and +a mod ship has no texture.** +- `InspectableSchematicShapeDrawer` (`Assets/Scripts/Editor/CultCacheDrawers.cs:93-146`) + loads `EquippableItemData.Schematic` as an asset GUID and draws it with + `EditorGUI.DrawPreviewTexture` under one `GUILayout.Toggle` per cell. The + grid's height is locked to the texture's aspect, and hardpoint cells are + tinted. +- `ShipAuthoringStore` sets `hull.Schematic = null` for a mod hull + (`ShipAuthoring.cs:103`). So Studio shows a mod hull's cells over nothing. +- The strokes live in `ShipAuthoring.SchematicLines` (key 4, `ShipPolyline`: + points in Blender space, radii, opacities, colour; `ShipAuthoring.cs:19,40-48`) + at `mod-ship:`, in the same `ship.cc` as the hull at `mod-hull:`. +- A drawer can see the sibling record. `CultInspector.Records` is every stored + document in the open store, and `CultInspector.Record` is a read-only copy of + the one being drawn (CultLib `CultCacheStudioDrawers.cs:46-49`). A drawer + returns a new value only for the member it draws. +- A hardpoint's footprint is a plain `Shape` (`ItemData.cs:563`). It carries no + drawer attribute, so Studio shows it with the default drawing. + +**M32. Neither writer guards the file against the other, and they write +differently.** +- Python `push` pulls the whole store, replaces one envelope and rewrites the + file. Records it did not touch are carried as raw bytes (M28). The add-on's + `replace_layout` also checks a hash of the hull body (`ship_cc.py:155-161`). + So a Blender write is safe record by record. +- Studio's Save is `_cache.FlushAsync()` (`CultCacheStudioWindow.cs:423-425`). + It rewrites every record from memory. Its only refresh is a manual Reload + button (`:94`). There is no file watch and no stale-file check, and CultCache + keeps no file version (`CultCache.cs`: no revision or timestamp check on the + single-file store). +- So if Studio has `ship.cc` open while Package writes the model, anchors and + lines, its next Save silently restores the old ones. + +**M33. Blender already turns a Grease Pencil object into the strokes, and Line +Art can make that object from the mesh.** +- Package captures the collection's one Grease Pencil object, evaluated by + default, into `SchematicLines` (`__init__.py:387-391`, + `ship_cc.capture_grease_pencil` `:198`). +- Blender's Line Art modifier makes Grease Pencil strokes from mesh feature + lines: contour, silhouette, crease, material border, edge marks and + intersections, seen from a camera. It is evaluated through the depsgraph like + any modifier. So `capture_grease_pencil(..., evaluated=True)` reads its output + unchanged. + +**M34. Thing cannot carry the ship authoring surface yet.** Thing is the rename +of Eve (`Eve/docs/thing-campaign.md`). That campaign renames and publishes; it +adds no primitives. +- **Component kinds** (`Eve/docs/surface-contract-v1.md:96-106,309-333`): + - layout: `surface`, `grid`, `panel`, `card` and similar; + - `text`, `image.*`, `graph`, `tree` and `inspector.kv`; + - controls: button, toggle, slider, stepper, segmented, colour, select and + input; + - `inventory.grid`, `inventory.item` and `inventory.drag_session`. These are + spatial items with footprints and rotation, and drop operations whose fit is + left to the provider. +- There is no kind for vector strokes or polylines. The browser lowering's only + SVG is for `graph` diagrams (`eve-browser-lowering/src/index.ts:1653-1685`). +- There is no cell-painting kind. Occupancy can be drawn as a `grid` of + `control.toggle`, which means 1,024 bindings for a 32 x 32 hull. +- **Provider side:** + - C# `GameCult.Eve.Surface` with CultMesh `OperationBinding` and state + bindings (CultLib `src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md`); + - `cultmesh-browser`, a WebSocket client. +- **Lowerings** (`Eve/docs/renderer-parity.md:32-41`): + - The web reference renders fixtures and local advertisements. Its stated gap + is "live Odin/CultMesh provider feed and command round-trip tests". + - Flutter (Windows, Linux, Android) and iOS are screenshot parity targets on + fixtures. + - EveUnity has a live CultMesh provider path. +- `CultInspectorModel` is engine-free C#, but nothing projects it into an Eve + surface. The Unity window is its only lowering. + +#### The design in brief + +``` +Blender (add-on) ship.cc (one file, the only truth) Studio (host per ship-authoring-host) + model, materials, UVs HullData mod-hull: <-- cells, hardpoint values, stats (Studio edits) + Bake Schematic: Line Art GP --Package-> ShipAuthoring mod-ship: <-- anchors, SchematicLines (Blender writes) + mount gizmos, cell overlay --Package-> ShipModel mod-model: <-- nodes, meshes, surfaces, images (Blender writes) + Rasterise (proposes cells) --replace_layout, revision-checked--> HullData.Shape + mount helpers (paired rows) --replace_layout--> HullData.Hardpoints (type, Transform, Position) + overlay reads mod-hull on file change (bpy.app.timers mtime poll) Studio reloads on file change; refuses a stale Save +``` + +**Who owns what.** +- **Blender owns** the body and everything spatial: + - the model; + - the strokes, baked from the model; + - anchors and mount meshes; + - the `Position` of a hardpoint that has a mount object. +- **Studio owns** the hull's semantics: + - cell occupancy after any Rasterise; + - each hardpoint's type, footprint, rotation, armour and firing arc; + - internal hardpoints and stats. +- Each record has one writer, except two narrow, revision-checked Blender writes + into `HullData`: + - Rasterise proposes `Shape`, only when she presses it; + - the mount helpers create and remove paired rows and write the mounted + `Position`. + +**Strokes: Line Art, then Package's existing capture.** +- `Bake Schematic` creates or refreshes a Grease Pencil object `Schematic` in + `Generated`. It carries a Line Art modifier whose source is the render meshes, + so `Source` and `Generated` are excluded. +- The edge types are contour, crease, material border, edge marks and + intersection, seen from a top-down orthographic camera that the bake also + places in `Generated`. +- Package already captures the one evaluated Grease Pencil object (M33). So the + bake adds no write path. +- Two ways to change the result: + - **Edge marks:** she marks a feature with Mark Freestyle Edge and rebakes. + - **Freeze Schematic:** it applies the modifier so she can redraw strokes by + hand. This is the Grease Pencil-assisted path, and a frozen object is marked + `aetheria.keep`. +- Silhouette alone loses panel lines. A hand-drawn-only path costs her time on + every hull. Line Art gives the feature lines with the hand edit as a fallback. + +**Studio: strokes under the grid, footprints painted.** +- `ShipSchematicProjection` (ServerShared, engine-free) maps `SchematicLines` + into cell space: + - cell = Blender XY / 2 m, mirrored on both axes, plus `Shape.CenterOfMass`; + - schematic +x is Blender −X, and schematic +y is Blender −Y. + + This is `ships-addon-frame`'s grid rule, run backwards. +- The shape drawer finds `mod-ship:` in `inspector.Records` when the hull + has no texture. It draws the projected strokes with `Handles.DrawAAPolyLine`, + using each line's colour and radius, and unlocks the height. +- A new `InspectableFootprint` drawer paints a hardpoint's footprint as a toggle + grid with the hull's occupied cells shown around it. +- The rules live in ServerShared, so a Thing host reuses them and replaces only + the drawers. + +**The bridge: the shared file, with a stale-write guard and a file watch.** +- Studio's window gets a `FileSystemWatcher` on the open path: + - when the store is not dirty, a change on disk reloads it on the next editor + tick; + - when it is dirty, Save compares the file's SHA-256 with the one recorded at + the last load or flush. If they differ, Save refuses and offers Reload, + naming the file. +- The guard sits in Studio, not in CultCache. The operator's + CultCache scope restraint (2026-10-02) keeps CultCache from growing into a database, and + Studio is the only writer that flushes whole stores. +- The add-on polls the file's mtime from a `bpy.app.timers` callback. Blender + forbids threads, so there is no watcher thread. On a change it rereads + `mod-hull` for its overlay. +- **Prior art:** + - Send2UE ships exported files and remote commands, not live state. + - Multi-User replicates Blender datablocks for many editors at once. That is + the wrong shape for one operator whose two tools own disjoint records. + - CultCache's own write rules are already per record on the Python side (M32). +- A CultMesh live link earns its place only when a provider process holds the + store, which is the Thing host. Then Blender connects through `cultmesh-py` + and stops writing the file. Until then it adds a transport and no invariant. + This is not a separate fork: it follows `ship-authoring-host`. + +**Gizmos and the overlay in Blender.** These go in a `WorkSpaceTool` "Ship +Mounts" with a `GizmoGroup`, and replace N-panel text entry. +- **Overlay:** a `gpu` and `blf` draw handler in the 3D view, at Ship Root's + placement: + - the cell grid, with occupied cells filled; + - each hardpoint's footprint, tinted by type; + - each weapon's firing arc as a fan. + + It is read-only and replaces `ships-addon-frame` r3's generated Grid object, + so there is nothing left to exclude from export. +- **Mount gizmos:** one per mount object (weapon empty, muzzle, thruster mesh, + radiator mesh), coloured by role. + - Clicking selects the object and names its hardpoint row in the tool header. + - Dragging a weapon snaps it to cell centres. On release its row's `Position` + is written through `replace_layout`. + - Package re-derives every mounted `Position` from the object (the cell under + its bounds centre). So the r3 warning becomes a derivation. +- **Studio's side:** it shows `Position` read-only on a row whose `Transform` + names a mount object. +- **Not built:** + - a Sprytile-style cell painter in Blender (Studio owns cells); + - a firing-arc dial gizmo (Studio owns the arc; the overlay only shows it). +- **Deleted:** the N-panel hull editor: + - the cell checkbox grid and the hardpoint boxes; + - `AETHERIA_PG_cell`, `AETHERIA_PG_hardpoint` and `AETHERIA_PG_layout`; + - Load Layout, Resize Grid, Add Hardpoint, Remove Hardpoint and Save Layout. + +#### Authority map + +- **Owner.** + - `ship.cc` is the only truth. + - `HullData` semantics belong to Studio's edits, judged by + `ShipAuthoringStore.Validate`. + - `ShipAuthoring` and `ShipModel` belong to Package. + - Spatial placement belongs to Blender's objects. +- **Inputs.** + - Blender reads its scene and the file's `mod-hull` (for the overlay and the + revision). + - Studio reads the whole store, and the sibling `mod-ship` for strokes. +- **Outputs.** + - Blender writes `mod-ship`, `mod-model`, a proposed `Shape`, paired + hardpoint rows and mounted `Position`s. + - Studio writes `mod-hull`. +- **Derived state.** + - The Blender overlay is display-only. + - Studio's stroke underlay is display-only. + - A mounted hardpoint's `Position` is derived from its object. + - The `Schematic` Grease Pencil object is derived from the meshes unless + frozen. +- **Forbidden writers.** + - The N-panel layout editor, which is deleted. + - A Studio Save over a file that changed on disk since it loaded. + - Studio editing a mounted `Position`. + - Blender editing a hardpoint's type, footprint, rotation, armour or arc. + - Python computing `CenterOfMass`. +- **Shared paths.** + - Every Blender write to `HullData` goes through `replace_layout` and its + revision check. + - Rasterise, the mount helpers and the gizmo release write through that one + path. + - Package writes the other two records. +- **Deletion line.** The N-panel layout editor is cut in `ships-addon-frame` r4, + before any overlay or gizmo is added. + +#### Questions + +**`ship-authoring-host`: where does the hull editor and stroke view run?** + +Context: +- Thing cannot carry it today (M34). It has no stroke kind and no cell-painting + kind. +- No lowering has proven the live CultMesh round trip. +- Nothing projects `CultInspectorModel` into a surface. + +Prior art: the Language Server Protocol and Jupyter's kernel and front-end +split. In both, one process owns the model and the editors are interchangeable +clients. This is the target shape doctrine names. Both reached it after a +working single-host editor existed. + +- **a. `thin-imgui-now-thing-next`.** + - The Studio drawers above, kept thin: every rule lives in engine-free + ServerShared (`ShipSchematicProjection`, the footprint rule) and in + `CultInspectorModel`. + - The Studio disk guard. + - A Thing host for Studio as its own campaign after the release, with the + missing pieces named here: a stroke kind, a cell-paint kind, the browser + live loop, and a `CultInspectorModel`-to-surface projection. + - About 160k Hands tokens now, in two cuts. The IMGUI added is about 150 + lines of drawer. +- **b. `thing-now`.** + - An Aetheria provider: AetherDb, which already owns `ship-authoring`, holds + `ship.cc` and publishes a surface over CultMesh. + - New contract kinds `schematic.strokes` and `cells.paint`, in the contract, + the browser lowering and conformance fixtures. + - The browser live-loop proof. + - A `CultInspectorModel` projection limited to the field kinds `HullData` + uses. + - Blender connects through `cultmesh-py`. + - About 5 cuts, roughly 600-800k Hands tokens, across Eve, CultLib and + Aetheria. Hull authoring waits on all of it, ahead of the release. +- **c. `thing-now-minimal`.** + - As b, but no new kinds: + - the provider renders the strokes server-side as an `image` asset; + - cells are a `grid` of `control.toggle`. + - About 3 cuts, roughly 300-400k Hands tokens. + - It leaves 1,024 bindings per hull and a stroke view that cannot zoom + crisply. The Thing campaign would then rebuild that surface. + +Recommendation: **a**, confidence medium-high. +- The release needs hull authoring now. Studio already edits every `HullData` + field. +- The IMGUI cost is held to one drawer file, because the rules sit where a + Thing host will read them. +- b is the right destination, but it would put three unproven pieces (two new + kinds and the live loop) on the release's critical path. +- c builds a surface that b would then throw away. + +Depends on: nothing. The Blender cuts are independent of this answer. + +#### Cuts + +| Spec | Repo | Depends on | Hands budget | What it does | +|---|---|---|---|---| +| `ships-studio-schematic` r1 | Aetheria | `ship-authoring-host` | ~90k | `ShipSchematicProjection`; strokes under the grid; the `InspectableFootprint` drawer; tests on the projection | +| `studio-disk-guard` r1 | CultLib | `ship-authoring-host` | ~70k | File watch, auto-reload when clean, stale-Save refusal by SHA-256, in the Studio window; a CultLib release | +| `ships-addon-schematic-bake` r1 | Aetheria | — | ~70k | Bake Schematic (Line Art and the ortho camera in `Generated`), Freeze Schematic, its own module; smoke | +| `ships-addon-frame` r4 | Aetheria | `ships-cc-package` | ~150k | r3 with the `.cc` change, the N-panel layout editor cut first, the Grid object replaced by the cell overlay, Rasterise kept | +| `ships-addon-mounts` r4 | Aetheria | `ships-addon-frame` | ~110k | r3 without the texture cap and without internal-hardpoint editing; rows are pairs only | +| `ships-addon-gizmos` r1 | Aetheria | `ships-addon-mounts` | ~120k | The Ship Mounts tool, mount gizmos, the footprint and arc overlay, mounted `Position` derived on release and at Package, Studio's read-only `Position` | + +Admitted on 2026-10-03, along with question `ship-authoring-host`: +`ships-studio-schematic` r1, `ships-addon-schematic-bake` r1 and +`ships-addon-gizmos` r1. Two batches wait on Self, bounds-checked, in +`F:\Projects\eureka-scratch-carry\studio-pane\`: +- `pending-after-ships-cc-package.json`: `ships-addon-frame` r4, + `ships-addon-mounts` r4 and their Superseded resolutions of r3. Admission + refuses r4 until `ships-cc-package`, which it depends on, is admitted. +- `pending-after-cultlib-in-campaign.json`: `studio-disk-guard` r1. Admission + refuses it because `GameCult/CultLib` is not a repo of the campaign. Self adds + the repo (a campaign revision) or routes the cut to a CultLib campaign. + +**Specs this changes.** +- **`ships-addon-frame` r3, superseded by r4.** + - It folds in the `.cc` section's change: the model writer leaves out + `Source`. + - The Grid object becomes the overlay, and the layout editor is cut first. +- **`ships-addon-mounts` r3, superseded by r4.** + - It folds in the `.cc` section's change: the texture cap leaves. + - "The layout panel stays the editor" becomes "Studio is the editor". + - Add and Remove Hardpoint for internal types leave Blender. +- **`ships-cc-model` (draft).** It should mark `ShipModel`'s bulk arrays + `[CultInspectorHidden]` and show their counts read-only. Otherwise Studio, + opening `ship.cc`, tries to draw millions of floats in IMGUI. Self amends the + draft before admitting it. + +**Order against the `.cc` move on 2026-10-06.** +- **Before the 6th:** + - `ships-studio-schematic` and `studio-disk-guard`, once + `ship-authoring-host` is ruled. They read only `HullData` and + `ShipAuthoring.SchematicLines`, both on master. + - `ships-addon-schematic-bake`. It lives in its own module with a one-line + registration, so its merge with `ships-cc-package`'s `__init__.py` edits is + trivial. It feeds Package's existing capture. +- **After `ships-cc-package` lands:** `ships-addon-frame` r4, then + `ships-addon-mounts` r4, then `ships-addon-gizmos`. Each builds on + `ShipModel`-in-`.cc`, not on the GLB. +- If she rules b or c, `ships-studio-schematic` and `studio-disk-guard` are + withdrawn and the Thing cuts are mapped then. The Blender cuts do not change. + +#### Rationale + +**Why Studio owns semantics and Blender owns space.** +- The operator's ruling puts editing where GameCult controls the UI. +- What Blender knows better than any grid editor is where things are on the + mesh. So placement is the one hull value Blender writes, and it is derived + from objects, not typed. + +**Why Line Art and not silhouette edges or a custom edge extractor.** +- Line Art is Blender's own feature-line generator. Its output is the Grease + Pencil object that Package already captures. +- A Python edge walker would re-implement occlusion and chaining, and own them + forever. + +**Why the guard sits in Studio, not in CultCache.** +- Python already writes safely record by record. +- Only a whole-store flush from memory can clobber. The editor that does that + flush owns the check. + +**Rejected.** +- A Blender fork with a native editor (the Eyes pass, section 2). +- A Sprytile-style cell painter in Blender: a second editor of cells. +- A CultMesh link before a provider holds the store. + +### S3: the first playable mod ship in a built player + +- **First hull: Headliner.** + - It is a single hull. Quiet comes as two variants (Dexter and Sinister), and + MQ2 A makes them two full packages. + - Its 50k triangles exercise the heavy path: collider generation and texture + memory. + - It is Lucent's duel ship (ruling `lucent-duel-bait`), so `faction-play-2` will + want it in Lucent flights. + - Its mesh and texture are already in `Quiet.blend`, so no new GLB is needed. + - The operator authors it with the add-on, because placing the mounts needs her + eyes. She commits the package with the add-on cuts' tooling. +- **`ships-player` does the agent-side work**, and does not wait on the hull: + - the template radiator material; + - glTFast's shader variants in the player; + - one preload log line with hull count, shader names and texture memory; + - LFS rules for packages (M11); + - `GameData/Mods` staged into the player; + - the C3 sim test; + - the `SetEntity` probe; + - the built-player boot smoke on Starfire, with the skiff fixture and a bad + package. +- **What it needs to build a player** is question `s3-player-build` (M10). +- **The operator's S3 session** is gate 4, gate 5 (S4) and C2 to C6: Headliner in a + built player, about fifteen minutes. + +### S5 and the variants campaign: what this strand needs, and when + +- **Nothing before release for S5 itself.** + - The bar is 3 shipped prefab hulls plus 10 package hulls + (`content-bar-one-per-concept`). The prefab hulls keep working through the + `Prefab` branch. + - Packages are complete hulls (MQ2 A), so no family needs variants. + - S5, which migrates the shipped hulls, retires the FBX builder and collapses + families, waits on variants C3 and C4, and stays theirs. +- **Before the operator's CultLib checkout moves past the C2a merge:** + - Python must still write a raw record into a single-file store. `ship_cc` pushes + `HullData` and `ShipAuthoring` envelopes (`ship_cc.py:108,163`), and `parity-py` + rewrites that write path. + - Follow-up `variants-python-raw-push` asks `parity-py` to keep that path or name + its replacement, with `tools/blender/tests` as its consumer check. + - `ships-addon-mounts` adds a guard: the add-on refuses an untested + `cultcache_py` revision (M12). +- **When C4 brings element ids to Aetheria**, they arrive on `ShipAnchor`, + `HardpointData` and `ShipPolyline`. + - `ship_cc` already carries hardpoint slots past `FiringArc`, keyed by mount id. + `ships-addon-package` does the same for anchors, keyed by anchor id. + - Lines are replaced whole, which Q7 allows. + - Key 32 (`HullData.Visual`) is taken, and C4 must not reuse it. + - The lane's own S5 text puts hull families "in `Aetheria.cc`". MQ1's confirmed + reading puts a family in its ship's `.cc`. S5's owner should resolve which one + holds before C4 maps it. + - This is follow-up `c4-ship-element-ids`. + +### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Ship package | `GameData/Mods//`; `` 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:` in the package. | Stats seeded from a reference hull at create; layout written by Blender's Save and Package. | `HullData` in the package: cells, hardpoints, stats. How it gets a manufacturer is question `release-hull-home`. | +| Anchor | `ShipAnchor.Id`, equal to the mount id for mounts and to the GLB node's `aetheria.id`. | Written whole by Package from role-tagged objects. Unknown tail slots are carried by id. | Blender objects tagged `aetheria.role` propose; the C# validator decides. | +| GLB | Retired by "Ship data all in the `.cc`": the body is `ShipModel` at `mod-model:`. | Was re-exported by every Package; `ships-cc-package` deletes the export. | None. `ShipAuthoring` key 2 is retired. | +| Derived catalog | `Aetheria.modded.cc` in the persistent data path. | Recomposed every boot, and disposable. | `ShipModCatalog.Compose`. Never authored. | +| Mod prototypes | Hull record key. | Built at boot, kept across scenes, gone at exit. | `ShipModShips.Preload`. Cache only. | + +### Rationale + +**Why the operator checks move to the S3 session.** +- They need a mod ship in a zone, which today means the console and a docked + player (M5). Done in the Editor with the skiff fixture, they prove the wiring on + a tetrahedron. +- Done once in a built player with Headliner, the same fifteen minutes also give + gates 4 and 5, which are owed anyway. +- The merge is safe without them because the path is inert with no package + installed. + +**Why mounts before the add-on.** The add-on writes anchors. If it learned +`articulation` as the weapon role, the first real package would need migrating when +the ruling's code landed. `ships-mounts` also collapses the role checks into the +validator (M4), so that the add-on's Validate button and compose refuse the same +things. + +**Why the add-on calls AetherDb instead of growing a Python validator.** The doc's +shared-path claim is true only for C# (`moddable-ship-authoring.md`, correction 4). +A second validator in Python would be a second semantic authority. A subprocess +call costs a few seconds per Package and keeps one owner. The same reasoning makes +`create --like` a C# command: `HullData` has some 30 slots that Python would +otherwise copy by hand. + +**Why generated structural anchors.** The collider, map icon, shield and tractor +are functions of the mesh's bounds and silhouette. Hand-making them per hull is the +work that M8's hand-scaled icosphere already shows. Generating them, with a "keep +mine" flag, removes about half of the per-hull steps without taking a decision from +her. + +**Why first-party hulls through the package path** (the recommendation in +`release-hull-home`). In KSP, RimWorld, Factorio and Starsector, the base game's +content uses the same format and the same loading path as mods: +- KSP stock parts live in `GameData/Squad`, beside mod folders. +- Factorio's base game is the `base` mod, read before every other mod and merged + into one prototype table. +- Starsector mods put their ships into an existing faction's fleets and markets with + their own `.faction` file. The engine merges that file into the core faction's + known-hull lists, so the package carries its own availability. +- The RimWorld claim is from memory and was not re-fetched. + +Dogfooding the mod path means the release exercises the path players' mods will +take. A package that carries its own product needs no edit to `Aetheria.cc`. + +Sources: +- https://forum.kerbalspaceprogram.com/topic/144858-squad-folder/ +- https://forums.factorio.com/viewtopic.php?t=12564 +- https://spmatlas.com/guides/mods-explained/ +- https://fractalsoftworks.com/forum/index.php?topic=12970.0 +- https://docs.blender.org/manual/en/latest/addons/scene_gltf2.html + +## Lanes: scenarios, mining and the loot move + +The operator ruled on 2026-10-03 that this campaign adopts `codex/scenarios` and +`codex/mining` from where they stand ("Adopt mining and scenarios", ruling +`adopt-mining-and-scenarios`). This section maps: +- both adoptions; +- the loot move into `ServerShared`, which jettison rests on (ruling + `jettison-shared-floating-items`); +- the merge order of every lane and the faction-play cuts; +- the re-map of `faction-play-4` to ruling `ballistics-real-ammo`. + +The cuts are typed specs in the mind: `scenarios-adopt`, `mining-index`, +`mining-index-pins`, `mining-target-queries`, `mining-merge`, `mining-index-tree`, +`mining-belt-cells`, `loot-1` to `loot-3`, `ballistic-ammo` and `faction-play-4` r2. +The forks are questions `fastblast-ammo`, `reticle-exactness` and `belt-size-tiers`. + +The lane tips were read with `git show` in a scratch clone on 2026-10-03: +- `codex/scenarios` is at `aa3baf12`: 30 ahead of master and 20 behind, base `702b454b`. +- `codex/mining` is at `83d8371e`: 32 ahead and 21 behind, base `65c63495`. +- Everything master gained since either base is `Scenarios map:` doc commits. + +The operator's records for both lanes live in `docs/scenarios-cut.md` on master: the +Soul passes on scenarios batches 2 to 4 (lines 72-140), and the mining Cut 3 Soul and +belt-freeze rulings (lines 830-876). + +### Body facts + +**L1. The scenarios blocker is real, latent, and wider than one lookup.** +- Soul's batch-4 probe P30 exists only in a dead session scratchpad + (`...\F--Projects-CultLib\2a6aec4d-...\scratchpad\soul5\SoulScen5Probes.cs`, log + `probesC.log`). At `aa3baf12` it returned: + - (a) An L-shaped gun in an L-shaped hardpoint equips at origin `(1, 1)`. + `Hardpoints[origin]` is NULL, while the hardpoint found by the item's cells is the + right one. `ArcFor` returns the default 120 instead of the authored 360. + - (b) A design with an empty leading column: `ItemFits(-1, 4)` is true but + `TryFindSpace` is false, and `ArcFor` throws `IndexOutOfRangeException`. + - (c) Over the catalog with that design, 380 placements are accepted. In 9 the origin + is outside every hardpoint, in 7 it is inside another hardpoint (a radiator), and + 14 offsets are never tried by `TryFindSpace`. +- 18 readers take an item's hardpoint from its origin cell + (`Hardpoints[item.Position.x, item.Position.y]`): + - `FireControl.ArcFor` (`:38`); + - eleven Unity weapon managers; + - `EntityInstance.cs:245,260` and `ShipInstance.cs:70`; + - `ActionGameManager.cs:1044,1048,1049`. + + This is a grep at `aa3baf12`. The pattern in `scenarios-adopt`'s negative check + matches exactly these 18. +- `Entity.HardpointAt` (`Entity.cs:820-835`) already finds the right hardpoint at equip. + `TryEquip` (`:904-935`) throws that answer away. +- The defect is latent. A catalog probe at `aa3baf12` with catalog `70a7b0a9` (an xUnit + probe run through `ygg-verify`, job `aeth@aa3baf12e4`, 2026-10-03) found: + - every catalog hardpoint is a full rectangle; + - the only gear designs whose shape lacks its origin cell are the Vulcan reactor and + the Industrial Thermostatic Heater (Tool). + + So nothing in the shipped catalog trips it today. The first non-rectangular hardpoint + or mount would. +- The "held fix" was never written. The fix batch was queued and not dispatched under + the 2026-10-01 drain (scenarios-cut.md:105-106). P30's harness survives only in that + scratchpad, so `scenarios-adopt` commits its fixtures as tests. + +**L2. Merge-tree probes between the lane tips** (`git merge-tree --write-tree +--name-only`, 2026-10-03): + +| Pair | Conflicts | +|---|---| +| master x scenarios | none | +| master x mining | none | +| scenarios x moddable-ships | `AetheriaStores.cs` (`CatalogTypes`, line 9) | +| mining x moddable-ships | `AetheriaStores.cs`, `tools/AetherDb/Program.cs` | +| scenarios x mining | `AetheriaStores.cs`; `Zone.cs` (one region: scenarios' `MineAsteroid` against mining's deletion); `GameData/Aetheria.cc`; `FireControlCut124Tests.cs` | + +- The scenarios x mining set matches the merge rule recorded in scenarios-cut.md S4 + (lines 133-140). +- Only mining changes `Settings.asset` (`347752dd` to `b2e346f5`, `MiningDifficulty` + removed), so it merges without a conflict. + +**L3. The belt freeze's cause is mapped** (mining Cut 3 Soul F1). Historical: the scan, `ChunksNear` and `ActionGameManager.TargetCandidates` were deleted by `mining-index` and `mining-target-queries`; see "Mining index: as built". +- Every reticle, next or previous press builds `ActionGameManager.TargetCandidates` + (`:1174` at `83d8371e`). +- That calls `Entity.VisibleChunksInReach` (`Entity.cs:377-385`), which calls + `Zone.ChunksNear` (`Zone.cs:326-339`). +- `ChunksNear` loops over every rock of every belt whose annulus reaches, posing each + rock and then testing its visibility. +- The handlers then sort the whole list by distance and search it with + `Array.IndexOf` (`:393-407`). +- Soul measured 63 ms at 30k rocks, 449 ms at 300k and 4.3 s at 3M. At 3M, 633k rocks + are in reach and 2,240 are visible. + +**L4. The operator's index ruling, and the visibility bound.** +- The operator, verbatim: "Just don't specialize the indexing too much towards asteroid + belts, because we'll want a bunch of subsystems feeding targeting data" + (scenarios-cut.md:871-875). +- Detection of a rock is the settled sensor rule (Q14 A): + `ChunkInfo = saturate(sum_i v * s_i * curve_i(angle) / d / (n k))`. The sources are + `Sensor.Gain` (`Sensor.cs:187-199`) and `Entity.ChunkInfo` (`Entity.cs:355-368`). +- The rule is linear in visibility `v` and falls as `1/d`. A Bezier curve lies inside + the hull of its control points. So for a region with a known maximum visibility and + a known nearest distance, an upper bound on `ChunkInfo` is cheap to compute. +- Rock visibility is `CrossSection * pi * cells^2 * light` (`Zone.ChunkVisibility`, + `:314-321`). +- Together these let a provider prune whole regions without deciding detection. + +**L5. Hands' unbuilt work in progress** (superseded by `Targeting/` on master; the patch was not adopted) is +`F:\Projects\HANDOFF-mining-index-wip-2026-10-01.patch`: 257 lines against +`83d8371e`'s `Zone.cs`. +- It groups rocks into bands of 256 by orbit distance, each sorted by orbital phase at + a key time. +- It widens the query arc by the band's rate spread, re-keys at most 8 bands per query, + bounds the light per band, and counts `ChunksExamined`. +- Its structure is belt-shaped (`ChunksNear(position, range, distancePerVisibility)` on + `Zone`), and its queries still build eager lists. +- Kept: the kinetic bands. Not kept: the belt-shaped API and the eager queries. + +**L6. Nothing of the loot move has landed, on master or on any lane.** +- `git grep` for `Zone.Loot`, `TryPickUp` and `LootDrop` over every local and remote + ref found nothing in code. +- On master `f1dee184`, the loot path is all Unity: + - the roll uses `UnityEngine.Random.value` and `onUnitSphere` + (`EntityInstance.cs:302-321`); + - pickups are `GridObject`s, moved by Unity (`GridObject.cs`); + - collection is `ShieldManager.OnCollisionEnter` (`ShieldManager.cs:40-55`); + - the tractor beam pulls by `Physics.SphereCastAll` (`TractorBeam.cs:14-30`). +- No lane touches these hunks. Mining edits `EntityInstance.cs:236,410` and + `ZoneRenderer.cs:72-483`, away from the loot lines. Moddable-ships edits + `ZoneRenderer.cs:289-304`. + +**L7. Today's loot tuning, read from the assets.** +- `GameSettings.cs:14-16`: `PickupLifetime` 30, `LootDropProbability` .25, + `LootDropVelocity` 25. +- All five pickup prefabs (`Assets/Prefabs/RPG/Pickups/*.prefab`) carry the same + `GridObject` values: `Drag` .05, `LaunchDrag` .2, `Gravity` 1, `GridAttraction` 10, + `RotationSpeed` 1. +- `Tractor Beam.prefab`: `Radius` 25, `Traction` 25, `Distance` 75. +- `GridObject` also moves mines (`Mine.cs:12`, `MineManager.cs:21-32`). + +**L8. Fork L already decides the pickup, and the Tractor Beam has no capability yet.** +- Fork L was ruled by the operator on 2026-09-17 (headless-playground-cut.md:924-970): + - pickup is a simulation-owned timed grab: extend, envelop, pull; + - no Unity collision decides a pickup; + - the tractor beam is a generic Tool item that provides only pickup; + - the simulation owns the grabbed object and its pose; + - `GrabEvent` names the object by a typed simulation identity; + - loot is a body plus an item instance. +- Two readings are therefore not the design: + - option (a) of that section, "contact detection stays Unity collision", which was + recommended before the ruling; + - the brief's "`Zone.TryPickUp` from `ShieldManager`". + + The jettison ruling's own words agree: jettisoned cargo "can be picked up without + unity's involvement". +- Scenarios rulings of 2026-09-30 (scenarios-cut.md:46-56): picking up requires a + pickup behaviour, which the Tractor Beam carries, and every generated ship and the + player's starting ship carry one. +- The catalog probe (L1's job) found the Tractor Beam as `GearData` on a **Sensors** + hardpoint, with **no behaviours** and one product. Nothing in the simulation can grab + today. +- The presentation seam exists, but grabs nothing real: + - `GrabEvent` carries an `int TargetHandle` (`CapabilityEvents.cs:53-67`); + - `CapabilityPresentationBinder` resolves it to a live `Transform` through delegates + (`:16-45`), and only `FieldTester` supplies them. + +**L9. The ammunition substrate, by catalog probe** (same job, catalog `70a7b0a9`): + +| Hardpoint | Weapon | Behaviour | Magazine | Ammo | Energy | +|---|---|---|---|---|---| +| Ballistic | 6k Shooter | Instant | 6 | none | 5 | +| Ballistic | Autocannon | Auto | 50 | Ammo | 5 | +| Ballistic | ClearPath | Auto | 0 | none | 1..0.5 | +| Ballistic | DeathCluster | Auto | 8 | Ammo | 25..5 | +| Ballistic | Earp | Instant | 6 | none | 5 | +| Ballistic | pretty pretty bang bang | Charged | 8 | Ammo | 25..50 | +| Energy | FastBlast+- | Auto | 12 | Ammo | 0 | +| Energy | ChargeBlast SG, ChargeBlast+-, ColdFire, CShot RainbowLite Lazer, plight, Spectra | mixed | 0 or 1 | none | 0 to 150 | +| Launcher | GT 3K, LRMM72, pswarm, scorched void policy, SRMM72 | mixed | 2 to 288 | none | 0.1 to 50 | + +- The catalog has one ammunition commodity, `Ammo`, at price 1000. +- One cargo unit is one **magazine**. `InstantWeapon.UseAmmo` removes 1 unit per reload + and refills `MagazineSize` rounds (`InstantWeapon.cs:176-202`). `ConstantWeapon` does + the same (`:129-160`). So `faction-play-4` r1's "`AmmoMagazines x MagazineSize` + rounds" over-stocked by a factor of `MagazineSize`. +- `UseAmmo` returns true at once when `MagazineSize <= 1` (`:178`), so a one-round gun + fires free even with an `AmmoType`. +- Firing energy reaches a weapon through the power bus: + - `PowerBus.cs:122` takes every active `IPowerConsumer`; + - an item's `PowerSupply` is its tier's grant ratio (`:196-205`), which is 0 below a + starved tier; + - so a weapon that requests nothing still reads its tier's ratio, and a brownout + still reaches it. + + That is why `ballistic-ammo` takes ammo-fed weapons off the bus instead of zeroing + their request. +- `ChargedWeapon`'s charge is time-based (`_charge += dt / ChargeTime`). Only firing + spends the capacitor, so bypassing the spend does not stall a charge. + +### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Equipped item's hardpoint | `EquippedItem.Hardpoint`, a reference into the hull's `Hardpoints` list; null for Tool gear. | Set once at equip from the placement rule, and derived again on every load (load re-equips). Never stored. | `Entity.HardpointAt`, through `TryEquip`. No reader derives it from the origin cell. | +| Targeting index | `Zone.Targets`, one per zone. | Built with the zone. Providers register as belts are built and entities admitted. Rebuilt on load; never saved. | `TargetingIndex` for candidate sets and bounds; each provider for its own motion; `Entity` for detection. | +| Belt band (provider region) | Belt key plus band index; arc segments by key order. | Derived from `AsteroidBeltData`, `PlanetSettings` and time. Re-keyed lazily, a bounded number per query. Cache only. `mining-belt-cells` replaces it with rings and cells. | `BeltTargets`. | +| Belt ring and cell (after `mining-belt-cells`) | Belt key, ring index, cell index. A ring is a run of rocks by orbit distance; its cells are equal sectors of a frame turning at the ring's mean rate. | Derived when the belt is built, never saved. A ring is refreshed (its rocks re-bucketed) when its drift passes one cell, within a rock budget per query. | `BeltTargets`. | +| Target choice | The `TargetRef` a key press writes. | Made per press; no list is kept between presses. | `Entity` (reticle, next, previous, nearest), through `SetTarget`. | +| Floating item | `FloatingItemId`: a zone counter, never reused in a zone. | Released at death (and later by jettison); drifts; held during a grab; removed into cargo by `TryPickUp`, or at `FloatingItemLifetime`. Not saved: gone when the zone unloads, as Unity loot is today. | `Zone`: release, step, expiry and commit. | +| Loot dice | Zone `CombatSeed`, loot stream, death ordinal. | One generator per death, discarded after the roll. | `Zone.DropLoot`, on `SimulationDice`. Never `UnityEngine.Random`, never a shared stream. | +| Grab | Holder entity plus `FloatingItemId`. | Started, Extend, Envelop, Pull, then Completed or Cancelled. One holder per item, one grab per Pickup. | The `Pickup` behaviour owns the timeline; `Zone` owns the held pose and the commit. | +| Pickup capability | `PickupData` (BehaviorData union key 40) on the Tractor Beam design. | Authored by an AetherDb one-shot, then edited in the catalog. Every generated ship carries one. | The catalog author. | +| Ammunition | An `AmmoType` commodity; one unit is one magazine. | Stocked at generation and in station inventories; consumed one unit per reload; moved by `TryTransferItems` when rearming. | Cargo. `WeaponData.DrawsRounds`, derived from `AmmoType`, decides reactor or magazine. | + +### Merge order + +Each lane becomes a `eureka/aetheria-release-*` branch; nothing is pushed to +`codex/*`. In order of landing on master: + +1. **`ships-merge`.** Its spec exists; ruling `ships-merge-on-agent-proof`. +2. **`scenarios-adopt`.** + - Master (with ships) is merged into scenarios first. The only conflict is + `AetheriaStores.cs:9`. The blocker fix follows. + - Scenarios goes before mining for two reasons: its remaining work is one small cut, + and mining's merge rule was written to resolve scenarios' side. +3. **`mining-index`, then `mining-index-pins`, then `mining-target-queries`**, on the + mining branch. They touch only mining's own code, so they run in parallel with + steps 1 and 2. The pins land first, so the same tests guard `Best` when + `mining-target-queries` gives it callers. +4. **`mining-merge`.** Master (with ships and scenarios) is merged into mining under the + recorded rule, and the field kinds are applied again to scenarios' catalog. +5. **`feedback-1` and `audio-1`** may land at any time: their hunks are disjoint from + all three lanes (B10). `feedback-1` must land before `loot-2`. +6. **`loot-1` to `loot-3`**, on one branch from master after step 4, merged together. + They wait for step 4 because mining's belt code rewrites `Zone.cs`, and the loot + cuts edit `Zone`'s constructor and `Update`. +7. **`ballistic-ammo`**, after step 4: it needs scenarios' `LoadoutGenerator` and + mining's `Weapon.cs`. It is independent of the loot cuts. +8. **`faction-play-1` to `-3`**, re-anchored first (follow-up `faction-play-reanchor`). + `faction-play-1` rewrites lines both lanes changed, so it starts only after step 4: + - mining's targeting lines: `Minion.cs:14-21`, `Combat.cs:28`, and `Target` as a + `TargetRef` written through `SetTarget`; + - scenarios' admission lines: `Zone.Admit`. +9. **`faction-play-4`** r2, after `faction-play-3` and `ballistic-ammo`. +10. **Jettison, then pirate collection**, on `Zone.Release` and the grab, after step 6. +11. **`mining-index-tree`, then `mining-belt-cells`**, at any time after step 4, on a + branch from master. They gate nothing, neither the merge nor the release (ruling + `targeting-sublinear-wanted-not-critical`). They inherit the tests of + `mining-index-pins` and `mining-target-queries`, and tighten the cost pins. + +### Mining index: as built (master `df7c44f2`) + +Status, read this before L3 to L5 and the cuts above. They are the plan and the pre-cut +Body. `eureka/aetheria-release-mining` merged to master as `df7c44f2`. Landed: `mining-index` +(`cut-mining-index.h1`), `mining-index-pins` (`cut-mining-index-pins.h1`), +`mining-target-queries` (`cut-mining-target-queries.h1` and `.h2`) and `mining-merge` +(`cut-mining-merge.h1`). Not landed: `mining-index-tree` and `mining-belt-cells`. L3's +scan, L5's patch and the belt task machine no longer exist. + +**Owner, by question.** +- *Which things can lie in a region, and how visible at most:* `TargetingIndex` + (`ServerShared/Targeting/TargetingIndex.cs`), one per zone as `Zone.Targets`. It names + no belt, rock or chunk. It owns region ordering and reach. +- *Where rocks are and which regions bound them:* `BeltTargets`, one provider per belt, + registered in `Zone`'s constructor beside `EntityTargets`. +- *Is this target detected, and which one does a press pick:* `Entity` + (`ChunkVisible`, `VisibleEntities`, `TargetUnderReticle`, `TargetNext`, + `TargetPrevious`, `TargetNearestEnemy`), through `PickTarget` and `Eligible`. +- *Who writes the choice:* `Entity.SetTarget`, the one writer. +- *Rock wear and respawn:* `Zone`'s wear store (`ChunkWear`, `BrokenUntil`). Belts have + no task, timer list or update thread: `EvaluateBelt` computes poses on demand for + rendering, and `BeltTargets` derives bounds from time. + +**Inputs and outputs.** +- `TargetSearch` in: searcher position, reach, optional observer, and + `ReachPerVisibility` (`Entity.DetectionReachPerVisibility`, from sensor gain bounds and + the curve's hull). +- Providers out: `TargetRegion`s (distance interval, bearing interval, visibility + ceiling) and `TargetCandidate`s (a `TargetRef` and a planar position). +- Index out: `Within` returns every candidate within exact reach whose region can be + detected, unordered. `Best` takes an `ITargetKey` (`Bound`, `Key`, `Precedes`) and + returns the least-key eligible candidate: regions are sorted by bound and opened until + a bound exceeds the best key. +- `Entity` presses: `AngleKey` (reticle: least planar angle to the look direction) and + `DistanceKey` (next and previous: the successor or predecessor in (distance, target) + order, wrapping, with the held target as pivot). Ties order a ship before a rock, + then by chunk field and index. +- `Entity.VisibleChunksInReach` keeps its contract (visible chunks within mining reach, + by index then field) on `Within`. + +**Derived state, never saved.** The index and every provider's bands, keys and `Rekeys` +count are derived from `AsteroidBeltData`, `PlanetSettings` and zone time and rebuilt +with the zone. Bands re-key lazily, at most 8 per query. `Examined` is a diagnostic +counter, not state. No candidate list survives a press. The only persisted belt state is +`ZonePack` key 6 (`ChunkWear`). + +**Forbidden writers.** +- Unity code picks no target. `ActionGameManager` binds four input actions to the four + `Entity` methods and does nothing else (`ActionGameManager.cs:389-392`). It builds no + candidate list, sorts nothing and calls neither `Within` nor `Best`. +- Nothing enumerates a belt's rocks to answer a targeting question. The only loop over + a belt's rocks outside `BeltTargets` is `Zone.EvaluateBelt` (rendering). +- The index decides reach and nothing about detection. A provider's bounds must hold for + every thing in the region; a provider that loosens them costs speed, one that tightens + them past a real rock drops targets. +- Only `SetTarget` writes `Target`; presses return false and keep the target when no + candidate exists. +- No `UnityEngine.Random` or shared stream in target choice (it has none). + +**Shared paths.** Reticle, next, previous and nearest-enemy presses; `VisibleChunksInReach`; +fire control's use of `Target` (`TargetRef`); the AI's `Minion` and `Combat` target +writers (F6, still `SetTarget`); load (`Zone` rebuilds providers). + +**Verification layer.** `tests/Aetheria.Shared.Tests/MiningIndexTests.cs`: index +equals brute force (`WithinIsExactlyTheBruteForceSet`, `BestIsTheBruteForceNearest`), +region bounds hold per rock (`BeltRegionBoundsHoldForEveryRock`, +`ACurveStaysWithinItsHullRange`), no visible rock dropped, cost pins (`Examined`, +`DarkBandsAreSkippedWhole`, `ReKeyingIsBoundedPerQuery`, `RegionsStayNearTheSearchArc`, +`VisibilityBoundPrunesADimBelt`) and `ARockExactlyAtReachIsFound`. +`MiningTargetQueriesTests.cs`: press rules against a brute-force order, perception +(`ShipsTheObserverHasNotDetectedAreNeverPicked`), tie orders, empty presses, and +per-press cost ceilings (`AKeyPressDoesNotGrowWithTheBelt`, +`PressPruningKeepsEachPressNearItsMeasuredCost`). Both are partial `MiningCut3Tests` +and compile only `ServerShared` (B11). The Unity side was compiled separately: a +Unity 6000.3.24f1 batchmode compile at `a83aa54e` on 2026-10-06 (`mining-merge-unity`) +exited 0 with no `error CS` in any of the five assemblies. Both ships smokes passed, and it +needed no fix commit, so the cut has no report. Stryker and `tools/mutation-table.sh` covered `Entity.cs` spans fully; survivors in +`BeltTargets` and `TargetingIndex` are equivalent or float boundaries. + +**Live seams and debt.** +- `EntityTargets` is registered on every zone but no production query reaches it with + an observer: `Entity.PickTarget` offers `VisibleEntities` itself, and `MiningSearch` + carries no observer. The index owns no ship answer today. Finding + `orphaned-index-surfaces`, deferred to `mining-index-tree`. +- `Entity.VisibleChunksInReach` has tests as its only callers; `Within` has no + production caller. +- A press still costs O(rocks in a fixed belt): `Best` enumerates and sorts the flat + region set. Deferred to `mining-index-tree` (hierarchy, heap, `Accepts` split) and + `mining-belt-cells` (rings and cells); findings `cut-mining-index-tree.r1` and + `cut-mining-belt-cells.r1` carry them. Neither gates the release. +- The index comment headers (`TargetingIndex.cs:10`, `Zone.cs:26`, both test files) + cite `docs/aetheria-release-map.md`, which exists only on + `eureka/aetheria-release-target`, not on master. + +### Mining index: rings, cells and best-first search + +**Why the press still grows.** At `a4e8e147`, after `mining-target-queries`, a press is +one `Best` query. Its cost has two linear terms in the rock count of a fixed belt: +- **Region enumeration.** `Best` asks every provider for its whole flat region set, + bounds each region and sorts the set, before opening one. A band holds a fixed 256 + rocks, so at 3M rocks a band is 0.05 units wide and a full ring. A 400-unit search + crosses about 8,000 of them, and each contributes its arc segments: 30k-64k regions, + 38-61 ms. +- **Sliver regions.** A segment of 32 rocks in such a band is a 0.05 x 470-unit + sliver. A sliver near the searcher has a small bound but holds rocks spread along + hundreds of units, so best-first opens many slivers for few useful rocks. + +**The floor.** The press queries are exact: the smallest angle to the look direction +(reticle), and the successor in (distance, target) order (next, previous). In linear +space these are thin-cone and thin-ring range queries. Any tree of cells must open +every cell that the cone's ray or the ring's circle crosses: about the boundary's +length over the cell size. That is O(sqrt(n)) for n rocks in reach, the kd-tree +line-query bound (Lee and Wong 1977), and no linear-space structure beats it for +simplex ranges (Chazelle 1989). The target is therefore sqrt(n), not flat. A flat +cost needs an approximate answer (question `reticle-exactness`). + +**Target growth.** In a fixed belt, regions opened plus rocks examined per press grow +as the square root of the rock count: at most 4.5x per tenfold rocks (sqrt(10) is +3.16), against 10x at `a4e8e147`. Visibility tests per press (`ChunkVisible`, the +expensive part) stay within 2x per tenfold. Building stays O(n) per belt, and a +refresh costs O(ring). + +**Proof by measurement.** Soul's F1 fixture: rocks 300..900 about the sun, light radius +1000, kind cross-section 5, sensor Eye, guns 150 and 400. +- Run it at 30k, 300k and 3M rocks, with the eye at chunk 0 + (5, 5), at (310, 0) and + at (600, 0). +- Presses: five `TargetNext` from no target, one `TargetPrevious` from no target, and + `TargetUnderReticle` looking along the belt and across it. +- Record `Opened`, `Examined`, `Tested` and time. Repeat after +300 s (stale rings) + and after +1e7 s. +- The suite pins 30k against 300k. The 3M point is a Soul probe, too slow for the + suite. +- Expected at 3M, with 16-rock cells about 3.5 units across: low thousands examined + and hundreds of regions opened per press. Today it is 37k-108k examined and 30k-64k + regions. + +**The design.** The index owns search order. The belt owns rock motion and cell bounds. +- **`mining-index-tree` (index only).** The provider seam becomes a hierarchy, `Roots` + and `Open`. Opening a region yields child regions, candidates, or both. + - `Best` keeps a binary heap of regions by their bound (netstandard2.1 has no + `PriorityQueue`). It stops when the next bound exceeds the best key. This is + Hjaltason and Samet's incremental distance browsing, now over a tree. + - `Within` walks the tree depth-first, pruning with `CanHold`. + - `ITargetKey` splits into a cheap geometric `Key` and an expensive `Accepts` + (detection). `Accepts` runs only for a candidate that would beat the best so far. + - New diagnostics beside `Examined`: `Opened` (regions opened) and `Tested` + (`Accepts` calls). + - `BeltTargets` and `EntityTargets` are adapted mechanically: today's flat regions + become roots that open into candidates. Answers and costs do not change. +- **`mining-belt-cells` (belt only).** `BeltTargets` is rebuilt as rings of square + cells. + - Rocks sorted by orbit distance are cut into rings. A ring closes once its width + reaches its cell arc (2 pi r x 16 / count), so cells are about square at any + density. A ring also closes at a cap of 8,192 rocks, which bounds one refresh. + - Each ring turns at its mean rate. Its cells are equal sectors of that turning + frame, about 16 rocks each, held as one index array with cell offsets. + - A rock drifts against its frame by at most the ring's rate half-spread times the + time since the ring was refreshed. That drift widens every cell's sector, so the + bounds stay sound without touching a rock. + - When the drift passes one cell, the ring is refreshed: every rock is re-bucketed + by its current turn in one O(ring) counting pass, with no sort. + - A refresh happens only when a ring node is opened, before any of its cells exist + in the query, so a query never sees a rock twice. + - Each query refreshes at most 16,384 rocks. A ring left stale is searched through + its wider sectors, which stays correct. + - The tree is implicit: a balanced range tree over ring indices (annulus bounds), + whose ring leaves open into halving cell ranges (sector bounds), down to single + cells. Today's `Sector` bounds and light bound carry over. +- Drift in cell units grows at about pi r |d rate / d r| per second, whatever the + density. + - With period = distance, a ring goes stale every r / pi seconds: about 190 s at + r = 600. + - Refresh work per unit time is O(n) per belt, amortised over the presses that touch + it. + +**Prior art**, cited from memory and not re-fetched: +- Hjaltason and Samet, "Distance browsing in spatial databases" (ACM TODS 1999): the + heap-ordered search that `Best` becomes. +- Šaltenis, Jensen, Leutenegger and Lopez, "Indexing the positions of continuously + moving objects" (SIGMOD 2000), the TPR-tree. Its bounds widen with elapsed time by a + velocity bound and are tightened on update. Here that is the drift-widened sector + and the refresh. +- Basch, Guibas and Hershberger, "Data structures for mobile data" (SODA 1997). An + event-driven kinetic order is rejected: every pair of rocks with different rates + eventually swaps, so events grow as the square of a ring's size. +- Arya, Mount, Netanyahu, Silverman and Wu, "An optimal algorithm for approximate + nearest neighbor searching in fixed dimensions" (JACM 1998). A cost flat in density + is reachable only for an approximate answer (question `reticle-exactness`). +- Space games: EVE's belts are static, and Elite Dangerous generates ring rocks per + cell around the player. Neither simulates per-rock orbital shear, so they bear on + the visual budget, not on this index. + +**Forks.** +- `reticle-exactness`: keep the reticle exact at sqrt(n), or accept an angle tolerance + for a flat cost. Next and previous stay exact either way, because cycling must visit + every target once. +- `belt-size-tiers`: give each size class its own provider, so small rocks are pruned + at their shorter detection reach. This is a constant factor on `Tested`, not a + growth fix. + +### Rationale + +**Why every item carries its hardpoint, rather than keeping the origin inside it.** +Soul named both fixes. +- Keeping the origin inside the hardpoint forbids legal placements. An L-shaped gun + could never fill an L-shaped hardpoint whose corner is empty, because the gun's own + origin cell is that corner (P30 (a)). +- Looking the hardpoint up by the item's cells at every reader would put the placement + rule in 18 places. + +The placement rule already knows the answer at equip. Keeping that answer deletes 18 +re-derivations; it adds nothing. + +**Why the index is generic and the belt is a provider.** +- The operator ruled it (L4). +- The seam is the smallest that carries both providers in the tree today and the ones + the operator named, EW and microfauna. A provider yields regions with a distance + interval, a bearing interval and a visibility ceiling, and the candidates for a + region. +- The index owns no motion model. A provider for orbiting rocks, one for moving ships + and one for a slime field each keep their own. +- Prior art, cited from memory and not re-fetched: + - best-first incremental search over a region hierarchy: Hjaltason and Samet, + "Distance browsing in spatial databases" (ACM TODS 1999); + - kinetic sorted orders for moving points: Basch, Guibas and Hershberger, "Data + structures for mobile data" (SODA 1997). + +**Why the index and the key-press rules are two cuts.** +- The index alone removes the 633k-rock scan, but leaves the handlers sorting every + visible rock (2,240 at 3M) on each press. +- The operator's rule is that a press must not grow with belt size. Only best-first + answers meet it. After Soul's first pass on the index, the operator accepted the + remaining growth with rock density (see below). +- Together the two are about 900 lines with tests, over one Hands budget. The seam + between them, the index's query API, is where Soul can falsify each. + +**Why the index fix before the merge is only pins, and the cells come after.** +- Soul (verdict `cut-mining-index.s1`) measured the index at `a4e8e147`. It is correct, + and the 3M-rock press fell from 4.3 s to 150-600 ms. +- Press cost still grows linearly with rock count in a fixed belt (finding + `f1-press-linear-in-density`). +- The operator ruled that this is not critical, but that the improvement is wanted + (ruling `targeting-sublinear-wanted-not-critical`, which supersedes + `targeting-density-scaling-accepted`): "O(sqrt(n)) is way better than O(n), my + ruling says it's not critical, not that it's undesired". +- So the merge waits only for `mining-index-pins`: three tests for findings + `cost-loosening-unpinned` and `exact-reach-unpinned`, which the suite could not see. + - The share of regions past reach catches a widened arc window or a loosened + `Nearest`. + - Examined against an exact per-rock bound catches a loosened visibility bound. + - A rock exactly at reach, searched from beyond its belt's outer edge too, catches + a slack sign slip. +- `mining-target-queries` r1 stands as specced. Its `AKeyPressDoesNotGrowWithTheBelt` + scales area at constant density, which both rulings leave in force. +- The sublinear structure is the section "Mining index: rings, cells and best-first + search" above, as two cuts after `mining-merge`. + +**Why loot is three cuts on one branch, merged together.** +- The ruled design (L8) needs three things: + - floating items in the simulation (`loot-1`); + - the grab as a capability, with its catalog entry and generation (`loot-2`); + - the presenters (`loot-3`). +- Together they are about 1,000 lines. +- Merging after `loot-1` alone would ship a game where loot cannot be collected, + because `loot-1` deletes the Unity collection that the ruling retires. + +One branch keeps master playable, and three cuts keep each Soul pass small. + +**Why floating items are not saved.** Today's Unity loot is not saved either, and a +floating item lives 30 seconds. Saving them would add a `ZonePack` field and a +migration for a window shorter than a save cycle. Jettison may want longer lifetimes; +its cut can revisit this with a reason. + +**Why the loot dice are the zone's, keyed by death ordinal.** +- Fire control's Cut 6b rule is that a roll belongs to the thing rolled for, not to a + shared stream, so a UI or another system drawing first cannot change it. +- The loot roll follows the same rule with its own stream, so it cannot disturb shot + dice either (test `LootDiceDoNotTouchShotDice`). +- The mixing function moves to one owner (`SimulationDice`) instead of being copied. + The owner-level fix is CultMath's (follow-up `cultmath-mixed-seed`). + +**Why ammo-fed weapons leave the power bus.** By L9, a weapon on the bus reads its +tier's grant ratio even when it requests nothing. A request of zero would still let a +brownout silence a gun whose energy comes from its rounds. Off the bus, its supply is +1 and the reactor cannot touch it. That makes the ruling's trade-off literal: energy +weapons compete for reactor power, and ballistic weapons compete for cargo. + +**Why one ammunition commodity.** +- The catalog has one (`Ammo`). +- The mechanism is data (`AmmoType` per weapon), so a split by calibre is a catalog edit + that needs no code. +- A tender carrying one commodity is also the simplest rearm loop to prove. + +This is a default, not a ruling. The operator can ask for calibres at any time. + +**Why mining's Q6a should be asked again.** Mining ruled that ore is lost when a hold +is full (Q6a A), because pickups were Unity-only (mining-cut.md:286-287). After +`loot-1`, overflow could float through `Zone.Release` like any other loot. Follow-up +`mining-cuts-4-5-7` carries the question to that map. diff --git a/docs/aetheria-release-target.md b/docs/aetheria-release-target.md new file mode 100644 index 000000000..745ac6467 --- /dev/null +++ b/docs/aetheria-release-target.md @@ -0,0 +1,196 @@ +# Aetheria Release Target: the Demo + +Date: 2026-10-03, re-scoped 2026-10-06 + +Status: target. This document owns the ends. The typed `target` in the Eureka mind +(campaign `aetheria-release`) owns the labelled invariants and the not-in-scope list; +this file gives their reasons. Cut specs, questions and rulings are typed documents in +the mind and are not restated here. Body facts, the model page, the scope sort and the +rationale for the cut order are in `docs/aetheria-release-map.md`. + +## Why + +The game has deep systems: fire control, stats and power, heat, item provenance, +locomotion, scenarios and the targeting index. What keeps it unreleasable is that a +player has nothing to do between minute five and minute thirty, and nothing they do +is seen or heard. It plays no sound. A hit on a hull shows nothing where it lands. +Loot cannot be sold, damage cannot be repaired, no boss guards anything, and reaching +the exit does nothing. Every NPC fights the same way. + +The first release is a demo, and it is called one. The full game, Terminus with three +procedurally generated regions, needs enough faction variety to give each region its +own challenges and stories. Until that content exists the game is not presented as +complete. + +## Operator direction + +- 2026-10-06 (ruling `first-release-is-a-demo`): "Full Terminus requires enough + faction variety to present unique challenges and stories across three regions, I + can't call it anything more than a demo if we don't have enough content to + populate the three gates. I'm fine with reducing scope so we can get to a release + on time, but I will not pretend it is the whole game." +- 2026-10-06 (ruling `demo-cut-fixed-region`): the demo is one gate, one region and + one boss with a fixed faction presence. Full Terminus keeps procedural regions, + each with its own political and economic setup. +- 2026-10-06 (ruling `demo-cast`): one allied faction, the protagonist's ostensible + employer under Aetheria's usual polite coercion; one opposing faction, which + decides the boss; the rest neutral. Allied: the Pirates. Antagonist and boss: + Zhestokost. Featured neutrals: Lucent Media for spectacle, Aeronautics Unlimited + as the vanilla that balances the other flavours. +- 2026-10-06 (ruling `miss-terris-belongs-to-emily-r3`): Miss Terri's is Emily's + faction. Its role in the prelude stands. Agents write no lore, explanations, + faction play, doctrine, barks or ship concepts for it, and build on none of the + AI-written material already made for it. +- 2026-10-06 (ruling `ship-authors-need-no-unity`): a person adding a ship works in + Blender and the CultLib tools and never installs Unity or syncs the repo. +- 2026-10-02 and 2026-10-03 (ruling `operator-release-direction`): the release needs + audio, ships, stations and gear to covet and collect, and mechanics that are + visible and visceral. The announcement bar names a wider selection of ships and + gear, a UX polish pass, and audio feedback throughout. + +## What "the demo" means + +The demo is a public build, announced to players and Patreon, with a scenario named +"Terminus (Demo)". It is done when every line below holds on one Windows build made +from `master`, and the operator has played that build and called it. A ticked list +without the operator's call is not a release. + +1. **One run, start to finish.** New Game starts the demo in a hull made by the + Pirates, at a station near the entrance of one region with a fixed cast: the + Pirates allied, Zhestokost hostile, Lucent and AU neutral. The Zhestokost home + zone holds the region's one gate, guarded by its boss. The gate stays sealed + while the boss lives. Passing it after the boss dies ends the run as won. Death + ends the run as lost. +2. **The run pays.** At a docked station the player can buy, sell what they looted + and repair what they lost. Credits persist across save and Continue. +3. **The four factions fight differently.** Each cast faction has a doctrine a + playtester can describe without being told, from the Faction Play design in + AetheriaLore. +4. **Every player action that changes the world makes a sound.** Weapon fire and + charging, impacts on shield and hull, ship destruction, docking and undocking, + the wormhole and the gate, thrusters and the drive, inventory and trade actions, + menu clicks, the overheat alarm, and music in the menu, overworld, combat and the + boss fight. Master, music, effects and interface volume are player settings that + persist. +5. **Every hit is seen where it lands and felt by the one who takes it.** An impact + on a shield shows on the shield at the side it came from; an impact on a hull + shows at the struck point; a hit on the player moves the camera in proportion to + the damage. Shake can go to zero and the hit is still shown. +6. **Damage, heat and exposure are visible before they are fatal.** Lost armour, a + destroyed item and a critical hull look different on the ship and in the HUD. + Overheat and hull-critical warnings use sound plus a visual, and the more urgent + one wins. The player can see how visible they are and the largest source of it. +7. **There is enough to covet in the demo.** Each cast faction flies a hull of its + own and Zhestokost's boss flies its boss hull, each entering through the + first-party package path. The exact count is question `demo-content-bar`. Rarity + tiers show their colours, and every weapon type sounds and looks distinct. +8. **A new player can play the demo through on the Windows build without hitting a + blocking defect**, published through the build-delivery path. + +## Ends + +- **The simulation states what happened, and presentation shows it.** Fire control, + shields, damage, heat, detection, the boss and the gate publish typed facts. + Visual and audio presenters derive a cue from each and render it. They decide no + rule and recompute no geometry. +- **One cue, two channels.** Each fact becomes a cue in one engine-free function; + the visual and the audio presenter render that same cue. +- **The run is a scenario.** The demo is a compiled scenario script beside the + tutorial and main galaxies. Its cast, starting standing and gate are written in + that script; placement stays with the galaxy generator. +- **New content is data, authored without Unity.** A new hull, station or piece of + gear arrives as a catalog record or a first-party package that carries its own + product. Nobody hand-wires a prefab array, and no author needs Unity or the repo. +- **The player controls intensity without losing the signal.** Turning shake, + flashes, particles or any audio channel down never removes the tactical + information it carried. + +## Invariants that must survive + +The typed target carries these as labelled statements. The reasons: + +- **Legacy first** (operator, 2026-09-11). All work lands in the restored Unity tree. + No daemon, CultMesh or Eve re-architecture here. +- **The simulation owns the facts.** Fire control was rebuilt so that no Unity + collider or effect decides a hit. Feedback reads `FireControl`'s facts and never + re-derives them. The same holds for the run: whether the boss lives and whether + the gate is open are decided in `ServerShared`. +- **The simulation names no sound.** Audio is a presenter of the same semantic + events visual effects use. +- **State is CultCache.** Player preferences go in the player store, authored data in + the catalog, run state (credits included) in the run store. Presentation tuning + stays on its Unity component (fork M (a) of `docs/settings-globals-cut.md`). +- **Instruments bound what is shown.** A cue never shows the player more than their + sensors know. +- **The demo is named a demo.** Release copy, store text, the scenario name and + these docs call it a demo. +- **Miss Terri's is not AI-written.** No agent writes material for it. +- **Ship authors need no Unity.** +- **The lanes are respected.** This campaign never commits to a `codex/*` branch or + the operator's checkout. +- **Rules are tested headless.** Every rule decided in `ServerShared` has a + behavioural test in `tests/Aetheria.Shared.Tests`. +- **The operator judges whether the bar is met.** + +## Not in scope for the demo + +- Full Terminus: three regions, procedural regional politics and economy, faction + selection per run, and the full content bar. These come after the demo. +- Factions outside the cast as flags. They stay brands on shelves and generated + traffic where the generator already puts them. +- Mining yield and content (mining Cuts 4, 5 and 7), the sublinear targeting index + (ruling `targeting-sublinear-wanted-not-critical`), and belt rendering through the + index. +- The faction-play tender loop (`faction-play-4`), drones and munitions, the + Colosseum, articulated mounts. +- Narrative: storylets, Ghostlight, Ink beats per boss. +- Volumetrics and the nebula backport. +- Asura and procedural planet content. +- New mechanics beyond what the demo's rulings name. +- Build publishing machinery (`docs/build-delivery-target.md` owns it). +- Platforms other than Windows 64-bit, and multiplayer. + +Open questions decide where four more items fall: the material pipeline +(`demo-material-scope`), the all-in-`.cc` ship move (`demo-ship-cc-timing`), the +control rebuild (`demo-controls-rebuild`) and which modes a release build lists +(`demo-build-scenario-list`). + +## Strands + +- **The run.** Three cuts: `demo-galaxy` (the scenario, its cast, the gate's + placement), `boss-gate` (the boss and the sealed gate, the win), and + `station-services` (sell, repair, saved credits). The on-ramp is follow-up + `demo-difficulty-onramp`, after a played build. +- **Feedback.** `feedback-1`: impacts with a world point and a layer, shown on the + shield or hull and in the camera. +- **Audio.** `audio-1`, native Unity audio (ruling `audio-native-unity-plus-faust`). + Its sequel covers interface, trade, docking, the wormhole and gate, music + (including the boss), loops and the overheat alarm. +- **Faction play.** `faction-play-1` to `-3`, re-anchored on post-merge master and + revised for the demo cast with the Pirates' doctrine (follow-up + `faction-play-reanchor-demo`). The Pirates need a catalog record first (follow-up + `pirates-catalog-record`). +- **Loot.** `loot-1` to `-3`: floating items are simulation state and pickup is a + capability. With `station-services`, loot becomes money. +- **Content.** The package path that landed with moddable-ships, then the add-on's + frame and mounts cuts, then hulls for the cast and the boss (follow-ups + `demo-cast-hulls`, `zhestokost-boss-hull`), authored at the operator's pace. +- **UX polish.** A ledger of small fixes verified through scenarios (follow-up + `demo-ux-ledger`). + +## Sequencing + +The run goes first, because it is the hole every other strand fills: without it the +demo has no thirty minutes. `demo-galaxy` and `station-services` are independent +and can run in parallel; `boss-gate` follows `demo-galaxy`. Feedback and audio follow +the order already mapped (`feedback-1`, then `audio-1`). Faction play waits for its +re-anchor. Content runs in parallel at the operator's pace, because the hull clock +starts only when the first demo hull flies. + +## Lanes and collisions + +The hot files are `Entity.cs`, `FireControl.cs`, `ActionGameManager.cs`, +`ZoneGenerator.cs`, `ZoneRenderer.cs` and `TradeMenu.cs`. Each cut branches from +`origin/master` in its own worktree and keeps its hunks out of the regions other open +cuts change, which are listed per spec. New behaviour goes in new files wherever an +existing owner allows it. diff --git a/docs/headless-playground-cut.md b/docs/headless-playground-cut.md index b90621050..8e52bdaf1 100644 --- a/docs/headless-playground-cut.md +++ b/docs/headless-playground-cut.md @@ -683,7 +683,9 @@ moves the `BehaviorData` union list down by 10 lines. writes only `LookDirection`) engage only within 60 degrees of their spawn facing. That is fork W. -**Death, loot and pickup** (carried from the earlier draft). +**Death, loot and pickup** (carried from the earlier draft). Loot into the simulation did +not land with this cut; cut `loot-1` of campaign `aetheria-release` owns it now (ruling +`jettison-shared-floating-items`). - `Zone` subscribes to the `Death` of each entity it adds. On death it: - removes the entity and its agent; - drops each non-hull item with probability `LootDropProbability`, and all cargo, diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md index 4feec9edb..292ad913c 100644 --- a/docs/merge-to-master-checklist.md +++ b/docs/merge-to-master-checklist.md @@ -38,7 +38,7 @@ Compiled 2026-09-29 by Eyes from the maps on the branch. Pointers are `doc:line` ### 2c. Flight, power and economy -6. **Provenance in play.** Starting-ship items show tier colour; the properties panel shows Manufacturer, product name and flavour for branded gear. Dock and buy: the brand still shows in cargo. `give Lamp` (any design name): no Manufacturer row. Kill an NPC and pick up loot: brand shown (`item-provenance-cut.md:528-539`). Proves: item-provenance Cut C. +6. **Provenance in play.** Tier colour shows only after settings-globals Cut 1 (this expectation was falsified by the 2026-09-17 smoke, section 8); the properties panel shows Manufacturer, product name and flavour for branded gear. Dock and buy: the brand still shows in cargo. `give Lamp` (any design name): no Manufacturer row. Kill an NPC and pick up loot: brand shown (`item-provenance-cut.md:528-539`). Proves: item-provenance Cut C. 7. **Fly a restored hull (Longinus or Djinni).** Thrusters move and turn it (`locomotion-cut.md:501`). Watch the speed cap: `VelocityLimit` is gated by the hull's `Active`, and a hull taken offline with thrusters firing loses the cap (`:377`). It did not happen in 300 s on shipped settings; report if you see runaway speed. Unrecorded: how to spawn a restored hull in play (the map names no path). 8. **Weak reactor (O2).** Fly with a reactor too small for the loadout. Expect visible, legible starvation rather than a cooked reactor (`stats-and-power-cut.md:705-706`). Proves: power bus (map Cut 3). 9. **Brownout and refill (O6, O4).** Brownout reads as degradation, not breakage (`:774`, `:990`). A weapon refilling under brownout stutters instead of firing full rate and cooking the ship (`:729-730`). Proves: power-supply term, brownout, input capacitors. diff --git a/docs/three-gates-scope.md b/docs/three-gates-scope.md index 85912b862..8dda73507 100644 --- a/docs/three-gates-scope.md +++ b/docs/three-gates-scope.md @@ -2,6 +2,20 @@ Date: 2026-09-11 +**Status (2026-10-06).** The first release is a demo: one gate, one region, one boss, +with a fixed faction cast (rulings `first-release-is-a-demo`, `demo-cut-fixed-region` +and `demo-cast` in campaign `aetheria-release`). `docs/aetheria-release-target.md` owns +the release now; this document records the full game's three-gate run, which comes +after the demo. Where the items below stand: + +- Item 1: death deletes the run (`ActionGameManager.Die` calls `RunSave.Clear`). The win + screen is cut `boss-gate`; saved credits are cut `station-services`. +- Item 2: for the demo's one gate, cuts `demo-galaxy` and `boss-gate`. Three sections, + locked boss-zone wormholes and difficulty per section are full game. +- Item 3: cut `station-services` (sell and repair) and cuts `loot-1` to `-3`. +- Item 4: landed (`docs/fire-control-cut.md`). +- Items 5 and 6: not in the demo. + Aetheria ships as a short rogue-lite run on the existing Unity game: start at the Entrance, fight through three boss chokepoints, reach the Exit. Everything the player earns comes from what they destroy. More systems come after this @@ -46,7 +60,8 @@ AI share it, so AI accuracy is no longer superhuman by construction. **Ruling (operator, 2026-09-17):** hit detection goes away entirely. A hit is a dice roll over weapon stats, the stats of a new targeting-system subsystem, and sensor -state. Fire control is being mapped as Cut 2 of `docs/headless-playground-cut.md`. +state. Fire control was mapped from Cut 2 of `docs/headless-playground-cut.md` and has +landed (`docs/fire-control-cut.md`). **Ruling (operator, 2026-09-17):** the player and the AI can aim for a specific subsystem once they have gathered enough target data to reveal it. Operator's words: "We