diff --git a/findings/subsystems/strategic-turn-internals.md b/findings/subsystems/strategic-turn-internals.md index afc1c12..c777ebc 100644 --- a/findings/subsystems/strategic-turn-internals.md +++ b/findings/subsystems/strategic-turn-internals.md @@ -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`; diff --git a/findings/subsystems/treaty-turn-stamp.md b/findings/subsystems/treaty-turn-stamp.md new file mode 100644 index 0000000..adcd039 --- /dev/null +++ b/findings/subsystems/treaty-turn-stamp.md @@ -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* 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. 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. diff --git a/ghidra/addresses.d/lane-t2.json b/ghidra/addresses.d/lane-t2.json new file mode 100644 index 0000000..be53b77 --- /dev/null +++ b/ghidra/addresses.d/lane-t2.json @@ -0,0 +1,60 @@ +{ + "entries": [ + { + "name": "StrategyServer_StampTreatyTurns", + "addr": "0x007898c0", + "convention": "thiscall", + "prototype": "void (StrategyServer* S, vector* allianceBroken, vector* napBroken, vector* 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" + } + ] +}