sots-re/findings/subsystems/t34-observed-designs.md
alex ea7b771c76 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
2026-09-09 10:03:48 -04:00

15 KiB
Raw Blame History

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)