sots-re/findings/subsystems/ai-stepping-and-passes.md
alex e9dec36d77 findings: lane AI3 -- the AI stepping order is save player order, and pass 0 writes nothing
Closes five of lane AI2's open items and corrects two published claims.

The stepping order: StrategyServer::ResumePlaying 0x007ddc90 walks the player
array in INDEX ORDER and raises SEResumePlaying at each live player; the app
callback StrategyApp::OnClientEvent 0x00838e10 delivers it inline for humans
and appends it, deduplicated, to the pending queue for AI players. So the AI
players are stepped in save player order, each at most once -- computable from
a save with no live measurement. AI2's search missed it because 0x00b29f98 is
not a pointer to the StrategyApp, it IS the StrategyApp: the enqueue writes the
absolute member address 0x00b29fb4 and never materialises the object base. The
enqueue also has zero direct callers and no vtable slot -- its address is stored
into StrategyServer+0x170 by CreateGame. Lane B6's third blind spot, twice over.

The two passes: `pass` is a tier index, not a plan/act switch. 0x006abb2a picks
between two per-candidate quota fields -- tier 0 takes +0x10, tier 1 takes +0x14
-- and the hub loops `for (i = 0; i <= pass; ++i)`. Every order-emitting exit is
gated pass == 1 (0x006bbd50, 0x006c16c0, 0x006cea50), so pass 0 claims each
task's minimum force in priority order and WRITES NOTHING. That halves the
ModCount arithmetic.

Corrections:
- AI2 §4.2: the nine "no order method" tasks are not planners. All nine emit
  orders at depth 4-9; the depth-4 cut hid it. AITColonize reaches list 7 and
  AITBuildPoliceShips reaches list 3, which is what their names promise.
- AI2 §3: the two priority overrides' flag polarity is inverted. The tunable
  applies when bit 0 of +0x4 is SET, and "committed" is not a supported name.
- AI2 §10.6: the .data invade tunables have no loader. 650 and 750, image
  constants, exactly one reader each and no writer anywhere.

Also: slot 12 named (a preemption permission, 0x006a8d20), slot 13's consumer
found (0x00696620, a range budget -- and the Zuul are exempt, a FOURTH
independent cross-check on AI2's species reading), slot 11's dispatch located.
ServerPlayer+0xf9/+0xfa -- the two bytes that decide whether a player is
AI-controlled -- sit in a hole in the serialised layout and are NOT in the save;
their writer is unfound and is the biggest remaining hole.

Static only; nothing here has run under an instrument. 160 indirect call sites
inside the AI closure are unresolved, so reachability is still a lower bound.
Four predictions with falsifiers in §7.

ghidra/addresses.d/lane-ai3.json: 15 entries, 1,105 -> 1,120, no duplicates,
validated to a scratch path.
2026-09-08 15:29:53 -04:00

536 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The AI's stepping order and what the two passes are
Lane AI3, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
Static only; VM140 is held by lane W2. **Nothing in this document has ever run under an instrument** —
every claim is read from the instruction stream, and §8 names the probe for each thing that needs one.
Continues `ai-task-system.md` (lane AI2) and `ai-turn-logic.md` (lane AI1). **This lane closes AI2's open
items 2, 3, 4, 5 and 6, and corrects two published claims.**
**Method.** Same as AI1's and AI2's: `objdump -b binary -m i386 -M intel` over the raw image, every body
swept to the **next function start** (rule 17), call targets from `dumps/functions.json`, jump tables and
data read out of the image at real instruction boundaries, indirect-site census from
`tools/vtable_map.py`. The decompiler was not used. The one new instrument is an unlimited-depth direct
call-graph built from an E8/E9 scan of all 41,089 function bodies — AI2's reachability table was cut at
depth 4 and that cut is the source of its wrong inference (§2).
Addresses: `ghidra/addresses.d/lane-ai3.json` (15 entries; `tools/gen_addresses.py` to a scratch path,
1,105 → **1,120**, no duplicate names).
---
## 0. The two answers, up front
**1. The AI players are stepped in ascending server-player-index order — i.e. save player order — and each
player appears at most once per drain.** The enqueue site AI2 could not find is
`StrategyApp::OnClientEvent` **0x00838e10**, and the thing that fills it is
`StrategyServer::ResumePlaying` **0x007ddc90**, which walks the player vector at `StrategyServer+0x54`
**in index order**. The order is therefore predictable from a save alone, with no live measurement.
AI2's search missed it for a specific and generalisable reason: **`0x00b29f98` is not a pointer to the
`StrategyApp`, it *is* the `StrategyApp`.** MSVC folds the object base into the absolute address of the
member, so the enqueue writes the absolute `0x00b29fb4` (= app+0x1c) and never materialises
`0x00b29f98` at all. A scan for "functions that load the singleton" cannot see it. The address
`0x00b29fb4` has **exactly two references in the whole image**, both in the enqueue.
Compounding it: `0x00838e10` has **zero direct callers and no vtable slot**. Its address is stored once,
into `StrategyServer+0x170`, by `CreateGame` at `0x00889177`. That is lane B6's third blind spot, and it
is the second time this campaign has hit it.
**2. Pass 0 and pass 1 are not "plan then execute". `pass` is a numeric tier, and the pass-1 sweep is a
strict superset of the pass-0 sweep that additionally emits every order.**
The single instruction that settles it is at **0x006abb2a**, in
`FillCandidateToTierQuota 0x006abb00`:
```
have = f(slot->+0x10) + g(slot) + slot->+0x20 ; force already assigned to this candidate
want = (tier == 0) ? cand->+0x10 ; 0x006abb41
: (tier == 1) ? cand->+0x14 ; 0x006abb39
: 0
if (have >= want) return ; quota already met
```
Each candidate carries **two quota fields**, and `pass` picks which one is in force. The hub above it
runs `for (i = 0; i <= pass; ++i) gather(..., i, ...)`, so pass 1 re-runs tier 0 and then tops up to the
larger tier-1 quota.
And every order-emitting exit from that hub is gated `pass == 1`:
| exit | gate | reaches |
|---|---|---|
| `IssueRouteForFleets 0x006bbd50` | `if (pass != 1) return` @0x006bbd78 | `0x006b76a0` → `AI_IssueFleetTask` → **list 14** |
| `AssignFleetsAndIssueOrders 0x006c16c0` | `if (pass != 1) goto ret` @0x006c177e | **lists 14, 8, 10** |
| `RequestBuildForTask 0x006cea50` | `if (pass != 1) return` @0x006ceaac | **lists 3, 1** |
So the model is the classic two-phase allocation: **pass 0 claims fleets against every task's *minimum*
requirement, in priority order; pass 1 tops each task up to its *desired* requirement, again in priority
order, and issues the orders.** For `ModCount` that means **only the pass-1 sweep produces
`TurnCommands` elements** (§2.4 states the one case I could not close).
---
## 1. The stepping order, instruction by instruction
### 1.1 `StrategyApp` is a static object at `0x00b29f98`
Proved by `mov ecx,0xb29f98; call <method>` at `0x007842f6` and `0x007843a2` — an immediate, not a load —
and cross-checked at `0x007843ac`, where `Update` reads the absolutes `ds:0xb29fa8 - ds:0xb29fa4` exactly
where `SyncLocalClients 0x00815fd0` reads `this->+0x10 - this->+0xc`.
| offset | absolute | what | evidence |
|---|---|---|---|
| +0x0 | 0x00b29f98 | flag byte; bit 2 = "a `StrategyServer` exists" | `test byte [esi],4` @0x00815fd6, `or al,4` @0x008891a6 |
| +0x4 | 0x00b29f9c | `StrategyServer*` | `mov [esi+4],eax` @0x0088916c after `0x007d78d0` |
| +0xc/+0x10/+0x14 | 0x00b29fa4/a8/ac | `vector<StrategyClient*>` | walked by `RunPendingAITurns` and `SyncLocalClients` |
| **+0x1c/+0x20/+0x24** | **0x00b29fb4/b8/bc** | **`vector<int> pendingAITurns`** | §1.3 |
| +0x2c | 0x00b29fc4 | `AIProcessMinTime` (seconds, float) | AI2 §7 |
### 1.2 `StrategyServer::ResumePlaying 0x007ddc90` — the order
```
if (0x0080f4d0(&this->+0x4)) return
if (++this->+0x168 == 1 && this->+0x1b4) { obs->vt[4](2,0); obs->vt[7](); }
0x007dd230(&this->+0x174, 0) ; clear
0x007dd230(&this->+0x174, playerCount) ; resize
for (i = 0; i < playerCount; ++i) ; players[] at this->+0x54..+0x58, INDEX ORDER
if (players[i]->Elim == 0) players[i]->Status = 0 ; +0xf8, +0x164
for (i = 0; i < playerCount; ++i) ; SECOND walk, INDEX ORDER
p = players[i]
if (p->Status != 0) continue
ev = { vptr = 0x00a23bb8 } ; on the stack
cb = this->+0x170
if (!cb) Log("...") else cb(p->+0x4 /*netId*/, 0x26, &ev)
```
`+0xf8` is `Elim` and `+0x164` is `Status` — both **save-visible** `ServerPlayer` fields
(`findings/objects/struct-recovery.md`, rows `0xf8 | -0x2a8 | bool | Elim` and
`0x164 | -0x23c | int | Status`). So:
- **every non-eliminated player has `Status` reset to 0 and then receives `SEResumePlaying`, in player-array
order**;
- an eliminated player receives it only if its `Status` already happened to be 0.
**Cross-lane note (rule 18's open list).** `method-rules.md` names *"the `Player.Status` writer between
tail phase 31 and the autosave"* as an open watchpoint question. `0x007ddd3f` **is** a `Player.Status`
writer — `players[i]->Status = 0` for every live player. It fires at the *resume* boundary, which per AI1
is **after** the End-Turn autosave, so it is probably not the writer that item is chasing; recording it so
the next lane to take that watchpoint knows which hit is this one.
### 1.3 `StrategyApp::OnClientEvent 0x00838e10` — the enqueue
```
if (*(void**)0x00b29f9c == 0) return ; no StrategyServer
if (netId == 0) return
find client in [0x00b29fa4 .. 0x00b29fa8) with client->+0x148 == netId ; else return
p = client->+0x150
if (eventId == 0x26 && p->+0xf9 != 0 && p->+0xfa == 0) {
if (!0x00438fe0(&pending /*0x00b29fb4*/, &netId)) ; already queued?
0x0059f1a0(&pending, &netId) ; vector<int>::push_back
return
}
StrategyClient::RaiseEvent(client, eventId, ev) ; 0x00783ee0, synchronous
```
Three consequences that matter:
1. **`SEResumePlaying` for an AI player is the only deferred event in the game.** Every other event, and
the same event for a human player, is delivered inline from the server's own call.
2. **The queue is deduplicated.** `0x00438fe0` returns true when the id is already present and the push is
skipped, so a player can appear at most once per drain.
3. The AI test is **two bytes on the player**, `+0xf9 != 0 && +0xfa == 0`.
### 1.4 The AI-player predicate is on two bytes the save does not carry
`struct-recovery.md`'s `ServerPlayer` table runs `0xf8 Elim`, then jumps to `0xfb NPC`, `0xfc RebAI`,
`0xfd ReqCL`, `0xfe AIBn`, `0xff CnTrd`, `0x100 CnRad` … — a packed run of bools. **`+0xf9` and `+0xfa`
are the two holes in that run: in-memory bytes with no serialised counterpart.**
That is a real gap for Rung B. Whether a given player's turn is run by the AI is decided on state that is
**not in the save**, so it must be set at load or game-setup time from something that is (`AIBn`, `NPC`,
`RebAI`, or a `GameOptions` player-type list). **I did not find the writer.** An image-wide scan for the
`+0xf9`/`+0xfa` displacements returns 83 and 82 sites across many unrelated classes, and I did not filter
it to `ServerPlayer` receivers. This is the one thing in §1 I would hand to a watchpoint first (§8).
### 1.5 `RunPendingAITurns` re-reads its bounds every iteration
AI2 read the drain correctly. One detail worth adding, because a reimplementation will get it wrong:
`0x00838d16`–`0x00838d2a` **re-loads both `+0x1c` and `+0x20` on every iteration**, so the vector may
legally grow (and reallocate) while it is being walked, and any player enqueued *by* an AI turn is picked
up in the same drain. Whether that ever happens is unmeasured.
### 1.6 What this means for `ModCount`
The stepping order is **save player order**, filtered to live players, filtered to AI players, each once.
Combined with §2, the per-turn sequence of `TurnCommands` writes is:
> for each live AI player in save order: pass 0 (claims only, no writes) then pass 1 (writes), with tasks
> visited in AI2's stable descending priority order within each pass.
That is a complete, offline-computable ordering claim. It is the piece that was missing.
---
## 2. The two passes
### 2.1 The dispatch, re-verified
`RunTaskList 0x006b3320(agent, &list, pass, &claims)` — `__cdecl`, four stack args. At `0x006b3478`:
```
eax = [ebp+0x10] ; pass
[edi+0x130] += 4 ; push the task onto the call stack at agent->+0x12c
edx = task->vt[5] ; [*task + 0x14]
push eax ; push agent ; ecx = task
call edx ; Execute(agent, pass), __thiscall, ret 8
if (back(agent->+0x12c) == task) pop
```
So `pass` is the second stack argument of `Execute`, at `[ebp+0xc]` in a standard frame. **All 30 distinct
`Execute` bodies read it.** (31 classes, 30 bodies: `AITColonize`/`AITColonizeGoal`,
`AITInvade`/`AITInvadeGoal`, `AITEscortGateInvade`/`AITEscortGateInvadeGoal` and
`AITRespondAttackSystem`/`AITRespondDefendSystem` share bodies.)
### 2.2 `pass` is a tier index, and the tier selects a quota
`AcquireFleetsForTask 0x006ceef0` is the hub every fleet-shaped task reaches. Its real span is **992
bytes** to the next function start; Ghidra's size is short again (rule 17, now **seven** functions across
seven lanes). It has three blocks, and each is the same shape:
```
for (i = 0; i <= pass; ++i) ; 0x006cef6a / 0x006cf039 / 0x006cf0fe -- `jl` guard then `jle` back-edge
gather(agent, threshold, target, candidates, i, &gathered)
if (0x00698960(candidates, ..., &gathered)) ; is the requirement met?
commit(...)
```
Block A gathers via `GatherFleetsForTier 0x006abf80`, which walks the 0x20-stride candidate vector and
calls `FillCandidateToTierQuota 0x006abb00` per candidate with the tier. That function's prologue is the
definition quoted in §0: **tier 0 ⇒ the quota at `cand->+0x10`, tier 1 ⇒ the quota at `cand->+0x14`,
anything else ⇒ 0.** It is a compiler-generated switch (`sub ecx,0 / je / dec / jne`), not an `if`.
So the two passes are **two force requirements per candidate**, a minimum and a desired, filled in two
priority-ordered sweeps of the whole task list.
### 2.3 Pass 0 writes nothing
Three independent readings agree:
1. **The two emitters are pass-1 gated.** `IssueRouteForFleets 0x006bbd50` and
`AssignFleetsAndIssueOrders 0x006c16c0` both open with the MSVC `sub eax,0 / je L / dec eax / jne L`
shape on `pass`, taking the working arm only when `pass == 1`. `RequestBuildForTask 0x006cea50` does
the same.
2. **The hub returns an empty fleet list on pass 0.** The result vector lives at `[ebp-0x3c]`; I
enumerated *every* `lea` of that slot in the 992-byte body — five sites — and only two of them pass it
to a function that can write it, namely `0x006bbd50` (twice) and `0x006c16c0` (once). Both are pass-1
only. So on pass 0 the copy-out at `0x006cf24d` copies an empty vector, and every caller's
"for each fleet returned, issue the order" loop runs zero times.
3. **No gather or commit helper reaches an order method.** Unlimited-depth direct closures:
`0x006c3e50` (commit) reaches 16 functions and no order method; `0x006cb310` 268 and none; `0x006b7c90`
139 and none; `0x006abf80` 40 and none; `0x00698960` 1 and none. Only `0x006c16c0`, `0x006cea50` and
`0x006bbd50` — the three pass-1 gates — carry an order edge.
The two task-level bodies that gate on `pass` themselves corroborate:
- **`AITAdvanceIdleShips::Execute 0x0068f230`** is `if (pass != 1) return;` at `0x0068f25a` — its entire
body is pass-1 only. It has **priority 0**, so it is always the very last task in the list. The AI
sweeps up whatever is still idle only after every other task has taken both its minimum and its desired
force. That is exactly what the two-tier model predicts.
- **`AITNodeBore::Execute 0x0068e590`** does its setup (`0x00685810`) and its finaliser (`0x0068e090`)
only when `pass == 0`, and forwards `pass` to `0x0068a520` in both.
### 2.4 The one exception I could not close
`AITRaid::Execute 0x0068e670` reaches **list 16** at depth 1: at `0x0068e89e` it calls `0x006b76a0` and
then, at `0x0068e8b8`, loops `ClientOrder 0x007635f0(client, fleetId, 1)` over the returned fleets.
**Neither the call nor the loop has a `pass` guard of its own.** It fires iff the fleet vector at
`[ebp-0x28]` is non-empty, and that vector is downstream of the same hub — so §2.3's argument says it is
empty on pass 0, but I did not trace `[ebp-0x28]` to closure. **Treat "AITRaid emits nothing on pass 0"
as inferred, not verified.** It is one entry probe on `0x007635f0` (§8).
---
## 3. The `.data` invade tunables have no loader — they are constants (AI2 §10.6, closed)
`0x00a1795c` and `0x00a17960` each have **exactly one 4-byte reference in the entire image**, and both are
the `mov eax, ds:[imm32]` inside their own `GetPriority` override. There is no writer anywhere. Their
values are in the PE image:
| address | value | override | table priority | effect |
|---|---:|---|---:|---|
| `0x00a1795c` | **650** | `AITInvade::GetPriority` 0x00683670 | 500 | flag raises it to 650 |
| `0x00a17960` | **750** | `AITEscortGateInvade::GetPriority` 0x006835e0 | 400 | flag raises it to 750 |
They sit inside a run of unrelated statics (floats 0.5/0.75/0.9 and pointer tables into the weapon-name
block at `0x00a17618`), i.e. one translation unit's `.data`, packed by the linker. There is no CSV path
and nothing to find. Lane N's pop-type table and lane E1's difficulty table were *built in code from
`.rdata` literals*; this is the simpler shape one step further down — **no construction at all**.
### 3.1 Correction to `ai-task-system.md` §3: the flag polarity is inverted
AI2 published `if (!(this->+0x4 & 1)) return *(int*)0x00a1795c`. The instruction stream is:
```
00683670 movzx eax, byte [ecx+4]
00683674 not al
00683676 test al, 1
00683678 je 0x0068367f ; ZF set <=> bit0 of ~b == 0 <=> bit0 of b == 1
0068367a jmp 0x00694220 ; bit0 CLEAR -> the default table lookup
0068367f mov eax, ds:0xa1795c ; bit0 SET -> the tunable
```
So the tunable applies when **bit 0 of `this->+0x4` is SET**, the opposite of the published claim. This
matters because it flips the meaning of the flag — AI2 called it "not yet committed", and on the
instruction stream the tuned, *higher* priority is the flag-set state, not the flag-clear state. I have
**not** identified what bit 0 of `+0x4` is; note that `AITInvade::Execute` maintains two different bytes
at `+0x38` and `+0x39` that look far more like "committed" than `+0x4` does, so "committed" should be
treated as an unsupported name, not just an inverted one.
Also: the default arm is not the table directly. `0x00694220` is a shared 17-byte thunk,
`return AITask_PriorityForType(this->vt[1]())`, which then calls AI2's 33-arm table at `0x00691f00`. The
table itself stands unchanged.
---
## 4. Slots 11, 12 and 13 (AI2 §10.2)
### 4.1 Slot 12 — **named**: preemption permission
`IsClaimedByAnotherTask 0x006a8d20(agent; void* obj)` is the only consumer, and it is called from the
per-fleet filter inside `FillCandidateToTierQuota` at `0x006abc71` with `test al,al; jne <skip candidate>`
— so **true means "reject this candidate"**.
```
key = obj ? obj->+4 : 0
owner = lookup(key) in agent->+0x2e8..+0x2ec ; 8-byte pairs {IAITask* owner, int objectId}
if (not found here and not in the 4-byte set agent->+0x2d8..+0x2dc) return false ; free
cur = back(agent->+0x12c) ; the task call stack RunTaskList maintains
if (stack empty || !cur) return true
if (!cur->vt[12]()) return true ; 0x006a8db3
if (!owner) return false
if (cur->GetTypeId() == owner->GetTypeId()) return true
return !(cur->GetPriority() > owner->GetPriority())
```
**Slot 12 is "this task may take an object already claimed by a strictly lower-priority task of a
different type".** Default `false` (26 classes ⇒ any claimed object is off limits); five classes set it.
Because the list is processed in descending priority, the owner is normally the *higher*-priority task, so
the steal branch should almost never fire — which is a falsifiable prediction (§7, P3).
This also names `agent->+0x2e8` positively: it is the **claim registry**, a vector of
`{IAITask*, objectId}` pairs. `RunTaskList` erases the task's entries from it before calling `Execute`,
which is consistent.
### 4.2 Slot 13 — **consumer found**, semantics partially read
`RangePenaltyForTask 0x00696620` is the only slot-13 dispatch I found (`call [eax+0x34]` at `0x00696630`,
on the `this` receiver):
```
budget = this ? this->vt[13]() : 15
n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1)
if (n < budget) return 0
switch (player->Species) ; byte index @0x006966a8 = [0,0,0,0,2,1,0]
; jump table @0x0069669c = [0x69668a, 0x696693, 0x69668a]
species 5 (Zuul) -> return 0
species 0,1,2,3,4,6 and >6 -> return 1000000
```
So slot 13 is a **range/hop budget**, compared against a count, with a prohibitive 1,000,000 penalty past
it. Its default is 15; `AITDefendColonyIncoming` and `AITDefendGateIncoming` return `INT_MAX`, so they
never take the penalty — an incoming attack is answered at any distance.
**And the Zuul are exempt.** That is a **fourth independent cross-check on AI2's species reading**, after
Hiver-only gates in arm 1, Zuul-only node-bore in arm 5, and NPC building nothing in arm 4. Four
coincidences is not a coincidence.
I have not identified `agent->+0x10->+0x8` or `0x0080da80`, so I am not naming the units. `n` is a small
count that grows with something; "hops" and "turns" both fit and I cannot separate them statically.
### 4.3 Slot 11 — consumer found, semantics not read
Dispatched at **`0x006cf10e`**, inside `AcquireFleetsForTask`'s third block: `eax = task ? task->vt[11]()
: 1`, and `eax` is then pushed as an argument to `0x006cb310`, the block-C gatherer. Its default is `true`
and only `AITInvade`/`AITInvadeGoal` override it, returning `this->+0x39`.
`AITInvade::Execute 0x0068d7a0` computes that byte at `0x0068d80f`–`0x0068d82a`:
```
this->+0x39 = (0x006a6380(agent, this->+0xc) < 2 * 0x006a6260(agent, this->+0xc))
```
i.e. a comparison of two per-target quantities with a factor of two — a "do I have less than twice X?"
ratio test. So slot 11 gates how block C gathers, and for an invade it is a strength ratio against the
target. **Not named.** `0x006a6260` and `0x006a6380` were not read.
---
## 5. The nine "no order method" tasks are **not** planners — AI2 §4.2 corrected
AI2 wrote that nine task classes have no order method within depth 4 and inferred, explicitly flagged as
unverified, that they are "goal tasks whose job is to spawn sub-tasks onto the list, not to emit orders".
**That is wrong, and the cause is the depth-4 cut.** All nine reach an order method by *direct* calls
only, at depths 4 to 9. Unlimited-depth direct closure, one concrete path each:
| task | order | path |
|---|---|---|
| `AITColonize` / `AITColonizeGoal` | **list 7 (colonize)** @4 | `0068b400 → 0068b280 → 006930f0 → 00578ff0 → 00769640` |
| `AITEscortGateInvade` / `…Goal` | **list 7** @4 | `0068c7c0 → 0068c5d0 → 00693080 → 005790b0 → 00769640` |
| `AITInvade` / `AITInvadeGoal` | **list 14** @5 | `0068d7a0 → 0068d460 → 006ceb80 → 006c16c0 → 006987e0 → 007634d0` |
| `AITNodeBore` | **list 14** @5 | `0068e590 → 0068a520 → 006ceef0 → 006c16c0 → 006987e0 → 007634d0` |
| `AITBuildPoliceShips` | **list 3 (build)** @6 | `00690380 → 006ce460 → 006ce360 → 006ce190 → 006bd790 → 006b3bc0 → 00762fd0` |
| `AITBuildDeepScanShips` | **list 3** @6 | `006901a0 → …` (identical) |
`AITColonize` emitting the **colonize** order and `AITBuildPoliceShips` emitting a **build** order is
exactly what those names promise. There is no planner tier.
What the six shared/thin bodies actually are is **forwarders into shared parameterised workers**:
| body | forwards to | shape |
|---|---|---|
| `0x0068b400` (Colonize, ColonizeGoal) | `0x0068b280` | 48 B; `worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10)`, plus `edi = this->+0xc` as an **implicit register argument** |
| `0x0068c7c0` (EscortGateInvade, …Goal) | `0x0068c5d0` | 80 B; `__fastcall(ecx = this->+0xc, edx = agent)` + 8 stack args |
| `0x0068d7a0` (Invade, InvadeGoal) | `0x0068d460` | 160 B; 11 args, then maintains `this->+0x38`/`+0x39` |
The `+0xc`-in-`edi` convention in the first of those is worth flagging on its own: it is a
whole-program-optimised custom calling convention, invisible to a decompiler prototype, and a
reimplementation that only ports the stack arguments will silently pass garbage.
**What the "Goal" suffix actually distinguishes is not behaviour — the paired classes share an identical
`Execute` — it is the two `GetTarget` slots.** AI2's slot 2/3 body census already showed the variants
return different members (`+0x8`/`+0xc` vs `+0xc`/`+0x10`). So a Goal task is the same task pointed at a
different pair of fields, not a different kind of thing.
---
## 6. The reachability bound, honestly (AI2 §10.1)
AI2's table was a depth-4 direct closure. Mine is an unlimited-depth direct closure over an E8/E9 scan of
all 41,089 bodies. **It is still a lower bound**, and here is the size of the gap.
The union of the unlimited direct closures of all 31 `Execute` bodies is **1,534 functions**, 358 of them
in the AI band. Inside that set, `tools/vtable_map.py` finds **940 indirect call sites**:
| kind | count | reachability risk |
|---|---:|---|
| `call-abs` (import / CRT thunks) | 598 | none |
| `virtual`, slot resolved | 156 | low — 133 of them are slots 0–13, i.e. `IAITask` itself |
| **`call-reg-unresolved`** | **88** | **unknown** |
| **`vptr-unresolved`** | **72** | **unknown** |
| `call-reg-nonmem` / `not-vptr` | 26 | low |
So the honest statement is: **160 indirect call sites inside the AI closure cannot be resolved by the
current tool, and any of them could reach an order method.** Rule 16 stands.
Where it matters most, though, the closure is **tight**. The two functions that carry every order emission
have almost no indirect surface at all:
- `AcquireFleetsForTask 0x006ceef0`: 7 indirect sites — 4 import thunks, `IAITask` slot 1 (`GetTypeId`,
returns a constant) twice, and slot 11 (returns a bool). None can reach code.
- `AssignFleetsAndIssueOrders 0x006c16c0`: 4 indirect sites, all import thunks.
One thing the census turned up that deserves a follow-up: **slot 5 (`Execute`) is dispatched at 14 sites
inside the AI closure**, not just from `RunTaskList 0x006b348d`. `0x006c8fd0` alone has twelve. Tasks
running other tasks is real; I did not read those sites and cannot say whether they are sub-task execution
or an unrelated class that shares slot 5.
---
## 7. Predictions (rule 2) — written before any run
### P1 — the AI turn sequence is fully determined by the save
Stepping order = live players in save-array order, filtered to AI; within each player, tasks in AI2's
stable descending priority order; two passes, the first writing nothing.
**Prediction:** hook `0x006b348d` and log `(playerNetId, task->GetTypeName(), task->GetPriority(), pass)`
for one turn. The log is a concatenation of per-player blocks in ascending save-player order; within each
block the priorities are non-increasing within pass 0, then non-increasing again within pass 1, over the
**same** multiset.
*Falsified if:* the player blocks are in any other order (then `ResumePlaying` is not the only filler of
the pending vector, or the callback is not the only enqueue), or the two passes visit different sets.
### P2 — pass 0 writes no `TurnCommands` at all
**Prediction:** hook the 26 order methods and record `pass` from a hook on `0x006b348d`. **Zero** order
calls occur between the pass-0 entry and the pass-0 exit of `RunTaskList` for every player.
*Falsified if:* any order method fires during pass 0. The **most likely** falsifier is
`AITRaid`'s list-16 emit at `0x0068e8b8` (§2.4), which has no pass guard of its own — so instrument
`0x007635f0`'s **entry**, not its cost (rule 20).
*Why it matters:* this halves the `ModCount` arithmetic. AI2's P1 says an AI fleet order costs two list-14
elements; if pass 0 also emitted, it would cost four.
### P3 — task-to-task preemption never fires on a normal turn
Slot 12 permits stealing only from a **strictly lower-priority** owner, and the list is processed in
descending priority, so by the time a task runs, every existing owner outranks it.
**Prediction:** an entry probe on `0x006a8d20` shows it is called often and **the steal branch
(`0x006a8ded` via `0x006a8de9`) is never taken**. Note this is a rule-20 shape: a count of zero at the
call site cannot distinguish "never called" from "called and always rejected" — probe the branch.
*Falsified if:* the branch fires. That would mean the claim registry outlives the sort, i.e. claims from a
*previous* task list survive into this turn, which would make the AI's state path-dependent across turns
and is a much bigger deal for Rung B than the preemption itself.
### P4 — the two AI-player bytes are derived, not loaded
`ServerPlayer+0xf9`/`+0xfa` have no save field (§1.4).
**Prediction:** a write watchpoint on `player+0xf9` fires exactly once per player during game setup /
load, from a function that reads `AIBn`, `NPC` or `RebAI`, and never again during a turn.
*Falsified if:* it is written mid-turn — then whether a player is AI-controlled can change during a game
and the stepping-order model needs a per-turn input the save does not supply.
---
## 8. What this lane did **not** do
1. **Nothing ran under an instrument.** Every claim above is static. §7 is the measurement plan; §2.4,
§4.2, §4.3 and §1.4 are the four places where I would spend the probes first.
2. **`ServerPlayer+0xf9`/`+0xfa` have no located writer** (§1.4). This is the biggest remaining hole,
because it is an *input* to the stepping order that the save does not contain. Probe: a hardware write
watchpoint on `player+0xf9` across load and one turn.
3. **`AITRaid`'s pass-0 behaviour is inferred, not verified** (§2.4). Probe: entry hook on `0x007635f0`.
4. **Slot 11 is not named** (§4.3), and `0x006a6260`/`0x006a6380` were not read.
5. **Slot 13's units are not named** (§4.2); `agent->+0x10->+0x8` and `0x0080da80` were not read.
6. **Bit 0 of `IAITask+0x4` is not identified** (§3.1). I corrected the polarity and withdrew the name.
7. **160 indirect call sites inside the AI closure are unresolved** (§6), so the reachability result is
still a lower bound — just a much larger one than AI2's.
8. **The 14 non-`RunTaskList` slot-5 dispatch sites were not read** (§6).
9. **`0x006cb310`, `0x006b7c90` and `0x006c3e50` were read only for their order-method reachability**, not
for what they do. The claim "pass 0 claims fleets" is the *shape* the quota code implies; I did not
verify that `0x006c3e50` actually records a claim.
---
## 9. Corrections to earlier findings
- **`ai-task-system.md` §4.2 — "the nine with no order method are goal/planner tasks".** They are not.
All nine emit orders; the depth-4 cut hid it (§5). AI2 flagged the inference as unverified and it was
the right thing to flag.
- **`ai-task-system.md` §3 — the two priority overrides' flag polarity.** The tunable applies when bit 0
of `+0x4` is **set**, not clear, and "committed" is not a supported name for that bit (§3.1).
- **`ai-task-system.md` §10.6 — "the `.data` priority tunables were not traced to a loader".** There is no
loader; they are image constants, 650 and 750, with one reader each and no writer (§3).
- **`ai-task-system.md` §10.4 — "the two-pass meaning is inference".** Settled (§2): `pass` is a tier
index selecting between two per-candidate quota fields, and pass 0 emits nothing.
- **`ai-task-system.md` §6.1 / §10.5 — "the `StrategyApp+0x1c` fill site was not found".** Found (§1).
AI2's method was sound; it was defeated by the object being static rather than heap-allocated.
- **`ai-turn-logic.md` / `ai-task-system.md` — `IAITask` slot 12.** Named: a preemption permission (§4.1).
---
## 10. Ranked plan for `sots-engine/src/game/ai`
AI2's items 5 and 7 are done. The revision:
| # | deliverable | why | testable how | blocked on |
|---|---|---|---|---|
| **1** | ~~`game/ai/tasks` tuned priorities~~ **DONE this lane** — the two tunables are now constants (650/750) with the corrected flag polarity, and the misleading `committed` name is withdrawn | §3 makes them facts, not inputs | host: golden values + polarity test | — |
| **2** | ~~`game/ai/turn_order`~~ **DONE this lane** — the stepping order and the two-pass model as pure functions | §1 and §2. Zero game state; it is the `ModCount` sequence | host: order + dedup + pass-emission cases | — |
| **3** | **`game/ai/tables`** — the nine AI CSV tables (AI1's item 1, AI2's item 2) | unchanged: zero AI understanding needed, `mars/text` already exists | oracle diff vs the shipped CSVs | nothing |
| **4** | **measure P1 and P2 on VM140** | P2 halves the `ModCount` arithmetic; P1 is the whole ordering claim | hook `0x006b348d` + entry probes on the order methods | VM140 (lane W2) |
| **5** | **find the `ServerPlayer+0xf9` writer** (§8.2) | an input to the stepping order that the save does not carry | write watchpoint (rule 18: this is a watchpoint, not a week of reading) | VM140 |
| **6** | **read `0x006c3e50` and the claim registry** | closes "pass 0 claims fleets" from shape to fact | — | a lane |
| **7** | **`game/ai/agent`** — the spine as named stubs | now has real semantics for phase 20 and the two passes | host: phase-order test | #6 |