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.
787 lines
57 KiB
Markdown
787 lines
57 KiB
Markdown
# 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).
|