sots-re/findings/subsystems/treaty-turn-stamp.md

22 KiB
Raw Blame History

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<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 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<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.