sots-re/findings/control-flow/combat-done-tail.md
alex bb0f990de9 lane K: field offsets belong in the offset schema, and off_RNG was already right
gen_addresses.py distinguishes "addr" (VA -> RVA) from "offset" (a member
offset). Three lane-K entries used "addr" for offsets and generated negative
RVAs, which broke the engine build. Two are re-expressed as offsets; the third,
StrategyServer_off_RNGPtr_S_frame, is withdrawn - addresses.json already has
StrategyServer_off_RNG = 0x16c for the S frame alongside off_RNGPtr = 0x168 for
S+4, which is precisely lane T's rule. The second call site found this lane
confirms it rather than correcting it.
2026-09-08 08:48:25 -04:00

53 KiB
Raw Blame History

The second turn driver, read from the instruction stream — StrategyServer::OnAllCombatDone_Tail

Lane K, 2026-09-08. Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.

Method. StrategyServer::OnAllCombatDone_Tail 0x007d92a0 (1587 B) and its caller's message case were disassembled byte for byte with objdump -b binary -m i386 -M intel over the raw image (file-offset mapping from the PE section table, call targets annotated from dumps/functions.json, immediates resolved against .rdata). No claim below about control flow comes from the decompiler. Callee bodies were swept with ReVa; every such claim is marked.

Companion to findings/control-flow/turn-driver.md (lane T), which mapped StrategyServer::ProcessTurn and ServerPlayer::ProcessTurn and named this function as the biggest unread block in the turn. This doc closes that gap and corrects lane T's §0.1 and §5 — see §7.


0. Where it sits, and what its argument is

End Turn
  -> StrategyHost::OnMessage 0x00784640, SNMEndTurn case (0x007848d8..0x00784979)
       BeginProcessTurn -> ApplyTurnCommands -> ... -> StrategyServer::ProcessTurn 0x007dc6c0
       host->+0x9c = S->encounters          (FUN_007cf540 -> FUN_007cd100, vector assign)
       if (!host->+0x9c.empty()) SyncLocalClients(1); ...
       host->+0x58 = 5
  -> combat resolution (RunCombatRound 0x007cbe80 / the combat server FUN_007cfd00)
       both build a Game::SNMAllCombatDone (vptr 0x00a24758) and send it
  -> StrategyHost::OnMessage, SNMAllCombatDone case (0x00784cf8..0x00784efe)
       StrategyServer::OnAllCombatDone_Tail(S, msg+4)   <-- THIS DOC
       ...
       GenerateTurnEvents(S)
       autosave
       host->+0x58 = 6

Caller. Exactly one, verified with a whole-image direct-call scan and confirmed by Ghidra (callerCount: 1): StrategyHost::OnMessage at 0x00784d07, mov ecx,[esi+0x54]; push edi; call 0x7d92a0. esi is the StrategyHost, [host+0x54] is the StrategyServer.

Base, checked explicitly (the campaign has paid for this once): this is S, the same frame StrategyServer::ProcessTurn uses, not S+4. Four independent reads in this function settle it — [ebx+0x54]/[ebx+0x58] is the vector<ServerPlayer*> Players, [ebx+0x158] the ServerTradeManager, [ebx+0x1b4] the SVScriptObject, [ebx+0x1e8] the encounter vector — all S-frame offsets lane T already established. Convert any StrategyServer_off_* from addresses.json by adding 4.

Signature. void __thiscall StrategyServer::OnAllCombatDone_Tail(StrategyServer* S, std::vector<EncounterResult>* results), ret 4. Body 0x007d92a0..0x007d98d0.

The argument's stride is enumerated, not guessed. The prologue divides the argument's byte length by a constant and compares the quotient with the encounter count:

007d92cd  mov eax,[ebp+0x8]              ; the argument
007d92d0  mov ecx,[eax+0x4]              ; _Mylast
007d92d3  sub ecx,[eax]                  ;   - _Myfirst
007d92d5  mov eax,0xae4c415d
007d92da  imul ecx                       ; magic / add-back / sar 8  ->  / 0x178
...
007d92de  mov ecx,[ebx+0x1ec]            ; encounters._Mylast
007d92e4  sub ecx,[ebx+0x1e8]            ;   - _Myfirst
007d92fa  mov eax,0x8d3dcb09             ; magic / add-back / sar 6  ->  / 0x74
...
007d930d  cmp eax,edi
007d930f  je  0x7d9320
007d9311  push 0xa26140                  ; "Number of encounter results doesn't match number of encounters."
007d9316  push 0x2
007d9318  call 0x8b9ff0                  ; Log

Both magic/shift pairs were solved numerically over 0..200 multiples: 0xae4c415d, add-back, sar 8 is / 0x178; 0x8d3dcb09, add-back, sar 6 is / 0x74 (lane T's team-record stride — the "team records" and the encounters are the same vector). So:

type stride evidence
EncounterResult (the message payload element) 0x178 the division above
Encounter (S+0x1e8) 0x74 the division above, and lane T's ProcessTurn phase 31
Encounter's member record (enc+0x28/+0x2c) 0x44 0x78787879 / sar 5, four sites in this function

The mismatch is logged, not fatal. The je at 0x007d930f skips only two pushes and the call; 0x007d9320 is the fall-through. There is no early return anywhere in this function.

0.1 Game::SNMAllCombatDone

RTTI vtable at 0x00a24758 (Game::SNMAllCombatDone), four slots: 0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 (the middle pair are the Read/Write of a network message). Three construction sites:

site what
RunCombatRound 0x007cc847 builds it on the stack, {vptr, 0, 0, 0}
FUN_007cfd00+0x541 (0x007d0241) — the combat server; Ghidra sizes this function at 384 B but the real body runs to ret at 0x007d02b9 builds it on the stack, sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9
0x008663b0 operator new(0x14) + vptr — the deserialization factory

Layout, from the stack constructors and from every offset this function reads: struct SNMAllCombatDone { void* vptr; std::vector<EncounterResult> results; } — the vector is at msg+4, which is why the tail is called with msg+4 and not msg. 0x10 bytes by enumeration; the factory's operator new(0x14) is 4 bytes larger and is not explained — flagged, not resolved.


1. Phase map

this = ebx = S throughout. "verified" below means read from the instruction stream in this lane; callee bodies are marked separately.

# VA what runs notes
0 0x007d92ca ++S->+0x8 the same word StrategyServer::ProcessTurn increments at 0x007dc6f0. See §7.1
1 0x007d92cd–0x007d931d arity check, above. Logs and continues
2 0x007d9320–0x007d94b3 first contact, over all ordered pairs of combatants. Outer loop over encounters; two nested loops i,j over the encounter's member vector, skipping i == j. Per member: id = m->player->+0x4 (0 when the player pointer is null); X = HandleMap::Resolve(GetGame() + 0x84, id). When both A and B resolve: A->MarkPlayerEncountered(B); B->MarkPlayerEncountered(A); A->MarkSpeciesDiscovered(B->Species); B->MarkSpeciesDiscovered(A->Species) see §2A. The i == j skip is cmp [ebp-0x18],edi; je at 0x007d93c9, with edi the inner index
3 0x007d94b3–0x007d9538 per (encounter, result), gate result->+0x5 != 0 && result->+0x4 != 0 (two adjacent BYTE flags): AnnounceEncounterSighting(S, enc->+0xc, PickDominantEncounterType(enc->+0x38)) res->+0x4 != 0 is exactly the flag that makes ApplyEncounterResult a no-op in phase 6 — so phase 3 is the sighting arm and phase 4 is the battle arm. See §2A
4 0x007d9538–0x007d9610 a second, separate pass over the same pairs, gate result->+0x4 == 0: for every member with member->+0x4 != 2, ++(WORD)S->Players[member->player->+0x28]->+0x3d8->+0x1a a 16-bit "battles fought this turn" statistic on the player turn record. Phases 3 and 4 are two full passes, not two arms of one loop
5 0x007d9610 FUN_00789d00(S, &S->encounters, results) — diplomacy: dead-homeworld and treaty-betrayal counters 979 B; see §2A
6 0x007d961c–0x007d9690 per (encounter, result): StrategyServer::ApplyEncounterResult(S, enc, result) 0x007d8920 (2402 B), then if (enc->+0xc) Node::ResupplyAlliedFleets(enc->+0xc, 1) 0x007463f0 the loop re-reads _Myfirst/_Mylast every iteration, so the callee is permitted to resize the vector under it. This phase draws RNG through its subtree — see §3
7 0x007d9690–0x007d96bf S->encounters.clear() see §2 — this is the one place a reader is most likely to invent a filter that isn't there
8 0x007d96bf–0x007d96df if (S->+0x1b4) { script->vft[0x10](8, 0); script->vft[0x34](); } SVScriptObject hook, event id 8
9 0x007d96df–0x007d970b per player FUN_00818530(p, 1) — the in-progress AIRebellion step at ServerPlayer+0x3b8 lane T's ProcessTurn phase 22 calls the same function with 0. This is the only site that passes 1
10 0x007d970b ProcessNodeSpaceTravel(S) 0x007a0e20 run a second time this turn — ProcessTurn phase 7 already ran it
11 0x007d9714 FUN_007ae010(S) — node-line decay. THE ONLY RNG DRAW IN THE TAIL 1117 B; see §3
12 0x007d971b FUN_007be870(S) — colony-loss queue drain (S+0x254/+0x258, 0xc stride) 1706 B
13 0x007d9720 FUN_00798290(S) cdecl — treasury / special-project morale 895 B
14 0x007d9727 FUN_00798630(S) cdecl — foreign-fleet-presence morale 933 B
15 0x007d9731 StrategyServer::ProcessBankruptcy 0x007c0a50 2291 B; see §4
16 0x007d9738 FUN_007bd260(S) — arrived-colonizer resolution on unowned systems 205 B
17 0x007d973f FUN_007cf560(S) — rebuild every StarSystem::PlayerView; tears down and rebuilds the tree at S+0x228, resets S+0x22c = 0 948 B
18 0x007d9746 FUN_007a4ff0(S) — overharvest / no-engine / no-fuel warnings 1865 B
19 0x007d974d FUN_007a3750(S) — infra/terraform completion queue drain (S+0x234/+0x238) 1257 B
20 0x007d9752–0x007d978e if (S->+0x1b4) { vft[0x10](0x14,0); vft[0x64](); vft[0x10](0x15,0); vft[0x6c](); } both pairs are inside the one null test — the je at 0x007d975a targets 0x007d978e, past both. The reload of S+0x1b4 at 0x007d9772 is not a second test
21 0x007d9790 FUN_007a3c60(S) — survey masks + EVENT_FLEET_EXPLORED + derived system stats 1025 B
22 0x007d9797 FUN_0078a7c0(S) 254 B — run a second time this turn (ProcessTurn phase 12)
23 0x007d979c–0x007d9813 eight vtable calls on the ServerTradeManager at S+0x158, in order vft[0x38](), vft[0x20](1), vft[0x30](), vft[0x2c](), vft[0x24](), vft[0x1c](), vft[0x34](), vft[0x3c](); then S->+0x15c's vft[0x34]() wholly unmodelled; see the gap list
24 0x007d9815 FUN_0078ace0(S) — recompute per-player ship maintenance (+0x15c) and research bonus (+0x160), rebuild ShipRecs at +0x1b0 431 B
25 0x007d981a FUN_0086a8d0(S) cdecl — the sensor / fog-of-war update 248 B — run a second time this turn (ProcessTurn phase 24)
26 0x007d9820–0x007d9843 if (S->+0x1b4) { vft[0x10](0x1c,0); vft[0x78](); } byte-identical to ProcessTurn phase 25
27 0x007d9845 FUN_0078ab30(S) — StarSystem::PlayerView refresh 110 B — run a second time this turn (ProcessTurn phase 26)
28 0x007d984c FUN_007ade00(S) — node-line sighting masks 519 B
29 0x007d9855 FUN_007a23e0(S, 1) — abort intercept orders whose target went invisible, and post EVENT_FLEET_INTERCEPT_ABORTED 784 B. LoadGame calls the same function with 0, which suppresses only the event
30 0x007d985c FUN_0078aba0(S) — rebuild the per-turn communication bitmask ServerPlayer+0x198 295 B
31 0x007d9861–0x007d9889 per player ServerPlayer::UpdateBankruptcyLimits(p) 0x00818600 see §4
32 0x007d988b FUN_007a4070(S) — EVENT_ENEMY_INCOMING_<Species> / EVENT_ALIEN_INCOMING 1640 B
33 0x007d9890–0x007d98aa S->+0x15c: vft[0x38](), vft[0x3c]()
34 0x007d98aa FUN_007c2350(S) — record observed designs into ServerPlayer+0x254/+0x258 222 B
35 0x007d98b1 FUN_007991a0(S) — rebuild every PlayerReport in ServerPlayer+0x244/+0x248 208 B
36 0x007d98ba FUN_0078a0e0(S) — fill every player's turn record +0x3d8 and archive it 416 B; see §5. Must stay last
— 0x007d98bf–0x007d98d0 SEH unlink, ret 4 no __security_check_cookie; the cookie is only the EH frame's

1.1 It is straight-line

Past 0x007d96bf there is not one conditional jump that skips a phase. Every jcc in phases 8..36 is either a per-player loop bound or one of the three test esi,esi null tests on S+0x1b4. Phases 11 through 36 run on every invocation, whatever the encounters were. The one branch that could be mistaken for a phase gate is the empty-vector test at 0x007d9697, and it converges three instructions later at 0x007d96bf.


2. Phase 7 is clear(), and the shape that proves it

This is the trap this campaign keeps paying for, so it is worth the bytes:

007d9690  mov eax,[esi+0x4]                 ; encounters._Mylast
007d9693  mov ecx,[esi]                     ; encounters._Myfirst
007d9695  cmp ecx,eax
007d9697  je  0x7d96bf                      ;   empty -> skip
007d9699  mov edx,[ebp+0x8]                 ; results
007d969c  push edx
007d969d  push ecx                          ; first
007d969e  push eax                          ; last
007d969f  push eax                          ; last
007d96a0  call 0x7c5780
007d96a5  mov ecx,[ebp+0x8]
007d96a8  push ecx
007d96a9  mov edi,eax                        ; newEnd
007d96ab  mov eax,[esi+0x4]
007d96ae  lea edx,[esi+0xc]
007d96b1  push edx ; push eax ; push edi
007d96b4  call 0x679c80
007d96b9  add esp,0x20
007d96bc  mov [esi+0x4],edi                  ; _Mylast = newEnd
007d96bf  ...                                ; BOTH PATHS ARRIVE HERE

FUN_007c5780(last, last, first, c) followed by FUN_00679c80(newEnd, last, &vec+0xc, c) and _Mylast = newEnd is MSVC's vector::erase(_First, _Last) — unchecked_move(_Last, _Mylast, _First) then _Destroy. The identical four-argument shape with the identical argument order appears at 0x007cd147 / 0x007cd15b inside FUN_007cd100, which is vector<Encounter>::operator= taking the empty-source path — and there it demonstrably clears the destination. With _First == begin and _Last == end this is erase(begin, end), i.e. clear().

Both callee bodies were then read independently and confirm it. FUN_007c5780(first, last, dest, _Al) is std::_Uninit_move over 0x74-byte Encounters — and the range it is handed here is [_Mylast, _Mylast), empty, so it copies nothing and returns dest == _Myfirst. FUN_00679c80 is std::_Destroy_range, stepping 0x74 and per element destroying the sub-object at enc+0x64, the 0x38-stride vector at enc+0x40/+0x44/+0x48, and the 0x44-stride member vector at enc+0x28/+0x2c/+0x30. The trailing results pointer is the stateless-allocator reference slot and is unused by either body.

There is no predicate and no filter: the encounter list is emptied wholesale at the end of the tail. Anyone reading a decompile of phase 7 will see an if wrapping three calls and be tempted to describe it as "encounters are conditionally pruned". It is not: the if is the empty-vector guard erase always carries.


2A. Phases 2–6, identified

Names below are proposals; the call shapes and argument orders are instruction-verified from this function, and each callee body was read (mostly as instructions — Ghidra's decompile of FUN_007a9db0 is badly broken, deleting the entire event-posting body as unreachable).

addr name what
0x00578050 GetGame() mov ecx,0xb29f98; jmp 0x005f6450 where 0x005f6450 is mov eax,[ecx+4]; ret — i.e. return *(void**)0xb29f9c. 0x00b29f98 is the same global the autosave takes as its this, and +0x4 is the same "strat game" pointer it null-checks and hands to the save writer (§6). 670 xrefs
0x008b9240 HandleMap::Resolve(id) if (!id) return 0; slot = id & 0xF; if (slot >= (map->+0xc − map->+0x8)/0x14) return 0; b = map->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+4 ? 0 : *(void**)(it + 0x10); — a 16-bucket hash_map<uint32, Object*>, buckets 0x14 bytes each, each bucket a red-black tree. GetGame()+0x84 is the game's global handle map
0x0080df10 ServerPlayer::MarkPlayerEncountered(other) this->HasEnc(+0x1a8) |= 1 << other->PlyrIdx(+0x28); no-op if other == 0
0x0080dee0 ServerPlayer::MarkSpeciesDiscovered(species) if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) |= 1 << species. Species index 4 is permanently excluded
0x004f4c40 PickDominantEncounterType(uint typeMask) clears bit 0 (Standard), then narrows by group precedence — if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} it is restricted to that group; otherwise the ambient groups {Swarm, Derelict, Monitor, SlaversRefuel, CrowRuins}, {CrowsNest, GravTrap} and {GasCloud, Meteor, Pirate, TradeRaiders, 20, 23} are dropped if anything else remains — then returns the index of the lowest surviving set bit in [0, 0x18), else 0. FUN_004f4970 is the id→name switch that fixes the return type as an EncounterType enum
0x007a9db0 StrategyServer::AnnounceEncounterSighting(node, encType) 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, posts an event whose key is the literal "EVENT_" (0x00a24bf8, length 6) concatenated with the type name — EVENT_PIRATE, EVENT_TRADERAIDERS, … Finally push_backs a 0x10-byte {system, encType, turn, turn+1} record into S+0x2c8/+0x2cc/+0x2d0
0x00789d00 StrategyServer::UpdateDiplomacyStatsFromCombat(encounters, results) two passes over ServerPlayer+0x230 vector<DiplomacyStats>. A: if the battle was at a player's own HomeSys(+0x2c), was a real battle (res->+4 == 0), had planet stats, and the int64 at res+0x120 is ≤ 0 (population gone), then every participant that actually fought them gets deadhome(+0x20)++. B: for every ordered pair with GetRelation < 1, a treaty slot signed within the last 3 turns and not yet betrayed since signing, where the other side actually fought — bty++ and lastXbty = turn, for NAP, alliance and ceasefire independently. No RNG, no events
0x007463f0 Node::ResupplyAlliedFleets(mask=1) if (!node->+0x100) return; then per fleet at the node whose owner has GetRelation == 3 (allied/self), per ship: StarShip::RefreshFromDesign(1) 0x00854680 = ship->+0x20 = design->+0xe8; ship->+0x6c = design->+0xd8 plus five recompute helpers. A repair/refuel/stat refresh, not a movement step. (strategic-turn-internals.md already calls it RefuelInOrbit(1); that is the right idea)
0x007d8920 StrategyServer::ApplyEncounterResult(Encounter*, EncounterResults*) see below

2A.1 ApplyEncounterResult — top-level shape only

Dispatch on three bytes of the result: res->+0x4 != 0 makes the whole function a no-op; 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); 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 — independently confirming both the S+0x2f4 base and the 0x11c stride of §5A.

That res->+0x4 gate is what makes phases 3 and 4 complementary: +0x4 != 0 is "no battle happened", so phase 3 announces a sighting and phase 4 counts a battle.

FUN_007d5af0 was not read. It is 7499 bytes and it is where combat outcomes actually resolve. It was characterised only by its string table, its callees and its RNG/event reachability.

Source: every identification in this section is a delegated read of the callee body — mostly as instructions, since the decompiler is unreliable on this family. The call shapes are mine, from this function's bytes. Field names (HasEnc, HasDiscCl, HasEng, HomeSys, PlyrIdx, Species, deadhome, lastnap/btynap, PID, NShips) come from objects/layouts.md and line up with the offsets in the code.


3. The tail advances the strategic RNG — phases 6 and 11

Two phases draw, and neither is modelled anywhere in the repo.

Phase 6, ApplyEncounterResult, through its subtree (depth ≤ 4, callee sweep): FUN_007d5af0 → FUN_007bb530 → RNG_NextInt 0x004271c0 (the node-cannon path, which posts EVENT_NODECANNON_FLINGS / EVENT_NODECANNON_KILLS), and FUN_007d5af0 → FUN_007a7f30 → RNG_Twist 0x00426e00 plus FUN_007a7f30 → FUN_007a0540 → FUN_00852d30 → RNG_NextInt (the salvage / back-engineering special-project path, EVENT_SPRJBACKENG_UNLOCKED). No NextFloat and no Chance in that subtree to depth 4. Draw counts are entirely combat-dependent and unknown.

Phase 11, FUN_007ae010 — instruction-verified. It (1117 B) walks the 0x30-stride node-line records at [S+0x154]+8/+0xc. For each line whose remaining lifetime has run out it rolls:

007ae095  mov  ecx,[esi+0x16c]              ; the strategic RNG object
          fld  dword [0x009e2ea0]           ; 0.5f
          push ecx ; fstp [esp]
          call 0x008e6dd0                   ; Mars::RNG::Chance(float)

Mars::RNG::Chance (0x008e6dd0) early-outs at p <= 0 and p >= 1 without a draw; 0.5f takes neither early-out, so this is exactly one NextFloat per expired node line per turn. The count is state-dependent, so the number of words the tail consumes is not a constant.

This matters more than its size suggests. Every RNG accounting the campaign has done treats the strategic generator as advancing only inside StrategyServer::ProcessTurn. It also advances here, after combat — once per collapsing node line, plus whatever combat resolution spends — and the byte-identical autosave (§6) is written after that. Any reimplementation that reproduces ProcessTurn exactly and stops will still diverge on the first turn a node line expires or a node cannon fires.

Downstream of a successful roll (FUN_007a92e0 690 B then FUN_007a4700 2244 B) fleets are destroyed or halted and three events are posted: EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE, EVENT_NODEDECAY_FLEET_HALTED, EVENT_NODEDECAY_FLEET_HALTED_VIANODE. The roll is skipped for a line if any fleet with flag 0x20000 is targeting it.

Source: the Chance(0.5f) call site and its operand are instruction-verified; the surrounding record layout and the downstream event names are from a ReVa sweep.

3.1 The RNG pointer is S+0x16c, and lane T's frame note holds

Two independent instruction reads agree: ServerPlayer::ProcessTurn 0x0089147f (eax=[esi+8]; eax-=4; rng=[eax+0x16c], and ServerPlayer+0x8 == S+4, so eax == S) and this call site (mov ecx,[esi+0x16c], esi == S). So the pointer is at S+0x16c.

addresses.json already carries both frames of the same field — StrategyServer_off_RNG = 0x16c (the S frame) and StrategyServer_off_RNGPtr = 0x168 (the S+4 frame) — which is exactly lane T's §0 rule: every StrategyServer_off_* is S+4 except off_RNG. That is confirmed here from a second, independent call site, not corrected. No new entry was added; a lane-K draft that duplicated off_RNG was withdrawn before publication.


4. Bankruptcy — both halves, and the ordering is now instruction-verified

findings/subsystems/formula-gaps.md Q1 already derived the formula and asserted the ordering ("OnAllCombatDone_Tail → … → ProcessBankruptcy → (later in the tail) UpdateBankruptcyLimits"). That assertion is now read off the instruction stream, with addresses:

  • StrategyServer::ProcessBankruptcy 0x007c0a50 is phase 15, at 0x007d9731.
  • ServerPlayer::UpdateBankruptcyLimits 0x00818600 is phase 31, at 0x007d9876, 0x145 bytes later.

So within one invocation the decision runs before the limits are recomputed: the limits a turn's bankruptcy check uses are the ones written at the end of the previous turn, or by LoadGame 0x007ddc16. Those two are the only call sites of UpdateBankruptcyLimits in the image.

4.1 Three refinements to formula-gaps.md §3.3

Read as instructions over the whole 173-byte body:

maxIncome = Sum over s in P->OwnId (+0x30/+0x34) of max( ComputeMaxIncome(s), 0 )     // int32
BnkEl = max( trunc( maxIncome / -0.15000000596046448 ), -2000000000 )                 // P+0x2cc
BnkPr = max( -trunc( maxIncome * BANKRUPTCY_PROTECTION_LIMIT_FACTOR ), BnkEl )        // P+0x2d0
  1. The divisor is not -0.15. The .rdata double at 0x00a2ec30 is -0.15000000596046448 — that is (double)(float)-0.15f, the round-tripped float. A reimplementation that writes -0.15 will be one ulp out on large empires.
  2. The per-system term is clamped at 0 before summing. FUN_007521c0 = ServerSystem::ComputeMaxIncome returns max(ComputeOutputRates(sys)[3], 0) (a jg), so negative-income colonies contribute nothing rather than reducing the total.
  3. 3.3 is not a binary constant. 0x00aedfdc points to 0x00b23e28, which is past .data's raw end — it is .bss, zero-initialised, filled from the game data files at load. Only -0.15… and -2000000000 (int32 at 0x00a2c638) are hard-coded. The 3.3 in formula-gaps.md came from the data, and should be labelled as DB-sourced.

FUN_00925220 is _ftol2 (SSE2 cvttsd2si fast path behind the flag at 0x00b2f55c, x87 fistp fixup otherwise) — truncation toward zero, not pow or fmod. Only two clamps exist, both one-sided lower bounds; there is no upper clamp, so a player with zero owned systems has both limits at 0 and reads as bankruptcy level 2 on the next BankruptcyLevel call.

ProcessBankruptcy itself posts five events — EVENT_PLAYER_BANKRUPT, EVENT_PLAYER_BANKRUPTCY_IMMINENT, EVENT_PLAYER_BANKRUPTCY_AVOIDED, EVENT_PLAYER_ECONOMY_WARNING, EVENT_PLAYER_ECONOMY_OK — writes +0x2c4/+0x2c8 through SetBankruptcyState, and can reach FUN_007bd930 (2133 B, EVENT_PLAYER_ELIMINATED_). It draws no RNG and never writes savings (+0x284).

Source: UpdateBankruptcyLimits and its constants are instruction-verified; ProcessBankruptcy's body is a ReVa sweep with its event literals read from the raw push imm32 operands.


5. The turn record — phase 36, FUN_0078a0e0

ServerPlayer+0x3d8 is a per-player per-turn summary record. Lane T found two of its fields (+0x8 alliance/vision mask, set in ProcessTurn's pre-pass; +0x10 trade income, from ComputeBudget). Phase 4 of this function writes a third (+0x1a). Phase 36 fills the rest and archives the whole thing.

rec offset width value
+0x0c 32 P->Sav(+0x284) − P->PvSav(+0x188) — net savings change this turn
+0x14 32 P->Sav(+0x284)
+0x18 16 owned-system count (P->+0x34 − P->+0x30) >> 2, truncated to short
+0x20,+0x24 64 Σ over owned systems (sys->+0x194 + sys->+0x18c) — total population, accumulated with cdq/add/adc
+0x28 16 completed-tech count (FUN_0057d980 over the tree's +0x10/+0x14, state == 4)
+0x2a,+0x2c,+0x2e 16 ship counts by hull size 0/1/2 for designs without flag 0x400
+0x30,+0x32,+0x34 16 ship counts by hull size 0/1/2 for designs with flag 0x400

The census is built by FUN_00818a50(P, int[8]); slots [0] and [1] (the two grand totals) are computed and discarded — only the six per-class breakdowns are stored. Then, per player at vector index i, FUN_00894260(S->+0x200, i, S->+0xc, rec) copies the record into the history archive keyed by turn. The archive's record copy-assign (FUN_008712a0) deliberately does not copy +0x1c.

FUN_0078a0e0 is called from exactly two places: here (0x007d98ba) and LoadGame (0x007ddc40). The record is therefore rebuilt at the end of every turn and on load, which is why it never has to survive a save round-trip unchanged.

Per the campaign's rule, the on-disk primitive is not the memory kind: the short stores above are what the memory layout uses. Whether the archive's serializer writes them with WriteInt (four bytes on the wire) has not been checked here and must not be assumed.

Source: field-by-field instruction read of the 416-byte body; the archive indexing chain (FUN_00893fd0 / FUN_00884e40 / FUN_008712a0) is a ReVa sweep.


5A. Turn results — accumulated here, rotated and dispatched after

SETurnResults (RTTI vtable 0x00a24b00) is a per-player "what happened to you this turn" package. sizeof == 0x11c, enumerated five independent ways: the container stride in vector<SETurnResults>::resize 0x007cd2a0 (imul esi,esi,0x11c, and the reciprocal 0xe6c2b449 / sar 8), the add esi,0x11c in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx * 0x11c]), the operator new[] in 0x0078b0c0, and the default constructor closing at +0x118 (_Alval of the last member, a vector at +0x10c).

The pipeline, all four steps instruction-verified:

step where what
accumulate during the turn, including three phases of this function writers index S->+0x2f4 by PlyrIdx * 0x11c
rotate ApplyEncounterResults 0x007d4400 tail, 0x007d4fa0–0x007d505f destroy every element of S+0x304; swap the vector headers of S+0x2f4 and S+0x304; then resize(S+0x2f4, 0) and resize(S+0x2f4, nPlayers) — fresh empties for the next turn
dispatch SynchronizePlayer 0x007c6220, 0x007c865f–0x007c86e6 if (S->+0x304.size() == Players.size()) { r = S->+0x304 + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }
discard GenerateTurnEvents 0x007dc640, first statement FUN_007c5610(&scratch, S->+0x304, S->+0x308) — erase-to-empty of S+0x304

So there are two adjacent vector<SETurnResults> 0x10 apart: S+0x2f4 is the accumulator the turn writes into, S+0x304 is the outbox the network reads from. ApplyEncounterResults swaps them. SETurnResults is strategy-event id 0x25, sent one record to one player, and it is not serialized — its vtable has no Read/Write pair and it appears in no save schema.

Three phases of this function write turn-result records. Found by a whole-image byte scan for imul r32,r32,0x11c and add r32,0x11c at real instruction boundaries, attributed to containing functions:

phase function sites
6 FUN_007d8920 the encounter-result applier 0x007d8f9e
11 FUN_007ae010 node-line decay 0x007ae286, 0x007ae3ce
18 FUN_007a4ff0 fleet warnings 0x007a516f

(Plus FUN_007a4700, reached from phase 11, and FUN_007b9df0, reached from ProcessTurn phase 1; ProcessAid writes three.) So lane T's "no turn results run here" is half right and half wrong: the transport object is neither constructed nor dispatched here, but the records are filled here, and the rotation that freezes them happens 0xf3 bytes after this function returns.

5B. Turn events — and BuildTurnEvents is not what its name says

GenerateTurnEvents 0x007dc640 is 122 bytes and does four things:

FUN_007c5610(&scratch, S->+0x304, S->+0x308)      ; clear the SETurnResults outbox (5A)
if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 ; clear a 0xc-stride vector at S+0x244
                                                  ;   (the copy loop at 0x007dc680 is DEAD:
                                                  ;    `cmp edx,edx; je` at 0x007dc676 always jumps)
FUN_00792a20(S)                                   ; prune two intrusive lists at S+0x2d8 / S+0x2e4
if (S->+0x128 & 4) BuildTurnEvents(S)             ; almost always false

BuildTurnEvents 0x007db780 (3701 B) is not a per-turn event builder. Its whole body is under if (S->+0x12c != 0), and bit 2 of S+0x128 together with the S+0x12c descriptor is set in exactly one place in the image — FUN_007bd1b0 at 0x007bd204/0x007bd23a, on the setup / load / rejoin path. What it does is a full-state resync push: SEResetMap (id 0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19), plus SynchronizePlayer. It references no EVENT_* string at all — the only string immediates in its 3701 bytes are "vector<T> too long" and "StrategyServer: OnEvent() called, but no callback function specified.".

It also calls FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c — to establish the baseline, not to diff against it. Nothing in it compares a shadow word to a current word. turn-spine.md reads as if this were a per-turn diff step; it is not.

SETurnEvents (id 0x28) is a verbatim copy of one player's own EventStorage bucket for the current turn, built at 0x007dc334–0x007dc3d0: EventStorage::FindTurnBucket(GetEventStorage(p), S->+0xc), then { SE vptr, TurnEvents vptr, bucket->EvTurn, copy of bucket->Events }, sent with OnEventCallback(netId, 0x28, &ev). Not a diff, not a broadcast.

sizeof(EventStorage::TurnEvents) == 0x18, four ways: the serializer touches +0x04 (int EvTurn) and +0x08 (a 16-byte vector incl. _Alval); SETurnResults's default ctor places the subobject at +0x08 and the next member at +0x20; the copy ctor mirrors that; and EventStorage::FindTurnBucket 0x00811f70 strides by 0x18 (magic 0x2aaaaaab / sar 2). Wire schema, in order: "EvTurn" by WriteInt (four bytes on the wire, default -1), then "Events" as a framed counted array of Game::EventStorage::Event bound through Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c). This agrees exactly with objects/layouts.json, which already grades it verified at sizeof 24.

Where the per-turn events actually reach the client is worth stating: SETurnResults embeds an EventStorage::TurnEvents at +0x08 (vptr +0x08, EvTurn +0x0c, vector<Event> +0x10/+0x14/+0x18), verified from both its constructors. So the bucket rides inside the id-0x25 record. Whether that embedded bucket is filled on a normal turn was not established — labelled hypothesis.

5B.1 TurnEvents_Write and TurnEvents_Read are swapped in Ghidra

0x00825bb0 (labelled TurnEvents_Write) calls 0x008b9d20, which invokes stream vtable slot +0x10, passes a stack scratch as a destination and stores on success — that is ReadInt. 0x00825c40 (labelled TurnEvents_Read) calls 0x008b9d50, which invokes slot +0x24 and pushes the member's value — that is WriteInt, the same shape as the golden Game::ObservedTech::Write 0x00817cf0. So 0x00825bb0 is the READ and 0x00825c40 is the WRITE. The same swap affects EventStorage_Read 0x00825cc0, which is the Write.

objects/layouts.json and objects/streams.json have this right (write: 0x825c40, read: 0x825bb0). What is wrong is the Ghidra symbol names and the sentence in findings/subsystems/events.md §96, which repeats the swap. Corrected in Ghidra by this lane; the note is flagged for its owner rather than rewritten, since it is lane E's and the surrounding argument depends on it.

Source: 5A is instruction-verified end to end (I re-read GenerateTurnEvents, the ApplyEncounterResults tail and the SynchronizePlayer dispatch from the bytes myself). 5B's BuildTurnEvents phase list and the SETurnResults field table are a delegated ReVa sweep with the call targets and immediates instruction-verified.


6. The autosave — mechanism, and why it reproduces

It is not in this function. It is the second-to-last step of the SNMAllCombatDone handler, after the tail returns. The handler, read from the instruction stream (0x00784cf8..0x00784efe):

 1. StrategyServer::OnAllCombatDone_Tail(S, msg+4)                         0x00784d07
 2. for each e in host->+0x114 (4-byte stride): FUN_00789360(S, e);  then FUN_008320e0(host->+0x114)
 3. for i over host->+0x118 (0xc stride): e = FUN_005c3d10(vec, i);
                                          FUN_0083cb80(S+4, e->+4, e->+8)
    then FUN_00793810(&local, vec._Myfirst, vec._Mylast)
 4. FUN_00789330(S);  FUN_007c0600(S);  ApplyEncounterResults(S) 0x007d4400
 5. FUN_00761310(host);  SyncLocalClients(0)
 6. GenerateTurnEvents(S) 0x007dc640                    <-- TURN EVENTS
 7. if (HasNetworkSession()) StrategyHost::Autosave(&name, /*endTurn=*/0)  <-- THE AUTOSAVE, 0x00784e59
 8. host->+0x58 = 6

6.1 StrategyHost::Autosave 0x00895210

void __thiscall (StrategyHost* this /* the global at 0x00b29f98 */, std::string* outName, bool endTurn). 2364 B. Two call sites in the whole image:

caller endTurn file
SendEndTurn 0x007839d7 1 (Autosave EndTurn).sav — the pre-turn state
StrategyHost::OnMessage 0x00784e59 0 (Autosave).sav — the post-turn state

Which is exactly the pair findings/subsystems/determinism-oracle.md observed on disk.

Body, read from the instruction stream:

  1. if (this->+0x4 == 0) → Log(2, "Can't autosave- Strat game doesn't exist."), clear outName, return.

  2. Log("Autosaving strat game..."); dir = FUN_007a05a0(game) ("SavedGames"), ext = FUN_007a0620(game) ("sav").

  3. Four 0x400-byte path buffers, each _snprintf(buf, 0x3ff, "%s/%s.%s", dir, name, ext). The four names are the localized strings registered at 0x009bed00..0x009bed7f:

    global key buffer
    0x00af092c SOTS_GAME_AUTOSAVE [ebp-0x1410]
    0x00af0934 SOTS_GAME_AUTOSAVEBACKUP [ebp-0x1010]
    0x00af093c SOTS_GAME_ENDTURN_AUTOSAVE [ebp-0xc10]
    0x00af0944 SOTS_GAME_ENDTURN_AUTOSAVEBACKUP [ebp-0x810]
  4. if (!IsSinglePlayerHost()) → remove() both ENDTURN files. (The pre-turn autosave is a single-player feature.)

  5. cur, bak = endTurn ? (ENDTURN, ENDTURN_BACKUP) : (AUTOSAVE, AUTOSAVE_BACKUP); mkdir(dir).

  6. The rotation, and it fires only when endTurn == 0: remove(bak); rename(cur, bak). Read the flag carefully — 0x0089525f stores 1 into [ebp-0x14a1] and the je at 0x00895266 jumps over the store of 0, so the byte is (endTurn == 0), not (endTurn != 0). This is the (Autosave Backup).sav the determinism note recorded as "a rename, hash unchanged".

  7. Gate: if (!(this->flags & 4) || this->+0x4 == 0) nothing is written at all.

  8. Transient state is detached before writing. For each player in this->+0xc/+0x10, if its connection pl->+0x12c is non-null, conn->vft[0x14](&slot) moves it into a scratch vector; after the write conn->vft[0x18](slot) puts it back and the slot is nulled (0x008959ac). Per-player sidecar names are built with FUN_0059a9e0 / FUN_0059aa30 ("Player"/"AIAgent" + "%s.%08i.%s") into a 0x20-stride vector.

  9. FUN_00877070(this->+0x4, curPath, /*write=*/1, &names) at 0x0089595d — the write itself. Returns a bool.

6.2 What is actually captured, and what makes it reproducible

FUN_00877070 is the file root verify/save-reader/save_reader.py already models (its comment at line 694 names this address). Read from the instruction stream: it opens the stream with FUN_00816510(path, &stream, write) — with write = 1 that is operator new(0x118) + ctor 0x008d10c0 + FUN_008d1090(path, "wb"), the gzip writer — and then writes four named top-level sections through stream->vft[0x28](tag, &ref):

"Summary"   "CreateParams"   "Sim"   "CDT"

followed by one "CD" record per entry of the names vector whose +0x1c is non-null. That is exactly the root save_reader.py parses.

Four properties, together, are the whole mechanism the byte-identical End Turn oracle rests on:

  1. Fixed position in the message sequence. The autosave is step 7 of one message handler. Everything the turn computes — the tail's 36 phases, ApplyEncounterResults, GenerateTurnEvents — has already run and written to memory. Nothing is in flight.
  2. Nothing identifying is passed to the writer. The file name is built in step 3 of Autosave and never reaches FUN_00877070's payload; the writer takes (game, path, write, names) and serialises game state alone. No timestamp, no machine id, no session salt, no save-name field — which is why the note's four differently-named files are one byte sequence.
  3. Transient connection state is detached (step 8) before the write and reattached after, so nothing session-scoped can leak into Sim.
  4. The container is deterministic, as the determinism note independently measured (MTIME 0, XFL 0, OS 11) — FUN_008d1090(path, "wb") never passes a filename or time to the gzip header.

The one known exception in the note — Player.Status resetting 4 → 0 on load, moving Summary.Checksum — is consistent with this: Status is ServerPlayer+0x164, which lane T showed ProcessTurn phase 31 writes to 1 and which the post-turn state leaves at 4; nothing in the write path normalises it.

Labelled hypothesis. I did not prove that SNMAllCombatDone is delivered on turns with no combat. The evidence that it is: (Autosave).sav is written on every End Turn (the determinism note, five runs), and this handler is the only reachable caller of StrategyHost::Autosave(…, 0) in the image. That is strong, but it is an inference from the oracle, not from the instruction stream. A path you cannot exercise is a hypothesis — this one is exercisable by lane O and worth one check.


7. Corrections

7.1 S+0x8 advances twice per turn, not once

turn-driver.md §0.1 lists S+0x8 as incremented by "StrategyServer::ProcessTurn, first instruction" and concludes that it and S+0xc "both advance once per turn … so they stay in lockstep".

OnAllCombatDone_Tail's first act is inc DWORD PTR [ebx+0x8] at 0x007d92ca, on the same base and the same word. Given §6's inference that the message is delivered every turn, S+0x8 advances at least twice per turn while S+0xc (ModCount) advances once. They are not in lockstep, and a reimplementation must not treat S+0x8 as a turn number. It reads as a server-phase / driver-invocation counter.

7.2 Turn results and turn events are not in this function

turn-driver.md §5 says: "no bankruptcy, no turn-results build, no turn-events build, no autosave. Those are all in StrategyServer::OnAllCombatDone_Tail." One of the four is right.

subsystem where it actually is
bankruptcy here — phase 15 (ProcessBankruptcy) and phase 31 (UpdateBankruptcyLimits)
turn results filled here (phases 6, 11, 18 write S->+0x2f4[PlyrIdx]), but rotated by ApplyEncounterResults and dispatched by SynchronizePlayer — both after this returns. See §5A
turn events not here, and mostly not per-turn either: GenerateTurnEvents 0x007dc640 is a 122-byte flush, and the BuildTurnEvents it may call is the load/rejoin resync. See §5B
autosave StrategyHost::Autosave 0x00895210, called from the handler, after this returns. See §6

A whole-image direct-call reachability search from 0x007d92a0 to each of BuildTurnEvents 0x007db780, GenerateTurnEvents 0x007dc640, SETurnResults::Create 0x007a7ae0, the copy ctor 0x007c24d0, vector<SETurnResults>::resize 0x007cd2a0, SynchronizePlayer 0x007c6220 and the TurnEvents serializers finds no path — computed twice, once at depth 6 and once as the full transitive closure (1420 functions reachable from 0x007d92a0). EventStorage::PostEvent 0x008862b0 and EventStorage::FindTurnBucket 0x00811f70 are reachable, so the tail posts individual player events but never packages or ships one. Caveat: this is the direct-call closure; the tail does make indirect calls, so "no static path" is not a proof of "no dynamic path". What settles it is that every construction site of both classes is a direct call from a function outside that closure.

7.2a DispatchTurnResults and SendTurnResultsToPlayers are misnomers

Two names already in the Ghidra DB describe sends that do not exist. 0x007cd2a0 is std::vector<SETurnResults>::resize (shrink → _Erase 0x007c5610, grow → _Reserve 0x007cb340 + _Ufill), and 0x007c5850 is _Ufill(dest, count) — per element Create() a stack temp, copy-construct into dest via 0x007c24d0, destroy the temp via 0x0079ac10. Likewise Game::SETurnResults::Create 0x007a7ae0 is the default constructor and FUN_007c24d0 is the copy constructor, not a factory and a raiser. Nothing in that group sends anything; the only send is SynchronizePlayer 0x007c86d1 (push 0x25).

Strategy-event ids recovered along the way: 0x01 SEAddPlayer, 0x19 SESyncDesign, 0x25 SETurnResults, 0x28 SETurnEvents, 0x29 SEResetMap, 0x2a SEInitTrade.

7.3 player->+0x8 + 0x60 is S+0x64, not a skew

A callee sweep flagged that FUN_00818a50 reaches the fleet vector as player[8] + 0x60/+0x64 while the drivers use S+0x64/+0x68. That is not an anomaly: lane T established ServerPlayer+0x8 == S+4, so player[8] + 0x60 == S + 0x64. Same field, S+4 frame. Recorded here so the next lane does not re-open it.


8. Object notes recovered here

// Game::SNMAllCombatDone -- 0x10 by enumeration (see 0.1)
struct SNMAllCombatDone { void* vptr; std::vector<EncounterResult> results; };

// Encounter -- 0x74 stride (S+0x1e8), confirmed by two independent divisions
//   +0x0c  StarSystem*                  (phase 3 arg, phase 6 -> RefuelInOrbit)
//   +0x28  vector<Member> _Myfirst      (0x44 stride)
//   +0x2c  vector<Member> _Mylast
//   +0x38  int/handle                   (phase 3 -> FUN_004f4c40)
// Encounter::Member -- 0x44 stride
//   +0x00  ServerPlayer*                (its +0x4 is the lookup id, its +0x28 is PlyrIdx)
//   +0x04  int role/type                (== 2 is excluded from the phase-4 tally)

// EncounterResult -- 0x178 stride, index-parallel to Encounter
//   +0x04  BYTE flag   (phase 3 requires != 0; phase 4 requires == 0)
//   +0x05  BYTE flag   (phase 3 requires != 0)
//   ... the other 0x172 bytes are consumed by FUN_007d8920 and are NOT mapped here

// ServerPlayer turn record (ServerPlayer+0x3d8) -- see 5 for the fields this lane adds
//   +0x1a  WORD, incremented per non-type-2 combatant per zero-flag encounter result (phase 4)

New ServerPlayer offsets surfaced by the callee sweep and not in struct-recovery.md: +0x198 (per-turn communication bitmask, indexed by player-vector position, not PlyrIdx; rebuilt every turn by phase 30, so almost certainly unsaved) and +0x348[] (per-species known/translatable table, 4-byte stride, bit 0). Both are decompiler-derived except +0x198, whose writes were read as instructions.


9. Gap list

Ranked the way lane T ranked its own. "RNG?" / "events?" come from a callee sweep; a ? means unswept.

Tier 1 — small, self-contained, and each closes something the campaign already half-models

target size why
FUN_007ae010 node-line decay (phase 11) 1117 B one NextFloat per expired line, count state-dependent, and it is instruction-verified. Every RNG account in the repo assumes the strategic generator only moves inside ProcessTurn. This is the cheapest of the two tail draws to close, and it sits before the autosave
FUN_0078a0e0 turn records (phase 36) 416 B fully read here (§5). Pure, draw-free, event-free, and it is the input to the history archive the UI graphs. Also runs on load, so it is testable without a turn
UpdateBankruptcyLimits (phase 31) 173 B fully read here (§4). Three corrections to formula-gaps.md already banked; the only unknown left is the DB value of BANKRUPTCY_PROTECTION_LIMIT_FACTOR
FUN_0078aba0 communication masks (phase 30) 295 B fully read by the sweep; 30 lines of bit arithmetic, no RNG, no events, and it exposes the undocumented ServerPlayer+0x348 species table
FUN_0078ace0 maintenance / research bonus (phase 24) 431 B writes ServerPlayer+0x15c (Maint) and +0x160 (shrm) — two fields ComputeBudget consumes, so it closes a loop B1 currently copies out of the original. ShipMaintenance 0x00814c80 and ShipResearchBonus 0x0081f930 are both fully read

Tier 2 — medium, and each is a named game rule

target size why
FUN_007d5af0 the combat resolver (under phase 6) 7499 B the largest unread thing reachable from this function, the second RNG source in the tail, and the source of EVENT_VICTORY / EVENT_DEFEAT / EVENT_ENGAGED / EVENT_COMBAT_OBSERVED / EVENT_STATION_KILLED / EVENT_NODECANNON_* / EVENT_SPRJBACKENG_UNLOCKED. It is its own milestone, but nothing about combat determinism can be settled without it
ProcessBankruptcy 0x007c0a50 (phase 15) 2291 B (+1682 +2133) five events, a two-level threshold rule, player elimination. The formula half is done; the decision half is not
ApplyEncounterResult 0x007d8920 (phase 6) 2402 B the dispatch and the publication tail are now mapped (§2A.1); the middle — the report construction around FUN_007d5af0 — is not
AnnounceEncounterSighting 0x007a9db0 (phase 3) 978 B the "EVENT_" + typename composition and the S+0x2c8 sighting log are mapped; the visibility gate FUN_00788cd0 is not. Ghidra's decompile of this function is unusable — it deletes the entire event body as unreachable — so it must be read as instructions
UpdateDiplomacyStatsFromCombat 0x00789d00 (phase 5) 979 B fully described by the sweep; DiplomacyStats is already verified 13/13 in objects/layouts.md, so this is transcription plus one careful pass on the treaty-betrayal window (< 3 turns)
FUN_007c0a50's FUN_00889500 cost-cutting 1682 B what a bankrupt empire actually does
FUN_007a4070 incoming-fleet warnings (phase 32) 1640 B the EVENT_ENEMY_INCOMING_<Species> suffix rule is settled (species tag from FUN_0053b030, falling back to EVENT_ALIEN_INCOMING on a resource-table miss); the detection-mask half is not
FUN_007cf560 PlayerView rebuild (phase 17) 948 B draw-free but it destroys and rebuilds the whole S+0x228 tree and emits stream writes — a serialization surface, not bookkeeping
FUN_007bd260 → FUN_007b9190 (phase 16) 205 + 301 B tiny driver, real rule: it founds colonies on unowned systems. Best value-per-byte in the tail
FUN_00798290 + FUN_00798630 morale (phases 13, 14) 895 + 933 B table-driven, no RNG, no events; together they hand over the morale-event id→delta and id→string tables (0x00743420 / 0x00743530) that several other subsystems reuse

Tier 3 — larger, or blind

FUN_00789d00 (979 B, phase 5, unswept), FUN_007be870 colony-loss drain (1706 B), FUN_007a4ff0 fleet warnings (1865 B), FUN_007a3750 infra/terra drain (1257 B), FUN_007a3c60 survey + derived stats (1025 B), FUN_007a23e0 intercept aborts (784 B), FUN_007ade00 node-line sightings (519 B), FUN_007c2350 → FUN_007be340 observed designs (222 + ? B), FUN_007991a0 → FUN_00798c90 player reports (208 + 1285 B).

Tier 4 — the vtable blocks, which are pure blind spots

Phase 23 is nine virtual calls in a row and not one of them is identified. Eight on the ServerTradeManager at S+0x158 (vft[0x38], vft[0x20](1), vft[0x30], vft[0x2c], vft[0x24], vft[0x1c], vft[0x34], vft[0x3c]) and one on S+0x15c (vft[0x34]); phase 33 adds two more on S+0x15c. ServerTradeManager::ProcessTurn is already ProcessTurn phase 2 and strategic-turn-internals.md §1.4 has its formulas, so the class is half-known — but this eight-call sequence is the end-of-turn half of trade and nothing in the repo touches it. analyze-vtable on the concrete *Impl behind S+0x158 would close all nine cheaply.

The three SVScriptObject hook pairs (ids 8, 0x14, 0x15, 0x1c) are scripted-scenario callbacks, presumed dead in a normal game and not proven so — same status lane T gave the two in ProcessTurn.

What is conspicuously absent from this function

The combat-done tail does no movement, no production, no research, and touches savings only through ProcessBankruptcy's consequences. It is the reconciliation half of the turn: apply results, tell everyone what they now know, recompute derived state, snapshot. ProcessTurn decides; this decides nothing except bankruptcy and node-line decay.


10. What this lane did not read

The whole of OnAllCombatDone_Tail was read as instructions, and so were StrategyHost::Autosave, SaveGame_WriteFile, OpenSaveStream, IsSinglePlayerHost, GenerateTurnEvents, the ApplyEncounterResults rotation, the SynchronizePlayer dispatch and the SNMAllCombatDone case of OnMessage. The boundary is the callee bodies.

  • FUN_007d5af0 (7499 B), the combat resolver under phase 6 — completely unread. Everything §2A.1 says about ApplyEncounterResult describes the dispatch around it. This is the biggest hole left.
  • Twenty-eight callee bodies were characterised by delegated ReVa sweeps, not by me. Their call shapes and argument orders are instruction-verified from this function; their contents are decompiler-derived except where §2A / §3 / §4 / §5 say otherwise. AnnounceEncounterSighting 0x007a9db0 in particular is a function whose decompile is known-broken.
  • The 0x178-byte EncounterResult beyond +0x4, +0x5, +0x6, +0x7, +0x10c and +0x120.
  • The nine virtual slots of phase 23 and the two of phase 33 — the largest blind spot in the map.
  • Whether SNMAllCombatDone is delivered on turns with no combat (§6, labelled hypothesis), and whether the TurnEvents embedded in an SETurnResults is filled on a normal turn (§5B, labelled hypothesis).