Lane L3 needed to watch the trade-route and spy-program vectors across a whole game played forward, not one turn of one save. `modcount` and `tshn` arm once on purpose -- their targets are picked from one turn's state and re-picking them would move the measurement -- so this is a third mode rather than a change to either. `cont` puts all four debug slots on the two containers, `_Myfirst` as well as `_Mylast`. Both vectors are default-constructed with all three pointers zero, so the first element writes all three: watching only `_Mylast` cannot separate "allocated for the first time" from "appended to an existing buffer", and those are different events in the model this lane set out to falsify. It re-arms and re-logs on every End Turn, and the canary self-test's counter is therefore read as a delta -- on the arm-once modes the delta is the old value, so their log lines are byte-identical. `ReportContainer` is factored out of `ArmTshnSlots` so both modes emit the same container line. Lane W3's published count=0 is the control every later count is compared against, and a reformatted line would have made that comparison a judgement call. The defect: `Shim_Init` returned before `install_watchpoints` whenever the trace mode was `off`, so `hooks=off watch=on` printed `watch=on` in the banner and armed absolutely nothing -- a config that reports a confident zero, which is the failure method rule 1 exists to catch. The watchpoints are an independent instrument with their own arming detour and no trace records, and a long play session wants them without paying 30-45 s per End Turn for template hooks that measure nothing it is asking about. MinHook is now initialised and the module installed on the `hooks=off` path when `watch=on`. Configs: shim.cfg.l3cont / .l3control differ in exactly one key for rule 19; .l3probe is lane H's hp11 verbatim plus the three watch keys, so the entry counts stay comparable to lane H's empty-container baseline line for line. Gates: clean_room_check OK; host ctest 54/54; CT111 shim cross-build OK, exports 66 names identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
648 lines
28 KiB
C++
648 lines
28 KiB
C++
#include "shim/hooks/watchpoints.h"
|
|
|
|
#include <windows.h>
|
|
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
|
|
#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<std::uint32_t>(c->Dr6);
|
|
|
|
std::uint32_t slot = 0xff;
|
|
for (int i = 0; i < 4; ++i) {
|
|
if (dr6 & (1u << i)) { slot = static_cast<std::uint32_t>(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<std::size_t>(n) < kMaxHits) {
|
|
Hit& h = g_hits[n];
|
|
h.seq = static_cast<std::uint32_t>(n);
|
|
h.slot = slot;
|
|
h.dr6 = dr6;
|
|
h.eip = static_cast<std::uint32_t>(c->Eip);
|
|
h.tid = GetCurrentThreadId();
|
|
h.mark = static_cast<std::uint32_t>(g_mark);
|
|
const std::uintptr_t addr = g_watchAddr[slot];
|
|
h.value = addr ? *reinterpret_cast<volatile std::uint32_t*>(addr) : 0;
|
|
|
|
const std::uintptr_t ebp = static_cast<std::uintptr_t>(c->Ebp);
|
|
h.ebpRet = StackReadable(ebp + 4) ? *reinterpret_cast<std::uint32_t*>(ebp + 4) : 0;
|
|
h.ebpRet2 = 0;
|
|
if (StackReadable(ebp)) {
|
|
const std::uintptr_t up = *reinterpret_cast<std::uint32_t*>(ebp);
|
|
if (StackReadable(up + 4)) h.ebpRet2 = *reinterpret_cast<std::uint32_t*>(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<std::uintptr_t>(c->Esp); found < 4 && p < c->Esp + 0x200;
|
|
p += 4) {
|
|
if (!StackReadable(p)) break;
|
|
const std::uint32_t v = *reinterpret_cast<std::uint32_t*>(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<std::uint32_t>(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<void*>(p), n);
|
|
}
|
|
inline std::uint32_t U32(std::uintptr_t p) { return *reinterpret_cast<volatile std::uint32_t*>(p); }
|
|
inline std::uint8_t U8(std::uintptr_t p) { return *reinterpret_cast<volatile std::uint8_t*>(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<std::uintptr_t>(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<char>(U8(p + i));
|
|
out[i] = (c >= 32 && static_cast<unsigned char>(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<int>((*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<unsigned>(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<unsigned>(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<unsigned>(sys), nm, af, p.size, nve, static_cast<unsigned>(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<std::uintptr_t>(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<void*>(vecBegin), 8)) {
|
|
begin = *reinterpret_cast<std::uint32_t**>(vecBegin);
|
|
end = *reinterpret_cast<std::uint32_t**>(vecBegin + 4);
|
|
}
|
|
const int n = (begin && end && end >= begin) ? static_cast<int>(end - begin) : 0;
|
|
LogF("watch: players vector @%p begin=%p end=%p count=%d",
|
|
reinterpret_cast<void*>(vecBegin), static_cast<void*>(begin), static_cast<void*>(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<void*>(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<std::uintptr_t>(const_cast<std::uint32_t*>(&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<unsigned>(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<const IMAGE_DOS_HEADER*>(exeBase);
|
|
const IMAGE_NT_HEADERS* nt =
|
|
reinterpret_cast<const IMAGE_NT_HEADERS*>(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<unsigned>(g_textLo),
|
|
static_cast<unsigned>(g_textHi), g_outPath);
|
|
|
|
void* target = reinterpret_cast<void*>(exeBase + sots::addr::StrategyServer_ApplyAllTurnCommands);
|
|
MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast<void*>(&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<std::size_t>(n) > kMaxHits ? kMaxHits
|
|
: static_cast<std::size_t>(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<int>(h.value), h.value,
|
|
static_cast<unsigned long>(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<std::size_t>(n) > kMaxHits)
|
|
LogF("watch: OVERFLOW -- %ld hits taken, only %u recorded", n,
|
|
static_cast<unsigned>(kMaxHits));
|
|
}
|
|
|
|
} // namespace shim::hooks
|