sots-re/findings/control-flow/turn-driver.md
alex 728348b4a0 lane A2: the alliance mask read from the bytes, and every ModCount writer
Spine phase 4, byte for byte at 0x007dc871-0x007dc8c7. Three separate stores to
the same word -- clear, OR the self bit, then conditionally OR the alliance
mask -- which is what makes the alliance term an OR and is invisible in any
summary of the phase. The bit is the player's POSITION IN THE VECTOR.

Checked against the almem bytes the game archived: 72 of 80 player-records
agree, and the other 8 are predicted by the same model (FinalizeTurnRecords also
runs on load, and the load path does not run the spine, so every save's earliest
archived turn carries a zero mask -- true on all 11).

Reported as loudly: the corpus cannot separate `1 << vectorIndex` from
`1 << PlyrIdx` (0 of 80 records differ), cannot separate the OR from a plain
assignment (every observed alliance mask already contains its member's own bit),
and cannot separate the ALid guard from an AL != 0 guard. Those three are
instruction-stream readings only.

ModCount, named and enumerated. StrategyServer::Write tags both words itself, so
S+0x8 is the wire's ModCount and S+0xc is Frame -- addresses.json has the name
on the wrong word, and turn-driver.md's "they stay in lockstep" is corrected in
place. RTTI gives the reason for the two bases: Game::StrategySim is a base
sub-object at offset 4.

29 writer sites: 20 command handlers plus 6 inlined in the command-batch applier
(each an unconditional bump on ENTRY, before validation), the two turn drivers,
and the abandon/chaos check -- which is gated on Abdn, false on all 28 systems
of all 11 saves. So the per-turn delta is 2 + one per command applied out of
every player's TurnCommands block; the 0x1b4 container stride independently
confirms lane Q's block layout. It is not derivable from the pre-turn save.

The residual is a watchpoint, specified with its own written prediction (exactly
12 hits on turn1-state) and four falsifiers. Direct-call reachability is stated
as the lower bound it is, per lane V2's indirect-edge measurement.

Engine side: sots-engine wip/alliance 5e409cf.
2026-09-08 12:44:30 -04:00

412 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# The 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.~~
> **WRONG ON BOTH COUNTS — corrected by lane K/Z (`combat-done-tail.md` §7.1) and re-derived
> independently by lane A2 (`alliance-mask-and-modcount.md` §2.1).**
>
> `StrategyServer::Write` names both words itself: `0x0079fb2f lea edx,[edi+0x08]; push "ModCount"` and
> `0x0079fb40 lea eax,[edi+0x0c]; push "Frame"`, with `edi = S` (settled inside the same function by
> `0x0079fb90 mov eax,[edi+0x16c]` under the tag `"RNG"`, which is `StrategyServer_off_RNGPtr = 0x168` in the
> `S+4` frame). So:
>
> * **`S+0x8` is the wire's `ModCount`** — the word this section says has never been named. It advances
> **12–44 times a turn**, not once: it is bumped on entry to every `StrategySim` command-application method
> (26 sites), and the two drivers account for only 2 of them. `addresses.json`'s
> `StrategyServer_off_ModCount` has the name on the **other** word.
> * **`S+0xc` is the wire's `Frame`** — the turn number, which is also the key the turn-record archive is
> written under.
>
> They are **not** in lockstep; a reimplementation must not treat `S+0x8` as a turn number.
> `ghidra/addresses.d/lane-a2.json` carries `StrategySim_off_ModCount` (0x4) and `StrategySim_off_Frame` (0x8)
> in the `S+4` frame, with the correction stated on each. Also settled there: the `S+4` frame is the
> **`Game::StrategySim`** base sub-object — RTTI gives `Game::StrategyServer`'s bases as `Mars::IStreamable`
> at offset 0 and `Game::StrategySim` at offset **4**.
---
## 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 | 0x007dc871–0x007dc8c7 | **per-player pre-pass**: `rec = p->+0x3d8; rec->+0x8 = 0; 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`. *(VA range corrected by lane A2 — it was given here as 0x007dc8c8–0x007dc8c6, which is phase 5's start. The three separate stores are also from lane A2: the clear-then-OR is what makes the alliance term an OR. Checked against the archived `almem` bytes on 80 player-records, 0 mismatches — `alliance-mask-and-modcount.md` §1.)* |
| 5 | 0x007dc8c8–0x007dc928 | build `vector<int> A = {2}` (`FUN_00483410` reserve 1, store 2, bump `_Mylast`); `FUN_00794ad0(&B)` builds `vector<int> 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<int>` 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<RaidTargets>` 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<TechRecord> 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.