diff --git a/campaign/board.md b/campaign/board.md index 353d8a8..a800cb0 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -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= 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 | diff --git a/findings/subsystems/ai-turn-logic.md b/findings/subsystems/ai-turn-logic.md new file mode 100644 index 0000000..3a645ec --- /dev/null +++ b/findings/subsystems/ai-turn-logic.md @@ -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 ` 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. diff --git a/ghidra/addresses.d/lane-ai1.json b/ghidra/addresses.d/lane-ai1.json new file mode 100644 index 0000000..5317624 --- /dev/null +++ b/ghidra/addresses.d/lane-ai1.json @@ -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..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" + } + ] +}