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.
31 KiB
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
Statusreset to 0 and then receivesSEResumePlaying, in player-array order; - an eliminated player receives it only if its
Statusalready 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:
SEResumePlayingfor 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.- The queue is deduplicated.
0x00438fe0returns true when the id is already present and the push is skipped, so a player can appear at most once per drain. - 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:
- The two emitters are pass-1 gated.
IssueRouteForFleets 0x006bbd50andAssignFleetsAndIssueOrders 0x006c16c0both open with the MSVCsub eax,0 / je L / dec eax / jne Lshape onpass, taking the working arm only whenpass == 1.RequestBuildForTask 0x006cea50does the same. - The hub returns an empty fleet list on pass 0. The result vector lives at
[ebp-0x3c]; I enumerated everyleaof that slot in the 992-byte body — five sites — and only two of them pass it to a function that can write it, namely0x006bbd50(twice) and0x006c16c0(once). Both are pass-1 only. So on pass 0 the copy-out at0x006cf24dcopies an empty vector, and every caller's "for each fleet returned, issue the order" loop runs zero times. - No gather or commit helper reaches an order method. Unlimited-depth direct closures:
0x006c3e50(commit) reaches 16 functions and no order method;0x006cb310268 and none;0x006b7c90139 and none;0x006abf8040 and none;0x006989601 and none. Only0x006c16c0,0x006cea50and0x006bbd50— the three pass-1 gates — carry an order edge.
The two task-level bodies that gate on pass themselves corroborate:
AITAdvanceIdleShips::Execute 0x0068f230isif (pass != 1) return;at0x0068f25a— 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 0x0068e590does its setup (0x00685810) and its finaliser (0x0068e090) only whenpass == 0, and forwardspassto0x0068a520in 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, insideAcquireFleetsForTask's third block: `eax = task ? task->vt11 - 1
, andeaxis then pushed as an argument to0x006cb310, the block-C gatherer. Its default istrueand 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,IAITaskslot 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
- 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.
ServerPlayer+0xf9/+0xfahave 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 onplayer+0xf9across load and one turn.AITRaid's pass-0 behaviour is inferred, not verified (§2.4). Probe: entry hook on0x007635f0.- Slot 11 is not named (§4.3), and
0x006a6260/0x006a6380were not read. - Slot 13's units are not named (§4.2);
agent->+0x10->+0x8and0x0080da80were not read. - Bit 0 of
IAITask+0x4is not identified (§3.1). I corrected the polarity and withdrew the name. - 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.
- The 14 non-
RunTaskListslot-5 dispatch sites were not read (§6). 0x006cb310,0x006b7c90and0x006c3e50were 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 that0x006c3e50actually 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+0x4is set, not clear, and "committed" is not a supported name for that bit (§3.1).ai-task-system.md§10.6 — "the.datapriority 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):passis a tier index selecting between two per-candidate quota fields, and pass 0 emits nothing.ai-task-system.md§6.1 / §10.5 — "theStrategyApp+0x1cfill 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—IAITaskslot 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 prioritiescommitted name is withdrawn |
§3 makes them facts, not inputs | host: golden values + polarity test | — |
| 2 | game/ai/turn_order |
§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 |