diff --git a/findings/control-flow/alliance-mask-and-modcount.md b/findings/control-flow/alliance-mask-and-modcount.md new file mode 100644 index 0000000..9b5d504 --- /dev/null +++ b/findings/control-flow/alliance-mask-and-modcount.md @@ -0,0 +1,354 @@ +# The alliance mask (spine phase 4), and every writer of `ModCount` + +Lane A2, 2026-09-08. Static + host, no VM held. Engine worktree `wip/alliance` off `main` +`b2bad30`; the prediction is `sots-engine/docs/A2-alliance-and-modcount.md`, committed +(`49ae628`) before any implementation. Program `sots` / "Sword of the Stars.exe", +ImageBase 0x00400000, all addresses VAs. + +**Method.** Every control-flow and offset claim below was read from the instruction stream — +`objdump -b binary -m i386 -M intel` over the raw image, file offsets from the PE section table. +The decompiler was not used at all in this lane. Reachability came from a whole-image direct-call +edge sweep built with `tools/x86disp.py`'s decoder, sweeping each body **to the next function +start** (rule 17). + +--- + +## 0. The two results, up front + +1. **The alliance mask is closed.** `almem == (1 << vectorIndex) | (ALid != -1 ? AL : 0)`, and it + agrees with the bytes the game wrote on **72 of 80 player-records**; the other 8 are + *predicted* by the same model. `S04` is implemented and `T36`'s regression on the reference + pair drops from **17 to 9**. +2. **`ModCount` is a command counter, and it is not derivable from the pre-turn save.** It is + `StrategySim+0x4` (= `S+0x8`), bumped once on entry to each of **26 command-application + sites**, once by each turn driver, and once per abandoned-system check. The per-turn delta is + the number of commands applied, and the AI's commands are generated inside the turn. Static + reading has taken this as far as it goes; the residual is a **watchpoint**, specified in §3. + +--- + +## 1. Spine phase 4 — the alliance / shared-vision mask + +### 1.1 The bytes + +`StrategyServer::ProcessTurn` 0x007dc871–0x007dc8c7, byte for byte: + +``` +007dc871 mov edx,[esi+0x58] ; Players._Mylast (esi = S) +007dc874 sub edx,[esi+0x54] ; - Players._Myfirst +007dc877 xor ecx,ecx ; i = 0 +007dc879 test edx,0xfffffffc +007dc87f jle 0x7dc8c8 ; empty vector -> skip the whole phase +007dc881 mov eax,[esi+0x54] +007dc884 mov eax,[eax+ecx*4] ; p = Players[i] +007dc887 mov edx,[eax+0x3d8] ; rec = p->turnRecord +007dc88d mov edi,0x1 +007dc892 shl edi,cl ; bit = 1 << i <-- the LOOP COUNTER +007dc894 mov [edx+0x8],ebx ; rec->almem = 0 (ebx == 0) +007dc897 mov edx,[eax+0x3d8] ; ... reloaded +007dc89d or [edx+0x8],edi ; rec->almem |= bit +007dc8a0 cmp DWORD PTR [eax+0x168],0xffffffff ; ALid == -1 ? +007dc8a7 je 0x7dc8b8 +007dc8a9 mov edx,[eax+0x3d8] ; ... reloaded again +007dc8af mov eax,[eax+0x16c] ; AL +007dc8b5 or [edx+0x8],eax ; rec->almem |= AL +007dc8b8 mov edx,[esi+0x58] +007dc8bb sub edx,[esi+0x54] +007dc8be inc ecx +007dc8bf sar edx,0x2 +007dc8c2 xor ebx,ebx +007dc8c4 cmp ecx,edx +007dc8c6 jl 0x7dc881 +``` + +So: + +```c +for (int i = 0; i < numPlayers; ++i) { + ServerPlayer* p = Players[i]; + TurnRecord* rec = p->turnRecord; // +0x3d8 + rec->almem = 0; // +0x8 + rec->almem |= 1 << i; + if (p->ALid != -1) rec->almem |= p->AL; // +0x168, +0x16c +} +``` + +Three separate stores to the same word. That is what makes it an **OR** rather than an +assignment, and it is not visible in any summary of the phase. The record pointer is reloaded +from `p->+0x3d8` before each store. + +**Lane T's reading of this phase (`turn-driver.md` §1 row 4) is correct in every particular**, +including the load-bearing one: the bit index is the player's **position in the server's player +vector**, not `PlyrIdx`. Lane T's `ServerPlayer_off_TurnRecord`, `_off_AllianceId` and +`_off_AllianceMask` already carry the rule; this lane's duplicate address entries were dropped +and the agreement recorded instead. The only thing added here is the byte-level range (lane T's +table gives it as `0x007dc8c8–0x007dc8c6`, which is a transcription slip — the phase runs +0x007dc871–0x007dc8c7 and 0x007dc8c8 is where phase 5 begins). + +### 1.2 The output is on the wire, and it agrees + +`p->+0x3d8` is the per-player turn record that tail phase 36 archives into +`/Sim/turnstats/nply[]/hist/stats[]`; `almem` is the archive element's second int. So the phase +is checkable against bytes the original wrote, with no game. + +Over the eleven-save corpus, one element per player per save at the save's own frame: + +| | records | result | +|---|---:|---| +| `almem == (1 << i) \| (ALid != -1 ? AL : 0)` | **72** | agree | +| `almem == 0` on `turn1-state.sav` | **8** | **also predicted** — see below | +| disagree | **0** | | + +`sots-engine/tests/app/test_turn_record.cpp` now compares **7** fields, not 6: +**11 saves, 80 player-records, 560 fields, 0 mismatches.** + +### 1.3 The eight zeros are the model, not an exception + +`turn1-state.sav` is the game's first frame and every one of its eight `almem` values is 0 while +the rule says 1, 2, 12, 12, 16, 32, 64, 128. That is falsifier **F5** of the written prediction, +and it fires exactly as the prediction said it would: + +`FinalizeTurnRecords` 0x0078a0e0 is called from **two** places — the tail (0x007d98ba) and +`LoadGame` (0x007ddc40) — and `almem` is written **only** by spine phase 4. A record archived by +the load / new-game path therefore carries the turn record's initial zero. The corpus confirms +this on every save independently: the archive is cumulative, and in **all eleven** saves the +`trn == 1` element is `almem == 0` for every player while every `trn >= 2` element matches the +rule. `zuul-turn23-fleet23.sav` carries 23 elements per player and shows the same shape. + +The engine expresses this as a positive claim rather than a skipped field: `BuildTurnRecord` +takes a `spineRan` flag, the caller sets it from `frame > EarliestArchivedTurn(hist)`, and the +model **predicts zero** for the load-written element. All 80 records are compared. + +### 1.4 What the corpus does NOT separate — reported as loudly as the agreement + +Three parts of the rule are instruction-stream readings that **no comparison against these saves +can test**, and the run prints the fact every time: + +| claim | why the corpus cannot separate it | +|---|---| +| the bit is `1 << vectorIndex`, **not** `1 << PlyrIdx` | every player in every save has `PlyrIdx == i`. **0 of 80 records separate them.** | +| the alliance term is an **OR** with the self bit | every observed alliance mask already contains its own member's bit (`AL == 12` on players 2 and 3, `AL == 4` on player 2), so `self \| AL == AL` there | +| the guard is `ALid != -1`, **not** `AL != 0` | in the corpus `AL == 0` exactly when `ALid == -1`, so the two guards never disagree | + +14 of 80 records do carry a live alliance id, so the alliance term is **exercised** — falsifier F7 +did not bite — but it is exercised only in the shape where the three readings above coincide. +`sots-engine/tests/app/test_alliance.cpp` pins all three as unit cases with the separating inputs +the corpus lacks, so a later save that does separate them has something to disagree with. + +One more untested branch, reproduced rather than corrected: `shl edi,cl` masks the count to five +bits, so a 32nd player would set bit 0. No save has more than 8 players. + +--- + +## 2. `ModCount` — every writer + +### 2.1 It is `S+0x8`, and `addresses.json` has the name on the wrong word + +`StrategyServer::Write` 0x0079fa70 tags its own members. With `edi = this`: + +``` +0079fb2f lea edx,[edi+0x08] ; push "ModCount" (0x00a23844) +0079fb40 lea eax,[edi+0x0c] ; push "Frame" (0x00a2383c) +0079fb4f mov eax,[edi+0x14] ; push "GameID" (0x00a238f0) +0079fb90 mov eax,[edi+0x16c] ; push "RNG" (0x00a23828) +``` + +The last line settles the frame: `StrategyServer_off_RNGPtr` is `0x168` in the **S+4** frame, so +`[edi+0x16c]` means `edi = S`. Therefore + +* **`S+0x8` is the wire's `ModCount`** — the word `StrategyServer::ProcessTurn`'s first + instruction bumps; +* **`S+0xc` is the wire's `Frame`** — the word `BeginProcessTurn` bumps, that `TechTree::SetResearched` + stamps as `turnResearched`, and that `FinalizeTurnRecords` uses as the archive key. + +**Corrections that follow, stated plainly (rule 11):** + +1. `ghidra/addresses.json`'s `StrategyServer_off_ModCount` (offset `0x8`, S+4 frame, i.e. `S+0xc`) + carries the name of the *other* word. It is `Frame`. **Flagged for the integrator rather than + edited here**, because `addresses.json` is the shared file the per-lane fragments exist to keep + out of. `ghidra/addresses.d/lane-a2.json` adds `StrategySim_off_ModCount` (0x4) and + `StrategySim_off_Frame` (0x8) with the correction in their prototypes. +2. `turn-driver.md` §0.1's "the word at `S+0x8` has never been named" and "both advance once per + turn … so they stay in lockstep" are wrong on both counts. Lane K corrected the lockstep half + in `combat-done-tail.md` §7.1 and lane Z named the word there. This lane re-derives the name + independently from the serializer and confirms it. §0.1 is corrected in place. +3. RTTI gives the reason the two bases exist: `Game::StrategyServer` has bases + `Mars::IStreamable` at offset 0 and **`Game::StrategySim` at offset 4** (vftables 0x00a26084 + and 0x00a26034). The "RAW base" every `StrategyServer_off_*` is expressed in is the + `StrategySim` sub-object. `ModCount` is `StrategySim+0x4`. + +### 2.2 The writers: a command counter + +A whole-image sweep for `inc dword [reg+4]` and `inc dword [reg+8]` at real instruction +boundaries, with the base proved to be a `StrategySim` by an independent fingerprint — the same +register feeding `lea ecx,[base+0x80]; call 0x008b9240`, the entity-hash lookup that +`addresses.json` already places at `StrategySim+0x80`. + +**Twenty command handlers, each bumping once on entry:** + +| VA | the command, from its own log string | +|---|---| +| 0x00821a80 | set research rate | +| 0x00821b40 | boost research | +| 0x00821b90 | surrender | +| 0x00821c30 | abandon system | +| 0x00821cf0 | set fleet auto-support | +| 0x00821d70 | set fleet to guard | +| 0x00821e20 | set fleet to raid | +| 0x0083d5d0 | remove design | +| 0x0083d6e0 | set research project | +| 0x00842f40 | cancel build order | +| 0x00849460 | (no log string; same shape) | +| 0x00849520 | set ship action | +| 0x00849570 | set fleet name | +| 0x0085b6d0 | ship requesting support | +| 0x00865780 | **move fleet** ("StrategySim: … cannot move fleet") | +| 0x00865910 | set fleet layout | +| 0x0086c3e0 | set system rates | +| 0x008784e0 | set player notes | +| 0x00882910 | create design | +| 0x0088bed0 | transfer ships | + +Plus **six more inlined** inside `StrategySim::ApplyTurnCommandBatch` 0x0088f9b0 (0x0088fe0a, +0x008902fe, 0x008903b9, 0x0089046c, 0x008905c8, 0x008907bc) — cancel project, set civilian +ratios, set performance mods, set weapon groups, and two more its strings name. + +**The bump is unconditional and precedes validation.** In every one of the twenty it is the +second or third instruction of the body, *before* the handle lookup that decides whether the +command is even applicable. A command naming a player that does not exist still advances +`ModCount`. + +**The shape of a turn**, from `StrategyNetworkClient::OnMessage` 0x00784640 — the single direct +caller of all three: + +``` +0x00784904 StrategyServer::ApplyAllTurnCommands(S) ; -> ApplyTurnCommandBatch(S+4, blocks, nPlayers) +0x0078491c StrategyServer::ProcessTurn(S) ; +1 +0x00784d07 StrategyServer::OnAllCombatDone_Tail(S) ; +1 +``` + +`ApplyAllTurnCommands` 0x0078f6a0 computes `count = (S->+0x178 − S->+0x174) / 0x1b4` — signed +magic `0x964fda6d`, `sar 8`, the reciprocal of **436** — and hands the run to the batch applier. +**0x1b4 = sizeof(`Game::TurnCommands`)**, which independently confirms lane Q's field walk +(27 `std::list` members at 0x70…0x1a8, stride 0xc, so the class ends at 0x1b4). So `ModCount`'s +per-turn delta is: + +> **2 (the two drivers) + one per command applied out of every player's `TurnCommands` block** +> (+ one per abandoned system, §2.3). + +That is exactly what "12 to 44 times a turn" looks like, and it is why the delta is not a +function of the board: `human-turn2→turn3` is 28 and `zuul-turn16→turn17` is 16 on boards with +**identical** system and player counts (28 and 7). + +### 2.3 The one writer inside the turn drivers, and it is gated shut + +`FUN_007b9df0` — the abandon/chaos check that spine **phase 1** calls once per system — bumps +`ModCount` as its fourth instruction (`inc [esi+8]` @0x007b9e20, `esi = ecx = S`, entered from +0x007dc7fd `push edi; mov ecx,esi; call 0x7b9df0`). + +The phase-1 loop gate is `cmp BYTE PTR [edi+0xc4],0; je ` and `ServerSystem+0xc4` is +**`Abdn`**. `Abdn` is **false on all 28 systems of all 11 saves**, so this writer contributes +**0** on every measured turn. Per rule 6 that makes its per-turn cost a hypothesis, not a +measurement — but it also means it is not the missing term. + +A direct-call reachability sweep from each driver over the whole image: + +| root | closure | `ModCount` writers reachable | +|---|---:|---| +| `StrategyServer::ProcessTurn` 0x007dc6c0 | 1382 | `FUN_007b9df0` only | +| `OnAllCombatDone_Tail` 0x007d92a0 | 1369 | `FUN_007b9df0`, `MoveFleetCommand` 0x00865780 | +| `ServerPlayer::ProcessTurn` 0x00891340 | 272 | `FUN_007b9df0` only | + +**This is a lower bound and is labelled as one.** Lane V2 measured that 5,045 of 5,207 +vtable-named functions have zero direct call sites, and that sweeping for jumps into another +function's start yields 14,958 further edges. A writer reached only through a vtable slot or a +tail-call thunk is invisible to the table above — which is precisely the failure mode rule 18 was +written about. + +### 2.4 Three sites examined and ruled out, named rather than dropped + +| site | why it is not `ModCount` | +|---|---| +| `StrategyNetworkClient::OnMessage` 0x007850d5 / 0x0078514b / 0x00785224 | its `this` is not a `StrategySim`: the class holds the server at `+0x54` (`mov ecx,[esi+0x54]` before both driver calls) and its own entity hash at `+0x84`, not `+0x80`. `[this+4]` is a client-side word. **Unresolved, not counted.** | +| `FUN_007b10c0` 0x007b1304 | `inc [eax+4]` with `eax` from a stack slot; the body's fingerprint is 0x74-stride team records and `[esi+0x250/0x254]`, not the `StrategySim` layout. **Unresolved.** | +| `FUN_00890d50` 0x00890f61 | `eax = this->+0x1e8 − 0x14; if (eax) inc [eax+8]`. The −0x14 adjust is not the +4 that reaches a `StrategySim`. **Unresolved.** | + +### 2.5 The corpus numbers + +| pair | `ModCount` | Δ | note | +|---|---|---:|---| +| `turn1-state` → `turn2-state` | 0 → 12 | **12** | no queued commands in either block | +| `turn2-state` → `turn3-state` | 12 → 24 | **12** | ditto | +| `human-turn2-orders` → `human-turn3-noderoute` | 25 → 53 | **28** | 20 owned systems | +| `zuul-turn15-orders` → `zuul-turn16` | 210 → 241 | **31** | | +| `zuul-turn16` → `zuul-turn17` | 241 → 257 | **16** | | +| `zuul-turn17` → `zuul-turn23` | 257 → 412 | 155 / 6 turns | | + +The two 12s look like a state function and are the strongest evidence *against* the reading +above; they are also both turns on which the human issued nothing, so the 10 non-driver bumps are +the AI's commands on a two-colony board, and a stable board plausibly produces a stable command +count. The `TurnCommands_v5` block in each of these saves is the **empty** 35-item block +(lane Q §1c), so the commands that produced the 12 were issued *after* the autosave that wrote +the input file — which is exactly why the pre-turn save cannot predict them. + +--- + +## 3. What needs the VM — the watchpoint, specified (rule 18) + +`ModCount` is a single 4-byte counter with an unknown writer set. That is the textbook shape for +a hardware watchpoint, and it settles in one turn what static reading has spent this lane +bounding. + +**Probe 1 — write watchpoint on `ModCount`.** + +* **Address.** `ModCount = (char*)S + 8`, where `S` is `StrategyServer::ProcessTurn`'s `this`. + Equivalently `*(void**)(ServerPlayer + 0x8) + 4`, or `*(void**)(ServerSystem + 0x10) + 4`. + Read it once at a breakpoint on 0x007dc6c0 and set the watchpoint from there; do **not** try to + compute it from a global, and do **not** use `S+0xc` — that is `Frame`, and getting the two + the wrong way round is exactly the error this document corrects. +* **Type.** DR0 as a 4-byte data-write watchpoint. One debug register suffices. +* **Record per hit.** EIP, the two return addresses up the stack, the value written, and a + monotonic hit index. +* **Workload.** Load `turn1-state.sav`, press End Turn once, wait for the post-turn autosave. +* **Prediction, written before the run:** **exactly 12 hits.** Two of them at 0x007dc6f0 and + 0x007d92ca. Zero of them at 0x007b9e20 (`Abdn` is false everywhere). The other **10** inside + the twenty handlers of §2.2 or the six inlined sites of `ApplyTurnCommandBatch`, all with a + return address in `StrategySim::ApplyTurnCommandBatch` 0x0088f9b0 or its callers, and all + **before** `ProcessTurn` is entered. +* **Falsifiers.** (a) more or fewer than 12 hits ⇒ something writes `ModCount` that is not an + increment (check `StrategyServer::Read` on load first); (b) a hit whose EIP is not in the 29 + enumerated sites ⇒ this static sweep missed a writer, which is the result worth having, and + lane V2's indirect-edge map is where to look next; (c) hits *after* `ProcessTurn` is entered ⇒ + commands are applied during the turn, not before it, and the "the queue is flushed first" + reading is wrong; (d) hits at 0x007850d5 / 0x0078514b / 0x00785224 ⇒ §2.4's exclusion of + `OnMessage` is wrong and `StrategyNetworkClient` shares the base. + +**Probe 2 — cheaper, and it answers the modelling question rather than the enumeration one.** +Break on `StrategyServer::ApplyAllTurnCommands` 0x0078f6a0 entry and log, for each of the `count` +blocks at `S->+0x174`, the six prologue gate bools and the 27 list sizes. That is the exact term +`ModCount` counts, in the exact layout lane Q recovered, and it says directly whether the +standalone could ever produce the number. **If probe 2 shows the AI's blocks are non-empty at +this point, `ModCount` is downstream of the whole AI and is the wrong leaf to chase before it; +if they are empty, the bumps come from somewhere else entirely and probe 1's hit list names it.** + +Both probes are minutes of work for whoever holds VM140 next. Neither is worth another lane of +reading. + +--- + +## 4. What this lane did not do + +* **Nothing was compared against the live game.** The alliance rule is checked against *bytes the + original wrote*, which is stronger than nothing and weaker than a live compare, and the phase + catalog's `verified` count stays 0. +* **The alliance rule's three unseparated readings** (§1.4) rest on the instruction stream alone. + A save with a player whose `PlyrIdx` differs from its vector position would settle the first + one; none exists, and one could be **manufactured** (rule 6) by editing `PlyrIdx` on a save, + which is a named wire field. +* **The `ModCount` writer set is a lower bound.** It is complete for *direct* `inc [this+4]` / + `inc [this+8]` forms with a proved `StrategySim` base. A writer that stores rather than + increments, or that reaches the counter through a pointer this sweep could not type, is not + excluded. §2.4 lists the three it could not type. +* **The abandon/chaos writer has never fired** on any save in the corpus. Its per-turn cost is a + hypothesis. +* No shim TU was touched, so no CT111 cross-build was required. diff --git a/findings/control-flow/turn-driver.md b/findings/control-flow/turn-driver.md index de578dd..6f11533 100644 --- a/findings/control-flow/turn-driver.md +++ b/findings/control-flow/turn-driver.md @@ -35,9 +35,30 @@ empty player vector and zero declared regions — the exact failure the campaign | `S+0x8` (= `(S+4)+0x4`) | **`StrategyServer::ProcessTurn`, first instruction** (`inc [esi+8]` @0x007dc6f0) | not observed by this lane | | `S+0xc` (= `(S+4)+0x8`, the entry `addresses.json` calls `StrategyServer_off_ModCount`, turn-spine calls `Frame`) | **`StrategyServer::BeginProcessTurn`** 0x007d990a (`inc [esi+0xc]`, then `"Begin processing turn %d."`) | `TechTree::SetResearched` 0x00581e83 stamps `node.turnResearched`; `ServerPlayer::ProcessTurn` reads it for `EVENT_NO_RESEARCH` and for the completed-tech sweep | -`turn-spine.md` and `addresses.json` give the **same word** (`S+0xc`) two different names ("Frame" / "ModCount"). -The word at `S+0x8` has never been named. Both advance once per turn, in different functions, so they stay in -lockstep and no existing result is invalidated — but a reimplementation needs both. +~~`turn-spine.md` and `addresses.json` give the **same word** (`S+0xc`) two different names ("Frame" / +"ModCount"). The word at `S+0x8` has never been named. Both advance once per turn, in different functions, so +they stay in lockstep and no existing result is invalidated — but a reimplementation needs both.~~ + +> **WRONG ON BOTH COUNTS — corrected by lane K/Z (`combat-done-tail.md` §7.1) and re-derived +> independently by lane A2 (`alliance-mask-and-modcount.md` §2.1).** +> +> `StrategyServer::Write` names both words itself: `0x0079fb2f lea edx,[edi+0x08]; push "ModCount"` and +> `0x0079fb40 lea eax,[edi+0x0c]; push "Frame"`, with `edi = S` (settled inside the same function by +> `0x0079fb90 mov eax,[edi+0x16c]` under the tag `"RNG"`, which is `StrategyServer_off_RNGPtr = 0x168` in the +> `S+4` frame). So: +> +> * **`S+0x8` is the wire's `ModCount`** — the word this section says has never been named. It advances +> **12–44 times a turn**, not once: it is bumped on entry to every `StrategySim` command-application method +> (26 sites), and the two drivers account for only 2 of them. `addresses.json`'s +> `StrategyServer_off_ModCount` has the name on the **other** word. +> * **`S+0xc` is the wire's `Frame`** — the turn number, which is also the key the turn-record archive is +> written under. +> +> They are **not** in lockstep; a reimplementation must not treat `S+0x8` as a turn number. +> `ghidra/addresses.d/lane-a2.json` carries `StrategySim_off_ModCount` (0x4) and `StrategySim_off_Frame` (0x8) +> in the `S+4` frame, with the correction stated on each. Also settled there: the `S+4` frame is the +> **`Game::StrategySim`** base sub-object — RTTI gives `Game::StrategyServer`'s bases as `Mars::IStreamable` +> at offset 0 and `Game::StrategySim` at offset **4**. --- @@ -54,7 +75,7 @@ Phases, in execution order. "verified" = read from the instruction stream in thi | 1 | 0x007dc6fb–0x007dc859 | **per-system pre-pass** over `Systems` (`S+0x44/0x48`), ascending | skips systems with `sys->+0xc4 == 0`. For an owned system: push **morale event id 0x26** onto the owner (`FUN_00752a10` ctor → `FUN_00743420/0x007433f0` id wrap → `FUN_00743530`+`FUN_008c97f0` format the text from the system's inline `std::string` at `sys+0xa8` → `FUN_00841420(owner, ev, sys)`). Then, for **every** system that passed the `+0xc4` gate, `FUN_007b9df0(S, sys, 0, &emptyVec)` (abandon / chaos) | | 2 | 0x007dc85f | `FUN_0086b300(S->+0x158)` — `ServerTradeManager::ProcessTurn` | 1494 B | | 3 | 0x007dc86a | `FUN_007adc80(S)` — `RegisterTradeSystems` | 192 B | -| 4 | 0x007dc8c8–0x007dc8c6 | **per-player pre-pass**: `rec = p->+0x3d8; rec->+0x8 = (1 << playerSlot); if (p->ALid(+0x168) != -1) rec->+0x8 \|= p->AL(+0x16c)` | the turn's shared-vision / alliance mask, rebuilt from scratch each turn. Note the bit index is the player's **position in the server vector**, not `PlyrIdx` | +| 4 | 0x007dc871–0x007dc8c7 | **per-player pre-pass**: `rec = p->+0x3d8; rec->+0x8 = 0; rec->+0x8 \|= (1 << playerSlot); if (p->ALid(+0x168) != -1) rec->+0x8 \|= p->AL(+0x16c)` | the turn's shared-vision / alliance mask, rebuilt from scratch each turn. Note the bit index is the player's **position in the server vector**, not `PlyrIdx`. *(VA range corrected by lane A2 — it was given here as 0x007dc8c8–0x007dc8c6, which is phase 5's start. The three separate stores are also from lane A2: the clear-then-OR is what makes the alliance term an OR. Checked against the archived `almem` bytes on 80 player-records, 0 mismatches — `alliance-mask-and-modcount.md` §1.)* | | 5 | 0x007dc8c8–0x007dc928 | build `vector A = {2}` (`FUN_00483410` reserve 1, store 2, bump `_Mylast`); `FUN_00794ad0(&B)` builds `vector B = {0..14}` (the 15 ship-action type ids); `FUN_00513110(&B, &A)` is a **set-difference / erase-if**, so `B = {0..14} \ {2}` | | | 6 | 0x007dc92b | `FUN_007b9b90(S, B, 0)` — **the ship-action dispatcher**, run over every action type *except* type 2 | 591 B; see §1.2 | | 7 | 0x007dc93a | **`ProcessNodeSpaceTravel(S)`** 0x007a0e20 | 2945 B | diff --git a/ghidra/addresses.d/lane-a2.json b/ghidra/addresses.d/lane-a2.json new file mode 100644 index 0000000..a4c23fc --- /dev/null +++ b/ghidra/addresses.d/lane-a2.json @@ -0,0 +1,100 @@ +{ + "entries": [ + { + "name": "StrategyServer_Write_ModCountFrameTags", + "addr": "0x0079fb2f", + "convention": "site", + "prototype": "site in StrategyServer::Write (0x0079fa70, already in addresses.json with the same address -- AGREEMENT, this lane's duplicate entry was dropped). THE DECISIVE EVIDENCE FOR THE ModCount / Frame NAMING: 0x0079fb2f `lea edx,[edi+0x08]; push \"ModCount\"` and 0x0079fb40 `lea eax,[edi+0x0c]; push \"Frame\"`, with edi = this. The frame is confirmed inside the same function by 0x0079fb90 `mov eax,[edi+0x16c]` under the tag \"RNG\", which is StrategyServer_off_RNGPtr (0x168 in the S+4 frame) -- so edi is S, not S+4. Tag order after the six id lists is ModCount, Frame, GameID ([edi+0x14] at 0x0079fb4f)", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2 (lane A2 2026-09-08, instruction stream 0x0079fa70-0x0079fc70)" + }, + { + "name": "StrategySim_off_ModCount", + "offset": "0x4", + "convention": "field", + "prototype": "int ModCount, in the S+4 (Game::StrategySim sub-object) frame == S+0x8. THIS IS THE WIRE'S /Sim/ModCount. CORRECTION, and it matters: addresses.json's `StrategyServer_off_ModCount` puts that name on offset 0x8 of this frame (== S+0xc), which StrategyServer::Write tags \"Frame\". Lane T's `StrategyServer_off_PhaseCounter` -- 'nobody has named this one' -- is this word, and it IS ModCount. Written by an unconditional increment on ENTRY to every StrategySim command-application method (26 sites), plus once by StrategyServer::ProcessTurn's first instruction, once by OnAllCombatDone_Tail's first instruction, and once per call of the abandon/chaos check. It is a modification counter: not a turn number, not a phase counter, and not constant per turn (12-44 on measured turns)", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2 (lane A2 2026-09-08); named first by lane Z in combat-done-tail.md §7.1, independently re-derived here from StrategyServer::Write" + }, + { + "name": "StrategySim_off_Frame", + "offset": "0x8", + "convention": "field", + "prototype": "int Frame, in the S+4 frame == S+0xc. THIS IS THE WIRE'S /Sim/Frame -- the turn number. Incremented once per turn by StrategyServer::BeginProcessTurn 0x007d990a; stamped into node.turnResearched by TechTree::SetResearched; used as minTurn by the EVENT_NO_RESEARCH sweep; and it is the KEY the turn-record archive is written under (FinalizeTurnRecords passes S->+0xc). The entry addresses.json calls `StrategyServer_off_ModCount` is this word, and the name is wrong on it", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2 (lane A2 2026-09-08)" + }, + { + "name": "StrategyServer_ProcessTurn_AllianceMaskPass", + "addr": "0x007dc871", + "convention": "site", + "prototype": "site in StrategyServer::ProcessTurn, phase 4, 0x007dc871-0x007dc8c7, byte-for-byte: `for (i = 0; i < (Players.end - Players.begin) >> 2; ++i) { p = Players[i]; rec = p->+0x3d8; rec->+0x8 = 0; rec->+0x8 |= 1 << i; if (p->+0x168 != -1) rec->+0x8 |= p->+0x16c; }`. THREE separate stores to the same word (`mov [edx+8],ebx` with ebx=0, then two `or`), which is why it is an OR and not an assignment; the turn-record pointer is RELOADED from p->+0x3d8 before each. The shift is `mov edi,1; shl edi,cl` with cl = the loop counter, so the bit is the player's POSITION IN THE VECTOR and x86's 5-bit shift mask applies above 31 players. Loop guard is `test edx,0xfffffffc; jle` on the byte count", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §1 (lane A2 2026-09-08). AGREES with lane T's ServerPlayer_off_TurnRecord / _off_AllianceId / _off_AllianceMask, which are kept and not duplicated here" + }, + { + "name": "StrategySim_ApplyTurnCommandBatch", + "addr": "0x0088f9b0", + "convention": "thiscall", + "prototype": "void (StrategySim* this /* S+4 */, Game::TurnCommands* first, int count) // applies a run of per-player command blocks: `last = first + count*0x1b4; for (b = first; b != last; b += 0x1b4) `. The 0x1b4 stride is sizeof(Game::TurnCommands) and independently confirms lane Q's layout (27 std::list members at 0x70..0x1a8, stride 0xc, so the class ends at 0x1b4). SIX command handlers are INLINED here and each bumps ModCount once (0x0088fe0a, 0x008902fe, 0x008903b9, 0x0089046c, 0x008905c8, 0x008907bc), each followed by `lea ecx,[base+0x80]; call HandleMap::Resolve` -- which is what proves the base is the StrategySim and not the block", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)" + }, + { + "name": "StrategyServer_ApplyAllTurnCommands", + "addr": "0x0078f6a0", + "convention": "thiscall", + "prototype": "void (StrategyServer* this /* S frame */) // the End-Turn command flush: computes `count = (this->+0x178 - this->+0x174) / 0x1b4` (signed magic 0x964fda6d, sar 8 -- the reciprocal of 436) and calls StrategySim::ApplyTurnCommandBatch(this+4, this->+0x174, count). The `lea ecx,[esi+4]` at 0x0078f6de is a third independent sighting of the two-bases split. Called from StrategyNetworkClient::OnMessage 0x00784904, immediately before that handler calls StrategyServer::ProcessTurn at 0x0078491c", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)" + }, + { + "name": "StrategyServer_off_TurnCommandQueue", + "offset": "0x174", + "convention": "field", + "prototype": "std::vector in the S frame (begin @+0x174, end @+0x178), element stride 0x1b4. One block per player; the End-Turn flush applies every command in every block, and each application bumps ModCount. This is the wire's `Player..TurnCommands_v5` custom-data block in memory", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)" + }, + { + "name": "TurnCommands_sizeof", + "offset": "0x1b4", + "convention": "field", + "prototype": "sizeof(Game::TurnCommands) = 436, recovered from the container stride in StrategyServer::ApplyAllTurnCommands (`imul ebx,ebx,0x1b4`) and from the signed-division reciprocal 0x964fda6d/sar 8 in the same function. Independent of, and agreeing with, lane Q's field walk, whose last list member sits at 0x1a8 with stride 0xc", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08); layout from findings/objects/turncommands-block.md (lane Q)" + }, + { + "name": "StrategyNetworkClient_OnMessage", + "addr": "0x00784640", + "convention": "thiscall", + "prototype": "void (StrategyNetworkClient* this, Message* m) // vftable 0x00a229f4 slot 6. THE END-TURN DISPATCHER: it is the only direct caller of StrategyServer::ProcessTurn (0x0078491c), of OnAllCombatDone_Tail (0x00784d07) and of StrategyServer::ApplyAllTurnCommands (0x00784904), and it loads the server with `mov ecx,[esi+0x54]` before the first two. NOTE: this class's own `this` is NOT a StrategySim -- its entity hash is at +0x84, not +0x80 -- so the three `inc [reg+4]` sites in this body (0x007850d5, 0x0078514b, 0x00785224) are NOT ModCount and are listed as unresolved rather than counted", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)" + }, + { + "name": "StrategyNetworkClient_off_Server", + "offset": "0x54", + "convention": "field", + "prototype": "StrategyServer* in the S frame (the base ProcessTurn receives, not S+4). Read at 0x00784901 and 0x00784919 immediately before the command flush and the turn driver", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)" + }, + { + "name": "StrategyServer_AbandonChaosCheck", + "addr": "0x007b9df0", + "convention": "thiscall", + "prototype": "void (StrategyServer* this /* S frame */, ServerSystem* sys, int mode, std::vector* out) // the abandon/chaos check called once per system from ProcessTurn phase 1. ITS FOURTH INSTRUCTION IS A ModCount BUMP: `inc [esi+0x8]` @0x007b9e20 with esi = this = S, so this is the only per-system ModCount writer in the turn. The phase-1 loop gate is `cmp BYTE [sys+0xc4],0; je` -- ServerSystem_off_Abdn -- so the call, and the bump, happen only for systems already flagged abandoned. Abdn is FALSE on all 28 systems of all 11 corpus saves, so this writer contributes 0 on every measured turn and is a rule-6 hypothesis for any turn where it does not", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.3 (lane A2 2026-09-08)" + }, + { + "name": "StrategySim_MoveFleetCommand", + "addr": "0x00865780", + "convention": "thiscall", + "prototype": "void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs \"StrategySim: Fleet not found.\", \"StrategySim: Waypoint %d(id) not found.\" and \"StrategySim: (see above) cannot move fleet %d(id).\" Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382)", + "status": "verified", + "source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)" + } + ] +}