lane T2: the treaty-turn stamp, and Player.Status's real writer

findings/subsystems/treaty-turn-stamp.md. Briefed on the post-combat tail, ranked its
phases by leaves-closed-per-effort against the standalone's 158-leaf residual, and found
that every tail phase which moves a leaf in this corpus is blocked on another lane
(ship construction, the budget) or on the tail's vtable blind spot. Section 5 lists that
ranking with the numbers. The two items below are what a lane holding no VM could close.

1. StrategyServer_StampTreatyTurns 0x007898c0, read as instructions. The diplomacy
   ledger's per-turn stamp over every ordered pair of players holding a treaty, creating
   the entry on demand. Sole caller is ApplyTurnCommands, so it is a HOST step and not a
   tail phase. Three corrections to strategic-turn-internals.md 5.2, which is flagged in
   place: the relation codes are 3=ally / 2=NAP / 1=cease-fire and not the reverse (which
   combat-done-tail.md 2A already implied independently); the stamped value is Frame, not
   ModCount; and the bit is the PlyrIdx field, not the player's vector position -- the
   opposite convention from the shared-vision mask.

   Modelled in sots-engine as host phase H02. Predicted 26 leaves on the reference pair
   and 14 on pair 2 before building; measured exactly 26 and 14, 0 regressed, and +12 on
   each of three further pairs from a different game at turns 2, 15 and 16 that the model
   was never fitted to. 76 closed / 0 regressed over five pairs, 0 RNG words. The rule
   also reproduces the ledger of ten of the eleven corpus saves entry for entry.

2. Player.Status. backlog.md item 6 has it blocked on "the writer between tail 31 and the
   autosave -- watchpoint". There is no such writer. A whole-image scan for immediate
   stores to ServerPlayer+0x164 finds exactly three: ProcessTurn's encounter loop writes
   1, ResumePlaying writes 0 on load (the 4 -> 0 the determinism note recorded), and
   StrategyServer_MarkPlayerTurnEnded 0x00821a40 writes 4 from the three End Turn
   SUBMISSION paths, which run before the turn is processed. Item 6 needs one predicate,
   not a watchpoint -- and an entry probe on that function names the set directly.

   NOT written. The only two saves in the corpus with a non-zero Status agree with
   Species != 4 on all eight players, and that is eight observations on a 1-bit predicate
   that nine of the eleven saves do not exercise at all. Evaluated and reported.

Seven addresses in ghidra/addresses.d/lane-t2.json, validated to a scratch path against
the merged set (1,095 entries, no duplicate). verify/results/standalone/{report,status}
deliberately untouched: lane C3 published into them minutes before this run.
This commit is contained in:
alex 2026-09-08 14:52:44 -04:00
parent 3b99e255c7
commit 49fa5501ba
3 changed files with 423 additions and 0 deletions

View file

@ -513,6 +513,19 @@ words are copied in. Nothing in `StrategyServer::ProcessTurn` itself is diplomac
diplomacy upkeep" (0x0086b300 / 0x007adc80) is **trade** (§1.4).
### 5.2 `DiplomacyStats` (vector at `+0x230`, one entry per other player, 0x24 B)
> **CORRECTED by lane T2, 2026-09-08 — two of this paragraph's three facts.** The caller and the location are
> right; the relation codes and the stamped value are not. (a) `GetRelation` 0x0080e050 forwards to the cdecl
> 0x006d2050, which reads **`3 = ally` (and self), `2 = NAP`, `1 = cease-fire`, `0 = war`** — 1 and 3 the
> other way round from the line below, which labelled itself MEDIUM confidence on exactly this mapping.
> `combat-done-tail.md` §2A agrees independently (`ResupplyAlliedFleets` is gated on `GetRelation == 3`).
> (b) The stamped value is **`S+0xc`, which lane Z named `Frame` — the turn — not `ModCount`**: the
> instruction is `mov eax,[edi+0xc]` with `edi = S`, and across the corpus every stamp equals the save's own
> `Frame` on ten of eleven saves while `ModCount` runs an order of magnitude higher (0/12/24 on the
> reference family). Also: the entry is created on demand, a fresh one starts its three `last*` at **-1**,
> and the relation's bit is the **`PlyrIdx` field**, not the position in the player vector.
> See `findings/subsystems/treaty-turn-stamp.md`. Modelled in `sots-engine` as host phase H02.
Updated by 0x007898c0 right after the alliance diff (called from `ApplyTurnCommands`):
* for every pair with a current relation (0x0080e050: 1 ally, 2 NAP, 3 cease-fire) the matching `last*` int16
(`lastally` @+0x10, `lastnap` @+8, `lastcf` @+0x18) is set to the current `ModCount`;

View file

@ -0,0 +1,350 @@
# 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`.
`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
```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<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. The
"before" column is `main` at `0f1c007` **rebuilt on the same host** — 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 | before | after | closed | regressed |
|---|---:|---:|---:|---:|---:|
| `turn1-state -> turn2-state` (the reference End Turn) | 209 | 158 | **132** | 51 -> **77** | **0** |
| `turn2-state -> turn3-state` (real End Turn) | 108 | 87 | **73** | 21 -> **35** | **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.
`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.

View file

@ -0,0 +1,60 @@
{
"entries": [
{
"name": "StrategyServer_StampTreatyTurns",
"addr": "0x007898c0",
"convention": "thiscall",
"prototype": "void (StrategyServer* S, vector<uint32>* allianceBroken, vector<uint32>* napBroken, vector<uint32>* cfBroken) // 700 B, ret 0xc. THE DIPLOMACY LEDGER'S PER-TURN STAMP, and the only writer of DiplomacyStats on a turn with no combat and no diplomatic command. Pass A (0x00789920..0x007899c4): over every ORDERED pair (A,B) of the S-frame player vector at S+0x54/+0x58, skipping A==B by POINTER, rel = A->GetRelation(B) 0x0080e050; rel 1 -> slot 8, rel 2 -> slot 0, rel 3 -> slot 4, else skip; then ctor a DiplomacyStats on the stack, GetDipStat(&local,B) 0x008180e0, store (int16)S->Frame(+0xc) at local+8+slot*2, SetDipStat(&local,B) 0x00863950. Field mapping: rel 3 -> lastally(+0x10), rel 2 -> lastnap(+8), rel 1 -> lastcf(+0x18). Pass B (0x007899ca..) is the betrayal counter and indexes the three broken-mask arguments by the inner loop index, substituting a zero local when a vector's length != nPlayers -- with no command stream it is a no-op. SOLE CALLER: ApplyTurnCommands 0x007b18b0 at 0x007b2461, so this runs BEFORE both turn drivers and AFTER BeginProcessTurn's frame bump",
"status": "mapped",
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08); closes 26 leaves on the reference pair and 14 on pair 2 in sots-engine, 0 regressed, and reproduces the ledger of 10 of 11 corpus saves exactly"
},
{
"name": "PlayerAlliances_Relation",
"addr": "0x006d2050",
"convention": "cdecl",
"prototype": "int (int myPlyrIdx, PlayerAlliances* a, int otherPlyrIdx) // 58 B. if (myPlyrIdx == otherPlyrIdx) return 3; bit = 1 << otherPlyrIdx (shl by cl, so masked to 5 bits); if (a->AL(+4) & bit) return 3; if (a->NA(+8) & bit) return 2; return (a->CF(+0xc) & bit) ? 1 : 0. THE RELATION CODES ARE 3 = ALLIED (and self), 2 = NON-AGGRESSION, 1 = CEASE-FIRE, 0 = WAR -- strategic-turn-internals.md section 5.2 had 1 and 3 the other way round. The bit is the INDEX FIELD, not the position in the player vector (the opposite of the shared-vision mask), and AL is tested with NO alliance-id guard",
"status": "mapped",
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08); agrees with combat-done-tail.md section 2A, which reads Node::ResupplyAlliedFleets as gated on GetRelation == 3 and UpdateDiplomacyStatsFromCombat as gated on GetRelation < 1"
},
{
"name": "ServerPlayer_GetRelation",
"addr": "0x0080e050",
"convention": "thiscall",
"prototype": "int (ServerPlayer* this, ServerPlayer* other) // 33 B, ret 4. A thin forwarder: tail-calls the cdecl PlayerAlliances_Relation 0x006d2050 with (this->PlyrIdx(+0x28), &this->Alliances(+0x168), other->PlyrIdx(+0x28)). 60+ call sites across the image",
"status": "mapped",
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
},
{
"name": "DiplomacyStats_ctor",
"addr": "0x0080e7b0",
"convention": "thiscall",
"prototype": "DiplomacyStats* (DiplomacyStats* this) // 55 B. vptr = 0x00a21430; every field zeroed; then lastcf(+0x18) = lastnap(+8) = lastally(+0x10) = -1. So a fresh entry's three 'last in force' fields are -1, NOT 0, and every counter (lastnapbty/bkn*/bty*/deadhome) is 0. This is what distinguishes 'never' from 'on turn 0' in the ledger",
"status": "mapped",
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
},
{
"name": "ServerPlayer_GetDipStat",
"addr": "0x008180e0",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this, DiplomacyStats* out, ServerPlayer* other) // 294 B, ret 8. if (!out) return; re-initialise *out to the ctor's defaults IN PLACE (the vptr is not touched); out->other(+4) = other->+0x4 (the handle id, i.e. the wire's PlayerID); if (!other) return; then a LINEAR FIRST-MATCH scan of the 0x24-stride vector at this->dipstats(+0x230/+0x234) for entry.other == GetId(other) 0x0042bfb0, copying the entry's thirteen int16 fields (out+8..out+0x21) on a hit. Stride read as 0x38e38e39 / sar 3",
"status": "mapped",
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
},
{
"name": "ServerPlayer_SetDipStat",
"addr": "0x00863950",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this, const DiplomacyStats* src, ServerPlayer* other) // 328 B, ret 8. if (!src || !other) return; the same linear first-match scan; ON A MISS default-construct a DiplomacyStats on the stack and push_back it (0x0085bc40) so a NEW ENTRY IS APPENDED AT THE END, then back().other = other->+0x4; finally copy src's thirteen int16 fields into the entry and re-write other. The append order is therefore the order in which pairs are first stamped, which is player-vector order",
"status": "mapped",
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
},
{
"name": "StrategyServer_MarkPlayerTurnEnded",
"addr": "0x00821a40",
"convention": "thiscall",
"prototype": "bool (StrategyServer* this, int playerId) // 60 B, ret 4. p = HandleMap::Resolve(this + 0x80, playerId) 0x008b9240; if (!p) { Log(2, <0x00a2fb30>, playerId); return false; } p->Status(+0x164) = 4; return true. THE ONLY WRITER OF Player.Status = 4 IN THE IMAGE. Three callers, all End Turn SUBMISSION paths that run before the turn is processed: EndTurn 0x00783be0 (+0x70, passes the client's own id at client+0x148), EndTurnForced 0x00783d30 (+0x7b), OnPlayerEndTurn 0x007d9af0 (+0x35). The other two immediate stores to +0x164 in the image are ProcessTurn +0x5ca (value 1, inside the 0x44-stride encounter-member loop) and ResumePlaying +0xb1 (value 0, the load-path normalisation determinism-oracle.md recorded as 'Status resets 4 -> 0 on load'). There is NO writer between tail phase 31 and the autosave; backlog.md item 6 looks in the wrong place",
"status": "mapped",
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08) section 3"
}
]
}