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
15 KiB
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, andfindings/subsystems/rung-b-rich-turn.md§5 rank 4 — lane CV's ranked worklist namesT34 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 blockverify/results/turncommands/br2-turn27-deep.tcb, lane X/S'sobservedtech-append.md(the siblingObservedTechrecord and its 0x2c element), lane PL'splayers-residual.md§M3, lane T'scombat-done-tail.mdphase 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), twoCMakeLists.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.
- The engine's fleet/ship iteration order is not the save's order. Symptom: the surviving
odidsequence is a different permutation and the rich-turn residual is worse than 21. - 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.
sim.frameis not the post-increment turn at tail time. Symptom: everyotnLis off by one and the residual grows rather than shrinks.- The cap is not 20, or is not per design-owner. Symptom: the list length is wrong, which
is a
LENleaf 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
odidalone. 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, andotnFsurvives 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
otnFto 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 = 32records all keepotnL = 27, and player 32's oneopid = 16record keepsotnL = 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)