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