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.
This commit is contained in:
alex 2026-09-08 14:43:41 -04:00
parent 8411c7e9c7
commit 232397aa12
3 changed files with 774 additions and 5 deletions

View file

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

View file

@ -37,8 +37,8 @@
"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"
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#2 -- lane AI1 2026-09-08; listener walk and vt[3] dispatch instruction-verified. UPGRADED mapped->verified by lane AI2 2026-09-08 (findings/subsystems/ai-task-system.md#5): the deque arm was read to the call at 0x006b395b -- `push [entry+4] (the receiver); push pkt; call [entry2] ; add esp,8` -- and the thunk 0x006d0ab0 it reaches is `OnAIPacket(this=[ebp+0xc], pkt=[ebp+8])`, matching that push order exactly"
},
{
"name": "StrategyAIAgent_OnAIPacket",
@ -140,9 +140,9 @@
"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"
"prototype": "int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. CORRECTED by lane AI2 2026-09-08: it is a STACK POINTER, not a plain index. The only two instructions in the image that write it are PushCurrentClient 0x00578020 (`g_StrategyClients[idx+1] = c; ++idx;`) and PopCurrentClient 0x00578040 (`--idx;`); the other 40 referencing functions only read `[idx*4 + 0x00ae47e4]`. StrategyAIAgent::OnEvent 0x006d0ad0 brackets the whole AI turn in Push(agent->+0x10)/Pop",
"status": "verified",
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08; corrected and upgraded by lane AI2, findings/subsystems/ai-task-system.md#6"
},
{
"name": "g_GlobalRNG",

View file

@ -0,0 +1,204 @@
{
"entries": [
{
"name": "StrategyAIAgent_RebuildAndRunTasks",
"addr": "0x006cf630",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIAgent::RebuildAndRunTasks() -- THE TASK SELECTION LOOP. Phase 20 of the AI Process Turn body (called from 0x006cfc94). Order: (1) 0x006b34f0(this, &this->+0x2f8) refreshes the per-fleet world model over client->+0x60..+0x64; (2) PruneTasks 0x006b3640(this); (3) `switch (client->+0x150->+0x5c)` over 0..6 through the 7-entry jump table at 0x006cf880 -- four distinct arms (case 0/2/3/6 -> 0x006cf665, case 1 -> 0x006cf6f8, case 5 -> 0x006cf75d, case 4 -> NOTHING) each calling a fixed, source-ordered list of per-task-family creators in 0x006ab6c0..0x006c0e60; (4) if this->+0x8, that object's vt[1](this); (5) PruneTasks again; (6) TaskList_SortByPriority 0x006bf9c0(&this->+0x31c, player->+0x5c) -- std::list::sort, STABLE, DESCENDING by IAITask::vt[10](); (7) RunTaskList 0x006b3320(this, &this->+0x31c, 0, &this->+0x2e8) then again with pass=1; (8) PruneTasks again; (9) if player->+0x2d8 in {1,2}, 0x006cf4c0 then 0x006cf590; (10) 0x006a8eb0(this) -- reaches client order method 0x00763a20; (11) if this->+0x124, cl_SetResearchRate(*(float*)0x009e2ea0) and clear the flag; (12) this->+0x128 = 0",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#1 -- lane AI2 2026-09-08, instruction-stream read of dumps/sots.exe (objdump -b binary -m i386 -M intel, swept to the next function start per rule 17); jump table read from the image"
},
{
"name": "StrategyAIAgent_TaskListSortByPriority",
"addr": "0x006bf9c0",
"convention": "thiscall",
"prototype": "void __thiscall std::list<Game::IAITask*>::sort(Pred) on the agent's task list -- the MSVC 7.1 binlist sort: eh_vector_constructor_iterator over 26 (0x1a) 0x0c-byte std::list bins, the `_Bin == 25` overflow branch, merge helper 0x006a9850. The Pred is a 4-byte functor carrying player->+0x5c, and the inlined comparison IGNORES it: the whole ordering key is IAITask::vt[10]() (see AITask_slot10_GetPriority). std::list::sort is STABLE, so ties keep creation order -- which makes the per-arm creator call order in RebuildAndRunTasks part of the answer, not an implementation detail",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#1 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "StrategyAIAgent_TaskListMerge",
"addr": "0x006a9850",
"convention": "thiscall",
"prototype": "void __thiscall std::list<Game::IAITask*>::merge(list& right, Pred) -- RET 8. THE COMPARISON, inlined at 0x006a9879..0x006a9895: `a = A->vt[10](); b = B->vt[10](); if (a > b) splice A before B;` (`cmp [ebp-0x10],eax / jle` -- so a strictly-greater test, descending order, ties left alone). Both calls are __thiscall with no stack args, which pins IAITask::vt[10] as `int GetPriority(void)`",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#1 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "StrategyAIAgent_RunTaskList",
"addr": "0x006b3320",
"convention": "cdecl",
"prototype": "void (Game::StrategyAIAgent* agent, std::list<IAITask*>* tasks, int pass, std::vector<IAITask*>* pending) -- THE TASK EXECUTION LOOP, run twice per turn with pass = 0 then 1. For each node of `tasks` in list order (i.e. priority order after the sort): task = node->value; erase task from `pending` (std::find 0x0069af70 + memmove compaction); push_back task onto the agent's active-task stack at agent->+0x12c/+0x130/+0x134 (growth helper 0x00483410, \"vector<T> too long\"); call `task->vt[5](agent, pass)`; then if back() is still that task, pop_back. The push/pop bracket makes agent->+0x12c a task CALL STACK, which is how goal tasks (AITColonizeGoal, AITInvadeGoal, AITEscortGateInvadeGoal) nest sub-tasks",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#1 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "StrategyAIAgent_PruneTasks",
"addr": "0x006b3640",
"convention": "cdecl",
"prototype": "void (Game::StrategyAIAgent* agent) -- `for each node of agent->+0x31c: task = node->value; if (task->vt[6](agent)) { erase task from the vector agent->+0x2e8..+0x2ec; agent->RemoveTask(task) 0x006af900; }`. Called three times inside RebuildAndRunTasks: before creation, after creation, and after execution. This is what pins IAITask::vt[6] as `bool IsFinished(StrategyAIAgent*)` -- returning true destroys the task",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "StrategyAIAgent_RemoveTask",
"addr": "0x006af900",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIAgent::RemoveTask(Game::IAITask* task) -- unlinks the task from four containers: the master list at this+0x31c and the vector at this+0x2e8 (via 0x006ae930), the 0x0c-stride vector at this+0x1cc (via 0x006a95e0), and the 0x20-stride vector at this+0x208..+0x20c (find 0x0069b0e0 then a rep-movsd compaction of 0x20-byte records)",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "AITask_PriorityForType",
"addr": "0x00691f00",
"convention": "cdecl",
"prototype": "int (int taskTypeId) -- the AI's whole task-ordering policy as one switch: `if ((unsigned)id > 0x20) return 0; jmp [id*4 + 0x00691ffc]`, 33 arms each a single `mov eax,imm32; ret`. Values (id -> priority): 0 AITSteamroll 1250, 1 AITExplore 600, 2 AITExploreInForce 550, 3 AITEscortGate 700, 4 AITEscortGateInvade 400, 5 AITEscortGateInvadeGoal 950, 6 AITDeployGateAt 1400, 7 AITColonize 900, 8 AITColonizeGoal 970, 9 AITColonizeAt 1300, 0xa AITInvade 500, 0xb AITInvadeGate 1000, 0xc AITInvadeGoal 930, 0xd (no class) 200, 0xe AITDefendColonyIncoming 1100, 0xf (no class) 300, 0x10 AITDefendGateIncoming 1200, 0x11 AITKillEasterEgg 800, 0x12 AITInterceptEnemy 850, 0x13 AITMining 350, 0x14 AITMiningReturn 375, 0x15 AITAttackBlockade 100, 0x16 AITAdvanceIdleShips 0, 0x17 AITStockFreighters 50, 0x18 AITRespondAttackSystem 980, 0x19 AITRespondDefendSystem 990, 0x1a AITNodeBore 1275, 0x1b AITBuildStations 910, 0x1c AITBuildPoliceShips 75, 0x1d AITBuildDeepScanShips 60, 0x1e AITRaid 399, 0x1f AITRetrieveArtifact 1, 0x20 AITReturnArtifact 2. The last two table entries are DEAD: both artifact classes override vt[10] with fixed 0x4ec/0x4ed (1260/1261). Ids 0xd and 0xf have priorities but no surviving class",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#3 -- lane AI2 2026-09-08, jump table and all 33 arms read from the image"
},
{
"name": "AITask_slot10_GetPriority",
"addr": "0x00694220",
"convention": "thiscall",
"prototype": "int __thiscall Game::IAITask::GetPriority() -- vtable slot 10, the DEFAULT implementation, used by 21 of the 31 concrete tasks (5 more reach it through the thunk 0x00682650): `return AITask_PriorityForType(this->vt[1]());`. Overrides: AITInvade 0x00683670 and AITEscortGateInvade 0x006835e0 return the globals at 0x00a1795c / 0x00a17960 when `this->+0x4 & 1` is clear, else default; AITAttackBlockade 0x00685600 scans a 0xc-stride vector at this->+0x8->+0x1cc for a related task and filters on its type id (1, 2, 7, 0x11, ...); AITRetrieveArtifact 0x005465a0 returns 0x4ec and AITReturnArtifact 0x00546800 returns 0x4ed unconditionally",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#3 -- lane AI2 2026-09-08, instruction-stream read of every slot-10 body"
},
{
"name": "AITask_vt_slot1_GetTypeId",
"offset": "0x04",
"convention": "offset",
"prototype": "Game::IAITask vtable slot 1 (byte offset 4) -- `int GetTypeId(void)`, PURE in the interface (vftable 0x009fa354), and in all 31 concrete classes a single 16-byte `mov eax,imm32; ret` returning a value in 0..0x20. It is the key into AITask_PriorityForType and the discriminator every cross-task filter uses",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08; all 31 bodies read from the image"
},
{
"name": "AITask_vt_slot5_Execute",
"offset": "0x14",
"convention": "offset",
"prototype": "Game::IAITask vtable slot 5 (byte offset 0x14) -- `void Execute(Game::StrategyAIAgent* agent, int pass)`, RET 8, PURE in the interface. THE task body: 27 distinct implementations across the 31 classes, 48..288+ bytes each, dispatched from StrategyAIAgent_RunTaskList 0x006b3320+0x167 (`mov edx,[task_vt+0x14]; push pass; push agent; mov ecx,task; call edx`). This is the only slot from which a client order method is ever reached",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "AITask_vt_slot6_IsFinished",
"offset": "0x18",
"convention": "offset",
"prototype": "Game::IAITask vtable slot 6 (byte offset 0x18) -- `bool IsFinished(Game::StrategyAIAgent* agent)`, RET 4, PURE in the interface. Called ONLY from StrategyAIAgent_PruneTasks 0x006b3640+0x2b; true means unlink and destroy. 27 distinct implementations; the two shared trivials are 0x005eda80 `return false` (AITAdvanceIdleShips, AITSteamroll -- never retire) and, e.g., AITBuildDeepScanShips 0x00682fe0 `return !0x0069a7f0(agent, 0x20, 0)`",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "AITask_vt_slot8_GetTypeName",
"offset": "0x20",
"convention": "offset",
"prototype": "Game::IAITask vtable slot 8 (byte offset 0x20) -- `const char* GetTypeName(void)`, PURE in the interface; in all 31 classes a 16-byte `mov eax,<rdata ptr>; ret` returning the class's own unmangled name (\"AITRaid\", \"AITColonizeGoal\", ...). Pairing slot 1 with slot 8 across the 31 vtables yields the complete task-type enum with no gaps except ids 0x0d and 0x0f",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08; all 31 bodies read from the image"
},
{
"name": "AITask_vt_slot9_Describe",
"offset": "0x24",
"convention": "offset",
"prototype": "Game::IAITask vtable slot 9 (byte offset 0x24) -- `void Describe(void)`, no args, PURE in the interface. Every implementation is a single log call of the form `Log(\"<TypeName>: %s -> %s\\n\", NameOf(vt2()), NameOf(vt3()))` (AITAdvanceIdleShips prints the literal \"AITAdvanceIdleShips: n/a -> n/a\\n\"). It is what pins slots 2 and 3 as the task's source and destination target getters",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "AITask_vt_slot7_OnObjectDestroyed",
"offset": "0x1c",
"convention": "offset",
"prototype": "Game::IAITask vtable slot 7 (byte offset 0x1c) -- `void OnObjectDestroyed(void* obj)`, RET 4, NOT pure: the interface default 0x005f8ac0 is a bare `ret 4`. The dominant override 0x00682540 (17 of 31 classes) nulls whichever of this->+0xc and this->+0x8 holds an object whose +0x4 equals the argument -- i.e. it drops dangling target references",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#2 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "PushCurrentClient",
"addr": "0x00578020",
"convention": "cdecl",
"prototype": "void (Game::StrategyClient* c) -- `g_StrategyClients[g_CurrentClientIndex + 1] = c; ++g_CurrentClientIndex;` (written as `mov [eax*4+0x00ae47e8],ecx` with eax = the old index, then `inc [0x00ae4808]`). So 0x00ae47e4 is a STACK of client scopes and 0x00ae4808 is its stack pointer, not a plain index -- the whole cl_* family reads `[idx*4 + 0x00ae47e4]`, i.e. the top of stack. 18 callers; the AI-relevant one is StrategyAIAgent::OnEvent 0x006d0ad0, which brackets the ENTIRE AI turn in Push(agent->+0x10) / Pop. That is the mechanism by which every cl_* call the AI makes -- cl_Chance, cl_RandRange, cl_SetResearchRate, cl_EndTurn -- lands on that AI's own client and its own RNG at client+0x134",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#6 -- lane AI2 2026-09-08, instruction-stream read; closes lane AI1's open item 6 on who sets g_CurrentClientIndex"
},
{
"name": "PopCurrentClient",
"addr": "0x00578040",
"convention": "cdecl",
"prototype": "void () -- `--g_CurrentClientIndex;`, the two-instruction pop matching PushCurrentClient 0x00578020. 18 callers, the same set. No other instruction in the image writes 0x00ae4808: an image-wide absolute-reference scan finds 42 referencing functions and every one of the other 40 only READS it",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#6 -- lane AI2 2026-09-08, image-wide absolute-reference scan plus instruction-stream read"
},
{
"name": "StrategyApp_RunPendingAITurns",
"addr": "0x00838c60",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyApp::RunPendingAITurns() -- called EVERY FRAME from StrategyNetworkClient::Update 0x007842b0+0xf7 with ECX = the StrategyApp singleton 0x00b29f98. `if (this->+0x1c == this->+0x20) return;` (empty pending-AI-player-id vector). Otherwise: t0 = clock 0x008d0b70; show the Game::AIProcessingDialog at 0x00b1149c if it exists; then FOR EVERY entry of +0x1c..+0x20 IN INDEX ORDER, find the client in +0xc..+0x10 whose client->+0x148 matches, update the dialog with client->+0x150, and StrategyClient::RaiseEvent 0x00783ee0(client, 0x26 /*SEResumePlaying*/, &ev) -- which is what runs that AI player's whole turn. The loop has no early exit and no frame yield. After it, the pending vector is emptied, and only THEN: `remaining = this->+0x2c - (clock() - t0); if (remaining > 0) Sleep((int)(remaining * 1000));` before hiding the dialog. THE THROTTLE IS A TRAILING SLEEP, NOT A COMPUTE BUDGET: AIProcessMinTime cannot change a decision and cannot defer an AI turn across frames",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#7 -- lane AI2 2026-09-08, instruction-stream read; settles lane AI1's open item 7"
},
{
"name": "StrategyApp_off_AIProcessMinTime",
"offset": "0x2c",
"convention": "offset",
"prototype": "float -- Game::StrategyApp+0x2c, in SECONDS. Set once in StrategyApp::CreateGame 0x00888e80+0x90: the GameOptions key \"AIProcessMinTime\" (string at 0x00a32e30) is read through 0x00898bc0, converted with the CRT string-to-long at 0x009dd320, `fild`ed and divided by the double 1000.0 at 0x009e22f8, then clamped at 0 before `fst [esi+0x2c]`. Its only consumer is the trailing Sleep in StrategyApp_RunPendingAITurns 0x00838c60+0x10e",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#7 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "StrategyApp_off_PendingAIPlayers",
"offset": "0x1c",
"convention": "offset",
"prototype": "std::vector<int> -- Game::StrategyApp+0x1c.._+0x20, the queue of player net ids whose AI turn is due. Drained in index order by StrategyApp_RunPendingAITurns 0x00838c60, which is therefore the ONLY thing that decides in what order the AI players are stepped and hence the order their TurnCommands blocks reach the host. WHO PUSHES TO IT WAS NOT FOUND by this lane -- neither an absolute-reference scan for the singleton nor an enumeration of the methods called on it located the enqueue site",
"status": "mapped",
"source": "findings/subsystems/ai-task-system.md#6 -- lane AI2 2026-09-08; the drain is instruction-verified, the fill is open"
},
{
"name": "ClientOrder_FleetTask",
"addr": "0x007634d0",
"convention": "thiscall",
"prototype": "bool __thiscall Game::StrategyClient::<fleet task order>(void* fleetObj, int mode, bool flag) -- RET 0xc. `if (this->+0x15c) return false;` then builds the 12-byte record {i32 fleetId = fleetObj->+4, i32 mode, bool flag}, calls the local-apply/validate 0x00821cf0(this->+0x148 /*playerId*/, &rec), and on true appends it to the accumulating TurnCommands at this+0x160 via the LIST 14 adder 0x00842a00. THIS IS LANE Q'S UNEXPLAINED LIST 14. The AI reaches it through 0x006987e0, which calls it TWICE per fleet -- (fleet, 0, true) then (fleet, 1, true) -- and 0x00842a00 keys its insert-or-update on BOTH fleetId (node+0x8) and mode (node+0xc), so an AI fleet order deposits TWO list-14 elements. The UI path (0x005e6fa0) and OnResumePlaying 0x00777480 also call it",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#4 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "TurnCommands_AddList14",
"addr": "0x00842a00",
"convention": "thiscall",
"prototype": "void __thiscall Game::TurnCommands::<add list-14 entry>(const rec* r) -- operates on the std::list at this+0x10c, which is lane Q's LIST 14 (member 14 of 27, +0x70 + 14*0x0c - 0x0c = +0x10c). Scans for a node with node->+0x8 == r->fleetId AND node->+0xc == r->mode; if found, overwrites node->+0x8/+0xc/+0x10 in place; otherwise push_back via 0x00766c20. The node payload is exactly lane Q's observed element record {i32, i32, bool}",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#4 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "AI_IssueFleetTask",
"addr": "0x006987e0",
"convention": "cdecl",
"prototype": "void* (Game::StrategyAIAgent* agent, std::vector<void*>* route, void* dest) -- the AI's single fleet-order bridge and the busiest AI->TurnCommands edge in the module. Pushes `dest` through cl_* helper 0x00578cd0, opens a route build with 0x0057b4a0, appends each element of `route` with 0x0057aa50, closes with 0x0057b4d0, resolves the resulting handle through 0x008f4b30, and if non-null calls ClientOrder_FleetTask 0x007634d0 twice: (obj, 0, true) then (obj, 1, true). Three callers -- 0x006b76a0, 0x006c15e0, 0x006c16c0 -- which between them are reached from the Execute (slot 5) body of 24 of the 31 task classes",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#4 -- lane AI2 2026-09-08, instruction-stream read plus a depth-4 direct-call closure over the AI band"
},
{
"name": "ClientOrder_SetSystemRates",
"addr": "0x00763270",
"convention": "thiscall",
"prototype": "Game::StrategyClient order method appending to TurnCommands LIST 5 (+0xa0, the planetary-budget/system-rates list lane O observed in zuul-turn17-orders2.sav) through helper 0x008490b0, which is `add ecx,0xa0; call 0x00843fa0`. Called from the AI at 0x0069dd80 (AI Prepare Turn's one-shot NextInt scheduler) and from seven non-AI sites including the cl_* façade at 0x00579110. It is one of the five order methods lane AI1's 21-row table missed",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#4 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "ClientOrder_Colonize",
"addr": "0x00769640",
"convention": "thiscall",
"prototype": "Game::StrategyClient order method appending to TurnCommands LIST 7 (+0xb8, lane O's `{i32 shipId, i32 w}` colonize list) through helper 0x00842890. Called from the AI at 0x006af790 -- phase 32 of the AI Process Turn body, i.e. AFTER cl_EndTurn -- and from ten non-AI sites, seven of which are the cl_* façade family 0x00578fc0..0x005790e0. Also missing from lane AI1's table",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#4 -- lane AI2 2026-09-08, instruction-stream read"
},
{
"name": "TurnCommands_off_ListBase",
"offset": "0x70",
"convention": "offset",
"prototype": "Game::TurnCommands -- the first of the 27 std::list members lane Q enumerated, stride 0x0c, so list N (1-based, as lane Q numbers them) is at +0x70 + (N-1)*0x0c and the last, list 27, is at +0x1a8. Recorded here because the order-method -> list mapping in ai-task-system.md#4 is expressed entirely in these offsets: the adder for list N is the function whose first `this`-relative access is +0x70 + (N-1)*0x0c",
"status": "verified",
"source": "findings/subsystems/ai-task-system.md#4 -- lane AI2 2026-09-08; agrees with lane Q's findings/objects/turncommands-block.md section 3"
}
]
}