gen_addresses: detect same-address-different-name; 13 vocabulary forks resolved across 8 fragments
This commit is contained in:
parent
962cdb6981
commit
9075f60f7b
13 changed files with 97 additions and 202 deletions
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,7 @@
|
|||
"name": "StrategyApp_OnClientEvent",
|
||||
"addr": "0x00838e10",
|
||||
"convention": "cdecl",
|
||||
"prototype": "void __cdecl Game::StrategyApp::OnClientEvent(int netId, int eventId, void* ev) -- THE AI ENQUEUE SITE (lane AI2 §6.1 open item, closed). Operates on the STATIC StrategyApp at 0x00b29f98 (not a pointer -- `mov ecx,0xb29f98` at 0x007843a2 proves the object itself lives there). Body: (1) `if (*(void**)0x00b29f9c == 0) return` -- app+0x4, the StrategyServer; (2) `if (netId == 0) return`; (3) linear search of the client vector at app+0xc/+0x10 (absolutes 0x00b29fa4/0x00b29fa8) for `client->+0x148 == netId`; not found -> return; (4) `p = client->+0x150`; (5) `if (eventId == 0x26 (SEResumePlaying) && p->+0xf9 != 0 && p->+0xfa == 0)` then `if (!0x00438fe0(&pending, &netId)) 0x0059f1a0(&pending, &netId)` -- a DEDUPLICATED push_back onto the pending-AI vector at app+0x1c (absolute 0x00b29fb4), and RETURN; (6) otherwise `StrategyClient::RaiseEvent 0x00783ee0(client, eventId, ev)` inline. So event 0x26 for an AI player is the ONLY deferred event; everything else is delivered synchronously. Registered as StrategyServer+0x170 by CreateGame at 0x00889177; it has ZERO direct callers and no vtable slot (lane B6's third blind spot)",
|
||||
"prototype": "void __cdecl Game::StrategyApp::OnClientEvent(int netId, int eventId, void* ev) -- THE AI ENQUEUE SITE (lane AI2 \u00a76.1 open item, closed). Operates on the STATIC StrategyApp at 0x00b29f98 (not a pointer -- `mov ecx,0xb29f98` at 0x007843a2 proves the object itself lives there). Body: (1) `if (*(void**)0x00b29f9c == 0) return` -- app+0x4, the StrategyServer; (2) `if (netId == 0) return`; (3) linear search of the client vector at app+0xc/+0x10 (absolutes 0x00b29fa4/0x00b29fa8) for `client->+0x148 == netId`; not found -> return; (4) `p = client->+0x150`; (5) `if (eventId == 0x26 (SEResumePlaying) && p->+0xf9 != 0 && p->+0xfa == 0)` then `if (!0x00438fe0(&pending, &netId)) 0x0059f1a0(&pending, &netId)` -- a DEDUPLICATED push_back onto the pending-AI vector at app+0x1c (absolute 0x00b29fb4), and RETURN; (6) otherwise `StrategyClient::RaiseEvent 0x00783ee0(client, eventId, ev)` inline. So event 0x26 for an AI player is the ONLY deferred event; everything else is delivered synchronously. Registered as StrategyServer+0x170 by CreateGame at 0x00889177; it has ZERO direct callers and no vtable slot (lane B6's third blind spot)",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read of dumps/sots.exe swept to the next function start (rule 17); enqueue located by absolute-reference scan for 0x00b29fb4, which has exactly 2 references in the image, both here"
|
||||
},
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
"name": "StrategyAIAgent_IsClaimedByAnotherTask",
|
||||
"addr": "0x006a8d20",
|
||||
"convention": "thiscall",
|
||||
"prototype": "bool __thiscall Game::StrategyAIAgent::IsClaimedByAnotherTask(void* obj) -- RET 4. NAMES IAITask VTABLE SLOT 12 (lane AI2 §10.2). Looks `obj->+4` up in the 8-byte-stride claim registry at agent->+0x2e8..+0x2ec (pairs of {IAITask* owner, int objectId}) and in the 4-byte set at agent->+0x2d8..+0x2dc; if the object is in neither, returns false (free). Otherwise `cur = back(agent->+0x12c /*the task call stack*/)`; with an empty stack or a null top it returns TRUE (claimed). Then at 0x006a8db3: `if (!cur->vt[12]()) return true;` -- and when slot 12 IS set, it returns false (i.e. lets the task take the object) only when the owner exists, `cur->GetTypeId() != owner->GetTypeId()`, and `cur->GetPriority() > owner->GetPriority()`. So SLOT 12 IS A PREEMPTION PERMISSION: 'this task may take an object already claimed by a strictly lower-priority task of a different type'. Default false; five classes set it. Caller 0x006abb00 skips the candidate when this returns true",
|
||||
"prototype": "bool __thiscall Game::StrategyAIAgent::IsClaimedByAnotherTask(void* obj) -- RET 4. NAMES IAITask VTABLE SLOT 12 (lane AI2 \u00a710.2). Looks `obj->+4` up in the 8-byte-stride claim registry at agent->+0x2e8..+0x2ec (pairs of {IAITask* owner, int objectId}) and in the 4-byte set at agent->+0x2d8..+0x2dc; if the object is in neither, returns false (free). Otherwise `cur = back(agent->+0x12c /*the task call stack*/)`; with an empty stack or a null top it returns TRUE (claimed). Then at 0x006a8db3: `if (!cur->vt[12]()) return true;` -- and when slot 12 IS set, it returns false (i.e. lets the task take the object) only when the owner exists, `cur->GetTypeId() != owner->GetTypeId()`, and `cur->GetPriority() > owner->GetPriority()`. So SLOT 12 IS A PREEMPTION PERMISSION: 'this task may take an object already claimed by a strictly lower-priority task of a different type'. Default false; five classes set it. Caller 0x006abb00 skips the candidate when this returns true",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read"
|
||||
},
|
||||
|
|
@ -96,14 +96,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read; jump table and byte index read from the image at instruction boundaries"
|
||||
},
|
||||
{
|
||||
"name": "AITask_GetPriorityDefaultThunk",
|
||||
"addr": "0x00694220",
|
||||
"convention": "thiscall",
|
||||
"prototype": "int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08, instruction-stream read"
|
||||
},
|
||||
{
|
||||
"name": "g_AITInvadeUncommittedPriority",
|
||||
"addr": "0x00a1795c",
|
||||
|
|
@ -120,5 +112,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08; value read from the PE image, reference count from an exhaustive 4-byte absolute-reference scan"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "AITask_GetPriorityDefaultThunk dropped 2026-09-08: same address 0x00694220 as lane-ai2's AITask_slot10_GetPriority, reached independently (AI2 via the slot-10 priority read, AI3 via the default thunk). Agreement recorded; AI2's name kept because the merged game/ai code already uses that vocabulary. Caught by gen_addresses.py's new same-address check, not by hand."
|
||||
}
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
{
|
||||
"entries": [
|
||||
{
|
||||
"name": "BuildQueue_ProcessTurn_RealEnd",
|
||||
"addr": "0x00891240",
|
||||
"convention": "site",
|
||||
"prototype": "The byte AFTER Game::BuildQueue::ProcessTurn's last instruction (`ret 0x8` at 0x00891226, then int3 padding). Ghidra reports the function as 1230 bytes from 0x00890d50, i.e. ending at 0x0089121e -- INSIDE the epilogue, before the security cookie check. The body is 1264 bytes. Earned rule 17. Note that this address is ALSO the entry of SystemBuildQueue_AttachBuiltShip, the function's own slot-10 callee, which is why the fleet half of construction looked absent from the pass",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08, instruction stream 0x00890d50-0x00891240)"
|
||||
},
|
||||
{
|
||||
"name": "BuildQueue_off_Owner",
|
||||
"offset": "0xc",
|
||||
|
|
@ -168,5 +160,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "BuildQueue_ProcessTurn_RealEnd dropped 2026-09-08: it named 0x00891240, which is the SAME address as SystemBuildQueue_AttachBuiltShip in this same file - because the 'next function start' bounding ProcessTurn IS that function. A boundary marker and the function it bounds are not two facts. The real end of ProcessTurn is documented in findings/subsystems/ship-construction.md instead."
|
||||
}
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
{
|
||||
"entries": [
|
||||
{
|
||||
"name": "ServerSystem_GrowCivilianPops_G3",
|
||||
"addr": "0x00754220",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (ServerSystem* sys) // plain `ret`, REAL END 0x00754b59 -- the body is a loop over group types 0,1,2 whose back edge is at 0x00754b2e (`inc esi; cmp esi,3; jl 0x7543e9`) and lies outside every decompiler `if`, so the function reads as straight-line code if you stop at the first `ret`. Returns immediately when the system has no owner. Per group type: skip when Population::TotalOfType(Pop2, t) <= 0, then skip unless t == 1 -- so only CIVILIANS grow here. Per species: delta = PopGrowthDelta(1, sp); cur = Count(Pop2,1,sp) + Count(pbon2,1,sp); cap = MaxPopGeneric(1, sp, PID, NULL); soft = CivilianSettleLimit(sp); limit = soft < cap ? soft : cap (and soft < cap with cur+delta > soft raises a per-species settle flag); applied = min(delta, limit - cur); haltv[1] with applied > 0 zeroes both. The SYSTEM TOTAL is then clamped into [-50,000,000 (a literal here), POPTYPE[1]+0x08 = 20,000,000] and, when the clamp bit, every entry of the clamp's own sign is rescaled by trunc(applied x (clamped / total)) with NO renormalisation. Write-back is Population::SetCount(Pop2, 1, sp, Count(Pop2,1,sp) + applied) -- note the headroom counted pbon2 and the write does not. THE 20,000,000 CLAMP IS WHAT DECIDES THE VALUE on both reference pairs: the uncapped delta is 7.5x it and the capacity headroom 25x it",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerSystem_CivilianSettleLimit",
|
||||
"addr": "0x0074a9a0",
|
||||
|
|
@ -16,14 +8,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerSystem_MaxPopGeneric_G3",
|
||||
"addr": "0x0074a4a0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "int64 (ServerSystem* sys, int groupType, int species, ServerPlayer* p, float* suitOverride) // `ret 0x10`, REAL END 0x0074a6cd -- 0x0074a6d0 is a DIFFERENT function (lane N and E1 both cite 0x0074a6d0 for the capacity-surplus pair; that is the next one along, not this). Returns 0 when species == 4; when the OWNER species' SpeciesDef+0x168[groupType] is not > 0 (which is why Zuul have no civilians); when groupType != 0 and the owner is RebAI; when groupType == 1 and bit 3 of ServerPlayer+0x348[species] is clear; and when groupType == 2 and 0x0082bdf0(p, species) is false. Otherwise ftoi64( (int64)Size x 1e8 x [ hazard x (POPTYPE[t]+0x20 x SpeciesDef(sp)+0x168[t] x (crossSpecies ? SpeciesDef(sp)+0x174[t] : 1)) ] ) via 0x00535eb0, plus the arcology flat bonus 0x0080dd30(p, t), then clamped to the int64 at POPTYPE[t]+0x28 -- see PopTypeTableStaticInit, that clamp is always a no-op -- and finally, for groupType 0 ONLY, scaled by INDSYS_IMPERIAL_POPULATION_MOD when the owner species is 4",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SpeciesDef_GroupCapacityFactor",
|
||||
"addr": "0x0053bb00",
|
||||
|
|
@ -72,30 +56,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "Ship_RepairCost_G3",
|
||||
"addr": "0x00815180",
|
||||
"convention": "thiscall",
|
||||
"prototype": "int (StarShip* sh, bool useAllowance) // `ret 4`, REAL END 0x008151b7 (0x008151ba..0x008151bf is int3). max(0, design->+0xcc - (sh->ConCap /*+0x68*/ + (useAllowance ? design->+0xd0 : 0))). No floating point, no clamp but the floor at zero. Its only caller, RepairShipsInOrbit, always passes 1. UNEXERCISED on the corpus, and that is a measurement rather than an absence: the independent colony's fleet sits over Koa'Vo on both reference pairs and that player's Sav closes exactly with the demand taken as zero",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "Ship_ApplyRepair_G3",
|
||||
"addr": "0x008151c0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StarShip* sh, int points) // `ret 4`, ends 0x00815221. Does NOTHING unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000 -- a different bit from the one RepairShipsInOrbit's candidate filter tests, so a ship can be charged points that never reach it. Otherwise sh->ConCap += max(points, 0) and then ConCap = min(max(ConCap, 0), design->+0xcc). THIS IS WHAT NAMES THE TWO FIELDS: ConCap is the construction invested in the hull so far -- not a per-turn capacity, despite the save-format name -- and design+0xcc is its ceiling",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerSystem_ShipCarriedPopIncome",
|
||||
"addr": "0x007460b0",
|
||||
"convention": "cdecl-through-register",
|
||||
"prototype": "int (ServerPlayer* owner) /* the system arrives in EBX */ // ends 0x0074615a. CORRECTION to output-turn-path.md, which names this SystemRepairDemandForOwner: it is NOT a repair function. It walks the fleets at the system through the system's own vtable slots 2 and 3, skips a fleet whose owner is not the argument or whose +0x78 byte is clear, and sums 0x0081f8c0 over each ship -- and 0x0081f8c0 gates on the design's CARRIED-POPULATION bit (design->+0xb8 & 0x04000000) and computes GroupIncome over the ship's Population at ship+0x9c. So ComputeOutput's out[6] is the income of population carried in slaver/colony hulls in orbit, which is a slot the engine's BudgetInputs already has, and it feeds no save leaf",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ShipCarriedPopIncomePerShip",
|
||||
"addr": "0x0081f8c0",
|
||||
|
|
@ -136,5 +96,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "ServerSystem_GrowCivilianPops_G3 dropped 2026-09-08: same address 0x00754220 as addresses.json's ServerSystem_GrowCivilianPops. A lane-suffixed alias of an existing entry is a fork of the vocabulary, not a new fact - if the prototype needed correcting, correct the entry. G3's real end finding (0x00754b59, past Ghidra's reported end) stands in findings/subsystems/population-growth.md. Dropped 2026-09-08 as same-address duplicates, agreement recorded: ServerSystem_MaxPopGeneric_G3 -> ServerSystem_MaxPopGeneric (addresses.json); Ship_RepairCost_G3 -> Ship_RepairCost (lane-c3.json); Ship_ApplyRepair_G3 -> Ship_ApplyRepair (lane-c3.json); ServerSystem_ShipCarriedPopIncome -> SystemRepairDemandForOwner (lane-c3.json)."
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"convention": "site",
|
||||
"prototype": "site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll",
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"convention": "site",
|
||||
"prototype": "site in FUN_007ae010 (phase 11 of OnAllCombatDone_Tail): `mov ecx,[esi+0x16c]; fld dword [0x009e2ea0] /*0.5f*/; push ecx; fstp [esp]; call 0x008e6dd0` = Mars::RNG::Chance(0.5f) on the strategic generator at S+0x16c. Chance early-outs WITHOUT a draw at p<=0 and p>=1 but takes neither at 0.5f, so this is EXACTLY ONE NextFloat PER EXPIRED NODE LINE PER TURN. State-dependent draw count, in the combat-done tail, BEFORE the autosave. Every RNG account in the repo assumes the strategic generator advances only inside StrategyServer::ProcessTurn; it also advances here, and again inside the combat resolver FUN_007d5af0 (RNG_NextInt on the node-cannon path, RNG_Twist + RNG_NextInt on the salvage path)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §3 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a73 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyHost_Autosave",
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyHost* this /*the global at 0x00b29f98*/, std::string* outName, bool endTurn) // THE AUTOSAVE. Exactly two call sites: SendEndTurn 0x007839d7 with endTurn=1 -> (Autosave EndTurn).sav, the PRE-turn state; StrategyHost::OnMessage 0x00784e59 with endTurn=0 -> (Autosave).sav, the POST-turn state. Body: null-check this->+0x4 (the strat game) -> log \"Can't autosave- Strat game doesn't exist.\"; build FOUR paths as _snprintf(buf,0x3ff,\"%s/%s.%s\", dir, name, ext) with dir=FUN_007a05a0(game) (\"SavedGames\") and ext=FUN_007a0620(game) (\"sav\") and the four localized names registered at 0x009bed00..0x009bed7f (SOTS_GAME_AUTOSAVE @0xaf092c, _AUTOSAVEBACKUP @0xaf0934, _ENDTURN_AUTOSAVE @0xaf093c, _ENDTURN_AUTOSAVEBACKUP @0xaf0944); if (!IsSinglePlayerHost()) remove both ENDTURN files; pick (cur,bak) by endTurn; mkdir(dir); ROTATE remove(bak)+rename(cur,bak) ONLY WHEN endTurn==0 (the flag byte at [ebp-0x14a1] is set to 1 and the je at 0x00895266 SKIPS the store of 0 when the arg is zero); gate on (this->flags & 4) && this->+0x4; DETACH each player's connection at pl->+0x12c via conn->vft[0x14] and reattach via conn->vft[0x18] after; call SaveGame_WriteFile(this->+0x4, curPath, 1, &agentNames) at 0x0089595d",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6 (lane K 2026-09-08, read from the instruction stream)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a76 (lane K 2026-09-08, read from the instruction stream)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyHost_IsSinglePlayerHost",
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"convention": "cdecl",
|
||||
"prototype": "bool () // whole 27-byte body: `g = *(void**)0x00b2d540; net = g->+0x148; return net != 0 && net->+0x4 == 0;`. Gates SendEndTurn's pre-turn autosave and the AI-agent sidecar branch inside StrategyHost::Autosave; its NEGATION gates the deletion of the ENDTURN autosave pair (so the pre-turn autosave is a single-player-only feature)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6.1 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a76.1 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyHost_HasNetworkSession",
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "bool (void* this) // `return this->+0x148 != 0;`. Called on the global at 0x00b2d540 from StrategyHost::OnMessage 0x00784e3f -- this is the gate on the POST-turn autosave",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a76 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SaveGame_WriteFile",
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
"convention": "cdecl",
|
||||
"prototype": "bool (void* game, const char* path, bool write, std::vector<AgentName>* agentNames) // the save-file ROOT that verify/save-reader/save_reader.py already models (its comment at line 694 names this address). Opens the stream with OpenSaveStream(path,&stream,write); with write=1 that is operator new(0x118) + ctor 0x008d10c0 + FUN_008d1090(path,\"wb\"), the gzip writer. Then writes four named top-level sections through stream->vft[0x28](tag,&ref): \"Summary\", \"CreateParams\", \"Sim\", \"CDT\"; then one \"CD\" record (tag at 0x00a2b9d4) per entry of the 0x20-stride agentNames vector whose +0x1c is non-null. NOTHING TIME-, NAME- OR MACHINE-DEPENDENT ENTERS THE PAYLOAD: the file NAME is built by StrategyHost::Autosave and never reaches here",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6.2 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a76.2 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "OpenSaveStream",
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
"convention": "cdecl",
|
||||
"prototype": "bool (const char* path, Stream** out, bool write) // write -> operator new(0x118), ctor 0x008d10c0, open FUN_008d1090(path, \"wb\" @0x009e150c); read -> OpenFile(path, @0x00a2ec2c). Returns *out != 0. The \"wb\" is the gzip container the determinism note measured as header-deterministic (MTIME 0, XFL 0, OS 11)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6.2 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a76.2 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_GenerateTurnEvents",
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this) // 122 BYTES, AND IT GENERATES NOTHING. (1) FUN_007c5610(&scratch, S->+0x304, S->+0x308) with ecx = &S->+0x304 -- erase-to-empty of the vector<SETurnResults> OUTBOX at S+0x304. (2) if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 -- clear of a 0xc-stride vector; THE COPY LOOP AT 0x007dc680 IS DEAD CODE, `cmp edx,edx; je` at 0x007dc676 is unconditionally taken. (3) FUN_00792a20(S) prunes two intrusive lists at S+0x2d8 and S+0x2e4. (4) if (S->+0x128 & 4) BuildTurnEvents(S) -- normally FALSE. One caller: StrategyHost::OnMessage 0x00784e34",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5B (lane K 2026-09-08, read from the instruction stream)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a75B (lane K 2026-09-08, read from the instruction stream)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_BuildTurnEvents_isResync",
|
||||
|
|
@ -78,14 +78,14 @@
|
|||
"convention": "note",
|
||||
"prototype": "MISNAMED. BuildTurnEvents 0x007db780 is NOT a per-turn turn-event builder: it is the FULL-STATE RESYNC PUSH for setup / load / rejoin. Its entire 3701-byte body is under `if (this->+0x12c != 0)`, and both that descriptor and bit 2 of +0x128 are set in exactly one place in the image -- FUN_007bd1b0 at 0x007bd204/0x007bd23a. It references NO EVENT_* string at all; its only string immediates are \"vector<T> too long\" and \"StrategyServer: OnEvent() called, but no callback function specified.\" It sends SEResetMap (0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19) and calls SynchronizePlayer. It CALLS FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c to ESTABLISH the baseline and never diffs against it. findings/control-flow/turn-spine.md reads as if this were a per-turn diff step -- it is not",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5B (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a75B (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_off_TurnResultsPending",
|
||||
"convention": "offset",
|
||||
"prototype": "StrategyServer+0x2f4 (S frame) = std::vector<Game::SETurnResults> ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)",
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a75A (lane K 2026-09-08)",
|
||||
"offset": "0x2f4"
|
||||
},
|
||||
{
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
"convention": "offset",
|
||||
"prototype": "StrategyServer+0x304 (S frame) = std::vector<Game::SETurnResults> OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)",
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a75A (lane K 2026-09-08)",
|
||||
"offset": "0x304"
|
||||
},
|
||||
{
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (SETurnResults* this) // the DEFAULT CONSTRUCTOR of Game::SETurnResults (Ghidra calls it Create; it is not a factory). sizeof == 0x11c, enumerated five ways: the 0x11c stride and its reciprocal 0xe6c2b449/sar 8 in vector<SETurnResults>::resize 0x007cd2a0, the `add esi,0x11c` in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx*0x11c]), the operator new[] in 0x0078b0c0, and this ctor closing at +0x118 (the _Alval of a vector member at +0x10c). Layout: +0x00 vptr; +0x04 bool; +0x08 EMBEDDED Game::EventStorage::TurnEvents (vptr +0x08, int EvTurn +0x0c, vector<Event> +0x10/+0x14/+0x18, _Alval +0x1c); +0x20 int stamped by SynchronizePlayer from S+0x1fc and cleared after; +0x24 vector<ClientEncounterResults> (what ApplyEncounterResult publishes into); +0x34 byte with two bit-flags; strings at +0x38/+0x54/+0xa4/+0xc0/+0xdc; list at +0x70; vectors at +0x80/+0x90/+0xfc/+0x10c; two bools at +0xa0/+0xa1; int at +0xf8",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a75A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "vector_SETurnResults_resize",
|
||||
|
|
@ -110,15 +110,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 (\"SendTurnResultsToPlayers\") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §7.2a (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "TurnEvents_serializer_direction",
|
||||
"addr": "0x00825c40",
|
||||
"convention": "note",
|
||||
"prototype": "THE GHIDRA SYMBOL NAMES ON 0x00825bb0 / 0x00825c40 ARE SWAPPED, and so is EventStorage_Read 0x00825cc0 (which is the Write). 0x00825c40 is the WRITE: it calls 0x008b9d50, which invokes stream vtable slot +0x24 and pushes the MEMBER'S VALUE -- identical in shape to the golden Game::ObservedTech::Write 0x00817cf0. 0x00825bb0 is the READ: it calls 0x008b9d20, which invokes slot +0x10 and passes a stack scratch as a DESTINATION. objects/layouts.json and objects/streams.json already have the direction right (write 0x825c40, read 0x825bb0); the Ghidra names and findings/subsystems/events.md repeat the swap. Wire schema of Game::EventStorage::TurnEvents, in order: \"EvTurn\" by WriteInt (FOUR BYTES ON THE WIRE, default -1) at this+0x04; then \"Events\" through slot +0x28 as a framed counted array of Game::EventStorage::Event bound via Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c) at this+0x08. sizeof == 0x18 by enumeration four ways: serializer span (0x08+0x10), the SETurnResults default ctor (subobject 0x08..0x1f, next member at +0x20), its copy ctor, and the 0x18 container stride in EventStorage::FindTurnBucket 0x00811f70",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5B.1 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a77.2a (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "GetGame",
|
||||
|
|
@ -126,7 +118,7 @@
|
|||
"convention": "cdecl",
|
||||
"prototype": "void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "HandleMap_Resolve",
|
||||
|
|
@ -134,7 +126,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_MarkPlayerEncountered",
|
||||
|
|
@ -142,7 +134,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (ServerPlayer* this, ServerPlayer* other) // `if (other) this->HasEnc(+0x1a8) |= 1 << other->PlyrIdx(+0x28);`. Called twice symmetrically per ordered combatant pair in OnAllCombatDone_Tail phase 2",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_MarkSpeciesDiscovered",
|
||||
|
|
@ -150,7 +142,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (ServerPlayer* this, uint species) // `if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) |= 1 << species;`. Species index 4 is permanently excluded. Called in OnAllCombatDone_Tail phase 2 as MarkSpeciesDiscovered(other->Species(+0x5c))",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "PickDominantEncounterType",
|
||||
|
|
@ -158,7 +150,7 @@
|
|||
"convention": "cdecl",
|
||||
"prototype": "int (uint typeMask) // A RANKER OVER GROUPS, NOT A FILTER OF ONE. Clears bit 0 (Standard); if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} the mask is RESTRICTED to that group; otherwise the ambient groups {Swarm 3, Derelict 4, Monitor 5, SlaversRefuel 9, CrowRuins 17}, {CrowsNest 12, GravTrap 13} and {GasCloud 11, Meteor 2, Pirate 6, TradeRaiders 18, 20, 23} are each dropped IF ANYTHING ELSE REMAINS. Returns the index of the lowest surviving set bit in [0,0x18), else 0. The four group masks are lazily built once into 0x00b0e96c..0x00b0e988. FUN_004f4970 is the id->name switch (Standard/VonNeumann/Meteor/Swarm/Derelict/Monitor/Pirate/SystemKiller/PuppetMaster/SlaversRefuel/SwarmQueen/GasCloud/CrowsNest/GravTrap/Locust/Berserker/CrowDefenders/CrowRuins/TradeRaiders), which fixes the return type as an EncounterType enum",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_AnnounceEncounterSighting",
|
||||
|
|
@ -166,7 +158,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this, Node* node /*= enc->+0xc*/, int encType /*= PickDominantEncounterType(enc->+0x38)*/) // GHIDRA'S DECOMPILE OF THIS FUNCTION IS UNUSABLE -- 19 'removing unreachable block' warnings delete the entire event-posting body; read it as instructions. mask = 0; if (!FUN_00788cd0(node, encType, &mask)) return; -- that gate is true only for VonNeumann(1), Meteor(2), Pirate(6), GasCloud(0xb), Berserker(0xf), 0x17, and fills mask with the players who can see it. Then per set player it composes the event key as the LITERAL \"EVENT_\" (0x00a24bf8, length 6) CONCATENATED WITH THE TYPE NAME -- EVENT_PIRATE, EVENT_TRADERAIDERS, ... -- and posts through ServerPlayer_GetEventStorage + EventStorage_PostEvent. Finally push_backs a 0x10-byte {system, encType, turn, turn+1} record into the vector at S+0x2c8/+0x2cc/+0x2d0. No RNG",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08). Call shape instruction-verified; body from a delegated instruction read",
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08). Call shape instruction-verified; body from a delegated instruction read",
|
||||
"confidence": "med"
|
||||
},
|
||||
{
|
||||
|
|
@ -175,7 +167,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this, std::vector<Encounter>* encounters, std::vector<EncounterResults>* results) // two passes over ServerPlayer+0x230 vector<DiplomacyStats>, no RNG, no events. PASS A (dead homeworld): if the battle was at a player's own HomeSys(+0x2c), was a real battle (result->+0x4 == 0), had planet stats (result->+0x10c != 0) and the INT64 at result+0x120 is <= 0, then every participant that actually fought them gets deadhome(+0x20)++. PASS B (treaty betrayal): for every ordered pair with GetRelation < 1, a treaty slot signed within the last 3 turns and not yet betrayed since signing (`last != -1 && turn-last < 3 && (bty == -1 || bty < last)`), where the other side actually fought -- bty++ and lastXbty = turn, independently for NAP (+0x8/+0xa/+0xe), alliance (+0x10/+0x12/+0x16) and ceasefire (+0x18/+0x1a/+0x1e)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived",
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived",
|
||||
"confidence": "med"
|
||||
},
|
||||
{
|
||||
|
|
@ -184,7 +176,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this, Encounter* enc, EncounterResults* res) // phase 6 of OnAllCombatDone_Tail. Dispatch on three result bytes: res->+0x4 != 0 makes the WHOLE FUNCTION A NO-OP (that flag means 'no battle happened', which is exactly what phase 3's sighting arm keys on); res->+0x6 != 0 -> FUN_007a06a0 (posts EVENT_PEACEFUL_ENCOUNTER); res->+0x7 != 0 -> FUN_007d3eb0 (posts EVENT_SYSTEM_SURRENDERED); otherwise the full path -- stamp StarShip+0x5c = turn on every participating ship; set the pairwise HasEng(+0x1ac) engagement bits; if enc->+0x3c stamp ServerPlayer+0x3d4 = turn; build a ~0xea0-byte combat report and run THE REAL RESOLVER FUN_007d5af0 (7499 B, UNREAD); append a Game::CombatReport to the std::list at S+0x1fc; fire the script hook vt[0x10](7,..)/vt[0x30](..). ALL branches then run a publication tail that push_backs a 0x30-byte Game::ClientEncounterResults into *(S+0x2f4) + PlyrIdx*0x11c + 0x24. DRAWS RNG through its subtree: FUN_007d5af0 -> FUN_007bb530 -> RNG_NextInt (node cannon), and -> FUN_007a7f30 -> RNG_Twist plus -> FUN_007a0540 -> FUN_00852d30 -> RNG_NextInt (salvage / back-engineering)",
|
||||
"status": "mapped",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A.1 (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived and FUN_007d5af0 unread",
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A.1 (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived and FUN_007d5af0 unread",
|
||||
"confidence": "med"
|
||||
},
|
||||
{
|
||||
|
|
@ -193,7 +185,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (Node* this /*= enc->+0xc, the encounter's system*/, byte mask) // `if (!this->+0x100 /*owner*/) return; for each fleet at the node (vt[8] count, vt[0x10] get): if (owner->GetRelation(fleet->PID(+0x58)) == 3) for each ship in fleet->NShips(+0xa4/+0xa8) StarShip::RefreshFromDesign(ship, mask);`. FUN_00854680(ship,1) copies ship->+0x20 = design->+0xe8 and ship->+0x6c = design->+0xd8 then runs five recompute helpers -- a repair/refuel/stat refresh, not a movement step. strategic-turn-internals.md line 320 already calls it RefuelInOrbit(1); called from OnAllCombatDone_Tail phase 6 with mask = 1",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_FinalizeTurnRecords",
|
||||
|
|
@ -201,7 +193,7 @@
|
|||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this) // the LAST call of OnAllCombatDone_Tail (0x007d98ba), and also called from LoadGame 0x007ddc40 -- so the per-player turn record is rebuilt at the end of every turn AND on load, and never has to survive a save round-trip. Per player, rec = P->+0x3d8: rec+0x0c (32) = P->Sav(+0x284) - P->PvSav(+0x188); rec+0x14 (32) = P->Sav; rec+0x18 (16, mov WORD) = (P->+0x34 - P->+0x30)>>2 owned systems; rec+0x28 (16) = completed-tech count from FUN_0057d980 over the tree's +0x10/+0x14 with state == 4; rec+0x20/+0x24 (int64, cdq/add/adc) = SUM over owned systems of (sys->+0x194 + sys->+0x18c) total population; rec+0x2a/+0x2c/+0x2e (16) = ship counts by hull size 0/1/2 for designs WITHOUT flag 0x400; rec+0x30/+0x32/+0x34 (16) = the same for designs WITH flag 0x400. The census comes from FUN_00818a50(P, int[8]) whose slots [0] and [1] (the grand totals) are computed and DISCARDED. Second loop: FUN_00894260(S->+0x200, i, S->+0xc, rec) archives the record by turn; the archive's copy-assign FUN_008712a0 deliberately does NOT copy +0x1c",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5 (lane K 2026-09-08, field-by-field instruction read)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a75 (lane K 2026-09-08, field-by-field instruction read)"
|
||||
},
|
||||
{
|
||||
"name": "ServerSystem_ComputeMaxIncome",
|
||||
|
|
@ -217,7 +209,8 @@
|
|||
"convention": "note",
|
||||
"prototype": "Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector<EncounterResults> results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §0.1 (lane K 2026-09-08)"
|
||||
"source": "findings/control-flow/combat-done-tail.md \u00a70.1 (lane K 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: TurnEvents_serializer_direction -> Game_EventStorage_TurnEvents_Write (addresses.json)."
|
||||
}
|
||||
|
|
@ -88,14 +88,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SpeciesDefTable_Get",
|
||||
"addr": "0x00545cc0",
|
||||
"convention": "cdecl",
|
||||
"prototype": "SpeciesDef* (int species) // table base 0x00b10a00, stride 0x184, seven rows; species > 6 returns a lazily-constructed default at 0x00b105b0. Fields read by the output chain: +0x4c the base resource demand (an int) and +0x50 the resource output factor (a float). The table is .bss, so both come from the data files",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SignedCubeRoot",
|
||||
"addr": "0x008e5680",
|
||||
|
|
@ -128,14 +120,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SpeciesDefTable_base",
|
||||
"addr": "0x00b10a00",
|
||||
"convention": "data",
|
||||
"prototype": "SpeciesDef[7] -- .bss, filled from the data files. Stride 0x184",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT",
|
||||
"addr": "0x00af08f4",
|
||||
|
|
@ -184,5 +168,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: SpeciesDefTable_Get -> SpeciesDef_Get (addresses.json); SpeciesDefTable_base -> g_SpeciesDefTable (addresses.json)."
|
||||
}
|
||||
|
|
@ -88,14 +88,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SVSOSwarmQueen_HiveInfo_Write",
|
||||
"addr": "0x004fe730",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (Game::SVSOSwarmQueen::HiveInfo* this, Mars::IStream* s) // vftable 0x009f1a68 slot 2. Fixes the element layout: +0x4 -> HiveID (handle), +0x8 -> NextQ (int), +0xc -> QueenID (handle). The wire order is NOT the member order",
|
||||
"status": "verified",
|
||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SVSOSwarmQueen_Ctor",
|
||||
"addr": "0x0051ae20",
|
||||
|
|
@ -104,5 +96,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: SVSOSwarmQueen_HiveInfo_Write -> Game_SVSOSwarmQueen_HiveInfo_Write (addresses.json)."
|
||||
}
|
||||
|
|
@ -392,14 +392,6 @@
|
|||
"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",
|
||||
|
|
@ -424,5 +416,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: g_flt_ResearchRollProgressThreshold -> ResearchRollProgressThreshold (lane-o.json)."
|
||||
}
|
||||
|
|
@ -79,22 +79,7 @@
|
|||
"prototype": "site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, get-decompilation 0x005876c0 lines 294-309)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_OnTechResearched_RecordObservedTech",
|
||||
"addr": "0x00891790",
|
||||
"convention": "site",
|
||||
"prototype": "site at the very head of ServerPlayer::OnTechResearched: RecordObservedTech is the FIRST statement, called unconditionally on every completion -- before the ResT/roll block and before the !silent event post. It de-duplicates by tech name, so the observed-tech vector grows by one 0x2c element per completion of a tech not already observed and by nothing otherwise",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, decompilation of 0x00891790 line 78)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_OnTechResearched_ResearchRollBlock",
|
||||
"addr": "0x00891790",
|
||||
"convention": "site",
|
||||
"prototype": "site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, decompilation of 0x00891790 lines 79-85, 0x0088df20, 0x00889d60)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: ServerPlayer_OnTechResearched_RecordObservedTech -> ServerPlayer_OnTechResearched (addresses.json); ServerPlayer_OnTechResearched_ResearchRollBlock -> ServerPlayer_OnTechResearched (addresses.json)."
|
||||
}
|
||||
|
|
@ -80,14 +80,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SVSOCrowDefenders_Write",
|
||||
"addr": "0x004f8c90",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (Game::SVSOCrowDefenders* this, Mars::IStream* s) // sys; ndsys count then a loop writing dsys; ndes count then a loop writing des; drad. NOTE: `dsys` is INSIDE the ndsys loop -- objects/layouts.json records it as a plain member, which is wrong, and no save can settle it because both counts are 0 everywhere",
|
||||
"status": "verified",
|
||||
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SVSOMonitor_Write",
|
||||
"addr": "0x004fd810",
|
||||
|
|
@ -104,5 +96,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: SVSOCrowDefenders_Write -> Game_SVSOCrowDefenders_Write (addresses.json)."
|
||||
}
|
||||
|
|
@ -80,14 +80,6 @@
|
|||
"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",
|
||||
|
|
@ -144,5 +136,6 @@
|
|||
"status": "verified",
|
||||
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
|
||||
}
|
||||
]
|
||||
],
|
||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: StrategyServer_wire_ModCount_vs_Frame -> StrategyServer_Write_ModCountFrameTags (lane-a2.json)."
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||
// Source: sots-re ghidra/addresses.json @ 471d6cb, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
@ -1439,8 +1439,6 @@ constexpr uint32_t StrategyAIAgent_AssignFleetsAndIssueOrders = 0x002c16c0;
|
|||
constexpr uint32_t StrategyAIAgent_IsClaimedByAnotherTask = 0x002a8d20;
|
||||
// thiscall int __thiscall Game::StrategyAIAgent::RangePenaltyForTask() -- the ONLY consumer of IAITask vtable slot 13 found in the image, dispatched at 0x00696630 on the `this` receiver. `budget = this ? this->vt[13]() : 15; n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1); if (n < budget) return 0;` else a 7-arm species switch on player->+0x5c through the byte index at 0x006966a8 = [0,0,0,0,2,1,0] and the table at 0x0069669c: species 0,1,2,3,4,6 and out-of-range -> 1000000 (0x000f4240), species 5 (Zuul) -> 0. So slot 13 is a RANGE/HOP BUDGET compared against a count, with a prohibitive penalty past it -- and the Zuul are exempt, a FOURTH independent cross-check on lane AI2's species reading (after Hiver gates, Zuul node-bore and NPC building nothing). The two 'Incoming' defence tasks return INT_MAX from slot 13, so they never take the penalty [verified]
|
||||
constexpr uint32_t StrategyAIAgent_RangePenaltyForTask = 0x00296620;
|
||||
// thiscall int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it [verified]
|
||||
constexpr uint32_t AITask_GetPriorityDefaultThunk = 0x00294220;
|
||||
// data int -- image-initialised value 650 (0x0000028a). AITInvade::GetPriority 0x00683670 is `movzx eax,byte [ecx+4]; not al; test al,1; je +5; jmp 0x00694220; mov eax,ds:0xa1795c; ret` -- so the tunable is returned when BIT 0 OF this->+0x4 IS SET, which is the OPPOSITE of lane AI2's stated `if (!(this->+0x4 & 1))`. It has EXACTLY ONE reference in the whole image (this load) and no writer anywhere: no loader, no CSV path. It is a code constant that happens to live in the writable data section. AITInvade's table priority is 500, so the flag raises it to 650 [verified]
|
||||
constexpr uint32_t g_AITInvadeUncommittedPriority = 0x0061795c;
|
||||
// data int -- image-initialised value 750 (0x000002ee), the twin of 0x00a1795c. AITEscortGateInvade::GetPriority 0x006835e0 has the identical shape and the identical inverted polarity: the tunable applies when bit 0 of this->+0x4 IS SET. Exactly one reference in the image, no writer. AITEscortGateInvade's table priority is 400, so the flag raises it to 750 [verified]
|
||||
|
|
@ -1539,8 +1537,6 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
|
|||
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
|
||||
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified]
|
||||
constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0;
|
||||
// site The byte AFTER Game::BuildQueue::ProcessTurn's last instruction (`ret 0x8` at 0x00891226, then int3 padding). Ghidra reports the function as 1230 bytes from 0x00890d50, i.e. ending at 0x0089121e -- INSIDE the epilogue, before the security cookie check. The body is 1264 bytes. Earned rule 17. Note that this address is ALSO the entry of SystemBuildQueue_AttachBuiltShip, the function's own slot-10 callee, which is why the fleet half of construction looked absent from the pass [verified]
|
||||
constexpr uint32_t BuildQueue_ProcessTurn_RealEnd = 0x00491240;
|
||||
// field ServerPlayer* -- the player that owns the queue. Read at 0x00890de9 (passed to the ship factory), 0x00890ec9 (passed to the system's post-build hook), 0x00890ef4 (the base of the ShipRecords update) and 0x00890f75 (the build-completed event's owner field) [verified]
|
||||
constexpr uint32_t BuildQueue_off_Owner = 0x0000000c;
|
||||
// field std::list<ShipBuildOrder> head sentinel. The pass walks it as `node = *(head); while (node != head) node = *node`, so it is the MSVC circular list. Read at 0x00890d9a and re-read every iteration at 0x00890d9d / 0x00891020 [verified]
|
||||
|
|
@ -1773,12 +1769,8 @@ constexpr uint32_t g_LanScanPortRange = 0x00713c70;
|
|||
constexpr uint32_t g_GameSpyAvailableHostOverride = 0x007085b0;
|
||||
// data const char* /* when non-NULL, replaces "<gamename>.ms<N>.gamespy.com" in SBServerListConnect */ [mapped]
|
||||
constexpr uint32_t g_GameSpyMasterHostOverride = 0x00709440;
|
||||
// thiscall void (ServerSystem* sys) // plain `ret`, REAL END 0x00754b59 -- the body is a loop over group types 0,1,2 whose back edge is at 0x00754b2e (`inc esi; cmp esi,3; jl 0x7543e9`) and lies outside every decompiler `if`, so the function reads as straight-line code if you stop at the first `ret`. Returns immediately when the system has no owner. Per group type: skip when Population::TotalOfType(Pop2, t) <= 0, then skip unless t == 1 -- so only CIVILIANS grow here. Per species: delta = PopGrowthDelta(1, sp); cur = Count(Pop2,1,sp) + Count(pbon2,1,sp); cap = MaxPopGeneric(1, sp, PID, NULL); soft = CivilianSettleLimit(sp); limit = soft < cap ? soft : cap (and soft < cap with cur+delta > soft raises a per-species settle flag); applied = min(delta, limit - cur); haltv[1] with applied > 0 zeroes both. The SYSTEM TOTAL is then clamped into [-50,000,000 (a literal here), POPTYPE[1]+0x08 = 20,000,000] and, when the clamp bit, every entry of the clamp's own sign is rescaled by trunc(applied x (clamped / total)) with NO renormalisation. Write-back is Population::SetCount(Pop2, 1, sp, Count(Pop2,1,sp) + applied) -- note the headroom counted pbon2 and the write does not. THE 20,000,000 CLAMP IS WHAT DECIDES THE VALUE on both reference pairs: the uncapped delta is 7.5x it and the capacity headroom 25x it [verified]
|
||||
constexpr uint32_t ServerSystem_GrowCivilianPops_G3 = 0x00354220;
|
||||
// thiscall int64 (ServerSystem* sys, int species) // `ret 4`, ends 0x0074aa2d. Returns 0 without an owner. Otherwise min( MaxPopGeneric(1, sp, PID, &StrategyServer::IdealSuit(server, sp)) , Population::Count(sys->dcs /*+0x104*/, 1, sp) ). The first term is the capacity the colony would have if the planet were perfectly suited; the second is the system's own `dcs` Population, which is on the wire. On Gamma Cephei `dcs` = 1e9 and it is the binding limit, not either capacity [verified]
|
||||
constexpr uint32_t ServerSystem_CivilianSettleLimit = 0x0034a9a0;
|
||||
// thiscall int64 (ServerSystem* sys, int groupType, int species, ServerPlayer* p, float* suitOverride) // `ret 0x10`, REAL END 0x0074a6cd -- 0x0074a6d0 is a DIFFERENT function (lane N and E1 both cite 0x0074a6d0 for the capacity-surplus pair; that is the next one along, not this). Returns 0 when species == 4; when the OWNER species' SpeciesDef+0x168[groupType] is not > 0 (which is why Zuul have no civilians); when groupType != 0 and the owner is RebAI; when groupType == 1 and bit 3 of ServerPlayer+0x348[species] is clear; and when groupType == 2 and 0x0082bdf0(p, species) is false. Otherwise ftoi64( (int64)Size x 1e8 x [ hazard x (POPTYPE[t]+0x20 x SpeciesDef(sp)+0x168[t] x (crossSpecies ? SpeciesDef(sp)+0x174[t] : 1)) ] ) via 0x00535eb0, plus the arcology flat bonus 0x0080dd30(p, t), then clamped to the int64 at POPTYPE[t]+0x28 -- see PopTypeTableStaticInit, that clamp is always a no-op -- and finally, for groupType 0 ONLY, scaled by INDSYS_IMPERIAL_POPULATION_MOD when the owner species is 4 [verified]
|
||||
constexpr uint32_t ServerSystem_MaxPopGeneric_G3 = 0x0034a4a0;
|
||||
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x168 + groupType*4]. The per-species, per-population-group capacity factor -- a DATA FILE value. Its imperial entry for Human is pinned at exactly 1.0 by the corpus (Gamma Cephei's pbon never drains and its Pop never shrinks, which bracket the imperial capacity at Size x 1e8); the civilian entry is bounded below at 0.27 by the observed growth and is otherwise unmeasured [verified]
|
||||
constexpr uint32_t SpeciesDef_GroupCapacityFactor = 0x0013bb00;
|
||||
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x174 + groupType*4]; applied by MaxPopGeneric only when the system owner's species differs from the population species and is not 4 [verified]
|
||||
|
|
@ -1791,12 +1783,6 @@ constexpr uint32_t PopGrowthSuitabilityDistance = 0x00136eb0;
|
|||
constexpr uint32_t PopGrowthDeltaHelper = 0x00137140;
|
||||
// cdecl void () // the CRT static initialiser for the population-type table at 0x00b104e8, and THE ONLY WRITER of the group-ceiling field. It sets every row's +0x28 to 0xffffffff and +0x2c to 0x7fffffff -- the int64 INT64_MAX -- along with +0x00/+0x04/+0x08/+0x0c to -1/1.0/-1/-1 and the float columns to 1.0. InitPopTypeTable 0x00535ca0 runs later and overwrites +0x00 through +0x20 but NEVER touches +0x24/+0x28/+0x2c. So MaxPopGeneric's group-ceiling clamp is present, always enabled, and always a no-op; a reader who opens only InitPopTypeTable sees zero there and would cap every carrying capacity in the game at nothing [verified]
|
||||
constexpr uint32_t PopTypeTableStaticInit = 0x005abe20;
|
||||
// thiscall int (StarShip* sh, bool useAllowance) // `ret 4`, REAL END 0x008151b7 (0x008151ba..0x008151bf is int3). max(0, design->+0xcc - (sh->ConCap /*+0x68*/ + (useAllowance ? design->+0xd0 : 0))). No floating point, no clamp but the floor at zero. Its only caller, RepairShipsInOrbit, always passes 1. UNEXERCISED on the corpus, and that is a measurement rather than an absence: the independent colony's fleet sits over Koa'Vo on both reference pairs and that player's Sav closes exactly with the demand taken as zero [verified]
|
||||
constexpr uint32_t Ship_RepairCost_G3 = 0x00415180;
|
||||
// thiscall void (StarShip* sh, int points) // `ret 4`, ends 0x00815221. Does NOTHING unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000 -- a different bit from the one RepairShipsInOrbit's candidate filter tests, so a ship can be charged points that never reach it. Otherwise sh->ConCap += max(points, 0) and then ConCap = min(max(ConCap, 0), design->+0xcc). THIS IS WHAT NAMES THE TWO FIELDS: ConCap is the construction invested in the hull so far -- not a per-turn capacity, despite the save-format name -- and design+0xcc is its ceiling [verified]
|
||||
constexpr uint32_t Ship_ApplyRepair_G3 = 0x004151c0;
|
||||
// cdecl-through-register int (ServerPlayer* owner) /* the system arrives in EBX */ // ends 0x0074615a. CORRECTION to output-turn-path.md, which names this SystemRepairDemandForOwner: it is NOT a repair function. It walks the fleets at the system through the system's own vtable slots 2 and 3, skips a fleet whose owner is not the argument or whose +0x78 byte is clear, and sums 0x0081f8c0 over each ship -- and 0x0081f8c0 gates on the design's CARRIED-POPULATION bit (design->+0xb8 & 0x04000000) and computes GroupIncome over the ship's Population at ship+0x9c. So ComputeOutput's out[6] is the income of population carried in slaver/colony hulls in orbit, which is a slot the engine's BudgetInputs already has, and it feeds no save leaf [verified]
|
||||
constexpr uint32_t ServerSystem_ShipCarriedPopIncome = 0x003460b0;
|
||||
// thiscall int (StarShip* sh) // returns 0 unless design->+0xb8 & 0x04000000 (the carried-population bit lane B6 named); otherwise sums GroupIncome over the ship's `hsp` Population at ship+0x9c for group types 0 and 1. Body read to the second GroupIncome call only [unverified]
|
||||
constexpr uint32_t ShipCarriedPopIncomePerShip = 0x0041f8c0;
|
||||
// data double 0.009999999776482582 = (double)0.01f -- the savings-interest multiplier ComputeBudget uses at 0x008631ce, then truncated by _ftol2. It is a WIDENED FLOAT, not the exact decimal, and the difference is not cosmetic: a treasury of exactly 50,000 earns 499, not 500. That one money was the whole residual on the human's Sav after civilian growth landed [verified]
|
||||
|
|
@ -1903,8 +1889,6 @@ constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x00000304;
|
|||
constexpr uint32_t SETurnResults_ctor = 0x003a7ae0;
|
||||
// thiscall void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 ("SendTurnResultsToPlayers") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25) [verified]
|
||||
constexpr uint32_t vector_SETurnResults_resize = 0x003cd2a0;
|
||||
// note THE GHIDRA SYMBOL NAMES ON 0x00825bb0 / 0x00825c40 ARE SWAPPED, and so is EventStorage_Read 0x00825cc0 (which is the Write). 0x00825c40 is the WRITE: it calls 0x008b9d50, which invokes stream vtable slot +0x24 and pushes the MEMBER'S VALUE -- identical in shape to the golden Game::ObservedTech::Write 0x00817cf0. 0x00825bb0 is the READ: it calls 0x008b9d20, which invokes slot +0x10 and passes a stack scratch as a DESTINATION. objects/layouts.json and objects/streams.json already have the direction right (write 0x825c40, read 0x825bb0); the Ghidra names and findings/subsystems/events.md repeat the swap. Wire schema of Game::EventStorage::TurnEvents, in order: "EvTurn" by WriteInt (FOUR BYTES ON THE WIRE, default -1) at this+0x04; then "Events" through slot +0x28 as a framed counted array of Game::EventStorage::Event bound via Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c) at this+0x08. sizeof == 0x18 by enumeration four ways: serializer span (0x08+0x10), the SETurnResults default ctor (subobject 0x08..0x1f, next member at +0x20), its copy ctor, and the 0x18 container stride in EventStorage::FindTurnBucket 0x00811f70 [verified]
|
||||
constexpr uint32_t TurnEvents_serializer_direction = 0x00425c40;
|
||||
// cdecl void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs [verified]
|
||||
constexpr uint32_t GetGame = 0x00178050;
|
||||
// thiscall void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object [verified]
|
||||
|
|
@ -1951,8 +1935,6 @@ constexpr uint32_t PopTypeRow = 0x00135e00;
|
|||
constexpr uint32_t InitPopTypeTable = 0x00135ca0;
|
||||
// cdecl int (int groupType, int64 count) // ftol(POPTYPE[groupType].incomeModifier x (count / 14000.0)). The income analogue of GroupOutput; note the divisor is 14000, not the output law's 500000, and there is no 1.8 factor [verified]
|
||||
constexpr uint32_t GroupIncome = 0x00135e80;
|
||||
// cdecl SpeciesDef* (int species) // table base 0x00b10a00, stride 0x184, seven rows; species > 6 returns a lazily-constructed default at 0x00b105b0. Fields read by the output chain: +0x4c the base resource demand (an int) and +0x50 the resource output factor (a float). The table is .bss, so both come from the data files [verified]
|
||||
constexpr uint32_t SpeciesDefTable_Get = 0x00145cc0;
|
||||
// cdecl double (double x) // x >= 0 ? pow(x, 1/3) : -pow(-x, 1/3), with the exponent taken from the double 0.3333333333333333 at 0x00a3a7c8. Used by StripMineFraction [verified]
|
||||
constexpr uint32_t SignedCubeRoot = 0x004e5680;
|
||||
// offset offset float ScOutMod -- the last of the five output multipliers ComputeTotalOutput applies, and the innermost in the x87 chain [verified]
|
||||
|
|
@ -1961,8 +1943,6 @@ constexpr uint32_t ServerPlayer_off_ScOutMod = 0x0000012c;
|
|||
constexpr uint32_t ServerPlayer_off_SetupOutputMult = 0x00000224;
|
||||
// data PopTypeRow[3] -- .bss, filled by InitPopTypeTable at startup. Stride 0x30 [verified]
|
||||
constexpr uint32_t PopTypeTable_base = 0x007104e8;
|
||||
// data SpeciesDef[7] -- .bss, filled from the data files. Stride 0x184 [verified]
|
||||
constexpr uint32_t SpeciesDefTable_base = 0x00710a00;
|
||||
// data float** -- pointer slot; storage 0x00af08f0, which unusually carries a value (0.1f) in the file image rather than being .bss [verified]
|
||||
constexpr uint32_t GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT = 0x006f08f4;
|
||||
// data int** -- pointer slot read by MoraleOutputMod; the threshold is an INT compared against the Morale int[7] entry [verified]
|
||||
|
|
@ -2063,6 +2043,30 @@ constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870;
|
|||
constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0;
|
||||
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1 [verified]
|
||||
constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
|
||||
// thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified]
|
||||
constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0;
|
||||
// data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified]
|
||||
constexpr uint32_t SVScriptObject_EventSlotJumpTable = 0x003a6480;
|
||||
// thiscall int (Game::SVSOSots* this, int evt, void* arg) // Game::SVSOSots vftable 0x00A063C4 slot +0x10, the ONLY slot that class overrides. evt 3 or 0x1b re-runs the new-game seeder 0x005a7d70; evt 0x1a runs 0x005a37e0; then it fans the delivery out to every child in the pointer vector at this+0x1c..0x20 through SVScriptObject_DispatchEvent. The loop re-reads both bounds every iteration, so a callee may resize the child vector under it [verified]
|
||||
constexpr uint32_t SVSOSots_HandleEvent = 0x001a7e40;
|
||||
// thiscall int (Game::SVSOSlaversRefuel* this, int evt, void* arg) // generic handler; the class overrides no event-specific slot at all. Body is `if (evt == 0x14) SVSOSlaversRefuel_UpdateDifficultyTier(this)` and nothing else [verified]
|
||||
constexpr uint32_t SVSOSlaversRefuel_HandleEvent = 0x0011a800;
|
||||
// thiscall void (Game::SVSOSlaversRefuel* this) // writes CDiff at this+0x38. Builds a 3x3-dword table on the stack -- thresholds 1 / 50 / 100 -- and scans for the FIRST threshold GREATER than StrategyServer+0xc (the frame), then stores index-1 if it differs from the stored value. Frame <= 0 exits at index 0; frame >= 100 runs off the end and stores NOTHING, so the tier can never reach 2. Only on a change does it continue into the per-system pass at 0x005158d4 [verified]
|
||||
constexpr uint32_t SVSOSlaversRefuel_UpdateDifficultyTier = 0x00115820;
|
||||
// thiscall void (Game::SVSORefugees* this) // vtable slot +0x60, event 0x13, sent from BeginProcessTurn. `if (!this->ini(+0x14)) { this->ini = 1; obj = <instantiate "Mission" / "_Refugee_Trader" from the data files>; if (obj) this->dids(+0x18).push_back(obj->handle(+0xa0)->id(+4)); }`. The store to the latch is unconditional on the lookup's result [verified]
|
||||
constexpr uint32_t SVSORefugees_OnTurnBegin = 0x00111260;
|
||||
// thiscall void (Game::SVSORefugees* this) // vtable slot +0x34, event 8, sent from OnAllCombatDone_Tail phase 8. Walks StrategyServer+0x44 (Systems) and drains the object vector at this+0x28..0x2c, destroying what it resolves. It does NOT write dids -- that is SVSORefugees_OnTurnBegin [verified]
|
||||
constexpr uint32_t SVSORefugees_OnCombatDone = 0x00111310;
|
||||
// thiscall void (Game::SVSOSwarmQueen* this) // vtable slot +0x60, event 0x13. Runs SVSOSwarmQueen_RegisterHives, then prunes hives whose system's EggScio (+0x184) no longer equals this->scenarioTag (+0x4), then SVSOSwarmQueen_TickHives and 0x00505100 [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_OnTurnBegin = 0x00129930;
|
||||
// thiscall void (Game::SVSOSwarmQueen* this) // for every system in StrategyServer+0x44 whose EggScio (+0x184) equals this->scenarioTag (+0x4, which the ctor sets to 3 -- the SWARM's tag, not the queen's own EncID 10), and that no hive already references, appends a HiveInfo {vptr 0x009f1a68, sys, nextQ, queen=0} to the vector at this+0x10..0x14, stride 0x10. nextQ = frame + *(int*)[0x00ae0204] + RNG_NextInt (0x004271c0, already in addresses.json; INCLUSIVE of its bound) over (*(int*)[0x00ae0208] - *(int*)[0x00ae0204]) -- ONE strategic-generator draw per new hive, taken inside BeginProcessTurn and therefore OUTSIDE both turn drivers [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_RegisterHives = 0x00127630;
|
||||
// thiscall void (Game::SVSOSwarmQueen* this) // per hive with queen (+0xc) == 0: if any spawn gate fails, `inc [hive+8]` -- the target turn SLIPS FORWARD BY ONE, which is why NextQ reads 31 after turn 1 and 32 after turn 2. Otherwise, if nextQ <= frame, spawn a queen (0x0050dfc0 then 0x004fe810) and append to the Queens vector at this+0x20. Gates read config pointers at 0x00ae0210, 0x00ae0228 and 0x00ae0220 [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_TickHives = 0x00127770;
|
||||
// thiscall void (Game::SVSOSwarmQueen* this) // vtable slot +0x64, event 0x14, sent from OnAllCombatDone_Tail phase 20. The same hive prune as the turn-begin handler, then 0x00521150. It does NOT register hives [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_OnTurnEnd = 0x001275d0;
|
||||
// cdecl Game::SVSOSwarmQueen* () // operator new(0x40); vftable 0x009f49e4; and the two ids that settle who the queen works for: [+0x4] = 3 (the scenario tag it selects systems by) and [+0x8] = 10 (its own EncID) [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_Ctor = 0x0011ae20;
|
||||
// thiscall 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 [verified]
|
||||
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
|
||||
// site 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 [verified]
|
||||
|
|
@ -2161,8 +2165,6 @@ constexpr uint32_t StrategyServer_UpdateSensors = 0x0046a8d0;
|
|||
constexpr uint32_t StrategyServer_StepAIRebellion = 0x00418530;
|
||||
// thiscall 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 [mapped]
|
||||
constexpr uint32_t StrategyServer_DetectEncounters = 0x003d7f70;
|
||||
// data 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 [verified]
|
||||
constexpr uint32_t g_flt_ResearchRollProgressThreshold = 0x0062c788;
|
||||
// data const float = 0.04f, subtracted from RebOutMod each turn for a RebAI player [verified]
|
||||
constexpr uint32_t g_flt_RebOutModDecay = 0x00617870;
|
||||
// data const float = 1.0f, the lower clamp of RebOutMod [verified]
|
||||
|
|
@ -2203,10 +2205,6 @@ constexpr uint32_t TechDef_off_Name = 0x00000040;
|
|||
constexpr uint32_t TechTree_SetResearched_flag_Refresh = 0x00000008;
|
||||
// site site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n [verified]
|
||||
constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
|
||||
// site site at the very head of ServerPlayer::OnTechResearched: RecordObservedTech is the FIRST statement, called unconditionally on every completion -- before the ResT/roll block and before the !silent event post. It de-duplicates by tech name, so the observed-tech vector grows by one 0x2c element per completion of a tech not already observed and by nothing otherwise [verified]
|
||||
constexpr uint32_t ServerPlayer_OnTechResearched_RecordObservedTech = 0x00491790;
|
||||
// site site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified]
|
||||
constexpr uint32_t ServerPlayer_OnTechResearched_ResearchRollBlock = 0x00491790;
|
||||
// member Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame) [verified]
|
||||
constexpr uint32_t StrategyServer_off_SpyManager = 0x00000158;
|
||||
// data void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots [verified]
|
||||
|
|
@ -2255,8 +2253,6 @@ constexpr uint32_t SVSOSots_vftable = 0x006063c4;
|
|||
constexpr uint32_t SVSOIndependentSystems_vftable = 0x00620314;
|
||||
// thiscall void (void*, Mars::IStream*) // `C2 04 00` -- a bare RET 4 shared as the inherited Read/Write for classes that serialize nothing [verified]
|
||||
constexpr uint32_t Streamable_NoOpStub = 0x001f8ac0;
|
||||
// thiscall void (Game::SVSOCrowDefenders* this, Mars::IStream* s) // sys; ndsys count then a loop writing dsys; ndes count then a loop writing des; drad. NOTE: `dsys` is INSIDE the ndsys loop -- objects/layouts.json records it as a plain member, which is wrong, and no save can settle it because both counts are 0 everywhere [verified]
|
||||
constexpr uint32_t SVSOCrowDefenders_Write = 0x000f8c90;
|
||||
// thiscall void (Game::SVSOMonitor* this, Mars::IStream* s) // calls SVSODerelict::Write (0x004fc2b0) as its first act -- Monitor derives from Derelict, which is why its tag run starts NDsn/DsnID/Dwght + NAsg/Eflt/Esys before nt/scnm/spwt/rsmd/dsgn [verified]
|
||||
constexpr uint32_t SVSOMonitor_Write = 0x000fd810;
|
||||
// thiscall void (Game::SVSODerelict* this, Mars::IStream* s) // NDsn count then a loop of (DsnID, Dwght); NAsg count then a loop of (Eflt, Esys). Two fields per iteration in each, confirmed by the 8-byte element strides [verified]
|
||||
|
|
@ -2373,8 +2369,6 @@ constexpr uint32_t ServerNodeGraph_FindPathById = 0x002e23d0;
|
|||
constexpr uint32_t StarFleet_HasFlagShips = 0x00303500;
|
||||
// cdecl 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 [verified]
|
||||
constexpr uint32_t StarFleet_PathRelation = 0x0038c360;
|
||||
// note 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 [verified]
|
||||
constexpr uint32_t StrategyServer_wire_ModCount_vs_Frame = 0x0039fb2f;
|
||||
// thiscall 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 [verified]
|
||||
constexpr uint32_t StrategyServer_ctor_VectorBlock = 0x0045b120;
|
||||
// thiscall 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 [verified]
|
||||
|
|
|
|||
|
|
@ -11,12 +11,30 @@ base = int(j["image_base"], 16)
|
|||
# ghidra/addresses.d/<lane>.json ({"entries": [...]}) — its own file, no shared-line edits.
|
||||
# Fragments are merged here in sorted order; a duplicate name is an error, not a silent
|
||||
# last-wins, because two lanes disagreeing about an address is exactly what we must not paper over.
|
||||
# Two collisions are possible and BOTH are errors:
|
||||
# same name, different address -> two lanes disagree about a fact.
|
||||
# same address, different name -> two lanes fork the vocabulary. This one is worse, because
|
||||
# nothing downstream notices: the header just grows a synonym and later readers cannot tell
|
||||
# that `RNG_NextInt` and `RNG_NextIntInclusive` are one function. A lane caught exactly that
|
||||
# by hand on the campaign's most-used RNG primitive; the check below is so nobody has to.
|
||||
def _key(e):
|
||||
return ("offset", e["offset"].lower()) if "offset" in e else ("addr", e["addr"].lower())
|
||||
|
||||
seen = {e["name"]: "addresses.json" for e in j["entries"]}
|
||||
# Offsets are only meaningful per owning struct, so they are not globally unique — key addresses only.
|
||||
by_addr = {_key(e)[1]: (e["name"], "addresses.json") for e in j["entries"] if "addr" in e}
|
||||
for frag_path in sorted(glob.glob(os.path.join(root, "ghidra", "addresses.d", "*.json"))):
|
||||
frag_name = os.path.basename(frag_path)
|
||||
for e in json.load(open(frag_path))["entries"]:
|
||||
if e["name"] in seen:
|
||||
sys.exit(f"duplicate address entry {e['name']!r}: in {seen[e['name']]} and {frag_name}")
|
||||
if "addr" in e:
|
||||
a = e["addr"].lower()
|
||||
if a in by_addr:
|
||||
other, where = by_addr[a]
|
||||
sys.exit(f"address {e['addr']} named twice: {other!r} in {where}, "
|
||||
f"{e['name']!r} in {frag_name} — pick one name and record the agreement")
|
||||
by_addr[a] = (e["name"], frag_name)
|
||||
seen[e["name"]] = frag_name
|
||||
j["entries"].append(e)
|
||||
try: rev = subprocess.check_output(["git","-C",root,"rev-parse","--short","HEAD"]).decode().strip()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue