# The treaty-turn stamp, and where `Player.Status` really comes from Lane T2, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. **Method.** Every claim about control flow below is read from the instruction stream with `objdump -b binary -m i386 -M intel` over the raw image (PE section table mapping, call targets resolved against `dumps/functions.json`, callers found with a whole-image byte scan for `E8/E9 rel32`). Nothing here comes from the decompiler. Where a claim is an inference from save bytes rather than from instructions it says so in the sentence that makes it. This lane was briefed on `OnAllCombatDone_Tail` and went looking for the tail phase with the best leaves-closed-per-effort. **The answer was not in the tail.** §5 says why, and lists what the tail's own phases are actually blocked on. §1–§3 are what the search found instead. --- ## 0. The prediction, written before the run Two models, both derived statically, both with a falsification section. Recorded here **before** anything was built or measured, per rule 2. ### 0.1 H02 `StampTreatyTurns` > Every turn, for every ordered pair of players `(A, B)` with a live treaty, `A`'s `DiplomacyStats` entry > for `B` has the matching `last*` field stamped with the **current turn** (`Frame`), the entry being > created on demand. Concretely, per player `A` in player-vector order, per player `B` in player-vector > order, `B != A`: > > ``` > rel = 3 if A.plyrIdx == B.plyrIdx else > 3 if (A.AL & (1 << B.plyrIdx)) else > 2 if (A.NA & (1 << B.plyrIdx)) else > 1 if (A.CF & (1 << B.plyrIdx)) else 0 > if rel == 0: continue > e = the first entry of A.dipstats with e.other == B.PlayerID, > else a NEW entry appended at the end with > {other = B.PlayerID, lastnap = lastally = lastcf = -1, every other field 0} > e.lastally = Frame if rel == 3 > e.lastnap = Frame if rel == 2 > e.lastcf = Frame if rel == 1 > ``` > > **Predicted leaves closed: 26 on the reference pair (turn1 -> turn2), 14 on pair 2 (turn2 -> turn3). > Predicted regressed: 0. Predicted RNG words: 0.** The 26 are six players gaining a `dipstats` vector (2 entries for each of the two `Singularity` players, 4 rows each — the container leaf plus its entries — and 4 entries each for the four species-4 players, 5 rows each). The 14 on pair 2 are `lastnap`/`lastally` moving 2 -> 3 on the fourteen entries that already exist. **How this could be wrong, and the symptom of each way.** 1. *The stamp is not `Frame`.* `strategic-turn-internals.md` §5.2 says it is `ModCount`. If §5.2 is right and I am wrong, the stamped value is 12 (pair 1) or 24 (pair 2), not 2 or 3 — **14 regressed leaves on pair 2 and 26 wrong values on pair 1**, and the two are trivially distinguishable because `ModCount` and `Frame` differ by an order of magnitude on both pairs. 2. *The relation mapping is inverted.* If `1 = ally, 2 = NAP, 3 = ceasefire` (also §5.2) rather than `3 = ally, 2 = NAP, 1 = ceasefire`, the value lands in `lastcf` where the oracle has `lastally`: **pair 1 closes ~12 of 26 and regresses none** (the created entries are still created, with the right `other`, but two of their thirteen counters are wrong), and pair 2 **closes 0 and regresses 14**. 3. *The shift is by vector position, not `plyrIdx`.* Undetectable in this corpus — every save has `plyrIdx == vector position`. The symptom would appear only on a save where a player was removed. Stated as a limitation, not tested. 4. *Entries are ordered by something other than the stamping order.* Then the created vectors have the right contents in the wrong order and **pair 1 closes the container leaf and regresses the entry leaves**. 5. *The phase is somewhere else in the turn and sees a different `Frame`.* If `Frame` were bumped after this ran, the stamp would be `Frame - 1` — 1 and 2 instead of 2 and 3 — i.e. **0 closed, 26 wrong on pair 1**. 6. *Some other writer also touches these fields on a normal turn.* Then the closed count is short of 26/14 by whatever that writer moves, and the residual is named rather than netted. ### 0.2 `Player.Status` — evaluated, not written > `Status` is set to **4** by `FUN_00821a40` (§3), which runs on the **End Turn submission** path, before > the turn is processed — not between tail phase 31 and the autosave, which is where `backlog.md` item 6 > looks for it. Which players get it is a property of who submits an End Turn, and the only two saves in > the corpus where the field is non-zero agree with `Species != 4`. > > **Predicted leaves: 4 on the reference pair, 0 on pair 2 — and NOT written**, because the gate is a > two-observation inference and the campaign's own rule 20 says a constant fitted to two observations is not > a constant. --- ## 1. `StampTreatyTurns` — 0x007898c0, and it is not in the tail **One caller, whole-image byte scan for `E8/E9 rel32`:** `ApplyTurnCommands` 0x007b18b0 at **0x007b2461** (+0xbb1), which is `strategic-turn-internals.md` §5.2's "right after the alliance diff". So the phase runs **before** either turn driver, in the host's command-application step — the same bracket `phase_catalog.h` already calls `Driver::Host`. **Rule 17, a fifth time: that call site is 0x1c bytes past Ghidra's reported end of its own caller.** Ghidra sizes `ApplyTurnCommands` at 2965 bytes, ending 0x007b2445; the real body runs to `ret 0x10` at **0x007b248d**, 0x48 bytes further, and the call to 0x007898c0 sits inside that tail. A sweep clipped at `fva + size` would have found **no caller at all** for this function and concluded it was dead. The surrounding instructions corroborate the identification independently: the call is the last real statement of `ApplyTurnCommands`, its three stack arguments are three locals at `[ebp-0x344]`, `[ebp-0x34c]`, `[ebp-0x348]`, and it is immediately followed by `FUN_007acb50(S, <0x00a24f9c>)` — the function `strategic-turn-internals.md` §5.2 already names as the one that "dumps the Alliances / Non Aggression / Cease fires tables to the log". `void __thiscall (StrategyServer* S, vector* allianceBroken, vector* napBroken, vector* cfBroken)`, `ret 0xc`, 700 B. `this` is the **`S` frame** — `[edi+0x54]`/`[edi+0x58]` is the `vector Players` and `[edi+0xc]` is `Frame`, both `S`-frame offsets lane T and lane Z established. ### 1.1 Pass A, the stamp — 0x00789920..0x007899c4, read as instructions ``` 007898fc mov eax,[edi+0x58] ; Players._Mylast 007899 02 mov ecx,[edi+0x54] ; ... _Myfirst sar eax,2 ; nPlayers loop i: 007d9920 mov ebx,[ecx+esi*4] ; A = Players[i] loop j: 007d9930 mov esi,[ecx+edx*4] ; B = Players[j] 007d9933 cmp ebx,esi 007d9935 je 0x78999a ; A == B -> skip 007d9937 push esi ; mov ecx,ebx 007d993a call 0x80e050 ; rel = A->GetRelation(B) 007d993f dec eax ; je 0x78995a ; rel == 1 -> slot 8 007d9942 dec eax ; je 0x789951 ; rel == 2 -> slot 0 007d9945 dec eax ; jne 0x78999a ; rel != 3 -> skip 007d9948 mov [ebp-0x5c],4 ; rel == 3 -> slot 4 007d9961 lea ecx,[ebp-0x58] ; call 0x80e7b0 ; DiplomacyStats ctor on the stack 007d9969 push esi ; lea eax,[ebp-0x58] ; push eax mov ecx,ebx ; call 0x8180e0 ; A->GetDipStat(&local, B) 007d997c mov eax,[edi+0xc] ; Frame 007d9986 mov WORD PTR [ebp+ecx*2-0x50],ax ; local. = (short)Frame 007d998e push esi ; lea edx,[ebp-0x58] ; push edx mov ecx,ebx ; call 0x863950 ; A->SetDipStat(&local, B) ``` The stamped field is at `local + 8 + slot*2`, because the ctor's `this` is `[ebp-0x58]` and the store's base is `[ebp-0x50]`. Against `objects/layouts.md`'s `Game::DiplomacyStats` (`+8 lastnap`, `+0x10 lastally`, `+0x18 lastcf`), that gives: | `GetRelation` | slot | field | |---|---|---| | 1 | 8 | `lastcf` | | 2 | 0 | `lastnap` | | 3 | 4 | `lastally` | ### 1.2 `GetRelation` 0x0080e050 — 33 bytes, fully resolved ```c // 0x0080e050: thiscall, ret 4 -> tail-calls the cdecl 0x006d2050 with // (this->PlyrIdx(+0x28), &this->Alliances(+0x168), other->PlyrIdx(+0x28)) int Relation(int myIdx, PlayerAlliances* a, int otherIdx) { // 0x006d2050, 58 bytes if (myIdx == otherIdx) return 3; uint32_t bit = 1u << otherIdx; // shl edx,cl -- x86 masks the count to 5 bits if (a->AL(+0x04) & bit) return 3; if (a->NA(+0x08) & bit) return 2; return (a->CF(+0x0c) & bit) ? 1 : 0; } ``` **So the relation codes are `3 = ally (and self)`, `2 = NAP`, `1 = ceasefire`, `0 = war`.** `strategic-turn-internals.md` §5.2 has 1 and 3 the wrong way round; it labelled itself MEDIUM confidence on exactly this mapping, and it was the half that was wrong. The corrected mapping is independently confirmed by `combat-done-tail.md` §2A, which reads `Node::ResupplyAlliedFleets` as gated on `GetRelation == 3 (allied/self)` and `UpdateDiplomacyStatsFromCombat` as gated on `GetRelation < 1` (war). Two further notes on this function, both places a reimplementation goes quietly wrong: * The shift count is the **`PlyrIdx` field** (`ServerPlayer+0x28`), *not* the player's position in the player vector. This is the exact opposite of the alliance/shared-vision mask (`app/alliance.h` §1), which uses the vector position and never loads the index field. Both are in the same subsystem and they disagree. No save in the corpus separates them — `plyrIdx == vector position` on all eleven — so this is an instruction-stream reading with no behavioural evidence behind it. * `AL` is consulted **unconditionally**. It is *not* gated on `ALid != -1` the way the shared-vision mask's alliance term is. ### 1.3 The three helpers **`DiplomacyStats::DiplomacyStats()` 0x0080e7b0** (55 B): vptr `0x00a21430`, everything zeroed, then `lastcf(+0x18) = lastnap(+8) = lastally(+0x10) = -1`. So a fresh entry is `{other 0, lastnap -1, lastnapbty 0, bknnap 0, btynap 0, lastally -1, lastallybty 0, bknally 0, btyally 0, lastcf -1, lastcfbty 0, bkncf 0, btycf 0, deadhome 0}`. **`ServerPlayer::GetDipStat(out, other)` 0x008180e0** (294 B, `ret 8`): if `out == 0` return; re-initialise `*out` to those defaults; `out->other = other->+0x4` (the handle id — the wire's `PlayerID`); if `other == 0` return; then a **linear, first-match** scan of the `0x24`-stride vector at `ServerPlayer+0x230` for `entry.other == GetId(other)` and, on a hit, copy the entry's thirteen `int16` fields into `out`. **`ServerPlayer::SetDipStat(src, other)` 0x00863950** (328 B, `ret 8`): if `src == 0 || other == 0` return; the same linear first-match scan; **on a miss, default-construct and `push_back`**, then set `back().other = other->+0x4`; finally copy `src`'s thirteen `int16` fields into the entry and re-write `other`. So a new entry is appended **at the end**, and the append order is the order in which pairs are first stamped — i.e. the `(i, j)` double-loop order, which is player-vector order. The `0x24` stride is read twice, once in each helper, from `0x38e38e39 / sar 3` over `[p+0x234] - [p+0x230]`. That is `objects/layouts.md`'s `dipstats` vector at `ServerPlayer+0x230` and `sizeof(Game::DiplomacyStats) == 0x24`, agreeing with the serializer's own enumeration. ### 1.4 The value is `Frame`, not `ModCount` `mov eax,[edi+0xc]` with `edi = S`, and lane Z's `StrategyServer::Write` tags `S+0x8` as `ModCount` and `S+0xc` as `Frame`. The save bytes settle it independently and are worth stating because they are the cheaper check: across `turn1/2/3-state.sav` `ModCount` runs 0 -> 12 -> 24 while `Frame` runs 1 -> 2 -> 3, and every stamped `last*` in the corpus is 2 or 3. **`strategic-turn-internals.md` §5.2's "set to the current `ModCount`" is wrong**; it is the turn. `Frame` is already the *new* turn when this runs: `StrategyServer::BeginProcessTurn` 0x007d990a does `inc [esi+0xc]` and the host order is `BeginProcessTurn -> ApplyTurnCommands -> ... -> ProcessTurn`. ### 1.5 Pass B is the betrayal counter, and it needs a command stream 0x007899ca onward is a second double loop, gated on `GetRelation(A,B) <= 0` (`jg` skips), that indexes the three `vector` arguments by the inner loop index — with a size check against `nPlayers` that substitutes a zero local when the vector is the wrong length. Those three vectors are the alliance-broken / NAP-broken / CF-broken masks the alliance diff in `ApplyTurnCommands` builds from the turn's `SNMUpdate` commands. **With no commands they are empty, the size check fails, the zero local is used, and pass B is a no-op.** It is not modelled: the standalone has no command stream, and a betrayal cannot occur without one. --- ## 2. What the save bytes say, and they say the same thing `dipstats` across the reference family, every non-zero field shown: | save | `Frame` | `ModCount` | P496 | P512 | P528 / P544 / P560 / P576 | |---|---|---|---|---|---| | turn1-state | 1 | 0 | — | — | — (all six vectors empty) | | turn2-state | 2 | 12 | `{other 512, lastally 2}` | `{other 496, lastally 2}` | three entries each, `lastnap 2`, `other` = the other three in vector order | | turn3-state | 3 | 24 | `lastally 3` | `lastally 3` | `lastnap 3` | Every created entry carries `lastnap = -1, lastcf = -1` beside the stamped `lastally`, or `lastally = -1, lastcf = -1` beside the stamped `lastnap` — which is the 0x0080e7b0 constructor, byte for byte. The two `Singularity` players are allied to each other (relation 3); the four species-4 players are mutually non-aggressive (relation 2); the two real empires have no treaty with anyone and their vectors stay empty on all three turns. Nothing else in the vector ever moves in this corpus, so `lastnapbty`, `bkn*`, `bty*` and `deadhome` are **unexercised** and are hypotheses, per rule 6. --- ## 3. `Player.Status` — the writer, and a correction to the roadmap `Status` is `ServerPlayer+0x164` (`objects/layouts.json` grades it `off_abs 356`). A whole-image scan for `C7 8x 64 01 00 00 imm32` — a `mov dword [reg+0x164], imm` — finds every immediate store to that displacement in the image. Three matter: | VA | value | containing function | |---|---|---| | 0x007dcc8a | **1** | `ProcessTurn` +0x5ca, inside a `0x44`-stride loop (the encounter-member stride), gated on two bytes at `member+0xf9`/`+0xfa` | | 0x00821a6b | **4** | `FUN_00821a40`, 60 B | | 0x007ddd41 | **0** | `ResumePlaying` 0x007ddc90 +0xb1 | `FUN_00821a40(this, playerId)`, `ret 4`, in full: ``` p = HandleMap::Resolve(this + 0x80, playerId) ; sub ecx,0xffffff80 is add ecx,0x80 if (!p) { Log(2, <0x00a2fb30>, playerId); return false; } p->Status(+0x164) = 4 return true ``` Its three callers, from the same byte scan: `EndTurn` 0x00783be0 (+0x70), `EndTurnForced` 0x00783d30 (+0x7b), `OnPlayerEndTurn` 0x007d9af0 (+0x35). In `EndTurn` the id passed is `this->+0x148`, the client's own player id, and the call is immediately followed by `[this+0x15c] = 1` and `[this+0x4c8] = 1` and a `RaiseEvent(0x21)` — this is the **End Turn submission** path, which runs before the turn is processed. **This corrects `backlog.md` item 6.** The roadmap has `Player.Status` blocked on "the writer between tail 31 and the autosave — watchpoint". There is no such writer. `Status = 4` is set when a player submits an End Turn; `ProcessTurn`'s encounter loop can set 1; `ResumePlaying` sets 0 on load, which is exactly the "`Status` resetting 4 -> 0 on load" that `determinism-oracle.md` recorded. Item 6 does not need a VM watchpoint; it needs one predicate — *which players submit an End Turn* — and that is a question about the client/host controller layer, not about the turn. What the corpus can say about that predicate, and it is not much: of the eleven saves, **only `turn2-state.sav` and `turn3-state.sav` carry a non-zero `Status` at all** (every other save was written through a load, and `ResumePlaying` had zeroed it). On those two, the four players with `Status = 4` are exactly the four with `Species != 4`, and the four with `Status = 0` are exactly the four species-4 monster/neutral players. That is 8 observations on 2 saves of a 1-bit predicate, on a field that no other save exercises. Rule 20 applies. **Evaluated, reported, not written.** The cheap way to settle it is not a watchpoint either: an entry probe on 0x00821a40 recording its `playerId` argument over one End Turn names the set directly, and a probe on `OnPlayerEndTurn` separates "the host ends the AI's turn" from "the AI player never ends a turn". --- ## 3A. The measurement `sots-engine` host phase **H02 `StampTreatyTurns`** (`src/app/treaty.{h,cpp}`), run through `tools/standalone_report.py`. `closed` and `regressed` are separate columns and are never netted. `main` moved twice while this lane ran (C3's output turn path, then B6's build queue), so the branch was merged onto `main` at **`2fd0852`** and everything below re-measured there — both columns on the same tree, the same host and the same tool. Rebuilding `main` before measuring it is not a formality: the checked-in `build-host` was stale and reported `closed 5` on the reference pair, which would have made this table look four times better than it is. | pair | baseline | main `2fd0852` | with H02 | closed | regressed | |---|---:|---:|---:|---:|---:| | `turn1-state -> turn2-state` (the reference End Turn) | 209 | 157 | **131** | 52 -> **78** | **0** | | `turn2-state -> turn3-state` (real End Turn) | 108 | 86 | **72** | 22 -> **36** | **0** | | `human-turn2-orders -> human-turn3-noderoute` | 375 | 311 | **299** | 64 -> **76** | **0** | | `zuul-turn15-orders -> zuul-turn16-noderoute` | 282 | 264 | **252** | 18 -> **30** | **0** | | `zuul-turn16-noderoute -> zuul-turn17-orders2` | 345 | 328 | **316** | 17 -> **29** | **0** | **+26 and +14 on the two reference pairs, exactly the predicted numbers, and +12 on each of three pairs the model was never fitted to** — a different game, species 5, turns 2, 15 and 16. Twelve is precisely the number of ordered treaty pairs each of those saves holds. Total **76 leaves closed, 0 regressed**, 0 RNG words. The five deltas are **identical against `0f1c007` and against `2fd0852`** — measured on both — so this phase is orthogonal to C3's and B6's work and neither of them is closing any of the same leaves. `zuul-turn17-orders2 -> zuul-turn23-fleet23` is a six-turn gap rather than a pair; it reports `closed 0, regressed 1` **both before and after**, so that one regression is not this phase's and is untouched by it. The measurement was taken on the WSL host with a WSL-built `sots_turn`, and the `before` column was taken with the same tool on the same host from a freshly rebuilt `main`. `verify/results/standalone/report.txt` and `status.json` are **deliberately not committed by this lane**: lane C3 published its own numbers into those two files minutes before this run, and regenerating them from a branch that does not carry C3's work would silently replace a better result with a worse one. They are the integrator's to regenerate after the merge. All eleven corpus saves run clean end to end. On ten of them the phase creates **zero** entries and only re-stamps, which is the check that matters: the entry set the rule derives is already the entry set the game wrote. Only `turn1-state.sav` creates any (14), and that is the save on which no turn has ever run. **What this did not cover, stated as loudly as the divergences.** The cease-fire arm has never executed — no save in the corpus contains a cease-fire — so `Relation::CeaseFire` and the `lastcf` store are held by a unit test and by the instruction stream, and by nothing else. The betrayal counters (`lastnapbty`, `bkn*`, `bty*`) and `deadhome` are likewise untouched by any save; pass B is not modelled at all. Nine of the eleven saves have exactly one relation kind (NAP) and eight players, so the corpus tests one shape hard rather than many shapes at all. And `plyrIdx == vector position` on every save, so the index-vs-position reading of §1.2 has no behavioural evidence behind it whatsoever. --- ## 4. Two names that should be in Ghidra | VA | proposed name | evidence | |---|---|---| | 0x007898c0 | `StrategyServer::StampTreatyTurns` | §1.1 | | 0x006d2050 | `PlayerAlliances::Relation` | §1.2 | | 0x0080e050 | `ServerPlayer::GetRelation` | §1.2 | | 0x0080e7b0 | `Game::DiplomacyStats::DiplomacyStats` | §1.3 | | 0x008180e0 | `ServerPlayer::GetDipStat` | §1.3 | | 0x00863950 | `ServerPlayer::SetDipStat` | §1.3 | | 0x00821a40 | `StrategyServer::MarkPlayerTurnEnded` | §3 | --- ## 5. Why the answer was not in the tail The lane was briefed on the post-combat tail and ranked its phases by leaves-closed-per-effort against the standalone's current 158-leaf residual on the reference pair. Every tail phase that moves a leaf in this corpus is blocked on something another lane owns, or on a blind spot: | tail phase | leaves it would move (pair 1 / pair 2) | why not now | |---|---|---| | T24 maintenance + research bonus + `ShipRecs` | 9 / 5 | `Maint` moves 0 -> 500 -> 1000 in lockstep with one ship completing per turn, and every `ShipRecs` leaf is a **new** ship's record. Blocked on ship construction, which is lane B6's | | T31 `UpdateBankruptcyLimits` | 4 / 4 | already `Blocked` in the phase table, on `ComputeOutput`'s turn path (lane C3) | | T36 `FinalizeTurnRecords` | 24 / 8 | already `Blocked`, on the budget and ship construction | | T02 first contact, T30 comms mask | 0 / 0 | `HasEnc`/`HasDiscCl` and `ServerPlayer+0x198` move no leaf in this corpus | | T05 `UpdateDiplomacyStatsFromCombat` | 0 / 0 | writes `deadhome` and `bty*`; **no battle occurs on either pair** — every encounter result seen has `res->+0x4 != 0` (lane Z). A path no save exercises is a hypothesis | | T11 node-line decay | ? | draws RNG; the corpus turn cost is measured at 0 words from this phase, so no line expires | | T08 / T20 / T26 script hooks, T23 / T33 trade vtables | 8 / 2 (`SvSctOb`) | the `SVScriptObject` encounter state (`EncObj[*]/Hives`, `did`, `didc`, `ini`, `CDiff`) is the tail's tier-4 blind spot: eleven virtual slots, none identified | The remaining large clusters — `TShn` (10/10) and `rcex` (6/6) — are lane E3's named residual with a watchpoint already specified, and `Events` (6/6) needs the whole turn's event inventory, not one phase. So the tail's own ranked list is, right now, correct and unactionable: it is waiting on C3, B6 and a vtable sweep. The two items above are what a lane holding no VM and no data-file dependency can close today.