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.
116 lines
No EOL
13 KiB
JSON
116 lines
No EOL
13 KiB
JSON
{
|
|
"entries": [
|
|
{
|
|
"name": "StrategyServer_NodeLineDecay",
|
|
"addr": "0x007ae010",
|
|
"convention": "thiscall",
|
|
"prototype": "void (StrategyServer* this /*base S*/) // phase 11 of OnAllCombatDone_Tail, called at 0x007d9714 as `mov ecx,esi; call`. 1117 B, three loops. LOOP 1 (0x007ae07a..0x007ae1e2) walks the 0x30-stride Game::NodePath records in the vector at (*(S+0x154))+0x8/+0xc, re-reading _Myfirst/_Mylast every iteration, and per record: (1) NodePath::RemainingLife(r, S->Frame) 0x006e2130, `test eax,eax; jg` -> not expired, NEXT RECORD, NO DRAW; (2) THE DRAW, `mov ecx,[esi+0x16c]; fld [0x009e2ea0] /*0.5f*/; call 0x008e6dd0` = Mars::RNG::Chance(0.5f), exactly one MT word; (3) `test al,al; je` -> roll failed, next record; (4) the 0x20000-fleet scan over S->Fleets (S+0x64/+0x68) calling 0x00703500(fleet,0x20000,0) then 0x0078c360(fleet,npid) and dropping the record when that returns 3; (5) push_back npid into a scratch vector<int>. LOOP 2 collapses each collected line via 0x007a92e0(690 B) then 0x007a4700(2244 B); LOOP 3 posts the decay-stage events through NodePath::DecayStage 0x006e21b0. THE ONLY RNG SITE IN THE WHOLE 1117 BYTES: direct-call sweep to depth 5 over 140 functions from 0x007ae010 against {NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0, Twist 0x00426e00, Seed 0x0049fdf0} yields exactly one hit, 0x007ae010 -> 0x008e6dd0. Neither downstream function draws (138 and 49 functions reached, zero hits) -- caveat: direct calls only, their subtrees contain unresolved indirect sites",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08, loop 1 read from the instruction stream)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_NodeLineDecay_FleetSkipIsPostDraw",
|
|
"addr": "0x007ae0b2",
|
|
"convention": "site",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "NodePath_RemainingLife",
|
|
"addr": "0x006e2130",
|
|
"convention": "thiscall",
|
|
"prototype": "int (NodePath* this, int turn) // RET 4, whole 122-byte body read as instructions. THE EXPIRY PREDICATE for node-line decay. `if (npt(+0x4) == 0) return INT_MAX; if (npdtn(+0x1c) == INT_MAX) return INT_MAX; aged = (npctm(+0x14) >= 0 && turn >= npctm) ? turn - npctm : 0; wear = (npdtf(+0x20) != INT_MAX && npdtf > 0) ? nptf(+0x24) / npdtf : 0; /* SIGNED idiv, nptf is never sign-checked */ rem = npdtn - wear - aged; return rem > 0 ? rem : 0;`. Writes nothing -- the lifetime is DERIVED from a creation stamp and a traffic accumulator, never ticked, so there is no decrement-ordering question. Two never-expire escape hatches (npt == 0, npdtn == INT_MAX). A line is expired exactly when this returns 0",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08, whole body from the instruction stream)"
|
|
},
|
|
{
|
|
"name": "NodePath_DecayStage",
|
|
"addr": "0x006e21b0",
|
|
"convention": "thiscall",
|
|
"prototype": "int (NodePath* this, int turn) // 52 B. Wraps NodePath::RemainingLife and buckets it: <=2 -> 0, <=5 -> 1, <=10 -> 2, else 3. Called TWICE per record by loop 3 of node-line decay (once with turn-1, once with turn) to detect a stage transition and post the two decay-stage events. Draw-free -- but note that instrumenting node-line decay by counting RemainingLife CALLS rather than Chance calls over-counts badly because of this wrapper",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_ProcessNodeSpaceTravel",
|
|
"addr": "0x007a0e20",
|
|
"convention": "thiscall",
|
|
"prototype": "void (StrategyServer* this /*base S*/) // 2945 B. Called TWICE per turn: StrategyServer::ProcessTurn phase 7 (0x007dc93a) and OnAllCombatDone_Tail phase 10 (0x007d970b). Body unread; hooked by lane Z only to measure whether it advances the strategic generator, because a draw inside it would be double-counted by anyone who modelled it as running once",
|
|
"status": "mapped",
|
|
"source": "findings/control-flow/turn-driver.md phase 7 + combat-done-tail.md phase 10; call shapes instruction-verified by those lanes"
|
|
},
|
|
{
|
|
"name": "StrategyServer_off_NodeGraph",
|
|
"offset": "0x00000150",
|
|
"convention": "offset",
|
|
"prototype": "Game::ServerNodeGraph* -- the node-line graph. Stored frame (S+4), so it is S+0x154 in the frame OnAllCombatDone_Tail and ProcessTurn receive; node-line decay reads it as `mov eax,[esi+0x154]` at 0x007ae03a with esi = S. Dereferenced without a null check by the original",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
|
|
},
|
|
{
|
|
"name": "ServerNodeGraph_off_Paths",
|
|
"offset": "0x00000008",
|
|
"convention": "offset",
|
|
"prototype": "std::vector<Game::NodePath> (_Myfirst @+0x8, _Mylast @+0xc). Element stride 0x30, confirmed four ways: the reciprocal 0x2aaaaaab / sar 3 at four sites in node-line decay, `add [ebp-0x14],0x30` in its loop 1, `add [ebp-0x18],0x30` in its loop 3, and `add eax,0x30` in ServerNodeGraph::FindPathById 0x006e23d0. Loop 1 of node-line decay re-reads both words every iteration but writes neither, so an entry snapshot is a valid prediction basis",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
|
|
},
|
|
{
|
|
"name": "ServerNodeGraph_FindPathById",
|
|
"addr": "0x006e23d0",
|
|
"convention": "thiscall",
|
|
"prototype": "NodePath* (ServerNodeGraph* this, int npid) // 59 B, linear scan of the 0x30-stride paths vector comparing npid(+0x8). Node-line decay collects npid HANDLES rather than record pointers in loop 1 and re-resolves them here in loop 2, which is how the original hedges against the collapse functions mutating the vector under it",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
|
|
},
|
|
{
|
|
"name": "StarFleet_HasFlagShips",
|
|
"addr": "0x00703500",
|
|
"convention": "thiscall",
|
|
"prototype": "bool (StarFleet* this, uint maskA, uint maskB) // RET 8, a 29-byte thunk onto 0x00702d70(this, maskA, maskB, out = 0). Returns count > 0 where count is the number of ships in the fleet's NShips vector (+0xa4/+0xa8) passing the two-mask ship-flag predicate 0x00814da0, itself gated on (fleet->+0xb8 & maskA) == maskA. Called from node-line decay's post-draw fleet scan with maskA = 0x20000",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
|
|
},
|
|
{
|
|
"name": "StarFleet_PathRelation",
|
|
"addr": "0x0078c360",
|
|
"convention": "cdecl",
|
|
"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)"
|
|
}
|
|
]
|
|
} |