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.
This commit is contained in:
alex 2026-09-08 10:04:21 -04:00
parent 888f318d8c
commit 9c37635ca3
3 changed files with 102 additions and 25 deletions

View file

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

View file

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

View file

@ -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)"
}
]
}
}