From 9c37635ca367f171d18f5fbb3b674e86b1ffae6e Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 10:04:21 -0400 Subject: [PATCH] Z: the unnamed counter at S+0x8 is ModCount, and my players flag was my own error StrategyServer::Write tags both words itself: S+0x8 is ModCount and S+0xc is Frame. addresses.json has the name on the wrong word and lane T's PhaseCounter is the one the wire calls ModCount. The saves confirm it independently -- ModCount 0/12/24 across turn1/2/3-state, 241/412 across Zuul 16/23 -- and those deltas are exactly the 12-44 per turn measured live. So the 'writer nobody has identified' question dissolves: it is a modification counter, it scales with the empire, and there is no single writer to find. The players=8 flag is withdrawn. The offset is right, pinned by the ctor's four-vector enumeration at 0x0085b120 with no frame arithmetic needed, and the count is right: the vector is empires + one rebel-AI per empire species + four NPC pseudo-players, so 8 on the Human saves and 7 on the Zuul ones against a lobby that says 2 in both. My draft claimed the hook read 8 on both saves. It read 7 on the Zuul one. I generalised from one run without re-reading the other, and a check aimed at something else caught it. --- findings/control-flow/combat-done-tail.md | 19 +++--- findings/control-flow/tail-rng-ledger.md | 72 ++++++++++++++++++----- ghidra/addresses.d/lane-z.json | 36 +++++++++++- 3 files changed, 102 insertions(+), 25 deletions(-) diff --git a/findings/control-flow/combat-done-tail.md b/findings/control-flow/combat-done-tail.md index 224a173..96a277e 100644 --- a/findings/control-flow/combat-done-tail.md +++ b/findings/control-flow/combat-done-tail.md @@ -623,14 +623,17 @@ same word. Given §6's inference that the message is delivered every turn, `S+0x per turn while `S+0xc` (`ModCount`) advances once. They are not in lockstep, and a reimplementation must not treat `S+0x8` as a turn number. It reads as a **server-phase / driver-invocation counter**. -> **MEASURED by lane Z, 2026-09-08 — "at least twice" is the right phrasing, and the real number is 12 to -> 44.** Read live at hook entry over eight turns on two saves, `S+0x8` advances **12–14 times per turn** on -> an early two-colony game and **16–44** on a turn-19 Zuul one, of which the two drivers account for 2. -> Both drivers were hooked, so the rest come from a writer nobody has identified, between the post-turn -> autosave and the next `ProcessTurn`. `S+0xc` advances by exactly 1 per turn over the same records. The -> conclusion here is strengthened: `S+0x8` is not a turn number, it is not a driver-invocation counter, and -> it is not even a constant per turn — it scales with the size of the game state. -> `findings/control-flow/tail-rng-ledger.md` §5. +> **NAMED and MEASURED by lane Z, 2026-09-08 — the word is `ModCount`, and it moves 12 to 44 times a +> turn.** `StrategyServer::Write` tags it itself: `0x0079fb2f lea edx,[edi+0x08]; push "ModCount"` and +> `0x0079fb40 lea eax,[edi+0x0c]; push "Frame"`, with `edi = S`. So **`S+0x8` is `ModCount`** — the word +> this section says "has never been named" — **and `S+0xc` is `Frame`**, the turn. (`addresses.json` has +> `StrategyServer_off_ModCount` on the wrong word; the name belongs to lane T's +> `StrategyServer_off_PhaseCounter`.) Measured live over eight turns on two saves, `S+0x8` advances **12–14 +> times per turn** on an early two-colony game and **16–44** on a turn-19 Zuul one, of which the two drivers +> account for 2 — and the saves agree independently (`ModCount` 0 → 12 → 24 across turn1/2/3-state, 241 → 412 +> across Zuul turns 16 → 23). A modification counter is exactly what that looks like, so "which writer bumps +> the rest" has no single answer. `S+0x8` is not a turn number, not a driver-invocation counter and not a +> constant per turn. `findings/control-flow/tail-rng-ledger.md` §5. ### 7.2 Turn results and turn events are not where lane T said diff --git a/findings/control-flow/tail-rng-ledger.md b/findings/control-flow/tail-rng-ledger.md index 0c1775e..65ee0ee 100644 --- a/findings/control-flow/tail-rng-ledger.md +++ b/findings/control-flow/tail-rng-ledger.md @@ -189,10 +189,32 @@ Two things fall out of the same records and are worth more than the phrasing: --- -## 5. Correction to `combat-done-tail.md` §7.1 — and to this lane's own prediction +## 5. `S+0x8` has a name, and it is `ModCount` — correcting `combat-done-tail.md` §7.1, lane T §0.1, `addresses.json`, and this lane's own prediction -Lane K wrote that `S+0x8` "advances **at least twice** per turn". That is correct and this lane's prediction -that it advances **exactly** twice is **wrong**. Observed `S+0x8` at hook entry: +Lane K wrote that `S+0x8` "advances **at least twice** per turn" and that "the word at `S+0x8` has never been +named" (lane T §0.1). The first is right and this lane's prediction that it advances **exactly** twice is +**wrong**. The second is now answered — by `StrategyServer::Write`'s own wire tags: + +``` +0079fb2f lea edx,[edi+0x08] ; push "ModCount" ; edi = S -- the same edi that indexes +0079fb40 lea eax,[edi+0x0c] ; push "Frame" ; the players vector at +0x54 +``` + +So **`S+0x8` is `ModCount` and `S+0xc` is `Frame`**, the turn number. `ghidra/addresses.json` has the name on +the wrong word: its `StrategyServer_off_ModCount = 0x8` is the stored-frame offset of `S+0xc`, which the wire +calls `Frame` — `turn-spine.md` was right to call it that and lane T flagged the clash without being able to +settle it. The name `ModCount` belongs to the word lane T recorded as `StrategyServer_off_PhaseCounter = 0x4`. + +Confirmed three ways, and the third is the satisfying one. **From the saves:** + +| save | `Frame` | `ModCount` | +|---|---|---| +| turn1-state / turn2-state / turn3-state | 1 / 2 / 3 | 0 / 12 / 24 | +| z-t6-endturn / z-t6-autosave (this lane's bracket) | 5 / 6 | 50 / 62 | +| zuul-turn16-noderoute / zuul-turn23-fleet23 | 16 / 23 | 241 / 412 | + +`Frame` is the turn; `ModCount` moves **+12 per turn** on the early Human game and averages **+24** on the +Zuul one. **From the live trace**, `S+0x8` at hook entry: | save | turn | `ProcessTurn` entry | tail entry | tail's callees | increments to the next turn | |---|---|---|---|---|---| @@ -205,13 +227,20 @@ that it advances **exactly** twice is **wrong**. Observed `S+0x8` at hook entry: | zuul-noderoute | 19 | 290 | 291 | 292 | **44** | | zuul-noderoute | 20 | 334 | 335 | 336 | — | -The two drivers account for **2 of 12 to 44** increments per turn, and the count is not a constant — it -grows with the size of the game state (12–14 on a turn-3 two-colony empire, 16–44 on a turn-19 Zuul one). -The rest happen between the post-turn autosave and the next `ProcessTurn`, from a writer this lane did not -identify (both drivers are hooked, so it is neither of them). Lane K's operational conclusion is -strengthened, not weakened: **`S+0x8` must never be treated as a turn number** — and it is not a -driver-invocation counter either. `S+0xc` (`ModCount`) reads 3, 4, 5, 6 and 17, 18, 19, 20 over the same -records and is the turn counter. +The live deltas (12, 14, 12 on the Human game; 16, 21, 44 on the Zuul one) sit exactly where the saves' +`ModCount` deltas say they should. The two turn drivers account for **2 of 12 to 44** increments; the rest +are spread across the turn and mostly fall between the post-turn autosave and the next `ProcessTurn`. + +That is no longer a mystery to be chased — **it is what a modification counter is for**. `S+0x8` is not a +turn number, not a driver-invocation counter and not a constant per turn: it counts state mutations, so it +scales with the size of the empire, and asking "which writer is responsible" has no single answer. Lane K's +operational conclusion stands and is now explained rather than merely observed. `S+0xc` (`Frame`) reads 3, 4, +5, 6 and 17, 18, 19, 20 over the same records and is the turn counter. + +**For the integrator:** this is a name collision to reconcile, not a new entry. `StrategyServer_off_ModCount` +(0x8, stored frame) and `StrategyServer_off_PhaseCounter` (0x4, stored frame) are the two words above with +their names swapped; `ghidra/addresses.d/lane-z.json` records the evidence under +`StrategyServer_wire_ModCount_vs_Frame` rather than adding a third name for either word. ## 6. Corrections to `combat-done-tail.md` §3 and §6.1 @@ -298,11 +327,24 @@ would have said so by construction rather than by anyone noticing. * **A turn with a genuinely empty encounter vector was not observed** (§4). Both saves produce exactly one sighting encounter on every turn. The tail-runs-every-turn claim is settled; the no-encounters variant is still an inference, now a much narrower one. -* **`players` reads 8 on a 2-player save.** `(S+0x54)` enumerated as a `ServerPlayer*` vector gives 8 on - every record of a game the lobby shows as 2 players. Either the server always allocates a fixed slot - count, or this offset enumerates something else (spare capacity is the failure this campaign has already - paid for once, at `S+0x64`). **Nothing in the ledger depends on it** — it is decoration on the argument - record — but it should not be reused until someone resolves it. +* ~~`players` reads 8 on a 2-player save, and may be the `S+0x64` bug again.~~ **Resolved, and the flag was + my own error.** The offset is right and so is the count. `StrategyServer`'s base-class ctor 0x0085b120 + (entered with `ecx = S+4`) zero-initialises four consecutive vectors as three-word triples with the fourth + word skipped — `+0x40/+0x50/+0x60/+0x70` raw, 0x10 apart, allocator-last — which enumerates the players + triple as literally `{S+0x54, S+0x58, S+0x5c}` with no frame arithmetic at all, and puts the fleets vector + at `S+0x64` exactly where B4 measured it. Five NPC accessors at 0x00788de0ff bounds-check an index against + `([S+0x58] − [S+0x54]) >> 2` and then index `_Myfirst`, which is a third confirmation. + + **The vector is not the lobby's player list.** It is `#empires + one rebel-AI per distinct empire species + + 4 NPC pseudo-players` (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony — all species + 4). `Sim.NumPlrs` reads **8** in the Human saves (two species) and **7** in every Zuul save (one species), + against a `Summary.Players` array with **2** entries in both. Both numbers are right; they count different + things. + + **And my draft of this document was wrong about my own data.** It said the hook read 8 "on both saves". It + did not: the trace reads 8 on `ref-turn2` and **7** on `zuul-turn16-noderoute`, matching each save's + `NumPlrs` exactly. I generalised from one run without re-reading the other, and it took a check aimed at + something else to catch it. * **Which of the 12-to-44 `S+0x8` increments per turn come from where** (§5), and what they scale with. * The direct-call sweeps behind "node-line decay's only RNG site is the `Chance(0.5f)`", "its downstream pair draws nothing" and "`ProcessNodeSpaceTravel` draws nothing" are **worth less than they look**, and diff --git a/ghidra/addresses.d/lane-z.json b/ghidra/addresses.d/lane-z.json index 4894b81..73620cc 100644 --- a/ghidra/addresses.d/lane-z.json +++ b/ghidra/addresses.d/lane-z.json @@ -12,7 +12,7 @@ "name": "StrategyServer_NodeLineDecay_FleetSkipIsPostDraw", "addr": "0x007ae0b2", "convention": "site", - "prototype": "site, and a CORRECTION to findings/control-flow/combat-done-tail.md §3, which says \"the roll is skipped for a line if any fleet with flag 0x20000 is targeting it\". IT IS NOT: the fleet scan begins HERE, at 0x007ae0b2, which is 0x1d bytes AFTER the Chance(0.5f) call at 0x007ae0a5 and is reached only when the roll SUCCEEDED (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The scan therefore cannot change the draw count -- it suppresses only the collapse (the 0x007a92e0 / 0x007a4700 pair), never the draw. The straight-line order in loop 1 is: expiry test -> DRAW -> roll gate -> fleet gate -> collect. Lane K's headline claim, one NextFloat per expired node line per turn, survives intact and is now pinned to a concrete expiry formula (NodePath_RemainingLife)", + "prototype": "site, and a CORRECTION to findings/control-flow/combat-done-tail.md \u00a73, which says \"the roll is skipped for a line if any fleet with flag 0x20000 is targeting it\". IT IS NOT: the fleet scan begins HERE, at 0x007ae0b2, which is 0x1d bytes AFTER the Chance(0.5f) call at 0x007ae0a5 and is reached only when the roll SUCCEEDED (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The scan therefore cannot change the draw count -- it suppresses only the collapse (the 0x007a92e0 / 0x007a4700 pair), never the draw. The straight-line order in loop 1 is: expiry test -> DRAW -> roll gate -> fleet gate -> collect. Lane K's headline claim, one NextFloat per expired node line per turn, survives intact and is now pinned to a concrete expiry formula (NodePath_RemainingLife)", "status": "verified", "source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)" }, @@ -79,6 +79,38 @@ "prototype": "int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it", "status": "verified", "source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)" + }, + { + "name": "StrategyServer_wire_ModCount_vs_Frame", + "addr": "0x0079fb2f", + "convention": "note", + "prototype": "NAME CORRECTION, from StrategyServer::Write's own wire tags. At 0x0079fb2f `lea edx,[edi+0x08]; push \"ModCount\"` and at 0x0079fb40 `lea eax,[edi+0x0c]; push \"Frame\"`, with edi = S (the same edi that indexes the players vector at +0x54). So in the S frame **S+0x8 is ModCount and S+0xc is Frame**, i.e. in the stored (S+4) frame +0x4 is ModCount and +0x8 is Frame. `StrategyServer_off_ModCount = 0x8` therefore carries the WRONG NAME: that word is Frame, the turn number. The word it names is the one lane T recorded as StrategyServer_off_PhaseCounter = 0x4 and lane K called 'never named' -- it has a name, and it is ModCount. CONFIRMED FROM THE SAVES, which is an independent instrument: Frame reads 1/2/3 on turn1/2/3-state, 16 on zuul-turn16, 23 on zuul-turn23, while ModCount reads 0/12/24/241/412. And CONFIRMED LIVE: lane Z measured S+0x8 advancing 12, 14, 12 per turn on the early Human game (the saves say +12/turn) and 16, 21, 44 on the Zuul one (the saves say ~24/turn average). A modification counter is exactly what those numbers look like, and it explains why only 2 of the 12-44 increments come from the two turn drivers. Integrator: reconcile StrategyServer_off_ModCount / StrategyServer_off_PhaseCounter rather than adding a third name", + "status": "verified", + "source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08; wire tags from the instruction stream, values from save_reader over five saves, deltas from the live trace)" + }, + { + "name": "StrategyServer_ctor_VectorBlock", + "addr": "0x0085b120", + "convention": "thiscall", + "prototype": "void (void* rawBase /* = S+4 */) // the StrategyServer base-class ctor, called from StrategyServer::StrategyServer 0x007d78d0 at 0x007d7905 as `lea ecx,[esi+0x4]`. It zero-initialises FOUR CONSECUTIVE std::vectors as three-word triples with the fourth word skipped: raw +0x40/+0x44/+0x48, +0x50/+0x54/+0x58, +0x60/+0x64/+0x68, +0x70/+0x74/+0x78, then `lea ecx,[esi+0x80]` for the entity hash. That is the campaign's `{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10 allocator-last shape (method rule 5) enumerated four times in a row, and it independently pins StrategyServer_off_Players = 0x50 and _off_Fleets = 0x60 in the raw frame WITHOUT any frame arithmetic -- the ctor is entered with ecx = S+4, so the players triple is literally {S+0x54, S+0x58, S+0x5c}. This is the enumeration that closes the 0x60-vs-0x64 question the campaign paid for once", + "status": "verified", + "source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)" + }, + { + "name": "StrategyServer_NPCPlayerAccessors", + "addr": "0x00788de0", + "convention": "thiscall", + "prototype": "ServerPlayer* (StrategyServer* this /*S frame*/) // five sibling accessors at 0x00788de0, 0x00788e10, 0x00788e40, 0x00788e70, 0x00788ea0, one per NPC pseudo-player index word at S+0x1b8/0x1bc/0x1c0/0x1c4/0x1c8 (the five words the ctor sets to -1 at 0x007d79fe..0x007d7a16, and the save's NPCm/NPCo/NPCi/NPCv/NPCa). Each is `idx = this->+0x1b8; if (idx < 0) return 0; first = [this+0x54]; last = [this+0x58]; if (idx >= (last-first)>>2) return 0; return first[idx];` -- a bounds check against the players vector's size followed by an index off _Myfirst, which is a third independent confirmation that S+0x54/S+0x58 are _Myfirst/_Mylast. THE PLAYER VECTOR IS NOT THE LOBBY'S PLAYER LIST: it is #empires + one rebel-AI per distinct empire species + 4 NPC pseudo-players (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony, all Species 4). Hence NumPlrs 8 on the Human saves (two species) and 7 on the Zuul ones (one species), against a lobby that says '2 Players' in both -- Summary.Players counts EMPIRE SLOTS and is also right", + "status": "verified", + "source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08; counts cross-checked against Sim.PlayerIDs in seven saves and against the live trace)" + }, + { + "name": "sizeof_Game_ServerPlayer", + "offset": "0x000003e0", + "convention": "offset", + "prototype": "sizeof(Game::ServerPlayer) = 0x3e0, from the two `push 0x3e0` + operator new sites that precede the ctor call: 0x007865b3 (the bare factory reached through the class-registry word at 0x00a26078) and 0x0078a2f5 (the save loader, which also sets +0x8 = S+4 and inserts into the entity hash at S+0x84). The ctor itself is 0x008803d0 -- NOTE that findings/control-flow/turn-driver.md \u00a73 cites 0x00880474 as 'the ServerPlayer constructor', which is an address INSIDE it; the instruction there is `mov WORD [esi+0x3b4],0x100`, a 16-bit store, so it sets ResErrRoll(+0x3b4) = 0 and cta(+0x3b5) = 1, not '+0x3b4 = 1' as that note reads", + "status": "verified", + "source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)" } ] -} +} \ No newline at end of file