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

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

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

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

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

36 KiB
Raw Blame History

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, filded, 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_backs 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