board: lane AI1 - the AI is a StrategyClient running after the autosave; orders go through the TurnCommands we already own; P2 routed to the VM

This commit is contained in:
alex 2026-09-08 13:39:38 -04:00
parent d639c9a112
commit d617285b0c
3 changed files with 779 additions and 0 deletions

View file

@ -235,3 +235,9 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| T36 census wired; a NEW blocker found by a wrong prediction | verify | mapped | high | 100% | 2026-09-08 | Lane E2. Default run UNCHANGED at closed 5 / regressed 0 on both pairs, exactly as predicted (T36 does not commit by default). --commit-blocked regressions fell 9 -> **7** (pair 1) and 11 -> **9** (pair 2). PREDICTION WAS 6 AND 8, AND THE MISS IS THE FINDING: falsification hypothesis 1 fired - two census leaves closed (shpt[2]=5 dreadnoughts, satt[0]=10 platforms) but **shpt[0] did not, and the archived value is higher than ours by EXACTLY ONE DESTROYER ON BOTH PAIRS** (0 vs 1, then 1 vs 2). The self-check on the INPUT turn is exact, so this is NOT a classification error - it is a NEW NAMED BLOCKER: **no phase we run builds a ship** (S11's build-queue sub-pass is the declared input boundary). Census itself reproduced through the app's own path: 11 saves, 80 records, 1040 fields, 480 census leaves, 0 mismatches - and it visits slots in the ORIGINAL'S IN-MEMORY ORDER (mission, command, engine) rather than the wire's, so it is an independent route to lane D2's 480/480 |
| T36 remains blocked on TWO named things | verify | backlog | — | 0% | 2026-09-08 | Neither is lane E2's: (1) sav/inc x3 = the budget, lane E1's income chain; (2) shpt[0] x1 = unmodelled ship construction. **The briefed --commit-blocked-except=<fields> knob is NOT IMPLEMENTABLE and lane E2 did not fake one**: the archived record is ONE STRUCT ON THE WIRE, so sav/inc cannot be omitted while the other eleven fields are written - the format has no hole. It implemented the same convention at PHASE granularity instead (--commit-blocked=IDS / --commit-blocked-except=IDS), which is what isolated the T36 rows and cleanly separated S31's two Player/Status regressions out of the pair-2 number |
| standalone data root | engine | verified | high | 100% | 2026-09-08 | The standalone grew `--data DIR` / $SOTS_DATA_DIR. NO GAME DATA EMBEDDED; absence costs exactly the six census counters and SAYS SO. Note for integrators: with a data root set, `game_data_realdata` fails - and it FAILS IDENTICALLY ON UNMODIFIED MAIN, because the extracted sots.gob is a different data version than the counts that test pins. Not a regression; worth pinning the data version in that test |
| THE STRATEGIC AI IS A StrategyClient, not part of ProcessTurn | control-flow | verified | high | 90% | 2026-09-08 | Lane AI1, instruction-verified. One extra Game::StrategyClient (0x708 B) is built PER AI PLAYER at SNMRunAI (net msg 0x3d) by StrategyApp::RunAI 0x008706f0, carrying a Game::StrategyAIAgent at StrategyClient+0x12c, and it receives the same SE* events the human client does. THE PER-TURN TRIGGER IS SEResumePlaying, client event 0x26, raised by StrategyServer::ResumePlaying 0x007ddc90 to every player with Status==0; it lands in StrategyClient::OnResumePlaying 0x00777480 which does `if (this->+0x12c) agent->vt[1](0x26, ev)` - and THAT CALL RUNS THE AI'S WHOLE TURN SYNCHRONOUSLY ON THE MAIN THREAD. Order: ProcessTurn -> OnAllCombatDone_Tail -> "(Autosave EndTurn)" [the save is written HERE] -> SNMResumePlaying -> each AI client's ~30 phases -> cl_EndTurn -> human End Turn -> SNMUpdate -> ApplyTurnCommands (ModCount++ per command) -> ProcessTurn. EXACTLY THE SHAPE LANE A2 MEASURED. CORRECTION to turn-spine.md 2.3: RaiseAIPrepareTurn/SEAIPrepareTurn (type 9) is CONSTRUCTION, not a turn hook - two callers, RunAI and CreateGame, both one-shot |
| how an AI decision becomes a command | control-flow | verified | high | 95% | 2026-09-08 | THERE IS NO SEPARATE AI ORDER CHANNEL - the join to everything we already own. StrategyClient+0x160 is an ACCUMULATING TurnCommands; +0x4d8 is the send buffer. ~21 client order methods in 0x00762ca0..0x00763f60 each do `lea ecx,[this+0x160]` and call a TurnCommands::Add* helper. At End Turn, StrategyClient::BuildTurnCommands 0x00783780 does Clear(+0x4d8); +0x4d8 = +0x160; playerId; SetResearchRate - WHICH INCIDENTALLY EXPLAINS why every save's block has the research-rate gate set and the other five clear. SendEndTurn copies +0x4d8 into SNMEndTurn. **11 of the 21 order methods are called directly from the AI module; 7 of those are also UI-reachable.** So ModCount is downstream of StrategyClient+0x160, and RUNG B REDUCES TO FILLING THAT OBJECT THE WAY THE AI DOES |
| AI determinism: every client seeded with ZERO | verify | mapped | high | 85% | 2026-09-08 | All AI randomness uses a PER-CLIENT Mars::RNG at StrategyClient+0x134 (operator_new(0x9cc) + RNG_Seed(ctorArg) in the client ctor); all six direct NextInt sites in the AI band reach it, as do the two facade helpers cl_Chance and cl_RandRange. **ZERO AI CALLS TO THE STRATEGIC GENERATOR** - which closes lane Z's zero residual FROM THE OTHER SIDE, and neither lane has to weaken anything. The seed comes from NextUInt() on a static g_GlobalRNG at 0x00af6e58 that is **NEVER SEEDED** (its only static init writes the IStreamable vftable; none of the six RNG_Seed call sites targets it), and AN ALL-ZERO MT STATE IS A FIXED POINT OF THE TWIST - so every AI client is seeded with 0. GOOD: constant and reproducible. BAD: **the AI's generator state is in NO SAVE** - the StrategyAIAgent::Streamable block's 35 tags carry none. UNVERIFIED LIVE: P1/P2 routed to the VM holder |
| P2: the test that decides whether Rung B is stated correctly | verify | in-progress | — | 0% | 2026-09-08 | ROUTED TO LANE H (holds VM140). Load one save twice IN TWO FRESH PROCESSES, End Turn in each, diff the autosaves. Predicted byte-identical incl. ModCount. IF IT HOLDS, Rung B stays as written (implement the AI, the byte-match is reachable from a save alone). **IF IT FAILS, Rung B is UNREACHABLE FROM A SAVE ALONE** and must be restated as "reproduces a recorded command stream", making --turn-commands injection the primary path instead of writing an AI. A roadmap-level answer for ten minutes of VM time, and it must be TWO SEPARATE PROCESS LAUNCHES to catch per-process seeding - distinct from the oracle restoration, which re-runs the same path |
| CORRECTION: fuzzy::IAIRule is the TACTICAL AI | subsystems | verified | high | 100% | 2026-09-08 | A framing error the roadmap and several briefs carried: Game::fuzzy::IAIRule is the tactical/COMBAT AI - its only four implementors are TacAISquadRule_*. The STRATEGIC AI is 34 IAITask goals + 29 IAIRelationScale/Shift diplomacy terms + AIRulesDB (mostly SHIP DESIGN, and AIRulesDB::SelectRules_T is the ship-design selector, NOT the task selection loop). Only the strategic half is on the Rung-B path |
| AI module boundary: lane AI1 read ~3% | meta | verified | high | 100% | 2026-09-08 | The module is **1,629 functions / ~360 KB** (dense core 0x680000-0x6d1000) and lane AI1 read the spine, maybe 3%, and says so. NOT DONE: the 34 IAITask bodies - NOT ONE - and IAITask's 8 pure virtuals are unnamed; THE TASK SELECTION LOOP IS UNFOUND; 19 of the 30 AI Process-Turn phases are unclassified addresses; the order-method -> list mapping (mechanical, one pass); and g_CurrentClientIndex 0x00ae4808 is uninstrumented - who sets it and in what ORDER AI clients are stepped matters, BECAUSE ModCount IS A COUNT. ONE HOP IS INFERRED NOT READ: StrategyAIContext::Broadcast 0x006b3840 delivering to the queued callback that reaches OnAIPacket - the identification is safe (one caller, registered by one function, jump table indexed by exactly the codes that function emits) but a next lane should finish it. ALSO UNSETTLED: AIProcessMinTime + Game::AIProcessingDialog hint at a think-time throttle; no deferral seen in OnAIPacket but no per-frame driver was looked for, and if an AI turn CAN span frames the clean "all AI orders in before the human's End Turn" ordering needs revisiting |

View file

@ -0,0 +1,585 @@
# 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.
---
## 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.

View file

@ -0,0 +1,188 @@
{
"entries": [
{
"name": "StrategyApp_RunAI",
"addr": "0x008706f0",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyApp::RunAI(int playerNetId, const char* aiCustomDataName, unsigned char aiPersonality, unsigned int rngSeed) -- RET 0x10, four stack args. The ONE-SHOT AI construction path, reached only from StrategyNetworkClient::OnMessage 0x00784640+0x96e, case SNMRunAI (net msg id 0x3d). Resolves the player through the handle registry at server+0x84; refuses on a human (`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) + StrategyClient ctor 0x00782ed0 WITH rngSeed; StrategyServer::InitGame; StrategyClient::CreateAI 0x007653c0(aiPersonality) which builds the Game::StrategyAIAgent into StrategyClient+0x12c (\"RunAI: Failed to create AI for %s (player %d).\"); if a save-game blob was supplied, \"Loading AI custom data from save game...\" through agent vt[5]/vt[6]; finally RaiseAIPrepareTurn 0x00815f20. NOT a per-turn entry point",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read of dumps/sots.exe (objdump -b binary -m i386 -M intel, disassembled to the next function start)"
},
{
"name": "StrategyApp_RaiseAIPrepareTurn",
"addr": "0x00815f20",
"convention": "cdecl",
"prototype": "void (Game::StrategyServer* srv) with EBX = Game::StrategyAIAgent* (register-passed; both call sites set EBX before the call). Builds a stack Game::SEAIPrepareTurn (vftable 0x00a23c00) and invokes agent->vt[1](9, &ev) -- i.e. StrategyAIAgent::OnEvent with client event type 9. Also fires the SVScriptObject hooks at srv+0x1b4 with ids 9 and 0xa. Exactly two call sites: RunAI 0x008706f0+0x26c and StrategyApp::CreateGame 0x00888e80+0x3f5 -- both are game/AI construction, so SEAIPrepareTurn is NOT raised once per turn despite the name",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyAIAgent_OnEvent",
"addr": "0x006d0ad0",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIAgent::OnEvent(int clientEventType, Game::StrategyEvent** ev) -- RET 8. IStrategyAIAgent vtable slot 1 (vftable 0x00a1b244). Pushes a log scope on this->+0x10, then calls StrategyAIContext::OnStrategyEvent 0x006c2b90 with ECX = this->+0x94 (the context) and args (type, ev, &thunk 0x006d0ab0, this). The thunk forwards to StrategyAIAgent::OnAIPacket 0x006cf8a0. Every StrategyClient event handler in 0x00773xxx-0x00777xxx forwards through this slot when StrategyClient+0x12c is non-null",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#2 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyAIContext_OnStrategyEvent",
"addr": "0x006c2b90",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIContext::OnStrategyEvent(int clientEventType, Game::StrategyEvent** ev, void (*cb)(void*, void*), Game::StrategyAIAgent* agent) -- RET 0x10. Registers {cb, agent, seq} on the pending-callback deque at this+0x58 (ring deque, buf@+0x5c cap@+0x60 head@+0x64 size@+0x68, 0x0c-byte nodes, push helper 0x0069e470 under a critical section). Then `switch (type - 6)` over 0..0x20 through the byte index table at 0x006c33a4 and jump table at 0x006c3360 (17 distinct cases), updating the AI world model and emitting INTERNAL AI packets {int code; ...} through StrategyAIContext::Broadcast 0x006b3840. Client event 9 (SEAIPrepareTurn) emits codes 1 then 2; client event 0x26 (SEResumePlaying) emits code 3. Tail: if the pending deque size is 1 it drains a second, separate queue at this+0x38 via 0x006a8690",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#2 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyAIContext_Broadcast",
"addr": "0x006b3840",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIContext::Broadcast(const AIPacket* pkt) -- walks the listener red-black tree at this+0xc (std::set/map nodes; `_Isnil` at node+0x15) and calls listener->vt[3](pkt) on each -- that is the Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet (0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50). Then, if the pending-callback deque at this+0x68 is non-empty, iterates it (0x0069e510 / 0x006a4ee0) and delivers the same packet to the queued {cb, this} pairs -- the hop that reaches StrategyAIAgent::OnAIPacket 0x006cf8a0. 19 call sites, all inside OnStrategyEvent 0x006c2b90 and 0x006c29c0",
"status": "mapped",
"source": "findings/subsystems/ai-turn-logic.md#2 -- lane AI1 2026-09-08; listener walk and vt[3] dispatch instruction-verified, the deque delivery arm read only to its first two iterations"
},
{
"name": "StrategyAIAgent_OnAIPacket",
"addr": "0x006cf8a0",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIAgent::OnAIPacket(const AIPacket* pkt) -- 2008 bytes. `eax = pkt->code - 2; if (eax > 0xf) return; jmp [eax*4 + 0x006d0078]` -- a 16-entry jump table over internal packet codes 2..17. Code 2 = the PREPARE TURN body (0x006cf958, logs \"====== AI Prepare Turn (%s) ======\"); code 3 = the PROCESS TURN body (0x006cfabf, logs \"====== AI Process Turn (%s) ======\", ~30 phases, ends by calling cl_EndTurn 0x00579310). Codes 4/6/7/8/10/11/13 fall through to the no-op at 0x006d0058. this->+0x10 = the owning StrategyClient, this->+0x14 = the ClientPlayer (name std::string at +0x40), this->+0x94 = the StrategyAIContext. Reached only through the thunk at 0x006d0ab0",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#3 -- lane AI1 2026-09-08, instruction-stream read; jump table read from the image"
},
{
"name": "StrategyClient_OnResumePlaying",
"addr": "0x00777480",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyClient::OnResumePlaying(Game::StrategyEvent** ev) -- the case-0x26 handler of StrategyClient::RaiseEvent 0x00783ee0 (jump table 0x00784200, 0x2c entries). At +0x9d: `if (this->+0x12c) agent->vt[1](0x26, ev)` -- THE per-turn AI trigger. SEResumePlaying is broadcast by StrategyServer::ResumePlaying 0x007ddc90 at the very start of a new turn, after OnAllCombatDone_Tail has written the pre-turn autosave",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyClient_off_AIAgent",
"offset": "0x12c",
"convention": "offset",
"prototype": "Game::StrategyAIAgent* -- non-null only on an AI client. Every StrategyClient event handler tests it before forwarding the event to the agent; SendEndTurn 0x00783980 reads agent->vt[8]() through it to fetch the AIEncounterFlags it appends to SNMEndTurn; cl_EndTurn 0x00579310 refuses to end the turn unless it is non-null",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyClient_off_RNG",
"offset": "0x134",
"convention": "offset",
"prototype": "Mars::RNG* -- a PER-CLIENT generator, operator_new(0x9cc) + RNG_Seed(ctorArg) in the StrategyClient constructor 0x00782ed0+0x143..+0x181. For an AI client the seed is RunAI's 4th argument. THE ONLY GENERATOR THE STRATEGIC AI DRAWS FROM: all six direct NextInt sites in the AI module reach it, and so do the two façade helpers cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0. It is distinct from the strategic generator at StrategyServer+0x16c and is NOT serialised anywhere in the save",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyClient_off_PendingTurnCommands",
"offset": "0x160",
"convention": "offset",
"prototype": "Game::TurnCommands -- the ACCUMULATING order queue. Every StrategyClient order method in 0x00762ca0..0x00763f60 does `lea ecx,[this+0x160]` and appends to one of its 27 lists (or sets one of its six gates). At End Turn, StrategyClient::BuildTurnCommands 0x00783780 does TurnCommands::operator=(this->+0x4d8, this->+0x160) and then overwrites the player id, research rate and fleet-move list from live state",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyClient_off_SendTurnCommands",
"offset": "0x4d8",
"convention": "offset",
"prototype": "Game::TurnCommands -- the SEND buffer, the object that becomes the `Player.<id>.TurnCommands_v5` block on the wire. Written only by StrategyClient::BuildTurnCommands 0x00783780 (from +0x160) and read by SendEndTurn 0x00783980+0x96, which copies it into the SNMEndTurn message (vftable 0x00a229e0) with TurnCommands::operator= 0x007832b0",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "StrategyClient_BuildTurnCommands",
"addr": "0x00783780",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyClient::BuildTurnCommands(Game::TurnCommands* dst) -- called once from EndTurn 0x00783be0+0xee with dst = &this->+0x4d8. TurnCommands::Clear 0x00893f00(dst); TurnCommands::operator= 0x007832b0(dst, &this->+0x160); dst->playerId(+4) = this->+0x150->+4; TurnCommands::SetResearchRate 0x0080f2d0(dst, this->+0x150->+0xbc) -- which is why EVERY save's TurnCommands block has the research-rate gate set and the other five clear; then, if this->+0x6d0, walks the pending fleet-move vector at this->+0x6d4/+0x6d8 (stride 8) into the move list",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "TurnCommands_Assign",
"addr": "0x007832b0",
"convention": "thiscall",
"prototype": "Game::TurnCommands& __thiscall Game::TurnCommands::operator=(const TurnCommands& src) -- member-by-member copy of the six gates and their payloads, then the 27 lists. 11 call sites, including SendEndTurn, StrategyServer::OnPlayerEndTurn 0x007d9af0+0x68 (the host storing an arriving block), BuildTurnEvents and LoadGame",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "cl_EndTurn",
"addr": "0x00579310",
"convention": "cdecl",
"prototype": "void () -- the AI's turn-submission façade. `c = g_StrategyClients[g_CurrentClientIndex]; if (c && c->AIAgent(+0x12c) && !c->bTurnEnded(+0x15c)) StrategyClient::EndTurn(c, true);` -- note the +0x12c test: this entry point works ONLY for an AI client. Called from the AI Process Turn body at 0x006cfcd9. The other caller of StrategyClient::EndTurn is the human UI at 0x005e4f80+0x5f",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "cl_Chance",
"addr": "0x00578cf0",
"convention": "cdecl",
"prototype": "bool (float p) -- `c = g_StrategyClients[g_CurrentClientIndex]; return c ? RNG_Chance(c->RNG(+0x134), p) : false;`. One of the AI's two randomness façades; used by the AI Process Turn body at 0x006cfc24 for the surrender roll that follows the \"Survival Outlook: Dead in %i turns (%5.2f%% chance to surrender this turn)\" log line",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "cl_RandRange",
"addr": "0x005798e0",
"convention": "cdecl",
"prototype": "int (int lo, int hi) -- `c = g_StrategyClients[g_CurrentClientIndex]; if (!c) return 0; n = hi - lo; return lo + RNG_NextInt(&c->RNG(+0x134)->mt, &n);`. NOTE RNG_NextInt is INCLUSIVE of its bound (addresses.json), so the range is [lo, hi] inclusive. Eight AI-module call sites",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read"
},
{
"name": "g_StrategyClients",
"addr": "0x00ae47e4",
"convention": "offset",
"prototype": "Game::StrategyClient* g_StrategyClients[] -- the client table the whole 0x00578cf0..0x005793xx façade family indexes with g_CurrentClientIndex (0x00ae4808). 40 functions reference it. The AI runs as the current client: everything it does goes through this indirection, which is how one process hosts the human client and N AI clients over the same API",
"status": "mapped",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08; the table's element count was not established"
},
{
"name": "g_CurrentClientIndex",
"addr": "0x00ae4808",
"convention": "offset",
"prototype": "int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. Not instrumented; who sets it, and when relative to the AI's turn, is open",
"status": "mapped",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08"
},
{
"name": "g_GlobalRNG",
"addr": "0x00af6e58",
"convention": "offset",
"prototype": "Mars::RNG -- a STATIC generator in .data, 0x9cc bytes. Its only static initialiser (0x009dc6e0) writes the Mars::IStreamable vftable 0x009e22bc, NOT the Mars::RNG vftable 0x009e9aec that RNG_Seed installs: none of the six RNG_Seed call sites in the image targets it, so its mt[624] is the zero-initialised BSS array and `left` is 0. An all-zero MT19937 state is a fixed point of the twist, so EVERY draw from it returns 0. Five consumers: SNMRunAI (the AI client seed, OnMessage+0x955), RunCombatRound 0x007cbe80+0x60f, 0x007c2fa0+0xc84, 0x0079ea90+0x73 (an RNG_Chance) and 0x005b9f00+0xc0",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read of the static initialiser and of all six RNG_Seed call sites; the all-zero fixed point is arithmetic, not measurement -- see the prediction in section 8"
},
{
"name": "AIRulesDB_LoadTechTables",
"addr": "0x006c7980",
"convention": "thiscall",
"prototype": "void __thiscall -- loads Data/Strategy/AI/aitechmode.csv, aitechpri.csv and aitechgrp.csv through the Mars::ICSVRowParser subclasses Game::AIUserTechModeRowParser (vftable 0x00a1ae8c), AIUserTechPriRowParser (0x00a1ae7c) and AIUserTechGrpRowParser (0x00a1ae6c). Diagnostics: \"%s, %i: tech %s does not exist.\", \"%s, %i: priority %i (%s) is out of range 0..255.\", \"%s, %i: bad research mode character: %s\", \"%s, %i: bad group: %s\". Tech group ids are the AITG_* strings emitted by 0x006920a0 (ARMOR BALWEAP BEAM BIOWEAP NRGWEAP SHIELDS TORPS WARHEAD)",
"status": "mapped",
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map; the parsers themselves were not read"
},
{
"name": "AIPersonaDB_LoadStockTables",
"addr": "0x006c6250",
"convention": "thiscall",
"prototype": "void __thiscall -- loads data/strategy/ai/stock_design_names.csv, stock_diplomacy_messages.csv and stock_player_names.csv through Game::AIPersonaDB::StockDesignNameRowParser (vftable 0x00a1b034), StockDiplomacyMessageRowParser (0x00a1b024) and StockPlayerNameRowParser (0x00a1b014). Diagnostics key on species and on a diplomatic event id; the id vocabulary is the 53 AIDIP_* strings returned by 0x00690960",
"status": "mapped",
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map"
},
{
"name": "AIRulesDB_LoadAffinityTables",
"addr": "0x006c63c0",
"convention": "thiscall",
"prototype": "void __thiscall -- loads data/strategy/ai/affinity_weapon.csv and affinity_section.csv (\"ToAISectionRule: %s ship section not found: %s\"). These are the ship-design affinity weights consumed by the design composer 0x006ad700 (\"AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS\", \"While AI for %s was designing a ship: Did not find any weapon to match %s, %s, bNoTrackingWeapons=%i.\"). Section-class rule selection is 0x0069cc30 / 0x0069cdb0 (\"AISelectSectionClassRules: maxout\", \"AISelectSectionClassRulesMergeFallback: maxout\"); the generic selector is 0x00695140 (\"AIRulesDB::SelectRules_T: maxout\")",
"status": "mapped",
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map"
},
{
"name": "StrategyAIContext_LoadWeaponReplacements",
"addr": "0x006b4dc0",
"convention": "thiscall",
"prototype": "void __thiscall -- loads Data/Strategy/AI/weapon_replacements.csv through Game::StrategyAIContext::WeaponReplacementsRowParser (vftable 0x00a1a62c). Consumed by 0x00694f80 (\"StrategyAIContext::GetWeaponReplacement: maxReplacements (%i)\")",
"status": "mapped",
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map"
}
]
}