sots-re/ghidra/addresses.d/lane-z.json
alex 3d5f83414a Z: the dominant RNG consumer is trade-raid generation, behind a virtual call
FUN_00893290 is ServerTradeManager::GenerateTradeRaidEncounters --
ServerTradeManagerImpl vftable slot 10 -- rolling Chance(TRADE_RAID_ODDS_PLAYER
= 0.2) and Chance(TRADE_RAID_ODDS_NPC = 0.05) once per player. Both are
strictly inside (0,1) so each is exactly one word, and no back-edge contains
either site, so one word per player per site is a hard bound.

Why no sweep found it: zero direct calls to it exist in the image and its only
reference is a vtable slot. The dispatch is a "call edx" through slot 10 at
0x007d8469 inside DetectEncounters -- one instruction before the DIRECT call
that lane I's closure did follow. Lane I's inventory is not wrong; its stated
caveat about indirect edges was load-bearing, and this is what it was hiding.

strategic-turn-internals.md line 153 had already named 0x00893290 "raid
encounter generation" against these exact StrategyVars. What was missing was
that it is where a turn's RNG goes.

Ghidra's size is wrong again: real body 1546 bytes ending 0x0089389a, reported
1532, ending mid-instruction. Rule 17, third time.
2026-09-08 10:57:12 -04:00

148 lines
No EOL
18 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)"
},
{
"name": "ServerTradeManager_GenerateTradeRaidEncounters",
"addr": "0x00893290",
"convention": "thiscall",
"prototype": "void (ServerTradeManagerImpl* this, std::vector<TeamRecord>* records) // RET 4. THE DOMINANT STRATEGIC-RNG CONSUMER OF A TURN: 16 of every turn's 18-20 words on the reference save, measured live. `this+4` is the RAW StrategyServer base (GetServer 0x0080eb50 returns it minus 4). Loops over StrategyServer::Players (RAW+0x50/+0x54, 4-byte stride) -- 8 entries on the Human saves -- and per player rolls up to THREE Mars::RNG::Chance calls on the strategic generator at S+0x16c, each exactly one MT word because all three probabilities are strictly inside (0,1): +0x196 (0x00893426) Chance(TRADE_RAID_ODDS_PLAYER, image default 0.2f) -> kind 0; +0x283 (0x00893513) Chance(TRADE_RAID_ODDS_NPC, 0.05f) gated on 0.0f < S->+0x1a0, which is PLAYER-INDEPENDENT so the site is all-or-nothing per turn -> kind 1; +0x33e (0x008935ce) Chance(TRADE_RAID_ODDS_REFUGEE, 0.05f) gated on a subsystem manager being present -> kind 2. NO BACK-EDGE CONTAINS ANY OF THE THREE SITES, so the cost is a hard bound of one word per player per site. Two per-player skip gates exist (a visitedMask bit test at 0x00893302 and a `>2` pre-filter at 0x008933e0) and neither fired on ref-turn2. A SUCCESSFUL roll calls vslot 17 (ServerTradeManager_CreateRaidEncounter), which draws 0 or 1 FURTHER word. The record vector is the 0x74-stride TeamRecord vector at StrategyServer+0x1e8 -- the same one lane I's EncounterDetect_Run receives one instruction later. REAL SIZE 0x60a = 1546 BYTES, ending 0x0089389a; Ghidra reports 1532 and its end lands mid-instruction (method rule 17). Already named 'raid encounter generation' by findings/subsystems/strategic-turn-internals.md line 153 with these exact StrategyVars -- what was new is that it is where a turn's RNG goes",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711 (lane Z 2026-09-08; per-call-site measurement live, body and vtable read from the instruction stream)"
},
{
"name": "StrategyServer_DetectEncounters_TradeRaidVCall",
"addr": "0x007d8469",
"convention": "site",
"prototype": "site in StrategyServer::DetectEncounters 0x007d7f70, and THE CONCRETE FALSIFIER FOR 'not in the direct-call closure'. `mov ecx,[esi+0x158]; mov eax,[ecx]; mov edx,[eax+0x28]; push edi; call edx` -- a VIRTUAL dispatch through ServerTradeManagerImpl vftable slot 10 to ServerTradeManager_GenerateTradeRaidEncounters, passing the same TeamRecord vector that the DIRECT call at 0x007d8470 (lane I's EncounterDetect_Run) receives one instruction later. There are ZERO direct `call rel32` targets equal to 0x00893290 in the whole image, and exactly one dword 0x00893290 in .rdata, at 0x00a31b9c = vftable 0x00a31b74 + 0x28. The interface vftable Game::ServerTradeManager 0x00a311a4 has purecall in that slot, so dispatch is the only way in. Lane I's 22-site inventory of ProcessTurn's closure follows E8/E9 rel32 only and says so; this edge is `call edx`, so THE LARGEST SINGLE RNG CONSUMER OF A TURN HANGS OFF A VIRTUAL EDGE INSIDE A FUNCTION THE CLOSURE ALREADY CONTAINS",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
},
{
"name": "ServerTradeManager_CreateRaidEncounter",
"addr": "0x008938a0",
"convention": "thiscall",
"prototype": "bool (ServerTradeManagerImpl* this, TeamRecord* out, ServerPlayer* p, int kind, std::vector<TeamRecord>* records) // ServerTradeManagerImpl vftable 0x00a31b74 slot 17, called from GenerateTradeRaidEncounters at 0x0089345e / 0x00893548 / 0x00893603 on each successful Chance roll. Real body 0x008938a0..0x00893af9. Draws `Mars::RNG::NextInt(&S->rng.mt, cands.size()-1)` at 0x008939ee to pick a raid target -- ONE FURTHER MT WORD -- but returns false at 0x0089391c WITHOUT DRAWING when the candidate vector from 0x0083b110 is empty. So a successful raid roll costs 0 or 1 extra word. It drew 0 on ref-turn2 turns 3-5, which is consistent with no roll succeeding (P ~= 0.8^8 * 0.95^8 ~= 11% on the image defaults) OR with an empty candidate list every time; the two are not distinguishable from a word count and this is the cheapest remaining experiment on this path",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
},
{
"name": "ServerTradeManagerImpl_vftable",
"addr": "0x00a31b74",
"convention": "note",
"prototype": "Game::ServerTradeManagerImpl offset-0 vftable, 22 slots, bases ServerTradeManagerImpl -> ServerTradeManager -> TradeManager -> Mars::IStreamable. SLOT 10 (+0x28, at 0x00a31b9c) = ServerTradeManager_GenerateTradeRaidEncounters 0x00893290; SLOT 17 = ServerTradeManager_CreateRaidEncounter 0x008938a0. The interface vftable Game::ServerTradeManager 0x00a311a4 has purecall in all 21 non-destructor slots. The instance is constructed by 0x00858f70 (writes vptrs 0x00a31b74 / 0x00a31b64) from 0x007d7d7b and 0x007dd1fd, and stored at StrategyServer+0x158 in the S frame (+0x154 in the stored frame, which addresses.json already calls StrategyServer_off_TradeManager). Lane K's combat-done-tail.md \u00a79 tier 4 calls the eight end-of-turn vtable calls on S+0x158 'the largest blind spot in the map' -- this closes two of that class's slots",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
}
]
}