# 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 ` 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.