sots-engine/src/shim/hooks/watchpoints.cpp
alex 6699f543bc watch.mode=cont: both containers, both ends, re-armed every turn; and hooks=off no longer disarms the watchpoints
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
2026-09-08 19:10:10 -04:00

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