#include "shim/hooks/ai_orders.h" #include #include #include #include #define WIN32_LEAN_AND_MEAN #include #include "MinHook.h" #include "generated/sots_addresses.h" namespace shim::hooks { namespace { // ---- configuration --------------------------------------------------------------------------- bool g_enabled = false; std::size_t g_probeInstallCount = 0; // `aiprobes=`; default off, so `aiorders=on` alone is ONE detour bool g_research = false; // `airesearch=`; the three research-selection dump hooks char g_outPath[MAX_PATH] = {}; FILE* g_out = nullptr; void (*g_log)(const char*) = nullptr; void LogF(const char* fmt, ...) { char buf[1200]; va_list ap; va_start(ap, fmt); std::vsnprintf(buf, sizeof buf, fmt, ap); va_end(ap); if (g_log) g_log(buf); if (g_out) { std::fputs(buf, g_out); std::fputc('\n', g_out); std::fflush(g_out); } } // ---- guarded reads --------------------------------------------------------------------------- // // Every read below is of a pointer the game handed us or of a heap node we reached by walking one. // A wrong offset must produce a logged zero, never a fault inside a detour, so nothing is read // without a probe first. Four bytes at a time: an element window may run past the end of a small // heap node, and probing per word bounds the damage to the word. inline bool Readable(std::uintptr_t p, std::size_t n) { return p != 0 && !IsBadReadPtr(reinterpret_cast(p), n); } inline std::uint32_t U32(std::uintptr_t p) { return Readable(p, 4) ? *reinterpret_cast(p) : 0u; } inline std::uint8_t U8(std::uintptr_t p) { return Readable(p, 1) ? *reinterpret_cast(p) : 0u; } inline float F32(std::uintptr_t p) { const std::uint32_t v = U32(p); float f; std::memcpy(&f, &v, 4); return f; } // ---- the `TurnCommands` block ------------------------------------------------------------------ // // Offsets from `Game::TurnCommands::Write` 0x00842540 as read by lane Q, and from the twenty-seven // per-list loops in `ApplyTurnCommandBatch` as read by lane AI4. Both are instruction-stream reads // of the same class from opposite ends -- the writer and the applier -- and they agree, which is // the only independent corroboration this layout has. constexpr std::uint32_t kBlockStride = 0x1b4; constexpr std::uint32_t kListBase = 0x70; // list 1's member constexpr std::uint32_t kListStride = 0x0c; // {_Myhead, _Mysize, _Alval}, allocator LAST constexpr int kListCount = 27; // How much of each element to record. The largest element record lane Q read is list 5's // {i32, OutputRates frame}; 48 bytes covers every scalar-only record with room to spare and is // short enough that a heap node's tail is unlikely to matter. Words that do not probe readable are // printed as `????????` rather than as zero, so truncation is visible. constexpr int kElemWords = 12; std::uint32_t g_batchSeq = 0; // ---- the entry probes --------------------------------------------------------------------------- constexpr std::size_t kMaxProbes = 24; volatile std::uint32_t g_calls[kMaxProbes]; // since the last batch dump volatile std::uint32_t g_total[kMaxProbes]; // since process start volatile std::uint32_t g_byPass[kMaxProbes][3]; // pass 0, pass 1, anything else (incl. stale) bool g_installed[kMaxProbes]; // Set by the RunTaskList stub from that function's third __cdecl stack argument. It is STALE once // RunTaskList returns -- nothing can clear it from a tail-jumping stub -- so `pass` on a hit that // falls outside a task sweep is the last pass that ran, not a measurement. The event ring's // `run` column is what makes that readable: hits with the same `run` as the preceding RunTaskList // entry are inside that sweep. volatile LONG g_pass = -1; volatile LONG g_runSeq = 0; volatile LONG g_agent = 0; struct Event { std::uint32_t seq; std::uint8_t probe; std::int8_t pass; std::uint32_t run; std::uint32_t agent; }; constexpr std::size_t kMaxEvents = 4096; Event g_events[kMaxEvents]; volatile LONG g_eventCount = 0; std::size_t g_eventsWritten = 0; } // namespace // ---- the hit sinks, called from the asm stubs --------------------------------------------------- extern "C" void AiProbeHit(int index) { if (index < 0 || static_cast(index) >= kMaxProbes) return; ++g_calls[index]; ++g_total[index]; const LONG p = g_pass; const int bucket = (p == 0) ? 0 : (p == 1) ? 1 : 2; ++g_byPass[index][bucket]; const LONG n = InterlockedIncrement(&g_eventCount) - 1; if (n >= 0 && static_cast(n) < kMaxEvents) { Event& e = g_events[n]; e.seq = static_cast(n); e.probe = static_cast(index); e.pass = static_cast(p); e.run = static_cast(g_runSeq); e.agent = static_cast(g_agent); } } // The RunTaskList stub's sink. Records the pass BEFORE the function runs, so every probe hit // inside the sweep sees it. extern "C" void AiOnRunTaskList(void* agent, int pass) { g_pass = pass; g_agent = static_cast(reinterpret_cast(agent)); InterlockedIncrement(&g_runSeq); AiProbeHit(0); } namespace { // ---- the probe table ------------------------------------------------------------------------ // // Order is the stub index, the report order and the `aiprobes=N` bisection order, so it is fixed. // Index 0 MUST be RunTaskList: it is both the pass recorder and the control. Every other row is // meaningless if row 0 reads zero, and the report says so rather than presenting a table of zeros. namespace A = sots::addr; struct ProbeDef { const char* name; std::uint32_t rva; void* stub; void** trampoline; }; } // namespace // One trampoline slot and one stub per probe. Written out rather than generated at runtime because // a runtime thunk needs an executable allocation and a relocation, and MinHook already owns that. #define AI_PROBE_STUB(i) \ extern "C" void* g_aiTr##i; \ void* g_aiTr##i = nullptr; \ extern "C" void AiProbeStub##i(void); \ asm(".text\n" \ ".globl _AiProbeStub" #i "\n" \ "_AiProbeStub" #i ":\n" \ " pushfl\n" \ " pushal\n" \ " pushl $" #i "\n" \ " call _AiProbeHit\n" \ " addl $4, %esp\n" \ " popal\n" \ " popfl\n" \ " jmp *_g_aiTr" #i "\n") // Index 0 is hand-written: it forwards RunTaskList's `agent` and `pass` stack arguments. After // `pushfl` (4) + `pushal` (32) the return address is at esp+36 and the four __cdecl arguments at // esp+40/44/48/52, so `pass` is esp+48 and `agent` is esp+40 -- and after the first push the // latter has moved to esp+44. Nothing is written; the arguments are read where the callee will // read them. extern "C" void* g_aiTr0; void* g_aiTr0 = nullptr; extern "C" void AiProbeStub0(void); asm(".text\n" ".globl _AiProbeStub0\n" "_AiProbeStub0:\n" " pushfl\n" " pushal\n" " pushl 48(%esp)\n" " pushl 44(%esp)\n" " call _AiOnRunTaskList\n" " addl $8, %esp\n" " popal\n" " popfl\n" " jmp *_g_aiTr0\n"); AI_PROBE_STUB(1); AI_PROBE_STUB(2); AI_PROBE_STUB(3); AI_PROBE_STUB(4); AI_PROBE_STUB(5); AI_PROBE_STUB(6); AI_PROBE_STUB(7); AI_PROBE_STUB(8); AI_PROBE_STUB(9); AI_PROBE_STUB(10); AI_PROBE_STUB(11); AI_PROBE_STUB(12); AI_PROBE_STUB(13); AI_PROBE_STUB(14); AI_PROBE_STUB(15); AI_PROBE_STUB(16); AI_PROBE_STUB(17); AI_PROBE_STUB(18); AI_PROBE_STUB(19); #undef AI_PROBE_STUB namespace { #define AI_PROBE(name, rva, i) \ ProbeDef { name, rva, reinterpret_cast(&AiProbeStub##i), &g_aiTr##i } const ProbeDef kProbes[] = { // 0: the pass recorder AND the control. 3 AI players x 2 passes on the reference game. AI_PROBE("StrategyAIAgent::RunTaskList [control+pass]", A::StrategyAIAgent_RunTaskList, 0), // 1-2: the AITRaid question (AI3 section 2.4). A zero on 1 means nothing without 2. AI_PROBE("StrategyClient::OrderList16 [list 16 emit]", A::StrategyClient_OrderList16, 1), AI_PROBE("AITRaid::Execute", A::AITRaid_Execute, 2), // 3-8: the six shared bodies behind the nine tasks AI2 called planners and AI3 showed emit. AI_PROBE("AITColonize::Execute [+Goal]", A::AITColonize_Execute, 3), AI_PROBE("AITEscortGateInvade::Execute [+Goal]", A::AITEscortGateInvade_Execute, 4), AI_PROBE("AITInvade::Execute [+Goal]", A::AITInvade_Execute, 5), AI_PROBE("AITNodeBore::Execute", A::AITNodeBore_Execute, 6), AI_PROBE("AITBuildPoliceShips::Execute", A::AITBuildPoliceShips_Execute, 7), AI_PROBE("AITBuildDeepScanShips::Execute", A::AITBuildDeepScanShips_Execute, 8), // 9: priority 0, pass-1 body, always last in the sweep -- a second control on the task list. AI_PROBE("AITAdvanceIdleShips::Execute", A::AITAdvanceIdleShips_Execute, 9), // 10-12: the three pass-1-gated emission exits. Entered on BOTH passes if the gate is theirs. AI_PROBE("StrategyAIAgent::RequestBuildForTask [lists 3,1]", A::StrategyAIAgent_RequestBuildForTask, 10), AI_PROBE("StrategyAIAgent::AssignFleetsAndIssueOrders [lists 14,8,10]", A::StrategyAIAgent_AssignFleetsAndIssueOrders, 11), AI_PROBE("StrategyAIAgent::IssueRouteForFleets [list 14]", A::StrategyAIAgent_IssueRouteForFleets, 12), // 13-14: the hub and the claim test. AI_PROBE("StrategyAIAgent::AcquireFleetsForTask", A::StrategyAIAgent_AcquireFleetsForTask, 13), AI_PROBE("StrategyAIAgent::IsClaimedByAnotherTask [entry only]", A::StrategyAIAgent_IsClaimedByAnotherTask, 14), // 15: one per submitting block -- the block count seen from the client side. AI_PROBE("StrategyClient::BuildTurnCommands [control]", A::StrategyClient_BuildTurnCommands, 15), // 16-17: the two research producers phase 18 tries BEFORE the candidate walk. If either of // these answers, the walk never runs and the candidate set is not where the answer comes from. AI_PROBE("AIResearch::ProducerA", A::StrategyAIAgent_ResearchProducerA, 16), AI_PROBE("AIResearch::ProducerB", A::StrategyAIAgent_ResearchProducerB, 17), // 18-19: the FALLBACK, reached only when the candidate walk accepts nothing. Its index source // reads player state rather than the generator, and it rotates over three arms -- so a hit // here means the outcome space is <= 3 by construction and is NOT a tie in a candidate list. // This pair is the discriminator between the two mechanisms, and it is why they are probed. AI_PROBE("AIResearch::FallbackIndex", A::StrategyAIAgent_ResearchFallbackIndex, 18), AI_PROBE("AIResearch::FallbackArm", A::StrategyAIAgent_ResearchFallbackArm, 19), }; #undef AI_PROBE constexpr std::size_t kProbeCount = sizeof kProbes / sizeof kProbes[0]; static_assert(kProbeCount <= kMaxProbes, "add more AI_PROBE_STUB() slots"); // ---- the dump --------------------------------------------------------------------------------- void DumpElements(int blk, int pid, int list, std::uintptr_t head) { // MSVC std::list node: {_Next, _Prev, _Myval}. begin() == _Myhead->_Next; the head is the nil // sentinel and terminates the walk. std::uintptr_t node = U32(head); int idx = 0; while (node && node != head && idx < 64) { const std::uintptr_t val = node + 8; char hex[kElemWords * 9 + 8] = {}; char ints[kElemWords * 13 + 8] = {}; int hp = 0, ip = 0; for (int w = 0; w < kElemWords; ++w) { const std::uintptr_t p = val + 4u * static_cast(w); if (Readable(p, 4)) { const std::uint32_t v = *reinterpret_cast(p); hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v); ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast(v)); } else { hp += std::snprintf(hex + hp, sizeof hex - hp, "???????? "); ip += std::snprintf(ints + ip, sizeof ints - ip, "? "); } } // The first two words as floats as well: several element records lead with or contain a // rate/fraction, and reading 0x3f4ccccd as 1061997773 hides that. LogF("aielem blk=%d pid=%d list=%d idx=%d node=0x%08x f0=%g f1=%g ints=[ %s] hex=[ %s]", blk, pid, list, idx, static_cast(node), static_cast(F32(val)), static_cast(F32(val + 4)), ints, hex); node = U32(node); ++idx; } if (idx >= 64) LogF("aielem blk=%d list=%d TRUNCATED at 64 elements", blk, list); } void DumpBlock(int i, int n, std::uintptr_t b) { const int pid = static_cast(U32(b + 0x04)); const int gRate = U8(b + 0x0c), gTgt = U8(b + 0x14), gBoost = U8(b + 0x20); const int gG4 = U8(b + 0x2c), gF3 = U8(b + 0x3c), gCiv = U8(b + 0x6c); LogF("aiblk seq=%u blk=%d/%d at=0x%08x pid=%d " "rate=%d:%g target=%d:%d boost=%d:%d,%g g4=%d:%d,%d f3=%d:%g,%g,%g civ=%d", g_batchSeq, i, n, static_cast(b), pid, gRate, static_cast(F32(b + 0x08)), gTgt, static_cast(U32(b + 0x10)), gBoost, static_cast(U32(b + 0x18)), static_cast(F32(b + 0x1c)), gG4, static_cast(U8(b + 0x24)), static_cast(U32(b + 0x28)), gF3, static_cast(F32(b + 0x30)), static_cast(F32(b + 0x34)), static_cast(F32(b + 0x38)), gCiv); char sizes[27 * 5 + 16] = {}; int sp = 0; int nonEmpty = 0; for (int L = 1; L <= kListCount; ++L) { const std::uintptr_t m = b + kListBase + kListStride * static_cast(L - 1); const std::uint32_t size = U32(m + 4); sp += std::snprintf(sizes + sp, sizeof sizes - sp, "%u ", size); if (size) ++nonEmpty; } LogF("ailists seq=%u blk=%d pid=%d nonEmpty=%d sizes(1..27)=[ %s]", g_batchSeq, i, pid, nonEmpty, sizes); for (int L = 1; L <= kListCount; ++L) { const std::uintptr_t m = b + kListBase + kListStride * static_cast(L - 1); const std::uintptr_t head = U32(m); const std::uint32_t size = U32(m + 4); // Measure the list twice. A wrong container layout would otherwise print a confident zero // for all twenty-seven, which is exactly what an empty block looks like (method rule 1). int walked = 0; std::uintptr_t node = U32(head); while (node && node != head && walked < 4096) { ++walked; node = U32(node); } if (static_cast(walked) != size) LogF("ailist MISMATCH blk=%d pid=%d list=%d _Mysize=%u walked=%d head=0x%08x " "-- the list layout is wrong and every size on this block is unmeasured", i, pid, L, size, walked, static_cast(head)); if (size) { LogF("ailist blk=%d pid=%d list=%d off=0x%03x size=%u", i, pid, L, kListBase + kListStride * (L - 1), size); DumpElements(i, pid, L, head); } } } } // namespace void ai_orders_flush(void (*log_line)(const char*)) { if (log_line) g_log = log_line; const LONG n = g_eventCount; const std::size_t have = static_cast(n) > kMaxEvents ? kMaxEvents : static_cast(n); for (std::size_t i = g_eventsWritten; i < have; ++i) { const Event& e = g_events[i]; const char* nm = (e.probe < kProbeCount) ? kProbes[e.probe].name : "?"; LogF("aievent seq=%u run=%u pass=%d agent=0x%08x probe=%u %s", e.seq, e.run, static_cast(e.pass), e.agent, static_cast(e.probe), nm); } g_eventsWritten = have; if (static_cast(n) > kMaxEvents) LogF("aievent OVERFLOW -- %ld hits taken, only %u recorded; counters below are complete, " "the ordering is not", n, static_cast(kMaxEvents)); } // ---- the batch detour --------------------------------------------------------------------------- extern "C" void* g_aiBatchOrig; void* g_aiBatchOrig = nullptr; extern "C" void AiBatchDetour(); extern "C" void AiOnBatch(void* blocksv, int n) { ++g_batchSeq; const std::uintptr_t blocks = reinterpret_cast(blocksv); LogF("---- aibatch seq=%u blocks=0x%08x n=%d stride=0x%x ----", g_batchSeq, static_cast(blocks), n, kBlockStride); if (n < 0 || n > 64 || !Readable(blocks, kBlockStride)) { LogF("aibatch seq=%u UNREADABLE (n=%d) -- nothing dumped, and this is a failure of the " "instrument, not an empty turn", g_batchSeq, n); } else { for (int i = 0; i < n; ++i) DumpBlock(i, n, blocks + kBlockStride * static_cast(i)); } // The probe window. Counters are reported and reset here, so a row's `turn` column covers the // AI sweep that produced THIS batch: the AI runs on SEResumePlaying at the end of the previous // turn's processing (and on load), and the batch is applied at the start of the next one. ai_orders_flush(nullptr); if (g_probeInstallCount == 0) { LogF("aiprobe seq=%u none installed (aiprobes=off)", g_batchSeq); } else { for (std::size_t i = 0; i < kProbeCount; ++i) { if (!g_installed[i] && i < g_probeInstallCount) LogF("COVERAGE: aiprobe %s NOT INSTALLED -- its count is meaningless, not zero", kProbes[i].name); LogF("aiprobe seq=%u idx=%u installed=%d turn=%u total=%u pass0=%u pass1=%u other=%u %s", g_batchSeq, static_cast(i), g_installed[i] ? 1 : 0, g_calls[i], g_total[i], g_byPass[i][0], g_byPass[i][1], g_byPass[i][2], kProbes[i].name); } if (g_calls[0] == 0) LogF("aiprobe seq=%u CONTROL ZERO -- RunTaskList was not entered in this window, so " "every other row above is unmeasured rather than absent", g_batchSeq); } for (std::size_t i = 0; i < kProbeCount; ++i) { g_calls[i] = 0; g_byPass[i][0] = g_byPass[i][1] = g_byPass[i][2] = 0; } } // ---- the research-selection capture (lane L4 addendum) ------------------------------------------- // // WHY THIS IS A DUMPER AND NOT THREE MORE COUNTERS. // // Exactly one AI decision in the reference game is not reproducible run to run: one shadow empire's // research target (lane L5). If that pick is a TIE broken by something per-process, the original's // possible outcomes form a small enumerable set, and a deterministic reimplementation can pick // canonically and claim membership of that set -- which is a stronger claim than "behaviourally // equivalent" and keeps a byte match reachable whenever the tiebreaks agree. Naming the set needs // the candidate list, in the order the selector sees it. A counter cannot give that. // // `SelectResearchTarget 0x006c8890` walks a vector of 0x0c-stride candidates FRONT TO BACK and // takes the first one `TryResearchCandidate 0x006c8580` accepts. There is no sort and no score in // the walk, so the vector's order IS the priority. One entry stub per candidate therefore records // the whole stream in arrival order; the LAST call before the walk ends is the accepted one. // // Three stubs, all register-transparent: // * SelectResearchTarget -- the per-player delimiter, and it prints the current target word so a // player that returns immediately is distinguishable from one that walks an empty list; // * TryResearchCandidate -- one line per candidate, with both candidate words and whatever a // std::string at +4 of either resolves to, which is how a tech gets a name here; // * cl_SetResearchTarget -- the outcome, which is the only place the chosen NAME is in a // register (phase 18 resolves the short-string union and pushes the char*). // // What it cannot do: a tail-jumping stub never sees a return value, so "the last candidate tried" // is the accepted one only when the walk actually accepted something -- and the fallback probes // (rows 18/19) are what say whether it did. Read the three together. namespace { std::uint32_t g_researchSeq = 0; int g_candIdx = 0; // MSVC std::string (0x1c): union _Bx at +0, _Mysize +0x10, _Myres +0x14; short strings live in the // union. Prints nothing rather than guessing when the shape does not validate. void ReadStdString(std::uintptr_t s, char* out, std::size_t cap) { out[0] = '\0'; if (!Readable(s, 0x18)) return; const std::uint32_t len = U32(s + 0x10); const std::uint32_t res = U32(s + 0x14); if (len == 0 || len > 0x80 || res < len) return; const std::uintptr_t p = (res < 16) ? s : static_cast(U32(s)); if (!Readable(p, len)) return; std::size_t n = len < cap - 1 ? len : cap - 1; for (std::size_t i = 0; i < n; ++i) { const char c = static_cast(U8(p + i)); out[i] = (c >= 32 && static_cast(c) < 127) ? c : '?'; } out[n] = '\0'; } // A candidate word is either a small integer or a pointer to an object whose +4 is the tech's name // string. Try the string; fall back to printing the word. void DescribeWord(std::uint32_t w, char* out, std::size_t cap) { out[0] = '\0'; if (w > 0x10000) { ReadStdString(static_cast(w) + 4, out, cap); if (out[0]) return; ReadStdString(static_cast(w), out, cap); if (out[0]) return; } std::snprintf(out, cap, "-"); } } // namespace extern "C" void* g_aiSelectOrig; void* g_aiSelectOrig = nullptr; extern "C" void AiSelectDetour(); extern "C" void AiOnSelectResearch(void* agent) { ++g_researchSeq; g_candIdx = 0; const std::uintptr_t a = reinterpret_cast(agent); // agent->+0x10 is the StrategyClient, client->+0x150 the ClientPlayer, player->+0x294 the // current research target. Read defensively: a wrong offset must print a zero, not fault. const std::uintptr_t client = U32(a + 0x10); const std::uintptr_t player = client ? U32(client + 0x150) : 0; LogF("---- airesearch sel=%u agent=0x%08x client=0x%08x player=0x%08x curTarget=0x%08x " "species=%d ----", g_researchSeq, static_cast(a), static_cast(client), static_cast(player), player ? U32(player + 0x294) : 0, player ? static_cast(U32(player + 0x5c)) : -1); } extern "C" void* g_aiCandOrig; void* g_aiCandOrig = nullptr; extern "C" void AiCandDetour(); extern "C" void AiOnResearchCandidate(std::uint32_t outSlot, std::uint32_t candWord1, std::uint32_t agent, std::uint32_t candWord0) { char n0[64], n1[64]; DescribeWord(candWord0, n0, sizeof n0); DescribeWord(candWord1, n1, sizeof n1); LogF("aicand sel=%u idx=%d agent=0x%08x slot=0x%08x w0=0x%08x(%d) '%s' w1=0x%08x(%d) '%s'", g_researchSeq, g_candIdx, agent, outSlot, candWord0, static_cast(candWord0), n0, candWord1, static_cast(candWord1), n1); ++g_candIdx; } extern "C" void* g_aiSetTargetOrig; void* g_aiSetTargetOrig = nullptr; extern "C" void AiSetTargetDetour(); extern "C" void AiOnSetResearchTarget(std::uint32_t namePtr) { char buf[96]; buf[0] = '\0'; if (Readable(namePtr, 1)) { std::size_t i = 0; for (; i < sizeof buf - 1; ++i) { if (!Readable(namePtr + i, 1)) break; const char c = static_cast(U8(namePtr + i)); if (!c) break; buf[i] = (c >= 32 && static_cast(c) < 127) ? c : '?'; } buf[i] = '\0'; } LogF("airesult sel=%u candidatesTried=%d chose='%s' (ptr=0x%08x)", g_researchSeq, g_candIdx, buf, namePtr); } // SelectResearchTarget is __thiscall with one stack argument: push ECX. asm(R"( .text .globl _AiSelectDetour _AiSelectDetour: pushfl pushal pushl %ecx call _AiOnSelectResearch addl $4, %esp popal popfl jmp *_g_aiSelectOrig )"); // TryResearchCandidate: ECX = the out slot, EDX = candidate word 1, and two stack arguments // (agent, candidate word 0). After pushfl+pushal the return address is at esp+36 and those two are // at esp+40 and esp+44; each push shifts the rest by four, so the reads walk backwards. asm(R"( .text .globl _AiCandDetour _AiCandDetour: pushfl pushal pushl 44(%esp) pushl 44(%esp) pushl %edx pushl %ecx call _AiOnResearchCandidate addl $16, %esp popal popfl jmp *_g_aiCandOrig )"); // cl_SetResearchTarget is __cdecl with one stack argument, the tech NAME. asm(R"( .text .globl _AiSetTargetDetour _AiSetTargetDetour: pushfl pushal pushl 40(%esp) call _AiOnSetResearchTarget addl $4, %esp popal popfl jmp *_g_aiSetTargetOrig )"); asm(R"( .text .globl _AiBatchDetour _AiBatchDetour: pushfl pushal pushl 44(%esp) pushl 44(%esp) call _AiOnBatch addl $8, %esp popal popfl jmp *_g_aiBatchOrig )"); bool ai_orders_config(const char* key, const char* value, std::string* err) { if (std::strcmp(key, "aiorders") == 0) { if (std::strcmp(value, "on") == 0) g_enabled = true; else if (std::strcmp(value, "off") == 0) g_enabled = false; else if (err) *err = "expected on|off"; return true; } if (std::strcmp(key, "aiorders.out") == 0) { std::snprintf(g_outPath, sizeof g_outPath, "%s", value); return true; } if (std::strcmp(key, "airesearch") == 0) { if (std::strcmp(value, "on") == 0) g_research = true; else if (std::strcmp(value, "off") == 0) g_research = false; else if (err) *err = "expected on|off"; return true; } if (std::strcmp(key, "aiprobes") == 0) { if (std::strcmp(value, "off") == 0 || std::strcmp(value, "none") == 0) { g_probeInstallCount = 0; } else if (std::strcmp(value, "all") == 0 || std::strcmp(value, "on") == 0) { g_probeInstallCount = kProbeCount; } else { char* end = nullptr; const long v = std::strtol(value, &end, 10); if (end == value || v < 0) { if (err) *err = "expected off|all|N"; return true; } g_probeInstallCount = static_cast(v) < kProbeCount ? static_cast(v) : kProbeCount; } return true; } return false; } bool ai_orders_enabled() { return g_enabled; } void install_ai_orders(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)) { g_log = log; if (!g_enabled) { if (g_log) g_log("aiorders: disabled (aiorders=off)"); return; } if (!g_outPath[0]) std::snprintf(g_outPath, sizeof g_outPath, "%s\\shim.aiorders.txt", gameDir); g_out = std::fopen(g_outPath, "w"); if (!g_out) LogF("aiorders: cannot open %s -- output goes to shim.log only", g_outPath); LogF("aiorders: out=%s probes=%u of %u", g_outPath, static_cast(g_probeInstallCount), static_cast(kProbeCount)); void* target = reinterpret_cast(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch); MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast(&AiBatchDetour), &g_aiBatchOrig); MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1; LogF("aiorders: batch hook StrategySim::ApplyTurnCommandBatch rva=0x%08x va=%p create=%s " "enable=%s", sots::addr::StrategySim_ApplyTurnCommandBatch, target, MH_StatusToString(s1), MH_StatusToString(s2)); if (s2 != MH_OK) LogF("COVERAGE: aiorders batch hook NOT INSTALLED -- no block will be dumped, and an empty " "report means the instrument failed, not that the AI emitted nothing"); if (g_research) { const struct { const char* name; std::uint32_t rva; void* detour; void** tramp; } kResearch[3] = { {"StrategyAIAgent::SelectResearchTarget", sots::addr::StrategyAIAgent_SelectResearchTarget, reinterpret_cast(&AiSelectDetour), &g_aiSelectOrig}, {"StrategyAIAgent::TryResearchCandidate", sots::addr::StrategyAIAgent_TryResearchCandidate, reinterpret_cast(&AiCandDetour), &g_aiCandOrig}, {"cl_SetResearchTarget", sots::addr::cl_SetResearchTarget, reinterpret_cast(&AiSetTargetDetour), &g_aiSetTargetOrig}, }; for (const auto& r : kResearch) { void* t = reinterpret_cast(exeBase + r.rva); MH_STATUS r1 = MH_CreateHook(t, r.detour, r.tramp); MH_STATUS r2 = r1 == MH_OK ? MH_EnableHook(t) : r1; LogF("airesearch: %s rva=0x%08x va=%p create=%s enable=%s", r.name, r.rva, t, MH_StatusToString(r1), MH_StatusToString(r2)); if (r2 != MH_OK) LogF("COVERAGE: airesearch hook %s NOT INSTALLED -- a silent capture below means " "the instrument failed, not that the selector did nothing", r.name); } } else { LogF("airesearch: disabled (airesearch=off)"); } for (std::size_t i = 0; i < g_probeInstallCount; ++i) { void* t = reinterpret_cast(exeBase + kProbes[i].rva); MH_STATUS p1 = MH_CreateHook(t, kProbes[i].stub, kProbes[i].trampoline); MH_STATUS p2 = p1 == MH_OK ? MH_EnableHook(t) : p1; g_installed[i] = (p2 == MH_OK); LogF("aiprobe: %u %s rva=0x%08x va=%p create=%s enable=%s", static_cast(i), kProbes[i].name, kProbes[i].rva, t, MH_StatusToString(p1), MH_StatusToString(p2)); if (!g_installed[i]) LogF("COVERAGE: aiprobe %s NOT INSTALLED -- its count is meaningless, not zero", kProbes[i].name); } } } // namespace shim::hooks