sots-re/findings/control-flow/turn-driver.md
alex 834eb09a19 T: read ServerPlayer::ProcessTurn and StrategyServer::ProcessTurn from the instruction stream
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).
2026-09-08 08:08:36 -04:00

31 KiB
Raw Blame History

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 TechDefs (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:

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.