sots-re/findings/subsystems/ai-turn-logic.md
alex 8ad9971f6e lane H: five live probes, the oracle restored, and a harness perturbation bisected to one hook
- 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
2026-09-08 13:53:36 -04:00

40 KiB
Raw Blame History

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:

  • SEAIPrepareTurn is not a per-turn event. RaiseAIPrepareTurn has exactly two callers — RunAI and StrategyApp::CreateGame 0x00888e80+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 StrategyClient derivative. 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:

  1. It pushes {callback, agent, seq} onto a ring deque at context+0x58 (buf@+0x5c cap@+0x60 head@+0x64 size@+0x68, 0x0c-byte nodes, push helper 0x0069e470 under a critical section).
  2. It runs switch (clientEventType - 6) over 0..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; …} through StrategyAIContext::Broadcast 0x006b3840.

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_EndTurn 0x00579310 — if (c && c->AIAgent(+0x12c) && !c->bTurnEnded(+0x15c)) EndTurn(c, true). The +0x12c test means this entry point is AI-only; the human UI reaches EndTurn by 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, because RNG_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 from agent->+0x10 → client->+0x134.
  • The two façade helpers the module calls, cl_Chance and cl_RandRange, both read client->+0x134.
  • Zero calls from the module to NextFloat, Chance, NextUInt, Twist, Seed or GaussianRange.

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 — the Mars::IStreamable vftable, not the Mars::RNG vftable 0x009e9aec that RNG_Seed installs;
  • none of the six RNG_Seed call sites in the image (0x004b2113, 0x0056bc2b, 0x0071cdf9, 0x00783046, 0x007d7d3d, 0x008aa0ec) targets it — every one is an operator_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::ITacAISquad­Rule 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:

  1. The 34 IAITask classes 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.
  2. The task selection loop is unfound. I did not locate where tasks are created, scored or ranked. The AIRulesDB::SelectRules_T family (0x00695140, 0x0069cc30, 0x0069cdb0) is the ship-design rule selector, not the task selector.
  3. 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.
  4. The Broadcast → queued-callback hop is inferred (§2), read only to its second loop iteration.
  5. Order-method → TurnCommands list is unmapped (§4.2). Mechanical, one pass.
  6. g_CurrentClientIndex is not instrumented. Who sets it, and whether the AI clients are stepped in player order or some other order, is open — and it matters, because ApplyTurnCommands walks S+0x174 in vector order and ModCount is a count, not a set.
  7. AIProcessMinTime (a GameOptions key read by CreateGame 0x00888e80) suggests a think-time throttle, and Game::AIProcessingDialog suggests 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 in OnAIPacket, which runs straight through, but I did not look for a per-frame driver.
  8. 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: RaiseAIPrepareTurn has two callers, both one-shot. The per-turn hook is SEResumePlaying (0x26) → StrategyClient::OnResumePlaying 0x00777480 → agent->OnEvent(0x26).
  • The brief's "the fuzzy IAIRule family" as the strategic rule system. Game::fuzzy::IAIRule has four implementors, all TacAISquadRule_*; it is the tactical AI (§6.1).
  • turn-driver.md is not contradicted anywhere. The AI is simply not in ProcessTurn, which is why 32 phases of instruction-verified reading never found it.