sots-re/ghidra/addresses.d/lane-t.json
alex 834eb09a19 T: read ServerPlayer::ProcessTurn and StrategyServer::ProcessTurn from the instruction stream
Phase map for both turn drivers, byte-for-byte from objdump rather than the
decompiler. Corrections that matter:

- the end-of-turn tail is UNCONDITIONAL. turn-spine.md 2.4 step 11 said it is
  deferred when encounters are pending; that came from reading an inlined
  std::vector destructor as a branch (both arms converge at 0x007dcb38). There
  is no branch on the encounter snapshot in the function, and 0x00794ad0 does
  not build an encounter list -- it builds the 15 ship-action type ids.
- ServerPlayer::ProcessTurn takes one float argument and never reads it.
- the ResearchRollPending site runs AFTER ProcessResearch, not before, and
  clears the flag only when the roll actually fires. Threshold is a strict
  0.5f < progress/Cost. Both consumers located by a full displacement scan.
- EVENT_NO_RESEARCH's gate is 'completed nothing this turn', not 'nothing
  available' -- 0x00584e50 collects RESEARCHED techs, not available ones.
- a fired research roll costs one or two RNG words, not one: the plague branch
  draws a second NextInt and posts EVENT_PLAGUE_OUTBREAK.
- two StrategyServer bases four bytes apart, and two per-turn counters.
- new: the research refund at 0x008914aa, previously unmodelled.

45 addresses in ghidra/addresses.d/lane-t.json (669 -> 722 entries, no dupes).
2026-09-08 08:08:36 -04:00

428 lines
No EOL
31 KiB
JSON

{
"entries": [
{
"name": "ServerPlayer_ProcessTurn",
"addr": "0x00891340",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72 (lane T 2026-09-08, whole function disassembled 0x00891340-0x00891783)"
},
{
"name": "ServerPlayer_ProcessTurn_ResearchCall",
"addr": "0x00891461",
"convention": "site",
"prototype": "site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_ProcessTurn_ResearchRefund",
"addr": "0x008914aa",
"convention": "site",
"prototype": "site in ServerPlayer::ProcessTurn, immediately after ProcessResearch returns: `if (overBudget > 0 && budget[17] > 0) Sav = SatAdd(Sav, ftol( (double)min(overBudget, budget[17]) / budget[17] * budget[16] ))`. budget[17] = researchPoints, budget[16] = researchMoney. Unspent research points are refunded to savings at the turn's own points-per-credit rate. PREVIOUSLY UNMODELLED - it is not in strategic-turn-internals.md \u00a71.2. The min is selected by `cmp ecx,eax; lea eax,[ebp-0x18]; jl; lea eax,[ebp-0x10]`, then `fild; fidiv budget[17]; fimul budget[16]; call _ftol2`",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x008914aa-0x008914f5)"
},
{
"name": "ServerPlayer_ProcessTurn_ResearchRollBlock",
"addr": "0x008915ec",
"convention": "site",
"prototype": "site in ServerPlayer::ProcessTurn, AFTER ProcessResearch (ProcessTurn+0x2ac vs +0x165): `if (ResT(+0x294) && ResErrRoll(+0x3b4) && 0.5f < TechTree::ResearchProgressRatio(tree, ResT)) { RollResearchEvent(this); ResErrRoll = false; }`. THE CLEAR IS INSIDE THE BRANCH - when the ratio test fails the flag is left set, which is how it survives to the next turn. The threshold is a strict `<` (MSVC `fld 0.5f; fcomp ratio; fnstsw; test ah,5; jp skip`), constant 0x00a2c788 = 0.5f. This is the site campaign/board.md describes as running BEFORE ProcessResearch; it runs after",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, 0x008915ec-0x00891629)"
},
{
"name": "ServerPlayer_SetResearchRollPending",
"addr": "0x0080e300",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this) // `mov byte [ecx+0x3b4], 1; ret` - the only ARMING write of ResErrRoll in the image other than the constructor. Referenced ONLY from the vtable word at 0x00a327bc (slot 6 of the 0x00a327a4 ServerPlayer vptr), so every caller is an indirect `call [vft+0x18]`. find-vtable-callers returns no genuine caller: all 18 hits are other classes' slot at byte offset 0x70 and pass arguments. WHO RE-ARMS THE FLAG DURING PLAY IS UNRESOLVED",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, tools/x86disp.py query 0x3b4 + ReVa find-cross-references)"
},
{
"name": "ServerPlayer_ClearResearchRollPending",
"addr": "0x0080e310",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this) // `mov byte [ecx+0x3b4], 0; ret`. Vtable word 0x00a327c0, the slot after SetResearchRollPending",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_ctor_ResearchRollPendingInit",
"addr": "0x00880474",
"convention": "site",
"prototype": "site in the ServerPlayer constructor: `mov WORD PTR [esi+0x3b4], 1` - a two-byte store that sets ResErrRoll(+0x3b4) = 1 and +0x3b5 = 0. THE FLAG IS BORN TRUE. Consistent with (but not proof of) the hypothesis that ResErrRoll is armed once at construction and consumed once, never re-armed except through the vtable pair",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, tools/x86disp.py query 0x3b4)"
},
{
"name": "TechTree_ResearchProgressRatio",
"addr": "0x0057e950",
"convention": "thiscall",
"prototype": "float (TechTree* this, TechDef* def) // RET 4, result in st(0). node = this->nodes(+0x10)[def->id(+0x0)]; if (!node) return 0.0f; return (float)((double)node->progress(+0x1c) / (double)TechTree::Cost(node)). Whole 59-byte body read. The divide is done in double and narrowed once on the fstp. Called from exactly one place: the ResearchRollPending block in ServerPlayer::ProcessTurn",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08)"
},
{
"name": "TechTree_FindFirstAvailableTech",
"addr": "0x0057da90",
"convention": "thiscall",
"prototype": "TechDef* (TechTree* this) // plain RET. Walks this->nodes(+0x10/+0x14) in index order; for each non-null node resolves node->def(+0x0) and re-indexes nodes by that def's id (the same self-resolving idiom lane E corrected in ProcessResearch), and returns the FIRST def whose node state(+0x14) == 2 (available). NULL when none. Used only as the second half of the EVENT_NO_RESEARCH test",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a74 (lane T 2026-09-08, whole 68-byte body read)"
},
{
"name": "TechTree_CollectResearchedTechs",
"addr": "0x00584e50",
"convention": "thiscall",
"prototype": "void (TechTree* this, std::vector<ResearchedTechRecord>* out, int minTurn, int maxTurn, bool sort) // RET 0x10. Clears *out, walks this->master(+0x4)->defs(+0x24/+0x28); SKIPS any def whose name (std::string at def+0x4) tail from its first '_' matches \"_Root\" case-insensitively; requires nodes[def->id]->state(+0x14) == 4 (researched) and minTurn <= node->turnResearched(+0x24) <= maxTurn; pushes {TechDef*, turnResearched, node->flag(+0x2c)} (12-byte record). When `sort` is true it finally calls 0x00582cd0(first, last, count, this). ServerPlayer::ProcessTurn calls it with (minTurn = server ModCount, maxTurn = INT_MAX, sort = true), i.e. \"techs this player completed on THIS turn\". strategic-turn-internals.md \u00a71.2 step 9 described it as a list of AVAILABLE techs; it is the opposite",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a74 (lane T 2026-09-08, whole 290-byte body read)"
},
{
"name": "ResearchedTechRecord_stride",
"offset": "0xc",
"convention": "field",
"prototype": "sizeof(ResearchedTechRecord) = { TechDef* def; int turnResearched; int flag } - the element TechTree::CollectResearchedTechs pushes. Stride confirmed by the vector-clear loop at 0x00584e75 (three dword copies, `add eax,0xc`)",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a74 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_SatAdd",
"addr": "0x00817990",
"convention": "cdecl",
"prototype": "int (int a, int b) // saturating add. Overflow-checked: b>0 and a+b<=a -> +2000000000 (0x77359400); b<0 and a+b>=a -> -2000000000 (0x88ca6c00); otherwise clamp(a+b, -2000000000, +2000000000). Whole 94-byte body read. Used for every write to ServerPlayer::Sav in ProcessTurn (the net apply and the research refund)",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_ConstructionSpend",
"addr": "0x00817f90",
"convention": "cdecl",
"prototype": "int (ServerPlayer* p, int available) // the AI branch of ProcessTurn phase 4: a human player uses budget[11], which ComputeBudget fills only when p->IsAI(+0xf9)==0, and an AI recomputes it here. Body not read by lane T; the call shape (two pushes, `add esp,8`) is verified",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, call site 0x0089144e)"
},
{
"name": "ServerPlayer_ProcessSpecialProjects",
"addr": "0x00840fe0",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this, int constructionSpend, int available) // TWO stack arguments, pushed as (edi = budget[15] available) then (eax = construction spend), so the callee sees (spend, available). turn-spine.md called this \"income/savings\"; strategic-turn-internals.md \u00a71.1 already corrected it to special projects. Body not read by lane T",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, call site 0x0089145c)"
},
{
"name": "ServerPlayer_PruneRaidTargets",
"addr": "0x00863cf0",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this) // the unconditional tail of ServerPlayer::ProcessTurn. Sweeps the vector<RaidTargets> at +0x338/+0x33c (0x20 stride) forward, erasing an element when: its inner range rec[0xc]==rec[0x10] (empty); or ModCount - rec[0x1c] > 20; or the entity lookup 0x008b9240(server EntityHash, rec[0x4]) fails; or the resolved object's +0x90 mask lacks bit (1 << this->PlyrIdx(+0x28)). Erase shifts the tail down 0x20 and calls the trailing element's vft[0] scalar destructor. Whole 239-byte body read",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "ServerTradeManager_ProcessTurn",
"addr": "0x0086b300",
"convention": "thiscall",
"prototype": "void (ServerTradeManager* this) // StrategyServer::ProcessTurn phase 2, called on StrategyServer+0x158 (S frame) / +0x154 (raw frame). 1494 bytes. Freighter -> route allocation; the formulas are in strategic-turn-internals.md \u00a71.4 and have never been run against the game",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_RegisterTradeSystems",
"addr": "0x007adc80",
"convention": "thiscall",
"prototype": "void (StrategyServer* this) // StrategyServer::ProcessTurn phase 3, 192 bytes. Every owned system whose owner has CnTrd registers NumTradeRoutesSupported routes",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_UnlockMetSpeciesTechs",
"addr": "0x0078aa70",
"convention": "thiscall",
"prototype": "void (StrategyServer* this) // StrategyServer::ProcessTurn phase 28, 185 bytes. For every player, for every species index 0..6 except 4 and the player's own: if the species is known (0x0080de60) and SpeciesDef::Get(sp)->+0x78 is a tech id != 0xc5 whose def resolves and is not already researched, make it visible via 0x00586200(def, 0). The \"you have met this race, its racial tech appears in your tree\" rule. Draw-free, event-free, three callees",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71, \u00a75 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_ProcessTurn_SystemLoop",
"addr": "0x007dc9c8",
"convention": "site",
"prototype": "site in StrategyServer::ProcessTurn: the per-system `ServerSystem::ProcessTurn` loop. `mov ecx,[Systems._Myfirst + i*4]; call 0x007598e0` - NO stack arguments and no returned value, confirming B4's correction that the decompile's `void* stream` parameter is a Ghidra guess. Loop bound recomputed every iteration as `(Systems._Mylast - _Myfirst) >> 2`",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_ProcessTurn_PlayerLoop",
"addr": "0x007dca00",
"convention": "site",
"prototype": "site in StrategyServer::ProcessTurn: the per-player `ServerPlayer::ProcessTurn` loop. `fld DWORD [ebp+8]; mov ecx,player; push ecx; fstp DWORD [esp]; call 0x00891340` - the driver's own float dt is forwarded as the callee's single stack argument (the `push ecx` only reserves the slot). Loop bound recomputed every iteration as `(Players._Mylast - _Myfirst) >> 2`",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_ProcessTurn_EndOfTurnTail",
"addr": "0x007dcb38",
"convention": "site",
"prototype": "site in StrategyServer::ProcessTurn: the first instruction of the end-of-turn tail (ProcessAid, ProcessSpecialProjects, ProcessSurrenders, the per-player 0x00818530 sweep, the two SVScriptObject hook pairs, 0x0086a8d0, 0x0078ab30, 0x00799380, UnlockMetSpeciesTechs, the per-system 0x00743ec0 sweep, 0x007b4c00 and the team/Status sweep). IT IS UNCONDITIONAL. turn-spine.md \u00a72.4 step 11 says the tail is deferred when encounters are pending; that came from reading the INLINED std::vector DESTRUCTOR at 0x007dcb15 as a branch - `je 0x7dcb38` skips only the `operator delete`, and both arms converge here. There is no branch on the encounter snapshot anywhere in this function",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71.1 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_base_delta",
"offset": "0x4",
"convention": "field",
"prototype": "THE TWO BASES, stated once. Let S = the `this` StrategyServer::ProcessTurn receives, which is also what ServerPlayer::GetServer (0x0080e320) returns. Then ServerPlayer+0x8 holds S+4, and EVERY `StrategyServer_off_*` entry in addresses.json except `StrategyServer_off_RNG` is expressed in the S+4 frame (Players 0x50 = S+0x54, Fleets 0x60 = S+0x64, EntityHash 0x80 = S+0x84, RNGPtr 0x168 = S+0x16c). `StrategyServer_off_RNG = 0x16c` is the same word in the S frame. Convert S-frame -> stored frame by subtracting 4. Reading the wrong base yields an empty Players vector and zero declared regions",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a70 (lane T 2026-09-08, three independent sequences: 0x0089147f, 0x007dc871, 0x00863d2a)"
},
{
"name": "StrategyServer_off_PhaseCounter",
"offset": "0x4",
"convention": "field",
"prototype": "int, S+4 frame (== S+0x8). Incremented by the FIRST instruction of StrategyServer::ProcessTurn (`inc [esi+8]` @0x007dc6f0). This is a SECOND per-turn counter, distinct from StrategyServer_off_ModCount (0x8 in this frame == S+0xc), which BeginProcessTurn increments and which the research code stamps and reads. Both advance once per turn in different functions. Nobody has named this one",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a70.1 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_off_Systems",
"offset": "0x40",
"convention": "field",
"prototype": "std::vector<ServerSystem*> (begin @+0x40, end @+0x44) in the S+4 frame, i.e. S+0x44/S+0x48. Iterated three times per turn: the morale/abandon pre-pass, the ServerSystem::ProcessTurn loop, and the tail's 0x00743ec0 sweep",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_off_TradeManager",
"offset": "0x154",
"convention": "field",
"prototype": "ServerTradeManager* in the S+4 frame (S+0x158). The `this` for ServerTradeManager::ProcessTurn, phase 2 of the turn",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_off_ScriptObject",
"offset": "0x1b0",
"convention": "field",
"prototype": "SVScriptObject* in the S+4 frame (S+0x1b4), null in a normal game. The end-of-turn tail calls two pairs on it when non-null: vft[0x10](6, 0) then vft[0x2c](), and later vft[0x10](0x1c, 0) then vft[0x78]()",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_off_TeamRecords",
"offset": "0x1e4",
"convention": "field",
"prototype": "std::vector<TeamRecord> in the S+4 frame (S+0x1e8), element stride 0x74. Rebuilt each turn by 0x007d7f70(this, &teamRecords) and then swept: for every member of every record, if (player->IsAI(+0xf9)==0 || player->+0xfa != 0) player->Status(+0x164) = 1. Strides verified from the signed-division magics: 0x8d3dcb09 with `sar 6` is /0x74, 0x78787879 with `sar 5` is /0x44",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "TeamRecord_stride",
"offset": "0x74",
"convention": "field",
"prototype": "sizeof(TeamRecord), the element of StrategyServer's team vector at +0x1e4 (S+4 frame)",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "TeamRecord_off_Members",
"offset": "0x28",
"convention": "field",
"prototype": "std::vector<TeamMember> inside a TeamRecord (begin @+0x28, end @+0x2c), element stride 0x44, first word of each element a ServerPlayer*",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "TeamMember_stride",
"offset": "0x44",
"convention": "field",
"prototype": "sizeof(TeamMember). Only its first word (ServerPlayer*) is read by the turn's Status sweep; the other 0x40 bytes are unread by that pass",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_off_TurnRecord",
"offset": "0x3d8",
"convention": "field",
"prototype": "void* to a per-player per-turn record. StrategyServer::ProcessTurn's player pre-pass writes rec->+0x8 = (1 << playerSlot) and ORs in the player's alliance mask AL(+0x16c) when ALid(+0x168) != -1; ServerPlayer::ProcessTurn then writes rec->+0x10 = budget[2] (trade income). NOTE the bit index is the player's POSITION in the server's Players vector, not PlyrIdx",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71, \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_off_ResearchAidGiven",
"offset": "0xc8",
"convention": "field",
"prototype": "int, written by ServerPlayer::ProcessTurn from budget[19] (research points given away as aid). Not serialized. strategic-turn-internals.md \u00a71.2 step 2 calls this a per-turn accumulator that is ZEROED; it is ASSIGNED",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x00891439)"
},
{
"name": "ServerPlayer_off_SavingsAidGiven",
"offset": "0xcc",
"convention": "field",
"prototype": "int, written by ServerPlayer::ProcessTurn from budget[14] (savings given away as aid). Not serialized",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x0089142d)"
},
{
"name": "ServerPlayer_off_RebOutMod",
"offset": "0x128",
"convention": "field",
"prototype": "float RebOutMod. When RebAI(+0xfc) is set, ServerPlayer::ProcessTurn does RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f) each turn. The three constants are image floats: 0x00a17870 = 0.04f, 0x00a17868 = 1.0f, 0x00a1786c = 2.0f",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_off_Status",
"offset": "0x164",
"convention": "field",
"prototype": "int Status (serialized). 1 = playing, 4 = turn done (SNMSetPlayerStatus). Set back to 1 by the end-of-turn team sweep for every member with (IsAI==0 || +0xfa != 0)",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08); name from findings/objects/struct-recovery.md"
},
{
"name": "ServerPlayer_off_AllianceId",
"offset": "0x168",
"convention": "field",
"prototype": "int ALid, the first word of the PlayerAlliances block {ALid, AL, NA, CF} at +0x168. -1 means no alliance; the turn's per-player pre-pass tests it before OR-ing AL into the turn record's visibility mask",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_off_AllianceMask",
"offset": "0x16c",
"convention": "field",
"prototype": "int AL, the alliance member bitmask at PlayerAlliances+4. OR-ed into (player->+0x3d8)->+0x8 once per turn when ALid != -1",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_off_RaidTargets",
"offset": "0x338",
"convention": "field",
"prototype": "std::vector<RaidTarget> (begin @+0x338, end @+0x33c), element stride 0x20, save tags `rdtc` + n x `rdt`. Pruned every turn by ServerPlayer_PruneRaidTargets. Fields the prune reads: +0x4 entity id, +0xc/+0x10 an inner range, +0x1c the turn the record was created",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08); stride from findings/objects/struct-recovery.md"
},
{
"name": "RaidTarget_stride",
"offset": "0x20",
"convention": "field",
"prototype": "sizeof(RaidTarget), the element of ServerPlayer+0x338. Confirmed by the prune's `add edi,0x20` step and its `sar ecx,5` count",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_off_TimedResearchBonuses",
"offset": "0x3a4",
"convention": "field",
"prototype": "std::vector<TimedResearchBonus> (begin @+0x3a4, end @+0x3a8), element {float PRm; int PRBt}, save tags `NumPR` + n x (`PRm`,`PRBt`). Swept once per turn by ServerPlayer::ProcessTurn: `for (i = count-1; i >= 0; --i) { TRM += e[i].PRm; if (--e[i].PRBt <= 0) erase(i); }`. THE ITERATION IS DESCENDING - float addition is not associative, so the order is load-bearing for bit-exactness",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x0089157a-0x008915ea)"
},
{
"name": "TimedResearchBonus_stride",
"offset": "0x8",
"convention": "field",
"prototype": "sizeof(TimedResearchBonus) = { float PRm; int PRBt }. Confirmed by `sar edi,3` on the byte count and `[ecx+edi*8]` addressing",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "ServerPlayer_OnResearchRollSucceeded",
"addr": "0x00889d60",
"convention": "thiscall",
"prototype": "bool (ServerPlayer* this) // the branch RollResearchEvent takes when odds > roll -- the one the campaign has never observed firing. Splits on the CURRENT RESEARCH TARGET: (a) ResT is one of five hard-coded plague-cure TechDefs (ids 0x273c..0x2740, resolved by 0x00535480) -> 0x00889bb0, which draws ONE FURTHER Mars::RNG::NextInt to pick a random element of this->OwnId (+0x30/+0x34) and posts EVENT_PLAGUE_OUTBREAK naming it; (b) ResT is in the aggression tech table (0x00690f70) AND this->+0x3b5 is set -> 0x008206a0, which allocates a Game::AIRebellionImpl into +0x3b8 if absent and then CANCELS the current research (progress reset 0x0057e7c0, state back to 2 via 0x0057e8d0, vtable hook, ResT = NULL), with no further RNG; (c) otherwise nothing. SO A FIRED ROLL COSTS 1 OR 2 RNG WORDS, not 1 -- every existing coverage note says 'exactly one NextFloat', which is the cost of REACHING here",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a73.1 (lane T 2026-09-08, ReVa decompilation of 0x00889d60/0x00889bb0/0x008206a0; RollResearchEvent itself read from the instruction stream)"
},
{
"name": "ServerPlayer_off_RebellionRollArmed",
"offset": "0x3b5",
"convention": "field",
"prototype": "bool `cta` -- the AI-rebellion gate of the succeeded-roll branch (0x00889d60 path b). The ServerPlayer constructor writes it in the SAME two-byte store that arms ResErrRoll: `mov WORD [esi+0x3b4], 1` sets +0x3b4 = 1 and +0x3b5 = 0. The two research-roll flags are adjacent and initialised together",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a73, \u00a73.1 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_ProcessShipActions",
"addr": "0x007b9b90",
"convention": "thiscall",
"prototype": "void (StrategyServer* this, std::vector<int>* actionTypes, bool force) // the ship-action dispatcher, run THREE TIMES per turn with different type sets -- instruction-verified at the three call sites 0x007dc92b, 0x007dc952, 0x007dcb0c. Walks the queued-ship-action list at S+0x78/0x7c and dispatches each action whose type is in `actionTypes` through a 12-entry function-pointer table indexed by type (targets include 0x00789500, the ship-borne BuildQueue::ProcessTurn wrapper); with `force` it validates (0x0083cbb0) and cancels (0x00849280) instead. Sets: {0..14} minus {2} before movement, {2} after movement, {0..14} with force at the end. Type 2 is the action that requires the fleet to have arrived",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a71.2 (lane T 2026-09-08; call shape verified from the instruction stream, body from ReVa)"
},
{
"name": "StrategyServer_BuildShipActionTypeList",
"addr": "0x00794ad0",
"convention": "cdecl",
"prototype": "std::vector<int>* (std::vector<int>* out) // fills *out with the fifteen ship-action type ids 0..14 and returns it. IT IS NOT AN ENCOUNTER SNAPSHOT -- turn-spine.md \u00a72.4 steps 4 and 11 read it as one, which is where the 'the tail is deferred when encounters exist' error came from. Encounter detection is 0x007d7f70 at the very end of the turn",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a71.1, \u00a71.2 (lane T 2026-09-08)"
},
{
"name": "StrategyServer_SnapshotPreviousTurn",
"addr": "0x0081b390",
"convention": "thiscall",
"prototype": "void (StrategyServer_secondBase* this) // phase 0, called on S+4. Per system copies current words into shadow words (+0x20c/0x210/0x214 <- +0x68/0x6c/0x70, +0x150.. <- +0x120.., +0xc9 <- +0xc8) via 0x00753530; per player +0x188 <- Sav(+0x284) and +0x18c <- +0x138. Also called from BuildTurnEvents, so it is the baseline 'changed since last turn' is diffed against. Draw-free, event-free",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a75 (lane T 2026-09-08, ReVa)"
},
{
"name": "StrategyServer_UpdateSensors",
"addr": "0x0086a8d0",
"convention": "cdecl",
"prototype": "void (StrategyServer* server) // the end-of-turn fog-of-war update, timed with QueryPerformanceCounter and logged as \"Sensors: %f seconds to update sensors for all players.\". Wraps 0x0086a6d0: per player x per system (predicate 0x00850cf0) and per player x per fleet (predicate 0x0081e630), packing 2-bit visibility results into system+0x24 and fleet+0x54 for up to 15 players. Deterministic, no events",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a75 (lane T 2026-09-08, ReVa)"
},
{
"name": "StrategyServer_StepAIRebellion",
"addr": "0x00818530",
"convention": "thiscall",
"prototype": "void (ServerPlayer* this, bool flag) // no-ops unless this->AIRebellion(+0x3b8) is non-null; steps it (0x006d1fd0(flag)), and if it signals completion (0x00690ff0) calls its vft[0](1) and nulls +0x3b8. Called per player with flag=0 from StrategyServer::ProcessTurn's tail and with flag=1 from the deferred combat tail",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a75 (lane T 2026-09-08, ReVa)"
},
{
"name": "StrategyServer_DetectEncounters",
"addr": "0x007d7f70",
"convention": "thiscall",
"prototype": "void (StrategyServer* this, std::vector<TeamRecord>* out) // the LAST phase of the turn: per system with combatants present (0x0078cb10), builds the pairwise 0x74-byte team records for fleet pairs whose relation (0x0080e050) is war, into StrategyServer+0x1e4 (S+4 frame). The turn's Status-back-to-playing sweep then walks the records it produced",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md \u00a71, \u00a75 (lane T 2026-09-08, ReVa)"
},
{
"name": "g_flt_ResearchRollProgressThreshold",
"addr": "0x00a2c788",
"convention": "data",
"prototype": "const float = 0.5f. The ONLY consumer is the ResearchRollPending block in ServerPlayer::ProcessTurn: the roll fires when 0.5f < progress/Cost, strictly. Not a registered config key - it is an image literal",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, bytes read from the image)"
},
{
"name": "g_flt_RebOutModDecay",
"addr": "0x00a17870",
"convention": "data",
"prototype": "const float = 0.04f, subtracted from RebOutMod each turn for a RebAI player",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "g_flt_RebOutModMin",
"addr": "0x00a17868",
"convention": "data",
"prototype": "const float = 1.0f, the lower clamp of RebOutMod",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
},
{
"name": "g_flt_RebOutModMax",
"addr": "0x00a1786c",
"convention": "data",
"prototype": "const float = 2.0f, the upper clamp of RebOutMod",
"status": "verified",
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
}
]
}