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

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

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

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

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

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

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

124 lines
15 KiB
JSON

{
"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 <method>` 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<StrategyClient*>, +0x1c/+0x20/+0x24 vector<int> 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<Candidate32>* candidates, int pass, int flag, vector<StarFleet*>* 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<Candidate32>* candidates, int tier, vector<Slot36>* 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<Slot36>* 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<Slot36>* fleets, void* target, vector<StarFleet*>* 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<Slot36>* 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<StarFleet*>* 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"
}
]
}