sots-re/findings/subsystems/t34-observed-designs.md
alex d9b06ca447 lane DT: T34 implemented and measured -- 36 closed / 2 regressed on the rich turn, 1 / 0 on the canonical pair
The observed-design list is a MOVE-TO-BACK vector capped at 20 records per
DESIGN OWNER, and that is why lane CV's 55 odes leaves looked unrelated: a turn's
output is a permutation of its input, not an append.

  rich turn   ad-turn27 + BR's deep tcb vs bp-pinB-turn28   1092 -> 1058
              36 closed / 2 regressed vs CV's run; 116 / 8 vs the 1166 baseline
  canonical   turn2-state + CB's tcb vs turn3-state           62 -> 61
              1 closed / 0 regressed, set-differenced by leaf PATH

All five committed predictions held; P5 held in direction and was wrong in
magnitude (9 leaves, not 4 -- I predicted spurious records, not leaves).

CV's "no upstream dependency" is right for the design half and wrong for the
other 24 leaves of the 79, two separate ways, and both are named rather than
attempted: the otch/owep arms need three undecoded design->tech/weapon set
builders (the wire's own DOpts list covers 13 of the 18 tech names the turn
moves), and the design half is itself capped by SHIP CONSTRUCTION -- two designs
get their first ships that turn. Fed the true post-turn ship list the same code
leaves 1 leaf of 55 instead of 21, so the mechanism is not the residual.

Control, run: with the NPC-owner guard compiled out the canonical pair goes
61 -> 70 and the rich turn stays at 1058. The guard is load-bearing on one pair
and inert on the other, which is why both were run.

Coverage, stated as loudly as the divergence: 8 players swept, TWO produce any
record; the canonical pair offers 16 (player, ship) pairs of which the guard
drops 15, leaving one record call on one design. No foreign observation moves on
either pair, so the visibility gate -- a two-bit-per-player word at Ship+0x54
that is not on the wire, stood in for with ownership -- is entirely unexercised
and is labelled a hypothesis.

ghidra/addresses.d/dt.json: 10 entries (the sweep 0x007c2350, the recorder
0x007be340, RecordObservedWeapon, the vector helpers, sizeof(ObservedDesign) =
0x10, the vftable, and ServerPlayer +0x254 / +0xfb). gen_addresses.py merges it
clean at 1320 entries, no duplicate name or address.
2026-09-09 10:18:52 -04:00

36 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

5.1 How the predictions fared

# prediction verdict
P1 rich turn odes: 55 → 21 HELD exactly. 21 leaves remain, and they are the 21 the simulation named, value for value.
P2 otch + owep: 24 leaves untouched HELD. All 24 are still there, unchanged.
P3 rich turn 1092 → 1058 HELD exactly.
P4 canonical pair 1 closed / 0 regressed HELD exactly. 62 → 61, and the leaf that goes is the one named.
P5 the NPC guard is worth a regression on the canonical pair and nothing on the rich turn HELD in direction, WRONG IN MAGNITUDE. Measured with the guard compiled out: canonical 61 → 70, i.e. 9 leaves, not the 4 predicted — I predicted the number of spurious records, and each record past the end of the target's list is worth four leaves plus the array's count leaf. The rich turn is 1058 either way, exactly as predicted.

5.2 The rich turn — ad-turn27-two-raiders.sav + br2-turn27-deep.tcb → bp-pinB-turn28.sav

state_checksum --floats bits --mask none:

run leaves
input, untouched (do-nothing baseline) 1166
lane CV's run (engine at e7e2bd6, same replay, no T34) 1092
this run (e7e2bd6 + T34) 1058

Closed and regressed, never netted.

against closed regressed
lane CV's 1092-leaf run 36 2
the do-nothing 1166-leaf baseline 116 8

The two regressions are Player[32]/odes/.[4]/otnF and .[18]/otnF — positions whose odid also moves, i.e. positions that now hold a different record. CV's run left the list in its input order, where those two slots' otnF happened to match the target by coincidence; with the list correctly reordered they no longer do. They are reported as regressions because that is what the leaf comparison says, and the reader should know they are not four independent facts.

The eight against the do-nothing baseline are those two plus the six lane CV already named and owns: /Sim/sprjs/usp (lane DW's writer defect, one item, twelve corpus saves — noted and not touched) and the five ntdev leaves on Sys[160/400/416/432/448].

The phase's own accounting adds up to the leaf comparison exactly: 39 leaves written, of which 36 close, 2 regress, and 1 (odes/.[8]/otnF) moves from one wrong value to another.

What remains, by tag — the 79-leaf group is now 45.

tag before after closed
odes/.[]/odid 17 17 0
odes/.[]/otnF 17 4 13
odes/.[]/otnL 21 0 21
otch/.[]/otnL 17 17 0
otch/.[] (the count leaf and the appended record) 2 2 0
owep/.[]/otnL 5 5 0
total 79 45 34

Every otnL leaf in the design list is closed; what is left is the list's content and order (odid, and the four otnF that ride on it) plus the whole tech/weapon half.

5.3 The canonical pair — turn2-state.sav + cb-turn2to3.tcb → turn3-state.sav

run leaves
input, untouched 108
before (engine at e7e2bd6, CV's own build) 62
after (e7e2bd6 + T34) 61

closed: 1 — Player[32 "Fane Lao"]/odes/.[1]/otnL (2 → 3), lane PL's M3 leaf. regressed: 0. Set-differenced by leaf path, not by count.

5.4 Coverage, stated as loudly as the divergences (rule 15, rule 23)

The two runs together are thin, and in three separate ways.

Distinct players. The rich turn sweeps 8 players and 2 of them — 16 and 32, the two non-NPC empires — produce a single record between them. Six players contribute nothing at all. The canonical pair sweeps 8 and one produces anything.

Distinct designs. The rich turn's 199 (player, ship) pairs are all 199 ships in the game and they collapse to 29 (player, design) pairs — 28 designs under player 32 and one under player 16; 26 records are created, 173 moved and 26 evicted. The canonical pair offers 16 (player, ship) pairs, of which the NPC guard drops 15, leaving one record call on one design for one player — so on that pair every rule except the guard and the move-to-back is unexercised, and the phase's "1 closed / 0 regressed" there is a statement about one design.

Unexercised rules. Nothing in either pair exercises the cross-player case: no observation of a foreign design moves on either turn, so the visibility gate (§3.1), the opid != observer notify path and the multi-owner behaviour of the per-owner cap are all not covered. Two of the four early returns in §3.3 are assumed rather than evaluated. And the whole otch/owep arm is not implemented at all, so the phase is "partial" in the catalog and not "implemented".

Where the cap is exercised it is exercised hard: player 32 carries 28 designs in service against a cap of 20, so the rich turn evicts and re-creates 26 records inside the one sweep. That is the single state in the corpus that can tell a per-call cap from a per-sweep one, and it is one state.

5.5 The residual is ship construction, not this phase

Fed the target's post-turn fleet and ship list instead of the input's — the same algorithm, the same order, only the ships differ — the design list comes out 1 leaf short of the target instead of 21: Player[32]/odes/.[18]/otnF, where this model writes 28 (the record was evicted and re-created inside the sweep) and the target holds 27. So 20 of the 21 records are exact and the 21-leaf residual on the runnable path is the shadow of §4(b), not of the mechanism.

That last leaf is not explained, and it is worth naming rather than rounding off: it means the original's eviction sequence differs from this one by exactly one eviction. The two candidates are (i) a runtime fleet/ship order that is not the save's serialisation order, and (ii) the earlier RecordObservedDesign call sites — 0x007c1370, 0x007c1390 and 0x007c1c80, reached from 0x00890d50, 0x00875030 and 0x007d5af0 — firing during the turn, before the tail sweep, and changing the list the sweep starts from. Either would leave the final order untouched (the sweep re-observes everything) while changing which records get evicted on the way, which is exactly the signature. Deciding between them is an entry probe on 0x007be340, not more reading.

5.6 The build gate

  • fresh worktree of sots-engine at e7e2bd6, own scratch directory, build-host and build-shim rm -rf'd before configuring (rules 21 and 24). No other lane's tree or build directory was touched, and the shared ~/sots-engine working tree — which is carrying lane DW's in-flight src/mars/stream/shapes.h edits — was read for git worktree add and nothing else.
  • host build 255/255 clean (253 plus this lane's two new objects).
  • host ctest with SOTS_SAVES_DIR at the corpus: 57/59. The two failures are mars_stream_save and app_turn, both pre-existing and lane DW's: app_test_turn reports 43 save(s) driven, 12 failure(s), all of them write_save(doc.game) == doc.inflated — CV's §4.1 usp item, twelve of forty-three saves. The denominator is 59, so this is a real-failure signature and not a broken configure (rule 24). No new failure.
  • tools/clean_room_check.sh OK; tools/check_shim_configs.py OK (27 hooks).
  • shim cross-build green on the host — cmake -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-mingw-i686.cmake configures and builds to build-shim/binkw32.dll and sots_turn.exe. Rule 13's requirement ("a lane that cannot cross-build its own shim TU must say so") is met: this lane can, and did.
  • tools/gen_addresses.py regenerates cleanly with ghidra/addresses.d/dt.json merged — 1320 entries, no duplicate name and no duplicate address (rule 14).
  • tools/gate.sh itself was NOT run, and that is a deliberate choice a reader should be able to overrule. It rsync --deletes $ENGINE to spicy:/tmp/eng-gate and then into CT111's single shared $REMOTE_TREE, so two lanes gating at once silently mix trees — rule 24's exact failure shape, and rule 21's hazard one level up. Every check it runs was run here instead, on this lane's own tree: clean-room, shim-configs, host build, host ctest against the corpus, and the shim cross-build. The integrator should run the real gate before merging.

5.7 A control, so this is not a green verdict over nothing (rule 1)

The NPC guard was compiled out and both pairs re-run. Canonical 61 → 70; rich turn 1058 → 1058. So the guard is load-bearing on one pair and inert on the other — which is the positive control that the canonical pair is testing something the rich turn cannot, and the reason both are reported rather than the better one. It also means a lane that had run only the rich turn would have shipped the guard-less version and never known.


6. Reproducing every number here

cd ~/sots-re

# 1. the mechanism, straight off the binary (no Ghidra server needed)
uv run python3 tools/x86disp.py dis 0x007c2350 230     # T34, the sweep
uv run python3 tools/x86disp.py dis 0x007be340 900     # RecordObservedDesign, append path
uv run python3 tools/x86disp.py dis 0x007be600 620     # ... move-to-back and the 20-per-owner trim
uv run python3 tools/x86disp.py query 0x254 --lea      # every lea on ServerPlayer::odes

# 2. build (fresh worktree, fresh build dirs -- rules 21 and 24)
git -C ~/sots-engine worktree add <scratch>/eng e7e2bd6 --detach
cd <scratch>/eng && rm -rf build-host build-shim
cmake --preset host && cmake --build --preset host -j 8          # 255/255
SOTS_SAVES_DIR=~/sots-re/verify/results/saves ctest --preset host  # 57/59, both failures DW's
cmake -S . -B build-shim -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-mingw-i686.cmake \
      -DCMAKE_BUILD_TYPE=Release && cmake --build build-shim -j8   # green

# 3. the two pairs
T=<scratch>/eng/build-host/src/app/sots_turn
S=~/sots-re/verify/results/saves
$T $S/ad-turn27-two-raiders.sav --data <data-root> \
   --turn-commands verify/results/turncommands/br2-turn27-deep.tcb --out /tmp/dt-r1.sav
$T $S/turn2-state.sav --data <data-root> \
   --turn-commands verify/results/turncommands/cb-turn2to3.tcb --out /tmp/dt-canon.sav

uv run python3 verify/state-checksum/state_checksum.py /tmp/dt-r1.sav \
   $S/bp-pinB-turn28.sav --floats bits --mask none --limit 100000    # DIVERGED: 1058
uv run python3 verify/state-checksum/state_checksum.py /tmp/dt-canon.sav \
   $S/turn3-state.sav --floats bits --mask none --limit 100000       # DIVERGED: 61

# 4. closed and regressed, by leaf PATH -- never by count
cut -d: -f1 verify/results/standalone/cv/cv-replay-vs-bp-pinB.txt | grep '^  /' | sort > /tmp/cv.p
cut -d: -f1 verify/results/standalone/dt/dt-replay-vs-bp-pinB.txt | grep '^  /' | sort > /tmp/dt.p
comm -23 /tmp/cv.p /tmp/dt.p | wc -l     # closed:    36
comm -13 /tmp/cv.p /tmp/dt.p             # regressed:  2

# 5. the phase's own report
$T $S/ad-turn27-two-raiders.sav --data <data-root> \
   --turn-commands verify/results/turncommands/br2-turn27-deep.tcb --phases --verbose --out /dev/null

The data root is an extracted sots.gob tree (Weapons/, Species/*/sections/, TechTree/MasterTechList.tech, Locale/EN/Strings.csv) — the same one lane CV built. T34 does not read it; it is passed so the run reproduces CV's 1092 baseline, which does.

Artefacts: verify/results/standalone/dt/{dt-replay-vs-bp-pinB,dt-canonical-before,dt-canonical-after,dt-observed-leaves-remaining}.txt, dt-replay-run.log; ghidra/addresses.d/dt.json. Engine: branch wip/dt off e7e2bd6, one commit.


7. What this lane did NOT establish

  1. The gate. Ship+0x54 is not on the wire and no probe was run. Ownership is a stand-in that no corpus save can separate from the truth (§3.1).
  2. The otch and owep arms. 24 of the 79 leaves. Three undecoded set builders (§4a).
  3. 0x0080baf0 and the 0x400 design flag — two early returns assumed false, never read (§3.3).
  4. The last otnF. Even given the true post-turn ship list this model's eviction sequence differs from the original's by one eviction (§5.5). Two named candidates, one probe.
  5. ServerPlayer+0xfb is NPC by adjacency, not by a direct read of its writer. The corpus agrees with it and a differential control makes it worth 9 leaves, but the identification is inference from lane T's +0xfc = RebAI plus the wire's field order.
  6. Nothing about a turn other than these two, a build other than e7e2bd6, or a target other than 724528ff… / turn3-state.sav.
  7. tools/gate.sh was not run (§5.6). Its host-side checks were, individually; its CT111 legs were not.

Proposed board rows

New row:

| T34 RecordObservedDesigns IMPLEMENTED: THE OBSERVED-DESIGN LIST IS A MOVE-TO-BACK VECTOR CAPPED AT 20 PER DESIGN OWNER, AND THAT IS WHY IT LOOKED LIKE 55 UNRELATED LEAVES | engine | verified | high | 100% | 2026-09-09 | Lane DT, HOST ONLY (no VM; 140/141/145/146 untouched). First item of Track 2, taken from lane CV's ranked worklist (rank 4, "79 leaves, no upstream dependency"). **THE PHASE IS `0x007c2350`** (lane T's tail phase 34, at 0x007d98aa): for each player, over EVERY ship of EVERY fleet, gated on `PlyrIdx < 15` and on a TWO-BIT-PER-PLAYER word at `Ship+0x54` that is **NOT SERIALISED**, call `RecordObservedDesign` `0x007be340` -- once per SHIP, not once per design. **THREE RULES IN THAT FUNCTION ARE INVISIBLE FROM ANY SAVE AND ALL THREE CHANGE THE ANSWER:** the dedup key is the **design id ALONE** (`cmp [eax+0x8],ecx`, stride **0x10** = `sizeof(ObservedDesign)`, verified three ways); a re-observation **ERASES the record and PUSHES A COPY ON THE BACK** (0x007be618: fields copied out, `vector::erase` 0x00795c40, `push_back` 0x00799d80) rather than updating in place, so the list ends in LAST-OBSERVATION order and `otnF` survives the move -- **which is why a turn's output is a PERMUTATION of its input and not an append**, and why 55 leaves looked unrelated; and the list is **CAPPED AT 20 RECORDS PER DESIGN OWNER**, counted from the most recent end, **applied after EVERY record call, not once per sweep** (0x007be67f, `cmp [ebp-0x10],0x14; jge erase`) -- so on an empire with 28 designs in service against a cap of 20 it EVICTS AND RE-CREATES 26 RECORDS INSIDE ONE SWEEP and each re-creation resets `otnF`. A FOURTH rule is a guard on the **DESIGN'S OWNER**, not on the observer: `owner->+0xfb && !owner->+0xfc` = NPC-and-not-rebel-AI, and every observation record in every corpus save agrees (the four NPC factions own 26 designs and appear in NOBODY's list). **MEASURED, CLOSED AND REGRESSED NEVER NETTED. Rich turn** (`ad-turn27` + BR's deep tcb vs `bp-pinB-turn28`): **1092 -> 1058**, i.e. **36 CLOSED / 2 REGRESSED** against CV's run and **116 / 8** against the 1166 do-nothing baseline. The 2 are two `otnF` words at positions whose `odid` also moves -- positions that now hold a DIFFERENT record, where CV's un-reordered list matched by coincidence. The 79-leaf group is now **45**: **every one of the 21 `odes/otnL` leaves is closed** and 13 of the 17 `otnF`, while all 17 `odid` and the whole 24-leaf tech/weapon half remain. **Canonical pair** (`turn2-state` + CB's tcb vs `turn3-state`): **62 -> 61, 1 CLOSED / 0 REGRESSED**, set-differenced by leaf PATH. **CV'S "NO UPSTREAM DEPENDENCY" IS RIGHT FOR THE DESIGN HALF AND WRONG FOR THE OTHER 24 LEAVES, TWO SEPARATE WAYS.** (a) The `otch`/`owep` arms need three undecoded `std::set` builders (`0x00862c90`, `0x008629b0` -> `RecordObservedTech`; `0x008626a0` -> `RecordObservedWeapon` `0x007be1b0`); the obvious wire candidate, the per-section `DOpts` option-tech list, covers only **13 of the 18 tech names the turn moves** and misses `WEP_GrnLas`/`WEP_PlsmCan`/`WEP_Dsrptr`/`CCC_BtlCmp` and the one newly APPENDED record `BIO_TerBac` -- so it is ONE of the three sources, not all of them, and the set ORDERING decides the append order. (b) The design half is itself capped by **SHIP CONSTRUCTION**: two designs (1826 "Egg Thief Mk 3", 1522 "Bravestar Mk 2") get their FIRST ships during that turn, no phase here builds a ship, so those two observations cannot happen and neither can the two evictions they would cause -- **that is the whole 21-leaf residual**. **PROOF THAT THE MECHANISM IS NOT THE RESIDUAL: fed the TARGET's post-turn ship list, the same code leaves 1 leaf of 55, not 21.** That last leaf is unexplained and named: the original's eviction sequence differs by exactly one eviction, and the two candidates (a runtime fleet order that is not the save's, or the three OTHER `RecordObservedDesign` call sites firing mid-turn) are separated by an entry probe on 0x007be340, not by more reading. **CONTROL, RUN (rule 1): with the NPC guard compiled out the canonical pair goes 61 -> 70 and the rich turn stays at 1058** -- the guard is load-bearing on one pair and inert on the other, which is why both were run and why a lane that ran only the rich turn would have shipped the wrong rule. **COVERAGE, STATED AS LOUDLY AS THE DIVERGENCE (rules 15/23): 8 players swept, TWO produce any record at all; the canonical pair offers 16 (player, ship) pairs of which the NPC guard drops 15, leaving ONE record call on ONE design.** No observation of a FOREIGN design moves on either pair, so the visibility gate, the cross-player notify path and the multi-owner cap are **entirely unexercised** -- the ownership stand-in for the gate is a HYPOTHESIS whose falsifier is one save in which a foreign record moves. Two further early returns (`0x0080baf0`, the `0x400` design flag) are assumed false, never read. `ServerPlayer+0xfb = NPC` is by ADJACENCY to lane T's `+0xfc = RebAI` plus the wire's field order, corroborated by the corpus and by the 9-leaf control, not by reading its writer. GATE: host build 255/255, host ctest **57/59** with the SAME two pre-existing failures (`app_test_turn`: 43 saves driven, 12 write_save failures = lane DW's `usp` item; denominator intact, rule 24), clean-room OK, shim-configs OK, **shim cross-build green on the host** (rule 13), `gen_addresses.py` clean with `dt.json` merged (1320 entries, rule 14). `tools/gate.sh` itself was NOT run: it `rsync --delete`s into CT111's single shared tree and two concurrent lanes would mix trees -- every check it runs was run here on this lane's own worktree instead, and the integrator should run the real gate before merging. Artefacts: `findings/subsystems/t34-observed-designs.md`, `verify/results/standalone/dt/*`, `ghidra/addresses.d/dt.json`; engine branch `wip/dt` off `e7e2bd6` |

Edits to existing rows:

  • campaign/backlog.md §3, the new engine item CV proposed (T34, 79 leaves) — replace with: 2026-09-09, lane DT, DONE for the design half. T34 is implemented and measured: rich turn 1092 -> 1058 (36 closed / 2 regressed), canonical pair 62 -> 61 (1 / 0). The group is 79 -> 45. **The remaining 45 are TWO named dependencies, not T34**: 24 leaves need the three undecoded design->tech/weapon set builders, and 21 need SHIP CONSTRUCTION (two designs get their first ships that turn). Fed the true post-turn ship list the phase leaves 1 leaf of 55.
  • campaign/backlog.md §2, the Rung B bullet — append: The rich-turn baseline is now **1058**, not 1092 (lane DT, T34).
  • The row for rung-b-rich-turn.md (lane CV) — append a correction: rank 4's "no upstream dependency" holds for the odes half only. Lane DT closed 34 of the 79 and named the other two blockers; the 539-leaf morale ring is now the largest UNSTARTED item and the ship-construction item (rank 8=, "no phase in this engine builds a ship") has grown a second consumer.
  • findings/subsystems/players-residual.md §M3 — append: CONFIRMED AND EXTENDED (lane DT). M3 is indeed not an intel pass, and the mechanism is now read: RecordObservedDesign0x007be340 is called once per SHIP from tail phase 34, so "self-registration triggered by design creation" is the *appearance* of a rule that is really "every ship you can see, every turn" -- on the canonical pair the player owns ships of exactly one design, which is why one record moved.odesre-stamping "on build" andotch"on creation" is the same appearance:odesis re-stamped every turn by the sweep, andotch only looks different because a tech already in the list is refreshed without being reordered.
  • findings/subsystems/observedtech-append.md — append a cross-reference: the sibling record Game::ObservedDesignis **0x10**, not 0x2c: same shape minus the embedded name string. Its append site isRecordObservedDesign0x007be340, which is also the only caller ofRecordObservedTech0x007ba1a0 from the tail (0x007be4e1, 0x007be535) and ofRecordObservedWeapon0x007be1b0 (0x007be591). Lane X's fourth caller list is now attributed: 0x007be228 is insideRecordObservedWeapon 0x007be1b0 itself -- recording a weapon also records its tech.
  • A new mars/stream note — none. This lane did not touch the wire codec, and CV's usp defect is lane DW's.
  • Row 62 (guest ledger) — VM140/141/145/146 unchanged by this lane; it took no guest.