gen_addresses.py distinguishes "addr" (VA -> RVA) from "offset" (a member offset). Three lane-K entries used "addr" for offsets and generated negative RVAs, which broke the engine build. Two are re-expressed as offsets; the third, StrategyServer_off_RNGPtr_S_frame, is withdrawn - addresses.json already has StrategyServer_off_RNG = 0x16c for the S frame alongside off_RNGPtr = 0x168 for S+4, which is precisely lane T's rule. The second call site found this lane confirms it rather than correcting it.
223 lines
26 KiB
JSON
223 lines
26 KiB
JSON
{
|
|
"entries": [
|
|
{
|
|
"name": "StrategyServer_OnAllCombatDone_Tail",
|
|
"addr": "0x007d92a0",
|
|
"convention": "thiscall",
|
|
"prototype": "void (StrategyServer* this /*base S*/, std::vector<EncounterResults>* results) // RET 4. THE SECOND TURN DRIVER. Reached from exactly one caller: StrategyHost::OnMessage 0x00784640 at 0x00784d07, on the SNMAllCombatDone message, with this = host->+0x54 and results = msg+4. 36 phases, whole 1587-byte body read from the instruction stream. Base is S (NOT S+4): Players at [S+0x54/0x58], ServerTradeManager at [S+0x158], SVScriptObject at [S+0x1b4], encounters at [S+0x1e8]. STRAIGHT-LINE past 0x007d96bf -- every jcc from there on is a per-player loop bound or one of three null tests on S+0x1b4. Order: ++S->+0x8 / arity check / first contact over all ordered combatant pairs / sighting announce / battle tally / diplomacy stats / ApplyEncounterResult per encounter + resupply / encounters.clear() / script(8) / AIRebellion(1) / ProcessNodeSpaceTravel / node-line decay (RNG) / colony-loss drain / two morale passes / ProcessBankruptcy / colonizer resolve / PlayerView rebuild / warnings / infra-terra drain / script(0x14,0x15) / survey+stats / FUN_0078a7c0 / eight ServerTradeManager vtable calls / upkeep / sensors / script(0x1c) / view refresh / node-line sightings / intercept aborts / comm masks / UpdateBankruptcyLimits per player / incoming warnings / two more vtable calls / observed designs / player reports / turn records",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-done-tail.md (lane K 2026-09-08, whole function disassembled 0x007d92a0-0x007d98d0)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_OnAllCombatDone_Tail_ClearEncounters",
|
|
"addr": "0x007d9690",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll",
|
|
"addr": "0x007ae095",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyHost_Autosave",
|
|
"addr": "0x00895210",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyHost_IsSinglePlayerHost",
|
|
"addr": "0x00815fb0",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyHost_HasNetworkSession",
|
|
"addr": "0x00898af0",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "SaveGame_WriteFile",
|
|
"addr": "0x00877070",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "OpenSaveStream",
|
|
"addr": "0x00816510",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_GenerateTurnEvents",
|
|
"addr": "0x007dc640",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_BuildTurnEvents_isResync",
|
|
"addr": "0x007db780",
|
|
"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)"
|
|
},
|
|
{
|
|
"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)",
|
|
"offset": "0x2f4"
|
|
},
|
|
{
|
|
"name": "StrategyServer_off_TurnResultsOutbox",
|
|
"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)",
|
|
"offset": "0x304"
|
|
},
|
|
{
|
|
"name": "SETurnResults_ctor",
|
|
"addr": "0x007a7ae0",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "vector_SETurnResults_resize",
|
|
"addr": "0x007cd2a0",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "GetGame",
|
|
"addr": "0x00578050",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "HandleMap_Resolve",
|
|
"addr": "0x008b9240",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "ServerPlayer_MarkPlayerEncountered",
|
|
"addr": "0x0080df10",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "ServerPlayer_MarkSpeciesDiscovered",
|
|
"addr": "0x0080dee0",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "PickDominantEncounterType",
|
|
"addr": "0x004f4c40",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_AnnounceEncounterSighting",
|
|
"addr": "0x007a9db0",
|
|
"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",
|
|
"confidence": "med"
|
|
},
|
|
{
|
|
"name": "StrategyServer_UpdateDiplomacyStatsFromCombat",
|
|
"addr": "0x00789d00",
|
|
"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",
|
|
"confidence": "med"
|
|
},
|
|
{
|
|
"name": "StrategyServer_ApplyEncounterResult",
|
|
"addr": "0x007d8920",
|
|
"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",
|
|
"confidence": "med"
|
|
},
|
|
{
|
|
"name": "Node_ResupplyAlliedFleets",
|
|
"addr": "0x007463f0",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "StrategyServer_FinalizeTurnRecords",
|
|
"addr": "0x0078a0e0",
|
|
"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)"
|
|
},
|
|
{
|
|
"name": "ServerSystem_ComputeMaxIncome",
|
|
"addr": "0x007521c0",
|
|
"convention": "thiscall",
|
|
"prototype": "int (ServerSystem* sys) // float mods[7] = {1.0f,0,..}; FUN_00747390(mods, sys, 0); float out[12] = {0}; ServerSystem::ComputeOutputRates(sys, out, mods); return max(*(int*)&out[3], 0). Slot 3 is the money/income rate. THE max() IS A `jg` -- the clamp at zero is what makes a loss-making colony contribute nothing to the bankruptcy limits rather than reducing them, which formula-gaps.md Q1 did not say. Only caller: ServerPlayer::UpdateBankruptcyLimits",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/formula-gaps.md Q1 addendum (lane K 2026-09-08)"
|
|
},
|
|
{
|
|
"name": "SNMAllCombatDone_layout",
|
|
"addr": "0x00a24758",
|
|
"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)"
|
|
}
|
|
]
|
|
}
|