# 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 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(": %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::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` 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 |