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:
parent
d639c9a112
commit
d617285b0c
3 changed files with 779 additions and 0 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
585
findings/subsystems/ai-turn-logic.md
Normal file
585
findings/subsystems/ai-turn-logic.md
Normal 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::ITacAISquadRule` over **`Game::fuzzy::IAIRule`** | `TacAISquad`, `TacAIAlphaManeuversSquad`, `TacAISquadRule_{Attack,Regroup,Retreat,Role}`, `TacAIAttackStrategy_{BruteForce,GateGuard,Stealth,Swarm,Trick}` |
|
||||
| top-level | 5 | | `StrategyAIAgent` (+`::Streamable`, `::DesignNameGen`), `StrategyAIContext`, `AIRulesDB`, `AIPersonaDB`, `fuzzy::AIProductionSystem` |
|
||||
|
||||
**Correction to the brief's framing.** `Game::fuzzy::IAIRule` is *not* the strategic rule interface.
|
||||
The only classes deriving from it are the four `TacAISquadRule_*` — **the fuzzy rule system is the
|
||||
tactical (combat) AI**, alongside `TacAISquad` and the five `TacAIAttackStrategy_*`. The strategic AI is
|
||||
built from three different things: the 34 `IAITask` goals, the 29 `IAIRelationScale`/`IAIRelationShift`
|
||||
diplomacy terms, and `AIRulesDB` (a CSV-driven rule table used mainly for **ship design**). Anyone
|
||||
budgeting `game/ai` should split those two halves; only the strategic half is on the Rung-B path.
|
||||
|
||||
### 6.2 The tuning surface — nine CSV tables plus one script
|
||||
|
||||
| file | loader | parser class |
|
||||
|---|---|---|
|
||||
| `Data/Strategy/AI/aitechmode.csv`, `aitechpri.csv`, `aitechgrp.csv` | 0x006c7980 | `AIUserTechModeRowParser` / `…Pri…` / `…Grp…` |
|
||||
| `data/strategy/ai/stock_design_names.csv`, `stock_diplomacy_messages.csv`, `stock_player_names.csv` | 0x006c6250 | `AIPersonaDB::Stock*RowParser` |
|
||||
| `data/strategy/ai/affinity_weapon.csv`, `affinity_section.csv` | 0x006c63c0 | `AIRulesDB::GenWeaponsRowParser` / `GenSectionsRowParser` |
|
||||
| `Data/Strategy/AI/weapon_replacements.csv` | 0x006b4dc0 | `StrategyAIContext::WeaponReplacementsRowParser` |
|
||||
| `data/strategy/ai/raider_sections.csv` | 0x00871a10 | `NpcRaidersRowParser` |
|
||||
| `Data/Strategy/AI/rebelai_names.csv` | 0x007b41c0 | `CSVRP_RebelAINames` |
|
||||
| `Data/Strategy/AI/AIRebellion.txt` | (script) | `Mars::Script` brace block |
|
||||
|
||||
All of them go through `Mars::ICSVRowParser`, which `mars/text` already implements and which lane D's
|
||||
catalog work already exercises: **these tables can be loaded into `sots-engine` today**, before a single
|
||||
line of AI logic exists, and validated the way the weapon/section catalogs were (229,042 values, 0 diffs).
|
||||
Their diagnostics name the vocabularies: 8 tech groups (`AITG_ARMOR BALWEAP BEAM BIOWEAP NRGWEAP SHIELDS
|
||||
TORPS WARHEAD`), 53 diplomacy message ids (`AIDIP_*`, emitted by 0x00690960), and 46 ship-purpose ids
|
||||
(`colonize`, `police`, `scout`, `tanker`, `nodebore`, `station_*`, `*_cm`, `*_rider`, … emitted by
|
||||
0x00690cb0) — that last list is the AI's **design-purpose enumeration**, and it is the vocabulary
|
||||
`AIComposeShipBlueprint` 0x006ad700 designs against.
|
||||
|
||||
### 6.3 What the agent persists
|
||||
|
||||
`Game::StrategyAIAgent::Streamable` (vftable 0x00a1a61c) — `Read` 0x006c8fd0 (4114 B), `Write` 0x006c6f00
|
||||
(2183 B). Consistent with `findings/objects/aiagent-block.md`: **cache state, not orders**. Notably it
|
||||
contains no generator state (§5.1) and no task list — the `IAITask` objects are rebuilt each session.
|
||||
The reader's one diagnostic, *"StrategyAIAgent: ReadFromStream: AISystem for network id %i not found."*,
|
||||
confirms the block is keyed on live object network ids.
|
||||
|
||||
Also confirmed by re-derivation: `Game::AISystem::Write` never reads its object (lane A). Nothing in this
|
||||
lane contradicts that.
|
||||
|
||||
---
|
||||
|
||||
## 7. What this lane did **not** do — read this before planning the next one
|
||||
|
||||
Honest boundary, in the spirit of lane K:
|
||||
|
||||
1. **The 34 `IAITask` classes are unread.** Not one body. `IAITask`'s 14-slot vtable has 8 pure virtuals
|
||||
whose meanings (score? feasible? execute? complete?) are unestablished. This is the heart of the
|
||||
strategic AI and it is the single biggest remaining block.
|
||||
2. **The task *selection* loop is unfound.** I did not locate where tasks are created, scored or ranked.
|
||||
The `AIRulesDB::SelectRules_T` family (0x00695140, 0x0069cc30, 0x0069cdb0) is the **ship-design** rule
|
||||
selector, not the task selector.
|
||||
3. **The 30 Process-Turn phases are addresses, not names.** Eleven of them reach a client order method, so
|
||||
they are the ones worth naming first; the other nineteen are unclassified.
|
||||
4. **The `Broadcast` → queued-callback hop is inferred** (§2), read only to its second loop iteration.
|
||||
5. **Order-method → `TurnCommands` list is unmapped** (§4.2). Mechanical, one pass.
|
||||
6. **`g_CurrentClientIndex` is not instrumented.** Who sets it, and whether the AI clients are stepped in
|
||||
player order or some other order, is open — and it *matters*, because `ApplyTurnCommands` walks
|
||||
`S+0x174` in vector order and `ModCount` is a count, not a set.
|
||||
7. **`AIProcessMinTime`** (a `GameOptions` key read by `CreateGame` 0x00888e80) suggests a think-time
|
||||
throttle, and `Game::AIProcessingDialog` suggests the AI can be slow enough to need a progress dialog.
|
||||
Whether an AI turn can be *deferred across frames* — which would break the clean "all AI orders are in
|
||||
before the human's End Turn" ordering — is **not settled**. I saw no evidence of deferral in
|
||||
`OnAIPacket`, which runs straight through, but I did not look for a per-frame driver.
|
||||
8. **Nothing here has run under an instrument.** Every claim is static.
|
||||
|
||||
---
|
||||
|
||||
## 8. Predictions (rule 2) — written before any run
|
||||
|
||||
### P1 — the AI seed is literally zero, in every game
|
||||
|
||||
`RunAI` logs *"RunAI: Creating AI client for %s using %08x for random seed."* at log level 2.
|
||||
**Prediction: the value is `00000000` for every AI player, in every game, on every run.**
|
||||
|
||||
*Test (cheap, VM140, no build):* start a new game with ≥2 AI players, capture the log, grep `RunAI:`.
|
||||
*Falsified if:* any nonzero value appears, or values differ between the AI players in one game, or between
|
||||
two runs. Each failure mode is diagnostic: a nonzero-but-constant value means `g_GlobalRNG` is seeded
|
||||
somewhere I did not find; per-player *different* values mean it is a live generator and my all-zero
|
||||
fixed-point argument is wrong; run-to-run different values mean it is time- or address-seeded and Rung B is
|
||||
much harder.
|
||||
|
||||
### P2 — the AI's turn is deterministic across a save/load, or Rung B is unreachable from a save alone
|
||||
|
||||
Load one corpus save twice **in two fresh processes**, press End Turn, and compare the two resulting
|
||||
autosaves.
|
||||
**Prediction:** they are byte-identical, including `ModCount` and `Summary.Checksum`.
|
||||
|
||||
*Why it should hold:* the AI's generator is reseeded to the same constant (P1) and its stream is rewound
|
||||
identically both times, so both runs draw the same numbers from the same position.
|
||||
*Falsified if:* `ModCount` differs between the two runs. That would mean the AI's decisions depend on
|
||||
something outside the save **and** outside a constant seed — and it would mean the milestone as stated in
|
||||
`backlog.md` §2 is unreachable, and Rung B must be restated as *"reproduces a recorded command stream"*.
|
||||
This is the **highest-value ten minutes available on VM140 right now**: it decides whether §4.1 of the
|
||||
backlog is a real target or a mirage, and it needs no engine work at all.
|
||||
|
||||
### P3 — `ModCount` on the reference pair
|
||||
|
||||
Lane A2 measured **10 non-driver `ModCount` bumps** across `turn1-state.sav`'s turn with an empty human
|
||||
command block. Under this lane's model those ten are exactly the commands the AI players emitted, one bump
|
||||
per command handled by `ApplyTurnCommands`.
|
||||
**Prediction:** with lane A2's `ModCount` watchpoint armed, all ten bumps land inside
|
||||
`ApplyTurnCommands` 0x007b18b0's closure (the 20 handlers plus the 6 inlined in
|
||||
`ApplyTurnCommandBatch` 0x0088f9b0), **none** inside `ProcessTurn`, **none** before `SNMUpdate`, and the
|
||||
count of `AI Process Turn` log lines in that turn equals the number of AI players.
|
||||
*Falsified if:* any bump precedes `SNMUpdate` (the AI would be issuing orders straight to the server,
|
||||
not through `TurnCommands`, and §0.2 is wrong), or if the ten do not partition across the AI players'
|
||||
blocks.
|
||||
|
||||
### P4 — four order methods can only ever be exercised by an AI
|
||||
|
||||
Of the 21 client order methods, 11 are called directly from the AI module and 7 of those 11 are also
|
||||
UI-reachable. **Prediction:** the four that are AI-reachable but have no UI caller — **0x00762ca0,
|
||||
0x00763670, 0x00763720, 0x00763990** (the middle two are also reached from 0x007f10d0, which I did not
|
||||
identify) — produce `TurnCommands` list entries that **no human-driven save can ever contain**, and are
|
||||
therefore permanently `hypothesis` rows in lane Q's table unless an AI game is captured.
|
||||
*Test:* drive every UI order on VM140, save, and diff the populated list set against lane Q's 27. Any list
|
||||
that never populates from the UI but does populate in an AI game is one of these.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
188
ghidra/addresses.d/lane-ai1.json
Normal file
188
ghidra/addresses.d/lane-ai1.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue