The strategic AI does not search or score. Once a turn (Process Turn phase 20, 0x006cf630) it
rebuilds a candidate task list -- which families it builds at all is a switch on the player's
SPECIES, four arms, and the NPC arm builds nothing -- sorts it by a per-task-type priority, and
walks it twice calling each task's Execute(agent, pass) with pass 0 then 1.
Closed from lane AI1's open list:
* the selection loop (its item 2) and the whole ordering policy: a 33-entry priority table at
0x00691f00 plus five named overrides;
* IAITask's unnamed pure virtuals (item 1) -- seven, not eight: GetTypeId, GetTargetA/B,
Execute, IsFinished, GetTypeName, Describe;
* the order-method -> list mapping (item 5): all 27 of lane Q's lists and all six prologue
gates now have a named producer, and list 14 -- lane Q's "observed but not understood" --
is the AI's fleet order, two elements per fleet;
* g_CurrentClientIndex (item 6): a stack pointer with exactly two writers, pushed around the
whole AI turn by StrategyAIAgent::OnEvent;
* the think-time throttle (item 7): AIProcessMinTime is a trailing Sleep, not a compute
budget. Every pending AI player's turn runs back-to-back inside one Update. The clean
"all AI orders in before the human's End Turn" ordering HOLDS, and Rung B is not at risk;
* Broadcast -> OnAIPacket, read to the call -- AI1's one inferred hop is now verified.
Corrections: 31 concrete task classes, not 34; 26 order methods, not 21; seven pure virtuals,
not eight. lane-ai1.json's Broadcast and g_CurrentClientIndex entries upgraded in place.
Open and said so: slots 11/12/13 unnamed, the nine goal tasks' bodies unread, the two-pass
meaning inferred, the StrategyApp pending-AI enqueue site not found, nothing run under an
instrument. Four predictions in section 9, P1 being a ModCount prediction.
ghidra/addresses.d/lane-ai2.json: 25 entries, 1031 -> 1056, no duplicates.
188 lines
18 KiB
JSON
188 lines
18 KiB
JSON
{
|
|
"entries": [
|
|
{
|
|
"name": "StrategyApp_RunAI",
|
|
"addr": "0x008706f0",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall Game::StrategyApp::RunAI(int playerNetId, const char* aiCustomDataName, unsigned char aiPersonality, unsigned int rngSeed) -- RET 0x10, four stack args. The ONE-SHOT AI construction path, reached only from StrategyNetworkClient::OnMessage 0x00784640+0x96e, case SNMRunAI (net msg id 0x3d). Resolves the player through the handle registry at server+0x84; refuses on a human (`p->IsAI(+0xf9) == 0` -> \"RunAI: Cannot create a StrategyClient/AI for a human player.\"); logs \"RunAI: Creating AI client for %s using %08x for random seed.\"; operator_new(0x708) + StrategyClient ctor 0x00782ed0 WITH rngSeed; StrategyServer::InitGame; StrategyClient::CreateAI 0x007653c0(aiPersonality) which builds the Game::StrategyAIAgent into StrategyClient+0x12c (\"RunAI: Failed to create AI for %s (player %d).\"); if a save-game blob was supplied, \"Loading AI custom data from save game...\" through agent vt[5]/vt[6]; finally RaiseAIPrepareTurn 0x00815f20. NOT a per-turn entry point",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read of dumps/sots.exe (objdump -b binary -m i386 -M intel, disassembled to the next function start)"
|
|
},
|
|
{
|
|
"name": "StrategyApp_RaiseAIPrepareTurn",
|
|
"addr": "0x00815f20",
|
|
"convention": "cdecl",
|
|
"prototype": "void (Game::StrategyServer* srv) with EBX = Game::StrategyAIAgent* (register-passed; both call sites set EBX before the call). Builds a stack Game::SEAIPrepareTurn (vftable 0x00a23c00) and invokes agent->vt[1](9, &ev) -- i.e. StrategyAIAgent::OnEvent with client event type 9. Also fires the SVScriptObject hooks at srv+0x1b4 with ids 9 and 0xa. Exactly two call sites: RunAI 0x008706f0+0x26c and StrategyApp::CreateGame 0x00888e80+0x3f5 -- both are game/AI construction, so SEAIPrepareTurn is NOT raised once per turn despite the name",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyAIAgent_OnEvent",
|
|
"addr": "0x006d0ad0",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall Game::StrategyAIAgent::OnEvent(int clientEventType, Game::StrategyEvent** ev) -- RET 8. IStrategyAIAgent vtable slot 1 (vftable 0x00a1b244). Pushes a log scope on this->+0x10, then calls StrategyAIContext::OnStrategyEvent 0x006c2b90 with ECX = this->+0x94 (the context) and args (type, ev, &thunk 0x006d0ab0, this). The thunk forwards to StrategyAIAgent::OnAIPacket 0x006cf8a0. Every StrategyClient event handler in 0x00773xxx-0x00777xxx forwards through this slot when StrategyClient+0x12c is non-null",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#2 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyAIContext_OnStrategyEvent",
|
|
"addr": "0x006c2b90",
|
|
"convention": "thiscall",
|
|
"prototype": "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",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#2 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyAIContext_Broadcast",
|
|
"addr": "0x006b3840",
|
|
"convention": "thiscall",
|
|
"prototype": "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",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#2 -- lane AI1 2026-09-08; listener walk and vt[3] dispatch instruction-verified. UPGRADED mapped->verified by lane AI2 2026-09-08 (findings/subsystems/ai-task-system.md#5): the deque arm was read to the call at 0x006b395b -- `push [entry+4] (the receiver); push pkt; call [entry2] ; add esp,8` -- and the thunk 0x006d0ab0 it reaches is `OnAIPacket(this=[ebp+0xc], pkt=[ebp+8])`, matching that push order exactly"
|
|
},
|
|
{
|
|
"name": "StrategyAIAgent_OnAIPacket",
|
|
"addr": "0x006cf8a0",
|
|
"convention": "thiscall",
|
|
"prototype": "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",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#3 -- lane AI1 2026-09-08, instruction-stream read; jump table read from the image"
|
|
},
|
|
{
|
|
"name": "StrategyClient_OnResumePlaying",
|
|
"addr": "0x00777480",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall Game::StrategyClient::OnResumePlaying(Game::StrategyEvent** ev) -- the case-0x26 handler of StrategyClient::RaiseEvent 0x00783ee0 (jump table 0x00784200, 0x2c entries). At +0x9d: `if (this->+0x12c) agent->vt[1](0x26, ev)` -- THE per-turn AI trigger. SEResumePlaying is broadcast by StrategyServer::ResumePlaying 0x007ddc90 at the very start of a new turn, after OnAllCombatDone_Tail has written the pre-turn autosave",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyClient_off_AIAgent",
|
|
"offset": "0x12c",
|
|
"convention": "offset",
|
|
"prototype": "Game::StrategyAIAgent* -- non-null only on an AI client. Every StrategyClient event handler tests it before forwarding the event to the agent; SendEndTurn 0x00783980 reads agent->vt[8]() through it to fetch the AIEncounterFlags it appends to SNMEndTurn; cl_EndTurn 0x00579310 refuses to end the turn unless it is non-null",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#1 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyClient_off_RNG",
|
|
"offset": "0x134",
|
|
"convention": "offset",
|
|
"prototype": "Mars::RNG* -- a PER-CLIENT generator, operator_new(0x9cc) + RNG_Seed(ctorArg) in the StrategyClient constructor 0x00782ed0+0x143..+0x181. For an AI client the seed is RunAI's 4th argument. THE ONLY GENERATOR THE STRATEGIC AI DRAWS FROM: all six direct NextInt sites in the AI module reach it, and so do the two façade helpers cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0. It is distinct from the strategic generator at StrategyServer+0x16c and is NOT serialised anywhere in the save",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyClient_off_PendingTurnCommands",
|
|
"offset": "0x160",
|
|
"convention": "offset",
|
|
"prototype": "Game::TurnCommands -- the ACCUMULATING order queue. Every StrategyClient order method in 0x00762ca0..0x00763f60 does `lea ecx,[this+0x160]` and appends to one of its 27 lists (or sets one of its six gates). At End Turn, StrategyClient::BuildTurnCommands 0x00783780 does TurnCommands::operator=(this->+0x4d8, this->+0x160) and then overwrites the player id, research rate and fleet-move list from live state",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyClient_off_SendTurnCommands",
|
|
"offset": "0x4d8",
|
|
"convention": "offset",
|
|
"prototype": "Game::TurnCommands -- the SEND buffer, the object that becomes the `Player.<id>.TurnCommands_v5` block on the wire. Written only by StrategyClient::BuildTurnCommands 0x00783780 (from +0x160) and read by SendEndTurn 0x00783980+0x96, which copies it into the SNMEndTurn message (vftable 0x00a229e0) with TurnCommands::operator= 0x007832b0",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "StrategyClient_BuildTurnCommands",
|
|
"addr": "0x00783780",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall Game::StrategyClient::BuildTurnCommands(Game::TurnCommands* dst) -- called once from EndTurn 0x00783be0+0xee with dst = &this->+0x4d8. TurnCommands::Clear 0x00893f00(dst); TurnCommands::operator= 0x007832b0(dst, &this->+0x160); dst->playerId(+4) = this->+0x150->+4; TurnCommands::SetResearchRate 0x0080f2d0(dst, this->+0x150->+0xbc) -- which is why EVERY save's TurnCommands block has the research-rate gate set and the other five clear; then, if this->+0x6d0, walks the pending fleet-move vector at this->+0x6d4/+0x6d8 (stride 8) into the move list",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "TurnCommands_Assign",
|
|
"addr": "0x007832b0",
|
|
"convention": "thiscall",
|
|
"prototype": "Game::TurnCommands& __thiscall Game::TurnCommands::operator=(const TurnCommands& src) -- member-by-member copy of the six gates and their payloads, then the 27 lists. 11 call sites, including SendEndTurn, StrategyServer::OnPlayerEndTurn 0x007d9af0+0x68 (the host storing an arriving block), BuildTurnEvents and LoadGame",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "cl_EndTurn",
|
|
"addr": "0x00579310",
|
|
"convention": "cdecl",
|
|
"prototype": "void () -- the AI's turn-submission façade. `c = g_StrategyClients[g_CurrentClientIndex]; if (c && c->AIAgent(+0x12c) && !c->bTurnEnded(+0x15c)) StrategyClient::EndTurn(c, true);` -- note the +0x12c test: this entry point works ONLY for an AI client. Called from the AI Process Turn body at 0x006cfcd9. The other caller of StrategyClient::EndTurn is the human UI at 0x005e4f80+0x5f",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "cl_Chance",
|
|
"addr": "0x00578cf0",
|
|
"convention": "cdecl",
|
|
"prototype": "bool (float p) -- `c = g_StrategyClients[g_CurrentClientIndex]; return c ? RNG_Chance(c->RNG(+0x134), p) : false;`. One of the AI's two randomness façades; used by the AI Process Turn body at 0x006cfc24 for the surrender roll that follows the \"Survival Outlook: Dead in %i turns (%5.2f%% chance to surrender this turn)\" log line",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "cl_RandRange",
|
|
"addr": "0x005798e0",
|
|
"convention": "cdecl",
|
|
"prototype": "int (int lo, int hi) -- `c = g_StrategyClients[g_CurrentClientIndex]; if (!c) return 0; n = hi - lo; return lo + RNG_NextInt(&c->RNG(+0x134)->mt, &n);`. NOTE RNG_NextInt is INCLUSIVE of its bound (addresses.json), so the range is [lo, hi] inclusive. Eight AI-module call sites",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read"
|
|
},
|
|
{
|
|
"name": "g_StrategyClients",
|
|
"addr": "0x00ae47e4",
|
|
"convention": "offset",
|
|
"prototype": "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",
|
|
"status": "mapped",
|
|
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08; the table's element count was not established"
|
|
},
|
|
{
|
|
"name": "g_CurrentClientIndex",
|
|
"addr": "0x00ae4808",
|
|
"convention": "offset",
|
|
"prototype": "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",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#4 -- lane AI1 2026-09-08; corrected and upgraded by lane AI2, findings/subsystems/ai-task-system.md#6"
|
|
},
|
|
{
|
|
"name": "g_GlobalRNG",
|
|
"addr": "0x00af6e58",
|
|
"convention": "offset",
|
|
"prototype": "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",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/ai-turn-logic.md#5 -- lane AI1 2026-09-08, instruction-stream read of the static initialiser and of all six RNG_Seed call sites; the all-zero fixed point is arithmetic, not measurement -- see the prediction in section 8"
|
|
},
|
|
{
|
|
"name": "AIRulesDB_LoadTechTables",
|
|
"addr": "0x006c7980",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall -- loads Data/Strategy/AI/aitechmode.csv, aitechpri.csv and aitechgrp.csv through the Mars::ICSVRowParser subclasses Game::AIUserTechModeRowParser (vftable 0x00a1ae8c), AIUserTechPriRowParser (0x00a1ae7c) and AIUserTechGrpRowParser (0x00a1ae6c). Diagnostics: \"%s, %i: tech %s does not exist.\", \"%s, %i: priority %i (%s) is out of range 0..255.\", \"%s, %i: bad research mode character: %s\", \"%s, %i: bad group: %s\". Tech group ids are the AITG_* strings emitted by 0x006920a0 (ARMOR BALWEAP BEAM BIOWEAP NRGWEAP SHIELDS TORPS WARHEAD)",
|
|
"status": "mapped",
|
|
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map; the parsers themselves were not read"
|
|
},
|
|
{
|
|
"name": "AIPersonaDB_LoadStockTables",
|
|
"addr": "0x006c6250",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall -- loads data/strategy/ai/stock_design_names.csv, stock_diplomacy_messages.csv and stock_player_names.csv through Game::AIPersonaDB::StockDesignNameRowParser (vftable 0x00a1b034), StockDiplomacyMessageRowParser (0x00a1b024) and StockPlayerNameRowParser (0x00a1b014). Diagnostics key on species and on a diplomatic event id; the id vocabulary is the 53 AIDIP_* strings returned by 0x00690960",
|
|
"status": "mapped",
|
|
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map"
|
|
},
|
|
{
|
|
"name": "AIRulesDB_LoadAffinityTables",
|
|
"addr": "0x006c63c0",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall -- loads data/strategy/ai/affinity_weapon.csv and affinity_section.csv (\"ToAISectionRule: %s ship section not found: %s\"). These are the ship-design affinity weights consumed by the design composer 0x006ad700 (\"AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS\", \"While AI for %s was designing a ship: Did not find any weapon to match %s, %s, bNoTrackingWeapons=%i.\"). Section-class rule selection is 0x0069cc30 / 0x0069cdb0 (\"AISelectSectionClassRules: maxout\", \"AISelectSectionClassRulesMergeFallback: maxout\"); the generic selector is 0x00695140 (\"AIRulesDB::SelectRules_T: maxout\")",
|
|
"status": "mapped",
|
|
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map"
|
|
},
|
|
{
|
|
"name": "StrategyAIContext_LoadWeaponReplacements",
|
|
"addr": "0x006b4dc0",
|
|
"convention": "thiscall",
|
|
"prototype": "void __thiscall -- loads Data/Strategy/AI/weapon_replacements.csv through Game::StrategyAIContext::WeaponReplacementsRowParser (vftable 0x00a1a62c). Consumed by 0x00694f80 (\"StrategyAIContext::GetWeaponReplacement: maxReplacements (%i)\")",
|
|
"status": "mapped",
|
|
"source": "findings/subsystems/ai-turn-logic.md#6 -- lane AI1 2026-09-08, string-reference map"
|
|
}
|
|
]
|
|
}
|