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.
33 KiB
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::Writenames both words itself:0x0079fb2f lea edx,[edi+0x08]; push "ModCount"and0x0079fb40 lea eax,[edi+0x0c]; push "Frame", withedi = S(settled inside the same function by0x0079fb90 mov eax,[edi+0x16c]under the tag"RNG", which isStrategyServer_off_RNGPtr = 0x168in theS+4frame). So:
S+0x8is the wire'sModCount— the word this section says has never been named. It advances 12–44 times a turn, not once: it is bumped on entry to everyStrategySimcommand-application method (26 sites), and the two drivers account for only 2 of them.addresses.json'sStrategyServer_off_ModCounthas the name on the other word.S+0xcis the wire'sFrame— 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+0x8as a turn number.ghidra/addresses.d/lane-a2.jsoncarriesStrategySim_off_ModCount(0x4) andStrategySim_off_Frame(0x8) in theS+4frame, with the correction stated on each. Also settled there: theS+4frame is theGame::StrategySimbase sub-object — RTTI givesGame::StrategyServer's bases asMars::IStreamableat offset 0 andGame::StrategySimat 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;
ProcessResearchis called at ProcessTurn+0x165. Site A runs afterProcessResearch, not before it.campaign/board.mdand lane U state the opposite ("consumed byServerPlayer::ProcessTurnBEFOREProcessResearch"). 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'sProcessResearchruns. - The threshold is a strict
>:fld 0.5f; fcomp ratio; test ah,5; jp skipis MSVC'sif (0.5f < ratio). The constant at 0x00a2c788 is0.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,
ResErrRollis 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:
- Edit the save directly.
ResErrRollis on the wire as a named tag, and per the campaign's own rule it is written byWriteBool. Set it true on a player whoseResTNmnames 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. - Play to it. Pick a fresh expensive target on a turn where savings +
ResRatecan fund ≥ 1.5 × cost, so the tech goes 0 % → complete in one turn whileResErrRollis 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 —
ResTis one of five hard-coded plague-cureTechDefs (ids 0x273c..0x2740, resolved byFUN_00535480) →FUN_00889bb0: draws one furtherMars::RNG::NextIntto pick a random element of the player'sOwnIdvector (ServerPlayer+0x30/0x34) and postsEVENT_PLAGUE_OUTBREAKnaming it. - Rebellion branch —
ResTis in the "aggression" tech table (FUN_00690f70) and the player'sctabyte at+0x3b5is set →FUN_008206a0: allocates aGame::AIRebellionImplintoServerPlayer+0x3b8if 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, clearsResT. No further RNG. - Otherwise: nothing.
Two consequences that matter to anyone declaring regions around this:
- 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 reachingFUN_00889d60; the plague branch spends a second word. +0x3b5— the byte theServerPlayerconstructor zeroes in the same two-byte store that setsResErrRoll(§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_00584e50isTechTree::CollectResearchedTechs(out, minTurn, maxTurn, sort), not a list of available techs (strategic-turn-internals.md§1.2 step 9). Whole body read: it clearsout, walksmaster->defs (+0x24/+0x28), skips any def whose name tail matches"_Root", requiresnode->state(+0x14) == 4(researched) andminTurn <= node->turnResearched(+0x24) <= maxTurn, and pushes{def, turnResearched, node->flag(+0x2c)}. The 4th argument is aboolat[ebp+0x14]: when set it callsFUN_00582cd0(first, last, count, master)— a sort.ret 0x10.- Called with
minTurn = ModCount, sodoneis "techs this player completed on this very turn". FUN_0057da90isTechTree::FindFirstAvailableTech(): first node inthis->nodeswhose self-resolved node hasstate == 2; returns itsTechDef*, else NULL. (Same self-resolvingnodes[def->id]idiom lane E corrected inProcessResearch.)
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.