We consume 18-22 generator words per turn and model none of them as a count.
All of it is inside StrategyServer::ProcessTurn; OnAllCombatDone_Tail costs 0
on every turn observed; the residual outside the two drivers is exactly 0. The
generator does not move between turns at all, so the interval a standalone has
to reproduce is closed at both ends.
The instrument reads generator STATE, not calls, and that choice paid: the
image has four draw entry points, not three (NextUInt 0x004f7670 is in no
lane's primitive set) plus inlined draws in twelve functions, two reachable
from the turn roots. A primitive-counting hook would have undercounted
silently.
Checked against the save files independently: the turn-6 autosave pair gives
18 words read from the two Sim.RNG blobs, and with twists == 0 that number
never passes through a twist implementation -- so the two instruments do not
share the hidden assumption they could have.
Corrections to combat-done-tail.md, in place:
* the node-line 0x20000-fleet check runs AFTER the Chance(0.5f) call and
cannot gate the draw; the expiry test is NodePath::RemainingLife 0x006e2130
and is now a formula rather than a description
* StrategyHost::Autosave is ret 8 and returns the std::string* in EAX
* SNMAllCombatDone IS delivered every End Turn (8 of 8) -- lane K's inference
was right; the stronger no-encounter reading is narrowed, not closed
* S+0x8 advances 12-14 times per turn, not twice
Node-line decay still has not fired. The hook reports the distance instead of
the absence: 51 of 53 lines are permanent, the mortal ones are dug ~1/turn by
the Zuul, each ~40 turns from expiry. It stays a labelled hypothesis.
56 KiB
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.
CORRECTED by lane Z, 2026-09-08 — the fleet check does not gate the roll. The
0x20000-fleet scan begins at 0x007ae0b2, which is 0x1d bytes after theChance(0.5f)call at 0x007ae0a5 and is reached only when the roll succeeded (test al,al; je 0x007ae1e2at 0x007ae0aa). The straight-line order in the loop is expiry test → draw → roll gate → fleet gate → collect, so the fleet scan suppresses only the collapse (FUN_007a92e0/FUN_007a4700), never the draw. The headline claim above — oneNextFloatper expired node line per turn — is unaffected, and the expiry test is now a read formula:NodePath::RemainingLife0x006e2130 returnsnpdtn − nptf/npdtf − (turn − npctm)clamped at 0, with two never-expire early-outs (npt == 0,npdtn == INT_MAX). Seefindings/control-flow/tail-rng-ledger.md§6 and §6.1.
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::ProcessBankruptcy0x007c0a50 is phase 15, at 0x007d9731.ServerPlayer::UpdateBankruptcyLimits0x00818600 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
- The divisor is not
-0.15. The.rdatadouble at 0x00a2ec30 is-0.15000000596046448— that is(double)(float)-0.15f, the round-tripped float. A reimplementation that writes-0.15will be one ulp out on large empires. - The per-system term is clamped at 0 before summing.
FUN_007521c0=ServerSystem::ComputeMaxIncomereturnsmax(ComputeOutputRates(sys)[3], 0)(ajg), so negative-income colonies contribute nothing rather than reducing the total. - 3.3 is not a binary constant.
0x00aedfdcpoints to0x00b23e28, 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 informula-gaps.mdcame 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, StrategyServer::FinalizeTurnRecords
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
std::string* __thiscall (StrategyHost* this /* the global at 0x00b29f98 */, std::string* outName, bool endTurn), ret 8. 2364 B. Two call sites in the whole image:
CORRECTED by lane Z, 2026-09-08 — the return type. The epilogue is
c2 08 00and 0x00895b5c ismov eax,esi, which putsoutNameback in EAX: this is MSVC's named-return-value slot, not avoid. A hook or reimplementation declaring itvoiddrops EAX at both call sites. Neither site passesthisas an argument — both hardcodemov ecx,0xb29f98. Lane Z also observed live that[global+0x54]is not theStrategyServer, so the global this function runs on is a different object from theStrategyHostwhose+0x54OnMessagereads.
| 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:
-
if (this->+0x4 == 0)→Log(2, "Can't autosave- Strat game doesn't exist."), clearoutName, return. -
Log("Autosaving strat game...");dir = FUN_007a05a0(game)("SavedGames"),ext = FUN_007a0620(game)("sav"). -
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] -
if (!IsSinglePlayerHost())→remove()both ENDTURN files. (The pre-turn autosave is a single-player feature.) -
cur, bak = endTurn ? (ENDTURN, ENDTURN_BACKUP) : (AUTOSAVE, AUTOSAVE_BACKUP);mkdir(dir). -
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 thejeat 0x00895266 jumps over the store of 0, so the byte is(endTurn == 0), not(endTurn != 0). This is the(Autosave Backup).savthe determinism note recorded as "a rename, hash unchanged". -
Gate:
if (!(this->flags & 4) || this->+0x4 == 0)nothing is written at all. -
Transient state is detached before writing. For each player in
this->+0xc/+0x10, if its connectionpl->+0x12cis non-null,conn->vft[0x14](&slot)moves it into a scratch vector; after the writeconn->vft[0x18](slot)puts it back and the slot is nulled (0x008959ac). Per-player sidecar names are built withFUN_0059a9e0/FUN_0059aa30("Player"/"AIAgent"+"%s.%08i.%s") into a 0x20-stride vector. -
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:
- 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. - Nothing identifying is passed to the writer. The file name is built in step 3 of
Autosaveand never reachesFUN_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. - Transient connection state is detached (step 8) before the write and reattached after, so nothing
session-scoped can leak into
Sim. - 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.
CHECKED by lane Z, 2026-09-08 — half settled, and the other half narrowed. A live hook on
OnAllCombatDone_Tailrecorded exactly one call per End Turn on 8 of 8 End Turns, at depth 0, between the two autosaves, with the post-turn autosave following it. So the handler is delivered every turn and the inference above was right. What is not settled is the stronger reading: on both saves played, the encounter vector is empty atProcessTurnentry and holds exactly one encounter by the time the tail runs (detection creates it, phase 7 clears it), so what was observed is "the tail runs on a turn with no battle", not "on a turn with no encounter at all". Every encounter seen hadres->+0x4 != 0, the flag that makesApplyEncounterResulta whole-function no-op — measured cost 0 RNG words, exactly as the gate predicts.findings/control-flow/tail-rng-ledger.md§4.The same records confirm §2 behaviourally:
encountersreads 1 at the phase-6 call and 0 at the phase-11 call, on every turn. Phase 7 really is a wholesaleclear().
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.
MEASURED by lane Z, 2026-09-08 — "at least twice" is the right phrasing; it is 12 to 14. Read live at hook entry over eight turns on two saves,
S+0x8advances 12–14 times per turn, of which the two drivers account for 2. Both drivers were hooked, so the other 10–12 increments come from a writer nobody has identified, and they happen between the post-turn autosave and the nextProcessTurn.S+0xcadvances by exactly 1 per turn over the same records. The conclusion here is strengthened:S+0x8is not a turn number and is not even a driver-invocation counter.findings/control-flow/tail-rng-ledger.md§5.
7.2 Turn results and turn events are not where lane T said
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 aboutApplyEncounterResultdescribes 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.
AnnounceEncounterSighting0x007a9db0 in particular is a function whose decompile is known-broken. - The 0x178-byte
EncounterResultbeyond+0x4,+0x5,+0x6,+0x7,+0x10cand+0x120. - The nine virtual slots of phase 23 and the two of phase 33 — the largest blind spot in the map.
- Whether
SNMAllCombatDoneis delivered on turns with no combat (§6, labelled hypothesis), and whether theTurnEventsembedded in anSETurnResultsis filled on a normal turn (§5B, labelled hypothesis).