# The turn driver, read from the instruction stream — `StrategyServer::ProcessTurn` and `ServerPlayer::ProcessTurn` Lane T, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. **Method.** Both functions were disassembled **byte for byte** with `objdump -b binary -m i386 -M intel` over the raw image (helper script: file-offset mapping from the PE section table, call targets annotated from `dumps/functions.json`, immediates resolved against `.rdata` for strings). No claim below about *control flow* comes from the decompiler. ReVa was used only to read *callee bodies* for the gap list, never for a branch claim. Predecessor docs this corrects or extends: `findings/control-flow/turn-spine.md` §2.4, `findings/subsystems/strategic-turn-internals.md` §1.2, `findings/subsystems/events.md` §"EVENT_NO_RESEARCH". --- ## 0. Two bases, and the one that bit this lane `StrategyServer` has **two base pointers four bytes apart** and the campaign's notes use both frames without saying which. Settled here from three independent instruction sequences: | name used here | what it is | evidence | |---|---|---| | **`S`** | the `this` of `StrategyServer::ProcessTurn` (0x007dc6c0), and the value `ServerPlayer::GetServer` (0x0080e320) returns | `Players` read at `[esi+0x54]`, `Fleets` at `[esi+0x64]` | | **`S+4`** | the pointer stored in `ServerPlayer+0x8`; the frame every `StrategyServer_off_*` entry in `ghidra/addresses.json` is expressed in | `ServerPlayer::ProcessTurn` 0x0089147f: `eax=[esi+8]; eax-=4; rng=[eax+0x16c]`, and `addresses.json` has `StrategyServer_off_RNGPtr = 0x168` | So `ServerPlayer+0x8 == S+4`, and `ServerPlayer::GetServer` = `*(void**)(p+8) - 4 == S`. Every `StrategyServer_off_*` in `addresses.json` is an `S+4` offset; add 4 to convert to the `S` frame. **Consequence for anyone hooking this:** `Players` is `S+0x54` *or* `(S+4)+0x50`. Read the wrong base and you get an empty player vector and zero declared regions — the exact failure the campaign already paid for once. ### 0.1 There are two per-turn counters, not one | word | who increments it | who reads it | |---|---|---| | `S+0x8` (= `(S+4)+0x4`) | **`StrategyServer::ProcessTurn`, first instruction** (`inc [esi+8]` @0x007dc6f0) | not observed by this lane | | `S+0xc` (= `(S+4)+0x8`, the entry `addresses.json` calls `StrategyServer_off_ModCount`, turn-spine calls `Frame`) | **`StrategyServer::BeginProcessTurn`** 0x007d990a (`inc [esi+0xc]`, then `"Begin processing turn %d."`) | `TechTree::SetResearched` 0x00581e83 stamps `node.turnResearched`; `ServerPlayer::ProcessTurn` reads it for `EVENT_NO_RESEARCH` and for the completed-tech sweep | `turn-spine.md` and `addresses.json` give the **same word** (`S+0xc`) two different names ("Frame" / "ModCount"). The word at `S+0x8` has never been named. Both advance once per turn, in different functions, so they stay in lockstep and no existing result is invalidated — but a reimplementation needs both. --- ## 1. `Game::StrategyServer::ProcessTurn(float dt)` — 0x007dc6c0 .. 0x007dcd67 (1703 B) `__thiscall`, `ret 4`. `this = S`. Ghidra's function size (1660) stops before the SEH tail; the real end is 0x007dcd67. Phases, in execution order. "verified" = read from the instruction stream in this lane. | # | VA | what runs | notes | |---|---|---|---| | 0 | 0x007dc6f0 | `++S->+0x8`; `FUN_0081b390(S+4)` | the second-base call, 108 B | | 1 | 0x007dc6fb–0x007dc859 | **per-system pre-pass** over `Systems` (`S+0x44/0x48`), ascending | skips systems with `sys->+0xc4 == 0`. For an owned system: push **morale event id 0x26** onto the owner (`FUN_00752a10` ctor → `FUN_00743420/0x007433f0` id wrap → `FUN_00743530`+`FUN_008c97f0` format the text from the system's inline `std::string` at `sys+0xa8` → `FUN_00841420(owner, ev, sys)`). Then, for **every** system that passed the `+0xc4` gate, `FUN_007b9df0(S, sys, 0, &emptyVec)` (abandon / chaos) | | 2 | 0x007dc85f | `FUN_0086b300(S->+0x158)` — `ServerTradeManager::ProcessTurn` | 1494 B | | 3 | 0x007dc86a | `FUN_007adc80(S)` — `RegisterTradeSystems` | 192 B | | 4 | 0x007dc8c8–0x007dc8c6 | **per-player pre-pass**: `rec = p->+0x3d8; rec->+0x8 = (1 << playerSlot); if (p->ALid(+0x168) != -1) rec->+0x8 \|= p->AL(+0x16c)` | the turn's shared-vision / alliance mask, rebuilt from scratch each turn. Note the bit index is the player's **position in the server vector**, not `PlyrIdx` | | 5 | 0x007dc8c8–0x007dc928 | build `vector A = {2}` (`FUN_00483410` reserve 1, store 2, bump `_Mylast`); `FUN_00794ad0(&B)` builds `vector B = {0..14}` (the 15 ship-action type ids); `FUN_00513110(&B, &A)` is a **set-difference / erase-if**, so `B = {0..14} \ {2}` | | | 6 | 0x007dc92b | `FUN_007b9b90(S, B, 0)` — **the ship-action dispatcher**, run over every action type *except* type 2 | 591 B; see §1.2 | | 7 | 0x007dc93a | **`ProcessNodeSpaceTravel(S)`** 0x007a0e20 | 2945 B | | 8 | 0x007dc943 | **`StrategyServer::ProcessFleetMovement(S)`** 0x007da9a0 → `MoveFleet` | 3198 B — **verified hook boundary (B4)** | | 9 | 0x007dc948 | `FUN_007b9b90(S, A, 0)` — the same dispatcher over `A = {2}` **only** | the action type that needs the fleet to have arrived first | | 10 | 0x007dc957–0x007dc9c6 | per fleet (`S+0x64/0x68`) → per ship (`fleet+0xa4/0xa8`): `FUN_00814ea0(ship)` | ship upkeep, 106 B | | 11 | 0x007dc9c8–0x007dc9f7 | **per-system `ServerSystem::ProcessTurn(sys)`** 0x007598e0 | **no arguments** (confirms B4). **verified hook boundary (B4)** | | 12 | 0x007dc9f9 | `FUN_0078a7c0(S)` | 254 B | | 13 | 0x007dca00–0x007dca2d | **per-player `ServerPlayer::ProcessTurn(p, dt)`** 0x00891340 | `dt` is `fld [ebp+8]; push ecx; fstp [esp]` — the driver's own float argument, forwarded. §2 shows the callee never reads it | | 14 | 0x007dca31 | `ProcessMissions(S)` 0x007999a0 | 640 B | | 15 | 0x007dca38 | `ProcessStations(S)` 0x007ae480 | 3050 B | | 16 | 0x007dca3f | `ProcessDefenceSats(S)` 0x007af0b0 | 2078 B | | 17 | 0x007dca44–0x007dcaee | per fleet → per ship flag pass: `if (FUN_00814da0(ship, 4, 0)) FUN_0080caf0(ship, 1)`; `if (FUN_00814da0(ship, 0x400000, 0)) FUN_00815230(ship)` | | | 18 | 0x007dcaf4–0x007dcb35 | `FUN_00794ad0(&C)` → `C = {0..14}`; `FUN_007b9b90(S, C, 1)` — the dispatcher a third time, over **all** action types, with the **force flag set** (validate and cancel whatever is left); destroy `C` | **see §1.1 — this is where the "deferred tail" claim came from, and `C` is not an encounter list at all** | | 19 | 0x007dcb3a | `ProcessAid(S)` 0x007ad100 | 2910 B | | 20 | 0x007dcb41 | `ProcessSpecialProjects(S)` 0x007a3310 | 1052 B | | 21 | 0x007dcb48 | `ProcessSurrenders(S)` 0x007d0d10 | 4113 B | | 22 | 0x007dcb4d–0x007dcb78 | per-player `FUN_00818530(p, 0)` | 73 B | | 23 | 0x007dcb7a | `if (S->+0x1b4) { script->vft[0x10](6, 0); script->vft[0x2c](); }` | the `SVScriptObject` hooks | | 24 | 0x007dcb9a | `FUN_0086a8d0(S)` (cdecl, `push esi`) | 248 B | | 25 | 0x007dcb9f | `if (S->+0x1b4) { script->vft[0x10](0x1c, 0); script->vft[0x78](); }` | | | 26 | 0x007dcbc3 | `FUN_0078ab30(S)` | 110 B | | 27 | 0x007dcbca | `FUN_00799380(S)` | 450 B | | 28 | 0x007dcbd1 | `FUN_0078aa70(S)` | 185 B — **identified**: per player × per species 0..6 (skipping 4 and the player's own), if the species is known (`FUN_0080de60`) then `SpeciesDef::Get(sp)->+0x78` is a tech id; if it is not 0xc5 and not already researched, `FUN_00586200(def, 0)` makes it visible. The "you have met this race, its racial tech appears in your tree" rule | | 29 | 0x007dcbd6–0x007dcc07 | per-system `FUN_00743ec0(sys)` | 41 B | | 30 | 0x007dcc0b | `FUN_007b4c00(S)` | 783 B | | 31 | 0x007dcc10–0x007dcce0 | `FUN_007d7f70(S, &S->+0x1e8)` then a sweep over `S+0x1e8`: **0x74-byte team records**, each holding a vector at `rec+0x28/0x2c` of **0x44-byte member entries whose first word is a `ServerPlayer*`**. For each member: `if (p->IsAI(+0xf9) == 0 \|\| p->+0xfa != 0) p->Status(+0x164) = 1` | the "everyone goes back to playing" sweep. Division constants verified: `0x8d3dcb09`/`sar 6` = ÷0x74, `0x78787879`/`sar 5` = ÷0x44 | | 32 | 0x007dcce6–0x007dcd67 | destroy `B`, `A`; SEH unwind; `__security_check_cookie`; `ret 4` | | ### 1.1 Correction: the end-of-turn tail is **not** conditional on pending encounters `turn-spine.md` §2.4 step 11 says: *"If encounters exist the tail is deferred to the `SNMAllCombatDone` handler."* **That is a decompiler artefact.** The bytes at 0x007dcb15 are ``` 007dcb15 cmp DWORD PTR [ebp-0x94],ebx ; vector C._Myfirst == 0 ? 007dcb1b je 0x7dcb38 ; yes -> skip the free 007dcb1d lea ecx,[ebp-0x94] 007dcb23 call DWORD PTR ds:0x9dd12c ; _Orphan_all 007dcb29 mov ecx,DWORD PTR [ebp-0x94] 007dcb2f push ecx 007dcb30 call 0x924faa ; operator delete (marked noreturn in the DB) 007dcb35 add esp,0x4 007dcb38 mov ecx,esi ; <-- BOTH PATHS ARRIVE HERE 007dcb3a call 0x7ad100 ; ProcessAid ``` Both arms converge on 0x007dcb38. It is the **inlined `std::vector` destructor**, exactly the shape lane A described: `operator delete` is marked noreturn, so the decompiler drops the fall-through and the `if` reads as a phase gate. There is **no branch anywhere in this function on the encounter snapshot's contents**. Phases 19–31 run on **every** turn, encounters or not. The same idiom appears at 0x007dc812, 0x007dc7d0, 0x00891725, 0x00891756 and 0x007dccea — treat every `if (vec._Myfirst)` in a decompile of this family as a destructor until the bytes say otherwise. (What is genuinely deferred to `SNMAllCombatDone` is the *separate* tail `StrategyServer::OnAllCombatDone_Tail` 0x007d92a0 — bankruptcy, `UpdateBankruptcyLimits`, turn results, turn events. That is a different function reached from a different message; the two were conflated.) The second reason the old reading cannot be right: **`FUN_00794ad0` does not produce an encounter list.** It fills a `vector` with the fifteen ship-action type ids 0..14 (§1.2). Encounter detection is `FUN_007d7f70` at phase 31, after everything. ### 1.2 The ship-action dispatcher, `FUN_007b9b90(S, actionTypes, force)` Runs **three times a turn** with different type sets. It walks the server's queued-ship-action list (`S+0x78/0x7c`, i.e. `(S+4)+0x74/0x78`) and, for each action whose type is in `actionTypes`, dispatches through a **12-entry function-pointer table indexed by action type** (targets include `FUN_00789500`, the ship-borne `BuildQueue::ProcessTurn` wrapper of `strategic-turn-internals.md` §3.4, plus colonise / terraform / mine / scrap-shaped handlers). With `force` set it instead validates each action (`FUN_0083cbb0`) and cancels the failures (`FUN_00849280`). So the turn's shape around movement is: **all ship actions except type 2 → node-space travel → fleet movement → action type 2 only → … → all action types with force-cancel.** Type 2 is the action that needs the fleet to have arrived first. *Source: ReVa decompilation of `FUN_00794ad0` / `FUN_00513110` / `FUN_007b9b90` (delegated sweep). The **call shape and the three argument sets are instruction-verified**; the dispatch-table contents are not.* --- ## 2. `Game::ServerPlayer::ProcessTurn(float dt)` — 0x00891340 .. 0x00891783 (1086 B + epilogue) `__thiscall`, **`ret 4`** — it takes exactly one stack argument, the `dt` the driver forwards, and **never reads it**: the whole body contains zero `[ebp+…]` references (checked mechanically over the full decode). A reimplementation may ignore `dt`. `this = esi` throughout. The `Budget` out-parameter lives at `[ebp-0x90]`; slot *i* is at `-0x90 + 4i`, so the vector header at `[ebp-0x38..-0x30]` is `Budget+0x58` — confirming B1's `{int slot[22]; vector researchAlloc @+0x58}` by construction rather than by inference. ### 2.1 Phase map | # | VA | what | |---|---|---| | 1 | 0x008913c6 | `ComputeBudget(&B, /*projected=*/false)` 0x00863030 — **verified hook boundary (B1)** | | 2 | 0x008913d6–0x0089141b | `net = B[1]+B[2]+B[3]+B[4]+B[5]+B[6] − B[7]−B[8]−B[9]−B[10]−B[11]−B[12]−B[13]−B[14]`; `Sav(+0x284) = SatAdd(B[0], net)`. **Verified operand by operand** — this is exactly B1's documented `net`, and `SatAdd` (0x00817990) clamps to ±2,000,000,000 (`0x77359400` / `0x88ca6c00`) | | 3 | 0x0089141e–0x00891439 | **assignments, not clears** (corrects `strategic-turn-internals.md` §1.2 step 2): `(p->+0x3d8)->+0x10 = B[2]` (trade income), `p->+0xcc = B[14]` (savings given away), `p->+0xc8 = B[19]` (research points given away) | | 4 | 0x00891441–0x0089145c | `spend = p->IsAI(+0xf9) ? ConstructionSpend(p, B[15]) : B[11]`; `ProcessSpecialProjects(p, spend, B[15])` 0x00840fe0. (Human uses the precomputed slot because `ComputeBudget` only fills `[11]` when `+0xf9 == 0`.) | | 5 | 0x00891461–0x008914f5 | **research**, gated on `ResT(+0x294) != 0`: `if (!RollResearchAccident(&B)) ProcessResearch(rng, &B.researchAlloc, &overBudget)`. Arg order verified from the push order at 0x00891496–0x008914a5: `(RNG*, vector*, int*)` with `this = p->TechTree(+0xf4)`. **verified hook boundary (B3/U)** | | 6 | 0x008914aa–0x008914f5 | **NEW — the research refund, previously unmodelled.** `if (overBudget > 0 && B[17] > 0) Sav = SatAdd(Sav, ftol( min(overBudget, B[17]) / (double)B[17] * B[16] ))`. Unspent research points are converted back to money at the turn's own points-per-credit rate and returned to savings. `overBudget` is a **fresh local**, not `Budget+0x64` | | 7 | 0x008914f7–0x00891511 | zero `TRM(+0xd0, float)`, `TRA(+0xd4)`, `TRP(+0xd8)`, `+0xdc`, `+0xe0` | | 8 | 0x00891517–0x00891574 | `if (RebAI(+0xfc)) RebOutMod(+0x128) = clamp(RebOutMod − 0.04f, 1.0f, 2.0f)`. Constants read out of the image: 0x00a17870 = `0.04f`, 0x00a17868 = `1.0f`, 0x00a1786c = `2.0f` | | 9 | 0x0089157a–0x008915ea | timed research bonuses, `vector<{float PRm; int PRBt}>` at `+0x3a4/+0x3a8`, **iterated from the LAST element down to index 0**: `TRM += PRm; if (--PRBt <= 0) erase`. The descending order is load-bearing for bit-exactness — float addition is not associative | | 10 | 0x008915ec–0x00891624 | **the `ResearchRollPending` site — §3** | | 11 | 0x0089162a–0x00891745 | **`EVENT_NO_RESEARCH` — §4** | | 12 | 0x00891748 | `FUN_00863cf0(p)` — unconditional tail, **identified**: prunes the `vector` at `p+0x338/+0x33c` (0x20 stride, `struct-recovery.md` line 337). A record is erased when its inner list is empty, when `ModCount − rec->+0x1c > 20`, when `FUN_008b9240((S+4)+0x80, rec->+0x4)` no longer resolves, or when the resolved object's `+0x90` mask lacks `1 << p->PlyrIdx(+0x28)` | | 13 | 0x0089174f–0x00891783 | destroy `B.researchAlloc`; SEH unwind; `ret 4` | --- ## 3. `ResearchRollPending` — settled `ResErrRoll` / `ResearchRollPending` is `ServerPlayer+0x3b4`, a serialized `bool` (save tag `ResErrRoll`, immediately after `ResTNm`; `verify/save-reader/save_reader.py:559`, `tools/serializers_golden.py:230`). There are exactly **two consumers in the whole image** (mechanical scan of every ModRM displacement 0x3b4 whose base register is a `this` candidate, `tools/x86disp.py query 0x3b4`): ### Site A — `ServerPlayer::ProcessTurn` + 0x2ac, VA 0x008915ec ``` 008915ec mov eax,[esi+0x294] ; ResT 008915f2 cmp eax,ebx ; je +0x3e ; ResT == 0 -> skip 008915f6 cmp BYTE [esi+0x3b4],bl 008915fc je 0x89162a ; !ResErrRoll -> skip 008915fe mov ecx,[esi+0xf4] ; TechTree* 00891604 push eax ; ResT 00891605 call 0x57e950 ; ratio = progress / Cost (returns float in st0) 0089160d fld DWORD PTR ds:0xa2c788 ; 0.5f 00891613 fcomp [ebp-0x10] ; 0.5f vs ratio 0089161b jp 0x89162a ; NOT (0.5f < ratio) -> skip 0089161d mov ecx,esi 0089161f call 0x88df20 ; RollResearchEvent (one NextFloat, PLUS a second word ; if the roll fires the plague path -- see §3.1) 00891624 mov BYTE [esi+0x3b4],bl ; ResErrRoll = 0 <-- INSIDE the branch ``` - **Ordering, corrected.** This site is at ProcessTurn+0x2ac; `ProcessResearch` is called at ProcessTurn+0x165. Site A runs **after** `ProcessResearch`, not before it. `campaign/board.md` and lane U state the opposite ("consumed by `ServerPlayer::ProcessTurn` BEFORE `ProcessResearch`"). The *effect* lane U inferred is right; the mechanism is one turn earlier than stated: the flag is consumed at the **end of turn N** so it is already gone when turn N+1's `ProcessResearch` runs. - The threshold is a **strict** `>`: `fld 0.5f; fcomp ratio; test ah,5; jp skip` is MSVC's `if (0.5f < ratio)`. The constant at 0x00a2c788 is `0.5f` (read from the image). - `FUN_0057e950` = `TechTree::ResearchProgressRatio(TechDef* def)`: `node = this->nodes[def->id]`; `return node ? (float)(node->progress(+0x1c) / (double)TechTree::Cost(node)) : 0.0f`. 59 bytes, whole body read. - **The clear is conditional on the roll firing.** If the ratio test fails, `ResErrRoll` is left set. This is different from site B and it is the reason the flag can persist across turns. ### Site B — `ServerPlayer::OnTechResearched` + 0x51, VA 0x008917e1 ``` 008917e1 cmp [esi+0x294],edi ; ResT == def ? 008917e7 jne 0x891804 008917e9 cmp BYTE [esi+0x3b4],bl 008917ef je 0x8917f8 008917f1 call 0x88df20 ; RollResearchEvent 008917f8 mov BYTE [esi+0x3b4],bl ; ResErrRoll = 0 <-- UNCONDITIONAL inside the ResT==def branch 008917fe mov [esi+0x294],ebx ; ResT = 0 ``` Confirms lane U's prototype string verbatim, and adds that the clear here is unconditional. ### The rule, and what it implies for the save lane O should build ``` turn N: ProcessResearch -> (maybe) SetResearched -> OnTechResearched [site B] ... site A: if (ResT && ResErrRoll && progress/Cost > 0.5f) { roll; ResErrRoll = false; } ``` `ResearchRollPending` survives into turn N+1's `ProcessResearch` **iff, at the end of turn N, `progress(ResT) / Cost(ResT) <= 0.5` and no completion consumed it.** For site B to then fire, the tech must go from **≤ 50 % of cost** to **completed** inside turn N+1 — which `ProcessResearch` (§2.3 of `strategic-turn-internals.md`) permits only through the `progress >= 1.5 × cost` guaranteed-completion path or a single spend large enough to push the odds above the roll. That is exactly lane V's rare call-9 and exactly why three sessions produced 0 / 1 / 0. **The save to build.** Two routes, and the cheap one is now available: 1. **Edit the save directly.** `ResErrRoll` is on the wire as a named tag, and per the campaign's own rule it is written by `WriteBool`. Set it true on a player whose `ResTNm` names a tech with **large cost and near-zero progress**, and whose budget delivers a research-points total ≥ 1.5 × that tech's cost in one turn (so the completion is guaranteed, not a roll). Then one End Turn fires site B with the flag set: `roll_draws == 1`. This is the only route that starts a turn with the flag already true, which is the state that has *never* been observed and the state the B3/U compare has never exercised. 2. **Play to it.** Pick a fresh expensive target on a turn where savings + `ResRate` can fund ≥ 1.5 × cost, so the tech goes 0 % → complete in one turn while `ResErrRoll` is still set. **Open, and labelled as such: nothing was found that *arms* the flag during play.** The only writes of `+0x3b4` in the whole image are the two clears above, the `ServerPlayer` constructor at 0x00880474 (`mov WORD [esi+0x3b4], 1` — the flag is born **true**, together with `+0x3b5 = 0`), a `mov [esi+0x3b4], eax` inside 0x00882500, `ServerPlayer::Read`, and a **virtual pair** reachable only through the vtable: `FUN_0080e300 = { p->+0x3b4 = 1; }` and `FUN_0080e310 = { p->+0x3b4 = 0; }`, at vtable slots 6 and 7 of `0x00a327a4`. `find-vtable-callers` was run on 0x0080e300 and every one of its 18 hits calls a *different* class's slot at byte offset 0x70 with arguments — none is this no-argument setter. **So: who re-arms `ResErrRoll` is unresolved.** The evidence is consistent with "born true, consumed once, re-armed only through the interface", which would match lane U's Zuul observation (true for turns 8–12, false from turn 13 on) — but that is a **hypothesis**, not a fact, and route 1 above does not depend on it. ### 3.1 What the roll actually buys — `FUN_00889d60`, the branch nobody has seen fire `RollResearchEvent` 0x0088df20 (whole 98-byte body read here): `odds = ResearchEventOdds(this, this->ResT)`; `roll = NextFloat(rng + 4)` — note the generator is entered at **`rng+4`**, a *third* instance of the two-bases pattern; `roll = roll*(1.0 − 0.0) + 0.0` with the bias constant at 0x009e1e68 = `0.0f`, so `roll == NextFloat()`; `if (odds > roll) FUN_00889d60(this)` (`fcompp` + `test ah,0x41` + `jne skip`, so equality also skips). `FUN_00889d60` then splits on the *current research target*: - **Plague branch** — `ResT` is one of five hard-coded plague-cure `TechDef`s (ids 0x273c..0x2740, resolved by `FUN_00535480`) → `FUN_00889bb0`: draws **one further `Mars::RNG::NextInt`** to pick a random element of the player's `OwnId` vector (`ServerPlayer+0x30/0x34`) and posts **`EVENT_PLAGUE_OUTBREAK`** naming it. - **Rebellion branch** — `ResT` is in the "aggression" tech table (`FUN_00690f70`) **and** the player's `cta` byte at `+0x3b5` is set → `FUN_008206a0`: allocates a `Game::AIRebellionImpl` into `ServerPlayer+0x3b8` if there is none, then **cancels the current research** — resets the node's progress (`FUN_0057e7c0`), puts its state back to 2 (`FUN_0057e8d0`), calls a vtable hook, clears `ResT`. **No further RNG.** - Otherwise: nothing. Two consequences that matter to anyone declaring regions around this: 1. **The RNG cost of a fired roll is 1 or 2 words, not 1.** Every existing coverage note says "exactly one `NextFloat`". That is the cost of *reaching* `FUN_00889d60`; the plague branch spends a second word. 2. `+0x3b5` — the byte the `ServerPlayer` constructor zeroes in the same two-byte store that sets `ResErrRoll` (§3) — is the **rebellion branch's own gate**. The two flags are adjacent and initialised together. *Source: ReVa decompilation of 0x00889d60 / 0x00889bb0 / 0x008206a0 / 0x00820380 (delegated sweep), except `RollResearchEvent` itself which was read from the instruction stream. Treat the branch contents as decompiler-derived until a live trace fires them.* --- ## 4. `EVENT_NO_RESEARCH` — settled, and the previous description was wrong VA 0x0089162a–0x00891745. Verified condition: ```c if (p->ResT == NULL) { std::vector done; // {TechDef*, int turnResearched, int flag}, stride 12 TechTree::CollectResearchedTechs(&done, /*minTurn=*/ModCount, /*maxTurn=*/INT_MAX, /*sort=*/1); if (done.empty() && TechTree::FindFirstAvailableTech() != NULL) EventStorage::PostEvent(&p->Events(+0x29c), "", "", 0, 0, ModCount, "EVENT_NO_RESEARCH", 1); } ``` - `FUN_00584e50` is **`TechTree::CollectResearchedTechs(out, minTurn, maxTurn, sort)`**, not a list of *available* techs (`strategic-turn-internals.md` §1.2 step 9). Whole body read: it clears `out`, walks `master->defs (+0x24/+0x28)`, **skips any def whose name tail matches `"_Root"`**, requires `node->state(+0x14) == 4` (researched) and `minTurn <= node->turnResearched(+0x24) <= maxTurn`, and pushes `{def, turnResearched, node->flag(+0x2c)}`. The 4th argument is a `bool` at `[ebp+0x14]`: when set it calls `FUN_00582cd0(first, last, count, master)` — a sort. `ret 0x10`. - Called with `minTurn = ModCount`, so `done` is **"techs this player completed on this very turn"**. - `FUN_0057da90` is **`TechTree::FindFirstAvailableTech()`**: first node in `this->nodes` whose self-resolved node has `state == 2`; returns its `TechDef*`, else NULL. (Same self-resolving `nodes[def->id]` idiom lane E corrected in `ProcessResearch`.) So the event means: *"you have no research target, you did not just finish one, and there is something you could pick."* Not "no techs are available" — the opposite. The two empty `std::string` arguments are built inline from `*(char**)0x00af09d4` and `*(char**)0x00af09cc` (both resolve to the same empty string at 0x009e100c) via strlen + `FUN_00425550`. --- ## 5. Gap list — what a turn does that we have not modelled Ranked by whether it is worth attacking next. "RNG?"/"events?" are from the callee sweep; a `?` means not swept. ### Tier 1 — self-contained, verified boundary, high value | target | size | why | |---|---|---| | **`ServerPlayer::ProcessTurn` itself** | 1086 B | fully read; the only thing between the verified pieces is arithmetic we now have. Phases 2, 3, 6, 7, 8, 9 are new and small. This is the natural next hook — see §6 | | **the research refund** (phase 6) | ~70 B | `Sav += ftol(min(overBudget,B[17]) / B[17] * B[16])`. Uses the `overBudget` B3 already produces and B1 already captures. Closes a real savings term | | **the PR bonus sweep** (phase 9) | ~110 B | a whole serialized vector (`NumPR`/`PRm`/`PRBt`) that nothing models. Descending iteration order is the only subtlety | | **`FUN_0078aa70`** (phase 28) | 185 B | the met-a-race → racial-tech-unlock rule; pure, no RNG, no events, three callees. A clean small win that touches the tech tree we already model | ### Tier 2 — medium, mapped elsewhere but never exercised | target | size | why | |---|---|---| | `ServerTradeManager::ProcessTurn` 0x0086b300 | 1494 B | `strategic-turn-internals.md` §1.4 has the formulas; nothing has ever run against the game. Feeds `ComputeBudget[2]`, which B1 currently copies out of the original | | `RegisterTradeSystems` 0x007adc80 | 192 B | tiny, and it is the input to the above | | `ProcessAid` 0x007ad100 | 2910 B | writes `Sav` and research points of *other* players; already implicated in `ComputeBudget[13]/[14]/[19]` | | the per-player alliance-mask rebuild (phase 4) | ~85 B | trivial, and it is the input to every visibility question | | the team sweep `FUN_007d7f70` + `S+0x1e8` (phase 31) | 2432 B | rebuilds the team table and is what puts every player back into `Status = 1`. The 0x74/0x44 strides are verified; the record contents are not | | `FUN_00863cf0` raid-target prune (player phase 12) | 239 B | fully read here; 20-turn ageing; needs the `RaidTargets` layout confirmed | ### Tier 3 — large, and each is its own milestone `ProcessSurrenders` 0x007d0d10 (4113 B), `ProcessStations` 0x007ae480 (3050 B), `ProcessNodeSpaceTravel` 0x007a0e20 (2945 B), `ProcessDefenceSats` 0x007af0b0 (2078 B), `ProcessSpecialProjects` (server) 0x007a3310 (1052 B), `ProcessSpecialProjects` (player) 0x00840fe0 (1015 B), `FUN_007b9df0` abandon/chaos (896 B), `FUN_007b4c00` (783 B), `ProcessMissions` 0x007999a0 (640 B), `FUN_007b9b90` (591 B, run three times a turn), `FUN_00799380` (450 B), `FUN_00794ad0` (330 B). ### Tier 3.5 — identified this lane by decompilation (not instruction-verified), so no longer blind | addr | what it is | |---|---| | `FUN_0081b390` phase 0 | a **previous-turn snapshot pass**: per system copies "current" words into shadow words (`+0x20c/0x210/0x214 ← +0x68/0x6c/0x70`, `+0x150.. ← +0x120..`, `+0xc9 ← +0xc8`), per player `+0x188 ← Sav(+0x284)` and `+0x18c ← +0x138`. Also called from `BuildTurnEvents`, so it is what "changed since last turn" is diffed against. Draw-free, event-free | | `FUN_007b9df0` phase 1 | the abandon/chaos check: below `INDSYS_MIN_CHAOS_POPULATION` it clears the owner, resets `OutputRates` and raises **`SESystemAbandoned`** through `FUN_007a6630` | | `FUN_00814ea0` phase 10 | gated on design flag `0x4000000` (the population-carrier flag `ComputeBudget[3]` also uses): upkeep of `Population` groups carried aboard colony/slaver ships in transit | | `FUN_0078a7c0` phase 12 | walks 12-byte `{systemIdx, playerIdx, value}` records, re-derives a per-system float at `sys+0x88` and **re-normalises `OutputRates`** via `FUN_00747390`. Reads as the trade-slider finalisation; the identification of `FUN_00833af0`/`FUN_00819ba0` is a **guess** | | `ProcessMissions` phase 14 | per player × active mission × target-name list: resolves each target name, tests a relation predicate, marks the mission complete (`+6 = 1`). **No `EVENT_*` literal was found — unconfirmed whether it posts anything** | | `FUN_00814da0` / `FUN_0080caf0` / `FUN_00815230` phase 17 | `FUN_00814da0(ship, m1, m2)` is a generic two-mask ship-flag predicate with 54 callers. The two bodies re-sync cached per-ship stat words (`ship+0x6c/0x70`, and one more) from the design record (`design+0xd8/0xdc/0xd0`) — a refit/upgrade cache refresh. Trivial, draw-free, event-free | | `FUN_00818530` phase 22 | steps an in-progress **`AIRebellion`** at `ServerPlayer+0x3b8`: if it signals completion, calls the object's `vft[0]` with 1 and nulls the pointer. Called with `0` here and with `1` from the deferred combat tail. **Same field `FUN_008206a0` writes when a research roll spawns a rebellion (§3.1)** | | `FUN_0086a8d0` phase 24 | the **sensor / fog-of-war update**, timed with `QueryPerformanceCounter` and logged as `"Sensors: %f seconds to update sensors for all players."`. Per player × per system and per player × per fleet, packing 2-bit visibility into `system+0x24` / `fleet+0x54` for up to 15 players. Deterministic | | `FUN_0078ab30` phase 26 | per (system, player): `FUN_006e4620(system, player, 0)` — refreshes each player's `StarSystem::PlayerView`. Callee not traced | | `FUN_00799380` phase 27 | a player-pair relation sweep plus a per-system population-carrier aggregation; best read as recomputing `PlayerReport` totals. **Guess** | | `FUN_00743ec0` phase 29 | if `sys->+0xd4 != 0`, sets `sys->+0x2c8` from `(*(sys+0x10))+8` or a default. Too small to name | | `FUN_007b4c00` phase 30 | builds a union-find-shaped merge over three heap nodes driven by per-system and per-fleet loops, immediately before encounter detection: most likely **the team partition** phase 31 then walks. **Plausible, not verified** | | `FUN_007d7f70` phase 31 | **encounter detection**: per system with combatants present, builds the pairwise 0x74-byte team records for fleet pairs at war | | `ConstructionSpend` 0x00817f90 | sums `min(ftol(orderFloat), remaining)` over the player's polymorphic build-order list, capped by `available`. Pure arithmetic, draw-free | *All of the above are ReVa decompilations, not instruction reads. Their call shapes are instruction-verified from the driver; their bodies are not.* ### Tier 4 — small and unidentified; cheap to close, low individual value `FUN_0081b390` (108 B, phase 0), `FUN_0078a7c0` (254 B), `FUN_00814ea0` ship upkeep (106 B), `FUN_00814da0` ship flag test (45 B), `FUN_0080caf0` (46 B), `FUN_00815230` (16 B), `FUN_00818530` (73 B), `FUN_0086a8d0` (248 B), `FUN_0078ab30` (110 B), `FUN_00743ec0` (41 B), `FUN_00513110` (144 B), `FUN_00483410` (133 B), plus the two `SVScriptObject` hook pairs (`vft[0x10](6)/vft[0x2c]`, `vft[0x10](0x1c)/vft[0x78]`) — scripted-scenario callbacks, dead in a normal game but not proven so. **What is conspicuously absent from a turn:** no bankruptcy, no turn-results build, no turn-events build, no autosave. Those are all in `StrategyServer::OnAllCombatDone_Tail` 0x007d92a0 — a second, separate driver that no lane has read. **That is the other half of the spine and it is the single biggest unread block in the turn.** --- ## 6. Prepared hook: `Game::ServerPlayer::ProcessTurn` Descriptor, regions and the written prediction live in the engine worktree (`wip/turn`), see `sots-engine/docs/T-turn-driver.md`. Not deployed; lane T holds no VM.