- determinism oracle regenerated and byte-identical (bb4fd9ac / 978041ac) - the four phase-23/33 draw-bearing tail callees run EVERY turn; the three inner functions holding the draws run zero times -- the gate is inside each outer body - CreateRaidEncounter is called (2 on one turn) and draws nothing: candidate list empty - Zuul: 7 calls / 7 words per trade-raid Chance site, 14 not 16, as predicted - EncounterDetect_Run receives an EMPTY record vector, so ProcessTeamRecord and AssignContacts never run; the 2-word detection residual is in 0x007d5150's subtree - a MinHook detour on 0x00893290 changes the game's output; bisected over six runs. The un-instrumented game and lane Z's instrument agree, so lane Z's numbers stand - lane AI1 insert: P2 held across two fresh processes, Rung B stays as written
40 KiB
The strategic AI — where it runs, and how a decision becomes a TurnCommands entry
Lane AI1, 2026-09-08. Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
Static only; VM140 was held by lane H.
Method. Every control-flow claim below was read from the instruction stream with
objdump -b binary -m i386 -M intel over the raw image, disassembled to the next function start
(rule 17), with call targets resolved from dumps/functions.json, string immediates resolved out of
.rdata/.data at real push imm32 / mov r32,imm32 boundaries, and class/vtable identities taken from
dumps/rtti.json. The decompiler was not used at all in this lane. Where a claim is inferred rather than
read, it says so.
Addresses: ghidra/addresses.d/lane-ai1.json (23 entries; validated with tools/gen_addresses.py to a
scratch path, 1009 total, no duplicate names).
0. The two answers, up front
1. The AI runs at the start of the turn, not inside ProcessTurn.
It is not a phase of StrategyServer::ProcessTurn and it is not a phase of
StrategyServer::OnAllCombatDone_Tail. The strategic AI is a StrategyClient. One extra
Game::StrategyClient (0x708 bytes) is constructed per AI player at SNMRunAI time, carrying a
Game::StrategyAIAgent at StrategyClient+0x12c. That client receives the same SE* events the human
client receives, and forwards each of them to its agent. The one that makes it play is
SEResumePlaying, client event type 0x26 — the event the host broadcasts through
StrategyServer::ResumePlaying 0x007ddc90 to say "a new turn has begun". It arrives at
StrategyClient::OnResumePlaying 0x00777480, which at +0x9d does
77751d mov ecx,[esi+0x12c] ; the AI agent, non-null only on an AI client
777526 test ecx,ecx
777528 je 0x777534
77752a mov edx,[ecx] ; agent vtable
77752c mov eax,[edx+4] ; slot 1 = StrategyAIAgent::OnEvent
77752f push edi ; the event
777530 push 0x26 ; SEResumePlaying
777532 call eax
and that call runs the AI's entire turn synchronously on the main thread, ending in cl_EndTurn().
So the ordering around the reference save is:
… ProcessTurn → OnAllCombatDone_Tail → "(Autosave EndTurn)" ← turn1-state.sav is written HERE
→ host SendResumePlaying → SNMResumePlaying (0x43)
→ StrategyServer::ResumePlaying → SEResumePlaying (0x26) to every player with Status==0
→ human client: UI unlocks
→ EACH AI client: OnResumePlaying → agent->OnEvent(0x26) → "====== AI Process Turn (name) ======"
… ~30 phases … cl_EndTurn() → SNMEndTurn carrying that AI's TurnCommands
→ host StorePlayerTurnCommands per AI, then waits for the human
→ human End Turn → SNMUpdate → ApplyTurnCommands (ModCount++ per command) → ProcessTurn
That is exactly the shape lane A2 measured: the pre-turn autosave predates every AI order, and the
ModCount bumps land in ApplyTurnCommands after it. No pre-turn save can contain the AI's orders, and
none ever will — not because of a save-format gap but because the orders do not exist yet when the file
is written.
2. An AI decision becomes a TurnCommands entry through the same client API the human UI uses.
There is no separate AI order channel. The chain, instruction-verified end to end:
AI task/rule decides
→ Game::StrategyClient order method, one of ~21 in 0x00762ca0 .. 0x00763f60
each does `lea ecx,[this+0x160]` ← the ACCUMULATING Game::TurnCommands
and calls a Game::TurnCommands::Add* helper in 0x0080f2xx / 0x0084 2xxx / 0x00842xxx
→ at End Turn: StrategyClient::BuildTurnCommands 0x00783780
TurnCommands::Clear(dst=&this->+0x4d8); dst = this->+0x160;
dst.playerId = player->+4; dst.researchRate = player->+0xbc; ← the gate every save has set
+ the pending fleet-move vector at this->+0x6d4/+0x6d8
→ SendEndTurn 0x00783980+0x96: msg.TurnCommands = this->+0x4d8 (TurnCommands::operator= 0x007832b0)
and msg.AIEncounterFlags = *agent->vt[8]() (0x00783a2f, only when +0x12c set)
→ SNMEndTurn (0x3f) → StrategyServer::OnPlayerEndTurn → StorePlayerTurnCommands
→ SNMUpdate (0x29) → StrategyServer::ApplyTurnCommands 0x007b18b0
→ the 20 command handlers, each bumping ModCount unconditionally on entry
So ModCount is downstream of StrategyClient+0x160, and Rung B reduces to: fill
StrategyClient+0x160 the way the AI does. Everything from +0x160 onwards is already ours — lane Q owns
TurnCommands, lane A2 owns the handler list and the ModCount rule.
1. Construction: StrategyApp::RunAI and what it establishes
RunAI 0x008706f0 (__thiscall, ret 0x10) is reached from exactly one place —
StrategyNetworkClient::OnMessage 0x00784640+0x96e, the SNMRunAI (net message id 0x3d) case. The call
site is worth quoting because it settles determinism:
784f94 mov ecx,0xaf6e58 ; g_GlobalRNG (a static Mars::RNG in .data)
784f99 call 0x4f7670 ; Mars::RNG::NextUInt
784f9e mov ecx,[edi+4] ; player net id
784fa1 push eax ; arg3 = the AI's RNG SEED
784fa2 movzx eax,BYTE [edi+0x24]
784fa6 push eax ; arg2 = AI personality / difficulty byte
784fa7 push esi ; arg1 = AI custom-data name (may be empty)
784fa8 push ecx ; arg0 = player net id
784fa9 mov ecx,0xb29f98 ; the StrategyApp singleton
784fae call 0x8706f0 ; RunAI
RunAI then, in order: resolves the player through the handle registry at server+0x84; refuses on
p->IsAI(+0xf9) == 0 ("RunAI: Cannot create a StrategyClient/AI for a human player."); logs
"RunAI: Creating AI client for %s using %08x for random seed."; operator_new(0x708) and the
StrategyClient constructor 0x00782ed0 with the seed; StrategyServer::InitGame;
StrategyClient::CreateAI 0x007653c0 with the personality byte, which builds the Game::StrategyAIAgent
into StrategyClient+0x12c ("RunAI: Failed to create AI for %s (player %d)."); optionally
"Loading AI custom data from save game..." through the agent's vt[5] / vt[6]; and finally
RaiseAIPrepareTurn 0x00815f20.
Two consequences that were not previously on the record:
SEAIPrepareTurnis not a per-turn event.RaiseAIPrepareTurnhas exactly two callers —RunAIandStrategyApp::CreateGame0x00888e80+0x3f5. It raises client event type 9, once, when the AI is built.turn-spine.md§2.3's[AI]line reads as if it were the turn hook; it is the construction hook. Corrected here.- The AI is not a subclass. RTTI has no
StrategyClientderivative. The AI plays through the plain client class plus one composed agent object, which is why every order it issues necessarily goes through the human's API.
2. The event plumbing, and the one hop that is inferred
IStrategyAIAgent::vt[1] is StrategyAIAgent::OnEvent(int clientEventType, StrategyEvent** ev)
(0x006d0ad0, ret 8). It pushes a log scope and calls
6d0aef mov ecx,[esi+0x94] ; the Game::StrategyAIContext
6d0af6 call 0x6c2b90 ; OnStrategyEvent(type, ev, &thunk 0x6d0ab0, agent)
StrategyAIContext::OnStrategyEvent 0x006c2b90 does two things:
- It pushes
{callback, agent, seq}onto a ring deque atcontext+0x58(buf@+0x5c cap@+0x60 head@+0x64 size@+0x68, 0x0c-byte nodes, push helper 0x0069e470 under a critical section). - It runs
switch (clientEventType - 6)over0..0x20, through the byte index table at 0x006c33a4 and the jump table at 0x006c3360 (17 distinct cases), updating the AI world model and emitting internal AI packets{int code; …}throughStrategyAIContext::Broadcast0x006b3840.
The byte table, read from the image, is
[0,1,16,2,3,4,5,16,6,16,16,16,7,16,16,16,8,16,16,16,9,10,11,12,16,13,16,16,16,16,16,14,15]
(index = clientEventType - 6; case 16 is the no-op at 0x006c3333). The two rows that matter:
| client event | index | case | emits |
|---|---|---|---|
9 SEAIPrepareTurn |
3 | 0x006c2c23 | packet 1, then packet 2 |
0x26 SEResumePlaying |
32 | 0x006c2cbe | packet 3 |
Broadcast walks a red-black listener set at context+0xc calling listener->vt[3](packet) — that is the
Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet
(0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50) — and then, if (context->+0x68 != 0),
iterates the pending-callback deque and delivers the same packet to the queued {cb, this} pairs.
That second arm is the one hop I read only partially (its first two loop iterations, 0x006b38ac ..
0x006b3930). It is the only path that can reach StrategyAIAgent::OnAIPacket 0x006cf8a0 — that function
has exactly one caller in the whole image, the thunk 0x006d0ab0, and the thunk is registered nowhere but
0x006c2b90. Combined with the fact that 0x006cf8a0's jump table is indexed by exactly the packet codes
0x006c2b90 emits, the identification is safe; but "Broadcast invokes the queued callback with the
packet" is inferred, not read to the call. A next lane should finish 0x006b3840 from 0x006b3930 to the
end. The design intent is visible either way: the queue makes the agent's own handler run after every
AIObject has absorbed the event.
3. StrategyAIAgent::OnAIPacket 0x006cf8a0 — the AI's behaviour switch
2008 bytes. this = ecx = the agent; one stack argument, a packet whose first dword is the code.
6cf8ce mov eax,[edi] ; pkt->code
6cf8d0 add eax,-2
6cf8d3 mov edx,0xf
6cf8dc ja 0x6d0058 ; codes outside 2..17 -> return
6cf8e2 jmp DWORD PTR [eax*4+0x6d0078]
Jump table (read from the image at 0x006d0078):
| code | target | what |
|---|---|---|
| 2 | 0x006cf958 | AI Prepare Turn |
| 3 | 0x006cfabf | AI Process Turn |
| 5 | 0x006d0023 | small, → 0x006bf400 |
| 9 | 0x006cfd19 | nested switch (table 0x006d00b8, cases 5/6/7) → 0x006b23b0 |
| 12 | 0x006cf8e9 | resolve pkt->+4 in the object registry, compare pkt->+8 to client->+0x148, → 0x0084a0d0 |
| 14,15,16 | 0x006cfda7 / 0x006cfdc4 / 0x006cfddf | → 0x006bf400 |
| 17 | 0x006d000b | → 0x00763ae0 (a client call) |
| 4,6,7,8,10,11,13 | 0x006d0058 | no-op |
Agent field identities used throughout, from these bodies: agent+0x10 = the owning StrategyClient;
agent+0x14 = the ClientPlayer (its name is the inline std::string at +0x40, the %s in both log
lines); agent+0x94 = the StrategyAIContext.
3.1 The Process Turn body — the AI's own phase list
0x006cfabf .. 0x006cfd14, in execution order. Names are given only where a string or an already-named callee pins them; the rest are addresses on purpose.
| # | VA | call | note |
|---|---|---|---|
| 0 | 0x006face | log "====== AI Process Turn (%s) ======" |
|
| 1 | 0x006cfadb | 0x00694ac0 | |
| 2 | 0x006cfaea | 0x006c57e0 | → client order method 0x00762ca0 |
| 3 | 0x006cfaf2 | 0x006afb50 | |
| 4 | 0x006cfb02 | 0x006af700 | |
| 5 | 0x006cfb0c | 0x0069a5f0 | |
| 6 | 0x006cfb1a | 0x0080d7b0 | a server-side helper |
| 7 | 0x006cfb60 | 0x006cb570 | → client 0x00769500/0x00769590/0x00773420/0x0076bce0/0x007668c0 |
| 8 | 0x006cfb73 | 0x0069a3d0 | |
| 9 | 0x006cfb81 | 0x00694ea0 | |
| 10 | 0x006cfb9f | 0x006a4620 | "Survival Outlook (%i-turn avg): Dead by %.1f" |
| 11 | 0x006cfc11 | log | "Survival Outlook: Dead in %i turns (%5.2f%% chance to surrender this turn)" |
| 12 | 0x006cfc24 | cl_Chance 0x00578cf0 |
the surrender roll — one draw on the client RNG |
| 13 | 0x006cfc3b | 0x006966b0 | |
| 14 | 0x006cfc46 | 0x006c3510 | → client order method 0x00763910 |
| 15 | 0x006cfc68 | cl_SetResearchRate(float) 0x00579180 |
→ client 0x007633f0 → TurnCommands::SetResearchRate |
| 16 | 0x006cfc72 | 0x006af9a0 | |
| 17 | 0x006cfc78 | 0x006b4a40 | |
| 18 | 0x006cfc82 | 0x006caf70 | also calls cl_SetResearchRate |
| 19 | 0x006cfc8a | 0x006c36b0 | |
| 20 | 0x006cfc94 | 0x006cf630 | |
| 21 | 0x006cfc9e | 0x0069dd80 | → client order method 0x00763270 |
| 22 | 0x006cfca6 | 0x006c36b0 | (again) |
| 23 | 0x006cfcb0 | 0x00694a50 | → client 0x00773600 |
| 24 | 0x006cfcb7 | 0x006c5540 | → client 0x0077fb40 |
| 25 | 0x006cfcbe | 0x006cf2d0 | |
| 26 | 0x006cfcc5 | 0x006b3ff0 | → client order method 0x00762f70 |
| 27 | 0x006cfcd4 | 0x006f2c20 | |
| 28 | 0x006cfcd9 | cl_EndTurn() 0x00579310 |
the turn is submitted here |
| 29 | 0x006cfce0 | 0x006af400 | post-submit bookkeeping |
| 30 | 0x006cfce7 | 0x006af690 | |
| 31 | 0x006cfcfa | 0x006a9480 | |
| 32 | 0x006cfd04 | 0x006af790 | → client 0x00769640 |
| 33 | 0x006cfd0f | 0x00686550 |
3.2 The Prepare Turn body
0x006cf958 .. 0x006cfaba: log "====== AI Prepare Turn (%s) ======", then 0x006bbad0, 0x00686550,
0x005a0da0, 0x006c3ab0, 0x005a0e50, 0x00694ac0, 0x006a4890, cl_RandRange 0x005798e0, 0x006a8770
(the design-name generator — CRAIC/DEAIC/DNAIC prefixes), 0x00682fb0, 0x006c8b80, 0x006cdbb0,
0x006a3f30, 0x006ca950, 0x006c6870, 0x006c6ba0, 0x006cac60, 0x006bdb00, 0x006bddf0, one virtual call,
0x006be6b0, and 0x0069dbb0.
0x0069dbb0 is worth naming because it is one of the six direct NextInt sites in the module:
69dbda cmp DWORD [esi+0x36c],0 ; agent field, 0 = not yet scheduled
69dbe1 jne …
69dbe3 mov eax,[esi+0x10] ; the StrategyClient
69dbe6 mov edi,[eax+8] ; current turn counter
69dbe9 mov eax,[eax+0x134] ; the CLIENT RNG
69dbf3 lea ecx,[eax+4] ; &mt
69dbf6 mov DWORD [ebp-0x10],0x25 ; bound 37, INCLUSIVE
69dbfd call RNG_NextInt
69dc02 lea edx,[eax+edi*1+3]
69dc06 mov [esi+0x36c],edx ; scheduled turn = now + 3 + rand(0..37)
A one-shot, lazily-initialised schedule (a 3–40 turn horizon), drawn once per agent, from the client generator.
4. The output path in detail
4.1 The two TurnCommands on a client
StrategyClient::BuildTurnCommands 0x00783780, called once from EndTurn 0x00783be0+0xee:
7837b9 call 0x893f00 ; TurnCommands::Clear(dst)
7837be lea eax,[esi+0x160] ; the ACCUMULATOR
7837c5 mov ecx,edi ; dst = &client->+0x4d8
7837c7 call 0x7832b0 ; TurnCommands::operator=
7837dd mov [edi+4],eax ; playerId = client->+0x150->+4
7837e6 fld DWORD [ecx+0xbc] ; player research rate
7837f5 call 0x80f2d0 ; TurnCommands::SetResearchRate -> sets gate @0x0c
7837fa cmp BYTE [esi+0x6d0],bl ; any pending fleet moves?
… ; walk client->+0x6d4/+0x6d8 (stride 8) into the move list
So +0x160 is where orders accumulate during the turn and +0x4d8 is the send buffer. This also explains
a standing observation from lane Q for free: every save's TurnCommands block has the research-rate gate
set and the other five clear, because BuildTurnCommands sets that one unconditionally from live player
state, whatever the player did.
EndTurn also refuses to do anything if client->+0x15c is already set, and every order method starts
with the same test — if (this->+0x15c) return false; — so no order can be added after End Turn.
4.2 The order API, and who calls it
Twenty-one methods in 0x00762ca0 .. 0x00763f60 each do lea ecx,[this+0x160]. They are shared between
the UI and the AI. This table is the join for Rung B — the left column is the ABI our game/ai module
must eventually target, the right column says whether a workload can exercise it from the UI:
| client order method | called by AI (module fn) | called by UI | TurnCommands::Add* helper |
|---|---|---|---|
| 0x00762ca0 | 0x006c57e0 | — | 0x0080f2c0, 0x00821fd0, 0x0080f360 |
| 0x00762f70 | 0x006b3ff0 | 0x005d7dc0 | 0x0083d5d0 |
| 0x00762fd0 | 0x006b3bc0 | 0x00656540 | 0x0080e0e0, 0x00849460, 0x00842840 |
| 0x00763110 | via cl_* 0x00578f60 |
0x005d20a0 | 0x0083d6e0, 0x0080f2f0 |
| 0x00763180 | — | 0x005d20a0, 0x005d2140 | 0x0083d6e0, 0x0080f2f0 |
| 0x007631f0 | — | 0x005d0f10 | 0x00863ba0, 0x00821b40, 0x0080f310 |
| 0x00763320 | — | — (0x007ee880) | 0x00868da0, 0x00868d20 |
| 0x00763380 | — | — (0x007e40c0) | 0x00822180, 0x008490d0 |
| 0x007633f0 | via cl_SetResearchRate 0x00579180 |
0x005d2ac0, 0x005eedb0 | 0x00821a80, 0x0080f2d0 |
| 0x00763450 | — | 0x005e2350, 0x005e8940 | 0x00821c30, 0x00842950 |
| 0x007634d0 | 0x006987e0 | 0x005e6fa0 | 0x00821cf0, 0x00842a00 |
| 0x00763570 | — | 0x005e6fa0 | 0x00821d70, 0x00842a90 |
| 0x007635f0 | 0x0068e670 | 0x005e6fa0 | 0x00821e20, 0x00842b10 |
| 0x00763670 | 0x006b6a60 | — | 0x00865a70, 0x00842b90 |
| 0x00763720 | 0x006b6b60 | — | 0x00865bd0, 0x00842c10 |
| 0x00763910 | 0x006b41b0, 0x006c3510 | 0x00607d10 | 0x0080e090, 0x00821b90, 0x0080f330 |
| 0x00763990 | 0x006df260 | — | 0x00821b90, 0x0080f330 |
| 0x00763a20 | 0x006a8eb0 | 0x005cbf70 | 0x00821ed0, 0x00842c90 |
| 0x00763a80 | — | 0x005cbf70 | 0x00821f80, 0x00842d40 |
| 0x00763eb0 | 0x006b42c0 | 0x00615070 | 0x0085b6d0, 0x00842900 |
| 0x00763f60 | — | 0x00615070 | 0x0085b6d0, 0x00842900 |
Eleven of the twenty-one are called directly from the AI module and two more through the cl_* façade;
seven of those eleven are also reachable from the UI, which means most of the AI's order vocabulary can be
exercised, and its wire form observed, without any AI at all — by driving the UI on VM140. That is the cheap way to close lane Q's twenty-two
hypothesis element rows (rule 6), and it is a bigger win than it looks: it is the same list, from the
other side.
The mapping from these helpers back to lane Q's 27 lists was not established in this lane. It is a
mechanical follow-up: each 0x0084 2xxx helper writes one list; lane Q already has each list's writer
(0x00822870 build, 0x0083e550 fleet move, 0x00822960 colonise, 0x0082e3d0 system rates …). Pairing
adder→writer by the list offset each touches names every command in one pass.
4.3 The façade
Forty functions index a global client table: g_StrategyClients 0x00ae47e4 with
g_CurrentClientIndex 0x00ae4808. The tight family 0x00578cf0 .. 0x005793xx is a ~29-entry cl_* API
over "the current client". Three of them are load-bearing for the AI and are now in addresses.json:
cl_EndTurn0x00579310 —if (c && c->AIAgent(+0x12c) && !c->bTurnEnded(+0x15c)) EndTurn(c, true). The+0x12ctest means this entry point is AI-only; the human UI reachesEndTurnby a different path (0x005e4f80+0x5f).cl_Chance(float)0x00578cf0 —RNG_Chance(c->RNG(+0x134), p).cl_RandRange(int,int)0x005798e0 —lo + NextInt(&c->RNG(+0x134)->mt, hi-lo); inclusive at both ends, becauseRNG_NextInt's bound is inclusive (addresses.json).
5. Determinism — settled, and it is not what the ledger implies
The strategic AI never touches the strategic generator. Every draw it makes goes to a per-client
Mars::RNG at StrategyClient+0x134, allocated and seeded in the client constructor:
783013 push 0x9cc ; sizeof(Mars::RNG)
783034 call operator_new
783040 mov edx,[ebp+8] ; the ctor's seed argument
783046 call 0x49fdf0 ; RNG_Seed
783051 mov [esi+0x134],eax
Evidence that this is the only generator the AI uses:
- A rel32 scan of the whole image for the seven RNG entry points finds six direct call sites inside the
AI module (0x00680000–0x006e0000), all
RNG_NextInt: 0x0069dbfd, 0x0069dc29, 0x0069df9d, 0x006cce84, 0x006cd02a, 0x006cd05d. Every one loads its generator fromagent->+0x10 → client->+0x134. - The two façade helpers the module calls,
cl_Chanceandcl_RandRange, both readclient->+0x134. - Zero calls from the module to
NextFloat,Chance,NextUInt,Twist,SeedorGaussianRange.
This closes lane Z's ledger from the other side. Lane Z measured the strategic generator advancing
18–22 words per turn, all inside StrategyServer::ProcessTurn, with exactly zero residual outside the
two turn drivers — and the AI runs inside that measured interval, between the autosave and ProcessTurn.
A zero residual and an AI that draws nothing from that generator are the same fact seen twice.
Neither of us has to weaken a claim.
Caveat, per rule 16: this is a direct-call scan. It is a lower bound on the AI's own draws, and it says nothing about a draw inlined into an AI function. Lane I's image-wide scan for the tempering immediates at instruction boundaries found eleven game functions with inlined draws; none of the eleven is in the AI band, which is what makes the claim safe — but that cross-check is lane I's, not this lane's, and it should be re-run against the band bounds used here.
5.1 The AI's seed, and why it is 0
The seed handed to the client constructor is one Mars::RNG::NextUInt() from g_GlobalRNG, a static
Mars::RNG at 0x00af6e58. That object is never seeded:
- its only static initialiser, 0x009dc6e0, writes
*(void**)0xaf6e58 = 0x009e22bc— theMars::IStreamablevftable, not theMars::RNGvftable 0x009e9aec thatRNG_Seedinstalls; - none of the six
RNG_Seedcall sites in the image (0x004b2113, 0x0056bc2b, 0x0071cdf9, 0x00783046, 0x007d7d3d, 0x008aa0ec) targets it — every one is anoperator_new(0x9cc)on a heap generator.
So its mt[624] is the zero-initialised .data/BSS array and left is 0. An all-zero MT19937 state is a
fixed point of the twist (y = 0 ⇒ every mt[i] = mt[i+M] ^ 0 ^ mag01[0] = 0), and the tempering of 0
is 0. Therefore g_GlobalRNG returns 0 on every draw, forever, and every AI client in every game is
seeded with 0.
That is a strong statement and it is arithmetic, not measurement, so §8 makes it falsifiable.
Its practical meaning for Rung B is good news: the AI's generator is a known constant seed, so an AI
client's stream is fully determined by how many draws that client has taken since it was created. It is
also the bad news: the AI's generator state is not in the save. The StrategyAIAgent::Streamable block
carries 35 named tags (AIAttr AIDNG AIHivJ AINumSys AIPlyHat AISit AISys AISysID AITech AITurnPris BStabPl BStabTn CLPlID CLSyID CLTn CmbR MBlstSy MBlstTn NBStab NCmbR NMBlst NPrv NPrvId NPrvVa NTecS NumCL SDFlT TecS lnat nalat prs2 trns dnid dnnc dnnm) and no generator state. So on load the AI is reconstructed
with seed 0 and a rewound stream, while the original game that produced the reference autosave had been
drawing on it since the game started.
This is the sharpest open risk to Rung B, and it is testable in minutes (§8). If the AI's per-turn decisions consume a variable number of draws, then a save loaded on turn N and stepped one turn cannot reproduce that game's turn-N AI orders — not by us, and not by the original either.
6. Architecture — the map, not the territory
The AI module is 0x00680000 .. 0x006e0000: 1,629 functions, ~360 KB of code (the dense core is
0x00680000–0x006d1000, 1,422 functions / ~310 KB). For scale: mars/stream, the campaign's largest closed
subsystem, is 5,407 LOC of engine. This lane read the spine — maybe 3% of the module.
6.1 The class inventory (RTTI, complete)
138 vtables carry an AI-related class name. The families:
| family | count | interface | what it is |
|---|---|---|---|
Game::AIT* |
34 | Game::IAITask (vt 0x009fa354, 14 slots, 8 pure) |
the strategic task/goal objects: AITColonize, AITColonizeGoal, AITExplore, AITInvade, AITInvadeGoal, AITRaid, AITSteamroll, AITNodeBore, AITMining, AITEscortGate…, AITBuildStations, AITBuildPoliceShips, AITDefend…, AITRespond…, AITStockFreighters, AITAdvanceIdleShips, AITKillEasterEgg, plus the two IAIAntiquariansTask artifact tasks (15 slots) |
Game::AIRK* |
13 | Game::IAIRelationScale (vt 0x009fa348, 2 slots) |
diplomatic scales: AIRKBackstab, AIRKBrokenAlly, AIRKBrokenNap, AIRKBrokenCeasefire, AIRKHighStrength, AIRKMedStrength, AIRKLostHomeworld, AIRKLowStrengthCommonEnemy, AIRKCulturalAffinity, AIRKRacialAffinity, AIRKAIAffinity(+Minority), AIRKPCAffinity, AIRKPCCombat, plus two victory-alarm scales from the scripted scenarios |
Game::AIRS* |
16 | Game::IAIRelationShift (vt 0x00a199a8, 2 slots) |
diplomatic shifts (per-event deltas): AIRSShipsLost, AIRSShipsDestroyed, AIRSColonyLost, AIRSPopulationDestroyed, AIRSSavingsReceived, AIRSResearchReceived, AIRSPeacefulTurn, AIRSCombatTurn, AIRSSurrenderDemanded, … |
Game::AIObject |
4 | 11 slots, Mars::IStreamable base |
the world model: AIPlayer, AISystem, AIFleet, AIBuildOrder. vt[3] is the packet sink |
Game::TacAI* |
11 | Game::ITacAISquadRule over Game::fuzzy::IAIRule |
TacAISquad, TacAIAlphaManeuversSquad, TacAISquadRule_{Attack,Regroup,Retreat,Role}, TacAIAttackStrategy_{BruteForce,GateGuard,Stealth,Swarm,Trick} |
| top-level | 5 | StrategyAIAgent (+::Streamable, ::DesignNameGen), StrategyAIContext, AIRulesDB, AIPersonaDB, fuzzy::AIProductionSystem |
Correction to the brief's framing. Game::fuzzy::IAIRule is not the strategic rule interface.
The only classes deriving from it are the four TacAISquadRule_* — the fuzzy rule system is the
tactical (combat) AI, alongside TacAISquad and the five TacAIAttackStrategy_*. The strategic AI is
built from three different things: the 34 IAITask goals, the 29 IAIRelationScale/IAIRelationShift
diplomacy terms, and AIRulesDB (a CSV-driven rule table used mainly for ship design). Anyone
budgeting game/ai should split those two halves; only the strategic half is on the Rung-B path.
6.2 The tuning surface — nine CSV tables plus one script
| file | loader | parser class |
|---|---|---|
Data/Strategy/AI/aitechmode.csv, aitechpri.csv, aitechgrp.csv |
0x006c7980 | AIUserTechModeRowParser / …Pri… / …Grp… |
data/strategy/ai/stock_design_names.csv, stock_diplomacy_messages.csv, stock_player_names.csv |
0x006c6250 | AIPersonaDB::Stock*RowParser |
data/strategy/ai/affinity_weapon.csv, affinity_section.csv |
0x006c63c0 | AIRulesDB::GenWeaponsRowParser / GenSectionsRowParser |
Data/Strategy/AI/weapon_replacements.csv |
0x006b4dc0 | StrategyAIContext::WeaponReplacementsRowParser |
data/strategy/ai/raider_sections.csv |
0x00871a10 | NpcRaidersRowParser |
Data/Strategy/AI/rebelai_names.csv |
0x007b41c0 | CSVRP_RebelAINames |
Data/Strategy/AI/AIRebellion.txt |
(script) | Mars::Script brace block |
All of them go through Mars::ICSVRowParser, which mars/text already implements and which lane D's
catalog work already exercises: these tables can be loaded into sots-engine today, before a single
line of AI logic exists, and validated the way the weapon/section catalogs were (229,042 values, 0 diffs).
Their diagnostics name the vocabularies: 8 tech groups (AITG_ARMOR BALWEAP BEAM BIOWEAP NRGWEAP SHIELDS TORPS WARHEAD), 53 diplomacy message ids (AIDIP_*, emitted by 0x00690960), and 46 ship-purpose ids
(colonize, police, scout, tanker, nodebore, station_*, *_cm, *_rider, … emitted by
0x00690cb0) — that last list is the AI's design-purpose enumeration, and it is the vocabulary
AIComposeShipBlueprint 0x006ad700 designs against.
6.3 What the agent persists
Game::StrategyAIAgent::Streamable (vftable 0x00a1a61c) — Read 0x006c8fd0 (4114 B), Write 0x006c6f00
(2183 B). Consistent with findings/objects/aiagent-block.md: cache state, not orders. Notably it
contains no generator state (§5.1) and no task list — the IAITask objects are rebuilt each session.
The reader's one diagnostic, "StrategyAIAgent: ReadFromStream: AISystem for network id %i not found.",
confirms the block is keyed on live object network ids.
Also confirmed by re-derivation: Game::AISystem::Write never reads its object (lane A). Nothing in this
lane contradicts that.
7. What this lane did not do — read this before planning the next one
Honest boundary, in the spirit of lane K:
- The 34
IAITaskclasses are unread. Not one body.IAITask's 14-slot vtable has 8 pure virtuals whose meanings (score? feasible? execute? complete?) are unestablished. This is the heart of the strategic AI and it is the single biggest remaining block. - The task selection loop is unfound. I did not locate where tasks are created, scored or ranked.
The
AIRulesDB::SelectRules_Tfamily (0x00695140, 0x0069cc30, 0x0069cdb0) is the ship-design rule selector, not the task selector. - The 30 Process-Turn phases are addresses, not names. Eleven of them reach a client order method, so they are the ones worth naming first; the other nineteen are unclassified.
- The
Broadcast→ queued-callback hop is inferred (§2), read only to its second loop iteration. - Order-method →
TurnCommandslist is unmapped (§4.2). Mechanical, one pass. g_CurrentClientIndexis not instrumented. Who sets it, and whether the AI clients are stepped in player order or some other order, is open — and it matters, becauseApplyTurnCommandswalksS+0x174in vector order andModCountis a count, not a set.AIProcessMinTime(aGameOptionskey read byCreateGame0x00888e80) suggests a think-time throttle, andGame::AIProcessingDialogsuggests the AI can be slow enough to need a progress dialog. Whether an AI turn can be deferred across frames — which would break the clean "all AI orders are in before the human's End Turn" ordering — is not settled. I saw no evidence of deferral inOnAIPacket, which runs straight through, but I did not look for a per-frame driver.- Nothing here has run under an instrument. Every claim is static.
8. Predictions (rule 2) — written before any run
P1 — the AI seed is literally zero, in every game
RunAI logs "RunAI: Creating AI client for %s using %08x for random seed." at log level 2.
Prediction: the value is 00000000 for every AI player, in every game, on every run.
Test (cheap, VM140, no build): start a new game with ≥2 AI players, capture the log, grep RunAI:.
Falsified if: any nonzero value appears, or values differ between the AI players in one game, or between
two runs. Each failure mode is diagnostic: a nonzero-but-constant value means g_GlobalRNG is seeded
somewhere I did not find; per-player different values mean it is a live generator and my all-zero
fixed-point argument is wrong; run-to-run different values mean it is time- or address-seeded and Rung B is
much harder.
P2 — the AI's turn is deterministic across a save/load, or Rung B is unreachable from a save alone
Load one corpus save twice in two fresh processes, press End Turn, and compare the two resulting
autosaves.
Prediction: they are byte-identical, including ModCount and Summary.Checksum.
Why it should hold: the AI's generator is reseeded to the same constant (P1) and its stream is rewound
identically both times, so both runs draw the same numbers from the same position.
Falsified if: ModCount differs between the two runs. That would mean the AI's decisions depend on
something outside the save and outside a constant seed — and it would mean the milestone as stated in
backlog.md §2 is unreachable, and Rung B must be restated as "reproduces a recorded command stream".
This is the highest-value ten minutes available on VM140 right now: it decides whether §4.1 of the
backlog is a real target or a mirage, and it needs no engine work at all.
P3 — ModCount on the reference pair
Lane A2 measured 10 non-driver ModCount bumps across turn1-state.sav's turn with an empty human
command block. Under this lane's model those ten are exactly the commands the AI players emitted, one bump
per command handled by ApplyTurnCommands.
Prediction: with lane A2's ModCount watchpoint armed, all ten bumps land inside
ApplyTurnCommands 0x007b18b0's closure (the 20 handlers plus the 6 inlined in
ApplyTurnCommandBatch 0x0088f9b0), none inside ProcessTurn, none before SNMUpdate, and the
count of AI Process Turn log lines in that turn equals the number of AI players.
Falsified if: any bump precedes SNMUpdate (the AI would be issuing orders straight to the server,
not through TurnCommands, and §0.2 is wrong), or if the ten do not partition across the AI players'
blocks.
P4 — four order methods can only ever be exercised by an AI
Of the 21 client order methods, 11 are called directly from the AI module and 7 of those 11 are also
UI-reachable. Prediction: the four that are AI-reachable but have no UI caller — 0x00762ca0,
0x00763670, 0x00763720, 0x00763990 (the middle two are also reached from 0x007f10d0, which I did not
identify) — produce TurnCommands list entries that no human-driven save can ever contain, and are
therefore permanently hypothesis rows in lane Q's table unless an AI game is captured.
Test: drive every UI order on VM140, save, and diff the populated list set against lane Q's 27. Any list
that never populates from the UI but does populate in an AI game is one of these.
8.1 Results — lane H, VM140, 2026-09-08
Run by lane H at lane AI1's request while it held VM140. Full lane-H report:
findings/control-flow/tail-probes.md.
P2 — HELD. Rung B stays as written.
ref-turn2.sav (2 players: human re Morrigi, AI Fane Lao) loaded in two separate process
launches — the game was killed and relaunched through the SOTS scheduled task between them, so
different pids (3400, then 2028) and a fresh address space each time — with the shim at
hooks=off, i.e. the proxy DLL forwards Bink exports and installs nothing. One End Turn in
each.
| run | pid | (Autosave EndTurn).sav |
(Autosave).sav |
|---|---|---|---|
| 1 | 3400 | bb4fd9ac89f41e3b… 66,732 B |
978041acd168b56e… 67,219 B |
| 2 | 2028 | bb4fd9ac89f41e3b… 66,732 B |
978041acd168b56e… 67,219 B |
Byte-identical, and equal to the 2026-09-07 oracle values from a third process on a different day.
Byte-identity subsumes the specific fields P2 named: ModCount and Summary.Checksum are inside the
compared bytes, and Summary.Checksum is derived from the whole payload, so a single differing AI
decision anywhere would have moved it.
So the AI's turn is reproducible from a save alone, in a fresh process. backlog.md §2's Rung B
is a real target: implementing the AI and byte-matching is reachable, and --turn-commands injection
does not have to become the primary path.
Corroborating evidence from the same day, same method, different save and different instrument
configurations — five further fresh-process launches of z2-endturn.sav (hooks=off, and four shim
configurations) all produced (Autosave).sav = b2124798470d85ea…, matching two lane-Z runs from a
different build. Seven fresh processes, two saves, one answer.
P1 — not answerable from the log at the default level.
session.log after a full load + End Turn of a 2-player game with one AI is 22 lines and contains
no RunAI: line, no random seed line and no AI Process Turn line; dxvklog\run.txt (the game's
stdout) carries the same 22 lines and nothing more. So the message exists in the image but is not
emitted at the log level this build runs at — P1's "one log read, no build" route does not exist as
described. Whatever gates level-2 logging has not been found; a -log / config switch was not
searched for, and that is the cheap next step before anyone builds a hook for this.
One suggestive observation, offered as a hypothesis and not as a result (rule 6). Lane H's
per-call-site RNG instrument separates draws by generator instance. On every measured turn the
non-strategic rows include FUN_005798e0+0x30, a NextInt that lane Z attributed to a
StrategyClient's generator at client+0x134 — and it repeatedly consumes more words than calls
(3 calls / 5 words, 3 / 4, 3 / 3 across turns and saves). Extra words in a bounded NextInt mean its
rejection loop ran, which requires successive draws to differ — impossible for an all-zero MT
state, where every temper returns 0. If that generator is an AI client's, P1 is false. It is not
established that it is: the human player has a StrategyClient too, and lane H did not determine
which instance those rows belong to. The decisive version of this test is cheap now that the
machinery exists: record the this pointer alongside the return address in draw_sites.cpp and
compare it against each client's +0x134.
P3 — not run. No ModCount watchpoint was armed; lane H held no debugger session and lane A2's
DR0 spec was not set up on this VM. Nothing was measured for or against it.
9. Ranked plan for sots-engine/src/game/ai
Ordered by evidence available now over cost, not by architectural tidiness.
| # | deliverable | why first | testable how | blocked on |
|---|---|---|---|---|
| 0 | Do not write game/ai yet. Run P2 (§8) first. |
It decides whether Rung B is reachable from a save at all. Ten minutes of VM time gates weeks of engine work. | compare two autosaves | VM140 (lane H) |
| 1 | game/ai/tables — the nine CSV tables into the existing catalog machinery |
Zero AI understanding required; mars/text + Mars::ICSVRowParser already exist and lane D's harness already validates catalogs value-for-value |
host: oracle diff against the shipped CSVs, the way game/data was closed (229,042 values / 0 diffs) |
nothing |
| 2 | game/ai/vocab — the three enumerations: 8 AITG_*, 53 AIDIP_*, 46 ship purposes; and the 34 AIT* / 13 AIRK* / 16 AIRS* class-name tables |
They are the AI's whole external vocabulary and they are already extracted (§6.2); they make every later finding expressible | host: string tables, trivially | nothing |
| 3 | Map the 21 order methods to lane Q's 27 lists (§4.2), and drive the seven UI-reachable ones on VM140 | Turns 22 hypothesis rows into observations (rule 6) and pins the ABI game/ai must eventually call. Pays off for Rung A too — this is the "orders save" workload backlog §5 already wants |
save-reader round-trip on the new saves; state_checksum --tree |
VM140 |
| 4 | app: accept a recorded AI command stream — a --turn-commands <file> input that injects other players' TurnCommands blocks before the driver runs |
This is Rung B minus the AI. It makes ModCount and Summary.Checksum reproducible today from a capture, and it is the fallback if P2 fails |
the 11 saves + one captured stream; ModCount must land exactly |
#3, one capture |
| 5 | Read IAITask — the 14-slot interface, then 3 representative tasks (AITColonize / AITColonizeGoal for the goal-vs-task split, AITExplore for the simplest, AITInvade for the composite) and the selection loop |
The real work. Everything above is scaffolding for it | nothing host-side until #6 | a lane |
| 6 | game/ai/agent — the event spine only: OnEvent(type) → context translate → packet → OnAIPacket, with the 30-phase Process Turn as named stubs |
The spine is fully mapped (§2, §3) and is a faithful skeleton; stubs make the divergence metric work for AI phases the way sots_turn does for driver phases |
host: phase-order test; live: hook 0x006cf8a0 and compare the phase sequence |
#5 for content |
| 7 | game/ai/rng — per-client Mars::RNG seeded 0, cl_Chance / cl_RandRange with the inclusive bound |
Tiny, fully specified (§5), and every later AI piece needs it | host: replay against mars/rng, which is already closed |
P1 |
| — | combat AI (TacAI*, fuzzy::IAIRule) |
Not on the Rung-B path at all (§6.1). Park it with GameCombatSim |
The honest one-line version: items 1–4 are worth doing regardless of what P2 says; items 5–7 are worth doing only if P2 passes.
10. Corrections to earlier findings
turn-spine.md§2.3, the[AI]line. "SNMRunAI (0x3d) → StrategyApp::RunAI → RaiseAIPrepareTurn → SEAIPrepareTurn; AI players' TurnCommands enter the same SNMEndTurn/SNMUpdate path." The second clause is right and is the key that unlocked this lane. The first is a construction path, not a turn hook:RaiseAIPrepareTurnhas two callers, both one-shot. The per-turn hook isSEResumePlaying(0x26) →StrategyClient::OnResumePlaying0x00777480 →agent->OnEvent(0x26).- The brief's "the fuzzy
IAIRulefamily" as the strategic rule system.Game::fuzzy::IAIRulehas four implementors, allTacAISquadRule_*; it is the tactical AI (§6.1). turn-driver.mdis not contradicted anywhere. The AI is simply not inProcessTurn, which is why 32 phases of instruction-verified reading never found it.