From acf9a4018e3b657112f334c6d0e8ef3542667b5c Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 16:48:32 -0400 Subject: [PATCH] sync generated header: 13 same-address duplicates resolved across 8 lane fragments --- include/generated/sots_addresses.h | 162 +++++++++++++++++++++++------ 1 file changed, 131 insertions(+), 31 deletions(-) diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 8a593fc..b38e10d 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -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 @ d4f9404, 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 @@ -1439,12 +1439,38 @@ 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] constexpr uint32_t g_AITEscortGateInvadeUncommittedPriority = 0x00617960; +// __thiscall void (StrategySim* this, int playerId, float* rate) -- the server-side applier for TurnCommands prologue gate @0x0c. Bumps ModCount as its SECOND instruction, `inc [ecx+4]` @0x00821a84. Called from ApplyTurnCommandBatch+0x616 inside the per-player gate loop that opens at 0x0088fdb0 with esi = block+0x38: the gate tested is `[esi-0x2c]` (= block+0x0c) and the arguments are `[esi-0x34]` (= block+0x04, playerId) and `&[esi-0x30]` (= block+0x08, the rate). THIS NAMES ONE OF LANE W2'S FOUR UNRESOLVED HANDLER EIPs, and it is the one that fires most: because StrategyClient::BuildTurnCommands sets that gate UNCONDITIONALLY, it costs one ModCount bump per SUBMITTED BLOCK regardless of what the player did -- 4 of the 10 command bumps on the reference turn [verified] +constexpr uint32_t TurnCommandApply_ResearchRate = 0x00421a80; +// __thiscall void (StrategySim* this, int playerId, void* element) -- the applier for TurnCommands list 5 (system rates: `{i32 systemId, StarSystem::OutputRates}`). Bumps ModCount @0x0086c3e6. Called from ApplyTurnCommandBatch+0x32d, in the block that walks member +0xa0. Names lane W2's trap EIP 0x0086c3e9. On the reference turn exactly one element was applied, from the one AI player with an empire [verified] +constexpr uint32_t TurnCommandApply_SystemRates = 0x0046c3e0; +// __thiscall void (StrategySim* this, int playerId, void* element) -- the applier for TurnCommands list 3 (build orders: `{i32 ordinal, i32 designId, i32 systemId, i32}`). Bumps ModCount @0x0084946b. Called from ApplyTurnCommandBatch+0x6ef, walking member +0x88. Names lane W2's trap EIP 0x0084946e. This is the site that puts the AI's destroyer into the build queue on the reference pair -- the order lane B6 could not find in the pre-turn save because it does not exist yet when that file is written [verified] +constexpr uint32_t TurnCommandApply_BuildOrder = 0x00449460; +// __thiscall void (StrategySim* this, int playerId, void* element, int flag=1) -- the applier for TurnCommands list 10 (`{i32, i32, counted i32}`). Bumps ModCount @0x0088befe. Called from ApplyTurnCommandBatch+0x8ff with a literal 1 pushed as a fourth argument, walking member +0xdc. Names lane W2's trap EIP 0x0088bf01, which W2 recorded only as 'unresolved (near DestroyFleet)'. WHAT list 10 IS is still open: AI3 has AssignFleetsAndIssueOrders reaching lists 14, 8 and 10 together, the element is an id pair plus an id vector, and the turn that produced it also produced a brand-new fleet -- but that is a shape argument, not a name [verified] +constexpr uint32_t TurnCommandApply_List10 = 0x0048bed0; +// site `inc [ecx+4]` with ecx = the StrategySim -- the ModCount bump for TurnCommands list 14, INLINED into ApplyTurnCommandBatch at +0xabc rather than living in a handler. The containing block starts at 0x00890445 with `add ecx,0x10c` (list 14's member offset) and fetches playerId as `[eax-0x108]`. Lane W2 measured this site firing EXACTLY TWICE on a turn in which exactly ONE fleet was moved (one list-8 bump), which CONFIRMS lane AI2's prediction P1: the AI's fleet-order bridge calls ClientOrder_FleetTask twice, mode 0 then mode 1, and the adder's (fleetId, mode) key makes those two distinct elements, where the UI emits only mode 0 [verified] +constexpr uint32_t TurnCommandApply_List14_Inline = 0x0049046c; +// site `inc [ebx+4]` -- the ModCount bump for TurnCommands prologue gate @0x14 (research target), inlined into ApplyTurnCommandBatch at +0x45a. Guarded by `cmp BYTE [edi-0x24],0` @0x0088fdfa with edi = block+0x38, i.e. the gate at block+0x14; playerId from `[eax-0x34]` and the tech id from `[ecx-0x28]` (= block+0x10). NEVER FIRED in either of lane W2's measured windows, which is consistent with the saves: no player's ResTNm changes on turn 2->3 or 3->4. Lane AI4 predicts it fires exactly THREE times on turn 1->2, once for each of the three AI players, all of which go ResTNm '' -> a named tech [mapped] +constexpr uint32_t TurnCommandApply_ResearchTarget_Inline = 0x0048fe0a; +// site `inc [ebx+4]` -- the ModCount bump for TurnCommands prologue gate @0x20 (research boost), inlined into ApplyTurnCommandBatch at +0xe0c. This is a SEPARATE per-player gate loop that runs AFTER all twenty-seven command lists, opening at 0x008907a6 with esi = block+0x20; the payload `[esi-0x8]`/`[esi-0x4]` is block+0x18 (savings spent) and block+0x1c (a fraction), handed to 0x00820560. So the six prologue gates are applied in THREE separate loops at three different points in the batch, not together [mapped] +constexpr uint32_t TurnCommandApply_ResearchBoost_Inline = 0x004907bc; +// __thiscall void (StrategySim* this, int playerId, void* payload) -- the applier for TurnCommands prologue gate @0x2c (a bool at +0x24 and an int at +0x28). Bumps ModCount @0x00821b93. Called from the LAST loop in ApplyTurnCommandBatch, at +0xe6d, with esi = block+0x24 and the gate tested as `[esi+0x8]`. Never observed set in any save; its AI producer is Process Turn phase 14 (0x006c3510 -> client order method 0x00763910), so an AI turn on which phase 14's own gate opens costs one bump nothing in the corpus has ever paid [mapped] +constexpr uint32_t TurnCommandApply_Group4Gate = 0x00421b90; +// __thiscall void (StrategySim* this, int playerId, void* element) -- the applier for TurnCommands list 1 (`StreamableHelper` frame plus an int). Bumps ModCount @0x0088291a. Called from ApplyTurnCommandBatch+0x652, walking member +0x70. Lane AI4 predicts this is the site that registers the AI's new hull on the reference pair's turn 1 (`Des[18 "Honor Lance"]`, NumDes 5 -> 6) and that it fires exactly once there; if it does NOT fire, an AI design is not a command at all and is carried some other way -- which is the single most useful falsifier in that prediction [mapped] +constexpr uint32_t TurnCommandApply_NewDesign = 0x00482910; +// site `inc [ebx+4]` -- the ModCount bump for TurnCommands list 7 (colonize: `{i32 shipId, i32}`), inlined into ApplyTurnCommandBatch at +0xc18. The containing block opens at 0x008905b5 with `lea ecx,[eax+0xb8]`, which is why an `add reg,imm` scan for the list member offsets misses it; the per-element mutator is 0x00849280 and does not itself bump. Recorded because the AI's colonize orders are the one command family whose producer is currently mis-attributed: Process Turn phase 32 reaches the colonize order method but runs AFTER cl_EndTurn has latched the client, so its orders are refused (see AIProcessTurn_SubmitLatch) [mapped] +constexpr uint32_t TurnCommandApply_Colonize_Inline = 0x004905c8; +// site `mov BYTE [esi+0x15c],1` inside StrategyClient::EndTurn -- the turn-ended latch. It is set BEFORE the calls to BuildTurnCommands (0x00783cd7) and SendEndTurn (0x00783d0d), and EVERY client order method opens with `if (this->+0x15c) return false`. Since cl_EndTurn is Process Turn phase 28 of 34, PHASES 29-33 ARE DEAD: they run, they call their order methods, and every one is refused -- including phase 32 (0x006af790 -> 0x00769640), which would otherwise issue a colonize order. A reimplementation that ports the phase list literally is one command per AI per turn over on ModCount [verified] +constexpr uint32_t AIProcessTurn_SubmitLatch = 0x00383c59; +// site `cmp DWORD PTR [eax+0x5c],1; jne 0x006c5a28` at the head of Process Turn phase 2 (0x006c57e0), where eax = client->+0x150 (the ClientPlayer) and +0x5c is Species. SPECIES 1 IS HIVER, so the whole of phase 2 -- a cl_Chance draw at 0x006c592a and the client order method 0x00762ca0 at 0x006c59b6, which sets TurnCommands prologue gate @0x3c -- is skipped for every other species. Two consequences: gate @0x3c is AI-only AND Hiver-only, which is why no save has ever carried it (the corpus has no Hiver player); and this is a FIFTH independent cross-check on lane AI2's reading of player+0x5c as Species [verified] +constexpr uint32_t AIProcessTurn_Phase2_HiverGate = 0x002c5813; +// __cdecl The `cl_*` facade entry for the research-target order, reaching client order method 0x00763110 at 0x00578faa. It has EXACTLY ONE caller in the whole image: 0x006caf70, which is AI Process Turn phase 18. So this entry point is AI-only surface, exactly as cl_EndTurn is -- extending lane AI1's P4 list, which was drawn from the order methods rather than the facade. The UI reaches the same order methods directly from 0x005d20a0 / 0x005d2140, so the *method* is shared and the *facade entry* is not [verified] +constexpr uint32_t cl_SetResearchTarget = 0x00178f60; +// __thiscall AI Process Turn phase 18 (called at 0x006cfc82). The only caller of cl_SetResearchTarget 0x00578f60, and it also calls cl_SetResearchRate. This is where an AI player's research target is chosen, and therefore where the ResTNm change lane C3 attributes to 'the AI's own orders, generated during the same turn' originates. Its internal gate -- when the AI does and does not pick a target -- was NOT read; the corpus says it opens on turn 1 for all three AI players and stays shut on turns 2 and 3 [mapped] +constexpr uint32_t AIProcessTurn_Phase18_Research = 0x002caf70; // thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified] constexpr uint32_t CombatResolve_Retreat = 0x003d5a00; // thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified] @@ -1511,8 +1537,6 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980; constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0; // thiscall void** (std::map* this, const int* key) // 125 B, ret 4. MSVC std::map::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 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 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] @@ -1745,12 +1769,8 @@ constexpr uint32_t g_LanScanPortRange = 0x00713c70; constexpr uint32_t g_GameSpyAvailableHostOverride = 0x007085b0; // data const char* /* when non-NULL, replaces ".ms.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] @@ -1763,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] @@ -1875,8 +1889,6 @@ constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x00000304; constexpr uint32_t SETurnResults_ctor = 0x003a7ae0; // thiscall void (std::vector* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector::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 (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: vector 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] @@ -1923,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] @@ -1933,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] @@ -2017,6 +2025,12 @@ constexpr uint32_t FlightPlan_Waypoint_Set = 0x003006e0; constexpr uint32_t NodeRoute_Construct = 0x002e1b20; // cdecl bool (StarFleet* f) // 90 B. FUN_006fe320(f) returns f->LocID(+0xa0) ONLY when the location's kind tag (+0x14) is 2, a DEEP-SPACE POINT -- never a system. Returns true iff any of the three position components differs by exact IEEE comparison (fucompp, test ah,0x44, jp), no epsilon. OrderFleetMove's opening snap is therefore point-only; combat-retreat-pipeline.md 2.5's 'snaps the fleet's position onto its current system' is corrected here -- a fleet parked at a system is never snapped [verified] constexpr uint32_t StarFleet_PosDiffersFromPointLocation = 0x0040ec50; +// data double 0xBFC3333340000000 = -0.15000000596046448 = (double)(float)-0.15f -- the elimination-limit divisor, loaded by ServerPlayer::UpdateBankruptcyLimits at 0x00818612 with `DD 05` (fld QWORD). It is the negation of g_DebtInterestRate (0x009ed188), and the two are stored separately. Named here because lane N's fix cited the value but not the address [verified] +constexpr uint32_t g_BankruptcyEliminationDivisor = 0x0062ec30; +// data float* -> 0x00b23e28. The config loader's pointer slot for the key BANKRUPTCY_PROTECTION_LIMIT_FACTOR, the same indirection pattern as ADDICTION_INCOME_MOD's (0x00aeca48 -> 0x00aeca44). Read once in the image, by UpdateBankruptcyLimits at 0x0081866e [verified] +constexpr uint32_t PTR_g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 0x006edfdc; +// data FLOAT32 -- and the width is the finding. UpdateBankruptcyLimits multiplies it in at 0x00818674 with `D8 09` = FMUL m32fp, i.e. a DWORD, while the elimination divisor two instructions earlier is `DD 05` = FLD m64fp. So whatever decimal the data file carries for this key is narrowed to a 4-byte float before the multiply, and a `double` multiply truncates to a different integer at every boundary -- for a factor of 3.3 that is EVERY max income divisible by ten, starting at maxIncome = 10 (decimal 33.0 -> 33, image 32.99999952 -> 32). The file image of the slot is zero: the loader fills it at run time, so the value itself is a data-file fact, not a binary one. The corpus cannot see the difference -- all seven of its non-zero BnkPr records invert to a max income where the two agree -- so this is an instruction-stream reading, thin-covered by construction (rule 23) [verified] +constexpr uint32_t g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 0x00723e28; // thiscall void __thiscall Game::TurnCommands::Write(Mars::IStream* s) -- the writer for the `Player..TurnCommands_v5` custom-data block (CDT id -> one CD frame). 764 bytes, no loops of its own. Two halves. (1) A PROLOGUE of six flag-gated groups, each a WriteBool on a member followed, only when that bool is set, by the group's payload; write order is NOT offset order, which is why the offset-sorted layout view cannot be aligned to the wire: bool@0x0c gates f32@0x08 (research rate); bool@0x14 gates i32@0x10 (research target tech); bool@0x20 gates i32@0x18 + f32@0x1c (research boost spend + fraction); bool@0x2c gates bool@0x24 + i32@0x28; bool@0x3c gates f32@0x30,0x34,0x38; bool@0x6c gates a StreamableHelper frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list members at 0x70..0x1a8, stride 0x0c ({_Myhead, _Mysize, _Alval}), each passed to its own free-function writer as helper(stream, &list). Every one is written unconditionally, so an empty list still costs one zero int: 8 prologue items + 27 zero counts = the 35-item block every no-orders save carries [verified] constexpr uint32_t TurnCommands_Write = 0x00442540; // thiscall void __thiscall Game::TurnCommands::Read(Mars::IStream* s) -- the reader paired with TurnCommands_Write (0x00842540). 1543 bytes; not decompiled by lane Q, listed so the pair is on the record [mapped] @@ -2029,6 +2043,30 @@ constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870; constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0; // cdecl void __cdecl (Mars::IStream* s, std::list* 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 = ; 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] @@ -2127,8 +2165,6 @@ constexpr uint32_t StrategyServer_UpdateSensors = 0x0046a8d0; constexpr uint32_t StrategyServer_StepAIRebellion = 0x00418530; // thiscall void (StrategyServer* this, std::vector* 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] @@ -2169,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] @@ -2221,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] @@ -2247,6 +2277,78 @@ constexpr uint32_t StrategySim_ModCountBump_unresolved_00821a84 = 0x00421a84; constexpr uint32_t StrategySim_ModCountBump_unresolved_0084946b = 0x0044946b; // site A ModCount bump observed live once per turn; trap EIP 0x0088bf01, nearest known symbol StrategyServer_DestroyFleet 0x0088b980 at +0x581 -- plausibly inside it, but unproven. Return address 0x008902b4 (StrategySim::ApplyTurnCommandBatch), so it is a command handler called from the batch applier rather than an inlined site [mapped] constexpr uint32_t StrategySim_ModCountBump_unresolved_0088befe = 0x0048befe; +// offset int AFlags -- the per-player 'has something here' mask, bit = PlyrIdx. Confirmed from ServerSystem::Write 0x00749630 at 0x00749aec (`mov ecx,[edi+0xcc]` with the "AFlags" tag at 0xa1f3b0; Write runs on the IStreamable subobject at +0x8, so 0xcc + 8 = 0xd4). Lane E3's +0xd4 is right [verified] +constexpr uint32_t ServerSystem_off_AFlags = 0x000000d4; +// offset int SnLv -- 2 bits per player (level = 0 none / 1 partial / 3 full), NOT SERIALISED. Written once per turn in the sensor phase at 0x0086a789 as `s->SnLv = (s->SnLv & ~(3<<2i)) | (level<<2i)`; read by ServerSystem::IsKnownTo 0x00746390 and RefreshVisibleOwnerIfKnown 0x0075bd70, and shipped to clients by SynchronizePlayer at 0x007c76a4. Because it is absent from every save, any NVO/TShn claim that depends on it is a hypothesis on the corpus alone (rule 6) [verified] +constexpr uint32_t ServerSystem_off_SnLv = 0x00000024; +// offset std::map::_Myhead for NVO (the visible-owner record). The map object starts at +0x270 and is 0x10 bytes: allocator +0x0, _Myhead +0x4, _Mysize +0x8, comparator +0xc. From ServerSystem::Write 0x0074a097/0x0074a195 (`[edi+0x26c]`, +8 for the IStreamable subobject) and from the live arming probe, which validated +0x274 on 9 of 28 systems and +0x26c on 0 [verified] +constexpr uint32_t ServerSystem_off_NVO_Myhead = 0x00000274; +// offset std::map _Mysize for NVO. Write 0x0074a07d (`[edi+0x270]`) carries the "NVO" tag 0xa1f2c0, so the wire's NVO count is this word. CORRECTS objects/layouts.md, which lists NVO@0x278 as an int field and PID@0x274 as a handle: +0x274 is _Myhead and the "PID" tag belongs to the node KEY, not to the object [verified] +constexpr uint32_t ServerSystem_off_NVO_Mysize = 0x00000278; +// offset std::map _Myhead for NVE (the sighting record). Write 0x0074a1bb. Confirms lane E3's +0x284/+0x288 [verified] +constexpr uint32_t ServerSystem_off_NVE_Myhead = 0x00000284; +// offset std::map _Mysize for NVE. Write 0x0074a1a1, tag "NVE" [verified] +constexpr uint32_t ServerSystem_off_NVE_Mysize = 0x00000288; +// offset std::map _Myhead for NVs (the per-player PlayerView record). Write 0x0074a27d [verified] +constexpr uint32_t ServerSystem_off_NVs_Myhead = 0x00000294; +// offset std::map _Mysize for NVs. Write 0x0074a263, tag "NVs" [verified] +constexpr uint32_t ServerSystem_off_NVs_Mysize = 0x00000298; +// offset int16 TShn, offset within the NVO map NODE (not the value). Node is _Left +0x0, _Parent +0x4, _Right +0x8, key(PlyrIdx) +0xc, value +0x10 = {int16 touched +0x10, int16 TShn +0x12, int32 OID +0x14, bool isind +0x18, IndependenceInfo indi +0x1c}, _Color +0x8c, _Isnil +0x8d -- so an NVO node is ~0x8e bytes, unlike NVE's 0x20. From Write 0x0074a0ca (`lea ecx,[ebx+0x12]`, tag "TShn") and confirmed live: a 4-byte watchpoint on value+0x0 trapped both halves separately [verified] +constexpr uint32_t ServerSystem_NVO_node_off_TShn = 0x00000012; +// thiscall OwnRec* (NVO_map* this /* ServerSystem+0x270 */, const int* plyrIdx) // std::map::operator[]; returns node+0x10. Exactly ONE caller in the image: ServerSystem::SetVisibleOwner 0x0075b94b (NVE's operator[] 0x00752730 has three). That single-caller fact is what makes the writer inventory below complete [verified] +constexpr uint32_t ServerSystem_NVO_operator_index = 0x0075a890; +// thiscall void (ServerSystem* this, ServerPlayer* p, const OwnRec* src) // ret 8. Stamps value.touched = (int16)this->owner(+0x10)->Frame(+0x8) and copies src's TShn/OID/isind/indi verbatim into NVO[p->PlyrIdx]. THE ONLY WRITER OF NVO.TShn IN THE IMAGE. Local gate (converge point 0x0075b983, the inlined IndependenceInfo dtor, so it is a real branch and not a rule-4 artifact): skip the whole write when src->OID == p->id but this->PID(+0x100)->id != p->id, logging "VisibleOwner: %s sees their self as owner of %s, but they are not." [verified] +constexpr uint32_t ServerSystem_SetVisibleOwner = 0x0075b880; +// offset site `mov WORD PTR [eax+0x2],dx` -- the TShn store. Trapped live by a DR watchpoint on Spica's NVO root node: EIP 0x0075b965 (the trap is taken after the store retires), twice per End Turn. The touched-word store is the instruction before it at 0x0075b957 (trap EIP 0x0075b95a) [verified] +constexpr uint32_t ServerSystem_SetVisibleOwner_TShnStore = 0x0075b961; +// thiscall void (ServerSystem* this, ServerPlayer* p) // ret 4. Builds an OwnRec with TShn = (int16)server->Frame, OID = this->PID(+0x100)->id, isind/indi from this->indi(+0x1c8), then calls SetVisibleOwner at 0x0075bd45. THIS is where TShn = Frame is decided. Confirmed live: the trapped frame's [ebp+4] was 0x0075bd4a [verified] +constexpr uint32_t ServerSystem_RecordVisibleOwner = 0x0075bca0; +// thiscall void (ServerSystem* this, ServerPlayer* p) // TShn = Frame, OID = 0 -- 'you now see this system as unowned'. Frame read at 0x0075bc5f [mapped] +constexpr uint32_t ServerSystem_RecordVisibleOwnerNone = 0x0075bc00; +// thiscall void (ServerSystem* this, ServerPlayer* from, ServerPlayer* to) // the NVO analogue of ShareObservation 0x00754d90: passes the source player's record verbatim, so TShn is NOT refreshed. No corpus save has an alliance, so this path has never executed (rule 6) [mapped] +constexpr uint32_t ServerSystem_ShareVisibleOwner = 0x0075b9b0; +// thiscall OwnRec* (ServerSystem* this, ServerPlayer* p) // map find on NVO (0x0074d2f2 `lea ecx,[esi+0x270]`, 0x0074d300 `cmp edx,[esi+0x274]`); returns node+0x10 or NULL. Every caller only READS the record [verified] +constexpr uint32_t ServerSystem_FindVisibleOwner = 0x0074d2e0; +// thiscall int (ServerSystem* this, ServerPlayer* p) // FindVisibleOwner then rec->OID [mapped] +constexpr uint32_t ServerSystem_GetVisibleOwnerId = 0x0074f710; +// thiscall bool (ServerSystem* this, ServerPlayer* p) // THE NVO GATE, and it is looser than IsVisibleTo 0x00743fb0. Whole body: `p && ( (this->AFlags(+0xd4) >> p->PlyrIdx(+0x28)) & 1 || ( p->PlyrIdx < 15 && ((this->SnLv(+0x24) >> 2*p->PlyrIdx) & 3) != 0 && TechTree::HasResearched(p->TechTree(+0xf4), 10000 /* CCC_AdvSens */) ) )`. Byte-decoded at 0x007463ae / 0x007463c0 / 0x007463cb / 0x007463da [verified] +constexpr uint32_t ServerSystem_IsKnownTo = 0x00746390; +// thiscall bool (ServerSystem* this, ServerPlayer* p) // ret 4. IsKnownTo inlined byte-for-byte (0x0075bd88 AFlags, 0x0075bd93 idx<15, 0x0075bd9e SnLv, 0x0075bdb2 HasResearched(0x2710)), then RecordVisibleOwner at 0x0075bdbe. Confirmed live: the trapped frame's [[ebp]+4] was 0x0075bdc3, the return of that call [verified] +constexpr uint32_t ServerSystem_RefreshVisibleOwnerIfKnown = 0x0075bd70; +// fastcall void (StrategyServer_S4* server) // for p in players(+0x50..+0x54) for s in systems(+0x40..+0x44): s->RefreshVisibleOwnerIfKnown(p) at 0x00813737. The player loop is UNFILTERED -- no NPC/species test anywhere on this path. Called from StrategyServer::UpdateSensors 0x0086a8d0 at 0x0086a91d [verified] +constexpr uint32_t SensorPhase_RefreshAllVisibleOwners = 0x00813700; +// thiscall int (Sensors* this, ServerPlayer* p, Entity* e) // 3 if IsVisibleTo (AFlags), else 1 if inside p's sensor envelope, else 0 (0x00850d18 / 0x00850e1b). Its result is packed into ServerSystem::SnLv(+0x24) at 0x0086a789 [mapped] +constexpr uint32_t SensorPhase_ComputeContactLevel = 0x00850cf0; +// thiscall void (ServerSystem* this, ServerPlayer* oldOwner, ServerPlayer* newOwner) // Game::ServerSystem vftable 0x00a2044c slot 6; records a visible-owner entry for both parties at 0x0075be77 / 0x0075bf2b [mapped] +constexpr uint32_t ServerSystem_ChangeOwner = 0x0075bde0; +// thiscall bool (StrategyServer_S4* this, int playerId, const float* rate) // ret 8. Bumps ModCount at 0x00821a84 then sets player->ResRate(+0xbc) = clamp(*rate, 0.0f, 1.0f), logging "OnCommand: Research rate %f constrained to %f for %s." when it clamps. NAMES lane W2's `StrategySim_ModCountBump_unresolved_00821a84`, the handler that fires 4x per End Turn on ref-turn2 -- once per player that has a client [verified] +constexpr uint32_t StrategyServer_OnCommand_SetResearchRate = 0x00821a80; +// thiscall bool (StrategyServer_S4* this, int playerId, const SysRatesCmd* cmd) // ret 8. Bumps ModCount at 0x0086c3e6. Strings "OnCommand: Unable to set system rates for %d(id). System not found." (0xa32448) and "OnCommand: Player setting system rates for system he does not own." (0xa32400). NAMES lane W2's `..._unresolved_0086c3e6` [verified] +constexpr uint32_t StrategyServer_OnCommand_SetSystemRates = 0x0086c3e0; +// thiscall int (StrategyServer_S4* this, int playerId, const TransferCmd* cmd, ...) // ret 0xc. Bumps ModCount at 0x0088befe. Strings "OnCommand: Cannot transfer ships. Player %d(id) not found." (0xa33248) and "TransferShips: Fleet flagged as empty, but still has ships.". NAMES lane W2's `..._unresolved_0088befe` [verified] +constexpr uint32_t StrategyServer_OnCommand_TransferShips = 0x0088bed0; +// thiscall bool (StrategyServer_S4* this, int requesterId, const AddOrderCmd* cmd, int* outReason) // ret 0xc. Bumps ModCount at 0x0084946b. cmd = {int which, id target, id design, int arg}; picks target->+0xa4 (which==0) or +0x98 (which==1) as the BuildQueue, validates with 0x0083ce50, then BuildQueue::AddOrder 0x00845260 push_backs onto the list at queue+0x10. Reached from the Build Screen 0x00656540 via 0x00762fd0, and from the command replay at 0x0089009f. NAMES lane W2's `..._unresolved_0084946b` [mapped] +constexpr uint32_t StrategyServer_OnCommand_AddBuildOrder = 0x00849460; +// offset site `inc DWORD PTR [ebx+0x4]` -- a SECOND ModCount bump INLINED into ApplyTurnCommandBatch 0x0088f9b0 (at +0x45a), distinct from the one lane W2 found at 0x0089046c. String "OnCommand: Cannot set research project for player %d(id). Player not found." (0xa30ed0). Fires 3x on turn1-state and 0x on turn2-state -- once per AI player that picks a research target on turn 1; the human player set a rate but no project, which is why the reference game's map banner reads "No Research Project Assigned" [verified] +constexpr uint32_t StrategyServer_OnCommand_SetResearchProject = 0x0088fe0a; +// thiscall bool (StrategyServer_S4* this, int playerId, const DesignCmd* cmd) // ret 8. Bumps ModCount at 0x0088291a. Strings "OnCommand: Cannot create design for player %d(id), player does not exist." (0xa32c40) and "OnCommand: Unable to create design. ID not specified." (0xa32c04). Fires 1x on turn1-state, 0x on turn2-state. ANSWERS LANE AI4'S FALSIFIER: command list 1 emits a design COMMAND, not merely a design object that the build order names [verified] +constexpr uint32_t StrategyServer_OnCommand_CreateDesign = 0x00882910; +// thiscall void (BuildQueue* this, int arg, ShipDesign* design) // builds an order record with five fields pre-set to -1 and push_backs it onto the std::list at this+0x10/+0x14 ("list too long" guard at 0x008452e4) [mapped] +constexpr uint32_t BuildQueue_AddOrder = 0x00845260; +// cdecl bool (ServerPlayer* who, BuildQueue* q, ShipDesign* d, int* outReason) // the 'can this be built' validator; writes a numbered reason (0,1,2,8,...) through arg 4 and calls BankruptcyLevel 0x0080db10 [mapped] +constexpr uint32_t StrategyServer_CanAddBuildOrder = 0x0083ce50; +// offset bool ReqCL -- 'this player requires a client'. THE PREDICATE BEHIND Player.Status: a player with ReqCL == 0 never reaches SetupAIPlayers' AI list (gate at 0x007bc25c), so never gets a StrategyClient, so is never the subject of MarkPlayerTurnEnded and keeps the 0 that ResumePlaying wrote. The script binding that creates the four '_NPC' pseudo-players clears it explicitly at 0x00797bcb [verified] +constexpr uint32_t ServerPlayer_off_ReqCL = 0x000000fd; +// thiscall bool (StrategyNetworkServer* this) // vftable slot 3. The game's OWN statement of which players must carry Status == 4: `for p in players: if (!p->ReqCL(+0xfd)) continue; if (p->Elim(+0xf8)) continue; if (p->Status(+0x164) != 4) return false;` -- loop 0x007cffb7..0x007d0002 with edi = 4 loaded at 0x007cffa3 [verified] +constexpr uint32_t StrategyNetworkServer_AllPlayersTurnEnded = 0x007cfd00; +// thiscall void (StrategyNetworkServer* this) // the ReqCL gate at 0x007bc25c decides which players get an AI client at all; AddAIPlayer 0x007bc333 -> 0x007b8ab0 [verified] +constexpr uint32_t StrategyNetworkServer_SetupAIPlayers = 0x007bc1c0; +// thiscall void (StrategyServer* this, const SNMPlayerEndTurn* msg) // ONE call per network message, not a loop: handle = msg->+0x4 straight off the wire, MarkPlayerTurnEnded(this+4, handle) at 0x007d9b25 with NO gate before the store. Reached from StrategyNetworkClient::OnMessage 0x00784816. The field tests at 0x007d9b5d/0x007d9ba2/0x007d9bb0 all follow the store and only decide whether to raise a 'waiting on X' event [verified] +constexpr uint32_t StrategyServer_OnPlayerEndTurn = 0x007d9af0; +// offset site `mov DWORD PTR [eax+0x164],1` -- the phase-31 Status write lane T2 located. Its gate, decoded at 0x007dcc78, is `if (p->IsAI(+0xf9) == 0 || p->+0xfa != 0)`, over the encounter records at S+0x1e8 (0x74-byte outer, 0x44-byte inner with the ServerPlayer* first). It reads NO species field [verified] +constexpr uint32_t StrategyServer_ProcessTurn_StatusOneStore = 0x007dcc8a; +// offset vftable for Game::SNMSetPlayerStatus (factory 0x0080f990, sizeof 0xc: vptr / +4 player INDEX / +8 status). Its handler is the OnMessage+0xa15 store lane W2 found: no gate, no field test -- the value comes verbatim off the wire. The sender is StrategyNetworkServer::Update 0x007cda40 at 0x007cdcf0, which computes `2*(flag!=0) + 2`, i.e. 4 when the player has nothing pending and 2 when it does [verified] +constexpr uint32_t Game_SNMSetPlayerStatus_vftable = 0x00a2d718; // thiscall void (StrategyServer* this /*base S*/) // phase 11 of OnAllCombatDone_Tail, called at 0x007d9714 as `mov ecx,esi; call`. 1117 B, three loops. LOOP 1 (0x007ae07a..0x007ae1e2) walks the 0x30-stride Game::NodePath records in the vector at (*(S+0x154))+0x8/+0xc, re-reading _Myfirst/_Mylast every iteration, and per record: (1) NodePath::RemainingLife(r, S->Frame) 0x006e2130, `test eax,eax; jg` -> not expired, NEXT RECORD, NO DRAW; (2) THE DRAW, `mov ecx,[esi+0x16c]; fld [0x009e2ea0] /*0.5f*/; call 0x008e6dd0` = Mars::RNG::Chance(0.5f), exactly one MT word; (3) `test al,al; je` -> roll failed, next record; (4) the 0x20000-fleet scan over S->Fleets (S+0x64/+0x68) calling 0x00703500(fleet,0x20000,0) then 0x0078c360(fleet,npid) and dropping the record when that returns 3; (5) push_back npid into a scratch vector. LOOP 2 collapses each collected line via 0x007a92e0(690 B) then 0x007a4700(2244 B); LOOP 3 posts the decay-stage events through NodePath::DecayStage 0x006e21b0. THE ONLY RNG SITE IN THE WHOLE 1117 BYTES: direct-call sweep to depth 5 over 140 functions from 0x007ae010 against {NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0, Twist 0x00426e00, Seed 0x0049fdf0} yields exactly one hit, 0x007ae010 -> 0x008e6dd0. Neither downstream function draws (138 and 49 functions reached, zero hits) -- caveat: direct calls only, their subtrees contain unresolved indirect sites [verified] constexpr uint32_t StrategyServer_NodeLineDecay = 0x003ae010; // site site, and a CORRECTION to findings/control-flow/combat-done-tail.md §3, which says "the roll is skipped for a line if any fleet with flag 0x20000 is targeting it". IT IS NOT: the fleet scan begins HERE, at 0x007ae0b2, which is 0x1d bytes AFTER the Chance(0.5f) call at 0x007ae0a5 and is reached only when the roll SUCCEEDED (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The scan therefore cannot change the draw count -- it suppresses only the collapse (the 0x007a92e0 / 0x007a4700 pair), never the draw. The straight-line order in loop 1 is: expiry test -> DRAW -> roll gate -> fleet gate -> collect. Lane K's headline claim, one NextFloat per expired node line per turn, survives intact and is now pinned to a concrete expiry formula (NodePath_RemainingLife) [verified] @@ -2267,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]