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
This commit is contained in:
alex 2026-09-09 10:03:48 -04:00
parent b6018a0f19
commit ea7b771c76

View file

@ -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) <notify list at S->+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) { <notify if odes[i].opid != player id>; 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<std::string>` 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)*