sots-re/ghidra/addresses.d/lane-ai3.json

117 lines
No EOL
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 \u00a76.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 \u00a710.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": "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"
}
],
"_note": "AITask_GetPriorityDefaultThunk dropped 2026-09-08: same address 0x00694220 as lane-ai2's AITask_slot10_GetPriority, reached independently (AI2 via the slot-10 priority read, AI3 via the default thunk). Agreement recorded; AI2's name kept because the merged game/ai code already uses that vocabulary. Caught by gen_addresses.py's new same-address check, not by hand."
}