- 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
643 lines
40 KiB
Markdown
643 lines
40 KiB
Markdown
# 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::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:
|
||
|
||
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.
|