From e9dec36d7794dbc0823f37e7a1d18a189ec4c450 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 15:29:53 -0400 Subject: [PATCH] findings: lane AI3 -- the AI stepping order is save player order, and pass 0 writes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- findings/subsystems/ai-stepping-and-passes.md | 536 ++++++++++++++++++ ghidra/addresses.d/lane-ai3.json | 124 ++++ 2 files changed, 660 insertions(+) create mode 100644 findings/subsystems/ai-stepping-and-passes.md create mode 100644 ghidra/addresses.d/lane-ai3.json diff --git a/findings/subsystems/ai-stepping-and-passes.md b/findings/subsystems/ai-stepping-and-passes.md new file mode 100644 index 0000000..ff5d13e --- /dev/null +++ b/findings/subsystems/ai-stepping-and-passes.md @@ -0,0 +1,536 @@ +# 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 ` 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` | walked by `RunPendingAITurns` and `SyncLocalClients` | +| **+0x1c/+0x20/+0x24** | **0x00b29fb4/b8/bc** | **`vector 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::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 ` +— 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 | diff --git a/ghidra/addresses.d/lane-ai3.json b/ghidra/addresses.d/lane-ai3.json new file mode 100644 index 0000000..47084fa --- /dev/null +++ b/ghidra/addresses.d/lane-ai3.json @@ -0,0 +1,124 @@ +{ + "entries": [ + { + "name": "StrategyApp_OnClientEvent", + "addr": "0x00838e10", + "convention": "cdecl", + "prototype": "void __cdecl Game::StrategyApp::OnClientEvent(int netId, int eventId, void* ev) -- THE AI ENQUEUE SITE (lane AI2 §6.1 open item, closed). Operates on the STATIC StrategyApp at 0x00b29f98 (not a pointer -- `mov ecx,0xb29f98` at 0x007843a2 proves the object itself lives there). Body: (1) `if (*(void**)0x00b29f9c == 0) return` -- app+0x4, the StrategyServer; (2) `if (netId == 0) return`; (3) linear search of the client vector at app+0xc/+0x10 (absolutes 0x00b29fa4/0x00b29fa8) for `client->+0x148 == netId`; not found -> return; (4) `p = client->+0x150`; (5) `if (eventId == 0x26 (SEResumePlaying) && p->+0xf9 != 0 && p->+0xfa == 0)` then `if (!0x00438fe0(&pending, &netId)) 0x0059f1a0(&pending, &netId)` -- a DEDUPLICATED push_back onto the pending-AI vector at app+0x1c (absolute 0x00b29fb4), and RETURN; (6) otherwise `StrategyClient::RaiseEvent 0x00783ee0(client, eventId, ev)` inline. So event 0x26 for an AI player is the ONLY deferred event; everything else is delivered synchronously. Registered as StrategyServer+0x170 by CreateGame at 0x00889177; it has ZERO direct callers and no vtable slot (lane B6's third blind spot)", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read of dumps/sots.exe swept to the next function start (rule 17); enqueue located by absolute-reference scan for 0x00b29fb4, which has exactly 2 references in the image, both here" + }, + { + "name": "StrategyServer_ResumePlaying", + "addr": "0x007ddc90", + "convention": "thiscall", + "prototype": "void __thiscall Game::StrategyServer::ResumePlaying() -- THE STEPPING ORDER. (1) `if (0x0080f4d0(&this->+0x4)) return`; (2) `if (++this->+0x168 == 1 && this->+0x1b4) { obs->vt[4](2,0); obs->vt[7](); }`; (3) 0x007dd230(&this->+0x174, 0) then 0x007dd230(&this->+0x174, playerCount) -- clear+resize; (4) FIRST walk of the player vector at this->+0x54..+0x58, IN INDEX ORDER: `if (!p->Elim /*+0xf8*/) p->Status /*+0x164*/ = 0` -- this is a Player.Status WRITER; (5) SECOND walk, again in index order: `if (p->Status == 0) { ev = {vptr 0x00a23bb8}; cb = this->+0x170; cb ? cb(p->+0x4 /*netId*/, 0x26, &ev) : Log(0x00a23c08); }`. The callback is StrategyApp::OnClientEvent 0x00838e10, so the pending-AI vector is filled in SERVER PLAYER INDEX ORDER -- i.e. save player order -- and RunPendingAITurns then walks it in index order. Both Elim and Status are save-visible ServerPlayer fields", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read; field names from findings/objects/struct-recovery.md (0xf8 Elim, 0x164 Status)" + }, + { + "name": "StrategyServer_SetClientEventCallback", + "addr": "0x007861f0", + "convention": "thiscall", + "prototype": "void __thiscall Game::StrategyServer::SetClientEventCallback(void (__cdecl* cb)(int netId, int eventId, void* ev)) -- RET 4. Two instructions: `this->+0x170 = arg`. The only registration in the image is CreateGame 0x00889177 installing StrategyApp::OnClientEvent 0x00838e10. Every server->client event in the game funnels through this one pointer; SynchronizePlayer 0x007c6220 dispatches through it at 0x007c6384, 0x007c65aa, 0x007c6889, 0x007c6a00, 0x007c6ae0 and more", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "g_StrategyApp", + "addr": "0x00b29f98", + "convention": "data", + "prototype": "Game::StrategyApp -- the STATIC APP OBJECT ITSELF, not a pointer to one. Proved by `mov ecx,0xb29f98; call ` at 0x007842f6/0x007843a2 and by the absolute pair 0x00b29fa4/0x00b29fa8 being read where a method reads this->+0xc/this->+0x10. Layout confirmed this lane: +0x0 flag byte (bit 2 = 'a StrategyServer exists'), +0x4 StrategyServer*, +0xc/+0x10/+0x14 vector, +0x1c/+0x20/+0x24 vector pendingAITurns (absolutes 0x00b29fb4/b8/bc), +0x2c AIProcessMinTime (seconds, float). Lane AI2's scan for 'functions that load 0x00b29f98' missed the enqueue because MSVC folds the object base into the absolute address of the member: the enqueue writes 0x00b29fb4 directly and never materialises 0x00b29f98", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08" + }, + { + "name": "StrategyAIAgent_AcquireFleetsForTask", + "addr": "0x006ceef0", + "convention": "cdecl", + "prototype": "void __cdecl Game::StrategyAIAgent::AcquireFleetsForTask(StrategyAIAgent* agent, IAITask* task, double dA, double dB, void* targetA, void* targetB, vector* candidates, int pass, int flag, vector* out) -- THE HUB EVERY FLEET-SHAPED TASK GOES THROUGH, and where `pass` acquires its meaning. Real span 992 bytes to the next function start (Ghidra's size is short). Three blocks, each a `for (i = 0; i <= pass; ++i) gather(..., i, ...)` loop followed by an 0x00698960 sufficiency test: block A (0x006cef5a, entered when targetB->+0x14 == 0, or == 2 with task->GetTypeId() == 0x1e) gathers via 0x006abf80; block B (0x006cf029) gathers via 0x006b7c90; block C (0x006cf0ee, only if A and B both failed and targetA != 0) gathers via 0x006cb310, taking task->vt[11]() at 0x006cf10e as an argument. ALL THREE order-emitting exits are pass==1 only: 0x006bbd50 (0x006cefcf, 0x006cf180) returns immediately unless pass==1, and 0x006c16c0 (0x006cf198) takes the arm at 0x006c1791 only when pass==1. The result vector at [ebp-0x3c] has exactly two possible writers -- 0x006bbd50 and 0x006c16c0 -- enumerated from every `lea` of that slot in the body, so on pass 0 this function RETURNS AN EMPTY FLEET LIST AND WRITES NO TurnCommands. At 0x006cf1aa it special-cases task->GetTypeId() 0x17 (StockFreighters) and 0x1a (NodeBore), substituting 0.0 for dA", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "StrategyAIAgent_GatherFleetsForTier", + "addr": "0x006abf80", + "convention": "cdecl", + "prototype": "void __cdecl Game::StrategyAIAgent::GatherFleetsForTier(StrategyAIAgent* agent, float threshold, void* target, vector* candidates, int tier, vector* out) -- 112 bytes. Walks the 0x20-stride candidate vector in index order, calling 0x006abb00(agent, threshold, target, &cand[k], tier, out, &out[k]) for each. `tier` is the loop index i of AcquireFleetsForTask's `for (i = 0; i <= pass; ++i)`, so it takes the values 0..pass", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "StrategyAIAgent_FillCandidateToTierQuota", + "addr": "0x006abb00", + "convention": "cdecl", + "prototype": "void __cdecl Game::StrategyAIAgent::FillCandidateToTierQuota(StrategyAIAgent* agent, float threshold, void* target, Candidate32* cand, int tier, vector* out, Slot36* slot) -- 464 bytes. THE INSTRUCTION THAT DEFINES THE TWO PASSES, at 0x006abb1c..0x006abb4d: `have = 0x00695b90(&slot->+0x10) + 0x00698860(slot) + slot->+0x20; want = (tier == 0) ? cand->+0x10 : (tier == 1) ? cand->+0x14 : 0; if (have >= want) return;` -- a compiler-generated switch on tier with case 0 -> 0x006abb41 (cand->+0x10) and case 1 -> 0x006abb39 (cand->+0x14). So each candidate carries TWO quota fields and `pass` selects which one is in force: pass 0 fills the +0x10 quota, pass 1 re-runs tier 0 and then fills the larger +0x14 quota. Then a per-fleet filter loop rejecting on 0x0069c8c0, IsClaimedByAnotherTask 0x006a8d20 and 0x006ab900", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "StrategyAIAgent_IssueRouteForFleets", + "addr": "0x006bbd50", + "convention": "cdecl", + "prototype": "bool __cdecl Game::StrategyAIAgent::IssueRouteForFleets(StrategyAIAgent* agent, int pass, vector* fleets, void* target, vector* out) -- 192 bytes. `if (pass != 1) return;` at 0x006bbd78 (the MSVC `sub eax,0 / je / dec / jne` switch shape). Otherwise walks the 0x24-stride fleet vector, calling 0x0057aac0 per element to build a route, then 0x006b76a0(agent, &route, target, out), which is one of the three callers of AI_IssueFleetTask 0x006987e0. This is one of the two pass-1 gates that make pass 0 emit nothing", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "StrategyAIAgent_RequestBuildForTask", + "addr": "0x006cea50", + "convention": "cdecl", + "prototype": "void __cdecl Game::StrategyAIAgent::RequestBuildForTask(StrategyAIAgent* agent, IAITask* task, int pass, double, double, void* targetA, void* targetB, void* targetA2, vector* gathered) -- 304 bytes, AcquireFleetsForTask's LAST-RESORT arm: no fleet could be found, so build ships. Two gates in the prologue: (1) 0x006cea7b..0x006ceaa6 `if (agent->+0x10->+0x150->+0x2d8 /*plcy*/ == 0 && task->GetTypeId() != 0x1a /*NodeBore*/) return` -- a SECOND, independent consumer of the save-visible `plcy` field, beyond the two defence creators lane AI2 found; (2) 0x006ceaac `if (pass != 1) return`. Reaches list 3 (build orders) via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "StrategyAIAgent_AssignFleetsAndIssueOrders", + "addr": "0x006c16c0", + "convention": "cdecl", + "prototype": "void __cdecl Game::StrategyAIAgent::AssignFleetsAndIssueOrders(StrategyAIAgent* agent, IAITask* task, int pass, vector* fleets, void* targetA, void* targetB, int flag) -- 3536 bytes, the busiest AI->TurnCommands function. `if (pass != 1) goto 0x006c241f` at 0x006c177e (the same `sub eax,0 / je / dec / jne` shape), so its ENTIRE working body -- including both calls to AI_IssueFleetTask 0x006987e0 at 0x006c1c86 and 0x006c1f78, and the two 0x00699fa0 -> list 8 paths -- runs on pass 1 only. Its only indirect call sites are four import thunks (0x009dd12c/0x009dd150), so its direct-call closure is complete: no vtable edge can escape it", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read; indirect-site census from tools/vtable_map.py" + }, + { + "name": "StrategyAIAgent_IsClaimedByAnotherTask", + "addr": "0x006a8d20", + "convention": "thiscall", + "prototype": "bool __thiscall Game::StrategyAIAgent::IsClaimedByAnotherTask(void* obj) -- RET 4. NAMES IAITask VTABLE SLOT 12 (lane AI2 §10.2). Looks `obj->+4` up in the 8-byte-stride claim registry at agent->+0x2e8..+0x2ec (pairs of {IAITask* owner, int objectId}) and in the 4-byte set at agent->+0x2d8..+0x2dc; if the object is in neither, returns false (free). Otherwise `cur = back(agent->+0x12c /*the task call stack*/)`; with an empty stack or a null top it returns TRUE (claimed). Then at 0x006a8db3: `if (!cur->vt[12]()) return true;` -- and when slot 12 IS set, it returns false (i.e. lets the task take the object) only when the owner exists, `cur->GetTypeId() != owner->GetTypeId()`, and `cur->GetPriority() > owner->GetPriority()`. So SLOT 12 IS A PREEMPTION PERMISSION: 'this task may take an object already claimed by a strictly lower-priority task of a different type'. Default false; five classes set it. Caller 0x006abb00 skips the candidate when this returns true", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "StrategyAIAgent_RangePenaltyForTask", + "addr": "0x00696620", + "convention": "thiscall", + "prototype": "int __thiscall Game::StrategyAIAgent::RangePenaltyForTask() -- the ONLY consumer of IAITask vtable slot 13 found in the image, dispatched 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;` else a 7-arm species switch on player->+0x5c through the byte index at 0x006966a8 = [0,0,0,0,2,1,0] and the table at 0x0069669c: species 0,1,2,3,4,6 and out-of-range -> 1000000 (0x000f4240), species 5 (Zuul) -> 0. So slot 13 is a RANGE/HOP BUDGET compared against a count, with a prohibitive penalty past it -- and the Zuul are exempt, a FOURTH independent cross-check on lane AI2's species reading (after Hiver gates, Zuul node-bore and NPC building nothing). The two 'Incoming' defence tasks return INT_MAX from slot 13, so they never take the penalty", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read; jump table and byte index read from the image at instruction boundaries" + }, + { + "name": "AITask_GetPriorityDefaultThunk", + "addr": "0x00694220", + "convention": "thiscall", + "prototype": "int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08, instruction-stream read" + }, + { + "name": "g_AITInvadeUncommittedPriority", + "addr": "0x00a1795c", + "convention": "data", + "prototype": "int -- image-initialised value 650 (0x0000028a). AITInvade::GetPriority 0x00683670 is `movzx eax,byte [ecx+4]; not al; test al,1; je +5; jmp 0x00694220; mov eax,ds:0xa1795c; ret` -- so the tunable is returned when BIT 0 OF this->+0x4 IS SET, which is the OPPOSITE of lane AI2's stated `if (!(this->+0x4 & 1))`. It has EXACTLY ONE reference in the whole image (this load) and no writer anywhere: no loader, no CSV path. It is a code constant that happens to live in the writable data section. AITInvade's table priority is 500, so the flag raises it to 650", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08; value read from the PE image, reference count from an exhaustive 4-byte absolute-reference scan" + }, + { + "name": "g_AITEscortGateInvadeUncommittedPriority", + "addr": "0x00a17960", + "convention": "data", + "prototype": "int -- image-initialised value 750 (0x000002ee), the twin of 0x00a1795c. AITEscortGateInvade::GetPriority 0x006835e0 has the identical shape and the identical inverted polarity: the tunable applies when bit 0 of this->+0x4 IS SET. Exactly one reference in the image, no writer. AITEscortGateInvade's table priority is 400, so the flag raises it to 750", + "status": "verified", + "source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08; value read from the PE image, reference count from an exhaustive 4-byte absolute-reference scan" + } + ] +}