diff --git a/campaign/board.md b/campaign/board.md index 278fdeb..03fb9b8 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -200,3 +200,9 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | retreat pipeline: events and RNG | verify | verified | high | 100% | 2026-09-08 | EXACTLY TWO EVENT_* keys in the 327-function closure - EVENT_FLEET_RETREATED_VIA_TELEPORT (depth 1, and gate retreat is species 1 + mode 1 + a destination, the only path to it) and EVENT_FLEET_INTERCEPT_ABORTED (depth 3, posted to the INTERCEPTOR'S owner when a split empties a fleet). Both were already in lane J's 23 at the right depth; what is NEW is the condition for each and that these two are the ONLY ones this pipeline can post. DRAW-FREE CONFIRMED INDEPENDENTLY: zero RNG calls, zero inlined MT tempering immediates, including the fleet-name generator. The one 0x11c hit is a FALSE POSITIVE (FtMS = -1 in the fleet ctor) - no turn-results write here | | new failure mode: tail-call thunk in an argument list | meta | verified | high | 100% | 2026-09-08 | Lane B5 correcting ITSELF: it first read the fleet-creation call's arguments wrong, because a `ret`-N TAIL-CALL THUNK IN THE MIDDLE OF AN ARGUMENT LIST makes three pushes look like they belong to the wrong call. CHECKING `ret N` AGAINST THE PUSH COUNT CATCHES IT. Worth a rules line - and note the same thunks HID the OID allocation chain from a closure pass entirely | | duplicate-name check working as intended | meta | verified | high | 100% | 2026-09-08 | Two live demonstrations today. (1) Lane B5 found StarFleet_SetFlag ALREADY in addresses.json from lane B4, same address and same prototype DERIVED FROM A DIFFERENT PATH - entry dropped, agreement recorded as corroboration. (2) The generator HARD-ERRORED on my merge: `duplicate address entry 'StrategyServer_off_TradeManager': in lane-t.json and lane-v2.json`, catching an in-flight collision between a landed lane and a running one. Exactly the designed behaviour: two lanes disagreeing about an address surfaces instead of being silently last-wins | +| vtable inversion tool | meta | verified | high | 100% | 2026-09-08 | Lane V2, `tools/vtable_map.py`. VALIDATED BLIND FIRST: 12/12, inputs only the PE + Ghidra function starts + the existing RTTI walk, nothing hand-fed - site 0x007d8469 classified virtual, slot 10 recovered, receiver typed as member +0x158, class Game::ServerTradeManagerImpl, target 0x00893290, zero direct call sites. Member typing came from the StrategyServer CONSTRUCTOR (0x007d78d0), corroborated independently by a method entered on the +4 SUB-OBJECT storing the same two ctor results exactly 4 bytes lower - TWO FRAMES, ONE ANSWER, and it re-derives lane T's 0x154 from a different direction. THE MAP IS EXACT: 2,172 vftables -> class -> sub-object offset -> slot -> target plus the inverse, a vftable accepted only with a COL at [vftable-4] pointing at a real type descriptor; no inference, no FPs. `ghidra/vtable-owners.json` | +| SCALE OF THE INDIRECT BLIND SPOT | meta | verified | high | 100% | 2026-09-08 | **5,045 of the 5,207 functions named by a vtable slot have ZERO DIRECT CALL SITES.** That is the size of what every call-graph result in this campaign was blind to. Additionally, sweeping for jumps into another function's start finds **14,958 MORE EDGES** - and three of the nine parked inlined-draw functions have NO E8 CALLER AT ALL and would read as dead code without them (the same tail-call-thunk effect that hid the OID allocation chain from lane B5's first pass) | +| indirect resolution: honest precision | meta | verified | high | 100% | 2026-09-08 | Lane V2 reported this as two things with very different precision rather than one number. Of 17,577 indirect sites: 7,415 are `call [disp32]` import thunks (not dispatch), **6,398 are PROVEN virtual with an exact slot**, 2,948 REPORT UNRESOLVED because the backward resolver refuses to cross a branch target or an unmodelled opcode. RECEIVER TYPING IS NOT SOLVED: only 163 of 6,398 (2.5%) get a class - but on those the falsification test fires on 1 in 163 (0.6%) against a 70% random baseline. THE DISPLACEMENT-ONLY ROUTE MEASURED 81% OUT-OF-RANGE VS 58% FOR A RANDOM VTABLE - WORSE THAN CHANCE - SO IT IS REJECTED OUTRIGHT, NOT FLAGGED (rule 9 with the ranker off). Three real bugs the falsification test caught: [ebp+8] typed as a member of `this`; constructors given a vftable's sub-object offset instead of 0; carrier spans not ending at the epilogue's pop esi | +| lane K tier-4 blind spot CLOSED | control-flow | verified | high | 100% | 2026-09-08 | The tool reproduces lane K's byte-level transcription of phase 23 EXACTLY AND INDEPENDENTLY, then names all eleven targets: trade slots 14/8/12/11/9/7/13/15 -> 0x008590d0 0x0088e8d0 0x0088e920 0x00848570 0x00868060 0x0088ad60 0x0088ef80 0x0082cca0; the ninth call is on +0x15c = ServerSpyManager slot 13 -> 0x008877b0; phase 33 = spy slots 14/15. All eleven have ZERO direct call sites. ALSO: Game::ServerSpyManager has NO *Impl - it is itself concrete over IServerSpyManager/ISpyManager/IStreamable, the opposite of the ServerTradeManager shape, so the *Impl rule is a pattern to CHECK, not to assume | +| CORRECTION: "the tail draws nothing" is true of 8 TURNS, not of the code | verify | backlog | — | 0% | 2026-09-08 | FOUR of the eleven phase-23/33 targets REACH A DRAW on the strategic generator (StrategyServer+0x16c), verified at the instruction: 0x00887c8a, 0x00840929, 0x00840a3c, 0x008409c7, 0x0088dc43, 0x0082cdb8, 0x00820e18, 0x0088b613. NONE HAS EVER BEEN OBSERVED FIRING - lane Z measured 0 tail words across 8 turns. So tail-rng-ledger.md's "the tail draws nothing" is a PROPERTY OF EIGHT TURNS. RULE-18 ITEM: hook those four callees with a save that has LIVE TRADE ROUTES AND A SPY PROGRAM. Our corpus has neither | +| RNG re-check over indirect edges: nothing contradicted | verify | verified | high | 100% | 2026-09-08 | Lane I's 22-site inventory is intact and lane Z's residual stays zero. CORRECTION to how the nine parked functions were recorded: NONE of the eleven inlined-draw functions is itself in a vftable (no dword equal to any of them exists in the image) - their TOPMOST DIRECT-CALL ANCESTORS are, and all nine resolved. CrowRuinsEncounter/SwarmEncounter are CombatEncounterBase (16 slots), NOT SVScriptObject, so ProcessTurn's slot-11 dispatch is not a route to them. NEW DRAW SURFACE: the SVScriptObject hooks on StrategyServer+0x1b4 (lane T's field), 30 derived classes, 11 of 14 slot-4 overrides / 4 of 7 slot-11 / 2 of 8 slot-25 reach a draw - with TWO LIMITS STATED: the generator arrives as an ARGUMENT at depth 2-4 so its identity is unproven, and +0x1b4's class is a HYPOTHESIS here (no ctor store found; lane T had it verified, lane V2 records the WEAKER claim). STRENGTHENED: over direct + every pinnable indirect edge, ProcessTurn's closure grows 1,430 -> 1,486 and the tail's 1,424 -> 1,668, and the only draws those ~300 functions add are the eight above. The MAXIMAL over-approximation puts half the image (19,697/41,089) in the closure - USELESS, AND LANE V2 SAYS SO rather than dressing it up | diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index 5a8721b..3f144cb 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/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 @ 3d5f834, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 648028d, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1295,6 +1295,72 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310; constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730; // thiscall void (Mars::StreamableHelper* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified] constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400; +// 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] +constexpr uint32_t CombatRetreat_ChooseDestinations = 0x0039bb90; +// thiscall void (RetreatContext* this, Mars::Vector3* battlePos, ServerSystem* excludeSys, ServerPlayer* p, ServerSystem** out1, ServerSystem** out2, ServerSystem** out3) // ret 0x18. THE RETREAT DESTINATION FORMULA. THREE INDEPENDENT nearest-system searches sharing one pass over S->Systems (S->+0x44/+0x48, 4-byte stride), each with its own best-so-far seeded to the .rdata FLT_MAX at 0x009e23a8 (0x7f7fffff): out1 = nearest sys with FUN_007437e0(sys) == p (owner); out2 = nearest sys with FUN_00744070(sys, p) FALSE (no hostile presence); out3 = nearest sys, unconditional. Both skips: sys == excludeSys, and FUN_007469e0(sys). THE INDEPENDENCE IS LOAD-BEARING -- each 'best' store is INSIDE its predicate arm (0x007872c6 / 0x007872ec), so a nearer rejected system does not spoil that search; a single-loop 'find nearest then filter' gives a different answer. Distance is SQUARED: the three component deltas are each fstp'd to float32 first, the products and sum accumulate on the x87 stack, the total narrows to float32 once at 0x007872a3 (a double accumulator with one final narrowing is BIT-IDENTICAL, since a float32 delta squares exactly in double). Comparison is a strict < (fld best; fcomp d; test ah,0x41; jne skip), so an exact tie goes to the EARLIER system in the vector [verified] +constexpr uint32_t CombatRetreat_FindDestination = 0x00387210; +// thiscall bool (ServerSystem* this) // 33 B. return this->Dstyd(+0xc5) != 0 || (this->vnh(+0xc6) != 0 && this->vnex3(+0xc8) == 0). A destroyed system, or one hosting a Von Neumann hive that has not been cleared out. All three are SAVED bools (struct-recovery.md section 1), which is what identifies the class as Game::ServerSystem rather than a bare StarSystem. Note CombatResolve_NodeCannon 0x007bb530 uses only the +0xc5 half for its own destination filter [verified] +constexpr uint32_t ServerSystem_IsUnusableRetreatTarget = 0x003469e0; +// thiscall ServerPlayer* (ServerSystem* this) // 7 B: mov eax,[ecx+0x100]; ret. this->PID, the saved owner handle [verified] +constexpr uint32_t ServerSystem_GetOwner = 0x003437e0; +// thiscall bool (ServerSystem* this, ServerPlayer* p) // ret 4. return (ServerPlayer_HostileMaskAt(p, this) & this->AFlags(+0xd4)) != 0 [verified] +constexpr uint32_t ServerSystem_HasHostilePresence = 0x00344070; +// thiscall uint32 (ServerPlayer* this, ServerSystem* sys) // ret 4. THE HOSTILITY MASK, and it carries a diplomacy rule that is nowhere in findings/subsystems/: ownerBit = (sys && sys->PID(+0x100) && sys->TAcq(+0x80) == S->Frame) ? (1 << sys->PID->PlyrIdx) : 0; return ~( (1 << this->PlyrIdx(+0x28)) | (this->CF(+0x174) & ~ownerBit) | this->NA(+0x170) | this->AL(+0x16c) ). A SYSTEM WHOSE OWNER CAPTURED IT ON THE CURRENT TURN LOSES THAT OWNER'S CEASEFIRE COVER. The current turn is reached as this->+0x8->+0x8, which is S->+0xc = Frame, because ServerPlayer+0x8 == S+4. The complement is over all 32 bits, harmless only because it is immediately ANDed with a presence mask [verified] +constexpr uint32_t ServerPlayer_HostileMaskAt = 0x0040dff0; +// thiscall void (RetreatContext* this) // RETREAT PHASE 2. One loop over res->+0x68/+0x6c -- a 0x14-STRIDE VECTOR OF RETREAT REQUESTS (magic 0x66666667 / sar 3), a Game::EncounterResults member not previously mapped: req->+0x04 ship handle, req->+0x08 mode word, req->+0x0c requested-destination handle, req->+0x10 second key word. Per request: ship = HandleMap::Resolve(S->+0x84, req->+0x04); skip if null, if ship->Dep(+0x50) != 0, if ship->EncID(+0x7c) == 0x15, if FUN_004f47f0(ship->EncID) (a DB-driven encounter-type mask), or if ship->PlrID(+0x10) == S->Players[S->+0x1c0] (the locally-controlled player). dest = CombatRetreat_ResolveShipDestination(ctx, req, ship); on 0 it logs 'Retreat: Could not find retreat destination for %s.' at level 2 and skips. Then, UNLESS (req->+0x08 == 1 && owner->Species(+0x5c) == 1), skip the ship if StarShip_IsGroundedByDamage(ship). Finally find-or-create the group via CombatRetreat_FindGroup and push_back the ship [verified] +constexpr uint32_t CombatRetreat_BuildGroups = 0x0039bcd0; +// thiscall ServerSystem* (RetreatContext* this, void* req, StarShip* ship) // ret 8. Returns 0 when ship is null or ship->+0x04 (its handle id) is 0. Else: if req->+0x0c != 0, HandleMap::Resolve(S->+0x84, req->+0x0c) and RETURN IT if found -- the explicitly ordered destination; if not found, log 'Retreat: %s cannot retreat to specified location. Location not found.' at level 2 with ship->PlrID->+0x40 (the player's name std::string) and fall through. Fall-through: return this->dest[ship->PlrID(+0x10)->PlyrIdx(+0x28)], the phase-1 default [verified] +constexpr uint32_t CombatRetreat_ResolveShipDestination = 0x0039bc50; +// thiscall RetreatGroup* (RetreatContext* this, StarShip* ship, ServerSystem* dest, int mode, int variant) // ret 0x10. Linear scan of this->groups (+0x1c/+0x20) for the group matching ALL FOUR key words: g->+0x00 == ship->PlrID(+0x10), g->+0x04 == dest, g->+0x2c == mode, g->+0x30 == variant. Returns 0 when absent [verified] +constexpr uint32_t CombatRetreat_FindGroup = 0x003871c0; +// thiscall RetreatGroup* (RetreatGroup* this) // 41 B; ENUMERATES the 0x34-byte heap record (operator new(0x34) at 0x0079bdee). Writes: +0x00 owner ServerPlayer* = 0; +0x04 dest ServerSystem* = 0; +0x08/+0x0c/+0x10 vector = 0 (allocator +0x14 never written, per earned-rule 5); +0x18/+0x1c/+0x20 vector = 0 (allocator +0x24); +0x28 BYTE partial = 0; +0x2c int mode = 0; +0x30 int variant = -1. Total 0x34 [verified] +constexpr uint32_t RetreatGroup_Ctor = 0x0038f1c0; +// thiscall bool (StarShip* this) // 46 B. design = this->DesID(+0x14); return (design->+0x74 != 0 || design->+0x78 != 0) && design->+0x12c < 2 && (FLT_EPSILON > this->+0x30). The .rdata float at 0x009e1ef8 is 0x34000000 = 1.1920929e-07, FLT_EPSILON -- the test is against an epsilon, NOT against zero, and it is strict, so a health exactly AT the epsilon is not grounded. this->+0x30 is the third float of the inline ShipHealth at 0x24..0x33 (R1 guesses drive). True means the ship CANNOT retreat. The species-1 / mode-1 gate path skips this test entirely [verified] +constexpr uint32_t StarShip_IsGroundedByDamage = 0x00415090; +// cdecl bool (int encounterType) // 67 B. DB-SOURCED, DO NOT HARD-CODE. Returns false for encounterType > 0x17. Otherwise lazily builds a bitmask once (flag at 0x00b0e988, cache at 0x00b0e984) by FUN_004f47b0, which ORs 1 << table[i] over a count at 0x00ae04e0 and an int array at 0x00adfe88 -- both .bss, filled from the game data files at load -- and returns (mask >> encounterType) & 1. The 0x17 bound is the same EncounterType range PickDominantEncounterType 0x004f4c40 returns over [verified] +constexpr uint32_t IsBlockedEncounterType = 0x000f47f0; +// thiscall void (RetreatContext* this) // RETREAT PHASE 3. Per group in this->groups: tally the group's ships by ship->FltID(+0x64) into a local 8-byte-stride vector<{StarFleet*, int}> (linear search, push {f,0} then ++count); then per distinct fleet f, if count == (f->+0xa8 - f->+0xa4)/4 (i.e. EVERY ship the fleet has is in this group) push f into g->fleets(+0x18), else set g->partial(+0x28) = 1. WARNING: the jb/jae pairs at 0x007b0504 and 0x007b0570 are MSVC's inlined vector::push_back aliasing check (_Inside(&_Val)), NOT a phase gate -- both arms converge on *_Mylast = value; _Mylast += 4 at 0x007b05ce. Draw-free, event-free, writes only the group records [verified] +constexpr uint32_t CombatRetreat_ClassifyFleets = 0x003b0320; +// thiscall void (RetreatContext* this) // RETREAT PHASE 4, and THE BIGGEST SAVED-STATE WRITER ON THE COMBAT PATH. Per group, gated on g->partial(+0x28) != 0: newFleet = StrategyServer_CreateFleet(S+4, IDMap_AllocateLocalID(S+4), g->owner, &enc->+0x1c, NULL) -- note the id mint is evaluated INSIDE the argument list, see IDMap_AllocateLocalID; push newFleet into g->fleets BEFORE the ship loop; then per ship in g->ships, skip it if ship->FltID(+0x64) is ALREADY in g->fleets (its whole fleet is retreating, leave it), else StarFleet_RemoveShip(old, ship), and if ((old->+0xa8 - old->+0xa4) & ~3) == 0 call StrategyServer_DestroyFleet(S+4, old, 0, 0), then StarFleet_AddShip(newFleet, ship). Finally if (enc->+0x0c) StarSystem_FleetArrives(enc->+0x0c, newFleet). PREDICTED ORIGINAL BUG: the fleet is created before the loop, so a group flagged partial creates a fleet even when every ship turns out to belong to a wholly-retreating fleet -- a zero-ship fleet in the save [verified] +constexpr uint32_t CombatRetreat_SplitFleets = 0x00390790; +// thiscall void (StarFleet* this, StarShip* ship) // ret 4. this->NShips(+0xa4).push_back(ship); SHIP->FltID(+0x64) = this (the saved handle); design = ship->DesID(+0x14); this->+0xb8 |= design->+0xb8 and this->+0xbc |= design->+0xbc (capability masks); this->+0xc0 (WORD, the fleet's gate-traffic cost) += FUN_0056f430(design->+0x12c); if bit 0x20000 of +0xb8 changed state, push_back this into (this->+0x10)->+0x114; FUN_00705c70(this) to recompute derived stats [verified] +constexpr uint32_t StarFleet_AddShip = 0x003062e0; +// thiscall void (StarFleet* this, StarShip* ship) // ret 4. Linear-search this->NShips(+0xa4/+0xa8) for ship; return if absent. If the fleet's flight plan is non-empty (+0xc8 != +0xcc) and IsGateTransitWaypoint(wpt->+0x08) 0x0056e6e0, this->PID(+0x58)->GTraf(+0x14c) -= (int16)this->+0xc0. Then SHIP->FltID(+0x64) = 0; erase the slot (memmove + _Mylast -= 4); FUN_00700090(this); FUN_00705070(this, ship); re-add the gate-traffic term under the same test; if bit 0x20000 of +0xb8 changed, find-and-erase this from (this->+0x10)->+0x114; if any ship remains, FUN_00705c70(this). GTraf is a SAVED ServerPlayer field [verified] +constexpr uint32_t StarFleet_RemoveShip = 0x003063a0; +// thiscall void (ServerSystem* this, StarFleet* f) // ret 4. f->Pos(+0x18/+0x1c/+0x20) = this->Pos (three raw dword copies); StarFleet_SetLocation(f, this); bit = 1 << f->PID(+0x58)->PlyrIdx(+0x28); this->FFlags(+0xd8) |= bit; this->+0x16c.push_back(f) (the SAVED NumFlts/Flt list); then recompute: if (FFlags & bit || GFlags(+0xdc) & bit || PID(+0x100) == owner) { AFlags(+0xd4) |= bit; VFlags(+0xcc) |= bit; } else AFlags &= ~bit -- on this path the else arm is unreachable because FFlags was just set [verified] +constexpr uint32_t StarSystem_FleetArrives = 0x0034f240; +// thiscall void (StarFleet* this, void* loc) // 16 B: this->LocID(+0xa0) = loc. Saved handle [verified] +constexpr uint32_t StarFleet_SetLocation = 0x002fe2f0; +// thiscall void* (StarFleet* this) // 19 B: eax = this->LocID(+0xa0); return (eax && eax->+0x14 == 0) ? eax : 0. The +0x14 word is a location-class tag; the sibling FUN_006fe320 returns it only when the tag is 2. The retreat path uses this one, whose class carries the fleet list at +0x16c and the name std::string at +0xa8 [verified] +constexpr uint32_t StarFleet_GetLocationIfNode = 0x002fe300; +// thiscall void (RetreatContext* this) // RETREAT PHASE 5. Two nested loops: over this->groups, then over g->fleets (whole fleets from phase 3 plus any new fleet from phase 4). Per fleet f: (a) if (enc->+0x0c && !ServerSystem_IsExploredBy(enc->+0x0c, f->PID(+0x58))) StrategyServer_GrantSystemIntel(S, S->+0x44[enc->+0x0c->Idx(+0x5c)], f->PID) -- RETREATING FROM AN UNEXPLORED SYSTEM REVEALS IT, writing the SAVED EFlags and PlayerView; (b) if CombatRetreat_UsesGate(g) && g->dest(+0x04) != 0, the GATE ARM: StarSystem_FleetDeparts(GetLocationIfNode(f), f), StarSystem_FleetArrives(g->dest, f), then two _snprintf'd strings from the .bss format-string pointers at 0x00aedf0c and 0x00aedf14 (destination Name(+0xa8), fleet FtName(+0x5c)) and EventStorage_PostEvent(ServerPlayer_GetEventStorage(f->PID), {msg, summary, f, 0, S->Frame, 'EVENT_FLEET_RETREATED_VIA_TELEPORT', 0}); (c) else the MOVE ARM: if (!StrategyServer_OrderFleetMove(S+4, f, &g->dest, 1)) log 'Retreat: Unable to set destination for retreat for %s.' at level 2 with f->PID->+0x40, else StarFleet_SetFlag(f, 2, true) and StarSystem_FleetDeparts(GetLocationIfNode(f), f) [verified] +constexpr uint32_t CombatRetreat_Execute = 0x003d5650; +// cdecl bool (RetreatGroup* g) // 48 B, ret 4 (ecx is set at the one call site but unused). return g != 0 && g->mode(+0x2c) == 1 && g->owner(+0x00) != 0 && g->owner->Species(+0x5c) == 1 && g->dest(+0x04) != 0. Species 1 is the gate-building species; this is the ONLY gate on EVENT_FLEET_RETREATED_VIA_TELEPORT, which is posted nowhere else in the image. The same (mode == 1 && species == 1) pair is what lets a ship with a dead drive past StarShip_IsGroundedByDamage in phase 2 [verified] +constexpr uint32_t CombatRetreat_UsesGate = 0x003859f0; +// thiscall bool (ServerSystem* this, ServerPlayer* p) // 34 B, ret 4: return (this->EFlags(+0xd0) & (1 << p->PlyrIdx(+0x28))) != 0. EFlags is a SAVED int [verified] +constexpr uint32_t ServerSystem_IsExploredBy = 0x00343880; +// thiscall void (ServerSystem* this, ServerPlayer* p, bool on) // 82 B, ret 8. bit = 1 << p->PlyrIdx(+0x28); wasSet = (this->EFlags(+0xd0) & bit) != 0; on ? EFlags |= bit : EFlags &= ~bit; then this->vft[0x1c](p, wasSet, on) -- AN INDIRECT EDGE, unresolved here, flagged for the vtable-inversion lane [verified] +constexpr uint32_t ServerSystem_SetExploredBy = 0x003438b0; +// thiscall void (StrategyServer* this /*the S frame*/, ServerSystem* sys, ServerPlayer* p) // 195 B, ret 8. Returns immediately if either pointer is null. ServerSystem_SetExploredBy(sys, p, true); FUN_006e4620(this->+0x154 /*the node-line/graph object*/, sys, p, 1); then builds a stack temp via FUN_00755ab0, fills it with FUN_007561d0(sys, &tmp, p), and applies it through FUN_007d1f00(this->+0x224, &sys->Idx(+0x5c), &p->PlyrIdx(+0x28), &tmp) -> FUN_0075cf50 -> FUN_00753430, destroying the temp with FUN_006bba00. this->+0x224 is INFERRED to be the per-(system, player) StarSystem::PlayerView map -- combat-done-tail.md phase 17 has its tree head at S+0x228 and its size word at S+0x22c, which puts the map object at S+0x224 -- but the final leg FUN_00753430 was NOT read, so the exact field written is a labelled hypothesis [hypothesis] +constexpr uint32_t StrategyServer_GrantSystemIntel = 0x003d5080; +// thiscall void (RetreatContext* this) // RETREAT PHASE 6, and it is only a destructor -- it writes no game state. FUN_00793090(this->+0x1c, this->+0x20) deletes every RetreatGroup; then vector::erase(begin,end) (a degenerate memmove of 0 bytes plus _Mylast = _Myfirst) and operator delete on the buffer, zeroing +0x1c/+0x20/+0x24; then _Tree::erase(head->_Left, head) via FUN_006a4e40 on the map at this->+0x0c and operator delete on its head node [verified] +constexpr uint32_t CombatRetreat_ContextDtor = 0x003a7cd0; +// thiscall StarFleet* (StrategyServer* this /*the S+4 frame*/, int oid, ServerPlayer* owner, Mars::Vector3* pos, const char* nameOverride) // 347 B, ret 0x10. operator new(0x120) -- SIZEOF(Game::StarFleet) = 0x120 BY ENUMERATION, agreeing with struct-recovery.md section 3 whose last member FtMS sits at +0x11c -- then ctor FUN_00706140; IDMap_Insert(this+0x80, obj, oid) which writes the id into obj->+0x04; obj->+0x10 = this (the S+4 pointer, NOT a ServerPlayer as it is on StarShip); obj->PID(+0x58) = owner; the name from FUN_00856310(owner) (-> FUN_00855f80 on owner+0x298) assigned into obj->FtName(+0x5c) unless nameOverride is given; pos copied to BOTH +0x18/+0x1c/+0x20 (Pos) and +0x4c/+0x50/+0x54 (PrvPos); StarFleet_SetFlag(obj, 0x400, true); push_back into the vector at this->+0x60 (= S+0x64), THE SAVED NumFlts/FltID/Flt MASTER LIST; then the virtual (*this)->vft[0x10](this, obj). Draw-free, including the name generator. DELEGATED instruction-level read; the call site is mine [verified] +constexpr uint32_t StrategyServer_CreateFleet = 0x0045b340; +// thiscall int (StrategyServer* this /*the S+4 frame*/) // AN 8-BYTE TAIL-CALL THUNK: sub ecx,0xffffff80 (ecx += 0x80) then jmp FUN_008b8b70, which is push [ecx+0x18] (the local node index); call IDMap_AllocateID; ret. IT TAKES NO STACK ARGUMENTS. At the one retreat call site 0x007907d8 three pushes sit immediately before it and belong to the LATER call to StrategyServer_CreateFleet -- the id mint is evaluated inside that call's argument list. Checking ret N against the push count is what catches this; see combat-retreat-pipeline.md section 7.3. The IDMap subobject therefore lives at (S+4)+0x80 [verified] +constexpr uint32_t IDMap_AllocateLocalID = 0x0040f710; +// thiscall int (IDMap* this, int nodeIndex) // ret 4. THE OBJECT-ID ALLOCATOR. Closes struct-recovery.md section 7's open item 'OID allocation (R2's x16)': R2's x16 is the shl 4 below. Layout: this->+0x08/+0x0c is a vector of STRIDE 0x14 (pinned twice -- FUN_008b8a70's 0x66666667 / sar 3 divide-by-20, and the lea eax,[edi+edi*4] with scale 4 here); NodeEntry is a std::map (0x10) followed by the counter at +0x10; this->+0x18 is the local node index. Body: returns 0 if nodeIndex == -1 (logs 'IDMap: Map not initialized.') or the node does not exist; else 0x008b8b16 'inc DWORD PTR [ecx+eax*4+0x10]' PRE-INCREMENTS the per-node counter, and on wrap to 0 logs 'IDMap: NextID wrapping for node %d.' and increments AGAIN (0x008b8b30), so 0 is never issued -- 0 is INVALID_NETWORK_ID; then id = (counter << 4) | (nodeIndex & 0xF) at 0x008b8b38/0x008b8b3e, with an overflow log if the counter no longer round-trips. THE COUNTER IS ALMOST CERTAINLY THE SAVE'S 'NMnx' TAG (StrategyServer block order: KeyPath, NMSz, NMLc, NMnx, ModCount, Frame) -- LABELLED HYPOTHESIS: StrategyServer::Write 0x0079fa70 was not read for it. DELEGATED instruction-level read [verified] +constexpr uint32_t IDMap_AllocateID = 0x004b8ae0; +// thiscall void (IDMap* this, void* obj, int id) // ret 8. Logs 'IDMap: Object already exists with id %d.' and continues if the id is taken; refuses id 0 with 'IDMap: Object with INVALID_NETWORK_ID not inserted.'; node = id & 0xF, bounds-checked; WRITES THE ID INTO obj->+0x04 (the NetworkObject handle slot every serialized object uses); then _Buynode + _Insert into the map at _Myfirst + node*0x14. DELEGATED instruction-level read [verified] +constexpr uint32_t IDMap_Insert = 0x004b9350; +// thiscall void (StrategyServer* this /*the S+4 frame*/, StarFleet* f, bool killRemainingShips, int shipDestroyArg) // 1352 B, ret 0xc. Called from the retreat split as (f, 0, 0), so the ship-massacre block 0x0088bbf1..0x0088bc82 is dead on that path. A REAL DELETE (vtable slot 0 with flag 1, the MSVC scalar deleting destructor, at 0x0088bea6). Order: (1) sweep the master fleet vector this->+0x60/+0x64 for any fleet whose flight plan (+0xc8/+0xcc, waypoint stride 0x1c) carries a waypoint whose field_0 is f's id -- for each, re-plan it via FUN_00707290 and POST 'EVENT_FLEET_INTERCEPT_ABORTED' to THAT fleet's owner, inlining player+0x29c rather than calling ServerPlayer::GetEventStorage; (2) optionally destroy remaining ships; (3) erase from the owner's fleet map at owner+0x2b4; (4) per player, FUN_00812110(player+0x29c, fleetId, -1) to purge queued events referencing it; (5) erase from this->+0x60 or log 'StrategySim: Can't delete %s (%i), not found in master list!'; (6) detach from its location; (7) vft[0x14](f), erase from this->+0x114, FUN_008b92a0(this+0x80, f); (8) delete. No RNG, no inlined MT draw, no SETurnResults stride. DELEGATED instruction-level read, full body to the next function start [verified] +constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980; +// thiscall bool (StrategyServer* this /*the S+4 frame*/, StarFleet* f, ServerSystem** dests, unsigned count) // 801 B, ret 0xc. Returns false if f is null, or if the path solver FUN_007066c0 sets any of bits 0x008 / 0x010 / 0x400 in its flag word (then logging 'StrategySim: %s (%s) move not permitted at this time.' at level 2) -- THE MEANING OF THOSE THREE BITS IS UNKNOWN, FUN_007066c0 was not opened. Before that gate it SNAPS the fleet's position onto its current system when they differ, tested by FUN_0080ec50 with fucompp EXACT IEEE EQUALITY and no epsilon; that snap is committed even on the false path. On success it builds a 0x1c-stride waypoint vector and commits it through FUN_00707080, which debits owner->GTraf(+0x14c) by (int16)f->+0xc0, writes f->+0xd8..+0xf8 and +0x100..+0x108 (cached source/destination positions and the leg record), then re-credits GTraf; finally it cancels conflicting ship actions via FUN_00849280. No RNG and no x87 in the function itself. DELEGATED instruction-level read, full body to the next function start [verified] +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; // thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified] constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670; // thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified] @@ -1411,6 +1477,52 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0; constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0; // note 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 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) [verified] constexpr uint32_t SNMAllCombatDone_layout = 0x00624758; +// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified] +constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480; +// thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified] +constexpr uint32_t ServerSystem_GroupOutput = 0x0034b7a0; +// thiscall double (ServerSystem* sys, int groupType) // `ret 4`, real end 0x0074da01. Sums GroupOutput over species 0..6 using GroupPopulation(groupType, species). For groupType 1 and the owner's own species on a non-independent system it first adds a capacity surplus: two calls to 0x0074a6d0 with the int64 out-pointer in argument slot 4 and then in slot 6, surplus = max(0, B - A). The income analogue is 0x0074d760 [verified] +constexpr uint32_t ServerSystem_PopOutput = 0x0034d8f0; +// thiscall double (ServerSystem* sys) // real end 0x0074b901. Sums over species 0..6: max(0, POPTYPE[2].outputMod x 1.8 x (slaves / 500000)). Does NOT go through GroupOutput, so it carries neither the station factor nor the morale multiplier. Slave counts come from 0x0074b610 [verified] +constexpr uint32_t ServerSystem_SlaveOutput = 0x0034b880; +// thiscall float (ServerSystem* sys) // real end 0x00747ddd. R = clamp01(signed_cbrt((pbon + Pop) / 100) x 0.01); if 0.0001 + R >= 1 the infrastructure term SUBSTITUTES it outright (R := float32(ibon + Infra)); returns float32(min(R, float32(ibon + Infra))). The substitution is not a clamp: a colony with a pending infrastructure bonus can return a fraction above 1 [verified] +constexpr uint32_t ServerSystem_StripMineFraction = 0x00347d30; +// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007484c4. CORRECTS strategic-turn-internals.md 3.3, which called this the population base-output term: its population is the RESOURCE stock (Res, plus MRes + ARes2 when the owner strip-mines), and the species constant at +0x4c is ADDED, not a cap -- the cap is the resource stock. B = rate > 0 ? max(rate x available x clamp01((pbon + Pop) x 1e-5), 1.0) : 0; return min(available, max(speciesBaseDemand + B, 0)). Called twice per output pass: once inside ComputeTotalOutput and once by ComputeOutputFromRates as the resource ledger's strip-mine demand [verified] +constexpr uint32_t ServerSystem_OverHarvestDemand = 0x003483b0; +// thiscall int64 (ServerSystem* sys, int groupType, int species) // groupType 0 returns (pbon + Pop) sign-extended, but ONLY for the system's effective species (indi->+4 when independent, else owner->Species) and 0 for every other; otherwise Population::Count(pbon2) + Population::Count(Pop2) for that (type, species) [verified] +constexpr uint32_t ServerSystem_GroupPopulation = 0x00347ba0; +// thiscall double (ServerSystem* sys, int species) // `ret 4`. Returns 1.0 when the system has no owner, is independent, or its Morale int[7] entry for the species is exactly 0 -- the zero guard matters, because 0 <= MORALE_DECREASE_OUTPUT would otherwise apply the penalty to every species with no record. Otherwise m >= MORALE_INCREASE_OUTPUT -> MORALE_INCREASE_OUTPUT_MOD, m <= MORALE_DECREASE_OUTPUT -> MORALE_DECREASE_OUTPUT_MOD, each used only when strictly positive, else 1.0 [verified] +constexpr uint32_t ServerSystem_MoraleOutputMod = 0x00346910; +// cdecl PopTypeRow* (int groupType) // table base 0x00b104e8, stride 0x30, three rows; groupType > 2 falls back to a lazily-initialised sentinel row at 0x00b10578 that no caller reaches. Row fields used here: +0x10 output modifier, +0x14 income modifier, +8 maximum population [verified] +constexpr uint32_t PopTypeRow = 0x00135e00; +// cdecl void () // builds the three-row population-type table IN CODE from x87 literals, rotating six values with fxch rather than storing them. Imperial {+4 1.0, maxpop 50000000, out 1.0, income 1.0, +0x18 1.0, +0x1c 1.0, +0x20 1.0}; civilian {+4 0.25, maxpop 20000000, out 0.33, income 0.33, +0x18 1.0, +0x1c 0.5, +0x20 2.0}; slaves {+4 0.0, maxpop 0, out SLAVES_OUTPUT_MOD, income SLAVES_INCOME_MOD, +0x18 SLAVES_REPAIR_MOD, +0x1c 0.0}. So the whole population -> output law is carried by the executable except the three slave modifiers [verified] +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] +constexpr uint32_t ServerPlayer_off_ScOutMod = 0x0000012c; +// offset offset float -- the game-setup handicap OUTPUT multiplier, the sibling of SetupIncomeMult (+0x228) and SetupResearchMult (+0x22c). Read by ComputeTotalOutput as the third of its five multipliers [verified] +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] +constexpr uint32_t GlobalConst_slot_MORALE_INCREASE_OUTPUT = 0x006ec784; +// data float** -- pointer slot read by MoraleOutputMod [verified] +constexpr uint32_t GlobalConst_slot_MORALE_INCREASE_OUTPUT_MOD = 0x006ec78c; +// data int** -- pointer slot read by MoraleOutputMod [verified] +constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT = 0x006ec794; +// data float** -- pointer slot read by MoraleOutputMod [verified] +constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT_MOD = 0x006ec79c; +// data float -- .bss storage reached through the slot at 0x00ae2e88; the output modifier of the slave row of the population-type table. SLAVES_INCOME_MOD is 0x00b0e9ac (slot 0x00ae2e84) and SLAVES_REPAIR_MOD is 0x00b0e9b4 (slot 0x00ae2e8c) [verified] +constexpr uint32_t GlobalConst_storage_SLAVES_OUTPUT_MOD = 0x0070e9b0; // data const float 0.5f -- the progress-ratio threshold in ServerPlayer::ProcessTurn's `ResT != NULL && ResErrRoll != 0 && CONST < progressRatio` gate (events.md §3, window 0x008915ec-0x00891624). Read out of dumps/sots.exe: .rdata bytes at 0x00a2c788 are 00 00 00 3f (float 0.5); the following word 0x00a2c78c is float 100.0, so this is a float32, NOT the double an 8-byte read would suggest (that reads as 5.28e13). CONSEQUENCE, measured on the live game: ResErrRoll survives into ProcessResearch only while progress/cost <= 0.5 at the START of the turn, so the OnTechResearched draw (0x0088df20) can fire only when a single turn supplies more than half the target tech's remaining cost. See the board's `research_roll_pending save` row [verified] constexpr uint32_t ResearchRollProgressThreshold = 0x0062c788; // 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] @@ -1555,6 +1667,34 @@ constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3; 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] +constexpr uint32_t ServerSpyManager_vftable = 0x0063073c; +// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 7 (+0x1c). Reached ONLY virtually, from 0x007d97ea in StrategyServer::OnAllCombatDone_Tail phase 23 (call 6 of 8). Zero direct call sites. Body not read; its direct+tail-jump closure is 189 functions and contains no RNG entry point and none of the eleven inlined-draw functions [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot7 = 0x0048ad60; +// thiscall void (Game::ServerTradeManagerImpl* this, int arg1) // vftable 0x00a31b74 slot 8 (+0x20). Reached ONLY virtually, from 0x007d97b6 in OnAllCombatDone_Tail phase 23 (call 2 of 8); the site pushes one argument. Closure 86 functions, draw-free [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot8 = 0x0048e8d0; +// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 9 (+0x24). Reached ONLY virtually, from 0x007d97dd (phase 23, call 5 of 8). Closure 185 functions, draw-free [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot9 = 0x00468060; +// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 11 (+0x2c). Reached ONLY virtually, from 0x007d97d0 (phase 23, call 4 of 8). Closure 188 functions, draw-free [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot11 = 0x00448570; +// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 12 (+0x30). Reached ONLY virtually, from 0x007d97c3 (phase 23, call 3 of 8). Closure 193 functions, draw-free [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot12 = 0x0048e920; +// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 13 (+0x34). Reached ONLY virtually, from 0x007d97f7 (phase 23, call 7 of 8). REACHES THE STRATEGIC GENERATOR: -> 0x00820ca0, NextFloat at 0x00820e18 with the generator loaded as [reg+0x16c] then `lea ecx,[ecx+4]`; and -> 0x0088b440, NextInt at 0x0088b613 with `mov ecx,[ecx+0x16c]; add ecx,4`. Neither was ever observed firing: lane Z measured 0 tail words on 8 turns [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot13 = 0x0048ef80; +// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 14 (+0x38). Reached ONLY virtually, from 0x007d97a7 (phase 23, call 1 of 8). Closure 143 functions, draw-free [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot14 = 0x004590d0; +// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 15 (+0x3c). Reached ONLY virtually, from 0x007d9804 (phase 23, call 8 of 8). REACHES THE STRATEGIC GENERATOR: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 as `mov eax,[eax+0x16c]` then `mov ecx,eax`. Never observed firing [mapped] +constexpr uint32_t ServerTradeManagerImpl_vslot15 = 0x0042cca0; +// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 13 (+0x34). Reached ONLY virtually, from 0x007d9811 in OnAllCombatDone_Tail phase 23 -- the ninth call of the block, and the only one whose receiver is StrategyServer+0x15c rather than +0x158. REACHES THE STRATEGIC GENERATOR: Chance at 0x00887c8a on `mov ecx,[ecx+0x16c]`, and through 0x008408e0 Chance at 0x00840929 and 0x00840a3c plus NextInt at 0x008409c7, all on [reg+0x16c]. Never observed firing [mapped] +constexpr uint32_t ServerSpyManager_vslot13 = 0x004877b0; +// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 14 (+0x38). Reached ONLY virtually, from 0x007d989b in OnAllCombatDone_Tail phase 33 (call 1 of 2). REACHES THE STRATEGIC GENERATOR: Chance at 0x0088dc43 on `mov ecx,[eax+0x16c]`. Never observed firing [mapped] +constexpr uint32_t ServerSpyManager_vslot14 = 0x0048db80; +// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 15 (+0x3c). Reached ONLY virtually, from 0x007d98a8 in OnAllCombatDone_Tail phase 33 (call 2 of 2). Closure 230 functions, draw-free [mapped] +constexpr uint32_t ServerSpyManager_vslot15 = 0x00487f30; +// thiscall void (void* this) // the do-nothing body every Game::SVScriptObject-derived class inherits in the hook slots it does not override. Occupies most of slots 4/11/25/27/30 across the 30 SVSO classes, which is what makes the non-stub overrides countable: 14 at slot 4, 7 at slot 11, 8 at slot 25, 1 at slot 30, 0 at slot 27 [mapped] +constexpr uint32_t SVScriptObject_EmptyOverride = 0x0040c5a0; // cdecl Game::SVScriptObject* (int encID) // The EncObj factory. `dec eax; cmp eax,0x16; ja ; jmp dword [eax*4 + 0x0052bf60]` -- a 23-entry dword jump table indexed by encID-1. Live ids: 1 VonNeumann, 3 Swarm, 4 Derelict, 5 Monitor, 7 SystemKiller, 8 PuppetMaster, 9 SlaversRefuel, 10 SwarmQueen, 14 Locust, 17 CrowRuins, 20 Refugees, 21 Ortgay. Ids 2, 6, 11-13, 15, 16, 18, 19, 22, 23 and everything outside 1..23 return NULL. Class names read off the vftable store in each ctor [verified] constexpr uint32_t SVScriptObject_FactoryByEncID = 0x0012bf00; // data void* [23] // the jump table SVScriptObject_FactoryByEncID indexes with encID-1 [verified]