diff --git a/findings/control-flow/turn-driver.md b/findings/control-flow/turn-driver.md new file mode 100644 index 0000000..2d493ca --- /dev/null +++ b/findings/control-flow/turn-driver.md @@ -0,0 +1,390 @@ +# 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 (exactly one NextFloat) +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. diff --git a/ghidra/addresses.d/lane-t.json b/ghidra/addresses.d/lane-t.json new file mode 100644 index 0000000..d19853e --- /dev/null +++ b/ghidra/addresses.d/lane-t.json @@ -0,0 +1,428 @@ +{ + "entries": [ + { + "name": "ServerPlayer_ProcessTurn", + "addr": "0x00891340", + "convention": "thiscall", + "prototype": "void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72 (lane T 2026-09-08, whole function disassembled 0x00891340-0x00891783)" + }, + { + "name": "ServerPlayer_ProcessTurn_ResearchCall", + "addr": "0x00891461", + "convention": "site", + "prototype": "site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_ProcessTurn_ResearchRefund", + "addr": "0x008914aa", + "convention": "site", + "prototype": "site in ServerPlayer::ProcessTurn, immediately after ProcessResearch returns: `if (overBudget > 0 && budget[17] > 0) Sav = SatAdd(Sav, ftol( (double)min(overBudget, budget[17]) / budget[17] * budget[16] ))`. budget[17] = researchPoints, budget[16] = researchMoney. Unspent research points are refunded to savings at the turn's own points-per-credit rate. PREVIOUSLY UNMODELLED - it is not in strategic-turn-internals.md \u00a71.2. The min is selected by `cmp ecx,eax; lea eax,[ebp-0x18]; jl; lea eax,[ebp-0x10]`, then `fild; fidiv budget[17]; fimul budget[16]; call _ftol2`", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x008914aa-0x008914f5)" + }, + { + "name": "ServerPlayer_ProcessTurn_ResearchRollBlock", + "addr": "0x008915ec", + "convention": "site", + "prototype": "site in ServerPlayer::ProcessTurn, AFTER ProcessResearch (ProcessTurn+0x2ac vs +0x165): `if (ResT(+0x294) && ResErrRoll(+0x3b4) && 0.5f < TechTree::ResearchProgressRatio(tree, ResT)) { RollResearchEvent(this); ResErrRoll = false; }`. THE CLEAR IS INSIDE THE BRANCH - when the ratio test fails the flag is left set, which is how it survives to the next turn. The threshold is a strict `<` (MSVC `fld 0.5f; fcomp ratio; fnstsw; test ah,5; jp skip`), constant 0x00a2c788 = 0.5f. This is the site campaign/board.md describes as running BEFORE ProcessResearch; it runs after", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, 0x008915ec-0x00891629)" + }, + { + "name": "ServerPlayer_SetResearchRollPending", + "addr": "0x0080e300", + "convention": "thiscall", + "prototype": "void (ServerPlayer* this) // `mov byte [ecx+0x3b4], 1; ret` - the only ARMING write of ResErrRoll in the image other than the constructor. Referenced ONLY from the vtable word at 0x00a327bc (slot 6 of the 0x00a327a4 ServerPlayer vptr), so every caller is an indirect `call [vft+0x18]`. find-vtable-callers returns no genuine caller: all 18 hits are other classes' slot at byte offset 0x70 and pass arguments. WHO RE-ARMS THE FLAG DURING PLAY IS UNRESOLVED", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, tools/x86disp.py query 0x3b4 + ReVa find-cross-references)" + }, + { + "name": "ServerPlayer_ClearResearchRollPending", + "addr": "0x0080e310", + "convention": "thiscall", + "prototype": "void (ServerPlayer* this) // `mov byte [ecx+0x3b4], 0; ret`. Vtable word 0x00a327c0, the slot after SetResearchRollPending", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_ctor_ResearchRollPendingInit", + "addr": "0x00880474", + "convention": "site", + "prototype": "site in the ServerPlayer constructor: `mov WORD PTR [esi+0x3b4], 1` - a two-byte store that sets ResErrRoll(+0x3b4) = 1 and +0x3b5 = 0. THE FLAG IS BORN TRUE. Consistent with (but not proof of) the hypothesis that ResErrRoll is armed once at construction and consumed once, never re-armed except through the vtable pair", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, tools/x86disp.py query 0x3b4)" + }, + { + "name": "TechTree_ResearchProgressRatio", + "addr": "0x0057e950", + "convention": "thiscall", + "prototype": "float (TechTree* this, TechDef* def) // RET 4, result in st(0). node = this->nodes(+0x10)[def->id(+0x0)]; if (!node) return 0.0f; return (float)((double)node->progress(+0x1c) / (double)TechTree::Cost(node)). Whole 59-byte body read. The divide is done in double and narrowed once on the fstp. Called from exactly one place: the ResearchRollPending block in ServerPlayer::ProcessTurn", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08)" + }, + { + "name": "TechTree_FindFirstAvailableTech", + "addr": "0x0057da90", + "convention": "thiscall", + "prototype": "TechDef* (TechTree* this) // plain RET. Walks this->nodes(+0x10/+0x14) in index order; for each non-null node resolves node->def(+0x0) and re-indexes nodes by that def's id (the same self-resolving idiom lane E corrected in ProcessResearch), and returns the FIRST def whose node state(+0x14) == 2 (available). NULL when none. Used only as the second half of the EVENT_NO_RESEARCH test", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a74 (lane T 2026-09-08, whole 68-byte body read)" + }, + { + "name": "TechTree_CollectResearchedTechs", + "addr": "0x00584e50", + "convention": "thiscall", + "prototype": "void (TechTree* this, std::vector* out, int minTurn, int maxTurn, bool sort) // RET 0x10. Clears *out, walks this->master(+0x4)->defs(+0x24/+0x28); SKIPS any def whose name (std::string at def+0x4) tail from its first '_' matches \"_Root\" case-insensitively; requires nodes[def->id]->state(+0x14) == 4 (researched) and minTurn <= node->turnResearched(+0x24) <= maxTurn; pushes {TechDef*, turnResearched, node->flag(+0x2c)} (12-byte record). When `sort` is true it finally calls 0x00582cd0(first, last, count, this). ServerPlayer::ProcessTurn calls it with (minTurn = server ModCount, maxTurn = INT_MAX, sort = true), i.e. \"techs this player completed on THIS turn\". strategic-turn-internals.md \u00a71.2 step 9 described it as a list of AVAILABLE techs; it is the opposite", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a74 (lane T 2026-09-08, whole 290-byte body read)" + }, + { + "name": "ResearchedTechRecord_stride", + "offset": "0xc", + "convention": "field", + "prototype": "sizeof(ResearchedTechRecord) = { TechDef* def; int turnResearched; int flag } - the element TechTree::CollectResearchedTechs pushes. Stride confirmed by the vector-clear loop at 0x00584e75 (three dword copies, `add eax,0xc`)", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a74 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_SatAdd", + "addr": "0x00817990", + "convention": "cdecl", + "prototype": "int (int a, int b) // saturating add. Overflow-checked: b>0 and a+b<=a -> +2000000000 (0x77359400); b<0 and a+b>=a -> -2000000000 (0x88ca6c00); otherwise clamp(a+b, -2000000000, +2000000000). Whole 94-byte body read. Used for every write to ServerPlayer::Sav in ProcessTurn (the net apply and the research refund)", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_ConstructionSpend", + "addr": "0x00817f90", + "convention": "cdecl", + "prototype": "int (ServerPlayer* p, int available) // the AI branch of ProcessTurn phase 4: a human player uses budget[11], which ComputeBudget fills only when p->IsAI(+0xf9)==0, and an AI recomputes it here. Body not read by lane T; the call shape (two pushes, `add esp,8`) is verified", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, call site 0x0089144e)" + }, + { + "name": "ServerPlayer_ProcessSpecialProjects", + "addr": "0x00840fe0", + "convention": "thiscall", + "prototype": "void (ServerPlayer* this, int constructionSpend, int available) // TWO stack arguments, pushed as (edi = budget[15] available) then (eax = construction spend), so the callee sees (spend, available). turn-spine.md called this \"income/savings\"; strategic-turn-internals.md \u00a71.1 already corrected it to special projects. Body not read by lane T", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, call site 0x0089145c)" + }, + { + "name": "ServerPlayer_PruneRaidTargets", + "addr": "0x00863cf0", + "convention": "thiscall", + "prototype": "void (ServerPlayer* this) // the unconditional tail of ServerPlayer::ProcessTurn. Sweeps the vector at +0x338/+0x33c (0x20 stride) forward, erasing an element when: its inner range rec[0xc]==rec[0x10] (empty); or ModCount - rec[0x1c] > 20; or the entity lookup 0x008b9240(server EntityHash, rec[0x4]) fails; or the resolved object's +0x90 mask lacks bit (1 << this->PlyrIdx(+0x28)). Erase shifts the tail down 0x20 and calls the trailing element's vft[0] scalar destructor. Whole 239-byte body read", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "ServerTradeManager_ProcessTurn", + "addr": "0x0086b300", + "convention": "thiscall", + "prototype": "void (ServerTradeManager* this) // StrategyServer::ProcessTurn phase 2, called on StrategyServer+0x158 (S frame) / +0x154 (raw frame). 1494 bytes. Freighter -> route allocation; the formulas are in strategic-turn-internals.md \u00a71.4 and have never been run against the game", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_RegisterTradeSystems", + "addr": "0x007adc80", + "convention": "thiscall", + "prototype": "void (StrategyServer* this) // StrategyServer::ProcessTurn phase 3, 192 bytes. Every owned system whose owner has CnTrd registers NumTradeRoutesSupported routes", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_UnlockMetSpeciesTechs", + "addr": "0x0078aa70", + "convention": "thiscall", + "prototype": "void (StrategyServer* this) // StrategyServer::ProcessTurn phase 28, 185 bytes. For every player, for every species index 0..6 except 4 and the player's own: if the species is known (0x0080de60) and SpeciesDef::Get(sp)->+0x78 is a tech id != 0xc5 whose def resolves and is not already researched, make it visible via 0x00586200(def, 0). The \"you have met this race, its racial tech appears in your tree\" rule. Draw-free, event-free, three callees", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71, \u00a75 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_ProcessTurn_SystemLoop", + "addr": "0x007dc9c8", + "convention": "site", + "prototype": "site in StrategyServer::ProcessTurn: the per-system `ServerSystem::ProcessTurn` loop. `mov ecx,[Systems._Myfirst + i*4]; call 0x007598e0` - NO stack arguments and no returned value, confirming B4's correction that the decompile's `void* stream` parameter is a Ghidra guess. Loop bound recomputed every iteration as `(Systems._Mylast - _Myfirst) >> 2`", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_ProcessTurn_PlayerLoop", + "addr": "0x007dca00", + "convention": "site", + "prototype": "site in StrategyServer::ProcessTurn: the per-player `ServerPlayer::ProcessTurn` loop. `fld DWORD [ebp+8]; mov ecx,player; push ecx; fstp DWORD [esp]; call 0x00891340` - the driver's own float dt is forwarded as the callee's single stack argument (the `push ecx` only reserves the slot). Loop bound recomputed every iteration as `(Players._Mylast - _Myfirst) >> 2`", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_ProcessTurn_EndOfTurnTail", + "addr": "0x007dcb38", + "convention": "site", + "prototype": "site in StrategyServer::ProcessTurn: the first instruction of the end-of-turn tail (ProcessAid, ProcessSpecialProjects, ProcessSurrenders, the per-player 0x00818530 sweep, the two SVScriptObject hook pairs, 0x0086a8d0, 0x0078ab30, 0x00799380, UnlockMetSpeciesTechs, the per-system 0x00743ec0 sweep, 0x007b4c00 and the team/Status sweep). IT IS UNCONDITIONAL. turn-spine.md \u00a72.4 step 11 says the tail is deferred when encounters are pending; that came from reading the INLINED std::vector DESTRUCTOR at 0x007dcb15 as a branch - `je 0x7dcb38` skips only the `operator delete`, and both arms converge here. There is no branch on the encounter snapshot anywhere in this function", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71.1 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_base_delta", + "offset": "0x4", + "convention": "field", + "prototype": "THE TWO BASES, stated once. Let S = the `this` StrategyServer::ProcessTurn receives, which is also what ServerPlayer::GetServer (0x0080e320) returns. Then ServerPlayer+0x8 holds S+4, and EVERY `StrategyServer_off_*` entry in addresses.json except `StrategyServer_off_RNG` is expressed in the S+4 frame (Players 0x50 = S+0x54, Fleets 0x60 = S+0x64, EntityHash 0x80 = S+0x84, RNGPtr 0x168 = S+0x16c). `StrategyServer_off_RNG = 0x16c` is the same word in the S frame. Convert S-frame -> stored frame by subtracting 4. Reading the wrong base yields an empty Players vector and zero declared regions", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a70 (lane T 2026-09-08, three independent sequences: 0x0089147f, 0x007dc871, 0x00863d2a)" + }, + { + "name": "StrategyServer_off_PhaseCounter", + "offset": "0x4", + "convention": "field", + "prototype": "int, S+4 frame (== S+0x8). Incremented by the FIRST instruction of StrategyServer::ProcessTurn (`inc [esi+8]` @0x007dc6f0). This is a SECOND per-turn counter, distinct from StrategyServer_off_ModCount (0x8 in this frame == S+0xc), which BeginProcessTurn increments and which the research code stamps and reads. Both advance once per turn in different functions. Nobody has named this one", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a70.1 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_off_Systems", + "offset": "0x40", + "convention": "field", + "prototype": "std::vector (begin @+0x40, end @+0x44) in the S+4 frame, i.e. S+0x44/S+0x48. Iterated three times per turn: the morale/abandon pre-pass, the ServerSystem::ProcessTurn loop, and the tail's 0x00743ec0 sweep", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_off_TradeManager", + "offset": "0x154", + "convention": "field", + "prototype": "ServerTradeManager* in the S+4 frame (S+0x158). The `this` for ServerTradeManager::ProcessTurn, phase 2 of the turn", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_off_ScriptObject", + "offset": "0x1b0", + "convention": "field", + "prototype": "SVScriptObject* in the S+4 frame (S+0x1b4), null in a normal game. The end-of-turn tail calls two pairs on it when non-null: vft[0x10](6, 0) then vft[0x2c](), and later vft[0x10](0x1c, 0) then vft[0x78]()", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_off_TeamRecords", + "offset": "0x1e4", + "convention": "field", + "prototype": "std::vector in the S+4 frame (S+0x1e8), element stride 0x74. Rebuilt each turn by 0x007d7f70(this, &teamRecords) and then swept: for every member of every record, if (player->IsAI(+0xf9)==0 || player->+0xfa != 0) player->Status(+0x164) = 1. Strides verified from the signed-division magics: 0x8d3dcb09 with `sar 6` is /0x74, 0x78787879 with `sar 5` is /0x44", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "TeamRecord_stride", + "offset": "0x74", + "convention": "field", + "prototype": "sizeof(TeamRecord), the element of StrategyServer's team vector at +0x1e4 (S+4 frame)", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "TeamRecord_off_Members", + "offset": "0x28", + "convention": "field", + "prototype": "std::vector inside a TeamRecord (begin @+0x28, end @+0x2c), element stride 0x44, first word of each element a ServerPlayer*", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "TeamMember_stride", + "offset": "0x44", + "convention": "field", + "prototype": "sizeof(TeamMember). Only its first word (ServerPlayer*) is read by the turn's Status sweep; the other 0x40 bytes are unread by that pass", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_off_TurnRecord", + "offset": "0x3d8", + "convention": "field", + "prototype": "void* to a per-player per-turn record. StrategyServer::ProcessTurn's player pre-pass writes rec->+0x8 = (1 << playerSlot) and ORs in the player's alliance mask AL(+0x16c) when ALid(+0x168) != -1; ServerPlayer::ProcessTurn then writes rec->+0x10 = budget[2] (trade income). NOTE the bit index is the player's POSITION in the server's Players vector, not PlyrIdx", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71, \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_off_ResearchAidGiven", + "offset": "0xc8", + "convention": "field", + "prototype": "int, written by ServerPlayer::ProcessTurn from budget[19] (research points given away as aid). Not serialized. strategic-turn-internals.md \u00a71.2 step 2 calls this a per-turn accumulator that is ZEROED; it is ASSIGNED", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x00891439)" + }, + { + "name": "ServerPlayer_off_SavingsAidGiven", + "offset": "0xcc", + "convention": "field", + "prototype": "int, written by ServerPlayer::ProcessTurn from budget[14] (savings given away as aid). Not serialized", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x0089142d)" + }, + { + "name": "ServerPlayer_off_RebOutMod", + "offset": "0x128", + "convention": "field", + "prototype": "float RebOutMod. When RebAI(+0xfc) is set, ServerPlayer::ProcessTurn does RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f) each turn. The three constants are image floats: 0x00a17870 = 0.04f, 0x00a17868 = 1.0f, 0x00a1786c = 2.0f", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_off_Status", + "offset": "0x164", + "convention": "field", + "prototype": "int Status (serialized). 1 = playing, 4 = turn done (SNMSetPlayerStatus). Set back to 1 by the end-of-turn team sweep for every member with (IsAI==0 || +0xfa != 0)", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08); name from findings/objects/struct-recovery.md" + }, + { + "name": "ServerPlayer_off_AllianceId", + "offset": "0x168", + "convention": "field", + "prototype": "int ALid, the first word of the PlayerAlliances block {ALid, AL, NA, CF} at +0x168. -1 means no alliance; the turn's per-player pre-pass tests it before OR-ing AL into the turn record's visibility mask", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_off_AllianceMask", + "offset": "0x16c", + "convention": "field", + "prototype": "int AL, the alliance member bitmask at PlayerAlliances+4. OR-ed into (player->+0x3d8)->+0x8 once per turn when ALid != -1", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a71 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_off_RaidTargets", + "offset": "0x338", + "convention": "field", + "prototype": "std::vector (begin @+0x338, end @+0x33c), element stride 0x20, save tags `rdtc` + n x `rdt`. Pruned every turn by ServerPlayer_PruneRaidTargets. Fields the prune reads: +0x4 entity id, +0xc/+0x10 an inner range, +0x1c the turn the record was created", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08); stride from findings/objects/struct-recovery.md" + }, + { + "name": "RaidTarget_stride", + "offset": "0x20", + "convention": "field", + "prototype": "sizeof(RaidTarget), the element of ServerPlayer+0x338. Confirmed by the prune's `add edi,0x20` step and its `sar ecx,5` count", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_off_TimedResearchBonuses", + "offset": "0x3a4", + "convention": "field", + "prototype": "std::vector (begin @+0x3a4, end @+0x3a8), element {float PRm; int PRBt}, save tags `NumPR` + n x (`PRm`,`PRBt`). Swept once per turn by ServerPlayer::ProcessTurn: `for (i = count-1; i >= 0; --i) { TRM += e[i].PRm; if (--e[i].PRBt <= 0) erase(i); }`. THE ITERATION IS DESCENDING - float addition is not associative, so the order is load-bearing for bit-exactness", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08, 0x0089157a-0x008915ea)" + }, + { + "name": "TimedResearchBonus_stride", + "offset": "0x8", + "convention": "field", + "prototype": "sizeof(TimedResearchBonus) = { float PRm; int PRBt }. Confirmed by `sar edi,3` on the byte count and `[ecx+edi*8]` addressing", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "ServerPlayer_OnResearchRollSucceeded", + "addr": "0x00889d60", + "convention": "thiscall", + "prototype": "bool (ServerPlayer* this) // the branch RollResearchEvent takes when odds > roll -- the one the campaign has never observed firing. Splits on the CURRENT RESEARCH TARGET: (a) ResT is one of five hard-coded plague-cure TechDefs (ids 0x273c..0x2740, resolved by 0x00535480) -> 0x00889bb0, which draws ONE FURTHER Mars::RNG::NextInt to pick a random element of this->OwnId (+0x30/+0x34) and posts EVENT_PLAGUE_OUTBREAK naming it; (b) ResT is in the aggression tech table (0x00690f70) AND this->+0x3b5 is set -> 0x008206a0, which allocates a Game::AIRebellionImpl into +0x3b8 if absent and then CANCELS the current research (progress reset 0x0057e7c0, state back to 2 via 0x0057e8d0, vtable hook, ResT = NULL), with no further RNG; (c) otherwise nothing. SO A FIRED ROLL COSTS 1 OR 2 RNG WORDS, not 1 -- every existing coverage note says 'exactly one NextFloat', which is the cost of REACHING here", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a73.1 (lane T 2026-09-08, ReVa decompilation of 0x00889d60/0x00889bb0/0x008206a0; RollResearchEvent itself read from the instruction stream)" + }, + { + "name": "ServerPlayer_off_RebellionRollArmed", + "offset": "0x3b5", + "convention": "field", + "prototype": "bool `cta` -- the AI-rebellion gate of the succeeded-roll branch (0x00889d60 path b). The ServerPlayer constructor writes it in the SAME two-byte store that arms ResErrRoll: `mov WORD [esi+0x3b4], 1` sets +0x3b4 = 1 and +0x3b5 = 0. The two research-roll flags are adjacent and initialised together", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a73, \u00a73.1 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_ProcessShipActions", + "addr": "0x007b9b90", + "convention": "thiscall", + "prototype": "void (StrategyServer* this, std::vector* actionTypes, bool force) // the ship-action dispatcher, run THREE TIMES per turn with different type sets -- instruction-verified at the three call sites 0x007dc92b, 0x007dc952, 0x007dcb0c. Walks the queued-ship-action list at S+0x78/0x7c and dispatches each action whose type is in `actionTypes` through a 12-entry function-pointer table indexed by type (targets include 0x00789500, the ship-borne BuildQueue::ProcessTurn wrapper); with `force` it validates (0x0083cbb0) and cancels (0x00849280) instead. Sets: {0..14} minus {2} before movement, {2} after movement, {0..14} with force at the end. Type 2 is the action that requires the fleet to have arrived", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a71.2 (lane T 2026-09-08; call shape verified from the instruction stream, body from ReVa)" + }, + { + "name": "StrategyServer_BuildShipActionTypeList", + "addr": "0x00794ad0", + "convention": "cdecl", + "prototype": "std::vector* (std::vector* out) // fills *out with the fifteen ship-action type ids 0..14 and returns it. IT IS NOT AN ENCOUNTER SNAPSHOT -- turn-spine.md \u00a72.4 steps 4 and 11 read it as one, which is where the 'the tail is deferred when encounters exist' error came from. Encounter detection is 0x007d7f70 at the very end of the turn", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a71.1, \u00a71.2 (lane T 2026-09-08)" + }, + { + "name": "StrategyServer_SnapshotPreviousTurn", + "addr": "0x0081b390", + "convention": "thiscall", + "prototype": "void (StrategyServer_secondBase* this) // phase 0, called on S+4. Per system copies current words into shadow words (+0x20c/0x210/0x214 <- +0x68/0x6c/0x70, +0x150.. <- +0x120.., +0xc9 <- +0xc8) via 0x00753530; per player +0x188 <- Sav(+0x284) and +0x18c <- +0x138. Also called from BuildTurnEvents, so it is the baseline 'changed since last turn' is diffed against. Draw-free, event-free", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a75 (lane T 2026-09-08, ReVa)" + }, + { + "name": "StrategyServer_UpdateSensors", + "addr": "0x0086a8d0", + "convention": "cdecl", + "prototype": "void (StrategyServer* server) // the end-of-turn fog-of-war update, timed with QueryPerformanceCounter and logged as \"Sensors: %f seconds to update sensors for all players.\". Wraps 0x0086a6d0: per player x per system (predicate 0x00850cf0) and per player x per fleet (predicate 0x0081e630), packing 2-bit visibility results into system+0x24 and fleet+0x54 for up to 15 players. Deterministic, no events", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a75 (lane T 2026-09-08, ReVa)" + }, + { + "name": "StrategyServer_StepAIRebellion", + "addr": "0x00818530", + "convention": "thiscall", + "prototype": "void (ServerPlayer* this, bool flag) // no-ops unless this->AIRebellion(+0x3b8) is non-null; steps it (0x006d1fd0(flag)), and if it signals completion (0x00690ff0) calls its vft[0](1) and nulls +0x3b8. Called per player with flag=0 from StrategyServer::ProcessTurn's tail and with flag=1 from the deferred combat tail", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a75 (lane T 2026-09-08, ReVa)" + }, + { + "name": "StrategyServer_DetectEncounters", + "addr": "0x007d7f70", + "convention": "thiscall", + "prototype": "void (StrategyServer* this, std::vector* out) // the LAST phase of the turn: per system with combatants present (0x0078cb10), builds the pairwise 0x74-byte team records for fleet pairs whose relation (0x0080e050) is war, into StrategyServer+0x1e4 (S+4 frame). The turn's Status-back-to-playing sweep then walks the records it produced", + "status": "mapped", + "source": "findings/control-flow/turn-driver.md \u00a71, \u00a75 (lane T 2026-09-08, ReVa)" + }, + { + "name": "g_flt_ResearchRollProgressThreshold", + "addr": "0x00a2c788", + "convention": "data", + "prototype": "const float = 0.5f. The ONLY consumer is the ResearchRollPending block in ServerPlayer::ProcessTurn: the roll fires when 0.5f < progress/Cost, strictly. Not a registered config key - it is an image literal", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, bytes read from the image)" + }, + { + "name": "g_flt_RebOutModDecay", + "addr": "0x00a17870", + "convention": "data", + "prototype": "const float = 0.04f, subtracted from RebOutMod each turn for a RebAI player", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "g_flt_RebOutModMin", + "addr": "0x00a17868", + "convention": "data", + "prototype": "const float = 1.0f, the lower clamp of RebOutMod", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + }, + { + "name": "g_flt_RebOutModMax", + "addr": "0x00a1786c", + "convention": "data", + "prototype": "const float = 2.0f, the upper clamp of RebOutMod", + "status": "verified", + "source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)" + } + ] +} \ No newline at end of file