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" + } + ] +}