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

31 KiB
Raw Blame History

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->vt11
1, and eaxis then pushed as an argument to0x006cb310, the block-C gatherer. Its default is trueand onlyAITInvade/AITInvadeGoaloverride it, returningthis->+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