diff --git a/campaign/board.md b/campaign/board.md index 533a070..d72c2c9 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -260,3 +260,9 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | trade-route + spy workload NOT BUILT, with reasons | verify | backlog | — | 0% | 2026-09-08 | Lane H could not build it and said exactly why: from ref-turn2 (turn 2, 1 colony, 0 techs) trade income needs trade-station construction PLUS its tech - tens of 30-60 s End Turns with a Build/Research click path each; **the corpus's newest Zuul save (turn 23) STILL has no trade routes** (slot 15's body did not execute on it); and NO LANE HAS IDENTIFIED WHICH UI produces an entry in the spyManager+0x10 vector, so there is no click path to write down. WHAT IT GOT INSTEAD IS BETTER FOR THE NEXT ATTEMPT: two named vectors to watch fill, so a workload can be CONFIRMED BEFORE a turn is spent measuring | | VM menu detection: use Exit-button redness | meta | verified | high | 100% | 2026-09-08 | The intro screens are bright white so brightness-based menu detection fires EARLY. Detect **redness in the Exit button**: `crop 100x14+462+674`, `mean.r > 0.2 && mean.g < 0.15`. With that, all ten of lane H's launches hit the menu first try at t+48-54 s | | lane H self-report: touched a shared worktree | meta | verified | high | 100% | 2026-09-08 | Recorded because it was self-reported. Lane H ran `git checkout -- include/generated/sots_addresses.h` in the SHARED ~/sots-engine main worktree, discarding an uncommitted regeneration another lane had in flight. It is a generated file and was regenerated, so no content was lost - but it was not that lane's tree to touch. Reinforces: lanes work in their OWN worktree, and generated files are regenerated by the integrator at merge | +| AI TASK SELECTION LOOP FOUND - it is not a scorer | control-flow | verified | high | 95% | 2026-09-08 | Lane AI2. **0x006cf630**, Process-Turn phase 20, which AI1 had listed only as an address. THERE IS NO SEARCH: PruneTasks -> `switch (player->Species)` (7-entry table @0x006cf880, FOUR distinct arms) building a FIXED, SOURCE-ORDERED list of per-family task creators -> PruneTasks -> **sort TaskList DESCENDING by task->vt[10]() using MSVC std::list::sort, which is STABLE** -> two passes of `task->vt[5](agent, pass)` -> PruneTasks. And vt[10] is, for 26 of 31 task classes, a CONSTANT LOOKUP on the task's type id through **0x00691f00** - 33 arms, each `mov eax,imm32; ret`. **THAT TABLE IS THE AI'S ENTIRE STRATEGIC ORDERING POLICY** and it is in the findings verbatim (DeployGateAt 1400 down to AdvanceIdleShips 0). Five overrides; the two artifact tasks IGNORE their table entries and return 1260/1261. The switch is on player+0x5c = Species, VERIFIED THREE WAYS: arm 1 is the only one building gate families (Hiver), arm 5 the only one building AITNodeBore (Zuul), and arm 4 - species 4, the engine's own Species::NPC - **BUILDS NOTHING AT ALL**. Both defensive families sit behind player+0x2d8 = the save field `plcy` | +| order-method -> TurnCommands mapping COMPLETE | objects | verified | high | 95% | 2026-09-08 | Done image-wide (94 `lea ecx,[r32+0x160]` sites in 76 functions). **ALL 27 of lane Q's lists and ALL SIX prologue gates now have a NAMED PRODUCER.** AI1's 21-row table was incomplete - there are **26** order methods, and the missed ones include list 5 (system rates, AI-called) and list 7 (colonize, AI-called). **LANE Q'S UNEXPLAINED LIST 14 IS THE AI'S FLEET ORDER**: 0x007634d0(fleetObj, mode, flag) -> {fleetId, mode, flag}, adder 0x00842a00 at +0x10c keyed on (fleetId, mode); the AI bridge 0x006987e0 calls it **TWICE**, (f,0,true) then (f,1,true), while the UI emits only mode 0. 24 of 31 tasks' Execute reach it. **That is a ModCount prediction: TWO bumps per AI fleet order, not one** - against lane A2's measured 10 | +| AI throttle SETTLED - Rung B is safe | control-flow | verified | high | 100% | 2026-09-08 | The open question from lane AI1 (if an AI turn can span frames, the "all AI orders in before the human's End Turn" ordering needed revisiting). **StrategyApp::RunPendingAITurns 0x00838c60 is called EVERY FRAME from Update 0x007842b0**, walks the pending-AI vector at StrategyApp+0x1c IN INDEX ORDER raising SEResumePlaying on each - NO early exit, NO yield - and THEN does Sleep(AIProcessMinTime - elapsed). **AIProcessMinTime is a TRAILING MINIMUM-DURATION SLEEP FOR THE PROGRESS DIALOG, not a compute budget.** An AI turn CANNOT span frames; AI1's ordering holds | +| g_CurrentClientIndex is a STACK POINTER | objects | verified | high | 100% | 2026-09-08 | Not an index. Two writers only, Push/PopCurrentClient 0x00578020/40, and StrategyAIAgent::OnEvent BRACKETS THE WHOLE AI TURN in them - that is how every cl_* call lands on the right client AND the right per-client RNG. Also: AI1's one inferred hop is now READ TO THE CALL at 0x006b395b (Broadcast -> OnAIPacket), push order matching the thunk exactly; lane-ai1.json's Broadcast and g_CurrentClientIndex upgraded mapped -> verified in place | +| AI2 corrections to AI1 | meta | verified | high | 100% | 2026-09-08 | **31 task classes, not 34** - slot-1 x slot-8 across 31 vtables gives a gapless type enum 0..0x20 with two retired ids. **26 order methods, not 21.** **7 pure virtuals, not 8** - named: GetTypeId, GetTargetA/B, Execute(agent,pass), IsFinished(agent), GetTypeName, Describe. Also a test expectation was wrong on first run (ColonizeAt 1300 outranks NodeBore 1275) and lane AI2 FIXED THE TEST, NOT THE CODE | +| AI2 boundary | meta | verified | high | 100% | 2026-09-08 | Slots 11/12/13 UNNAMED. The nine tasks with no reachable order method are INFERRED to be goal/planner tasks - bodies unread. The two-pass meaning is INFERENCE. The reachability table is a DIRECT-CALL CLOSURE = lower bound (rule 16 bites hard in a vtable system). **The StrategyApp+0x1c ENQUEUE SITE WAS NOT FOUND, so AI-stepping order is still open** - and that matters because ModCount is a count. The .data invade tunables 0x00a1795c/60 have no traced loader. NOTHING RAN UNDER AN INSTRUMENT; four falsifiable predictions with symptoms are in the finding | diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index 2649df2..2074718 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ bebdee1, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 232397a, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1327,7 +1327,7 @@ constexpr uint32_t StrategyApp_RaiseAIPrepareTurn = 0x00415f20; constexpr uint32_t StrategyAIAgent_OnEvent = 0x002d0ad0; // thiscall void __thiscall Game::StrategyAIContext::OnStrategyEvent(int clientEventType, Game::StrategyEvent** ev, void (*cb)(void*, void*), Game::StrategyAIAgent* agent) -- RET 0x10. Registers {cb, agent, seq} on the pending-callback deque at this+0x58 (ring deque, buf@+0x5c cap@+0x60 head@+0x64 size@+0x68, 0x0c-byte nodes, push helper 0x0069e470 under a critical section). Then `switch (type - 6)` over 0..0x20 through the byte index table at 0x006c33a4 and jump table at 0x006c3360 (17 distinct cases), updating the AI world model and emitting INTERNAL AI packets {int code; ...} through StrategyAIContext::Broadcast 0x006b3840. Client event 9 (SEAIPrepareTurn) emits codes 1 then 2; client event 0x26 (SEResumePlaying) emits code 3. Tail: if the pending deque size is 1 it drains a second, separate queue at this+0x38 via 0x006a8690 [verified] constexpr uint32_t StrategyAIContext_OnStrategyEvent = 0x002c2b90; -// thiscall void __thiscall Game::StrategyAIContext::Broadcast(const AIPacket* pkt) -- walks the listener red-black tree at this+0xc (std::set/map nodes; `_Isnil` at node+0x15) and calls listener->vt[3](pkt) on each -- that is the Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet (0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50). Then, if the pending-callback deque at this+0x68 is non-empty, iterates it (0x0069e510 / 0x006a4ee0) and delivers the same packet to the queued {cb, this} pairs -- the hop that reaches StrategyAIAgent::OnAIPacket 0x006cf8a0. 19 call sites, all inside OnStrategyEvent 0x006c2b90 and 0x006c29c0 [mapped] +// thiscall void __thiscall Game::StrategyAIContext::Broadcast(const AIPacket* pkt) -- walks the listener red-black tree at this+0xc (std::set/map nodes; `_Isnil` at node+0x15) and calls listener->vt[3](pkt) on each -- that is the Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet (0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50). Then, if the pending-callback deque at this+0x68 is non-empty, iterates it (0x0069e510 / 0x006a4ee0) and delivers the same packet to the queued {cb, this} pairs -- the hop that reaches StrategyAIAgent::OnAIPacket 0x006cf8a0. 19 call sites, all inside OnStrategyEvent 0x006c2b90 and 0x006c29c0 [verified] constexpr uint32_t StrategyAIContext_Broadcast = 0x002b3840; // thiscall void __thiscall Game::StrategyAIAgent::OnAIPacket(const AIPacket* pkt) -- 2008 bytes. `eax = pkt->code - 2; if (eax > 0xf) return; jmp [eax*4 + 0x006d0078]` -- a 16-entry jump table over internal packet codes 2..17. Code 2 = the PREPARE TURN body (0x006cf958, logs "====== AI Prepare Turn (%s) ======"); code 3 = the PROCESS TURN body (0x006cfabf, logs "====== AI Process Turn (%s) ======", ~30 phases, ends by calling cl_EndTurn 0x00579310). Codes 4/6/7/8/10/11/13 fall through to the no-op at 0x006d0058. this->+0x10 = the owning StrategyClient, this->+0x14 = the ClientPlayer (name std::string at +0x40), this->+0x94 = the StrategyAIContext. Reached only through the thunk at 0x006d0ab0 [verified] constexpr uint32_t StrategyAIAgent_OnAIPacket = 0x002cf8a0; @@ -1353,7 +1353,7 @@ constexpr uint32_t cl_Chance = 0x00178cf0; constexpr uint32_t cl_RandRange = 0x001798e0; // offset Game::StrategyClient* g_StrategyClients[] -- the client table the whole 0x00578cf0..0x005793xx façade family indexes with g_CurrentClientIndex (0x00ae4808). 40 functions reference it. The AI runs as the current client: everything it does goes through this indirection, which is how one process hosts the human client and N AI clients over the same API [mapped] constexpr uint32_t g_StrategyClients = 0x006e47e4; -// offset int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. Not instrumented; who sets it, and when relative to the AI's turn, is open [mapped] +// offset int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. CORRECTED by lane AI2 2026-09-08: it is a STACK POINTER, not a plain index. The only two instructions in the image that write it are PushCurrentClient 0x00578020 (`g_StrategyClients[idx+1] = c; ++idx;`) and PopCurrentClient 0x00578040 (`--idx;`); the other 40 referencing functions only read `[idx*4 + 0x00ae47e4]`. StrategyAIAgent::OnEvent 0x006d0ad0 brackets the whole AI turn in Push(agent->+0x10)/Pop [verified] constexpr uint32_t g_CurrentClientIndex = 0x006e4808; // offset Mars::RNG -- a STATIC generator in .data, 0x9cc bytes. Its only static initialiser (0x009dc6e0) writes the Mars::IStreamable vftable 0x009e22bc, NOT the Mars::RNG vftable 0x009e9aec that RNG_Seed installs: none of the six RNG_Seed call sites in the image targets it, so its mt[624] is the zero-initialised BSS array and `left` is 0. An all-zero MT19937 state is a fixed point of the twist, so EVERY draw from it returns 0. Five consumers: SNMRunAI (the AI client seed, OnMessage+0x955), RunCombatRound 0x007cbe80+0x60f, 0x007c2fa0+0xc84, 0x0079ea90+0x73 (an RNG_Chance) and 0x005b9f00+0xc0 [verified] constexpr uint32_t g_GlobalRNG = 0x006f6e58; @@ -1365,6 +1365,56 @@ constexpr uint32_t AIPersonaDB_LoadStockTables = 0x002c6250; constexpr uint32_t AIRulesDB_LoadAffinityTables = 0x002c63c0; // thiscall void __thiscall -- loads Data/Strategy/AI/weapon_replacements.csv through Game::StrategyAIContext::WeaponReplacementsRowParser (vftable 0x00a1a62c). Consumed by 0x00694f80 ("StrategyAIContext::GetWeaponReplacement: maxReplacements (%i)") [mapped] constexpr uint32_t StrategyAIContext_LoadWeaponReplacements = 0x002b4dc0; +// thiscall void __thiscall Game::StrategyAIAgent::RebuildAndRunTasks() -- THE TASK SELECTION LOOP. Phase 20 of the AI Process Turn body (called from 0x006cfc94). Order: (1) 0x006b34f0(this, &this->+0x2f8) refreshes the per-fleet world model over client->+0x60..+0x64; (2) PruneTasks 0x006b3640(this); (3) `switch (client->+0x150->+0x5c)` over 0..6 through the 7-entry jump table at 0x006cf880 -- four distinct arms (case 0/2/3/6 -> 0x006cf665, case 1 -> 0x006cf6f8, case 5 -> 0x006cf75d, case 4 -> NOTHING) each calling a fixed, source-ordered list of per-task-family creators in 0x006ab6c0..0x006c0e60; (4) if this->+0x8, that object's vt[1](this); (5) PruneTasks again; (6) TaskList_SortByPriority 0x006bf9c0(&this->+0x31c, player->+0x5c) -- std::list::sort, STABLE, DESCENDING by IAITask::vt[10](); (7) RunTaskList 0x006b3320(this, &this->+0x31c, 0, &this->+0x2e8) then again with pass=1; (8) PruneTasks again; (9) if player->+0x2d8 in {1,2}, 0x006cf4c0 then 0x006cf590; (10) 0x006a8eb0(this) -- reaches client order method 0x00763a20; (11) if this->+0x124, cl_SetResearchRate(*(float*)0x009e2ea0) and clear the flag; (12) this->+0x128 = 0 [verified] +constexpr uint32_t StrategyAIAgent_RebuildAndRunTasks = 0x002cf630; +// thiscall void __thiscall std::list::sort(Pred) on the agent's task list -- the MSVC 7.1 binlist sort: eh_vector_constructor_iterator over 26 (0x1a) 0x0c-byte std::list bins, the `_Bin == 25` overflow branch, merge helper 0x006a9850. The Pred is a 4-byte functor carrying player->+0x5c, and the inlined comparison IGNORES it: the whole ordering key is IAITask::vt[10]() (see AITask_slot10_GetPriority). std::list::sort is STABLE, so ties keep creation order -- which makes the per-arm creator call order in RebuildAndRunTasks part of the answer, not an implementation detail [verified] +constexpr uint32_t StrategyAIAgent_TaskListSortByPriority = 0x002bf9c0; +// thiscall void __thiscall std::list::merge(list& right, Pred) -- RET 8. THE COMPARISON, inlined at 0x006a9879..0x006a9895: `a = A->vt[10](); b = B->vt[10](); if (a > b) splice A before B;` (`cmp [ebp-0x10],eax / jle` -- so a strictly-greater test, descending order, ties left alone). Both calls are __thiscall with no stack args, which pins IAITask::vt[10] as `int GetPriority(void)` [verified] +constexpr uint32_t StrategyAIAgent_TaskListMerge = 0x002a9850; +// cdecl void (Game::StrategyAIAgent* agent, std::list* tasks, int pass, std::vector* pending) -- THE TASK EXECUTION LOOP, run twice per turn with pass = 0 then 1. For each node of `tasks` in list order (i.e. priority order after the sort): task = node->value; erase task from `pending` (std::find 0x0069af70 + memmove compaction); push_back task onto the agent's active-task stack at agent->+0x12c/+0x130/+0x134 (growth helper 0x00483410, "vector too long"); call `task->vt[5](agent, pass)`; then if back() is still that task, pop_back. The push/pop bracket makes agent->+0x12c a task CALL STACK, which is how goal tasks (AITColonizeGoal, AITInvadeGoal, AITEscortGateInvadeGoal) nest sub-tasks [verified] +constexpr uint32_t StrategyAIAgent_RunTaskList = 0x002b3320; +// cdecl void (Game::StrategyAIAgent* agent) -- `for each node of agent->+0x31c: task = node->value; if (task->vt[6](agent)) { erase task from the vector agent->+0x2e8..+0x2ec; agent->RemoveTask(task) 0x006af900; }`. Called three times inside RebuildAndRunTasks: before creation, after creation, and after execution. This is what pins IAITask::vt[6] as `bool IsFinished(StrategyAIAgent*)` -- returning true destroys the task [verified] +constexpr uint32_t StrategyAIAgent_PruneTasks = 0x002b3640; +// thiscall void __thiscall Game::StrategyAIAgent::RemoveTask(Game::IAITask* task) -- unlinks the task from four containers: the master list at this+0x31c and the vector at this+0x2e8 (via 0x006ae930), the 0x0c-stride vector at this+0x1cc (via 0x006a95e0), and the 0x20-stride vector at this+0x208..+0x20c (find 0x0069b0e0 then a rep-movsd compaction of 0x20-byte records) [verified] +constexpr uint32_t StrategyAIAgent_RemoveTask = 0x002af900; +// cdecl int (int taskTypeId) -- the AI's whole task-ordering policy as one switch: `if ((unsigned)id > 0x20) return 0; jmp [id*4 + 0x00691ffc]`, 33 arms each a single `mov eax,imm32; ret`. Values (id -> priority): 0 AITSteamroll 1250, 1 AITExplore 600, 2 AITExploreInForce 550, 3 AITEscortGate 700, 4 AITEscortGateInvade 400, 5 AITEscortGateInvadeGoal 950, 6 AITDeployGateAt 1400, 7 AITColonize 900, 8 AITColonizeGoal 970, 9 AITColonizeAt 1300, 0xa AITInvade 500, 0xb AITInvadeGate 1000, 0xc AITInvadeGoal 930, 0xd (no class) 200, 0xe AITDefendColonyIncoming 1100, 0xf (no class) 300, 0x10 AITDefendGateIncoming 1200, 0x11 AITKillEasterEgg 800, 0x12 AITInterceptEnemy 850, 0x13 AITMining 350, 0x14 AITMiningReturn 375, 0x15 AITAttackBlockade 100, 0x16 AITAdvanceIdleShips 0, 0x17 AITStockFreighters 50, 0x18 AITRespondAttackSystem 980, 0x19 AITRespondDefendSystem 990, 0x1a AITNodeBore 1275, 0x1b AITBuildStations 910, 0x1c AITBuildPoliceShips 75, 0x1d AITBuildDeepScanShips 60, 0x1e AITRaid 399, 0x1f AITRetrieveArtifact 1, 0x20 AITReturnArtifact 2. The last two table entries are DEAD: both artifact classes override vt[10] with fixed 0x4ec/0x4ed (1260/1261). Ids 0xd and 0xf have priorities but no surviving class [verified] +constexpr uint32_t AITask_PriorityForType = 0x00291f00; +// thiscall int __thiscall Game::IAITask::GetPriority() -- vtable slot 10, the DEFAULT implementation, used by 21 of the 31 concrete tasks (5 more reach it through the thunk 0x00682650): `return AITask_PriorityForType(this->vt[1]());`. Overrides: AITInvade 0x00683670 and AITEscortGateInvade 0x006835e0 return the globals at 0x00a1795c / 0x00a17960 when `this->+0x4 & 1` is clear, else default; AITAttackBlockade 0x00685600 scans a 0xc-stride vector at this->+0x8->+0x1cc for a related task and filters on its type id (1, 2, 7, 0x11, ...); AITRetrieveArtifact 0x005465a0 returns 0x4ec and AITReturnArtifact 0x00546800 returns 0x4ed unconditionally [verified] +constexpr uint32_t AITask_slot10_GetPriority = 0x00294220; +// offset Game::IAITask vtable slot 1 (byte offset 4) -- `int GetTypeId(void)`, PURE in the interface (vftable 0x009fa354), and in all 31 concrete classes a single 16-byte `mov eax,imm32; ret` returning a value in 0..0x20. It is the key into AITask_PriorityForType and the discriminator every cross-task filter uses [verified] +constexpr uint32_t AITask_vt_slot1_GetTypeId = 0x00000004; +// offset Game::IAITask vtable slot 5 (byte offset 0x14) -- `void Execute(Game::StrategyAIAgent* agent, int pass)`, RET 8, PURE in the interface. THE task body: 27 distinct implementations across the 31 classes, 48..288+ bytes each, dispatched from StrategyAIAgent_RunTaskList 0x006b3320+0x167 (`mov edx,[task_vt+0x14]; push pass; push agent; mov ecx,task; call edx`). This is the only slot from which a client order method is ever reached [verified] +constexpr uint32_t AITask_vt_slot5_Execute = 0x00000014; +// offset Game::IAITask vtable slot 6 (byte offset 0x18) -- `bool IsFinished(Game::StrategyAIAgent* agent)`, RET 4, PURE in the interface. Called ONLY from StrategyAIAgent_PruneTasks 0x006b3640+0x2b; true means unlink and destroy. 27 distinct implementations; the two shared trivials are 0x005eda80 `return false` (AITAdvanceIdleShips, AITSteamroll -- never retire) and, e.g., AITBuildDeepScanShips 0x00682fe0 `return !0x0069a7f0(agent, 0x20, 0)` [verified] +constexpr uint32_t AITask_vt_slot6_IsFinished = 0x00000018; +// offset Game::IAITask vtable slot 8 (byte offset 0x20) -- `const char* GetTypeName(void)`, PURE in the interface; in all 31 classes a 16-byte `mov eax,; ret` returning the class's own unmangled name ("AITRaid", "AITColonizeGoal", ...). Pairing slot 1 with slot 8 across the 31 vtables yields the complete task-type enum with no gaps except ids 0x0d and 0x0f [verified] +constexpr uint32_t AITask_vt_slot8_GetTypeName = 0x00000020; +// offset Game::IAITask vtable slot 9 (byte offset 0x24) -- `void Describe(void)`, no args, PURE in the interface. Every implementation is a single log call of the form `Log(": %s -> %s\n", NameOf(vt2()), NameOf(vt3()))` (AITAdvanceIdleShips prints the literal "AITAdvanceIdleShips: n/a -> n/a\n"). It is what pins slots 2 and 3 as the task's source and destination target getters [verified] +constexpr uint32_t AITask_vt_slot9_Describe = 0x00000024; +// offset Game::IAITask vtable slot 7 (byte offset 0x1c) -- `void OnObjectDestroyed(void* obj)`, RET 4, NOT pure: the interface default 0x005f8ac0 is a bare `ret 4`. The dominant override 0x00682540 (17 of 31 classes) nulls whichever of this->+0xc and this->+0x8 holds an object whose +0x4 equals the argument -- i.e. it drops dangling target references [verified] +constexpr uint32_t AITask_vt_slot7_OnObjectDestroyed = 0x0000001c; +// cdecl void (Game::StrategyClient* c) -- `g_StrategyClients[g_CurrentClientIndex + 1] = c; ++g_CurrentClientIndex;` (written as `mov [eax*4+0x00ae47e8],ecx` with eax = the old index, then `inc [0x00ae4808]`). So 0x00ae47e4 is a STACK of client scopes and 0x00ae4808 is its stack pointer, not a plain index -- the whole cl_* family reads `[idx*4 + 0x00ae47e4]`, i.e. the top of stack. 18 callers; the AI-relevant one is StrategyAIAgent::OnEvent 0x006d0ad0, which brackets the ENTIRE AI turn in Push(agent->+0x10) / Pop. That is the mechanism by which every cl_* call the AI makes -- cl_Chance, cl_RandRange, cl_SetResearchRate, cl_EndTurn -- lands on that AI's own client and its own RNG at client+0x134 [verified] +constexpr uint32_t PushCurrentClient = 0x00178020; +// cdecl void () -- `--g_CurrentClientIndex;`, the two-instruction pop matching PushCurrentClient 0x00578020. 18 callers, the same set. No other instruction in the image writes 0x00ae4808: an image-wide absolute-reference scan finds 42 referencing functions and every one of the other 40 only READS it [verified] +constexpr uint32_t PopCurrentClient = 0x00178040; +// thiscall void __thiscall Game::StrategyApp::RunPendingAITurns() -- called EVERY FRAME from StrategyNetworkClient::Update 0x007842b0+0xf7 with ECX = the StrategyApp singleton 0x00b29f98. `if (this->+0x1c == this->+0x20) return;` (empty pending-AI-player-id vector). Otherwise: t0 = clock 0x008d0b70; show the Game::AIProcessingDialog at 0x00b1149c if it exists; then FOR EVERY entry of +0x1c..+0x20 IN INDEX ORDER, find the client in +0xc..+0x10 whose client->+0x148 matches, update the dialog with client->+0x150, and StrategyClient::RaiseEvent 0x00783ee0(client, 0x26 /*SEResumePlaying*/, &ev) -- which is what runs that AI player's whole turn. The loop has no early exit and no frame yield. After it, the pending vector is emptied, and only THEN: `remaining = this->+0x2c - (clock() - t0); if (remaining > 0) Sleep((int)(remaining * 1000));` before hiding the dialog. THE THROTTLE IS A TRAILING SLEEP, NOT A COMPUTE BUDGET: AIProcessMinTime cannot change a decision and cannot defer an AI turn across frames [verified] +constexpr uint32_t StrategyApp_RunPendingAITurns = 0x00438c60; +// offset float -- Game::StrategyApp+0x2c, in SECONDS. Set once in StrategyApp::CreateGame 0x00888e80+0x90: the GameOptions key "AIProcessMinTime" (string at 0x00a32e30) is read through 0x00898bc0, converted with the CRT string-to-long at 0x009dd320, `fild`ed and divided by the double 1000.0 at 0x009e22f8, then clamped at 0 before `fst [esi+0x2c]`. Its only consumer is the trailing Sleep in StrategyApp_RunPendingAITurns 0x00838c60+0x10e [verified] +constexpr uint32_t StrategyApp_off_AIProcessMinTime = 0x0000002c; +// offset std::vector -- Game::StrategyApp+0x1c.._+0x20, the queue of player net ids whose AI turn is due. Drained in index order by StrategyApp_RunPendingAITurns 0x00838c60, which is therefore the ONLY thing that decides in what order the AI players are stepped and hence the order their TurnCommands blocks reach the host. WHO PUSHES TO IT WAS NOT FOUND by this lane -- neither an absolute-reference scan for the singleton nor an enumeration of the methods called on it located the enqueue site [mapped] +constexpr uint32_t StrategyApp_off_PendingAIPlayers = 0x0000001c; +// thiscall bool __thiscall Game::StrategyClient::(void* fleetObj, int mode, bool flag) -- RET 0xc. `if (this->+0x15c) return false;` then builds the 12-byte record {i32 fleetId = fleetObj->+4, i32 mode, bool flag}, calls the local-apply/validate 0x00821cf0(this->+0x148 /*playerId*/, &rec), and on true appends it to the accumulating TurnCommands at this+0x160 via the LIST 14 adder 0x00842a00. THIS IS LANE Q'S UNEXPLAINED LIST 14. The AI reaches it through 0x006987e0, which calls it TWICE per fleet -- (fleet, 0, true) then (fleet, 1, true) -- and 0x00842a00 keys its insert-or-update on BOTH fleetId (node+0x8) and mode (node+0xc), so an AI fleet order deposits TWO list-14 elements. The UI path (0x005e6fa0) and OnResumePlaying 0x00777480 also call it [verified] +constexpr uint32_t ClientOrder_FleetTask = 0x003634d0; +// thiscall void __thiscall Game::TurnCommands::(const rec* r) -- operates on the std::list at this+0x10c, which is lane Q's LIST 14 (member 14 of 27, +0x70 + 14*0x0c - 0x0c = +0x10c). Scans for a node with node->+0x8 == r->fleetId AND node->+0xc == r->mode; if found, overwrites node->+0x8/+0xc/+0x10 in place; otherwise push_back via 0x00766c20. The node payload is exactly lane Q's observed element record {i32, i32, bool} [verified] +constexpr uint32_t TurnCommands_AddList14 = 0x00442a00; +// cdecl void* (Game::StrategyAIAgent* agent, std::vector* route, void* dest) -- the AI's single fleet-order bridge and the busiest AI->TurnCommands edge in the module. Pushes `dest` through cl_* helper 0x00578cd0, opens a route build with 0x0057b4a0, appends each element of `route` with 0x0057aa50, closes with 0x0057b4d0, resolves the resulting handle through 0x008f4b30, and if non-null calls ClientOrder_FleetTask 0x007634d0 twice: (obj, 0, true) then (obj, 1, true). Three callers -- 0x006b76a0, 0x006c15e0, 0x006c16c0 -- which between them are reached from the Execute (slot 5) body of 24 of the 31 task classes [verified] +constexpr uint32_t AI_IssueFleetTask = 0x002987e0; +// thiscall Game::StrategyClient order method appending to TurnCommands LIST 5 (+0xa0, the planetary-budget/system-rates list lane O observed in zuul-turn17-orders2.sav) through helper 0x008490b0, which is `add ecx,0xa0; call 0x00843fa0`. Called from the AI at 0x0069dd80 (AI Prepare Turn's one-shot NextInt scheduler) and from seven non-AI sites including the cl_* façade at 0x00579110. It is one of the five order methods lane AI1's 21-row table missed [verified] +constexpr uint32_t ClientOrder_SetSystemRates = 0x00363270; +// thiscall Game::StrategyClient order method appending to TurnCommands LIST 7 (+0xb8, lane O's `{i32 shipId, i32 w}` colonize list) through helper 0x00842890. Called from the AI at 0x006af790 -- phase 32 of the AI Process Turn body, i.e. AFTER cl_EndTurn -- and from ten non-AI sites, seven of which are the cl_* façade family 0x00578fc0..0x005790e0. Also missing from lane AI1's table [verified] +constexpr uint32_t ClientOrder_Colonize = 0x00369640; +// offset Game::TurnCommands -- the first of the 27 std::list members lane Q enumerated, stride 0x0c, so list N (1-based, as lane Q numbers them) is at +0x70 + (N-1)*0x0c and the last, list 27, is at +0x1a8. Recorded here because the order-method -> list mapping in ai-task-system.md#4 is expressed entirely in these offsets: the adder for list N is the function whose first `this`-relative access is +0x70 + (N-1)*0x0c [verified] +constexpr uint32_t TurnCommands_off_ListBase = 0x00000070; // thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified] constexpr uint32_t CombatResolve_Retreat = 0x003d5a00; // thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]