sots-re/findings/control-flow/combat-done-tail.md
alex 9c37635ca3 Z: the unnamed counter at S+0x8 is ModCount, and my players flag was my own error
StrategyServer::Write tags both words itself: S+0x8 is ModCount and S+0xc is
Frame. addresses.json has the name on the wrong word and lane T's
PhaseCounter is the one the wire calls ModCount. The saves confirm it
independently -- ModCount 0/12/24 across turn1/2/3-state, 241/412 across Zuul
16/23 -- and those deltas are exactly the 12-44 per turn measured live. So the
'writer nobody has identified' question dissolves: it is a modification
counter, it scales with the empire, and there is no single writer to find.

The players=8 flag is withdrawn. The offset is right, pinned by the ctor's
four-vector enumeration at 0x0085b120 with no frame arithmetic needed, and the
count is right: the vector is empires + one rebel-AI per empire species + four
NPC pseudo-players, so 8 on the Human saves and 7 on the Zuul ones against a
lobby that says 2 in both. My draft claimed the hook read 8 on both saves. It
read 7 on the Zuul one. I generalised from one run without re-reading the
other, and a check aimed at something else caught it.
2026-09-08 10:04:21 -04:00

787 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `Encounter`s — 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** the `Chance(0.5f)` call at 0x007ae0a5 and is reached
> only when the roll *succeeded* (`test al,al; je 0x007ae1e2` at 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 — one `NextFloat` per
> expired node line per turn — is unaffected, and the expiry test is now a read formula:
> `NodePath::RemainingLife` 0x006e2130 returns `npdtn − nptf/npdtf − (turn − npctm)` clamped at 0, with two
> never-expire early-outs (`npt == 0`, `npdtn == INT_MAX`). See `findings/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::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, `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 00` and 0x00895b5c is
> `mov eax,esi`, which puts `outName` back in EAX: this is MSVC's named-return-value slot, not a `void`.
> A hook or reimplementation declaring it `void` drops EAX at both call sites. Neither site passes `this`
> as an argument — both hardcode `mov ecx,0xb29f98`. Lane Z also observed live that `[global+0x54]` is
> **not** the `StrategyServer`, so the global this function runs on is a different object from the
> `StrategyHost` whose `+0x54` `OnMessage` reads.
| 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.
> **CHECKED by lane Z, 2026-09-08 — half settled, and the other half narrowed.** A live hook on
> `OnAllCombatDone_Tail` recorded **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 at `ProcessTurn` entry 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 had `res->+0x4 != 0`, the
> flag that makes `ApplyEncounterResult` a 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: `encounters` reads 1 at the phase-6 call and 0 at the phase-11
> call, on every turn. Phase 7 really is a wholesale `clear()`.
---
## 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**.
> **NAMED and MEASURED by lane Z, 2026-09-08 — the word is `ModCount`, and it moves 12 to 44 times a
> turn.** `StrategyServer::Write` tags it itself: `0x0079fb2f lea edx,[edi+0x08]; push "ModCount"` and
> `0x0079fb40 lea eax,[edi+0x0c]; push "Frame"`, with `edi = S`. So **`S+0x8` is `ModCount`** — the word
> this section says "has never been named" — **and `S+0xc` is `Frame`**, the turn. (`addresses.json` has
> `StrategyServer_off_ModCount` on the wrong word; the name belongs to lane T's
> `StrategyServer_off_PhaseCounter`.) Measured live over eight turns on two saves, `S+0x8` advances **12–14
> times per turn** on an early two-colony game and **16–44** on a turn-19 Zuul one, of which the two drivers
> account for 2 — and the saves agree independently (`ModCount` 0 → 12 → 24 across turn1/2/3-state, 241 → 412
> across Zuul turns 16 → 23). A modification counter is exactly what that looks like, so "which writer bumps
> the rest" has no single answer. `S+0x8` is not a turn number, not a driver-invocation counter and not a
> constant per turn. `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
```c
// 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).