sots-re/findings/subsystems/ai-task-system.md
alex 232397aa12 lane AI2: the AI's task selection loop, the priority table, and the order-method -> TurnCommands map
The strategic AI does not search or score. Once a turn (Process Turn phase 20, 0x006cf630) it
rebuilds a candidate task list -- which families it builds at all is a switch on the player's
SPECIES, four arms, and the NPC arm builds nothing -- sorts it by a per-task-type priority, and
walks it twice calling each task's Execute(agent, pass) with pass 0 then 1.

Closed from lane AI1's open list:
  * the selection loop (its item 2) and the whole ordering policy: a 33-entry priority table at
    0x00691f00 plus five named overrides;
  * IAITask's unnamed pure virtuals (item 1) -- seven, not eight: GetTypeId, GetTargetA/B,
    Execute, IsFinished, GetTypeName, Describe;
  * the order-method -> list mapping (item 5): all 27 of lane Q's lists and all six prologue
    gates now have a named producer, and list 14 -- lane Q's "observed but not understood" --
    is the AI's fleet order, two elements per fleet;
  * g_CurrentClientIndex (item 6): a stack pointer with exactly two writers, pushed around the
    whole AI turn by StrategyAIAgent::OnEvent;
  * the think-time throttle (item 7): AIProcessMinTime is a trailing Sleep, not a compute
    budget. Every pending AI player's turn runs back-to-back inside one Update. The clean
    "all AI orders in before the human's End Turn" ordering HOLDS, and Rung B is not at risk;
  * Broadcast -> OnAIPacket, read to the call -- AI1's one inferred hop is now verified.

Corrections: 31 concrete task classes, not 34; 26 order methods, not 21; seven pure virtuals,
not eight. lane-ai1.json's Broadcast and g_CurrentClientIndex entries upgraded in place.

Open and said so: slots 11/12/13 unnamed, the nine goal tasks' bodies unread, the two-pass
meaning inferred, the StrategyApp pending-AI enqueue site not found, nothing run under an
instrument. Four predictions in section 9, P1 being a ModCount prediction.

ghidra/addresses.d/lane-ai2.json: 25 entries, 1031 -> 1056, no duplicates.
2026-09-08 14:43:41 -04:00

565 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The strategic AI's task system — selection, ranking, execution, and the order it emits
Lane AI2, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
Static only; VM140 is held by lane W2.
Continues `ai-turn-logic.md` (lane AI1), which established the spine — the AI is a `StrategyClient` whose
agent runs on client event `SEResumePlaying` (0x26), and its orders reach the save through
`StrategyClient+0x160`. AI1 read ~3% of the 1,629-function module and listed seven things it did not do.
**This lane closes five of them and corrects three published claims.**
**Method.** Same as AI1's, deliberately: every control-flow claim is read from the instruction stream with
`objdump -b binary -m i386 -M intel` over the raw image, swept to the **next function start** (rule 17),
with call targets from `dumps/functions.json`, jump tables and string immediates read out of the image at
real instruction boundaries, and class identities from `dumps/rtti.json` / `dumps/vtables.json` via
`tools/vtable_map.py`. The decompiler was not used. Where a claim is inferred it says so.
Addresses: `ghidra/addresses.d/lane-ai2.json` (25 entries; `tools/gen_addresses.py` to a scratch path,
1,031 → **1,056**, no duplicate names). Two entries in `lane-ai1.json` are upgraded in place (§8).
---
## 0. The two answers, up front
**1. The task selection loop is `0x006cf630` — phase 20 of the AI Process Turn body.**
It is not a scorer and there is no search. It is:
```
PruneTasks(agent) ; 0x006b3640, drops finished tasks
switch (player->Species) ; player+0x5c, 7-arm table at 0x006cf880
<a fixed, source-ordered list of per-family task CREATORS>
PruneTasks(agent)
sort agent->TaskList DESCENDING by task->GetPriority() ; 0x006bf9c0, std::list::sort (STABLE)
for pass in (0, 1):
for task in agent->TaskList: ; 0x006b3320
erase task from agent->+0x2e8
push task on agent->+0x12c ; a task CALL STACK
task->Execute(agent, pass) ; vtable slot 5
if back() is still task: pop
PruneTasks(agent)
```
`GetPriority()` is, for 26 of the 31 concrete task classes, a **constant lookup on the task's type id**
through `0x00691f00` — 33 arms, each a single `mov eax,imm32; ret`. So the AI's whole strategic ordering
policy is a 33-entry integer table plus five overrides, and it is reproduced in full in §3.
**2. The order-method → `TurnCommands` list mapping is done, and it says lane Q's `list 14` is the AI's
fleet order.** Twenty-four of the thirty-one task classes' `Execute` bodies reach client order method
`0x007634d0`, which appends to **list 14** — the list lane Q observed exactly once and marked
*"observed but not understood"*. It is now understood, and it comes with a falsifiable prediction (§9, P1):
the AI issues **two** list-14 elements per fleet, `{fleetId, 0, true}` and `{fleetId, 1, true}`, where the
UI issues only the first.
The mapping also found **five order methods AI1's 21-row table missed**, two of which matter a lot:
`0x00763270` → **list 5** (system rates) and `0x00769640` → **list 7** (colonize). Both are AI-called.
---
## 1. `0x006cf630` — the selection loop, instruction by instruction
`this = ecx = the StrategyAIAgent`. Declared size 589; real span to the next function start is 624 (rule 17
again: the jump table at `0x006cf880` lives in the last 32 bytes and Ghidra's size cuts it off).
| step | VA | what |
|---|---|---|
| 1 | `0x006cf63b` | `0x006b34f0(agent, &agent->+0x2f8)` — per-fleet world-model refresh over `client->+0x60..+0x64` |
| 2 | `0x006cf641` | **`PruneTasks`** `0x006b3640(agent)` |
| 3 | `0x006cf65e` | `jmp [Species*4 + 0x006cf880]` — **the creation switch** |
| 4 | `0x006cf7d6` | `if (agent->+0x8) agent->+0x8->vt[1](agent)` |
| 5 | `0x006cf7db` | **`PruneTasks`** again |
| 6 | `0x006cf7f8` | **`sort`** `0x006bf9c0(&agent->+0x31c, Species)` |
| 7 | `0x006cf808`, `0x006cf812` | **`RunTaskList`** `0x006b3320(agent, &agent->+0x31c, 0, &agent->+0x2e8)`, then again with `1` |
| 8 | `0x006cf818` | **`PruneTasks`** a third time |
| 9 | `0x006cf83a/41` | if `player->plcy` ∈ {1,2}: `0x006cf4c0`, then `0x006cf590(agent)` |
| 10 | `0x006cf848` | `0x006a8eb0(agent)` → client order method `0x00763a20` (list 19/20) |
| 11 | `0x006cf862` | if `agent->+0x124`: `cl_SetResearchRate(*(float*)0x009e2ea0)`, then clear the flag |
| 12 | `0x006cf871` | `agent->+0x128 = 0` |
### 1.1 The switch is on **species**, and that is not a guess
`client->+0x150` is the `ClientPlayer`; `+0x5c` on the player is the save field **`Species`** (0 Human …
6 Morrigi — `findings/objects/struct-recovery.md` §, the row `0x5c | -0x344 | int | Species`). The 7-entry
table at `0x006cf880` reads `[0x6cf665, 0x6cf6f8, 0x6cf665, 0x6cf665, 0x6cf7c9, 0x6cf75d, 0x6cf665]`, so
there are **four distinct arms**:
| species | arm | creators, **in call order** (the sort is stable, so this order is part of the answer) |
|---|---|---|
| 0, 2, 3, 6 | `0x006cf665` | Steamroll · Colonize+ColonizeAt · *[plcy≠0]* DefendGate*(Hiver-only)*+DefendColony · KillEasterEgg · Invade · Explore+ExploreInForce · ColonizeGoal/EscortGateInvadeGoal/Invade/InvadeGoal · InterceptEnemy · Mining+MiningReturn · AdvanceIdleShips · Raid · StockFreighters · AttackBlockade · BuildStations · BuildPoliceShips · BuildDeepScanShips · `0x006a21c0` |
| **1** | `0x006cf6f8` | Steamroll · Colonize+ColonizeAt · *[plcy≠0]* DefendGate+DefendColony · **EscortGateInvade+InvadeGate** · **DeployGateAt+EscortGate** · the goal group · InterceptEnemy · Mining · AdvanceIdleShips · Raid · StockFreighters · AttackBlockade · BuildStations · BuildPoliceShips · BuildDeepScanShips · `0x006a21c0` |
| **4** | `0x006cf7c9` | **nothing at all** — the arm jumps straight past the creation block |
| **5** | `0x006cf75d` | Steamroll · **NodeBore** · Colonize+ColonizeAt · *[plcy≠0]* Defend pair · KillEasterEgg · Invade · ExploreInForce *(only)* · the goal group · InterceptEnemy · Mining · AdvanceIdleShips · Raid · StockFreighters · AttackBlockade · BuildStations · BuildDeepScanShips · `0x006a21c0` |
Two independent cross-checks that the species reading is right, not a coincidence of a 0..6 range:
- **arm 1 is the only arm that builds gates** — `AITDeployGateAt`, `AITEscortGate`, `AITEscortGateInvade`,
`AITInvadeGate` — and the Hiver are the gate species;
- **arm 5 is the only arm that creates `AITNodeBore`**, and the corpus save built for species 5 is
`zuul-turn5-species5.sav`; node-boring is the Zuul mechanic.
Species **4 creates no tasks whatsoever** — and species 4 is the one the engine already calls **`NPC`**
(`sots-engine/src/game/sim/species.h`: `Human=0, Hiver=1, Tarkas=2, Liir=3, NPC=4, Zuul=5, Morrigi=6`,
established by the economy work, where the NPC race gets no imperial growth). So the arm that builds
nothing is the arm for the race that has no empire to run. That is a **third** independent cross-check on
the species reading, and it was found in the engine, not in the binary.
### 1.2 The `plcy` gate on the defensive tasks
Inside every arm, the two defensive creators sit behind the same inlined predicate (it also exists as a
standalone function, `0x006ac510`, which arms 1 and 5 call instead of inlining):
```
eax = player->+0x2d8 ; plcy
if (eax == 0) skip both
if (player->Species == 1) create AITDefendGateIncoming ; Hiver only
create AITDefendColonyIncoming
```
`player+0x2d8` is the save field **`plcy`** (`struct-recovery.md`, `0x2d8 | -0xc8 | int | plcy`). So a
save-visible integer decides whether the AI generates defence tasks at all — that is a cheap experiment
(§9, P3) and it is the kind of input Rung B has to get right.
---
## 2. `Game::IAITask` — the vtable, named
`Game::IAITask` vftable `0x009fa354`, **14 slots**. All 31 concrete `AIT*` vtables were dumped and compared
slot-for-slot; the table below is what that comparison plus the bodies say.
**Correction to AI1 §7.1: there are seven pure virtuals, not eight.** Slots 1, 2, 3, 5, 6, 8, 9 are
`purecall` in the interface. Slot 4 is not — its interface body `0x00496e00` is `xor eax,eax; ret`.
| slot | name | signature | evidence |
|---|---|---|---|
| 0 | `~IAITask` | scalar-deleting dtor, `ret 4` | interface body `0x00546520` reinstalls `0x009fa354` then `operator delete` |
| **1** | **`GetTypeId`** | `int (void)` **pure** | all 31 bodies are one `mov eax,imm32; ret`, values 0..0x20 |
| **2** | `GetTargetA` | `void* (void)` **pure** | 3 distinct bodies: `return this->+0x8` / `return 0` / `return this->+0xc` |
| **3** | `GetTargetB` | `void* (void)` **pure** | 4 distinct: `return this->+0xc` / `+0x8` / `+0x10` / `0` |
| 4 | `GetTargetC` | `void* (void)` | default `return 0`; only `AITMiningReturn` overrides (`return this->+0x10`) |
| **5** | **`Execute`** | `void (StrategyAIAgent*, int pass)`, `ret 8`, **pure** | dispatched at `0x006b348d`; 27 distinct bodies, 48–288+ B |
| **6** | **`IsFinished`** | `bool (StrategyAIAgent*)`, `ret 4`, **pure** | called only from `PruneTasks`; true ⇒ unlink + destroy |
| 7 | `OnObjectDestroyed` | `void (void* obj)`, `ret 4` | default is a bare `ret 4`; the common override nulls `this->+0x8`/`+0xc` when they name the dead object |
| **8** | **`GetTypeName`** | `const char* (void)` **pure** | all 31 return their own class-name literal |
| **9** | **`Describe`** | `void (void)` **pure** | every body is `Log("<TypeName>: %s -> %s\n", Name(vt2()), Name(vt3()))` — which is what pins slots 2 and 3 |
| 10 | **`GetPriority`** | `int (void)` | §3 — the sort key |
| 11 | ? | `bool (void)` | default `return true`; `AITInvade`/`AITInvadeGoal` return `this->+0x39` |
| 12 | ? | `bool (void)` | default `return false`; five classes return `true` |
| 13 | ? | `int (void)` | default `15`; `AITDefend{Colony,Gate}Incoming` return `0x7fffffff` |
Slots 11–13 are **not identified**. Slot 13's `15` vs `INT_MAX` split for exactly the two "incoming attack"
tasks reads like a lifetime or a hop budget, but I have not found the consumer and I am not going to name it.
### 2.1 The task-type enum, complete
Pairing slot 1 with slot 8 across the 31 vtables gives a gapless enum with two holes:
| id | class | id | class | id | class |
|---|---|---|---|---|---|
| 0 | `AITSteamroll` | 0xb | `AITInvadeGate` | 0x16 | `AITAdvanceIdleShips` |
| 1 | `AITExplore` | 0xc | `AITInvadeGoal` | 0x17 | `AITStockFreighters` |
| 2 | `AITExploreInForce` | **0xd** | **no class** | 0x18 | `AITRespondAttackSystem` |
| 3 | `AITEscortGate` | 0xe | `AITDefendColonyIncoming` | 0x19 | `AITRespondDefendSystem` |
| 4 | `AITEscortGateInvade` | **0xf** | **no class** | 0x1a | `AITNodeBore` |
| 5 | `AITEscortGateInvadeGoal` | 0x10 | `AITDefendGateIncoming` | 0x1b | `AITBuildStations` |
| 6 | `AITDeployGateAt` | 0x11 | `AITKillEasterEgg` | 0x1c | `AITBuildPoliceShips` |
| 7 | `AITColonize` | 0x12 | `AITInterceptEnemy` | 0x1d | `AITBuildDeepScanShips` |
| 8 | `AITColonizeGoal` | 0x13 | `AITMining` | 0x1e | `AITRaid` |
| 9 | `AITColonizeAt` | 0x14 | `AITMiningReturn` | 0x1f | `AITRetrieveArtifact` |
| 0xa | `AITInvade` | 0x15 | `AITAttackBlockade` | 0x20 | `AITReturnArtifact` |
Ids `0xd` and `0xf` have **priority-table entries** (200 and 300) but no surviving RTTI class — two tasks
that were cut. Their priorities bracket `AITMining`(350) and `AITAttackBlockade`(100), i.e. they were
low-priority economic or reactive tasks.
**Correction to AI1 §6.1: 31 concrete task classes, not 34.** The 34 counted `Game::IAITask` plus
`Game::IAIAntiquariansTask` plus the two artifact tasks separately; the two artifact tasks *are* two of the
31, and the two interfaces are interfaces.
---
## 3. The priority table — the AI's entire strategic ordering policy
`0x00691f00(int typeId)`: `if ((unsigned)id > 0x20) return 0; jmp [id*4 + 0x00691ffc];` — 33 arms, each one
`mov eax,imm32; ret`. Read in descending priority, this **is** the order the AI acts in:
| prio | task | prio | task | prio | task |
|---:|---|---:|---|---:|---|
| 1400 | `AITDeployGateAt` | 950 | `AITEscortGateInvadeGoal` | 400 | `AITEscortGateInvade` |
| 1300 | `AITColonizeAt` | 930 | `AITInvadeGoal` | 399 | `AITRaid` |
| 1275 | `AITNodeBore` | 910 | `AITBuildStations` | 375 | `AITMiningReturn` |
| **1261** | `AITReturnArtifact` * | 900 | `AITColonize` | 350 | `AITMining` |
| **1260** | `AITRetrieveArtifact` * | 850 | `AITInterceptEnemy` | 300 | *(id 0xf, no class)* |
| 1250 | `AITSteamroll` | 800 | `AITKillEasterEgg` | 200 | *(id 0xd, no class)* |
| 1200 | `AITDefendGateIncoming` | 700 | `AITEscortGate` | 100 | `AITAttackBlockade` |
| 1100 | `AITDefendColonyIncoming` | 600 | `AITExplore` | 75 | `AITBuildPoliceShips` |
| 1000 | `AITInvadeGate` | 550 | `AITExploreInForce` | 60 | `AITBuildDeepScanShips` |
| 990 | `AITRespondDefendSystem` | 500 | `AITInvade` | 50 | `AITStockFreighters` |
| 980 | `AITRespondAttackSystem` | | | 0 | `AITAdvanceIdleShips` |
| 970 | `AITColonizeGoal` | | | | |
\* The two artifact tasks **override slot 10** with fixed `0x4ec`/`0x4ed`, so their table entries (1 and 2)
are dead code. Recorded because a naive reimplementation that only ports the table would rank them last
instead of fourth and fifth.
The other four overrides:
- `AITInvade` `0x00683670` and `AITEscortGateInvade` `0x006835e0`: `if (!(this->+0x4 & 1)) return
*(int*)0x00a1795c` / `0x00a17960` — a **tunable** priority for the "not yet committed" state, held in
`.data`, i.e. probably CSV-driven. Whichever of the AI CSVs writes those two dwords is worth finding;
I did not.
- `AITAttackBlockade` `0x00685600`: walks a 0x0c-stride vector at `this->+0x8->+0x1cc` looking for a
related task, filtering it by `GetTypeId()` against 1, 2, 7, 0x11, … — i.e. its priority depends on what
*other* tasks exist. The only priority in the system that is not a constant.
- `AITInvadeGoal` reaches the default through the thunk `0x00682650`.
### 3.1 The comparison, and why the sort's stability matters
`0x006bf9c0` is MSVC 7.1 `std::list<T>::sort` (the 26-bin binlist, the `_Bin == 25` overflow arm, the merge
at `0x006a9850`). The comparison is **inlined into the merge** at `0x006a9879`:
```
ecx = A; a = A->vt[0x28]() ; slot 10
ecx = B; b = B->vt[0x28]()
if (a > b) splice A before B ; cmp/jle -- strictly greater, so equal keys never move
```
Both calls are `__thiscall` with no stack arguments, which is what fixes slot 10's signature.
**The predicate object is a 4-byte functor carrying `player->Species`, and the comparison never reads it.**
`0x006bf9c0` takes the species as its one argument and passes it into every `merge` call, and `merge`
ignores it (`ret 8`, `[ebp+0xc]` never loaded). Either it is vestigial or it feeds a comparator this build
does not use. Worth flagging because a reimplementation that *did* use it would diverge and the divergence
would be invisible until two tasks of different types tied — which they cannot, since the key is per-type.
Consequence for Rung B: **ties are broken by creation order**, so §1.1's per-arm creator order and the
insertion order inside each creator are load-bearing, not incidental.
---
## 4. The order-method → `TurnCommands` list mapping
Method: an image-wide scan for `lea ecx,[r32+0x160]` (the accumulating `TurnCommands`, AI1 §4.1) finds
**94 sites in 76 functions**; 43 of them are in the client band. For each, the call made while `ecx` still
holds `this+0x160` is the adder; each adder's first `this`-relative access resolves to one of lane Q's 27
list offsets (`+0x70 + (N-1)*0x0c`) or one of the six prologue gates.
**AI1's 21-row table is incomplete: there are 26 order methods, plus 3 whole-object operations.** The five
it missed are `0x00761480`, `0x007614c0`, `0x00761500`, `0x00761540`, `0x00763270`, `0x00769640`,
`0x00769920`, `0x0076b020`, `0x0076b130`, `0x00773510`, `0x00773600`, `0x00773700`, `0x00773800`,
`0x00773dd0`, `0x00774fc0`, `0x0077fb40`, `0x00781200`, `0x007813b0` — eighteen more sites, of which the
ones that matter are marked below.
| order method | adder | writes | AI callers (module fns) | other callers |
|---|---|---|---|---|
| `0x00762ca0` | `0x0080f360` | **gate `@0x3c`** (three floats) | `0x006c57e0` | — |
| `0x00762f70` | `0x00884a20` | **list 1** (looks up list 3) | `0x006b3ff0` | `0x005d7dc0` |
| `0x00762fd0` | `0x00842840` | **list 3** build orders | `0x006b3bc0` | `0x00656540` |
| `0x00763110` / `0x00763180` | `0x0080f2f0` | gate `@0x14` research target | — | `0x00578f60`, `0x005d20a0`, `0x005d2140` |
| `0x007631f0` | `0x0080f310` | gate `@0x20` research boost | — | `0x005d0f10` |
| **`0x00763270`** | `0x008490b0` | **list 5** system rates | **`0x0069dd80`** | `0x00579110`, +6 |
| `0x00763320` | `0x00868d20` | **lists 26/27** | — | `0x007ee880` |
| `0x00763380` | `0x008490d0` | **list 24** | — | `0x007e40c0` |
| `0x007633f0` | `0x0080f2d0` | gate `@0x0c` research rate | via `cl_SetResearchRate` | `0x005d2ac0`, `0x005eedb0` |
| `0x00763450` | `0x00842950` | **list 11** | — | `0x005e2350`, `0x005e8940` |
| **`0x007634d0`** | `0x00842a00` | **list 14** | **`0x006987e0`** | `0x005e6fa0`, `0x00763e50`, `0x00777480` |
| `0x00763570` | `0x00842a90` | **list 15** | — | `0x005e6fa0` |
| `0x007635f0` | `0x00842b10` | **list 16** | `0x0068e670` *(= `AITRaid::Execute`)* | `0x005e6fa0` |
| `0x00763670` | `0x00842b90` | **list 17** | `0x006b6a60` | `0x007f10d0` |
| `0x00763720` | `0x00842c10` | **list 18** | `0x006b6b60` | `0x007f10d0` |
| `0x00763910` / `0x00763990` | `0x0080f330` | **gate `@0x2c`** | `0x006b41b0`, `0x006c3510`, `0x006df260` | `0x00607d10` |
| `0x00763a20` | `0x00842c90` | **lists 19/20** (add) | `0x006a8eb0` | `0x005cbf70` |
| `0x00763a80` | `0x00842d40` | **lists 19/20** (erase) | — | `0x005cbf70` |
| `0x00763eb0` / `0x00763f60` | `0x00842900` | **list 9** | `0x006b42c0` | `0x00615070`, `0x0076fb30` |
| `0x00761480` | `0x00884b00` | **list 21** (+ 1/2/3/4/22) | — | `0x007857a0` |
| `0x007614c0` | `0x008783b0` | **list 21** | — | `0x0076f6c0` |
| `0x00761500` / `0x00773600` / `0x00773700` | `0x0087d860` | **list 23** | `0x00694a50` | `0x00776e10`, `0x005f6fe0`, `0x00803c70` |
| `0x00761540` | `0x00871d50` | **list 8** fleet moves | — | `0x00768640` |
| `0x00765440` | `0x00865340` | **list 22** | — | `0x005d7dc0` |
| **`0x00769640`** | `0x00842890` | **list 7** colonize | **`0x006af790`** | `0x00578fc0`..`0x005790e0` (7 `cl_*`), +3 |
| `0x00769920` | `0x00871dd0` | **list 10** | — | 4 UI |
| `0x0076b020` | `0x00871ea0` | **list 26** | — | `0x007ee880` |
| `0x0076b130` | `0x00871cc0` | **list 6** | — | `0x006196c0` |
| `0x00773510` | `0x008827c0` | **list 25** | — | 4 UI |
| `0x00773800` | `0x00871e20` | **list 13** | — | `0x0067dc30` |
| `0x00773dd0` | `0x008342e0` | **list 5** | — | `0x00783ee0` |
| `0x00774fc0` | `0x0087af20` | **list 8** (+12..16 lookups) | — | *(no direct caller)* |
| `0x0077fb40` | `0x0087d7d0` | **list 12** | `0x006c5540` | `0x00670150`, `0x00780050` |
| `0x00781200` | `0x00893100` | **list 1** | `0x006cd580` | `0x005d7dc0` |
| `0x007813b0` | `0x0081b020` | **gate `@0x6c`** `CivilianRatios` | — | `0x007f1820` |
| `0x0076f560` / `0x00782330` / `0x00783e80` | `0x00893f00` / `0x00781d30` / `0x007832b0` | all 27 — `Clear` / copy / `operator=` | — | — |
Every one of lane Q's 27 lists now has at least one named producer. **All six prologue gates are attributed**,
including the three no save has ever set: `@0x2c` (`0x00763910`/`0x00763990`, both AI-reachable), `@0x3c`
(`0x00762ca0`, **AI-only** — no UI caller), and `@0x6c` (`0x007813b0`, UI-only, `0x007f1820`).
### 4.1 List 14 is the AI's fleet order — lane Q's open row, closed
`ClientOrder_FleetTask 0x007634d0(void* fleetObj, int mode, bool flag)`, `ret 0xc`:
```
if (this->+0x15c) return false; ; turn already ended
rec = { i32 fleetId = fleetObj->+4, i32 mode, bool flag } ; 12 bytes on the stack
if (!0x00821cf0(this->+0x148 /*playerId*/, &rec)) return false; ; local apply/validate
TurnCommands(this+0x160).AddList14(&rec) ; 0x00842a00, list at +0x10c
```
`0x00842a00` scans list 14 for a node with `node->+0x8 == rec.fleetId` **and** `node->+0xc == rec.mode`,
updates in place if found, otherwise `push_back`. Node payload `{+0x8, +0xc, +0x10}` is exactly lane Q's
observed element record `{i32, i32, bool}` and lane O's observed value `{1456, 0, true}`.
The AI reaches it through `AI_IssueFleetTask 0x006987e0`, which builds a route through the `cl_*` façade
(`0x00578cd0` set destination, `0x0057b4a0` begin, `0x0057aa50` append hop, `0x0057b4d0` end,
`0x008f4b30` resolve) and then calls `0x007634d0` **twice**: `(obj, 0, true)` and `(obj, 1, true)`.
Because the adder keys on `(fleetId, mode)`, those are **two distinct list-14 elements**.
`0x006987e0` has three callers — `0x006b76a0`, `0x006c15e0`, `0x006c16c0` — and a depth-4 direct-call
closure over the AI band shows **24 of the 31 task classes' `Execute` bodies reach it**. It is the busiest
AI→`TurnCommands` edge in the module and the first thing `game/ai` will have to emit.
### 4.2 Which tasks reach which order method
Depth-4 direct-call closure from each class's slot-5 body, restricted to the AI band `0x680000..0x6e0000`:
| task (by priority) | order reached |
|---|---|
| `AITBuildStations` 910 | **list 3 build** @d3, list 1 @d4, list 14 @d4 |
| `AITRaid` 399 | **list 16 @d1** (`Execute` = `0x0068e670` calls `0x007635f0` directly) |
| `AITEscortGate` 700 | `cl_RandRange` @d2, list 14 @d4 |
| 21 others | list 14 only |
| `AITNodeBore`, `AITColonize`, `AITColonizeGoal`, `AITInvade`, `AITInvadeGoal`, `AITEscortGateInvade`, `AITEscortGateInvadeGoal`, `AITBuildPoliceShips`, `AITBuildDeepScanShips` | **no order method within depth 4** |
The nine with no reachable order method are the interesting ones. `AITColonize`/`AITColonizeGoal` share
`Execute` `0x0068b400` (48 bytes) and `AITInvade`/`AITInvadeGoal` share `0x0068d7a0` (160 bytes) — these are
**goal** tasks whose job is to spawn sub-tasks onto the list, not to emit orders. That is consistent with
`RunTaskList`'s push/pop bracket on `agent->+0x12c` and with the two-pass structure: pass 0 plans, pass 1
executes. **I did not read those bodies to confirm it**, and the two-pass reading is inference, not
instruction-verified.
---
## 5. `Broadcast` → `OnAIPacket` — AI1's inferred hop, now read to the `call`
AI1 read `StrategyAIContext::Broadcast 0x006b3840` to `0x006b3930` and asked a next lane to finish it.
Finished. After the listener-tree walk (`node->+0x10 ? listener->vt[3](pkt)`, `_Isnil` at `node+0x15`), the
queued arm is:
```
006b38ac if (context->+0x68 == 0) return
... three deque reads through 0x0069e510 / 0x006a4ee0 on the ring at context+0x58 ...
006b38eb if (*entry0 == 0) return
006b391f edi = entry1
006b3951 edx = entry2
006b3954 eax = edi->+4 ; the receiver stored with the callback
006b3957 ecx = *edx ; the callback function pointer
006b3959 push eax ; arg1 = receiver
006b395a push ebx ; arg0 = the packet
006b395b call ecx
006b395d add esp,8 ; __cdecl, two args
```
and the thunk `0x006d0ab0` that `OnStrategyEvent` registers is
```
006d0ab3 eax = [ebp+8] ; the packet
006d0ab6 ecx = [ebp+0xc] ; the receiver -> this
006d0ab9 push eax
006d0aba call 0x006cf8a0 ; OnAIPacket
```
The push order matches exactly. **`Broadcast` does invoke the queued callback with `(packet, agent)` and
that reaches `OnAIPacket`** — verified, not inferred. `lane-ai1.json`'s `StrategyAIContext_Broadcast` is
upgraded `mapped` → `verified`.
Honest boundary: I read the **final call** and the thunk. I did **not** model the deque iterator arithmetic
in `0x0069e510`/`0x006a4ee0`, so "exactly one queued callback is invoked per `Broadcast`" is *not* claimed —
the three reads look like one 3-dword record taken from the front, but there is no loop back-edge in the
range, which is the part I am confident about.
---
## 6. `g_CurrentClientIndex` is a stack pointer, and that closes AI1's open item 6
An image-wide absolute-reference scan for `0x00ae4808` finds **42 functions**. Forty of them only read
`[idx*4 + 0x00ae47e4]`. The two that write it are two-line functions:
```
PushCurrentClient 0x00578020(c): g_StrategyClients[idx + 1] = c; ++idx;
PopCurrentClient 0x00578040(): --idx;
```
(the store is literally `mov [eax*4+0x00ae47e8],ecx` with `eax` = the pre-increment index, i.e. base
`0x00ae47e4` + one slot). So `0x00ae47e4` is a **stack of client scopes** and `0x00ae4808` is its depth.
Eighteen functions push/pop it, and the one that matters is
```
StrategyAIAgent::OnEvent 0x006d0ad0:
PushCurrentClient(this->+0x10); ; this AI's StrategyClient
StrategyAIContext::OnStrategyEvent(...); ; <-- the ENTIRE AI turn happens in here
PopCurrentClient();
```
That is the mechanism behind AI1 §4.3 and §5: every `cl_*` call the AI makes — `cl_Chance`,
`cl_RandRange`, `cl_SetResearchRate`, `cl_EndTurn` — resolves to *that* client and *that* client's RNG at
`+0x134`, with no explicit plumbing anywhere in the 1,629-function module. `lane-ai1.json`'s
`g_CurrentClientIndex` entry is corrected and upgraded.
### 6.1 The order the AI players are stepped
Not this global. It is `StrategyApp::RunPendingAITurns 0x00838c60` (§7), which walks a `std::vector<int>` of
player net ids at `StrategyApp+0x1c..+0x20` **in index order** and raises `SEResumePlaying` on each matching
client. That vector's fill site was **not found**: neither an absolute-reference scan for the singleton
`0x00b29f98` (355 functions load it into a register; 690 reference sites) nor an enumeration of the 33
methods called on it located an enqueue. Open, and it is the last thing standing between here and a
statement about the order AI blocks reach `StorePlayerTurnCommands`.
---
## 7. The think-time throttle — settled, and it does not threaten Rung B
`StrategyNetworkClient::Update 0x007842b0` calls `0x00838c60` on the `StrategyApp` singleton **every frame**.
That function is:
```
if (this->+0x1c == this->+0x20) return; ; no AI turns pending
t0 = clock(); ; 0x008d0b70
if (g_AIProcessingDialog @0x00b1149c) Show(dialog); ; 0x005e3a70
for (i = 0; i < size(+0x1c..+0x20); ++i) ; INDEX ORDER, no early exit
find client in +0xc..+0x10 with client->+0x148 == id[i]
dialog.SetPlayer(client->+0x150) ; 0x005e23a0
StrategyClient::RaiseEvent(client, 0x26, &ev) ; 0x00783ee0 -- runs that AI's whole turn
clear the pending vector
remaining = this->+0x2c - (clock() - t0);
if (remaining > 0) Sleep((int)(remaining * 1000)); ; 0x009dd048
Hide(dialog); ; 0x005dcba0
```
`this->+0x2c` is `AIProcessMinTime`, read once in `StrategyApp::CreateGame 0x00888e80+0x90` from the
`GameOptions` key (string `0x00a32e30`), string-to-long'd, `fild`ed, divided by the double `1000.0` at
`0x009e22f8` and clamped at zero — so it is **seconds**, and its only consumer is that `Sleep`.
**So: `AIProcessMinTime` is a trailing minimum-duration `Sleep` to stop the "AI is thinking" dialog from
flashing. It is not a compute budget. It cannot change a decision, and an AI turn cannot be deferred across
frames — every pending AI player's entire turn runs back-to-back inside one `Update`.**
AI1's clean ordering — *all AI orders are in before the human's End Turn* — therefore **holds**, and lane W2's
byte-identical result across two processes is exactly what a `Sleep`-only throttle predicts. Rung B is not
at risk from this. `Game::AIProcessingDialog` (vftable `0x00a0a13c`, derives `Mars::Panel`, constructed at
`0x005e2fd0`) is a progress panel and nothing more.
---
## 8. Corrections to earlier findings
- **`ai-turn-logic.md` §7.1 — "`IAITask`'s 14-slot vtable has 8 pure virtuals".** Seven: slots 1, 2, 3, 5,
6, 8, 9. Slot 4's interface body is `xor eax,eax; ret`, a real default.
- **`ai-turn-logic.md` §6.1 — "34 `Game::AIT*` … the strategic task/goal objects".** There are **31**
concrete task classes. The count of 34 folded in `Game::IAITask` and `Game::IAIAntiquariansTask` (both
interfaces) and double-counted the two artifact tasks.
- **`ai-turn-logic.md` §4.2 — "Twenty-one methods in `0x00762ca0 .. 0x00763f60`".** The range is right for
what it enumerates, but the *set* is not the order API: an image-wide `lea ecx,[r32+0x160]` scan finds
**26 order methods** across `0x00761480 .. 0x00781200`, including the ones that write list 5 (system
rates), list 7 (colonize), list 8, list 21, list 23 and list 25, and the `CivilianRatios` gate. AI1's
prediction **P4** — that `0x00762ca0`, `0x00763670`, `0x00763720`, `0x00763990` are the AI-only methods —
survives as far as it goes (all four still have no UI caller) but is no longer the complete list of
AI-only surface, because five newly-found methods were not in its sample.
- **`ai-turn-logic.md` §2 — the `Broadcast` hop.** Was correct; now verified rather than inferred (§5).
- **`ai-turn-logic.md` §4.3 / §7.6 — `g_CurrentClientIndex`.** It is a stack pointer with exactly two
writers, not an unattributed index (§6).
- **`turncommands-block.md` §3 — "List 14 is observed but not understood".** It is the fleet-task order
(§4.1). Lane Q's element typing `{i32, i32, bool}` is confirmed from the adder's node layout, and the
middle field is a **mode**, part of the adder's dedup key — not a count and not padding.
---
## 9. Predictions (rule 2) — written before any run
### P1 — an AI fleet order deposits **two** list-14 elements, the UI deposits one
`0x006987e0` calls `ClientOrder_FleetTask` with `mode = 0` then `mode = 1`; `0x00842a00` keys on
`(fleetId, mode)`. Lane O's `human-turn2-orders.sav` (one UI fleet move) carries exactly one list-14
element, `{1456, 0, true}`.
**Prediction:** in a save taken from an AI player's turn, list 14 contains an **even** number of elements,
in fleet-id-major order, with each fleet appearing exactly twice as `{f, 0, true}` and `{f, 1, true}` — and
the mode-1 element **immediately follows** its mode-0 partner, because both are `push_back`s in the same
call.
*Falsified if:* only mode-0 elements appear (then `0x00821cf0` rejects the mode-1 call and it is a
validation gate, not an order), or the two are not adjacent (then something else appends between them), or
the UI path also emits mode 1 (then `0x005e6fa0` calls it twice too and the AI/UI split is wrong).
*Why it matters:* it is a **`ModCount` prediction**. If `ApplyTurnCommands` bumps once per list element,
each AI fleet order costs two bumps, not one — and lane A2 measured exactly **10** non-driver bumps on
`turn1-state.sav`'s turn with one AI player.
### P2 — `plcy == 0` suppresses both defence tasks, in every species
The gate at `0x006cf683` (and its out-of-line twin `0x006ac510`) skips `AITDefendGateIncoming` and
`AITDefendColonyIncoming` when `player->+0x2d8 == 0`, and `plcy` is a save-visible int.
**Prediction:** across the corpus, the AI players' `plcy` is non-zero in any save whose turn produced a
defensive AI response, and a save edited to `plcy = 0` produces an autosave whose `ModCount` is **lower**
by the number of orders those two task families would have emitted.
*Falsified if:* changing `plcy` changes nothing — which would mean the field at `+0x2d8` on the
`ClientPlayer` is **not** the same field as `plcy` at `+0x2d8` on the `ServerPlayer`, and the whole
species/policy reading in §1 rests on the wrong struct. That is the honest failure mode and it is the one I
would test first.
### P3 — the NPC species (4) generates no strategic tasks at all
Arm 4 of the table at `0x006cf880` jumps past the entire creation block, and species 4 is the engine's
`Species::NPC`.
**Prediction:** an AI player with `Species == 4` issues **no** fleet, colonise, build or research-target
orders, ever — its `TurnCommands` block stays at the 35-item empty shape every turn, and `ModCount` gains
nothing from it. A game with N AI players of which k are NPC has the same `ModCount` as the same game with
those k removed.
*Falsified if:* an NPC-species AI issues any order. That would mean `+0x5c` is not `Species`, and §1.1's
three cross-checks (Hiver gates in arm 1, Zuul node-bore in arm 5, NPC building nothing in arm 4) are
coincidence.
*Corollary worth noting:* whatever drives the NPC/monster factions, it is **not** the `IAITask` system.
### P4 — the ranking is reproducible offline from the table alone
The sort key is `AITask_PriorityForType(task->GetTypeId())` for 26 of 31 classes, with five named overrides
(§3), and `std::list::sort` is stable.
**Prediction:** given the multiset of tasks an AI holds at `0x006cf7f8`, the order `RunTaskList` visits them
is fully determined by §3's table plus the creator call order in §1.1 — **no game state is consulted** —
except where an `AITInvade`/`AITEscortGateInvade` has `+0x4 & 1` clear or an `AITAttackBlockade` is present.
*Test:* hook `0x006b348d` and log `(task->GetTypeName(), task->GetPriority(), pass)` for one turn; the
sequence must be non-increasing in priority within each pass, and must be exactly two passes over the same
multiset.
*Falsified if:* the sequence is not sorted, or the two passes visit different sets (a task retired between
them — `PruneTasks` does not run between the passes, so it should not happen).
---
## 10. What this lane did **not** do
1. **No task body was read past its first few instructions**, except the four slot-5 bodies quoted. The
§4.2 reachability table is a **direct-call closure**, so it is a **lower bound** (rule 16 applies:
anything reached through a vtable is invisible to it — and this is a system built out of vtables).
2. **Slots 11, 12, 13 are unidentified.**
3. **The nine "no order method" tasks** are called goal/planner tasks on structural grounds (shared bodies,
the push/pop bracket, the two passes). **Not verified.**
4. **The two-pass meaning is inference.** I know `Execute` runs twice with 0 then 1; I do not know what the
argument selects.
5. **The `StrategyApp+0x1c` enqueue site was not found** (§6.1), so the AI stepping order is still open.
6. **The `.data` priority tunables `0x00a1795c` / `0x00a17960`** were not traced to a loader.
7. **What species 4 is** — unknown.
8. **Nothing here ran under an instrument.** Every claim is static. §9 is what to measure.
---
## 11. Ranked plan for `sots-engine/src/game/ai` — revised
AI1's plan item 5 was "read `IAITask` … and the selection loop", blocking items 6 and 7. **That is done.**
The revision:
| # | deliverable | why | testable how | blocked on |
|---|---|---|---|---|
| **1** | ~~**`game/ai/tasks`**~~ **DONE this lane** — `sots-engine/src/game/ai/{tasks.h,tasks.cpp}`: the 33-entry type enum + names, the priority table verbatim, the five overrides behind a `TaskPriorityPolicy`, `CreationOrder(species, policyNonZero)` for the four arms, and `Rank()` as a stable descending sort. 193 checks in `tests/game_ai`, ctest **47/47**. The two tuned priorities are *inputs*, not constants, because their loader is unfound (§10.6) | It is the whole default ordering policy and it is pure data plus a stable sort. Zero game state | host: golden table + tie-order + per-species arm cases | — |
| **2** | **`game/ai/tables`** — the nine CSV tables (AI1's item 1) | unchanged: zero AI understanding, `mars/text` already exists | oracle diff vs the shipped CSVs | nothing |
| **3** | **`app`: emit list 14 and list 5/7 from a recorded plan** | §4 pins the exact ABI and §4.1 the exact element. This is `ModCount` from a capture without any AI reasoning | `ModCount` must land exactly on the reference pair | one capture |
| **4** | **measure P1 and P4 on VM140** | Two hooks, one turn. P1 decides whether `ModCount` is 1 or 2 per AI fleet order — that is *the* Rung-B arithmetic | `state_checksum`, a hook on `0x006b348d` and on `0x007634d0` | VM140 (lane W2) |
| **5** | **read the nine goal-task `Execute` bodies** (§10.3) and the three shared ones `0x0068b400`, `0x0068d7a0`, `0x0068c7c0` | The only remaining structural unknown in the loop | — | a lane |
| **6** | **`game/ai/agent`** — the spine as named stubs, now with real phase names for 20 and 32 | unchanged from AI1's item 6, but the skeleton is now specific | host: phase-order test | #5 |
| **7** | find the `StrategyApp+0x1c` enqueue | the last ordering unknown; a **watchpoint**, not a week of reading (rule 18) | DR write watchpoint on `0x00b29f98+0x20` | VM140 |