#include "shim/hooks/watchpoints.h" #include #include #include #include #include #include "MinHook.h" #include "generated/sots_addresses.h" namespace shim::hooks { namespace { // ---- configuration ------------------------------------------------------------------------- bool g_enabled = false; int g_players = 2; char g_outPath[MAX_PATH] = {}; // Which four addresses the single arming point computes. `modcount` is lane W2's set and is the // default so its run stays reproducible byte for byte; `tshn` is lane W3's; `cont` is lane L3's. // // `cont` differs from the other two in one further respect: it re-arms and re-logs on EVERY End // Turn rather than only the first. The other two modes measure one turn of one save, where arming // once is right. L3's workload is a game played forward across many turns and the question is // "did the container grow on turn N", which needs a line per turn. enum class Mode { ModCount, Tshn, Cont }; Mode g_mode = Mode::ModCount; // ---- what is being watched ------------------------------------------------------------------ // // Filled in by the arming function, because in `tshn` mode two of the four slots are only nameable // once the target object has been found. Printed with the address, so a reader of the log never // has to trust that slot N is what the brief said it would be. char g_slotName[4][96] = {}; void SetSlotName(int i, const char* s) { std::snprintf(g_slotName[i], sizeof g_slotName[i], "%s", s); } // ---- hit records ----------------------------------------------------------------------------- // // Written from a vectored exception handler, so: no allocation, no CRT, no locking. A fixed array // and one interlocked counter. Reading a stack word is guarded against the thread's own stack // bounds so a bad walk cannot fault inside the handler. struct Hit { std::uint32_t seq; std::uint32_t slot; // 0..3, or 0xff when DR6 named no slot std::uint32_t dr6; std::uint32_t eip; std::uint32_t value; // the word after the write (data breakpoints are traps) std::uint32_t tid; std::uint32_t ebpRet; // [ebp+4] std::uint32_t ebpRet2; // [[ebp]+4] std::uint32_t scan[4]; // first four code-looking words above ESP std::uint32_t mark; // value of the flush marker when the hit was taken }; constexpr std::size_t kMaxHits = 4096; Hit g_hits[kMaxHits]; volatile LONG g_hitCount = 0; std::size_t g_written = 0; volatile LONG g_mark = 0; // bumped by the arming detour: which End-Turn window a hit belongs to std::uintptr_t g_textLo = 0, g_textHi = 0; std::uintptr_t g_watchAddr[4] = {0, 0, 0, 0}; FILE* g_out = nullptr; void* g_veh = nullptr; DWORD g_armedTid = 0; // A word we own, used once to prove the hardware actually traps before any game number is // believed (method rule 1: a green verdict is not evidence). volatile std::uint32_t g_canary = 0; bool g_canaryArmed = false; volatile LONG g_canaryHits = 0; // The current thread's stack bounds, straight out of the TIB. Read with one instruction rather // than through `NtCurrentTeb()`, whose mingw definition trips -Werror=array-bounds when it is // inlined into a handler. fs:[0x04] = StackBase (high), fs:[0x08] = StackLimit (low). inline std::uint32_t ReadFs(std::uint32_t off) { std::uint32_t v; asm volatile("movl %%fs:(%1), %0" : "=r"(v) : "r"(off)); return v; } inline bool StackReadable(std::uintptr_t p) { const std::uintptr_t hi = ReadFs(0x04); const std::uintptr_t lo = ReadFs(0x08); return lo && hi > lo && p >= lo && p + 4 <= hi; } inline bool LooksLikeCode(std::uint32_t v) { return v >= g_textLo && v < g_textHi; } LONG CALLBACK WatchVeh(EXCEPTION_POINTERS* ep) { if (ep->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) return EXCEPTION_CONTINUE_SEARCH; CONTEXT* c = ep->ContextRecord; const std::uint32_t dr6 = static_cast(c->Dr6); std::uint32_t slot = 0xff; for (int i = 0; i < 4; ++i) { if (dr6 & (1u << i)) { slot = static_cast(i); break; } } // A single-step exception with no DR6 slot bit is not ours; leave it to whoever raised it. if (slot == 0xff) return EXCEPTION_CONTINUE_SEARCH; if (g_canaryArmed && slot == 3) { InterlockedIncrement(&g_canaryHits); c->Dr6 = 0; return EXCEPTION_CONTINUE_EXECUTION; } const LONG n = InterlockedIncrement(&g_hitCount) - 1; if (n >= 0 && static_cast(n) < kMaxHits) { Hit& h = g_hits[n]; h.seq = static_cast(n); h.slot = slot; h.dr6 = dr6; h.eip = static_cast(c->Eip); h.tid = GetCurrentThreadId(); h.mark = static_cast(g_mark); const std::uintptr_t addr = g_watchAddr[slot]; h.value = addr ? *reinterpret_cast(addr) : 0; const std::uintptr_t ebp = static_cast(c->Ebp); h.ebpRet = StackReadable(ebp + 4) ? *reinterpret_cast(ebp + 4) : 0; h.ebpRet2 = 0; if (StackReadable(ebp)) { const std::uintptr_t up = *reinterpret_cast(ebp); if (StackReadable(up + 4)) h.ebpRet2 = *reinterpret_cast(up + 4); } int found = 0; h.scan[0] = h.scan[1] = h.scan[2] = h.scan[3] = 0; for (std::uintptr_t p = static_cast(c->Esp); found < 4 && p < c->Esp + 0x200; p += 4) { if (!StackReadable(p)) break; const std::uint32_t v = *reinterpret_cast(p); if (LooksLikeCode(v)) h.scan[found++] = v; } } c->Dr6 = 0; return EXCEPTION_CONTINUE_EXECUTION; } // Set DR0..DR3 on the CALLING thread. Debug registers are per-thread state, and this is the // documented-enough way to reach them from inside that thread; the arm is read back and logged // rather than assumed, because an arm that silently did nothing looks exactly like "no writer". bool ArmCurrentThread(const std::uintptr_t addr[4], int count, std::uint32_t* readbackDr7) { CONTEXT c; std::memset(&c, 0, sizeof c); c.ContextFlags = CONTEXT_DEBUG_REGISTERS; const HANDLE th = GetCurrentThread(); if (!GetThreadContext(th, &c)) return false; c.Dr0 = addr[0]; c.Dr1 = addr[1]; c.Dr2 = addr[2]; c.Dr3 = addr[3]; DWORD dr7 = 0; for (int i = 0; i < count; ++i) { if (!addr[i]) continue; dr7 |= (1u << (i * 2)); // Ln: local enable dr7 |= (0b01u << (16 + i * 4)); // R/W: 01 = break on data write dr7 |= (0b11u << (18 + i * 4)); // LEN: 11 = 4 bytes } c.Dr7 = dr7; c.Dr6 = 0; if (!SetThreadContext(th, &c)) return false; CONTEXT back; std::memset(&back, 0, sizeof back); back.ContextFlags = CONTEXT_DEBUG_REGISTERS; if (GetThreadContext(th, &back)) *readbackDr7 = static_cast(back.Dr7); return true; } void (*g_log)(const char*) = nullptr; void LogF(const char* fmt, ...) { if (!g_log) return; char buf[1024]; va_list ap; va_start(ap, fmt); std::vsnprintf(buf, sizeof buf, fmt, ap); va_end(ap); g_log(buf); } bool g_armed = false; // ---- lane W3: reaching the NVO map, the trade vector and the spy vector ----------------------- // // All three hang off the same `S` the arming detour already has, so this adds no second hook. // // The offsets below were re-derived from the instruction stream of `ServerSystem::Write` // 0x00749630 rather than taken from a table, because two published accounts disagreed by 8. The // reason for the disagreement: `Write` is entered on the **IStreamable subobject at // ServerSystem+0x8** (the RTTI COL offset for its vftable is +0x8), so every displacement in its // body is 8 less than the ServerSystem-relative offset. `[edi+0x26c]`/`[edi+0x270]` at // 0x0074a195/0x0074a07d are therefore `NVO._Myhead`/`_Mysize` at ServerSystem **+0x274/+0x278**. // // This code does not *rely* on that reconciliation. It probes both candidate bases on every // system, counts how many validate under each, and logs both counts -- rule 1: an arm that landed // on the wrong object is indistinguishable from "nothing writes this". constexpr std::uint32_t kSysNvoHead = 0x274; // ServerSystem-relative constexpr std::uint32_t kSysAFlags = 0xd4; constexpr std::uint32_t kSysName = 0xa8; // std::string, 0x1c bytes constexpr std::uint32_t kSysNveSize = 0x288; constexpr std::uint32_t kNodeIsNil = 0x8d; // an NVO node is ~0x90 bytes; NVE's is 0x20 constexpr std::uint32_t kNodeValue = 0x10; // {int16 touched, int16 TShn, int32 OID, ...} 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 *reinterpret_cast(p); } inline std::uint8_t U8(std::uintptr_t p) { return *reinterpret_cast(p); } struct NvoProbe { bool ok = false; std::uintptr_t head = 0; std::uint32_t size = 0; std::uintptr_t root = 0; // == _Myhead->_Parent std::uint32_t key = 0; // the root node's key: a player INDEX (Write reads players[key]) }; // Validate `sys + headOff` as an MSVC `_Tree` header {_Myhead, _Mysize}. The head node is the nil // sentinel, so its _Isnil byte must be 1 and the root it parents must have _Isnil 0. NvoProbe ProbeNvo(std::uintptr_t sys, std::uint32_t headOff) { NvoProbe r; const std::uintptr_t hp = sys + headOff; if (!Readable(hp, 8)) return r; r.head = U32(hp); r.size = U32(hp + 4); if (!Readable(r.head, 0x90)) return r; if (U8(r.head + kNodeIsNil) != 1) return r; if (r.size == 0 || r.size > 64) return r; const std::uintptr_t root = U32(r.head + 4); if (!Readable(root, 0x90)) return r; if (U8(root + kNodeIsNil) != 0) return r; const std::uint32_t key = U32(root + 0xc); if (key > 63) return r; // player index, not a handle r.root = root; r.key = key; r.ok = true; return r; } // MSVC std::string (0x1c): union _Bx at +0, _Mysize +0x10, _Myres +0x14; short strings live in the // union. Copies at most `cap-1` bytes and always NUL-terminates. 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); const std::uintptr_t p = (res < 16) ? s : static_cast(U32(s)); if (len == 0 || len > 0x100 || !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 std::vector here is {_Myfirst,_Mylast,_Myend,_Alval} = 0x10, allocator LAST (method rule 5). // Returns the element count and reports the raw triple, because "the offset names something else" // and "the container is empty" have to be separable from the log alone. int VectorCount(std::uintptr_t vec, std::uint32_t* first, std::uint32_t* last, std::uint32_t* end, std::uint32_t stride) { *first = *last = *end = 0; if (!Readable(vec, 12)) return -1; *first = U32(vec); *last = U32(vec + 4); *end = U32(vec + 8); if (*last < *first || (*last - *first) % stride) return -1; return static_cast((*last - *first) / stride); } // The systems vector in the S+4 frame. Lane E3 decoded tail phase 17 as `mov edx,[esi+0x44]` = // systems.begin with esi = S, i.e. (S+4)+0x40 -- the same frame `StrategyServer_off_Players` // (0x50) is expressed in. Recorded in ghidra/addresses.d/lane-w3.json; kept local rather than // pulled from the generated header because a concurrent lane owns that header this session. constexpr std::uint32_t kServerOffSystems = 0x40; // The two containers lane W2 ยง8.3 located but did not arm. Both are one add from `S`. constexpr std::uint32_t kServerOffTradeMgr = 0x154; // -> ServerTradeManagerImpl* constexpr std::uint32_t kTradeMgrOffVec = 0x3c; // _Myfirst; _Mylast at +0x40 constexpr std::uint32_t kServerOffSpyMgr = 0x158; // -> ServerSpyManager* constexpr std::uint32_t kSpyMgrOffVec = 0x10; // _Myfirst; _Mylast at +0x14 // One container report line. Factored out of lane W3's arming function so lane L3's mode prints // the identical line -- the two modes' numbers have to be comparable word for word, and W3's // published counts are the control this campaign compares every later count against. struct ContInfo { std::uintptr_t mgr; // 0 when the manager pointer is null or unreadable int count; // element count, or -1 when the offset does not name a std::vector }; ContInfo ReportContainer(std::uintptr_t S, std::uint32_t mgrOff, std::uint32_t vecOff, const char* what) { const std::uintptr_t slot = S + 4 + mgrOff; const std::uintptr_t mgr = Readable(slot, 4) ? U32(slot) : 0; std::uint32_t vf = 0, vl = 0, ve = 0; int n = -1; if (Readable(mgr, vecOff + 12)) n = VectorCount(mgr + vecOff, &vf, &vl, &ve, 4); LogF("watch: %s -- manager=0x%08x (S+4+0x%x) vector@mgr+0x%x first=0x%08x last=0x%08x " "end=0x%08x count=%d", what, static_cast(mgr), mgrOff, vecOff, vf, vl, ve, n); return {mgr, n}; } // Lane L3's four slots: both ends of both containers. // // `_Mylast` alone catches every push_back, which is what lane W3 armed. `_Myfirst` is armed too // because these vectors are default-constructed -- all three pointers zero -- so the FIRST element // writes all three, and watching only `_Mylast` cannot tell "the vector was allocated for the // first time" from "an element was appended to an existing buffer". They are different events in // the model this lane is trying to falsify, so they get different slots. // // The canary self-test then borrows slot 3 for one write and gives it back, exactly as in the // other two modes. void ArmContSlots(std::uintptr_t S) { const ContInfo trade = ReportContainer(S, kServerOffTradeMgr, kTradeMgrOffVec, "trade routes"); const ContInfo spy = ReportContainer(S, kServerOffSpyMgr, kSpyMgrOffVec, "spy programs"); const struct { const ContInfo* c; std::uint32_t vecOff; const char* what; } want[4] = { {&trade, kTradeMgrOffVec, "trade routes _Myfirst"}, {&trade, kTradeMgrOffVec + 4, "trade routes _Mylast"}, {&spy, kSpyMgrOffVec, "spy programs _Myfirst"}, {&spy, kSpyMgrOffVec + 4, "spy programs _Mylast"}, }; for (int i = 0; i < 4; ++i) { if (want[i].c->mgr) { g_watchAddr[i] = want[i].c->mgr + want[i].vecOff; std::snprintf(g_slotName[i], sizeof g_slotName[i], "%s (mgr+0x%x, count %d at arm)", want[i].what, want[i].vecOff, want[i].c->count); } else { std::snprintf(g_slotName[i], sizeof g_slotName[i], "%s (manager null)", want[i].what); } } } // Lane W3's four slots. Slot 0 is the whole point: the `TShn` word of an NVO record on a system // whose AFlags is zero. Slot 1 is its map's _Mysize, which is what makes slot 0's silence mean // something -- if the node were freed and reallocated, slot 0 would be watching dead memory and // would report a confident nothing (rule 20's distinction, one level down). void ArmTshnSlots(std::uintptr_t S) { const std::uintptr_t sysVec = S + 4 + kServerOffSystems; std::uint32_t f = 0, l = 0, e = 0; const int nSys = VectorCount(sysVec, &f, &l, &e, 4); LogF("watch: systems vector @0x%08x first=0x%08x last=0x%08x end=0x%08x count=%d " "(expect 28 on the reference game)", static_cast(sysVec), f, l, e, nSys); // Does the vector hold the ServerSystem base, or its IStreamable subobject 8 bytes in? Probe // both and let the systems themselves decide, rather than trusting either published table. const std::uint32_t headOff[2] = {kSysNvoHead, kSysNvoHead - 8}; int okAt[2] = {0, 0}; for (int a = 0; a < 2; ++a) { for (int i = 0; i < nSys && i < 256; ++i) { const std::uintptr_t sys = U32(f + i * 4); if (!Readable(sys, 0x300)) continue; if (ProbeNvo(sys, headOff[a]).ok) ++okAt[a]; } } const int adj = (okAt[0] >= okAt[1]) ? 0 : 1; const std::uint32_t d = adj ? 8 : 0; LogF("watch: NVO header probe -- ServerSystem+0x%x validated on %d systems, +0x%x on %d; " "using +0x%x (pointer delta %u)", kSysNvoHead, okAt[0], kSysNvoHead - 8, okAt[1], headOff[adj], d); std::uintptr_t target = 0; NvoProbe tp; char tname[40] = {}; for (int i = 0; i < nSys && i < 256; ++i) { const std::uintptr_t sys = U32(f + i * 4); if (!Readable(sys, 0x300)) continue; const NvoProbe p = ProbeNvo(sys, headOff[adj]); const std::uint32_t af = Readable(sys + kSysAFlags - d, 4) ? U32(sys + kSysAFlags - d) : 0xffffffffu; const std::uint32_t nve = Readable(sys + kSysNveSize - d, 4) ? U32(sys + kSysNveSize - d) : 0xffffffffu; char nm[40]; ReadStdString(sys + kSysName - d, nm, sizeof nm); LogF("watch: sys[%d] @0x%08x '%s' AFlags=0x%x NVO=%u NVE=%u root=0x%08x key=%u ok=%d", i, static_cast(sys), nm, af, p.size, nve, static_cast(p.root), p.key, p.ok ? 1 : 0); if (!target && af == 0 && p.ok) { target = sys; tp = p; std::snprintf(tname, sizeof tname, "%s", nm); } } if (target) { g_watchAddr[0] = tp.root + kNodeValue; g_watchAddr[1] = target + kSysNvoHead + 4 - d; std::snprintf(g_slotName[0], sizeof g_slotName[0], "'%s' NVO root+0x10 {touched:i16,TShn:i16} key=player %u", tname, tp.key); std::snprintf(g_slotName[1], sizeof g_slotName[1], "'%s' NVO._Mysize (=%u at arm)", tname, tp.size); } else { LogF("watch: NO system has AFlags==0 with a non-empty NVO -- slots 0/1 UNSET. That is a " "FAILED TARGET SELECTION, not a measurement; every zero below is unmeasured."); SetSlotName(0, "(no target found)"); SetSlotName(1, "(no target found)"); } // The workload confirmation. Printing these two counts is the cheap half of the answer: two // lanes have failed to build a trade/spy workload, and nobody has yet printed the containers // from a live game to say whether a workload took. const struct { std::uint32_t mgrOff, vecOff; const char* what; } cont[2] = { {kServerOffTradeMgr, kTradeMgrOffVec, "trade routes"}, {kServerOffSpyMgr, kSpyMgrOffVec, "spy programs"}, }; for (int i = 0; i < 2; ++i) { const ContInfo ci = ReportContainer(S, cont[i].mgrOff, cont[i].vecOff, cont[i].what); if (ci.mgr) { g_watchAddr[2 + i] = ci.mgr + cont[i].vecOff + 4; std::snprintf(g_slotName[2 + i], sizeof g_slotName[2 + i], "%s vector _Mylast (mgr+0x%x, count %d at arm)", cont[i].what, cont[i].vecOff + 4, ci.count); } else { std::snprintf(g_slotName[2 + i], sizeof g_slotName[2 + i], "%s (manager null)", cont[i].what); } } } DWORD WINAPI FlusherThread(LPVOID) { for (;;) { Sleep(2000); watch_flush(nullptr); } } } // namespace // The arming detour. Register-transparent asm stub, same shape as the M0 hook in main.cpp: the // original's own `ret` stays in charge of stack cleanup, so this is correct whatever the real // convention turns out to be. extern "C" void* g_watchApplyAllOrig; void* g_watchApplyAllOrig = nullptr; extern "C" void WatchApplyAllDetour(); extern "C" void WatchOnApplyAll(void* self) { InterlockedIncrement(&g_mark); // modcount/tshn arm once and never again -- their targets are chosen from one turn's state and // re-picking them would move the measurement. `cont` re-arms every turn: the two manager // pointers are stable for the life of the server, and the count line is the per-turn signal. if (g_armed && g_mode != Mode::Cont) { watch_flush(g_log); return; } if (g_armed) watch_flush(g_log); const std::uintptr_t S = reinterpret_cast(self); g_watchAddr[0] = 0; g_watchAddr[1] = 0; g_watchAddr[2] = 0; g_watchAddr[3] = 0; if (g_mode == Mode::Cont) { ArmContSlots(S); } else if (g_mode == Mode::Tshn) { ArmTshnSlots(S); } else { g_watchAddr[0] = S + 0x8; g_watchAddr[1] = S + 0xc; SetSlotName(0, "S+0x8 (A2:ModCount / T:PhaseCounter)"); SetSlotName(1, "S+0xc (A2:Frame / addresses.json:ModCount)"); SetSlotName(2, "player[0]+0x164 Status"); SetSlotName(3, "player[1]+0x164 Status"); // players vector lives at S+0x54 (raw +0x50 in the S+4 frame -- see the ctor enumeration in // StrategyServer_base_delta). Read defensively: a wrong pointer here must not fault. if (g_players > 0) { const std::uintptr_t vecBegin = S + 4 + sots::addr::StrategyServer_off_Players; std::uint32_t* begin = nullptr; std::uint32_t* end = nullptr; if (!IsBadReadPtr(reinterpret_cast(vecBegin), 8)) { begin = *reinterpret_cast(vecBegin); end = *reinterpret_cast(vecBegin + 4); } const int n = (begin && end && end >= begin) ? static_cast(end - begin) : 0; LogF("watch: players vector @%p begin=%p end=%p count=%d", reinterpret_cast(vecBegin), static_cast(begin), static_cast(end), n); for (int i = 0; i < g_players && i < n && i < 2; ++i) { const std::uintptr_t p = begin[i]; if (p && !IsBadReadPtr(reinterpret_cast(p), 0x168)) g_watchAddr[2 + i] = p + sots::addr::ServerPlayer_off_Status; } } } // end Mode::ModCount // Instrument self-test: put slot 3 on a word we own, write it, and require exactly one trap // before any game number is trusted. const std::uintptr_t saved3 = g_watchAddr[3]; g_watchAddr[3] = reinterpret_cast(const_cast(&g_canary)); std::uint32_t dr7 = 0; // The counter is cumulative and `cont` mode runs this test once per End Turn, so take the // delta. On the arm-once modes `before` is 0 and the printed value is unchanged. const LONG canaryBefore = g_canaryHits; g_canaryArmed = true; if (!ArmCurrentThread(g_watchAddr, 4, &dr7)) { LogF("watch: ArmCurrentThread FAILED (err %lu) -- NOTHING IS ARMED", GetLastError()); g_canaryArmed = false; g_armed = true; // do not retry every turn return; } g_canary = 0x5a5a5a5a; const LONG canaryHits = g_canaryHits - canaryBefore; g_canaryArmed = false; LogF("watch: SELFTEST canary writes=1 traps=%ld dr7=0x%08x %s", canaryHits, dr7, canaryHits == 1 ? "PASS" : "FAIL -- every count below is unmeasured, not zero"); g_watchAddr[3] = saved3; if (!ArmCurrentThread(g_watchAddr, 4, &dr7)) { LogF("watch: re-arm FAILED (err %lu)", GetLastError()); g_armed = true; return; } g_armedTid = GetCurrentThreadId(); g_armed = true; for (int i = 0; i < 4; ++i) LogF("watch: slot %d -> %s = 0x%08x%s", i, g_slotName[i], static_cast(g_watchAddr[i]), g_watchAddr[i] ? "" : " (unset)"); LogF("watch: ARMED on tid %lu dr7=0x%08x, S=%p (ApplyAllTurnCommands this)", g_armedTid, dr7, self); } asm(R"( .text .globl _WatchApplyAllDetour _WatchApplyAllDetour: pushfl pushal pushl %ecx call _WatchOnApplyAll addl $4, %esp popal popfl jmp *_g_watchApplyAllOrig )"); bool watch_apply_config(const char* key, const char* value, std::string* err) { if (std::strcmp(key, "watch") == 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, "watch.mode") == 0) { if (std::strcmp(value, "modcount") == 0) g_mode = Mode::ModCount; else if (std::strcmp(value, "tshn") == 0) g_mode = Mode::Tshn; else if (std::strcmp(value, "cont") == 0) g_mode = Mode::Cont; else if (err) *err = "expected modcount|tshn|cont"; return true; } if (std::strcmp(key, "watch.out") == 0) { std::snprintf(g_outPath, sizeof g_outPath, "%s", value); return true; } if (std::strcmp(key, "watch.players") == 0) { g_players = std::atoi(value); if (g_players < 0) g_players = 0; if (g_players > 2) g_players = 2; return true; } return false; } bool watch_enabled() { return g_enabled; } void install_watchpoints(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)) { g_log = log; if (!g_enabled) { LogF("watch: disabled (watch=off)"); return; } if (!g_outPath[0]) std::snprintf(g_outPath, sizeof g_outPath, "%s\\shim.watch.txt", gameDir); g_out = std::fopen(g_outPath, "w"); if (!g_out) LogF("watch: cannot open %s -- hits go to shim.log only", g_outPath); // Text range, for the "does this stack word look like a return address" test. Taken from the // PE headers rather than guessed: method rule 17's lesson is that assumed extents lose data. const IMAGE_DOS_HEADER* dos = reinterpret_cast(exeBase); const IMAGE_NT_HEADERS* nt = reinterpret_cast(exeBase + dos->e_lfanew); g_textLo = exeBase + nt->OptionalHeader.BaseOfCode; g_textHi = g_textLo + nt->OptionalHeader.SizeOfCode; // A background flusher, because the process may be killed rather than quit and // DLL_PROCESS_DETACH is not guaranteed. The hit array is append-only and the reader only ever // trails the writer, so no lock is needed between them. CreateThread(nullptr, 0, &FlusherThread, nullptr, 0, nullptr); g_veh = AddVectoredExceptionHandler(1, WatchVeh); LogF("watch: VEH=%p text=[0x%08x,0x%08x) out=%s", g_veh, static_cast(g_textLo), static_cast(g_textHi), g_outPath); void* target = reinterpret_cast(exeBase + sots::addr::StrategyServer_ApplyAllTurnCommands); MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast(&WatchApplyAllDetour), &g_watchApplyAllOrig); MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1; LogF("watch: arm hook StrategyServer::ApplyAllTurnCommands rva=0x%08x va=%p create=%s enable=%s", sots::addr::StrategyServer_ApplyAllTurnCommands, target, MH_StatusToString(s1), MH_StatusToString(s2)); } void watch_flush(void (*log)(const char*)) { if (log) g_log = log; const LONG n = g_hitCount; const std::size_t have = static_cast(n) > kMaxHits ? kMaxHits : static_cast(n); if (have <= g_written) return; for (std::size_t i = g_written; i < have; ++i) { const Hit& h = g_hits[i]; char line[512]; std::snprintf(line, sizeof line, "watchhit seq=%u mark=%u slot=%u dr6=0x%08x eip=0x%08x value=%d(0x%08x) " "tid=%lu ebpret=0x%08x ebpret2=0x%08x scan=0x%08x,0x%08x,0x%08x,0x%08x", h.seq, h.mark, h.slot, h.dr6, h.eip, static_cast(h.value), h.value, static_cast(h.tid), h.ebpRet, h.ebpRet2, h.scan[0], h.scan[1], h.scan[2], h.scan[3]); if (g_log) g_log(line); if (g_out) { std::fputs(line, g_out); std::fputc('\n', g_out); } } g_written = have; if (g_out) std::fflush(g_out); if (static_cast(n) > kMaxHits) LogF("watch: OVERFLOW -- %ld hits taken, only %u recorded", n, static_cast(kMaxHits)); } } // namespace shim::hooks