22 KiB
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'sDiplomacyStatsentry forBhas the matchinglast*field stamped with the current turn (Frame), the entry being created on demand. Concretely, per playerAin player-vector order, per playerBin 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 == 1Predicted 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.
- The stamp is not
Frame.strategic-turn-internals.md§5.2 says it isModCount. 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 becauseModCountandFramediffer by an order of magnitude on both pairs. - The relation mapping is inverted. If
1 = ally, 2 = NAP, 3 = ceasefire(also §5.2) rather than3 = ally, 2 = NAP, 1 = ceasefire, the value lands inlastcfwhere the oracle haslastally: pair 1 closes ~12 of 26 and regresses none (the created entries are still created, with the rightother, but two of their thirteen counters are wrong), and pair 2 closes 0 and regresses 14. - The shift is by vector position, not
plyrIdx. Undetectable in this corpus — every save hasplyrIdx == vector position. The symptom would appear only on a save where a player was removed. Stated as a limitation, not tested. - 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.
- The phase is somewhere else in the turn and sees a different
Frame. IfFramewere bumped after this ran, the stamp would beFrame - 1— 1 and 2 instead of 2 and 3 — i.e. 0 closed, 26 wrong on pair 1. - 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
Statusis set to 4 byFUN_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 wherebacklog.mditem 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 withSpecies != 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<uint>* allianceBroken, vector<uint>* napBroken, vector<uint>* cfBroken), ret 0xc, 700 B. this is the S frame — [edi+0x54]/[edi+0x58] is the
vector<ServerPlayer*> 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.<slot> = (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
// 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
PlyrIdxfield (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 positionon all eleven — so this is an instruction-stream reading with no behavioural evidence behind it. ALis consulted unconditionally. It is not gated onALid != -1the 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<uint32> 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.