Phase map for both turn drivers, byte-for-byte from objdump rather than the decompiler. Corrections that matter: - the end-of-turn tail is UNCONDITIONAL. turn-spine.md 2.4 step 11 said it is deferred when encounters are pending; that came from reading an inlined std::vector destructor as a branch (both arms converge at 0x007dcb38). There is no branch on the encounter snapshot in the function, and 0x00794ad0 does not build an encounter list -- it builds the 15 ship-action type ids. - ServerPlayer::ProcessTurn takes one float argument and never reads it. - the ResearchRollPending site runs AFTER ProcessResearch, not before, and clears the flag only when the roll actually fires. Threshold is a strict 0.5f < progress/Cost. Both consumers located by a full displacement scan. - EVENT_NO_RESEARCH's gate is 'completed nothing this turn', not 'nothing available' -- 0x00584e50 collects RESEARCHED techs, not available ones. - a fired research roll costs one or two RNG words, not one: the plague branch draws a second NextInt and posts EVENT_PLAGUE_OUTBREAK. - two StrategyServer bases four bytes apart, and two per-turn counters. - new: the research refund at 0x008914aa, previously unmodelled. 45 addresses in ghidra/addresses.d/lane-t.json (669 -> 722 entries, no dupes).
390 lines
31 KiB
Markdown
390 lines
31 KiB
Markdown
# 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<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 (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<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.
|