New fpu_force module (4 register-transparent asm stubs, same pattern as the M0
Initialize hook, so the [unverified] prototypes of the turn-gate functions are
never relied on) plus two shim.cfg keys:
fpu.force=<cw>|off fldcw at StrategyClient::EndTurn and
StrategyServer::BeginProcessTurn, and nowhere else
fpu.sample_ticks=on|off per-frame sampler, logs only when the word CHANGES
Forcing is deliberately one write per turn: re-forcing inside the pipeline would
guarantee the value is present without proving it ever held, which is the exact
false negative this experiment has to avoid. Verification is kept separate --
StrategyServer::ProcessTurn is hooked sample-only, and its reading plus the
existing per-hook fpu_cw fields (38 samples per turn across phases 4, 6 and 8)
are what establish that the setting lasted the whole turn.
Six shim.cfg variants, identical apart from the fpu.force line, and docs.
Used to settle STATE_CHECKSUM.md 3.5: 53-bit and 64-bit x87 produce byte-
identical turn results, so an x64/SSE port has no double-rounding budget to
preserve; 24-bit and round-up each move exactly one thing. Findings and evidence
live in the notes repo (findings/subsystems/fpu-precision-sensitivity.md).
clean_room_check.sh OK; host ctest 32/32.
15 KiB
Shim trace / compare infrastructure (src/shim/trace/)
Hooks installed by the proxy binkw32.dll can run in one of four modes and write a
JSON-Lines log that the harness in sots-re/verify/harness/compare/ (tracecmp.py) validates
and diffs. The wire format is TRACE_FORMAT.md (v1) in that directory; the C++ emitter here is
a byte-exact port of its reference emitter (mkfixture.py) and is tested against it.
| file | what |
|---|---|
platform.h |
mutex (CRITICAL_SECTION / pthread), monotonic µs, thread id, UTC stamp, SHIM_CDECL/SHIM_STDCALL |
sha256.{h,cpp} |
FIPS 180-4 SHA-256, no allocation |
emitter.{h,cpp} |
typed values (Tv), Record, Meta, Buf, emit_record/emit_meta (spec §3–5) |
tracer.{h,cpp} |
Config (shim.cfg keys), Tracer (locked writer, call ids, depth), Region/Snapshot/Scratch, diff_tv/diff_outputs (spec §6) |
hook.h |
Hook<Descriptor>: the mode dispatch around a hooked C function |
selftest.{h,cpp} |
worked example hook over the shim's own Fill(); run once per launch |
Host tests: tests/shim_trace/ (ctest shim_trace_*). They build the same sources with
-Wall -Wextra -Werror on Linux; the shim cross-build links the same static library.
Modes
| mode | what runs | what is logged | caller gets |
|---|---|---|---|
off |
original | nothing | original's result |
trace |
original | args, ret, side (before/after of every declared region) |
original's result |
compare |
original, then ours on a copy of the pre-call state | trace fields + ours{ret,side}, diverged, diff |
original's result |
replace |
ours | args, ret, side (before/after around ours), coverage |
ours' result |
compare never lets ours touch game memory: the declared regions are snapshotted before the
original runs, those snapshots are copied into scratch buffers, the arguments are rebound onto
the scratch buffers (rebind), and ours runs there. The diff compares the original's after
with the scratch buffers' contents and both return values.
replace used to log nothing at all, which is why B3's replace-mode divergence could only be
found by hashing the autosave. It now writes one record per call — the same regions, measured
around ours instead of around the original — so a replace run is readable, --replay-able,
and directly comparable with a compare log's guard findings (docs/harness-audit.md).
A throw from ours, or from any describe/regions/rebind callback, is caught inside the hook and
becomes err on the record (which the harness counts as a divergence). The original is always
invoked in off/trace/compare, whatever happened during capture. Nothing propagates across
the hook boundary.
Configuration (shim.cfg, next to the DLL)
hooks=trace # default mode for every hook; off = install nothing at all
hook.Game::CfgVar_RegisterKey=compare # per-hook override, keyed by the record's "hook" name
hook.Shim::SelfTest::Fill=off
trace.path=C:\SOTS\shim.trace.jsonl # default: shim.trace.jsonl beside the DLL; overwritten per run
trace.inline_max=256 # regions <= this many bytes are logged as hex, else sha256 + 32-byte head
trace.flush=always # always (default) | lazy (flush only on divergence / err / exit)
fpu.force=0x007f # force the x87 control word at the turn gate; off (default) = force nothing
fpu.sample_ticks=on # on (default): log the control word to shim.log whenever it CHANGES
Unusable values are logged to shim.log (config: key=value rejected (...)) and ignored.
hooks=off keeps M0 behaviour: no MinHook, no trace file. If the trace file cannot be opened,
every template hook reports mode off (the game runs un-instrumented, never half-instrumented).
The M0 Mars::Application::Initialize hook is an asm stub, not a template hook: it is installed
whenever hooks is not off, and it only logs to shim.log. See "thiscall" below.
fpu.* — the x87 control word (src/shim/fpu_force.cpp)
Four more asm-stub hooks, installed alongside the M0 one. They exist to answer "does anything the turn pipeline computes actually depend on x87 intermediate precision?", which decides whether a future x64/SSE port has a double-rounding budget to preserve.
fpu.force=<16-bit control word>writes that word withfldcwatStrategyClient::EndTurnandStrategyServer::BeginProcessTurn, and nowhere else -- deliberately a single write per turn, because re-forcing at every hook would guarantee the value is present without proving it ever held. Each site logs observed-before, requested, and read-back-after.StrategyServer::ProcessTurnis hooked sample-only: its reading is the independent evidence that the forced word survived into the simulation. The template hooks' ownfpu_cwfields give ~38 more samples per turn from inside phases 4, 6 and 8.fpu.sample_tickshooksDemoApp::OnTickand logs only when the word moves, soshim.logcarries a timeline rather than a single claim.
Field layout: bits 0-5 exception masks, bits 8-9 precision control (00=24-bit, 10=53-bit,
11=64-bit), bits 10-11 rounding control (00=nearest, 01=down, 10=up, 11=truncate),
bit 12 infinity control (ignored since the 387, so 0x027f and 0x127f are the same
arithmetic). Mars::Application::Run re-arms 0x127f via _controlfp every frame, which is
why the forcing has to happen inside the turn call chain: a word forced at EndTurn is gone by
the time BeginProcessTurn runs on a later frame.
Result of the sweep: notes-repo:findings/subsystems/fpu-precision-sensitivity.md.
Log
shim.trace.jsonl: line 1 is the meta record (build, exe_sha256 of the running exe,
started, inline_max, and a hooks policy map with one entry per registered template hook);
every later line is one call record. call_id is process-global and taken before the original
runs, so nested hooks get higher ids and depth says who is inside whom. ts is microseconds
since the tracer opened. The file is ASCII, LF-terminated; a crash mid-run leaves at most one
truncated last line, which the harness reports as one invalid record (--skip-invalid drops it).
shim.log gets one trace: line at startup (path, default mode) and one selftest: line
(the self-test hook's mode and checksum), and shutdown: trace records=N on detach.
Declaring a hook
A hook is a descriptor struct; Hook<D> generates the detour. Everything the template needs is
static, so a descriptor is a header-only declaration plus a few small functions.
#include "shim/trace/hook.h"
using namespace shim::trace;
struct CfgVarRegisterKeyHook {
static constexpr const char* name = "Game::CfgVar_RegisterKey"; // the record's "hook"
static constexpr CallConv conv = CallConv::Cdecl; // Cdecl | Stdcall
using Ret = bool; // void is allowed
using Args = std::tuple<CfgTable*, const char*, int>; // the parameter list
// inputs, in declaration order (never compared by the harness; use .named("x"))
static void describe_args(std::vector<Tv>& out, CfgTable* t, const char* key, int value) {
out.push_back(tv::ptr(t).named("table"));
out.push_back(tv::str(key).named("key")); // raw cp1252 bytes; nullptr -> ptr 0x0
out.push_back(tv::i32(value).named("value"));
}
static Tv describe_ret(bool r) { return tv::boolean(r); } // omit when Ret is void
// declared side-effect regions: name, address, size, optional structured describer
static void regions(std::vector<Region>& out, CfgTable* t, const char*, int) {
out.push_back(Region{"cfg_table", t, sizeof(CfgTable), &describe_table});
}
static Tv describe_table(const void* p, std::size_t, unsigned /*inline_max*/) {
const CfgTable* t = static_cast<const CfgTable*>(p); // a *copy* of the region
Tv s = tv::struct_();
s.add("count", tv::u32(t->count));
return s; // diffs then name the field
}
// the same call, aimed at the scratch copies (regions in declaration order)
static Args rebind(Scratch& s, CfgTable*, const char* key, int value) {
return Args(s.as<CfgTable>(0), key, value);
}
static bool ours(CfgTable* t, const char* key, int value); // the reimplementation
static HookPolicy policy() { return HookPolicy{}; } // ftol / ftol_kind / ptr_exact / unordered
// REQUIRED (compile error if missing): everything the original writes that no Result
// region above covers. See docs/harness-audit.md.
static void coverage(Coverage& c) {
c.unmodelled("erases the consumed key from the caller's map", Risk::Medium,
"a red-black tree cannot be snapshotted before the call",
"guard:cfg_table");
// ...or, when the regions really are everything:
// c.complete("RegisterKey writes only the table slot declared above");
}
};
Install (in InstallHooks, after Tracer::open):
using H = Hook<CfgVarRegisterKeyHook>;
H::register_policy(tracer); // BEFORE tracer.open(): lands in meta.hooks
...
H::configure(tracer); // reads hook.<name> / hooks from the config
MH_CreateHook(target, reinterpret_cast<void*>(H::detour()), reinterpret_cast<void**>(&H::original));
MH_EnableHook(target);
Rules of the road:
- Regions are copies.
describecallbacks receive the snapshot buffer, not live memory; a region that contains pointers into other memory is only compared by the pointer values (ignored by default policy) unless you declare the pointed-to memory as another region. - Region sizes must be known at call time. A region whose length is only known after the call cannot be snapshotted "before"; declare an upper bound or split the hook.
- A clean compare bounds only what you declared. Everything else the original writes is
invisible to the diff. Two things exist so that is never silent:
coverage(), which the compiler makes you fill in, and guard regions —Region::kind = Region::Kind::Guard, a coarse span (usually the whole object) that is watched around the original, never handed tooursand never diffed. Any byte in it that moved and that no Result region covers is reported as an undeclared write. Guards are cheap; declare one over every object the hook can reach. - Push guards LAST. Scratch holds Result regions only, so a descriptor that remembers a
region index as
out.size()at push time must not interleave guards.Hook<>checks the order on every call and turns a violation into anerrrather than a shifted mapping. - Prefer
structdescribers over rawbytesfor anything with fields: the harness then points atside.cfg_table.after.v.countinstead of a byte offset. - Floats:
tv::f32stores at float32 width and prints%.9g; the diff rounds both sides to float32 first (spec §6). Never describe afloatasf64. - Big ints:
i64/u64at or beyond 2^53 in magnitude are emitted as decimal strings. jsonvalues are your own canonical text (spec §7); the shim compares them as text.- Policies only affect the shim's advisory
diff/diverged;tracecmp.pyrecomputes from the meta policy plus CLI flags and warns when the shim's verdict disagrees. - Re-entrancy: a hook calling into another hooked function is fine (depth, ids). A hook calling itself through ours in compare mode would recurse into the tracer; do not do that.
- Cost: trace/compare allocate (Tv trees, snapshots). Fine for M1-scale hooks; do not put
a per-frame hot path in
tracewithout thinking abouttrace.flush=lazy.
thiscall / unverified prototypes
The template's detour is a real C++ function with a fixed prototype. That is only safe when the
prototype is known. M0 proved that a C++ wrapper around an [unverified] thiscall corrupts
the game (docs/M0.md, gotcha 1). So:
__thiscallhooks and any hook whose entry insots_addresses.his[unverified]stay asm stubs:pushfl/pushal, call a C logger with ECX,popal/popfl,jmptrampoline. They are trace-only and cannot capture the return value. Their logger may still build aRecordand hand it toTracer::write(modetrace,retnull, regions snapshotted by hand).- Once a prototype is verified,
thiscallcan be adapted to the template by declaring the detour as aStdcallhook that takesthisas its first explicit parameter behind a tiny asm thunk (push ecxthen jump) — not provided here; do it only with a verified prototype. cdecl/stdcallwith verified prototypes use the template directly (CallConv::Cdecl,CallConv::Stdcall).
Running a report
# on the game VM: play; then copy C:\SOTS\shim.trace.jsonl to verify/traces/<run-tag>.jsonl
python3 verify/harness/compare/tracecmp.py verify/traces/m1-cfgvar-20260907.jsonl \
--json-out verify/results/compare/m1-cfgvar-20260907.json
# exit 0 clean, 1 divergences, 2 invalid record(s) (--skip-invalid tolerates a truncated tail)
# --hook NAME one hook only
# --tolerance NAME=abs:1e-6 / rel:1e-5 / ulp:2 float policy (overrides meta.hooks)
# --ptr exact compare pointer values too
# --replay IMPL.jsonl offline: a golden trace vs a host implementation's {call_id, ret, side} lines
Host tests
cmake -S . -B build-host -G Ninja -DCMAKE_BUILD_TYPE=Debug # or: cmake --preset host
cmake --build build-host && ctest --test-dir build-host
shim_trace_sha256— FIPS vectors, chunked updates, padding edges.shim_trace_emitter— golden strings for §4 escaping, number forms, bytes inline/head, records, meta; writesout/emitter_oracle.jsonl, thentests/shim_trace/oracle_emit.pyrebuilds the same records withmkfixture.emit_recordand compares byte for byte, andtracecmp.pymust read it back (exit 1: it carries one injected divergence).shim_trace_diff— every §6 rule (type, ptr, nan/inf, f32 rounding, abs/rel/ulp, bytes offset, set vs list, unordered policy, struct missing/extra, side names), snapshots, config.shim_trace_hook— the self-test hook through every mode: clean log →tracecmpexit 0, wrong/throwing ours → exit 1 (and exit 0 with--hookon the clean hook), truncated file → exit 2 / 0 with--skip-invalid, plus a second thread and the startuprun_oncepath.
The Python steps use /usr/bin/python3 and the harness dir from -DSOTS_TRACECMP_DIR (default
$HOME/sots-re/verify/harness/compare) or the SOTS_TRACECMP_DIR environment variable; when
tracecmp.py is not there they print SKIP and the test still passes.