From de18d2c91dc1bd5c670711afded0771ce48cc19f Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 08:51:03 -0400 Subject: [PATCH] K: the research-event roll costs one or two RNG words, not one Every Coverage note in this repo said RollResearchEvent draws "exactly one NextFloat". That is the cost of REACHING its branch. When the roll beats the odds, the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, and the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the current research. Nothing has caught this because the branch has not fired in three sessions. Corrected in research.h (two Coverage entries plus the scope comment, and the branch entry raised to Risk::High), research.cpp, tech_effects.h and tech_effects.cpp. Ours still models the first word only; the branch stays declared unmodelled, now accurately. Header regenerated from sots-re bb0f990 (750 entries) - lane K's map of the combat-done tail: the autosave and save-file writer, the bankruptcy limits, the turn-results accumulator and outbox, and the encounter-block callees. --- include/generated/sots_addresses.h | 62 ++++++++++++++++++++++++++++-- src/shim/hooks/research.cpp | 8 +++- src/shim/hooks/research.h | 30 ++++++++++----- src/shim/hooks/tech_effects.cpp | 7 +++- src/shim/hooks/tech_effects.h | 5 ++- 5 files changed, 94 insertions(+), 18 deletions(-) diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 52c8f95..03d3f91 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 @ 834eb09, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ bb0f990, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -449,7 +449,7 @@ constexpr uint32_t g_AITechValueCount = 0x006ea2ec; constexpr uint32_t ServerPlayer_off_ServerLink = 0x00000008; // offset Mars::RNG* -- the strategic generator (same object TechTree::ProcessResearch is handed) [verified] constexpr uint32_t StrategyServer_off_RNG = 0x0000016c; -// fastcall void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws EXACTLY ONE NextFloat from StrategyServer's generator, unconditionally, then fires 0x00889d60 when odds > roll. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion [verified] +// fastcall void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws ONE NextFloat from StrategyServer's generator (entered at rng+4), unconditionally, then fires ServerPlayer_OnResearchRollSucceeded (0x00889d60) when odds > roll (fcompp + test ah,0x41, so equality also skips). THAT ONE WORD IS THE COST OF REACHING THE BRANCH, NOT OF A FIRED ROLL: the plague path inside draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK; the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the research with no further draw. So a fired roll costs one or two words. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion [verified] constexpr uint32_t ServerPlayer_RollResearchEvent = 0x0048df20; // thiscall float (ServerPlayer* this, TechDef* def) -- 0 when def is null; a plague-family path via 0x00535480, else the AI-rebellion path via AITechRow (odds column) gated on !NPC. Read-only, makes no draw [verified] constexpr uint32_t ServerPlayer_ResearchEventOdds = 0x00420380; @@ -1295,6 +1295,62 @@ 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 (StrategyServer* this /*base S*/, std::vector* results) // RET 4. THE SECOND TURN DRIVER. Reached from exactly one caller: StrategyHost::OnMessage 0x00784640 at 0x00784d07, on the SNMAllCombatDone message, with this = host->+0x54 and results = msg+4. 36 phases, whole 1587-byte body read from the instruction stream. Base is S (NOT S+4): Players at [S+0x54/0x58], ServerTradeManager at [S+0x158], SVScriptObject at [S+0x1b4], encounters at [S+0x1e8]. STRAIGHT-LINE past 0x007d96bf -- every jcc from there on is a per-player loop bound or one of three null tests on S+0x1b4. Order: ++S->+0x8 / arity check / first contact over all ordered combatant pairs / sighting announce / battle tally / diplomacy stats / ApplyEncounterResult per encounter + resupply / encounters.clear() / script(8) / AIRebellion(1) / ProcessNodeSpaceTravel / node-line decay (RNG) / colony-loss drain / two morale passes / ProcessBankruptcy / colonizer resolve / PlayerView rebuild / warnings / infra-terra drain / script(0x14,0x15) / survey+stats / FUN_0078a7c0 / eight ServerTradeManager vtable calls / upkeep / sensors / script(0x1c) / view refresh / node-line sightings / intercept aborts / comm masks / UpdateBankruptcyLimits per player / incoming warnings / two more vtable calls / observed designs / player reports / turn records [verified] +constexpr uint32_t StrategyServer_OnAllCombatDone_Tail = 0x003d92a0; +// site site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf [verified] +constexpr uint32_t StrategyServer_OnAllCombatDone_Tail_ClearEncounters = 0x003d9690; +// site site in FUN_007ae010 (phase 11 of OnAllCombatDone_Tail): `mov ecx,[esi+0x16c]; fld dword [0x009e2ea0] /*0.5f*/; push ecx; fstp [esp]; call 0x008e6dd0` = Mars::RNG::Chance(0.5f) on the strategic generator at S+0x16c. Chance early-outs WITHOUT a draw at p<=0 and p>=1 but takes neither at 0.5f, so this is EXACTLY ONE NextFloat PER EXPIRED NODE LINE PER TURN. State-dependent draw count, in the combat-done tail, BEFORE the autosave. Every RNG account in the repo assumes the strategic generator advances only inside StrategyServer::ProcessTurn; it also advances here, and again inside the combat resolver FUN_007d5af0 (RNG_NextInt on the node-cannon path, RNG_Twist + RNG_NextInt on the salvage path) [verified] +constexpr uint32_t StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll = 0x003ae095; +// thiscall void (StrategyHost* this /*the global at 0x00b29f98*/, std::string* outName, bool endTurn) // THE AUTOSAVE. Exactly two call sites: SendEndTurn 0x007839d7 with endTurn=1 -> (Autosave EndTurn).sav, the PRE-turn state; StrategyHost::OnMessage 0x00784e59 with endTurn=0 -> (Autosave).sav, the POST-turn state. Body: null-check this->+0x4 (the strat game) -> log "Can't autosave- Strat game doesn't exist."; build FOUR paths as _snprintf(buf,0x3ff,"%s/%s.%s", dir, name, ext) with dir=FUN_007a05a0(game) ("SavedGames") and ext=FUN_007a0620(game) ("sav") and the four localized names registered at 0x009bed00..0x009bed7f (SOTS_GAME_AUTOSAVE @0xaf092c, _AUTOSAVEBACKUP @0xaf0934, _ENDTURN_AUTOSAVE @0xaf093c, _ENDTURN_AUTOSAVEBACKUP @0xaf0944); if (!IsSinglePlayerHost()) remove both ENDTURN files; pick (cur,bak) by endTurn; mkdir(dir); ROTATE remove(bak)+rename(cur,bak) ONLY WHEN endTurn==0 (the flag byte at [ebp-0x14a1] is set to 1 and the je at 0x00895266 SKIPS the store of 0 when the arg is zero); gate on (this->flags & 4) && this->+0x4; DETACH each player's connection at pl->+0x12c via conn->vft[0x14] and reattach via conn->vft[0x18] after; call SaveGame_WriteFile(this->+0x4, curPath, 1, &agentNames) at 0x0089595d [verified] +constexpr uint32_t StrategyHost_Autosave = 0x00495210; +// cdecl bool () // whole 27-byte body: `g = *(void**)0x00b2d540; net = g->+0x148; return net != 0 && net->+0x4 == 0;`. Gates SendEndTurn's pre-turn autosave and the AI-agent sidecar branch inside StrategyHost::Autosave; its NEGATION gates the deletion of the ENDTURN autosave pair (so the pre-turn autosave is a single-player-only feature) [verified] +constexpr uint32_t StrategyHost_IsSinglePlayerHost = 0x00415fb0; +// thiscall bool (void* this) // `return this->+0x148 != 0;`. Called on the global at 0x00b2d540 from StrategyHost::OnMessage 0x00784e3f -- this is the gate on the POST-turn autosave [verified] +constexpr uint32_t StrategyHost_HasNetworkSession = 0x00498af0; +// cdecl bool (void* game, const char* path, bool write, std::vector* agentNames) // the save-file ROOT that verify/save-reader/save_reader.py already models (its comment at line 694 names this address). Opens the stream with OpenSaveStream(path,&stream,write); with write=1 that is operator new(0x118) + ctor 0x008d10c0 + FUN_008d1090(path,"wb"), the gzip writer. Then writes four named top-level sections through stream->vft[0x28](tag,&ref): "Summary", "CreateParams", "Sim", "CDT"; then one "CD" record (tag at 0x00a2b9d4) per entry of the 0x20-stride agentNames vector whose +0x1c is non-null. NOTHING TIME-, NAME- OR MACHINE-DEPENDENT ENTERS THE PAYLOAD: the file NAME is built by StrategyHost::Autosave and never reaches here [verified] +constexpr uint32_t SaveGame_WriteFile = 0x00477070; +// cdecl bool (const char* path, Stream** out, bool write) // write -> operator new(0x118), ctor 0x008d10c0, open FUN_008d1090(path, "wb" @0x009e150c); read -> OpenFile(path, @0x00a2ec2c). Returns *out != 0. The "wb" is the gzip container the determinism note measured as header-deterministic (MTIME 0, XFL 0, OS 11) [verified] +constexpr uint32_t OpenSaveStream = 0x00416510; +// thiscall void (StrategyServer* this) // 122 BYTES, AND IT GENERATES NOTHING. (1) FUN_007c5610(&scratch, S->+0x304, S->+0x308) with ecx = &S->+0x304 -- erase-to-empty of the vector OUTBOX at S+0x304. (2) if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 -- clear of a 0xc-stride vector; THE COPY LOOP AT 0x007dc680 IS DEAD CODE, `cmp edx,edx; je` at 0x007dc676 is unconditionally taken. (3) FUN_00792a20(S) prunes two intrusive lists at S+0x2d8 and S+0x2e4. (4) if (S->+0x128 & 4) BuildTurnEvents(S) -- normally FALSE. One caller: StrategyHost::OnMessage 0x00784e34 [verified] +constexpr uint32_t StrategyServer_GenerateTurnEvents = 0x003dc640; +// note MISNAMED. BuildTurnEvents 0x007db780 is NOT a per-turn turn-event builder: it is the FULL-STATE RESYNC PUSH for setup / load / rejoin. Its entire 3701-byte body is under `if (this->+0x12c != 0)`, and both that descriptor and bit 2 of +0x128 are set in exactly one place in the image -- FUN_007bd1b0 at 0x007bd204/0x007bd23a. It references NO EVENT_* string at all; its only string immediates are "vector too long" and "StrategyServer: OnEvent() called, but no callback function specified." It sends SEResetMap (0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19) and calls SynchronizePlayer. It CALLS FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c to ESTABLISH the baseline and never diffs against it. findings/control-flow/turn-spine.md reads as if this were a per-turn diff step -- it is not [verified] +constexpr uint32_t StrategyServer_BuildTurnEvents_isResync = 0x003db780; +// offset StrategyServer+0x2f4 (S frame) = std::vector ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn [verified] +constexpr uint32_t StrategyServer_off_TurnResultsPending = 0x000002f4; +// offset StrategyServer+0x304 (S frame) = std::vector OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema [verified] +constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x00000304; +// thiscall void (SETurnResults* this) // the DEFAULT CONSTRUCTOR of Game::SETurnResults (Ghidra calls it Create; it is not a factory). sizeof == 0x11c, enumerated five ways: the 0x11c stride and its reciprocal 0xe6c2b449/sar 8 in vector::resize 0x007cd2a0, the `add esi,0x11c` in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx*0x11c]), the operator new[] in 0x0078b0c0, and this ctor closing at +0x118 (the _Alval of a vector member at +0x10c). Layout: +0x00 vptr; +0x04 bool; +0x08 EMBEDDED Game::EventStorage::TurnEvents (vptr +0x08, int EvTurn +0x0c, vector +0x10/+0x14/+0x18, _Alval +0x1c); +0x20 int stamped by SynchronizePlayer from S+0x1fc and cleared after; +0x24 vector (what ApplyEncounterResult publishes into); +0x34 byte with two bit-flags; strings at +0x38/+0x54/+0xa4/+0xc0/+0xdc; list at +0x70; vectors at +0x80/+0x90/+0xfc/+0x10c; two bools at +0xa0/+0xa1; int at +0xf8 [verified] +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] +constexpr uint32_t HandleMap_Resolve = 0x004b9240; +// thiscall void (ServerPlayer* this, ServerPlayer* other) // `if (other) this->HasEnc(+0x1a8) |= 1 << other->PlyrIdx(+0x28);`. Called twice symmetrically per ordered combatant pair in OnAllCombatDone_Tail phase 2 [verified] +constexpr uint32_t ServerPlayer_MarkPlayerEncountered = 0x0040df10; +// thiscall void (ServerPlayer* this, uint species) // `if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) |= 1 << species;`. Species index 4 is permanently excluded. Called in OnAllCombatDone_Tail phase 2 as MarkSpeciesDiscovered(other->Species(+0x5c)) [verified] +constexpr uint32_t ServerPlayer_MarkSpeciesDiscovered = 0x0040dee0; +// cdecl int (uint typeMask) // A RANKER OVER GROUPS, NOT A FILTER OF ONE. Clears bit 0 (Standard); if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} the mask is RESTRICTED to that group; otherwise the ambient groups {Swarm 3, Derelict 4, Monitor 5, SlaversRefuel 9, CrowRuins 17}, {CrowsNest 12, GravTrap 13} and {GasCloud 11, Meteor 2, Pirate 6, TradeRaiders 18, 20, 23} are each dropped IF ANYTHING ELSE REMAINS. Returns the index of the lowest surviving set bit in [0,0x18), else 0. The four group masks are lazily built once into 0x00b0e96c..0x00b0e988. FUN_004f4970 is the id->name switch (Standard/VonNeumann/Meteor/Swarm/Derelict/Monitor/Pirate/SystemKiller/PuppetMaster/SlaversRefuel/SwarmQueen/GasCloud/CrowsNest/GravTrap/Locust/Berserker/CrowDefenders/CrowRuins/TradeRaiders), which fixes the return type as an EncounterType enum [verified] +constexpr uint32_t PickDominantEncounterType = 0x000f4c40; +// thiscall void (StrategyServer* this, Node* node /*= enc->+0xc*/, int encType /*= PickDominantEncounterType(enc->+0x38)*/) // GHIDRA'S DECOMPILE OF THIS FUNCTION IS UNUSABLE -- 19 'removing unreachable block' warnings delete the entire event-posting body; read it as instructions. mask = 0; if (!FUN_00788cd0(node, encType, &mask)) return; -- that gate is true only for VonNeumann(1), Meteor(2), Pirate(6), GasCloud(0xb), Berserker(0xf), 0x17, and fills mask with the players who can see it. Then per set player it composes the event key as the LITERAL "EVENT_" (0x00a24bf8, length 6) CONCATENATED WITH THE TYPE NAME -- EVENT_PIRATE, EVENT_TRADERAIDERS, ... -- and posts through ServerPlayer_GetEventStorage + EventStorage_PostEvent. Finally push_backs a 0x10-byte {system, encType, turn, turn+1} record into the vector at S+0x2c8/+0x2cc/+0x2d0. No RNG [verified] +constexpr uint32_t StrategyServer_AnnounceEncounterSighting = 0x003a9db0; +// thiscall void (StrategyServer* this, std::vector* encounters, std::vector* results) // two passes over ServerPlayer+0x230 vector, no RNG, no events. PASS A (dead homeworld): if the battle was at a player's own HomeSys(+0x2c), was a real battle (result->+0x4 == 0), had planet stats (result->+0x10c != 0) and the INT64 at result+0x120 is <= 0, then every participant that actually fought them gets deadhome(+0x20)++. PASS B (treaty betrayal): for every ordered pair with GetRelation < 1, a treaty slot signed within the last 3 turns and not yet betrayed since signing (`last != -1 && turn-last < 3 && (bty == -1 || bty < last)`), where the other side actually fought -- bty++ and lastXbty = turn, independently for NAP (+0x8/+0xa/+0xe), alliance (+0x10/+0x12/+0x16) and ceasefire (+0x18/+0x1a/+0x1e) [verified] +constexpr uint32_t StrategyServer_UpdateDiplomacyStatsFromCombat = 0x00389d00; +// thiscall void (StrategyServer* this, Encounter* enc, EncounterResults* res) // phase 6 of OnAllCombatDone_Tail. Dispatch on three result bytes: res->+0x4 != 0 makes the WHOLE FUNCTION A NO-OP (that flag means 'no battle happened', which is exactly what phase 3's sighting arm keys on); res->+0x6 != 0 -> FUN_007a06a0 (posts EVENT_PEACEFUL_ENCOUNTER); res->+0x7 != 0 -> FUN_007d3eb0 (posts EVENT_SYSTEM_SURRENDERED); otherwise the full path -- stamp StarShip+0x5c = turn on every participating ship; set the pairwise HasEng(+0x1ac) engagement bits; if enc->+0x3c stamp ServerPlayer+0x3d4 = turn; build a ~0xea0-byte combat report and run THE REAL RESOLVER FUN_007d5af0 (7499 B, UNREAD); append a Game::CombatReport to the std::list at S+0x1fc; fire the script hook vt[0x10](7,..)/vt[0x30](..). ALL branches then run a publication tail that push_backs a 0x30-byte Game::ClientEncounterResults into *(S+0x2f4) + PlyrIdx*0x11c + 0x24. DRAWS RNG through its subtree: FUN_007d5af0 -> FUN_007bb530 -> RNG_NextInt (node cannon), and -> FUN_007a7f30 -> RNG_Twist plus -> FUN_007a0540 -> FUN_00852d30 -> RNG_NextInt (salvage / back-engineering) [mapped] +constexpr uint32_t StrategyServer_ApplyEncounterResult = 0x003d8920; +// thiscall void (Node* this /*= enc->+0xc, the encounter's system*/, byte mask) // `if (!this->+0x100 /*owner*/) return; for each fleet at the node (vt[8] count, vt[0x10] get): if (owner->GetRelation(fleet->PID(+0x58)) == 3) for each ship in fleet->NShips(+0xa4/+0xa8) StarShip::RefreshFromDesign(ship, mask);`. FUN_00854680(ship,1) copies ship->+0x20 = design->+0xe8 and ship->+0x6c = design->+0xd8 then runs five recompute helpers -- a repair/refuel/stat refresh, not a movement step. strategic-turn-internals.md line 320 already calls it RefuelInOrbit(1); called from OnAllCombatDone_Tail phase 6 with mask = 1 [verified] +constexpr uint32_t Node_ResupplyAlliedFleets = 0x003463f0; +// thiscall void (StrategyServer* this) // the LAST call of OnAllCombatDone_Tail (0x007d98ba), and also called from LoadGame 0x007ddc40 -- so the per-player turn record is rebuilt at the end of every turn AND on load, and never has to survive a save round-trip. Per player, rec = P->+0x3d8: rec+0x0c (32) = P->Sav(+0x284) - P->PvSav(+0x188); rec+0x14 (32) = P->Sav; rec+0x18 (16, mov WORD) = (P->+0x34 - P->+0x30)>>2 owned systems; rec+0x28 (16) = completed-tech count from FUN_0057d980 over the tree's +0x10/+0x14 with state == 4; rec+0x20/+0x24 (int64, cdq/add/adc) = SUM over owned systems of (sys->+0x194 + sys->+0x18c) total population; rec+0x2a/+0x2c/+0x2e (16) = ship counts by hull size 0/1/2 for designs WITHOUT flag 0x400; rec+0x30/+0x32/+0x34 (16) = the same for designs WITH flag 0x400. The census comes from FUN_00818a50(P, int[8]) whose slots [0] and [1] (the grand totals) are computed and DISCARDED. Second loop: FUN_00894260(S->+0x200, i, S->+0xc, rec) archives the record by turn; the archive's copy-assign FUN_008712a0 deliberately does NOT copy +0x1c [verified] +constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0; +// thiscall int (ServerSystem* sys) // float mods[7] = {1.0f,0,..}; FUN_00747390(mods, sys, 0); float out[12] = {0}; ServerSystem::ComputeOutputRates(sys, out, mods); return max(*(int*)&out[3], 0). Slot 3 is the money/income rate. THE max() IS A `jg` -- the clamp at zero is what makes a loss-making colony contribute nothing to the bankruptcy limits rather than reducing them, which formula-gaps.md Q1 did not say. Only caller: ServerPlayer::UpdateBankruptcyLimits [verified] +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; +// 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 (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] @@ -1423,7 +1479,7 @@ constexpr uint32_t TechTree_SetResearched_flag_Refresh = 0x00000008; 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 EXACTLY ONE NextFloat unconditionally and then enters 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. This is the one extra RNG word a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified] +// 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; // 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; diff --git a/src/shim/hooks/research.cpp b/src/shim/hooks/research.cpp index b8e5b21..2d5639d 100644 --- a/src/shim/hooks/research.cpp +++ b/src/shim/hooks/research.cpp @@ -601,8 +601,12 @@ void CascadeOnResearched(void* ctx, int nodeIndex, bool /*silent*/) { } // 2. `if (ResT == def) { if (ResearchRollPending) RollResearchEvent(); pending = 0; ResT = 0; }` - // RollResearchEvent draws exactly one NextFloat unconditionally. Clearing ResT is what - // makes a second completion in the same pass draw nothing. + // RollResearchEvent draws one NextFloat unconditionally. That single word is the cost of + // REACHING its branch, NOT the cost of a fired roll: when the roll beats the odds the + // plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, and the + // rebellion path cancels the research. `ours` models the first word only, and the branch + // is declared unmodelled. Clearing ResT is what makes a second completion in the same + // pass draw nothing. if (def && def == c.research_target) { if (c.roll_pending) { c.rand->NextFloat(); diff --git a/src/shim/hooks/research.h b/src/shim/hooks/research.h index 07d30b8..a50182c 100644 --- a/src/shim/hooks/research.h +++ b/src/shim/hooks/research.h @@ -37,8 +37,10 @@ // // The owner callback SetResearched invokes is modelled only as far as this hook's regions reach: // the observed-tech append (a de-duplicating append whose byte span region:observed_techs -// compares) and the single RNG word RollResearchEvent draws when the completing tech is the -// current target and the pending-roll byte is set. Its tech-effect field writes are B2's +// compares) and the RNG word RollResearchEvent draws when the completing tech is the current +// target and the pending-roll byte is set. That is ONE word only while the roll misses: when it +// beats the odds the plague branch draws a second word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, +// and the rebellion branch cancels the research outright. Its tech-effect field writes are B2's // milestone and remain what the `player` guard reports. // // REPLACE mode runs none of the cascade: there, every pointer is live game memory and applying @@ -105,16 +107,24 @@ struct TechTreeProcessResearchHook { "bitmasks and the species tech flags", trace::Risk::High, "B2's milestone. `ours` models only the two parts of the callback this " - "hook's regions can see -- the observed-tech append and the one RNG word " - "RollResearchEvent draws -- and the rest is what the player guard reports", + "hook's regions can see -- the observed-tech append and the RNG word " + "RollResearchEvent draws before its branch (one word on a missed roll, two " + "on a fired plague roll) -- and the rest is what the player guard reports", "guard:player"); c.unmodelled("the research-event branch RollResearchEvent takes when its roll beats the " - "odds (0x00889d60: the plague and AI-rebellion event paths)", - trace::Risk::Medium, - "the one NextFloat is drawn unconditionally and is modelled; the branch " - "behind it is entered only for the plague and AI-rebellion tech families, " - "whose odds are 0 everywhere else. If it is ever entered, region:rng is the " - "check -- it would consume draws ours does not", + "odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion " + "event paths)", + trace::Risk::High, + "RollResearchEvent draws one NextFloat unconditionally and that draw IS " + "modelled -- but that is only the cost of REACHING the branch. A FIRED roll " + "costs one or two words: the plague path draws a SECOND word (NextInt) to " + "pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion " + "path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current " + "research (no further draw). The branch is entered only for the plague and " + "AI-rebellion tech families, whose odds are 0 everywhere else, and it has " + "never been observed firing in three sessions -- which is why every earlier " + "note in this repo said 'exactly one NextFloat' and nothing caught it. " + "If it is ever entered, region:rng is the check", "region:rng"); c.unmodelled("constructs the ObservedTech element it appends to ServerPlayer+0x274", trace::Risk::Medium, diff --git a/src/shim/hooks/tech_effects.cpp b/src/shim/hooks/tech_effects.cpp index c818d4f..9cf849f 100644 --- a/src/shim/hooks/tech_effects.cpp +++ b/src/shim/hooks/tech_effects.cpp @@ -279,9 +279,12 @@ void ServerPlayerOnTechResearchedHook::ours(void* self, void* def, bool silent) const int raw_id = resolve_tech_id(self, def); // The pending research-event roll. It is the one place this callback consumes - // randomness -- exactly one NextFloat from the strategic generator, drawn before any + // randomness -- one NextFloat from the strategic generator, drawn before any // comparison, whenever the completing definition is the current research target and the - // pending byte is set. In compare mode the generator is a scratch copy, so drawing here + // pending byte is set. One word is the cost of reaching the branch, not of a fired roll: + // if the roll beats the odds the plague path draws a second word (NextInt) and posts + // EVENT_PLAGUE_OUTBREAK, and the rebellion path cancels the research. That branch is + // declared unmodelled below. In compare mode the generator is a scratch copy, so drawing here // is safe and is what makes the post-state comparable (B3's design); in replace mode it // is the live generator, which is what the game expects. const bool would_roll = tfx::ClearResearchTargetIfMatched(v, def); diff --git a/src/shim/hooks/tech_effects.h b/src/shim/hooks/tech_effects.h index c05929a..1cd3791 100644 --- a/src/shim/hooks/tech_effects.h +++ b/src/shim/hooks/tech_effects.h @@ -68,7 +68,10 @@ struct ServerPlayerOnTechResearchedHook { "state, and no region reaches them"); c.unmodelled("the pending plague-cure roll (ServerPlayer::RollResearchEvent)", trace::Risk::High, - "it draws exactly one word from the strategic generator unconditionally; " + "it draws one word from the strategic generator unconditionally, and a " + "SECOND word (NextInt) when the roll beats the odds and takes the plague " + "path -- which also posts EVENT_PLAGUE_OUTBREAK, while the rebellion path " + "cancels the research. So a fired roll costs one or two words, not one; " "running it in compare mode would consume real randomness. The two words it " "guards are still cleared and the record says whether it would have fired", "this is the extra draw B3 observed on a completion");