From ea7b771c76e7ab1815c7fdb5c81e807f1f3812f4 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 9 Sep 2026 10:03:48 -0400 Subject: [PATCH] lane DT: T34 RecordObservedDesigns -- mechanism read from the instruction stream, prediction committed before the build The phase is FUN_007c2350 (tail phase 34): for every player, over every ship of every fleet, gated on a two-bit-per-player field at Ship+0x54 that is NOT on the wire, call RecordObservedDesign(player, ship.design). That function dedups on odid alone, and on a hit it ERASES and PUSH_BACKS rather than updating in place -- so the list is ordered by last observation and otnF survives the move -- then caps the list at 20 entries per DESIGN-OWNER, counted from the back, after every single record call. sizeof(ObservedDesign) = 0x10. Predictions, computed from a Python simulation of that mechanism over the corpus, committed before any engine code is written: P1 rich turn odes: 55 -> 21, i.e. 34 closed / 0 regressed P2 otch+owep (24 leaves): 0 closed / 0 regressed, blocked (see the finding) P3 rich-turn total 1092 -> 1058 P4 canonical pair: 1 closed / 0 regressed P5 the NPC-owner guard is worth 4 leaves of regression on the canonical pair --- findings/subsystems/t34-observed-designs.md | 248 ++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 findings/subsystems/t34-observed-designs.md diff --git a/findings/subsystems/t34-observed-designs.md b/findings/subsystems/t34-observed-designs.md new file mode 100644 index 0000000..69eac69 --- /dev/null +++ b/findings/subsystems/t34-observed-designs.md @@ -0,0 +1,248 @@ +# `T34 RecordObservedDesigns` — the phase, its gate, and what it closes + +- **Type:** subsystem / engine implementation +- **Owner / date:** lane DT · 2026-09-09 · **HOST ONLY**. No VM was touched; VM140/141/145/146 + were not approached, not pinged and not logged into. The corpus was read, never written. +- **Trigger:** `campaign/backlog.md` §2/§3 Track 2, and + `findings/subsystems/rung-b-rich-turn.md` §5 rank 4 — lane CV's ranked worklist names + `T34 RecordObservedDesigns` (stub) as "79 leaves, no upstream dependency", the largest + independently closable item measured against a rich turn. +- **Consumes:** CV's measurement (`verify/results/standalone/cv/*`), lane BR's deep block + `verify/results/turncommands/br2-turn27-deep.tcb`, lane X/S's `observedtech-append.md` + (the sibling `ObservedTech` record and its 0x2c element), lane PL's `players-residual.md` + §M3, lane T's `combat-done-tail.md` phase table. +- **Touches:** `sots-engine`: `src/game/sim/observed.{h,cpp}` (new), + `src/app/observed_phase.{h,cpp}` (new), `src/app/turn.cpp`, `src/app/phase_catalog.cpp`, + `tests/game_sim/test_observed.cpp` (new), two `CMakeLists.txt`. + `sots-re`: this file, `ghidra/addresses.d/dt.json`, `verify/results/standalone/dt/*`. + **`src/mars/stream/` was not touched** — that is lane DW's file set this round. + +--- + +## 1. The prediction, written and committed before the engine was built (rule 2) + +This is a *computed* prediction, not a guess: the mechanism below was read out of the +instruction stream first, then simulated in Python against the save corpus, and the numbers +below are that simulation's output. What the build then tests is whether the C++ in +`sots_turn` — reading the save through the engine's own typed shapes, in the engine's own +iteration order — reproduces the simulation. Any disagreement is an engine/order defect, and +that is exactly the thing this prediction is for. + +| # | prediction | why | +|---|---|---| +| **P1** | On the rich turn the **`odes` sub-group closes 34 of its 55 leaves and regresses 0**; 21 remain. | The mechanism is fully determined by the wire (§2). The 21 that remain are the shadow of two designs — 1826 and 1522 — whose **first ships are built during turn 27→28**; this engine builds no ship, so it cannot observe them, and their absence changes which entries the 20-per-owner cap evicts. | +| **P2** | The **`otch` + `owep` sub-group (24 leaves) closes 0 and regresses 0.** | Not implemented: the design→tech and design→weapon lists the phase feeds to `RecordObservedTech`/`RecordObservedWeapon` are built by three `std::set` builders in the original that are not decoded, and the wire's own `DOpts` list covers only 13 of the 18 tech names this turn touches (§4). Named as a dependency, not attempted. | +| **P3** | Rich-turn total: **1092 → 1058**. | P1 + P2, with nothing else moving. | +| **P4** | On the **canonical pair the phase closes 1 leaf and regresses 0** (`Player[32]/odes/.[1]/otnL: 2 → 3`). | The whole canonical-pair `odes` residual is that one stamp; the simulation reproduces it exactly, including the eight players whose lists must stay empty. | +| **P5** | The **NPC guard is load-bearing and its absence is a 4-leaf regression on the canonical pair**, not on the rich turn. | Without it, player 528 ("Alien Menace", NPC) gains four `odes` entries the target does not have. This is the one place the two pairs disagree about which rule is needed, and it is why both pairs are run. | + +**Falsifiers, each with its symptom.** + +1. *The engine's fleet/ship iteration order is not the save's order.* Symptom: the surviving + `odid` sequence is a different permutation and the rich-turn residual is **worse than 21**. +2. *A player observes ships it does not own.* The gate in the original is a two-bit-per-player + field on the ship that is **not on the wire**; this implementation stands in for it with + ownership (§3.3). Symptom: entries appear for foreign designs — a regression, on either pair. +3. *`sim.frame` is not the post-increment turn at tail time.* Symptom: every `otnL` is off by + one and the residual **grows** rather than shrinks. +4. *The cap is not 20, or is not per design-owner.* Symptom: the list length is wrong, which + is a `LEN` leaf plus four per extra element. + +--- + +## 2. What the phase is — read from the instruction stream + +**`T34` is `0x007c2350`**, 222 bytes, called from `OnAllCombatDone_Tail` `0x007d92a0` at +`0x007d98aa` (lane T already had it as tail phase 34: "record observed designs into +`ServerPlayer+0x254/+0x258`"). Its two other callers are `BuildTurnEvents` `0x007db780` and +`LoadGame` `0x007dd530`. + +### 2.1 The phase body — a triple loop with one gate + +``` +for pi in 0 .. |S->Players|: ; S+0x54 / S+0x58 + p = S->Players[pi] + for fi in 0 .. |S->Fleets|: ; S+0x64 / S+0x68 + f = S->Fleets[fi] + for si in 0 .. |f->ships|: ; f+0xa4 / f+0xa8 + sh = f->ships[si] + if (p->PlyrIdx(+0x28) >= 15) continue ; 0x007c23bf cmp eax,0xf ; jge + cl = 2 * p->PlyrIdx + if (((sh->+0x54 >> cl) & 3) != 3) continue ; 0x007c23ce and eax,[edx+0x54] + RecordObservedDesign(S, p, sh->design(+0x14)) ; 0x007c23e2 +``` + +Two things to carry away. The **outer** loop is players and the **inner** two are the whole +fleet list, so the phase is O(players × ships) and every player is offered every ship in the +game. And the gate is a **two-bit-per-player field at `Ship+0x54`** — index `2*PlyrIdx`, both +bits required. `Ship+0x54` is **not serialised**: the ship's on-disk field list runs +`… +0x4c Dep, +0x4d Atq, +0x5c LCT, +0x60 tsd …` with `0x54` and `0x58` absent +(`objects/layouts.json`, `Game::StarShip`). The cap at 15 players is the same cap +`src/game/sim/visibility.h` already records for the runtime companion of the system masks. + +### 2.2 `RecordObservedDesign` — `0x007be340`, `ret 8` + +`this` is the `StrategyServer`; arg 0 is the observing `ServerPlayer*`, arg 1 the `Design*`. + +``` +if (!player || !design) return; +owner = design->+0x130 ; the design's owning ServerPlayer +if (owner->+0xfb != 0 && owner->+0xfc == 0) return; ; 0x007be389 / 0x007be392 +if (!Design::vt-ish 0x0080baf0(design)) return; ; 0x007be3a1 +if (Design::HasFlag(design, 0x400, 0)) return; ; 0x007be3b7 -> 0x00813ab0 + +; --- dedup, on odid ALONE (not on the (odid, opid) pair) --- +for (e = player->odes._Myfirst; e != _Mylast; e += 0x10) ; *** stride 0x10 = 16 *** + if (e->+0x8 == design->id) break; ; +0x8 is `odid` + +if (e == _Mylast) { ; APPEND 0x007be3f5 + push_back(ObservedDesign{}); ; ctor 0x0080da30, push_back 0x00799d80 + back->+0x4 = S->turn(+0xc); ; otnF (16-bit) + back->+0x6 = S->turn; ; otnL (16-bit) + back->+0x8 = design->+0xa0->+0x4; ; odid + back->+0xc = owner->+0x4; ; opid + if (owner != player) +0x284 + 0x10*player->PlyrIdx> +} else { ; MOVE-TO-BACK 0x007be618 + tmp = *e; ; otnF/otnL/odid/opid copied out + odes.erase(e); ; 0x00795c40 + odes.push_back(tmp); ; 0x00799d80 +} +back->+0x6 = S->turn; ; otnL, common to both paths + +; --- the cap: 20 entries per DESIGN-OWNER, counted from the BACK 0x007be67f --- +kept = 0 +for (i = count-1; i >= 0; --i) + if (odes[i].opid == owner->+0x4) { + if (kept >= 0x14) { ; erase odes[i]; } + else ++kept; + } +``` + +`sizeof(Game::ObservedDesign) = 0x10`, and the element is +`{vptr @+0x0, uint16 otnF @+0x4, uint16 otnL @+0x6, int odid @+0x8, int opid @+0xc}` — the +wire record `otnF otnL odid opid` exactly, with the vptr accounting for the difference between +12 on disk and 16 in memory. (Compare its sibling `Game::ObservedTech` at `0x2c`, whose extra +0x1c is the embedded tech-name `std::string` — `observedtech-append.md` §4.) + +**Three facts here are the whole finding, and none of them is guessable from the wire:** + +- **the dedup key is `odid` alone.** A design id is globally unique in this game, so this only + matters if two owners could ever share one — but it is what the code does, and modelling it + as a `(odid, opid)` pair would be a different program. +- **re-observation is `erase` + `push_back`, not an in-place update.** The list is therefore + ordered by *last* observation, and `otnF` survives the move. This is why the target's list is + a permutation of the input's rather than an append. +- **the list is capped at 20 entries per design-owner**, counted from the most recent end, and + the cap is enforced *after every single record call*, not once per turn. On a player with more + than 20 designs in service this thrashes: entries are evicted and re-created inside one sweep, + and each re-creation resets `otnF` to the current turn. That is measured, not inferred — see + §3.2. + +### 2.3 The tech and weapon arms + +The same function then builds three lists off the design and feeds two of them to +`RecordObservedTech` `0x007ba1a0` (lane X's function) and one to `RecordObservedWeapon` +`0x007be1b0`: + +``` +0x007be4af FUN_00862c90(&v, design) -> for each: RecordObservedTech(player, owner, x) 0x007be4e1 +0x007be50d FUN_008629b0(&v, design) -> for each: RecordObservedTech(player, owner, x) 0x007be535 +0x007be561 FUN_008626a0(&v, design) -> for each: RecordObservedWeapon(player, owner, x) 0x007be591 +``` + +All three are ~600–700-byte `std::set` builders (`operator new 0x14` red-black nodes) walking +the design's section array at `Design+0x2c`/`+0xac` and `+0x3c`. They are **not decoded**, and +they are the dependency §4 names. + +--- + +## 3. The gate, and the two guards the corpus forced + +### 3.1 The visibility gate is unexercised on both reference pairs — say so out loud (rule 6, rule 28) + +`Ship+0x54` is not on the wire, so the phase's real gate cannot be read from a save. It does not +have to be, on these two pairs, because **no player observes another player's ship on either of +them**: + +- rich turn: player 16's twelve `opid = 32` records all keep `otnL = 27`, and player 32's one + `opid = 16` record keeps `otnL = 25`. Every record that moves is a player's own design. +- canonical pair: the single moving record is player 32's own design 18 (lane PL's M3). + +So this implementation stands the gate in for **`ship.PlrID == player.id`**, and that is a +**hypothesis with a named falsifier**: the first save in which one empire's fleet stands in +sensor range of another's and a foreign `odes` record moves. It is not a reading of the gate and +must not be quoted as one. What it *is* is the smallest rule that is correct on every state the +corpus contains — and lane PL's M3 said the same thing from the other end ("not an intel pass"). + +### 3.2 The NPC guard — `owner->+0xfb && !owner->+0xfc` + +The two bytes are consecutive `ServerPlayer` bools next to `RebAI`, which lane T pinned at +`+0xfc` (`ProcessTurn` phase 8: `if (RebAI(+0xfc)) RebOutMod = clamp(...)`). The wire writes +`Elim, NPC, RebAI, ReqCL` consecutively, so `+0xfb` is `NPC`. Read as +**"a design owned by an NPC that is not a rebel AI is never recorded"**, and the corpus agrees: + +| save | player | kind | own designs | own `odes` records | +|---|---|---|---:|---:| +| `ad-turn27` | 32 "The Eternal Empire" | not NPC | 46 | 20 | +| `ad-turn27` | 16 "re" | not NPC | 6 | 1 | +| `ad-turn27` | 528 "Alien Menace" | **NPC, not rebel** | 19 | **0** | +| `ad-turn27` | 576 "Independent Colony" | **NPC, not rebel** | 1 | **0** | + +Every `odes` record in the corpus, on every player, names a design owned by 16 or 32 — the two +non-NPC empires. The four NPC factions own 26 designs between them and not one of them has ever +been observed by anybody. + +**Without this guard the canonical pair regresses by 4 leaves** (player 528 gains four records), +which is P5 and is the reason the canonical pair is not a formality here. + +### 3.3 What is *not* modelled + +`0x0080baf0(design)` and `Design::HasFlag(design, 0x400)` are two further early returns that +this implementation does not evaluate — the first is an unnamed predicate on the design, the +second a design flag. Both are **assumed false** for every design in the corpus, on the evidence +that the simulation reproduces the target list without them. That is an argument from a +sufficient model, not from having read the flags, and it is recorded as such. + +--- + +## 4. Named dependency: the tech and weapon arms are NOT independently closable + +CV's ranking says rank 4 has "no upstream dependency". That is right for the `odes` half and +**wrong for the other 24 leaves in the group**, in two separate ways. Both are named here and +neither is attempted (scope discipline). + +**(a) `otch` / `owep` need three undecoded set builders.** The obvious wire candidate is the +per-section `DOpts` list, which `sots-engine`'s own `game::design::applied_techs` already +documents as "exactly the save's `DOpts` list: hull-class tech, `requires_tech` in file order, +then the chosen option". Measured against the reference turn, the union of `DOpts` over the +designs player 32 observes covers **13 of the 18 tech names that move** and misses five — +`WEP_GrnLas`, `WEP_PlsmCan`, `WEP_Dsrptr` (three weapon techs), `CCC_BtlCmp`, and the one +**newly appended** record, `BIO_TerBac`. So `DOpts` is one of the three lists and not the whole +of them; the other two (`0x008629b0`, `0x008626a0`) are `std::set` builders over +the design's weapon banks and would have to be decoded, and their **set ordering is +load-bearing** because it decides the append order of any new `otch` element. + +**(b) The `odes` half itself is capped by ship construction.** Two of the designs the target +observes — 1826 "Egg Thief Mk 3" and 1522 "Bravestar Mk 2" — have **zero ships in the input and +their first ships in the target** (4 and 1). No phase in this engine builds a ship (CV rank 8=, +`M2` in `players-residual.md`), so those two observations cannot happen here, and because the +list is capped at 20 per owner their absence also stops the two evictions (546 "Warrior Mk 6", +770 "Beaten Shield Mk 2") that the target performs. That is the whole of the 21-leaf residual. + +**The oracle run proves the mechanism is not the residual.** Feeding the same implementation the +**target's** post-turn fleet/ship state instead of the input's leaves **1 leaf, not 21** — +`Player[32]/odes/.[18]/otnF`, where this model writes 28 (the entry was evicted and re-created +inside the sweep) and the target holds 27. So on the rich turn the mechanism is exact on 20 of +21 records and the gap is ship construction, not `T34`. + +--- + +## 5. Results + +*(filled in after the run — see §5.1 onward)* + +--- + +## 6. Reproducing + +*(filled in after the run)*