Lane L1 found each AI client's generator takes a fresh per-process word, so the AI is MT19937 from one word per client and game/ai is a function of (save, seed). A capture without the seeds records the answer without the input. - aiseed=log|pin on StrategyApp::RunAI: one detour that reads the fourth stack argument where the callee reads it, and in pin mode replaces it. Both modes log observed AND used, so a pinned run says so in its own capture. - aiorders.words= widens the element window past list 1's 48-byte horizon. - aiorders.deep= follows the heap: the route vector, the counted vector and the Population body were ABSENT from every capture so far, not empty. - tools/turncommands_capture.py (in sots-re) does the typing offline, so a corrected element record costs a re-parse rather than a VM run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
991 lines
43 KiB
C++
991 lines
43 KiB
C++
#include "shim/hooks/ai_orders.h"
|
|
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#include <windows.h>
|
|
|
|
#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, ...) {
|
|
// 4 KB, not 1200: lane CB's widened element window (`aiorders.words=64`) can put 64 hex words
|
|
// and 64 signed ints on one line, which is 1,408 characters before the prefix.
|
|
char buf[4096];
|
|
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<void*>(p), n);
|
|
}
|
|
|
|
inline std::uint32_t U32(std::uintptr_t p) {
|
|
return Readable(p, 4) ? *reinterpret_cast<volatile std::uint32_t*>(p) : 0u;
|
|
}
|
|
inline std::uint8_t U8(std::uintptr_t p) {
|
|
return Readable(p, 1) ? *reinterpret_cast<volatile std::uint8_t*>(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.
|
|
//
|
|
// LANE CB widened this to a configurable window (`aiorders.words=`), because list 1's element is a
|
|
// polymorphic `ShipDesignDef` whose id sits past 48 bytes -- L4 §1 P3 could not read it and said
|
|
// so. The default is still 12, so a run that does not ask for more gets L4's exact behaviour.
|
|
constexpr int kMaxElemWords = 64;
|
|
int g_elemWords = 12;
|
|
|
|
// ---- the AI client seeds (lane CB, on lane L1's finding) ---------------------------------------
|
|
//
|
|
// L1 probed `Game::StrategyApp::RunAI` across two processes and found each AI client's generator is
|
|
// seeded with a FRESH per-process 32-bit word -- three words per process, none shared between
|
|
// processes. That reframes the whole Rung-B problem: the AI is not non-deterministic, it is
|
|
// MT19937 from one word per client, and MT19937 is a generator this campaign owns bit for bit. So
|
|
// `game/ai` is a deterministic function of (save, per-client seed), and
|
|
//
|
|
// THE SEEDS ARE PART OF THE STREAM.
|
|
//
|
|
// A capture that records the command block but not the seeds records the AI's *answer* without its
|
|
// *input*; it can be replayed but it cannot be re-derived, and it cannot be re-run. Three words
|
|
// turn a log file into a reproducible pair.
|
|
//
|
|
// TWO MODES, AND THE SECOND IS NOT AN INSTRUMENT -- IT IS AN INTERVENTION.
|
|
// `aiseed=log` reads the seed argument where the callee will read it and changes nothing.
|
|
// `aiseed=pin` OVERWRITES the caller's pushed argument before the callee sees it. That is the
|
|
// only way to take a control on a workload whose outcome set has size k > 1: three lanes ran
|
|
// `hooks=off` on `turn1-state` and got three different files, so no single un-instrumented run
|
|
// on that workload is a control at all, and an instrumented run agreeing with one of them is a
|
|
// ~1/k coincidence rather than evidence.
|
|
// Both are logged with the observed AND the used value on every call, so a pinned run says so in
|
|
// its own capture and can never be mistaken for a natural one.
|
|
enum class SeedMode { Off, Log, Pin };
|
|
SeedMode g_seedMode = SeedMode::Off;
|
|
constexpr std::size_t kMaxSeedPins = 8;
|
|
struct SeedPin {
|
|
int netId; // -1 == the wildcard `*`, applied to any client with no exact pin
|
|
std::uint32_t value;
|
|
};
|
|
SeedPin g_seedPins[kMaxSeedPins];
|
|
std::size_t g_seedPinCount = 0;
|
|
std::uint32_t g_seedCalls = 0;
|
|
|
|
// `aiorders.deep=` -- follow the heap out of the element window (lane CB).
|
|
//
|
|
// THREE PAYLOADS IN THE CANONICAL BLOCK ARE BEHIND POINTERS and are therefore simply ABSENT from a
|
|
// window dump: list 8's route vector, list 10's counted vector and list 23's `Population` body.
|
|
// A replayer cannot reconstruct a fleet move without the route, so "the capture is complete" was
|
|
// false while these were unread.
|
|
//
|
|
// The follower is deliberately GENERIC: it scans the window for anything shaped like an MSVC
|
|
// `{_Myfirst,_Mylast,_Myend}` and anything shaped like an MSVC `std::string`, and dumps the bytes.
|
|
// It does NOT know which list it is looking at, and it types nothing -- which keeps L4's design
|
|
// point (a wrong record shows up as a wrong value offline, instead of being baked into the
|
|
// instrument) and means a false positive is a visible extra line rather than a silent mis-decode.
|
|
bool g_deep = false;
|
|
|
|
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<std::size_t>(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<std::size_t>(n) < kMaxEvents) {
|
|
Event& e = g_events[n];
|
|
e.seq = static_cast<std::uint32_t>(n);
|
|
e.probe = static_cast<std::uint8_t>(index);
|
|
e.pass = static_cast<std::int8_t>(p);
|
|
e.run = static_cast<std::uint32_t>(g_runSeq);
|
|
e.agent = static_cast<std::uint32_t>(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<LONG>(reinterpret_cast<std::uintptr_t>(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<void*>(&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 ---------------------------------------------------------------------------------
|
|
|
|
// ---- the deep scan (lane CB) --------------------------------------------------------------------
|
|
//
|
|
// Both detectors below are SHAPE tests on words the game handed us, and both can fire on a
|
|
// coincidence. That is stated in the report rather than tuned away: the thresholds bound how often
|
|
// it happens, and a `aivec`/`aistr` line on a list whose record has no vector or string is noise
|
|
// until a second run reproduces it.
|
|
|
|
// A plausible heap address. Below 64 KB is the null page; above 2 GB is kernel space on a 32-bit
|
|
// user process without /3GB, and no element in this block has ever pointed there.
|
|
inline bool PlausibleHeap(std::uint32_t p) { return p >= 0x00010000u && p < 0x80000000u; }
|
|
|
|
// `{_Myfirst, _Mylast, _Myend}` -- allocator LAST, so the three pointers are the first three words
|
|
// of the member and a fourth word is not needed to recognise it.
|
|
void ScanForVectors(int blk, int pid, int list, int idx, std::uintptr_t val, int words) {
|
|
int found = 0;
|
|
for (int i = 0; i + 2 < words && found < 8; ++i) {
|
|
const std::uintptr_t base = val + 4u * static_cast<unsigned>(i);
|
|
if (!Readable(base, 12)) continue;
|
|
const std::uint32_t first = U32(base), last = U32(base + 4), end = U32(base + 8);
|
|
if (!PlausibleHeap(first) || last < first || end < last) continue;
|
|
const std::uint32_t span = last - first, cap = end - first;
|
|
if ((span & 3u) || (cap & 3u) || cap > 4096u) continue;
|
|
const std::uint32_t count = span / 4u;
|
|
if (count > 256u) continue;
|
|
if (count && !Readable(first, span)) continue;
|
|
|
|
char hex[32 * 9 + 8] = {};
|
|
char ints[32 * 13 + 8] = {};
|
|
int hp = 0, ip = 0;
|
|
const std::uint32_t show = count < 32u ? count : 32u;
|
|
for (std::uint32_t w = 0; w < show; ++w) {
|
|
const std::uint32_t v = U32(first + 4u * w);
|
|
hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v);
|
|
ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast<int>(v));
|
|
}
|
|
LogF("aivec blk=%d pid=%d list=%d idx=%d at=w%d first=0x%08x cap=%u count=%u ints=[ %s] "
|
|
"hex=[ %s]%s",
|
|
blk, pid, list, idx, i, first, cap / 4u, count, ints, hex,
|
|
count > show ? " TRUNCATED" : "");
|
|
++found;
|
|
i += 2; // a match consumes its three words; overlapping reports are noise, not evidence
|
|
}
|
|
}
|
|
|
|
// MSVC `std::string`: `{union { char buf[16]; char* ptr }, _Mysize, _Myres, _Alval}` = 0x1c bytes,
|
|
// allocator last (method rule 5). `_Myres == 15` is the short-string case and the name is inline.
|
|
void ScanForStrings(int blk, int pid, int list, int idx, std::uintptr_t val, int words) {
|
|
int found = 0;
|
|
for (int i = 0; i + 5 < words && found < 4; ++i) {
|
|
const std::uintptr_t base = val + 4u * static_cast<unsigned>(i);
|
|
if (!Readable(base, 24)) continue;
|
|
const std::uint32_t size = U32(base + 16), res = U32(base + 20);
|
|
if (size > res || res > 0x1000u || res < 15u) continue;
|
|
char text[257] = {};
|
|
const std::uintptr_t chars =
|
|
(res == 15u) ? base : static_cast<std::uintptr_t>(U32(base));
|
|
if (res != 15u && !PlausibleHeap(static_cast<std::uint32_t>(chars))) continue;
|
|
const std::uint32_t n = size < 256u ? size : 256u;
|
|
if (n && !Readable(chars, n)) continue;
|
|
bool printable = true;
|
|
for (std::uint32_t c = 0; c < n; ++c) {
|
|
const std::uint8_t ch = U8(chars + c);
|
|
text[c] = static_cast<char>(ch);
|
|
if (ch < 0x20 || ch > 0x7e) printable = false;
|
|
}
|
|
if (!printable || n == 0) continue; // a non-printable "string" is a coincidence
|
|
LogF("aistr blk=%d pid=%d list=%d idx=%d at=w%d sso=%d len=%u text=\"%s\"", blk, pid, list,
|
|
idx, i, res == 15u ? 1 : 0, size, text);
|
|
++found;
|
|
i += 6;
|
|
}
|
|
}
|
|
|
|
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;
|
|
const int elemWords = g_elemWords;
|
|
while (node && node != head && idx < 64) {
|
|
const std::uintptr_t val = node + 8;
|
|
char hex[kMaxElemWords * 9 + 8] = {};
|
|
char ints[kMaxElemWords * 13 + 8] = {};
|
|
int hp = 0, ip = 0;
|
|
for (int w = 0; w < elemWords; ++w) {
|
|
const std::uintptr_t p = val + 4u * static_cast<unsigned>(w);
|
|
if (Readable(p, 4)) {
|
|
const std::uint32_t v = *reinterpret_cast<volatile std::uint32_t*>(p);
|
|
hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v);
|
|
ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast<int>(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 words=%d f0=%g f1=%g ints=[ %s] "
|
|
"hex=[ %s]",
|
|
blk, pid, list, idx, static_cast<unsigned>(node), elemWords,
|
|
static_cast<double>(F32(val)), static_cast<double>(F32(val + 4)), ints, hex);
|
|
if (g_deep) {
|
|
ScanForVectors(blk, pid, list, idx, val, elemWords);
|
|
ScanForStrings(blk, pid, list, idx, val, elemWords);
|
|
}
|
|
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<int>(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<unsigned>(b), pid, gRate,
|
|
static_cast<double>(F32(b + 0x08)), gTgt, static_cast<int>(U32(b + 0x10)), gBoost,
|
|
static_cast<int>(U32(b + 0x18)), static_cast<double>(F32(b + 0x1c)), gG4,
|
|
static_cast<int>(U8(b + 0x24)), static_cast<int>(U32(b + 0x28)), gF3,
|
|
static_cast<double>(F32(b + 0x30)), static_cast<double>(F32(b + 0x34)),
|
|
static_cast<double>(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<unsigned>(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<unsigned>(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<std::uint32_t>(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<unsigned>(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<std::size_t>(n) > kMaxEvents ? kMaxEvents : static_cast<std::size_t>(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<int>(e.pass), e.agent, static_cast<unsigned>(e.probe), nm);
|
|
}
|
|
g_eventsWritten = have;
|
|
if (static_cast<std::size_t>(n) > kMaxEvents)
|
|
LogF("aievent OVERFLOW -- %ld hits taken, only %u recorded; counters below are complete, "
|
|
"the ordering is not",
|
|
n, static_cast<unsigned>(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<std::uintptr_t>(blocksv);
|
|
LogF("---- aibatch seq=%u blocks=0x%08x n=%d stride=0x%x ----", g_batchSeq,
|
|
static_cast<unsigned>(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<unsigned>(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<unsigned>(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<std::uintptr_t>(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<char>(U8(p + i));
|
|
out[i] = (c >= 32 && static_cast<unsigned char>(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<std::uintptr_t>(w) + 4, out, cap);
|
|
if (out[0]) return;
|
|
ReadStdString(static_cast<std::uintptr_t>(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<std::uintptr_t>(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<unsigned>(a), static_cast<unsigned>(client),
|
|
static_cast<unsigned>(player), player ? U32(player + 0x294) : 0,
|
|
player ? static_cast<int>(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<int>(candWord0), n0,
|
|
candWord1, static_cast<int>(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<char>(U8(namePtr + i));
|
|
if (!c) break;
|
|
buf[i] = (c >= 32 && static_cast<unsigned char>(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
|
|
)");
|
|
|
|
// ---- the RunAI seed detour ----------------------------------------------------------------------
|
|
//
|
|
// `RunAI` is `__thiscall` with FOUR stack arguments and `ret 0x10`, so the caller pushed them and
|
|
// the callee reads them out of the caller's frame. At the stub's entry the return address is at
|
|
// esp+0 and `rngSeed` -- the fourth argument -- is at esp+0x10. After `pushfl` (4) + `pushal` (32)
|
|
// that is esp+52, and `netId` is esp+40. The stub hands the C function the NET ID BY VALUE and the
|
|
// SEED SLOT BY ADDRESS, which is what lets one function both read it and, in pin mode, replace it
|
|
// where the callee will look.
|
|
extern "C" void* g_aiSeedOrig;
|
|
void* g_aiSeedOrig = nullptr;
|
|
extern "C" void AiSeedDetour();
|
|
|
|
extern "C" void AiOnRunAI(int netId, std::uint32_t* seedSlot) {
|
|
++g_seedCalls;
|
|
const std::uint32_t observed = *seedSlot;
|
|
std::uint32_t used = observed;
|
|
if (g_seedMode == SeedMode::Pin) {
|
|
const SeedPin* chosen = nullptr;
|
|
for (std::size_t i = 0; i < g_seedPinCount; ++i)
|
|
if (g_seedPins[i].netId == netId) { chosen = &g_seedPins[i]; break; }
|
|
if (!chosen)
|
|
for (std::size_t i = 0; i < g_seedPinCount; ++i)
|
|
if (g_seedPins[i].netId < 0) { chosen = &g_seedPins[i]; break; }
|
|
if (chosen) {
|
|
*seedSlot = chosen->value;
|
|
used = chosen->value;
|
|
}
|
|
}
|
|
// `call` is the ordinal of this RunAI within the process, so a reader can bind the three seeds
|
|
// to the three clients even if two clients share a net id.
|
|
LogF("aiseed call=%u netId=%d observed=0x%08x used=0x%08x pinned=%d", g_seedCalls, netId,
|
|
observed, used, used != observed ? 1 : 0);
|
|
}
|
|
|
|
asm(R"(
|
|
.text
|
|
.globl _AiSeedDetour
|
|
_AiSeedDetour:
|
|
pushfl
|
|
pushal
|
|
leal 52(%esp), %eax
|
|
pushl %eax
|
|
pushl 44(%esp)
|
|
call _AiOnRunAI
|
|
addl $8, %esp
|
|
popal
|
|
popfl
|
|
jmp *_g_aiSeedOrig
|
|
)");
|
|
|
|
// `<netid>=<hex>` or `*=<hex>`, comma separated. A malformed entry is reported and the whole key
|
|
// is refused: a half-applied pin set is worse than none, because the run would look pinned.
|
|
bool ParseSeedPins(const char* value, std::string* err) {
|
|
g_seedPinCount = 0;
|
|
const char* p = value;
|
|
while (*p) {
|
|
while (*p == ' ' || *p == ',') ++p;
|
|
if (!*p) break;
|
|
if (g_seedPinCount >= kMaxSeedPins) {
|
|
if (err) *err = "too many seed pins";
|
|
return false;
|
|
}
|
|
int netId = -1;
|
|
if (*p == '*') {
|
|
++p;
|
|
} else {
|
|
char* end = nullptr;
|
|
netId = static_cast<int>(std::strtol(p, &end, 10));
|
|
if (end == p) {
|
|
if (err) *err = "expected <netid>=<hex> or *=<hex>";
|
|
return false;
|
|
}
|
|
p = end;
|
|
}
|
|
if (*p != '=') {
|
|
if (err) *err = "expected '=' after the net id";
|
|
return false;
|
|
}
|
|
++p;
|
|
char* end = nullptr;
|
|
const unsigned long v = std::strtoul(p, &end, 16);
|
|
if (end == p) {
|
|
if (err) *err = "expected a hex seed";
|
|
return false;
|
|
}
|
|
p = end;
|
|
g_seedPins[g_seedPinCount].netId = netId;
|
|
g_seedPins[g_seedPinCount].value = static_cast<std::uint32_t>(v);
|
|
++g_seedPinCount;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool ai_orders_config(const char* key, const char* value, std::string* err) {
|
|
if (std::strcmp(key, "aiseed") == 0) {
|
|
if (std::strcmp(value, "off") == 0) g_seedMode = SeedMode::Off;
|
|
else if (std::strcmp(value, "log") == 0) g_seedMode = SeedMode::Log;
|
|
else if (std::strcmp(value, "pin") == 0) g_seedMode = SeedMode::Pin;
|
|
else if (err) *err = "expected off|log|pin";
|
|
return true;
|
|
}
|
|
if (std::strcmp(key, "aiseed.values") == 0) {
|
|
if (!ParseSeedPins(value, err)) g_seedPinCount = 0;
|
|
return true;
|
|
}
|
|
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, "aiorders.words") == 0) {
|
|
char* end = nullptr;
|
|
const long v = std::strtol(value, &end, 10);
|
|
if (end == value || v < 1) {
|
|
if (err) *err = "expected a word count >= 1";
|
|
return true;
|
|
}
|
|
g_elemWords = static_cast<int>(v < kMaxElemWords ? v : kMaxElemWords);
|
|
return true;
|
|
}
|
|
if (std::strcmp(key, "aiorders.deep") == 0) {
|
|
if (std::strcmp(value, "on") == 0) g_deep = true;
|
|
else if (std::strcmp(value, "off") == 0) g_deep = false;
|
|
else if (err) *err = "expected on|off";
|
|
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<std::size_t>(v) < kProbeCount ? static_cast<std::size_t>(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<unsigned>(g_probeInstallCount), static_cast<unsigned>(kProbeCount));
|
|
// The capture has to be self-describing: an offline reader must not have to be told what
|
|
// window the words were taken at, and a capture with `deep=0` is INCOMPLETE rather than
|
|
// "a turn with no route", which is exactly the confusion method rule 20 is about.
|
|
LogF("aicfg words=%d deep=%d research=%d seed=%s pins=%u detours=%u", g_elemWords,
|
|
g_deep ? 1 : 0, g_research ? 1 : 0,
|
|
g_seedMode == SeedMode::Off ? "off" : (g_seedMode == SeedMode::Pin ? "pin" : "log"),
|
|
static_cast<unsigned>(g_seedPinCount),
|
|
static_cast<unsigned>(1 + g_probeInstallCount + (g_research ? 3u : 0u) +
|
|
(g_seedMode == SeedMode::Off ? 0u : 1u)));
|
|
if (g_seedMode == SeedMode::Pin && g_seedPinCount == 0)
|
|
LogF("aiseed: PIN MODE WITH NO PINS -- every seed passes through unchanged, so this run is "
|
|
"NOT pinned and must not be reported as one");
|
|
for (std::size_t i = 0; i < g_seedPinCount; ++i)
|
|
LogF("aiseed pin netId=%d value=0x%08x", g_seedPins[i].netId, g_seedPins[i].value);
|
|
|
|
void* target =
|
|
reinterpret_cast<void*>(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch);
|
|
MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast<void*>(&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_seedMode != SeedMode::Off) {
|
|
void* t = reinterpret_cast<void*>(exeBase + sots::addr::StrategyApp_RunAI);
|
|
MH_STATUS q1 = MH_CreateHook(t, reinterpret_cast<void*>(&AiSeedDetour), &g_aiSeedOrig);
|
|
MH_STATUS q2 = q1 == MH_OK ? MH_EnableHook(t) : q1;
|
|
LogF("aiseed: StrategyApp::RunAI rva=0x%08x va=%p create=%s enable=%s",
|
|
sots::addr::StrategyApp_RunAI, t, MH_StatusToString(q1), MH_StatusToString(q2));
|
|
if (q2 != MH_OK)
|
|
LogF("COVERAGE: aiseed hook NOT INSTALLED -- the capture will carry NO seeds, and an "
|
|
"absent seed list means the instrument failed, not that the clients were unseeded");
|
|
} else {
|
|
LogF("aiseed: disabled (aiseed=off) -- the capture will carry no seeds");
|
|
}
|
|
|
|
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<void*>(&AiSelectDetour), &g_aiSelectOrig},
|
|
{"StrategyAIAgent::TryResearchCandidate", sots::addr::StrategyAIAgent_TryResearchCandidate,
|
|
reinterpret_cast<void*>(&AiCandDetour), &g_aiCandOrig},
|
|
{"cl_SetResearchTarget", sots::addr::cl_SetResearchTarget,
|
|
reinterpret_cast<void*>(&AiSetTargetDetour), &g_aiSetTargetOrig},
|
|
};
|
|
for (const auto& r : kResearch) {
|
|
void* t = reinterpret_cast<void*>(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<void*>(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<unsigned>(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
|