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.
36 KiB
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 iszuul-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:
AITInvade0x00683670andAITEscortGateInvade0x006835e0: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.AITAttackBlockade0x00685600: walks a 0x0c-stride vector atthis->+0x8->+0x1cclooking for a related task, filtering it byGetTypeId()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.AITInvadeGoalreaches the default through the thunk0x00682650.
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 isxor eax,eax; ret, a real default.ai-turn-logic.md§6.1 — "34Game::AIT*… the strategic task/goal objects". There are 31 concrete task classes. The count of 34 folded inGame::IAITaskandGame::IAIAntiquariansTask(both interfaces) and double-counted the two artifact tasks.ai-turn-logic.md§4.2 — "Twenty-one methods in0x00762ca0 .. 0x00763f60". The range is right for what it enumerates, but the set is not the order API: an image-widelea ecx,[r32+0x160]scan finds 26 order methods across0x00761480 .. 0x00781200, including the ones that write list 5 (system rates), list 7 (colonize), list 8, list 21, list 23 and list 25, and theCivilianRatiosgate. AI1's prediction P4 — that0x00762ca0,0x00763670,0x00763720,0x00763990are 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 — theBroadcasthop. 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
- 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).
- Slots 11, 12, 13 are unidentified.
- 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.
- The two-pass meaning is inference. I know
Executeruns twice with 0 then 1; I do not know what the argument selects. - The
StrategyApp+0x1cenqueue site was not found (§6.1), so the AI stepping order is still open. - The
.datapriority tunables0x00a1795c/0x00a17960were not traced to a loader. - What species 4 is — unknown.
- 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/taskssots-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 |