Compare commits

...

2 commits

22 changed files with 3269 additions and 14 deletions

View file

@ -25,6 +25,18 @@ add_subdirectory(src/mars/vfs) # .gob ZIP reader + native override (lib mars
add_subdirectory(src/mars/stream) # Streamable save format + gzip (lib mars_stream)
add_subdirectory(src/mars/rng) # MT19937 (lib mars_rng)
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
add_library(shim_trace STATIC
src/shim/trace/sha256.cpp
src/shim/trace/emitter.cpp
src/shim/trace/tracer.cpp
src/shim/trace/selftest.cpp)
target_include_directories(shim_trace PUBLIC ${CMAKE_SOURCE_DIR}/src)
target_compile_options(shim_trace PRIVATE -Wall -Wextra -Werror)
if(MINGW)
target_compile_definitions(shim_trace PUBLIC __USE_MINGW_ANSI_STDIO=1) # C99 %lld/%.17g
endif()
if(WIN32)
# ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ----
add_library(minhook STATIC
@ -35,7 +47,7 @@ if(WIN32)
target_include_directories(minhook PUBLIC third_party/minhook/include)
add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def)
target_link_libraries(binkw32 PRIVATE minhook sots_addresses)
target_link_libraries(binkw32 PRIVATE minhook sots_addresses shim_trace)
target_compile_definitions(binkw32 PRIVATE
SHIM_BUILD_ID="${SHIM_BUILD_ID}"
SOTS_ADDR_PROVENANCE="${SOTS_ADDR_PROVENANCE}")
@ -47,7 +59,7 @@ else()
add_executable(addr_smoke tests/addr_smoke.cpp)
target_link_libraries(addr_smoke PRIVATE sots_addresses)
add_test(NAME addr_smoke COMMAND addr_smoke)
foreach(_t mars_parse mars_text game_sim mars_vfs mars_stream)
foreach(_t mars_parse mars_text game_sim mars_vfs mars_stream shim_trace)
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t})
endif()

193
docs/shim-trace.md Normal file
View file

@ -0,0 +1,193 @@
# 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 | nothing | 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.
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)
```
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.
## 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.
```cpp
#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
};
```
Install (in `InstallHooks`, after `Tracer::open`):
```cpp
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.** `describe` callbacks 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.
- **Prefer `struct` describers** over raw `bytes` for anything with fields: the harness then
points at `side.cfg_table.after.v.count` instead of a byte offset.
- **Floats**: `tv::f32` stores at float32 width and prints `%.9g`; the diff rounds both sides
to float32 first (spec §6). Never describe a `float` as `f64`.
- **Big ints**: `i64`/`u64` at or beyond 2^53 in magnitude are emitted as decimal strings.
- **`json` values** are your own canonical text (spec §7); the shim compares them as text.
- **Policies** only affect the shim's advisory `diff`/`diverged`; `tracecmp.py` recomputes 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 `trace` without thinking about `trace.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:
- `__thiscall` hooks and any hook whose entry in `sots_addresses.h` is `[unverified]` stay
**asm stubs**: `pushfl/pushal`, call a C logger with ECX, `popal/popfl`, `jmp` trampoline.
They are trace-only and cannot capture the return value. Their logger may still build a
`Record` and hand it to `Tracer::write` (mode `trace`, `ret` null, regions snapshotted by hand).
- Once a prototype is verified, `thiscall` can be adapted to the template by declaring the
detour as a `Stdcall` hook that takes `this` as its first explicit parameter behind a tiny asm
thunk (`push ecx` then jump) — not provided here; do it only with a verified prototype.
- `cdecl`/`stdcall` with 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; writes `out/emitter_oracle.jsonl`, then `tests/shim_trace/oracle_emit.py` rebuilds the
same records with `mkfixture.emit_record` and compares byte for byte, and `tracecmp.py` must
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 → `tracecmp` exit 0,
wrong/throwing ours → exit 1 (and exit 0 with `--hook` on the clean hook), truncated file →
exit 2 / 0 with `--skip-invalid`, plus a second thread and the startup `run_once` path.
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.

View file

@ -11,9 +11,13 @@
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <string>
#include "MinHook.h"
#include "generated/sots_addresses.h"
#include "shim/trace/hook.h"
#include "shim/trace/selftest.h"
#include "shim/trace/tracer.h"
namespace {
@ -37,21 +41,27 @@ void Log(const char* fmt, ...) {
}
// ---- config (shim.cfg next to the DLL; "key=value" per line) ------------------------------
//
// `hooks=`, `hook.<Name>=`, `trace.*` are owned by the tracer (src/shim/trace/tracer.h);
// `hooks=off` additionally means "install nothing" (the M0 asm-stub hook included).
struct Config {
bool hooks = true; // hooks=trace (default) | off
bool hooks = true; // hooks != off
shim::trace::Config trace;
};
Config ReadConfig() {
Config cfg;
char path[MAX_PATH];
std::snprintf(path, sizeof path, "%s\\shim.trace.jsonl", g_dir);
cfg.trace.path = path;
std::snprintf(path, sizeof path, "%s\\shim.cfg", g_dir);
FILE* f = std::fopen(path, "r");
if (!f) {
Log("config: %s not found, using defaults", path);
return cfg;
}
char line[256];
char line[512];
while (std::fgets(line, sizeof line, f)) {
char* p = line;
while (*p == ' ' || *p == '\t') ++p;
@ -61,14 +71,16 @@ Config ReadConfig() {
*eq = '\0';
char* val = eq + 1;
val[std::strcspn(val, "\r\n")] = '\0';
if (std::strcmp(p, "hooks") == 0) {
cfg.hooks = std::strcmp(val, "off") != 0;
Log("config: hooks=%s", val);
std::string err;
if (cfg.trace.apply(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val);
} else {
Log("config: ignoring unknown key '%s'", p);
}
}
std::fclose(f);
cfg.hooks = cfg.trace.default_mode != shim::trace::Mode::Off;
return cfg;
}
@ -151,14 +163,46 @@ void Shim_Init(HMODULE self) {
Log("addresses: %s", SOTS_ADDR_PROVENANCE);
const Config cfg = ReadConfig();
if (cfg.hooks) {
InstallHooks();
} else {
if (!cfg.hooks) {
Log("hook: disabled by config");
return;
}
// Trace log: register every template hook's policy (goes into the meta line), then open.
// Hooks read their mode from the tracer after open(); a tracer that failed to open reports
// Off for everything, so the game runs un-instrumented rather than half-instrumented.
shim::trace::Tracer& tracer = shim::trace::Tracer::instance();
tracer.configure(cfg.trace);
shim::trace::Hook<shim::selftest::FillHook>::register_policy(tracer);
char exeSha[65] = {};
if (!shim::trace::sha256_file(exePath, exeSha)) Log("trace: could not hash %s", exePath);
if (tracer.open(SHIM_BUILD_ID, exeSha)) {
Log("trace: %s (default mode %s, inline_max %u, flush %s)", cfg.trace.path.c_str(),
shim::trace::mode_name(cfg.trace.default_mode), cfg.trace.inline_max,
cfg.trace.flush_always ? "always" : "lazy");
} else {
Log("trace: cannot open %s; template hooks forced off", cfg.trace.path.c_str());
}
InstallHooks();
// Self-test through the hook template (no MinHook involved): one record per launch proves
// the emitter/tracer inside the game process. `hook.Shim::SelfTest::Fill=off` silences it.
{
using H = shim::trace::Hook<shim::selftest::FillHook>;
H::configure(tracer);
const unsigned sum = shim::selftest::run_once(H::mode);
Log("selftest: %s mode=%s checksum=%08x records=%lu", shim::selftest::FillHook::name,
shim::trace::mode_name(H::mode), sum, static_cast<unsigned long>(tracer.records_written()));
}
}
void Shim_Shutdown() {
shim::trace::Tracer& tracer = shim::trace::Tracer::instance();
if (tracer.is_open()) {
Log("shutdown: trace records=%lu", static_cast<unsigned long>(tracer.records_written()));
tracer.close();
}
if (g_minhookUp) {
MH_STATUS st = MH_Uninitialize();
Log("shutdown: MH_Uninitialize -> %s", MH_StatusToString(st));

View file

@ -1,7 +1,21 @@
# sots-engine shim configuration. Read once when binkw32.dll (the proxy) loads.
# One key=value per line; '#' starts a comment.
# One key=value per line; '#' starts a comment. See docs/shim-trace.md.
#
# hooks = trace | off
# trace install the hooks and log them to shim.log (default)
# off forward Bink calls only; touch nothing in the exe
# hooks = off | trace | compare | replace
# default mode for every hook
# off forward Bink calls only; touch nothing in the exe (nothing is installed)
# trace run the original, log args/ret/side effects to shim.trace.jsonl
# compare run the original AND our reimplementation on the same inputs, log both + diff
# replace run our reimplementation instead of the original (nothing logged)
hooks=trace
# hook.<Name> = off | trace | compare | replace
# per-hook override; <Name> is the hook's record name (the "hook" field in the log)
#hook.Shim::SelfTest::Fill=off
# trace.path = <file> default: shim.trace.jsonl next to the DLL (overwritten each run)
# trace.inline_max = <bytes> regions up to this size are logged as hex (default 256)
# trace.flush = always | lazy fflush every record (default) or only on divergence/err/exit
#trace.path=C:\SOTS\shim.trace.jsonl
#trace.inline_max=256
#trace.flush=always

482
src/shim/trace/emitter.cpp Normal file
View file

@ -0,0 +1,482 @@
#include "shim/trace/emitter.h"
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include "shim/trace/sha256.h"
namespace shim::trace {
// ---- modes ----------------------------------------------------------------------------------
const char* mode_name(Mode m) {
switch (m) {
case Mode::Off: return "off";
case Mode::Trace: return "trace";
case Mode::Compare: return "compare";
case Mode::Replace: return "replace";
}
return "off";
}
bool parse_mode(const char* text, Mode& out) {
if (!text) return false;
if (std::strcmp(text, "off") == 0) { out = Mode::Off; return true; }
if (std::strcmp(text, "trace") == 0) { out = Mode::Trace; return true; }
if (std::strcmp(text, "compare") == 0) { out = Mode::Compare; return true; }
if (std::strcmp(text, "replace") == 0) { out = Mode::Replace; return true; }
return false;
}
// ---- typed values -----------------------------------------------------------------------------
const char* Tv::type_name() const {
switch (kind) {
case Bool: return "bool";
case Int: return itype;
case Float: return f32 ? "f32" : "f64";
case Str: return "str";
case WStr: return "wstr";
case Ptr: return "ptr";
case Enum: return "enum";
case Null: return "null";
case Bytes: return "bytes";
case List: return "list";
case Set: return "set";
case Struct: return "struct";
case Json: return "json";
}
return "null";
}
Tv& Tv::add(const char* key, Tv v) {
keys.emplace_back(key);
items.push_back(std::move(v));
return *this;
}
namespace tv {
namespace {
Tv make_int(const char* t, long long v) {
Tv r;
r.kind = Tv::Int;
r.itype = t;
r.i = v;
return r;
}
Tv make_uint(const char* t, unsigned long long v) {
Tv r;
r.kind = Tv::Int;
r.itype = t;
r.is_unsigned = true;
r.u = v;
return r;
}
} // namespace
Tv boolean(bool v) { Tv r; r.kind = Tv::Bool; r.b = v; return r; }
Tv i8(std::int8_t v) { return make_int("i8", v); }
Tv i16(std::int16_t v) { return make_int("i16", v); }
Tv i32(std::int32_t v) { return make_int("i32", v); }
Tv i64(std::int64_t v) { return make_int("i64", v); }
Tv u8(std::uint8_t v) { return make_uint("u8", v); }
Tv u16(std::uint16_t v) { return make_uint("u16", v); }
Tv u32(std::uint32_t v) { return make_uint("u32", v); }
Tv u64(std::uint64_t v) { return make_uint("u64", v); }
Tv f32(float v) { Tv r; r.kind = Tv::Float; r.f32 = true; r.f = v; return r; }
Tv f64(double v) { Tv r; r.kind = Tv::Float; r.f = v; return r; }
Tv str(const char* s) {
if (!s) return ptr(static_cast<std::uintptr_t>(0));
return str(s, std::strlen(s));
}
Tv str(const void* bytes, std::size_t n) {
Tv r;
r.kind = Tv::Str;
r.s.assign(static_cast<const char*>(bytes), n);
return r;
}
Tv wstr(const std::uint16_t* units, std::size_t n) {
Tv r;
r.kind = Tv::WStr;
r.w.assign(units, units + n);
return r;
}
Tv wstr(const wchar_t* s) {
if (!s) return ptr(static_cast<std::uintptr_t>(0));
Tv r;
r.kind = Tv::WStr;
for (; *s; ++s) r.w.push_back(static_cast<std::uint16_t>(*s));
return r;
}
Tv ptr(const void* p) { return ptr(reinterpret_cast<std::uintptr_t>(p)); }
Tv ptr(std::uintptr_t p) { Tv r; r.kind = Tv::Ptr; r.p = p; return r; }
Tv enum_(long long v, const char* symbolic) {
Tv r;
r.kind = Tv::Enum;
r.i = v;
if (symbolic) r.s = symbolic;
return r;
}
Tv null() { return Tv(); }
Tv bytes(const void* data, std::size_t n, std::size_t inline_max) {
Tv r;
r.kind = Tv::Bytes;
r.blen = n;
Sha256::digest_hex(data, n, r.sha);
const std::uint8_t* p = static_cast<const std::uint8_t*>(data);
if (n <= inline_max) {
r.inline_full = true;
r.data.assign(p, p + n);
} else {
r.data.assign(p, p + 32);
}
return r;
}
Tv list(std::vector<Tv> items) { Tv r; r.kind = Tv::List; r.items = std::move(items); return r; }
Tv set(std::vector<Tv> items) { Tv r; r.kind = Tv::Set; r.items = std::move(items); return r; }
Tv struct_() { Tv r; r.kind = Tv::Struct; return r; }
Tv json(const char* canonical_text) { Tv r; r.kind = Tv::Json; r.s = canonical_text; return r; }
} // namespace tv
// ---- output buffer --------------------------------------------------------------------------------
Buf::Buf() { grow(4096); }
Buf::~Buf() { std::free(data_); }
bool Buf::grow(std::size_t need) {
if (need <= cap_) return true;
std::size_t ncap = cap_ ? cap_ : 4096;
while (ncap < need) ncap *= 2;
char* nd = static_cast<char*>(std::realloc(data_, ncap));
if (!nd) {
overflow_ = true;
return false;
}
data_ = nd;
cap_ = ncap;
return true;
}
void Buf::put(char c) {
if (len_ + 1 > cap_ && !grow(len_ + 1)) return;
data_[len_++] = c;
}
void Buf::put(const void* bytes, std::size_t n) {
if (!n) return;
if (len_ + n > cap_ && !grow(len_ + n)) return;
std::memcpy(data_ + len_, bytes, n);
len_ += n;
}
void Buf::puts(const char* s) { put(s, std::strlen(s)); }
void Buf::printf(const char* fmt, ...) {
char tmp[128];
va_list ap;
va_start(ap, fmt);
const int n = std::vsnprintf(tmp, sizeof tmp, fmt, ap);
va_end(ap);
if (n < 0) {
overflow_ = true;
return;
}
if (static_cast<std::size_t>(n) < sizeof tmp) {
put(tmp, static_cast<std::size_t>(n));
return;
}
if (!grow(len_ + static_cast<std::size_t>(n) + 1)) return;
va_start(ap, fmt);
std::vsnprintf(data_ + len_, static_cast<std::size_t>(n) + 1, fmt, ap);
va_end(ap);
len_ += static_cast<std::size_t>(n);
}
void Buf::clear() {
len_ = 0;
overflow_ = false;
}
// ---- escaping / numbers -------------------------------------------------------------------------
namespace {
const char* const kHex = "0123456789abcdef";
inline void put_u_escape(Buf& out, unsigned unit) {
char e[6] = {'\\', 'u', kHex[(unit >> 12) & 15], kHex[(unit >> 8) & 15], kHex[(unit >> 4) & 15], kHex[unit & 15]};
out.put(e, 6);
}
// One unit of either string flavour (bytes for str, UTF-16 units for wstr): mkfixture.esc().
inline void put_unit(Buf& out, unsigned unit) {
switch (unit) {
case '"': out.put("\\\"", 2); return;
case '\\': out.put("\\\\", 2); return;
case '\n': out.put("\\n", 2); return;
case '\r': out.put("\\r", 2); return;
case '\t': out.put("\\t", 2); return;
default:
if (unit < 0x20 || unit >= 0x7f) put_u_escape(out, unit);
else out.put(static_cast<char>(unit));
}
}
void put_hex(Buf& out, const std::uint8_t* p, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
out.put(kHex[p[i] >> 4]);
out.put(kHex[p[i] & 15]);
}
}
const long long kTwo53 = 1LL << 53;
} // namespace
void emit_esc(Buf& out, const char* bytes, std::size_t n) {
out.put('"');
for (std::size_t i = 0; i < n; ++i) put_unit(out, static_cast<unsigned char>(bytes[i]));
out.put('"');
}
void emit_esc(Buf& out, const std::string& s) { emit_esc(out, s.data(), s.size()); }
void emit_esc_w(Buf& out, const std::uint16_t* units, std::size_t n) {
out.put('"');
for (std::size_t i = 0; i < n; ++i) put_unit(out, units[i]);
out.put('"');
}
// Integers above 2^53 in magnitude go out as decimal strings (section 3): mkfixture.u64().
void emit_int(Buf& out, long long v) {
const bool quote = v >= kTwo53 || v <= -kTwo53;
if (quote) out.put('"');
out.printf("%lld", v);
if (quote) out.put('"');
}
void emit_uint(Buf& out, unsigned long long v) {
const bool quote = v >= static_cast<unsigned long long>(kTwo53);
if (quote) out.put('"');
out.printf("%llu", v);
if (quote) out.put('"');
}
// mkfixture.fmt_num() for floats.
void emit_float(Buf& out, double v, bool f32) {
if (std::isnan(v)) { out.puts("\"nan\""); return; }
if (std::isinf(v)) { out.puts(v > 0 ? "\"inf\"" : "\"-inf\""); return; }
out.printf(f32 ? "%.9g" : "%.17g", v);
}
// ---- typed values ----------------------------------------------------------------------------------
// mkfixture.emit_tv(): {"t":..., ("n","sha256","hex"|"head") | "v":... [,"name":...]} [,"n":...]
void emit_tv(Buf& out, const Tv& v) {
out.puts("{\"t\":");
emit_esc(out, v.type_name(), std::strlen(v.type_name()));
if (v.kind == Tv::Bytes) {
out.printf(",\"n\":%llu,\"sha256\":\"", static_cast<unsigned long long>(v.blen));
out.puts(v.sha);
out.puts(v.inline_full ? "\",\"hex\":\"" : "\",\"head\":\"");
put_hex(out, v.data.data(), v.data.size());
out.puts("\"}");
return;
}
out.puts(",\"v\":");
switch (v.kind) {
case Tv::Bool: out.puts(v.b ? "true" : "false"); break;
case Tv::Int:
if (v.is_unsigned) emit_uint(out, v.u);
else emit_int(out, v.i);
break;
case Tv::Float: emit_float(out, v.f, v.f32); break;
case Tv::Str: emit_esc(out, v.s); break;
case Tv::WStr: emit_esc_w(out, v.w.data(), v.w.size()); break;
case Tv::Ptr: out.printf("\"0x%08llx\"", static_cast<unsigned long long>(v.p)); break;
case Tv::Enum: out.printf("%lld", v.i); break;
case Tv::Null: out.puts("null"); break;
case Tv::List:
case Tv::Set:
out.put('[');
for (std::size_t i = 0; i < v.items.size(); ++i) {
if (i) out.put(',');
emit_tv(out, v.items[i]);
}
out.put(']');
break;
case Tv::Struct:
out.put('{');
for (std::size_t i = 0; i < v.items.size(); ++i) {
if (i) out.put(',');
emit_esc(out, v.keys[i]);
out.put(':');
emit_tv(out, v.items[i]);
}
out.put('}');
break;
case Tv::Json: out.puts(v.s.c_str()); break;
case Tv::Bytes: break; // handled above
}
if (v.kind == Tv::Enum && !v.s.empty()) {
out.puts(",\"name\":");
emit_esc(out, v.s);
}
if (!v.name.empty()) {
out.puts(",\"n\":");
emit_esc(out, v.name);
}
out.put('}');
}
std::string canonical(const Tv& v) {
Buf b;
emit_tv(b, v);
return b.str();
}
// ---- records ----------------------------------------------------------------------------------------
namespace {
void emit_side(Buf& out, const std::vector<SideEntry>& side) {
out.put('{');
for (std::size_t i = 0; i < side.size(); ++i) {
const SideEntry& e = side[i];
if (i) out.put(',');
emit_esc(out, e.name);
out.puts(":{");
if (e.before_kind == SideEntry::IsNull) {
out.puts("\"before\":null,");
} else if (e.before_kind == SideEntry::Value) {
out.puts("\"before\":");
emit_tv(out, e.before);
out.put(',');
}
out.puts("\"after\":");
emit_tv(out, e.after);
out.put('}');
}
out.put('}');
}
void emit_ours_side(Buf& out, const std::vector<OursSide>& side) {
out.put('{');
for (std::size_t i = 0; i < side.size(); ++i) {
if (i) out.put(',');
emit_esc(out, side[i].name);
out.puts(":{\"after\":");
emit_tv(out, side[i].after);
out.put('}');
}
out.put('}');
}
void emit_opt_tv(Buf& out, const std::optional<Tv>& v) {
if (v) emit_tv(out, *v);
else out.puts("null");
}
void emit_diff(Buf& out, const std::vector<DiffEntry>& diff) {
out.put('[');
for (std::size_t i = 0; i < diff.size(); ++i) {
const DiffEntry& d = diff[i];
if (i) out.put(',');
out.puts("{\"path\":");
emit_esc(out, d.path);
out.puts(",\"why\":");
emit_esc(out, d.why, std::strlen(d.why));
out.puts(",\"orig\":");
if (!d.orig_raw.empty()) out.puts(d.orig_raw.c_str());
else emit_opt_tv(out, d.orig);
out.puts(",\"ours\":");
if (!d.ours_raw.empty()) out.puts(d.ours_raw.c_str());
else emit_opt_tv(out, d.ours);
if (d.first_diff_offset >= 0) out.printf(",\"first_diff_offset\":%lld", d.first_diff_offset);
if (!d.note.empty()) {
out.puts(",\"note\":");
emit_esc(out, d.note);
}
out.put('}');
}
out.put(']');
}
} // namespace
// mkfixture.emit_record(): fixed KEY_ORDER, optional keys omitted.
void emit_record(Buf& out, const Record& r) {
out.printf("{\"ts\":%lld,\"hook\":", static_cast<long long>(r.ts));
emit_esc(out, r.hook);
out.printf(",\"mode\":\"%s\",\"call_id\":%llu,\"thread\":%llu", mode_name(r.mode),
static_cast<unsigned long long>(r.call_id), static_cast<unsigned long long>(r.thread));
if (r.depth >= 0) out.printf(",\"depth\":%d", r.depth);
out.puts(",\"args\":[");
for (std::size_t i = 0; i < r.args.size(); ++i) {
if (i) out.put(',');
emit_tv(out, r.args[i]);
}
out.puts("],\"ret\":");
emit_opt_tv(out, r.ret);
out.puts(",\"side\":");
emit_side(out, r.side);
if (r.has_ours) {
out.puts(",\"ours\":{\"ret\":");
emit_opt_tv(out, r.ours_ret);
out.puts(",\"side\":");
emit_ours_side(out, r.ours_side);
out.put('}');
}
if (r.diverged >= 0) out.puts(r.diverged ? ",\"diverged\":true" : ",\"diverged\":false");
if (r.has_diff) {
out.puts(",\"diff\":");
emit_diff(out, r.diff);
}
if (r.err) {
out.puts(",\"err\":");
emit_esc(out, *r.err);
}
if (r.note) {
out.puts(",\"note\":");
emit_esc(out, *r.note);
}
out.puts("}\n");
}
// mkfixture.emit_meta() over the dict shape mkfixture.meta() builds.
void emit_meta(Buf& out, const Meta& m) {
out.puts("{\"meta\":{\"format\":1,\"build\":");
emit_esc(out, m.build);
out.puts(",\"exe_sha256\":");
emit_esc(out, m.exe_sha256);
out.puts(",\"started\":");
emit_esc(out, m.started);
out.printf(",\"inline_max\":%u,\"hooks\":{", m.inline_max);
for (std::size_t i = 0; i < m.hooks.size(); ++i) {
const HookPolicy& p = m.hooks[i].second;
if (i) out.put(',');
emit_esc(out, m.hooks[i].first);
out.puts(":{\"ftol\":");
emit_float(out, p.ftol, false);
out.printf(",\"ftol_kind\":\"%s\",\"ptr\":\"%s\"", p.ftol_kind, p.ptr_exact ? "exact" : "ignore");
if (!p.unordered.empty()) {
out.puts(",\"unordered\":[");
for (std::size_t j = 0; j < p.unordered.size(); ++j) {
if (j) out.put(',');
emit_esc(out, p.unordered[j]);
}
out.put(']');
}
out.put('}');
}
out.puts("}}}\n");
}
} // namespace shim::trace

196
src/shim/trace/emitter.h Normal file
View file

@ -0,0 +1,196 @@
// JSONL emitter for the trace/compare log (sots-re/verify/harness/compare/TRACE_FORMAT.md, v1).
//
// A faithful port of the reference emitter in mkfixture.py (esc / fmt_num / emit_tv /
// emit_record / emit_meta): fixed key order, ASCII-only escaping, %.9g / %.17g floats,
// big integers as decimal strings, bytes as n + sha256 (+ hex inline or a 32-byte head).
//
// Typed values (`Tv`) are a small tree; building one allocates (std::string / std::vector),
// so callers on a hook boundary wrap construction in try/catch (hook.h does). The output
// buffer (`Buf`) is malloc-based and never throws: on allocation failure it flags overflow
// and drops bytes, and the tracer refuses to write a record whose buffer is not ok().
#pragma once
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace shim::trace {
// ---- modes ----------------------------------------------------------------------------------
enum class Mode : std::uint8_t { Off, Trace, Compare, Replace };
const char* mode_name(Mode m); // "off" | "trace" | "compare" | "replace"
bool parse_mode(const char* text, Mode& out); // accepts the same four words
// ---- typed values -----------------------------------------------------------------------------
struct Tv {
enum Kind : std::uint8_t { Bool, Int, Float, Str, WStr, Ptr, Enum, Null, Bytes, List, Set, Struct, Json };
Kind kind = Null;
const char* itype = "i32"; // Int: "i8".."u64"
bool is_unsigned = false; // Int: which of i/u is live
bool b = false; // Bool
long long i = 0; // Int (signed), Enum
unsigned long long u = 0; // Int (unsigned)
double f = 0.0; // Float (already rounded to float32 when f32)
bool f32 = false; // Float width
std::uintptr_t p = 0; // Ptr
std::string s; // Str raw bytes | Enum symbolic name ("" = none) | Json canonical text
std::vector<std::uint16_t> w; // WStr UTF-16 units
// Bytes: length, sha256 hex, and either the full data (inline_full) or the first 32 bytes.
std::size_t blen = 0;
char sha[65] = {};
bool inline_full = false;
std::vector<std::uint8_t> data;
// List / Set: items. Struct: keys[i] names items[i] (insertion order is emission order).
std::vector<Tv> items;
std::vector<std::string> keys;
std::string name; // optional "n" (never emitted for Bytes)
const char* type_name() const;
Tv&& named(const char* n) && { name = n; return std::move(*this); }
Tv& named(const char* n) & { name = n; return *this; }
// Struct helper.
Tv& add(const char* key, Tv v);
};
namespace tv {
Tv boolean(bool v);
Tv i8(std::int8_t v);
Tv i16(std::int16_t v);
Tv i32(std::int32_t v);
Tv i64(std::int64_t v);
Tv u8(std::uint8_t v);
Tv u16(std::uint16_t v);
Tv u32(std::uint32_t v);
Tv u64(std::uint64_t v);
Tv f32(float v);
Tv f64(double v);
Tv str(const char* s); // NUL-terminated raw bytes (nullptr -> ptr 0x0, see below)
Tv str(const void* bytes, std::size_t n); // raw bytes, embedded NULs preserved
Tv wstr(const std::uint16_t* units, std::size_t n);
Tv wstr(const wchar_t* s); // NUL-terminated; units truncated to 16 bits
Tv ptr(const void* p);
Tv ptr(std::uintptr_t p);
Tv enum_(long long v, const char* symbolic = nullptr);
Tv null();
Tv bytes(const void* data, std::size_t n, std::size_t inline_max);
Tv list(std::vector<Tv> items);
Tv set(std::vector<Tv> items);
Tv struct_();
Tv json(const char* canonical_text); // caller guarantees section-7 canonical JSON
} // namespace tv
// ---- records ------------------------------------------------------------------------------------
struct SideEntry {
std::string name;
enum Before : std::uint8_t { Absent, IsNull, Value } before_kind = Absent;
Tv before;
Tv after;
};
struct OursSide {
std::string name;
Tv after;
};
struct DiffEntry {
std::string path;
const char* why = "exact"; // exact | ftol | type | len | missing | extra | hash | err
std::optional<Tv> orig;
std::optional<Tv> ours;
std::string orig_raw; // when non-empty: raw JSON text emitted instead of `orig` (e.g. a length)
std::string ours_raw; // same for `ours` (e.g. the quoted err text)
long long first_diff_offset = -1; // bytes only, when both inline
std::string note; // free text, "" = absent
};
struct Record {
std::int64_t ts = 0;
std::string hook;
Mode mode = Mode::Trace;
std::uint32_t call_id = 0;
std::uint32_t thread = 0;
int depth = -1; // <0 = omit
std::vector<Tv> args;
std::optional<Tv> ret; // nullopt = void -> null
std::vector<SideEntry> side;
bool has_ours = false;
std::optional<Tv> ours_ret;
std::vector<OursSide> ours_side;
int diverged = -1; // <0 omit, 0 false, 1 true
bool has_diff = false;
std::vector<DiffEntry> diff;
std::optional<std::string> err;
std::optional<std::string> note;
};
// Per-hook policy, recorded in meta.hooks (the keys tracecmp.py --tolerance accepts).
struct HookPolicy {
double ftol = 0.0;
const char* ftol_kind = "abs"; // abs | rel | ulp
bool ptr_exact = false;
std::vector<std::string> unordered; // list paths to treat as sets
};
struct Meta {
std::string build;
std::string exe_sha256;
std::string started;
unsigned inline_max = 256;
std::vector<std::pair<std::string, HookPolicy>> hooks;
};
// ---- output buffer --------------------------------------------------------------------------------
class Buf {
public:
Buf();
~Buf();
Buf(const Buf&) = delete;
Buf& operator=(const Buf&) = delete;
void put(char c);
void put(const void* bytes, std::size_t n);
void puts(const char* s);
#if defined(__MINGW32__)
void printf(const char* fmt, ...) __attribute__((format(gnu_printf, 2, 3)));
#else
void printf(const char* fmt, ...) __attribute__((format(printf, 2, 3)));
#endif
void clear();
const char* data() const { return data_ ? data_ : ""; }
std::size_t size() const { return len_; }
bool ok() const { return !overflow_; }
std::string str() const { return std::string(data(), len_); }
private:
bool grow(std::size_t need);
char* data_ = nullptr;
std::size_t len_ = 0, cap_ = 0;
bool overflow_ = false;
};
// ---- emit ---------------------------------------------------------------------------------------------
void emit_esc(Buf& out, const char* bytes, std::size_t n); // section 4 rule 2 (str)
void emit_esc(Buf& out, const std::string& s);
void emit_esc_w(Buf& out, const std::uint16_t* units, std::size_t n); // section 4 rule 2 (wstr)
void emit_int(Buf& out, long long v);
void emit_uint(Buf& out, unsigned long long v);
void emit_float(Buf& out, double v, bool f32);
void emit_tv(Buf& out, const Tv& v);
void emit_record(Buf& out, const Record& r); // one line, ends in "}\n"
void emit_meta(Buf& out, const Meta& m); // one line, ends in "}\n"
// Canonical text of a typed value: emit_tv() output. Used as the sort key for sets.
std::string canonical(const Tv& v);
} // namespace shim::trace

222
src/shim/trace/hook.h Normal file
View file

@ -0,0 +1,222 @@
// Hook template: mode dispatch (off / trace / compare / replace) around a hooked C function.
//
// A hook is a descriptor struct D (see docs/shim-trace.md, "Declaring a hook"):
//
// struct MyHook {
// static constexpr const char* name = "Game::Foo"; // record `hook` name
// static constexpr CallConv conv = CallConv::Cdecl; // Cdecl | Stdcall
// using Ret = int; // void allowed
// using Args = std::tuple<Table*, const char*, int>; // parameter list
// static void describe_args(std::vector<Tv>& out, Table* t, const char* k, int v);
// static Tv describe_ret(int r); // omit when Ret is void
// static void regions(std::vector<Region>& out, Table* t, const char* k, int v);
// static Args rebind(Scratch& s, Table* t, const char* k, int v); // args for `ours`
// static int ours(Table* t, const char* k, int v); // the reimplementation
// static HookPolicy policy(); // meta.hooks entry
// };
//
// Hook<MyHook>::detour() is the function to install (MinHook target -> detour) and
// Hook<MyHook>::original the trampoline (or the real function in host tests).
//
// Mode semantics:
// Off call original.
// Trace snapshot regions, call original, snapshot again, emit {args, ret, side}.
// Compare snapshot, call original, snapshot; copy the *before* snapshots into scratch
// memory, rebind the args onto the copies, call ours, snapshot the copies, diff,
// emit {.., ours, diverged, diff}. The original's result is what the caller gets.
// Replace call ours only; nothing is emitted (nothing to diff against).
//
// Nothing escapes the hook boundary: every capture step is wrapped, a throw from ours or
// from a describe/regions/rebind callback becomes `err` in the record, and the original is
// always invoked in Off/Trace/Compare regardless of capture failures.
//
// thiscall (and any hook whose prototype is [unverified] in sots_addresses.h) must NOT use
// this template: see docs/M0.md gotcha 1. Those stay register-transparent asm stubs that
// only log an entry and tail-jump to the trampoline (trace-only, no return capture).
#pragma once
#include <exception>
#include <optional>
#include <string>
#include <tuple>
#include <type_traits>
#include <utility>
#include <vector>
#include "shim/trace/emitter.h"
#include "shim/trace/platform.h"
#include "shim/trace/tracer.h"
namespace shim::trace {
enum class CallConv { Cdecl, Stdcall };
template <CallConv CC, class R, class... A>
struct FnPtr;
template <class R, class... A>
struct FnPtr<CallConv::Cdecl, R, A...> {
using type = R(SHIM_CDECL*)(A...);
};
template <class R, class... A>
struct FnPtr<CallConv::Stdcall, R, A...> {
using type = R(SHIM_STDCALL*)(A...);
};
namespace detail {
// Holds a return value, or nothing for void, so one code path serves both.
template <class R>
struct RetSlot {
R v{};
template <class F, class... X>
void invoke(F f, X... x) { v = f(x...); }
template <class F, class T>
void apply(F f, T& t) { v = std::apply(f, t); }
R get() const { return v; }
template <class D>
std::optional<Tv> describe() const { return D::describe_ret(v); }
};
template <>
struct RetSlot<void> {
template <class F, class... X>
void invoke(F f, X... x) { f(x...); }
template <class F, class T>
void apply(F f, T& t) { std::apply(f, t); }
void get() const {}
template <class D>
std::optional<Tv> describe() const { return std::nullopt; }
};
inline std::string what(const char* stage) {
std::string s = stage;
try {
throw;
} catch (const std::exception& e) {
s += ": ";
s += e.what();
} catch (...) {
s += ": non-std exception";
}
return s;
}
} // namespace detail
template <class D, class ArgsTuple = typename D::Args>
class Hook;
template <class D, class... A>
class Hook<D, std::tuple<A...>> {
public:
using Ret = typename D::Ret;
using Fn = typename FnPtr<D::conv, Ret, A...>::type;
// Trampoline to the original (MinHook fills it) — or, in host tests, the real function.
inline static Fn original = nullptr;
inline static Mode mode = Mode::Off;
// Reads the mode for D::name from the tracer (Off when the tracer is not open).
static void configure(Tracer& t) { mode = t.mode_for(D::name); }
static void register_policy(Tracer& t) { t.register_hook(D::name, D::policy()); }
static Fn detour() {
if constexpr (D::conv == CallConv::Cdecl) return &detour_cdecl;
else return &detour_stdcall;
}
static Ret dispatch(A... a) {
switch (mode) {
case Mode::Off: return original(a...);
case Mode::Replace: return D::ours(a...);
case Mode::Trace: return run<false>(a...);
case Mode::Compare: return run<true>(a...);
}
return original(a...);
}
private:
static Ret SHIM_CDECL detour_cdecl(A... a) { return dispatch(a...); }
static Ret SHIM_STDCALL detour_stdcall(A... a) { return dispatch(a...); }
template <bool kCompare>
static Ret run(A... a) {
Tracer& tr = Tracer::instance();
const unsigned inline_max = tr.inline_max();
Record rec;
std::vector<Region> regions;
std::vector<Snapshot> before;
bool captured = false;
rec.mode = kCompare ? Mode::Compare : Mode::Trace;
rec.call_id = tr.next_call_id(); // before the original: nested hooks get later ids
rec.depth = tr.enter();
try {
rec.hook = D::name;
D::describe_args(rec.args, a...);
D::regions(regions, a...);
for (const Region& r : regions) before.push_back(Snapshot::capture(r));
captured = true;
} catch (...) {
rec.err = detail::what("capture before original");
}
detail::RetSlot<Ret> orig_ret;
orig_ret.invoke(original, a...); // always; the game must see the original's behavior
try {
if (captured) {
rec.ret = orig_ret.template describe<D>();
for (std::size_t i = 0; i < regions.size(); ++i) {
SideEntry e;
e.name = before[i].name;
e.before_kind = SideEntry::Value;
e.before = before[i].to_tv(inline_max);
e.after = before[i].recapture(regions[i]).to_tv(inline_max);
rec.side.push_back(std::move(e));
}
if (kCompare) compare(rec, before, inline_max, a...);
}
} catch (...) {
rec.err = detail::what("capture after original");
}
try {
tr.write(rec);
} catch (...) {
}
tr.leave();
return orig_ret.get();
}
static void compare(Record& rec, const std::vector<Snapshot>& before, unsigned inline_max, A... a) {
Scratch scratch(before);
detail::RetSlot<Ret> ours_ret;
try {
std::tuple<A...> args2 = D::rebind(scratch, a...);
ours_ret.apply(&D::ours, args2);
} catch (...) {
rec.err = detail::what("ours");
rec.diverged = 1;
rec.has_diff = true;
DiffEntry d;
d.path = "call";
d.why = "err";
Buf b;
emit_esc(b, *rec.err);
d.ours_raw = b.str();
rec.diff.push_back(std::move(d));
return;
}
rec.has_ours = true;
rec.ours_ret = ours_ret.template describe<D>();
for (std::size_t i = 0; i < scratch.count(); ++i) {
OursSide s;
s.name = scratch.current(i).name;
s.after = scratch.current(i).to_tv(inline_max);
rec.ours_side.push_back(std::move(s));
}
rec.has_diff = true;
rec.diverged = diff_outputs(rec.ret, rec.side, rec.ours_ret, rec.ours_side, D::policy(), rec.diff) ? 1 : 0;
}
};
} // namespace shim::trace

122
src/shim/trace/platform.h Normal file
View file

@ -0,0 +1,122 @@
// Tiny platform layer so the trace/compare infrastructure compiles both inside the shim
// (i686 MinGW, Win32 API) and on the host (Linux, pthread) for ctest.
//
// Nothing here throws. Nothing here allocates.
#pragma once
#include <cstdint>
#include <cstdio>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#else
#include <pthread.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>
#endif
#if defined(_WIN32) && (defined(__i386__) || defined(_M_IX86))
#define SHIM_X86_WIN 1
#define SHIM_CDECL __attribute__((cdecl))
#define SHIM_STDCALL __attribute__((stdcall))
#else
#define SHIM_X86_WIN 0
#define SHIM_CDECL
#define SHIM_STDCALL
#endif
namespace shim {
// Non-recursive mutex. CRITICAL_SECTION on Windows (recursive there, which is fine: a hook
// that re-enters the tracer from inside `write` would be a bug either way).
class Mutex {
public:
Mutex();
~Mutex();
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;
void lock();
void unlock();
private:
#if defined(_WIN32)
CRITICAL_SECTION cs_;
#else
pthread_mutex_t m_;
#endif
};
class LockGuard {
public:
explicit LockGuard(Mutex& m) : m_(m) { m_.lock(); }
~LockGuard() { m_.unlock(); }
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
Mutex& m_;
};
// Monotonic microseconds (QPC on Windows, CLOCK_MONOTONIC elsewhere). Absolute origin is
// arbitrary; the tracer subtracts its own start time.
std::int64_t monotonic_us();
// OS thread id (GetCurrentThreadId / gettid).
std::uint32_t thread_id();
// "YYYY-MM-DDTHH:MM:SSZ" of now, UTC. `out` must hold at least 21 bytes.
void utc_timestamp(char* out, unsigned cap);
// ---- inline definitions ---------------------------------------------------------------------
#if defined(_WIN32)
inline Mutex::Mutex() { InitializeCriticalSection(&cs_); }
inline Mutex::~Mutex() { DeleteCriticalSection(&cs_); }
inline void Mutex::lock() { EnterCriticalSection(&cs_); }
inline void Mutex::unlock() { LeaveCriticalSection(&cs_); }
inline std::int64_t monotonic_us() {
LARGE_INTEGER f, c;
QueryPerformanceFrequency(&f);
QueryPerformanceCounter(&c);
// split to avoid overflowing 64 bits for long uptimes
const long long secs = c.QuadPart / f.QuadPart;
const long long rem = c.QuadPart % f.QuadPart;
return static_cast<std::int64_t>(secs * 1000000LL + rem * 1000000LL / f.QuadPart);
}
inline std::uint32_t thread_id() { return static_cast<std::uint32_t>(GetCurrentThreadId()); }
inline void utc_timestamp(char* out, unsigned cap) {
SYSTEMTIME st;
GetSystemTime(&st);
if (cap < 21) { if (cap) out[0] = '\0'; return; }
std::snprintf(out, cap, "%04u-%02u-%02uT%02u:%02u:%02uZ", unsigned(st.wYear), unsigned(st.wMonth),
unsigned(st.wDay), unsigned(st.wHour), unsigned(st.wMinute), unsigned(st.wSecond));
}
#else
inline Mutex::Mutex() { pthread_mutex_init(&m_, nullptr); }
inline Mutex::~Mutex() { pthread_mutex_destroy(&m_); }
inline void Mutex::lock() { pthread_mutex_lock(&m_); }
inline void Mutex::unlock() { pthread_mutex_unlock(&m_); }
inline std::int64_t monotonic_us() {
timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return static_cast<std::int64_t>(ts.tv_sec) * 1000000LL + ts.tv_nsec / 1000;
}
inline std::uint32_t thread_id() { return static_cast<std::uint32_t>(syscall(SYS_gettid)); }
inline void utc_timestamp(char* out, unsigned cap) {
time_t now = time(nullptr);
tm t;
gmtime_r(&now, &t);
if (cap < 21) { if (cap) out[0] = '\0'; return; }
strftime(out, cap, "%Y-%m-%dT%H:%M:%SZ", &t);
}
#endif
} // namespace shim

View file

@ -0,0 +1,69 @@
#include "shim/trace/selftest.h"
#include <stdexcept>
namespace shim::selftest {
using trace::Tv;
std::uint32_t SHIM_CDECL Fill(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed) {
std::uint32_t x = seed, sum = 0;
for (std::uint32_t i = 0; i < n; ++i) {
x = x * 1664525u + 1013904223u;
buf[i] = static_cast<std::uint8_t>(x >> 24);
sum = sum * 31u + buf[i];
}
return sum;
}
// Same contract, written differently (rolling checksum accumulated after the fill).
std::uint32_t SHIM_CDECL FillOurs(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed) {
std::uint32_t x = seed;
for (std::uint32_t i = 0; i < n; ++i) {
x = 1664525u * x + 1013904223u;
buf[i] = static_cast<std::uint8_t>((x >> 24) & 0xffu);
}
std::uint32_t sum = 0;
for (std::uint32_t i = 0; i < n; ++i) sum = sum * 31u + buf[i];
return sum;
}
std::uint32_t SHIM_CDECL FillWrong(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed) {
const std::uint32_t sum = FillOurs(buf, n, seed);
if (n) buf[n - 1] = static_cast<std::uint8_t>(buf[n - 1] + 1);
return sum; // checksum still reports the original's answer: only the region diverges
}
std::uint32_t SHIM_CDECL FillThrows(std::uint8_t*, std::uint32_t, std::uint32_t) {
throw std::runtime_error("selftest: deliberate throw");
}
void FillHook::describe_args(std::vector<Tv>& out, std::uint8_t* buf, std::uint32_t n, std::uint32_t seed) {
out.push_back(trace::tv::ptr(buf).named("buf"));
out.push_back(trace::tv::u32(n).named("n"));
out.push_back(trace::tv::u32(seed).named("seed"));
}
Tv FillHook::describe_ret(std::uint32_t r) { return trace::tv::u32(r); }
void FillHook::regions(std::vector<trace::Region>& out, std::uint8_t* buf, std::uint32_t n, std::uint32_t) {
trace::Region r;
r.name = "buf";
r.ptr = buf;
r.size = n;
out.push_back(r);
}
FillHook::Args FillHook::rebind(trace::Scratch& s, std::uint8_t*, std::uint32_t n, std::uint32_t seed) {
return Args(s.as<std::uint8_t>(0), n, seed);
}
std::uint32_t run_once(trace::Mode mode) {
using H = trace::Hook<FillHook>;
H::original = &Fill;
H::mode = mode;
std::uint8_t buf[64] = {};
return H::detour()(buf, sizeof buf, 0x5eed);
}
} // namespace shim::selftest

53
src/shim/trace/selftest.h Normal file
View file

@ -0,0 +1,53 @@
// Worked example hook that needs no game knowledge: the shim's own `Fill` function.
//
// Fill(buf, n, seed): writes n bytes of an LCG stream into buf, returns a 32-bit checksum.
//
// FillHook wraps it with the hook template (args: ptr/u32/u32, ret: u32, one side region
// "buf"), with `ours` = an independent reimplementation. FillWrongHook is the same hook with
// a deliberately wrong `ours` (last byte off by one) so the compare path's divergence
// reporting can be exercised. The shim runs FillHook once at startup (see main.cpp) as a
// smoke record; the host test drives all modes and validates the log with tracecmp.py.
#pragma once
#include <cstdint>
#include <tuple>
#include <vector>
#include "shim/trace/hook.h"
namespace shim::selftest {
std::uint32_t SHIM_CDECL Fill(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed);
std::uint32_t SHIM_CDECL FillOurs(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed);
std::uint32_t SHIM_CDECL FillWrong(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed);
std::uint32_t SHIM_CDECL FillThrows(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed);
struct FillHook {
static constexpr const char* name = "Shim::SelfTest::Fill";
static constexpr trace::CallConv conv = trace::CallConv::Cdecl;
using Ret = std::uint32_t;
using Args = std::tuple<std::uint8_t*, std::uint32_t, std::uint32_t>;
static void describe_args(std::vector<trace::Tv>& out, std::uint8_t* buf, std::uint32_t n, std::uint32_t seed);
static trace::Tv describe_ret(std::uint32_t r);
static void regions(std::vector<trace::Region>& out, std::uint8_t* buf, std::uint32_t n, std::uint32_t seed);
static Args rebind(trace::Scratch& s, std::uint8_t* buf, std::uint32_t n, std::uint32_t seed);
static std::uint32_t ours(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed) { return FillOurs(buf, n, seed); }
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
};
struct FillWrongHook : FillHook {
static constexpr const char* name = "Shim::SelfTest::FillWrong";
static std::uint32_t ours(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed) { return FillWrong(buf, n, seed); }
};
struct FillThrowsHook : FillHook {
static constexpr const char* name = "Shim::SelfTest::FillThrows";
static std::uint32_t ours(std::uint8_t* buf, std::uint32_t n, std::uint32_t seed) { return FillThrows(buf, n, seed); }
};
// Runs FillHook once through its detour (original = Fill) with a 64-byte buffer.
// Returns the checksum. `mode` is applied to the hook first.
std::uint32_t run_once(trace::Mode mode);
} // namespace shim::selftest

116
src/shim/trace/sha256.cpp Normal file
View file

@ -0,0 +1,116 @@
#include "shim/trace/sha256.h"
#include <cstring>
namespace shim {
namespace {
const std::uint32_t K[64] = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
inline std::uint32_t rotr(std::uint32_t x, unsigned n) { return (x >> n) | (x << (32 - n)); }
} // namespace
Sha256::Sha256() : buflen_(0), total_(0) {
static const std::uint32_t init[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
std::memcpy(h_, init, sizeof h_);
}
void Sha256::block(const std::uint8_t* p) {
std::uint32_t w[64];
for (int i = 0; i < 16; ++i) {
w[i] = (std::uint32_t(p[4 * i]) << 24) | (std::uint32_t(p[4 * i + 1]) << 16) |
(std::uint32_t(p[4 * i + 2]) << 8) | std::uint32_t(p[4 * i + 3]);
}
for (int i = 16; i < 64; ++i) {
const std::uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3);
const std::uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
}
std::uint32_t a = h_[0], b = h_[1], c = h_[2], d = h_[3], e = h_[4], f = h_[5], g = h_[6], h = h_[7];
for (int i = 0; i < 64; ++i) {
const std::uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const std::uint32_t ch = (e & f) ^ (~e & g);
const std::uint32_t t1 = h + S1 + ch + K[i] + w[i];
const std::uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const std::uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
const std::uint32_t t2 = S0 + maj;
h = g; g = f; f = e; e = d + t1;
d = c; c = b; b = a; a = t1 + t2;
}
h_[0] += a; h_[1] += b; h_[2] += c; h_[3] += d;
h_[4] += e; h_[5] += f; h_[6] += g; h_[7] += h;
}
void Sha256::update(const void* data, std::size_t len) {
const std::uint8_t* p = static_cast<const std::uint8_t*>(data);
total_ += len;
if (buflen_) {
const std::size_t take = (64 - buflen_ < len) ? 64 - buflen_ : len;
std::memcpy(buf_ + buflen_, p, take);
buflen_ += take;
p += take;
len -= take;
if (buflen_ < 64) return;
block(buf_);
buflen_ = 0;
}
while (len >= 64) {
block(p);
p += 64;
len -= 64;
}
if (len) {
std::memcpy(buf_, p, len);
buflen_ = len;
}
}
void Sha256::finish(std::uint8_t out[32]) {
const std::uint64_t bits = total_ * 8;
std::uint8_t pad[72];
std::size_t padlen = (buflen_ < 56) ? 56 - buflen_ : 120 - buflen_;
std::memset(pad, 0, sizeof pad);
pad[0] = 0x80;
for (int i = 0; i < 8; ++i) pad[padlen + i] = static_cast<std::uint8_t>(bits >> (56 - 8 * i));
update(pad, padlen + 8);
for (int i = 0; i < 8; ++i) {
out[4 * i] = static_cast<std::uint8_t>(h_[i] >> 24);
out[4 * i + 1] = static_cast<std::uint8_t>(h_[i] >> 16);
out[4 * i + 2] = static_cast<std::uint8_t>(h_[i] >> 8);
out[4 * i + 3] = static_cast<std::uint8_t>(h_[i]);
}
}
void Sha256::digest(const void* data, std::size_t len, std::uint8_t out[32]) {
Sha256 s;
s.update(data, len);
s.finish(out);
}
void Sha256::to_hex(const std::uint8_t d[32], char hex[65]) {
static const char* digits = "0123456789abcdef";
for (int i = 0; i < 32; ++i) {
hex[2 * i] = digits[d[i] >> 4];
hex[2 * i + 1] = digits[d[i] & 15];
}
hex[64] = '\0';
}
void Sha256::digest_hex(const void* data, std::size_t len, char hex[65]) {
std::uint8_t d[32];
digest(data, len, d);
to_hex(d, hex);
}
} // namespace shim

30
src/shim/trace/sha256.h Normal file
View file

@ -0,0 +1,30 @@
// Minimal SHA-256 (FIPS 180-4), written from the standard. No allocation, no exceptions.
#pragma once
#include <cstddef>
#include <cstdint>
namespace shim {
class Sha256 {
public:
Sha256();
void update(const void* data, std::size_t len);
// Finalizes into `out[32]`. The object must not be updated afterwards.
void finish(std::uint8_t out[32]);
// One-shot helpers.
static void digest(const void* data, std::size_t len, std::uint8_t out[32]);
// Lowercase hex of the digest into `hex[65]` (NUL-terminated).
static void digest_hex(const void* data, std::size_t len, char hex[65]);
static void to_hex(const std::uint8_t d[32], char hex[65]);
private:
void block(const std::uint8_t* p);
std::uint32_t h_[8];
std::uint8_t buf_[64];
std::size_t buflen_;
std::uint64_t total_;
};
} // namespace shim

401
src/shim/trace/tracer.cpp Normal file
View file

@ -0,0 +1,401 @@
#include "shim/trace/tracer.h"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include "shim/trace/sha256.h"
namespace shim::trace {
// ---- configuration --------------------------------------------------------------------------
bool Config::apply(const char* key, const char* value, std::string* err) {
auto fail = [&](const char* msg) {
if (err) *err = msg;
return true;
};
if (std::strcmp(key, "hooks") == 0) {
Mode m;
if (!parse_mode(value, m)) return fail("hooks: expected off|trace|compare|replace");
default_mode = m;
return true;
}
if (std::strncmp(key, "hook.", 5) == 0) {
const char* name = key + 5;
if (!*name) return fail("hook.<Name>: empty name");
Mode m;
if (!parse_mode(value, m)) return fail("hook.<Name>: expected off|trace|compare|replace");
for (auto& hm : hook_modes) {
if (hm.first == name) {
hm.second = m;
return true;
}
}
hook_modes.emplace_back(name, m);
return true;
}
if (std::strcmp(key, "trace.path") == 0) {
if (!*value) return fail("trace.path: empty");
path = value;
return true;
}
if (std::strcmp(key, "trace.inline_max") == 0) {
char* end = nullptr;
const unsigned long n = std::strtoul(value, &end, 10);
if (!end || *end || end == value) return fail("trace.inline_max: expected a number");
inline_max = static_cast<unsigned>(n);
return true;
}
if (std::strcmp(key, "trace.flush") == 0) {
if (std::strcmp(value, "always") == 0) flush_always = true;
else if (std::strcmp(value, "lazy") == 0) flush_always = false;
else return fail("trace.flush: expected always|lazy");
return true;
}
return false;
}
Mode Config::mode_for(const char* hook) const {
for (const auto& hm : hook_modes)
if (hm.first == hook) return hm.second;
return default_mode;
}
// ---- snapshots ---------------------------------------------------------------------------------
Snapshot Snapshot::capture(const Region& r) {
Snapshot s;
s.name = r.name;
s.describe = r.describe;
const std::uint8_t* p = static_cast<const std::uint8_t*>(r.ptr);
if (p && r.size) s.data.assign(p, p + r.size);
return s;
}
Snapshot Snapshot::recapture(const Region& r) const {
Snapshot s = capture(r);
s.name = name;
s.describe = describe;
return s;
}
Tv Snapshot::to_tv(unsigned inline_max) const {
if (describe) return describe(data.data(), data.size(), inline_max);
return tv::bytes(data.data(), data.size(), inline_max);
}
Scratch::Scratch(const std::vector<Snapshot>& before) : copies_(before) {}
// ---- diff ----------------------------------------------------------------------------------------------
namespace {
DiffEntry entry(const std::string& path, const char* why, const Tv* a, const Tv* b) {
DiffEntry d;
d.path = path;
d.why = why;
if (a) d.orig = *a;
if (b) d.ours = *b;
return d;
}
float to_f32(double x) { return static_cast<float>(x); }
long long ulp_int(double x, bool f32) {
if (f32) {
const float f = to_f32(x);
std::int32_t i;
std::memcpy(&i, &f, sizeof i);
return i >= 0 ? i : -static_cast<long long>(i & 0x7fffffff);
}
std::int64_t i;
std::memcpy(&i, &x, sizeof i);
return i >= 0 ? i : -static_cast<long long>(i & 0x7fffffffffffffffLL);
}
// tracecmp.floats_equal with the f32 rounding rule applied by the caller.
bool floats_equal(double a, double b, const HookPolicy& pol, bool f32) {
if (std::isnan(a) || std::isnan(b)) return std::isnan(a) && std::isnan(b);
if (std::isinf(a) || std::isinf(b)) return a == b;
if (a == b) return true;
if (pol.ftol <= 0) return false;
if (std::strcmp(pol.ftol_kind, "abs") == 0) return std::fabs(a - b) <= pol.ftol;
if (std::strcmp(pol.ftol_kind, "rel") == 0)
return std::fabs(a - b) <= pol.ftol * std::max(std::fabs(a), std::fabs(b));
if (std::strcmp(pol.ftol_kind, "ulp") == 0) {
const long long d = ulp_int(a, f32) - ulp_int(b, f32);
return static_cast<double>(d < 0 ? -d : d) <= pol.ftol;
}
return false;
}
bool is_unordered(const HookPolicy& pol, const std::string& path) {
for (const auto& p : pol.unordered)
if (p == path) return true;
return false;
}
std::vector<const Tv*> sorted_by_canonical(const std::vector<Tv>& items) {
std::vector<std::pair<std::string, const Tv*>> keyed;
keyed.reserve(items.size());
for (const Tv& t : items) keyed.emplace_back(canonical(t), &t);
std::sort(keyed.begin(), keyed.end(),
[](const auto& x, const auto& y) { return x.first < y.first; });
std::vector<const Tv*> out;
out.reserve(keyed.size());
for (const auto& k : keyed) out.push_back(k.second);
return out;
}
const Tv* struct_field(const Tv& s, const std::string& key) {
for (std::size_t i = 0; i < s.keys.size(); ++i)
if (s.keys[i] == key) return &s.items[i];
return nullptr;
}
std::string len_text(std::size_t n) { return std::to_string(n); }
} // namespace
bool diff_tv(const Tv& a, const Tv& b, const HookPolicy& pol, const std::string& path,
std::vector<DiffEntry>& out) {
const std::size_t start = out.size();
if (a.kind != b.kind || (a.kind == Tv::Int && std::strcmp(a.itype, b.itype) != 0) ||
(a.kind == Tv::Float && a.f32 != b.f32)) {
out.push_back(entry(path, "type", &a, &b));
return true;
}
switch (a.kind) {
case Tv::Ptr: {
const bool za = a.p == 0, zb = b.p == 0;
if (za != zb || (pol.ptr_exact && a.p != b.p)) out.push_back(entry(path, "exact", &a, &b));
break;
}
case Tv::Null:
break;
case Tv::Bool:
if (a.b != b.b) out.push_back(entry(path, "exact", &a, &b));
break;
case Tv::Enum:
if (a.i != b.i) out.push_back(entry(path, "exact", &a, &b));
break;
case Tv::Str:
case Tv::Json:
if (a.s != b.s) out.push_back(entry(path, "exact", &a, &b));
break;
case Tv::WStr:
if (a.w != b.w) out.push_back(entry(path, "exact", &a, &b));
break;
case Tv::Int:
if (a.is_unsigned ? (a.u != b.u) : (a.i != b.i)) out.push_back(entry(path, "exact", &a, &b));
break;
case Tv::Float: {
// %.9g round-trips the float32, not the widened double: compare at float32 width.
const double x = a.f32 ? static_cast<double>(to_f32(a.f)) : a.f;
const double y = b.f32 ? static_cast<double>(to_f32(b.f)) : b.f;
if (!floats_equal(x, y, pol, a.f32)) out.push_back(entry(path, pol.ftol > 0 ? "ftol" : "exact", &a, &b));
break;
}
case Tv::Bytes: {
if (a.blen != b.blen) {
out.push_back(entry(path, "len", &a, &b));
break;
}
if (std::strcmp(a.sha, b.sha) != 0) {
DiffEntry d = entry(path, "hash", &a, &b);
if (a.inline_full && b.inline_full) {
const std::size_t n = std::min(a.data.size(), b.data.size());
for (std::size_t i = 0; i < n; ++i) {
if (a.data[i] != b.data[i]) {
d.first_diff_offset = static_cast<long long>(i);
break;
}
}
}
out.push_back(std::move(d));
} else if (a.inline_full && b.inline_full && a.data != b.data) {
DiffEntry d = entry(path, "hash", &a, &b);
d.note = "same sha256, different hex: corrupt log";
out.push_back(std::move(d));
}
break;
}
case Tv::List:
case Tv::Set: {
std::vector<const Tv*> va, vb;
if (a.kind == Tv::Set || is_unordered(pol, path)) {
va = sorted_by_canonical(a.items);
vb = sorted_by_canonical(b.items);
} else {
for (const Tv& t : a.items) va.push_back(&t);
for (const Tv& t : b.items) vb.push_back(&t);
}
if (va.size() != vb.size()) {
DiffEntry d = entry(path, "len", nullptr, nullptr);
d.orig_raw = len_text(va.size());
d.ours_raw = len_text(vb.size());
out.push_back(std::move(d));
break;
}
for (std::size_t i = 0; i < va.size(); ++i)
diff_tv(*va[i], *vb[i], pol, path + ".v[" + std::to_string(i) + "]", out);
break;
}
case Tv::Struct: {
std::vector<std::string> ka = a.keys, kb = b.keys;
std::sort(ka.begin(), ka.end());
std::sort(kb.begin(), kb.end());
for (const std::string& k : ka)
if (!struct_field(b, k)) out.push_back(entry(path + ".v." + k, "missing", struct_field(a, k), nullptr));
for (const std::string& k : kb)
if (!struct_field(a, k)) out.push_back(entry(path + ".v." + k, "extra", nullptr, struct_field(b, k)));
for (const std::string& k : ka)
if (const Tv* fb = struct_field(b, k)) diff_tv(*struct_field(a, k), *fb, pol, path + ".v." + k, out);
break;
}
}
return out.size() > start;
}
bool diff_outputs(const std::optional<Tv>& ret_a, const std::vector<SideEntry>& side_a,
const std::optional<Tv>& ret_b, const std::vector<OursSide>& side_b, const HookPolicy& pol,
std::vector<DiffEntry>& out) {
const std::size_t start = out.size();
if (ret_a.has_value() != ret_b.has_value()) {
out.push_back(entry("ret", ret_b ? "extra" : "missing", ret_a ? &*ret_a : nullptr, ret_b ? &*ret_b : nullptr));
} else if (ret_a) {
diff_tv(*ret_a, *ret_b, pol, "ret", out);
}
auto find_a = [&](const std::string& n) -> const SideEntry* {
for (const auto& e : side_a) if (e.name == n) return &e;
return nullptr;
};
auto find_b = [&](const std::string& n) -> const OursSide* {
for (const auto& e : side_b) if (e.name == n) return &e;
return nullptr;
};
std::vector<std::string> na, nb;
for (const auto& e : side_a) na.push_back(e.name);
for (const auto& e : side_b) nb.push_back(e.name);
std::sort(na.begin(), na.end());
std::sort(nb.begin(), nb.end());
for (const std::string& n : na)
if (!find_b(n)) out.push_back(entry("side." + n + ".after", "missing", &find_a(n)->after, nullptr));
for (const std::string& n : nb)
if (!find_a(n)) out.push_back(entry("side." + n + ".after", "extra", nullptr, &find_b(n)->after));
for (const std::string& n : na)
if (const OursSide* b = find_b(n)) diff_tv(find_a(n)->after, b->after, pol, "side." + n + ".after", out);
return out.size() > start;
}
// ---- tracer ------------------------------------------------------------------------------------------------
namespace {
thread_local int t_depth = 0;
std::atomic<std::uint32_t> g_next_call_id{0};
} // namespace
Tracer& Tracer::instance() {
static Tracer t;
return t;
}
void Tracer::configure(const Config& cfg) { cfg_ = cfg; }
void Tracer::register_hook(const char* name, const HookPolicy& policy) {
for (auto& h : hooks_) {
if (h.first == name) {
h.second = policy;
return;
}
}
hooks_.emplace_back(name, policy);
}
bool Tracer::open(const char* build_id, const char* exe_sha256) {
if (file_) return true;
if (cfg_.path.empty()) return false;
std::FILE* f = std::fopen(cfg_.path.c_str(), "wb");
if (!f) return false;
Meta m;
m.build = build_id ? build_id : "";
m.exe_sha256 = exe_sha256 ? exe_sha256 : "";
char ts[32];
utc_timestamp(ts, sizeof ts);
m.started = ts;
m.inline_max = cfg_.inline_max;
m.hooks = hooks_;
Buf b;
emit_meta(b, m);
if (!b.ok()) {
std::fclose(f);
return false;
}
std::fwrite(b.data(), 1, b.size(), f);
std::fflush(f);
LockGuard g(mu_);
file_ = f;
t0_ = monotonic_us();
written_ = 0;
return true;
}
void Tracer::close() {
LockGuard g(mu_);
if (!file_) return;
std::fflush(file_);
std::fclose(file_);
file_ = nullptr;
}
Mode Tracer::mode_for(const char* hook) const {
if (!file_) return Mode::Off;
return cfg_.mode_for(hook);
}
std::uint32_t Tracer::next_call_id() { return g_next_call_id.fetch_add(1, std::memory_order_relaxed); }
int Tracer::enter() { return t_depth++; }
void Tracer::leave() { if (t_depth > 0) --t_depth; }
std::int64_t Tracer::now_us() const { return monotonic_us() - t0_; }
bool Tracer::write(Record& rec) {
if (!file_) return false;
rec.ts = now_us();
rec.thread = thread_id();
Buf b;
emit_record(b, rec);
if (!b.ok()) return false;
const bool urgent = cfg_.flush_always || rec.diverged > 0 || rec.err.has_value();
LockGuard g(mu_);
if (!file_) return false;
std::fwrite(b.data(), 1, b.size(), file_);
if (urgent) std::fflush(file_);
++written_;
return true;
}
void Tracer::flush() {
LockGuard g(mu_);
if (file_) std::fflush(file_);
}
bool sha256_file(const char* path, char hex[65]) {
std::FILE* f = std::fopen(path, "rb");
if (!f) return false;
Sha256 h;
std::uint8_t buf[65536];
std::size_t n;
while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) h.update(buf, n);
std::fclose(f);
std::uint8_t d[32];
h.finish(d);
Sha256::to_hex(d, hex);
return true;
}
} // namespace shim::trace

133
src/shim/trace/tracer.h Normal file
View file

@ -0,0 +1,133 @@
// Tracer: per-hook modes from shim.cfg, the locked JSONL writer, call ids / depth, side-effect
// snapshots + scratch copies, and the shim-side diff (TRACE_FORMAT.md section 6).
//
// Host-testable: only platform.h touches the OS.
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "shim/trace/emitter.h"
#include "shim/trace/platform.h"
namespace shim::trace {
// ---- configuration --------------------------------------------------------------------------
//
// shim.cfg keys handled here (main.cpp forwards every key it does not own):
// hooks=off|trace|compare|replace default mode for every hook ("off" = nothing installed)
// hook.<Name>=off|trace|compare|replace per-hook override (<Name> is the hook's record name)
// trace.path=<file> log file (default: <shim dir>\shim.trace.jsonl)
// trace.inline_max=<n> bytes inlined as hex (default 256)
// trace.flush=always|lazy fflush after every record (default) or only on
// divergence / err / close
struct Config {
Mode default_mode = Mode::Trace;
std::vector<std::pair<std::string, Mode>> hook_modes;
std::string path;
unsigned inline_max = 256;
bool flush_always = true;
// Returns true when `key` is a trace key. Sets *err (if given) and returns true when the
// key is ours but the value is unusable (the key is then left unchanged).
bool apply(const char* key, const char* value, std::string* err = nullptr);
Mode mode_for(const char* hook) const;
};
// ---- side-effect regions ------------------------------------------------------------------------
// A declared region of memory the hooked function may write. `describe` turns the captured
// bytes into a structured Tv (so diffs name a field); nullptr = plain `bytes`.
struct Region {
const char* name = "";
const void* ptr = nullptr;
std::size_t size = 0;
Tv (*describe)(const void* data, std::size_t size, unsigned inline_max) = nullptr;
};
struct Snapshot {
std::string name;
std::vector<std::uint8_t> data;
Tv (*describe)(const void*, std::size_t, unsigned) = nullptr;
static Snapshot capture(const Region& r);
// Re-read the same region (for "after"); the result shares name/describe.
Snapshot recapture(const Region& r) const;
Tv to_tv(unsigned inline_max) const;
};
// Writable copies of a set of snapshots: the state `ours` runs on in compare mode.
class Scratch {
public:
explicit Scratch(const std::vector<Snapshot>& before);
std::size_t count() const { return copies_.size(); }
void* ptr(std::size_t i) { return copies_[i].data.data(); }
template <class T>
T* as(std::size_t i) { return static_cast<T*>(ptr(i)); }
std::size_t size(std::size_t i) const { return copies_[i].data.size(); }
const Snapshot& current(std::size_t i) const { return copies_[i]; } // reflects writes by ours
private:
std::vector<Snapshot> copies_;
};
// ---- diff ------------------------------------------------------------------------------------------
// Appends diff entries for `a` (original) vs `b` (ours) at `path`; returns true if any.
bool diff_tv(const Tv& a, const Tv& b, const HookPolicy& pol, const std::string& path,
std::vector<DiffEntry>& out);
// Section 6 over a whole record: ret, then side names (missing/extra), then side.<n>.after.
bool diff_outputs(const std::optional<Tv>& ret_a, const std::vector<SideEntry>& side_a,
const std::optional<Tv>& ret_b, const std::vector<OursSide>& side_b, const HookPolicy& pol,
std::vector<DiffEntry>& out);
// ---- tracer ------------------------------------------------------------------------------------------
class Tracer {
public:
static Tracer& instance();
// All three before open(): policy goes into the meta record.
void configure(const Config& cfg);
void register_hook(const char* name, const HookPolicy& policy);
const Config& config() const { return cfg_; }
// Opens cfg.path ("w"), writes the meta line. false = could not open (tracing stays off).
bool open(const char* build_id, const char* exe_sha256);
void close();
bool is_open() const { return file_ != nullptr; }
Mode mode_for(const char* hook) const; // Off when not open
unsigned inline_max() const { return cfg_.inline_max; }
std::uint32_t next_call_id(); // atomic; take it BEFORE calling the original
int enter(); // ++ thread-local depth; returns the depth of this call
void leave();
std::int64_t now_us() const; // microseconds since open()
// Fills ts/thread, serializes, appends under the lock. false = not open / buffer overflow.
bool write(Record& rec);
void flush();
std::uint32_t records_written() const { return written_; }
private:
Tracer() = default;
Config cfg_;
std::vector<std::pair<std::string, HookPolicy>> hooks_;
std::FILE* file_ = nullptr;
std::int64_t t0_ = 0;
Mutex mu_;
std::uint32_t next_id_ = 0;
std::uint32_t written_ = 0;
};
// SHA-256 of a whole file, lowercase hex. false if unreadable.
bool sha256_file(const char* path, char hex[65]);
} // namespace shim::trace

View file

@ -0,0 +1,22 @@
# Host tests for the shim's trace/compare infrastructure (src/shim/trace). The Python
# oracle (sots-re/verify/harness/compare) is used when SOTS_TRACECMP_DIR points at it;
# the tests skip those steps cleanly otherwise.
set(SOTS_TRACECMP_DIR "$ENV{HOME}/sots-re/verify/harness/compare" CACHE PATH
"Directory holding tracecmp.py + mkfixture.py (python oracle for the trace tests)")
set(_out ${CMAKE_CURRENT_BINARY_DIR}/out)
file(MAKE_DIRECTORY ${_out})
foreach(_t sha256 emitter diff hook)
add_executable(shim_trace_test_${_t} test_${_t}.cpp)
target_link_libraries(shim_trace_test_${_t} PRIVATE shim_trace)
target_compile_options(shim_trace_test_${_t} PRIVATE -Wall -Wextra -Werror)
target_compile_definitions(shim_trace_test_${_t} PRIVATE SOTS_TRACECMP_DIR="${SOTS_TRACECMP_DIR}")
endforeach()
target_link_libraries(shim_trace_test_hook PRIVATE pthread)
add_test(NAME shim_trace_sha256 COMMAND shim_trace_test_sha256)
add_test(NAME shim_trace_emitter COMMAND shim_trace_test_emitter ${_out}/emitter_oracle.jsonl
${CMAKE_CURRENT_SOURCE_DIR}/oracle_emit.py)
add_test(NAME shim_trace_diff COMMAND shim_trace_test_diff)
add_test(NAME shim_trace_hook COMMAND shim_trace_test_hook ${_out})

37
tests/shim_trace/check.h Normal file
View file

@ -0,0 +1,37 @@
// Minimal test helpers for the shim trace tests (no dependency on other modules).
#pragma once
#include <cstdio>
#include <string>
namespace tracetest {
inline int& failures() { static int n = 0; return n; }
inline int& checks() { static int n = 0; return n; }
inline void report(bool ok, const char* expr, const char* file, int line, const std::string& detail) {
++checks();
if (ok) return;
++failures();
std::fprintf(stderr, "FAIL %s:%d %s%s%s\n", file, line, expr, detail.empty() ? "" : " -- ", detail.c_str());
}
inline void check_str(const std::string& got, const std::string& want, const char* expr, const char* file, int line) {
report(got == want, expr, file, line, got == want ? "" : "\n got: " + got + "\n want: " + want);
}
template <class A, class B>
inline void check_eq(const A& a, const B& b, const char* expr, const char* file, int line) {
report(a == b, expr, file, line, a == b ? "" : "got " + std::to_string(a) + ", expected " + std::to_string(b));
}
inline int finish(const char* name) {
std::printf("%s: %d checks, %d failures\n", name, checks(), failures());
return failures() == 0 ? 0 : 1;
}
} // namespace tracetest
#define CHECK(expr) ::tracetest::report((expr), #expr, __FILE__, __LINE__, "")
#define CHECK_EQ(a, b) ::tracetest::check_eq((a), (b), #a " == " #b, __FILE__, __LINE__)
#define CHECK_STR(got, want) ::tracetest::check_str((got), (want), #got, __FILE__, __LINE__)

View file

@ -0,0 +1,62 @@
// Running the Python oracle (sots-re/verify/harness/compare) from a host test.
//
// The harness directory comes from -DSOTS_TRACECMP_DIR (CMake) or the SOTS_TRACECMP_DIR
// environment variable; when neither points at a directory holding tracecmp.py the
// python steps are skipped (returning kSkipped) and the test still passes.
#pragma once
#include <cstdio>
#include <cstdlib>
#include <string>
namespace tracetest {
constexpr int kSkipped = -1;
inline std::string harness_dir() {
if (const char* e = std::getenv("SOTS_TRACECMP_DIR"); e && *e) return e;
#ifdef SOTS_TRACECMP_DIR
return SOTS_TRACECMP_DIR;
#else
return "";
#endif
}
inline bool file_exists(const std::string& p) {
if (std::FILE* f = std::fopen(p.c_str(), "rb")) {
std::fclose(f);
return true;
}
return false;
}
inline bool harness_present() { return file_exists(harness_dir() + "/tracecmp.py"); }
// Exit status of `python3 <script> <args>` (kSkipped when unavailable, 127 if it failed to run).
inline int run_python(const std::string& script, const std::string& args) {
if (!file_exists(script)) {
std::printf(" SKIP (no %s)\n", script.c_str());
return kSkipped;
}
const std::string cmd = "/usr/bin/python3 " + script + " " + args;
std::printf(" $ %s\n", cmd.c_str());
std::fflush(stdout);
const int rc = std::system(cmd.c_str());
if (rc == -1) return 127;
#if defined(_WIN32)
return rc;
#else
return WIFEXITED(rc) ? WEXITSTATUS(rc) : 127;
#endif
}
// tracecmp.py LOG [flags]: 0 clean, 1 divergence, 2 invalid.
inline int run_tracecmp(const std::string& log, const std::string& flags = "") {
if (!harness_present()) {
std::printf(" SKIP tracecmp (SOTS_TRACECMP_DIR not set / harness absent)\n");
return kSkipped;
}
return run_python(harness_dir() + "/tracecmp.py", log + " --quiet " + flags);
}
} // namespace tracetest

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""oracle_emit.py HARNESS_DIR CPP_FIXTURE.jsonl
Rebuilds the records test_emitter.cpp::write_oracle_fixture() writes, using the
reference emitter in HARNESS_DIR/mkfixture.py (emit_meta / emit_record), and compares
the two files byte for byte. Exit 0 = identical, 1 = differ, 2 = usage.
Stdlib only.
"""
import sys
def build(mk) -> str:
inf, nan = float("inf"), float("nan")
meta = {"format": 1, "build": "shim-trace test", "exe_sha256": "0" * 64,
"started": "2026-09-07T00:00:00Z", "inline_max": 256,
"hooks": {"Hook::A": {"ftol": 0.0, "ftol_kind": "abs", "ptr": "ignore"},
"Mars::ParseBlock": {"ftol": 1e-6, "ftol_kind": "rel", "ptr": "exact",
"unordered": ["ret.v.items"]}}}
fwd = bytes(range(16))
recs = [
{"ts": 1000, "hook": "CfgVar_RegisterKey", "mode": "trace", "call_id": 0, "thread": 4120, "depth": 0,
"args": [mk.s("Résumé", "key"), mk.i32(-3, "value"), mk.ptr(0x00A36FD0, "table")],
"ret": mk.boolean(True),
"side": {"cfg_table": {"before": mk.by(fwd), "after": mk.by(fwd[::-1])}}},
{"ts": 1037, "hook": "Manifest_Load", "mode": "trace", "call_id": 1, "thread": 4120,
"args": [mk.s("Weapons/_weapons.txt", "path")],
"ret": mk.i32(2, "count"),
"side": {"registry": {"after": mk.struct_({
"entries": mk.lst(mk.struct_({"id": mk.u32(i), "name": mk.s("w%d.weapon" % i)}) for i in (7, 9)),
"deleted": mk.sset(mk.u32(i) for i in (501, 550, 599)),
"flags": mk.enum(2, "MF_0"),
"big": mk.u64(2**60 + 1),
"small": mk.u64(5),
"edge": mk.u64(2**53),
"neg": mk._tv("i64", str(-(2**60)), None), # |v| >= 2^53 -> decimal string (section 3)
"nothing": mk.null(),
"w": mk._tv("wstr", "A€\x00", None)})}}},
{"ts": 1074, "hook": "Mars::ParseBlock", "mode": "trace", "call_id": 2, "thread": 4124, "depth": 1,
"args": [mk.s('Tab\tKey Quote"d Back\\slash nl\nx cr\rx \x01\x7f\xff €', "text"), mk.u32(2, "len")],
"ret": mk.jsonv({"weapon": {"count": 3, "damage": 1.5, "name": "Laser\xe9", "ok": True, "range": 2.25,
"tags": ["a", "b"]}}),
"side": {"scratch": {"after": mk.lst(mk.f32(v) for v in (1.5, 0.1, -2.5e-7, 1e30))},
"nanbox": {"after": mk.f32(nan)},
"infs": {"after": mk.lst([mk._tv("f64", "inf", None), mk._tv("f64", "-inf", None),
mk._tv("f64", 0.1, None), mk._tv("f64", 1e300, None),
mk._tv("f64", 0.0, None)])},
"blob": {"before": None, "after": mk.by(bytes(range(256)) * 2)}}},
{"ts": 1111, "hook": "CfgVar_RegisterKey", "mode": "compare", "call_id": 3, "thread": 4120,
"args": [mk._tv("i8", -128, None), mk._tv("i16", 32767, None), mk._tv("u8", 255, None),
mk._tv("u16", 65535, None), mk.u64(2**53 - 1)],
"ret": mk.null(), "side": {},
"ours": {"ret": mk.null(), "side": {}}, "diverged": False, "diff": []},
{"ts": 1148, "hook": "Manifest_Load", "mode": "compare", "call_id": 4, "thread": 1,
"args": [], "ret": mk.i32(1), "side": {"r": {"after": mk.by(b"\x01\x02")}},
"ours": {"ret": mk.i32(2), "side": {"r": {"after": mk.by(b"\x01\x03")}}},
"diverged": True,
"diff": [{"path": "ret", "why": "exact", "orig": mk.i32(1), "ours": mk.i32(2)},
{"path": "side.r.after", "why": "hash", "orig": mk.by(b"\x01\x02"), "ours": mk.by(b"\x01\x03"),
"first_diff_offset": 1}],
"note": "hello"},
{"ts": 1185, "hook": "X", "mode": "compare", "call_id": 5, "thread": 1,
"args": [], "ret": None, "side": {},
"diverged": True, "diff": [{"path": "call", "why": "err", "orig": None, "ours": "ours threw"}],
"err": "ours threw"},
{"ts": 1222, "hook": "Y", "mode": "replace", "call_id": 6, "thread": 2,
"args": [mk.boolean(False, "flag")], "ret": mk.ptr(0), "side": {}},
]
return mk.emit_meta(meta) + "".join(mk.emit_record(r) for r in recs)
def main(argv):
if len(argv) != 3:
print(__doc__)
return 2
sys.path.insert(0, argv[1])
import mkfixture as mk # noqa: E402
want = build(mk)
with open(argv[2], "r", encoding="utf-8", newline="") as f:
got = f.read()
if got == want:
print("oracle_emit: identical (%d bytes, %d lines)" % (len(want), want.count("\n")))
return 0
gl, wl = got.split("\n"), want.split("\n")
for i, (g, w) in enumerate(zip(gl, wl), 1):
if g != w:
j = next((k for k in range(min(len(g), len(w))) if g[k] != w[k]), min(len(g), len(w)))
print("oracle_emit: line %d differs at column %d" % (i, j + 1))
print(" cpp: ...%s" % g[max(0, j - 40):j + 60])
print(" py: ...%s" % w[max(0, j - 40):j + 60])
return 1
print("oracle_emit: line count differs (cpp %d, py %d)" % (len(gl), len(wl)))
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -0,0 +1,256 @@
// Snapshot / Scratch / diff rules (TRACE_FORMAT.md section 6), mirrored on tracecmp.compare_tv.
#include <cmath>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
#include "check.h"
#include "shim/trace/tracer.h"
using namespace shim::trace;
static std::vector<DiffEntry> diff(const Tv& a, const Tv& b, HookPolicy pol = HookPolicy{}) {
std::vector<DiffEntry> out;
diff_tv(a, b, pol, "ret", out);
return out;
}
static std::string why(const std::vector<DiffEntry>& d, std::size_t i = 0) { return i < d.size() ? d[i].why : "<none>"; }
static std::string path(const std::vector<DiffEntry>& d, std::size_t i = 0) { return i < d.size() ? d[i].path : "<none>"; }
static void scalars() {
CHECK(diff(tv::boolean(true), tv::boolean(true)).empty());
CHECK_STR(why(diff(tv::boolean(true), tv::boolean(false))), "exact");
CHECK_STR(why(diff(tv::i32(1), tv::u32(1))), "type"); // i32 vs u32 is a type divergence
CHECK_STR(why(diff(tv::i32(1), tv::i32(2))), "exact");
CHECK(diff(tv::u64(1ull << 60), tv::u64(1ull << 60)).empty());
CHECK_STR(why(diff(tv::str("a"), tv::str("b"))), "exact");
CHECK(diff(tv::str("R\xe9"), tv::str("R\xe9")).empty());
const std::uint16_t w1[] = {1, 2}, w2[] = {1, 3};
CHECK_STR(why(diff(tv::wstr(w1, 2), tv::wstr(w2, 2))), "exact");
CHECK_STR(why(diff(tv::str("a"), tv::wstr(w1, 1))), "type");
CHECK(diff(tv::null(), tv::null()).empty());
CHECK_STR(why(diff(tv::null(), tv::i32(0))), "type");
CHECK(diff(tv::enum_(2, "A"), tv::enum_(2, "B")).empty()); // symbolic name is not compared
CHECK_STR(why(diff(tv::enum_(2), tv::enum_(3))), "exact");
CHECK(diff(tv::json("{\"a\":1}"), tv::json("{\"a\":1}")).empty());
CHECK_STR(why(diff(tv::json("{\"a\":1}"), tv::json("{\"a\":2}"))), "exact");
}
static void pointers() {
// ignored by default; null vs non-null always diverges; exact when the policy says so
CHECK(diff(tv::ptr(static_cast<std::uintptr_t>(0x10)), tv::ptr(static_cast<std::uintptr_t>(0x20))).empty());
CHECK_STR(why(diff(tv::ptr(static_cast<std::uintptr_t>(0)), tv::ptr(static_cast<std::uintptr_t>(0x20)))), "exact");
CHECK_STR(why(diff(tv::ptr(static_cast<std::uintptr_t>(0x20)), tv::ptr(static_cast<std::uintptr_t>(0)))), "exact");
CHECK(diff(tv::ptr(static_cast<std::uintptr_t>(0)), tv::ptr(static_cast<std::uintptr_t>(0))).empty());
HookPolicy exact;
exact.ptr_exact = true;
CHECK_STR(why(diff(tv::ptr(static_cast<std::uintptr_t>(0x10)), tv::ptr(static_cast<std::uintptr_t>(0x20)), exact)), "exact");
CHECK(diff(tv::ptr(static_cast<std::uintptr_t>(0x10)), tv::ptr(static_cast<std::uintptr_t>(0x10)), exact).empty());
}
static void floats() {
const float nanf = std::numeric_limits<float>::quiet_NaN();
const double inf = std::numeric_limits<double>::infinity();
CHECK(diff(tv::f32(nanf), tv::f32(nanf)).empty()); // nan == nan
CHECK_STR(why(diff(tv::f32(nanf), tv::f32(0.f))), "exact");
CHECK(diff(tv::f64(inf), tv::f64(inf)).empty());
CHECK_STR(why(diff(tv::f64(inf), tv::f64(-inf))), "exact");
CHECK_STR(why(diff(tv::f32(1.f), tv::f64(1.0))), "type"); // never mix widths
// the f32 rounding rule: a value stored as f32 compares at float32 precision
CHECK(diff(tv::f32(0.1f), tv::f32(static_cast<float>(0.1))).empty());
CHECK_STR(why(diff(tv::f32(1.0f), tv::f32(1.0f + 1e-5f))), "exact");
HookPolicy abs;
abs.ftol = 1e-3;
CHECK(diff(tv::f32(1.0f), tv::f32(1.0f + 1e-5f), abs).empty()); // mkfixture case #7 passes abs 1e-3
CHECK_STR(why(diff(tv::f32(1.0f), tv::f32(1.5f), abs)), "ftol"); // and reports "ftol" when a tolerance is set
CHECK_STR(why(diff(tv::f32(nanf), tv::f32(1.f), abs)), "ftol"); // nan only equals nan, whatever the tolerance
HookPolicy rel;
rel.ftol = 1e-6;
rel.ftol_kind = "rel";
CHECK(diff(tv::f64(1e6), tv::f64(1e6 + 0.5), rel).empty());
CHECK_STR(why(diff(tv::f64(1.0), tv::f64(1.0 + 1e-5), rel)), "ftol");
HookPolicy ulp;
ulp.ftol = 2;
ulp.ftol_kind = "ulp";
CHECK(diff(tv::f32(1.0f), tv::f32(std::nextafter(1.0f, 2.0f)), ulp).empty());
CHECK(diff(tv::f32(1.0f), tv::f32(std::nextafter(std::nextafter(1.0f, 2.0f), 2.0f)), ulp).empty());
CHECK_STR(why(diff(tv::f32(1.0f), tv::f32(std::nextafter(std::nextafter(std::nextafter(1.0f, 2.0f), 2.0f), 2.0f)), ulp)), "ftol");
CHECK(diff(tv::f32(-0.0f), tv::f32(0.0f), ulp).empty()); // -0 == 0
CHECK(diff(tv::f64(1.0), tv::f64(std::nextafter(1.0, 2.0)), ulp).empty());
}
static void bytes() {
const std::uint8_t a[4] = {1, 2, 3, 4}, b[4] = {1, 2, 9, 4}, c[3] = {1, 2, 3};
CHECK(diff(tv::bytes(a, 4, 256), tv::bytes(a, 4, 256)).empty());
auto d = diff(tv::bytes(a, 4, 256), tv::bytes(b, 4, 256));
CHECK_STR(why(d), "hash");
CHECK_EQ(d.empty() ? -1 : d[0].first_diff_offset, 2); // first differing byte offset (inline)
d = diff(tv::bytes(a, 4, 256), tv::bytes(c, 3, 256));
CHECK_STR(why(d), "len");
d = diff(tv::bytes(a, 4, 2), tv::bytes(b, 4, 2)); // hashed (n > inline_max): no offset
CHECK_STR(why(d), "hash");
CHECK_EQ(d.empty() ? 0 : d[0].first_diff_offset, -1);
// same sha256 but different hex = corrupt log
Tv x = tv::bytes(a, 4, 256), y = tv::bytes(a, 4, 256);
y.data[0] = 7;
d = diff(x, y);
CHECK_STR(why(d), "hash");
CHECK(!d.empty() && d[0].note.find("corrupt") != std::string::npos);
}
static void containers() {
auto L = [](std::vector<Tv> v) { return tv::list(std::move(v)); };
auto S = [](std::vector<Tv> v) { return tv::set(std::move(v)); };
CHECK(diff(L({tv::i32(1), tv::i32(2)}), L({tv::i32(1), tv::i32(2)})).empty());
auto d = diff(L({tv::i32(1), tv::i32(2)}), L({tv::i32(2), tv::i32(1)}));
CHECK_STR(why(d), "exact");
CHECK_STR(path(d), "ret.v[0]");
CHECK(diff(S({tv::i32(1), tv::i32(2)}), S({tv::i32(2), tv::i32(1)})).empty()); // set order irrelevant
CHECK_STR(why(diff(S({tv::i32(1), tv::i32(1)}), S({tv::i32(1), tv::i32(2)}))), "exact"); // multiset
d = diff(L({tv::i32(1)}), L({tv::i32(1), tv::i32(2)}));
CHECK_STR(why(d), "len");
CHECK_STR(d.empty() ? "" : d[0].orig_raw, "1");
CHECK_STR(d.empty() ? "" : d[0].ours_raw, "2");
HookPolicy un;
un.unordered = {"ret"};
CHECK(diff(L({tv::i32(1), tv::i32(2)}), L({tv::i32(2), tv::i32(1)}), un).empty()); // policy makes a list a set
Tv sa = tv::struct_(), sb = tv::struct_();
sa.add("id", tv::u32(1)).add("big", tv::u64(5)).add("name", tv::str("x"));
sb.add("name", tv::str("x")).add("id", tv::u32(1)).add("big", tv::u64(5)); // key order irrelevant
CHECK(diff(sa, sb).empty());
Tv sc = tv::struct_();
sc.add("id", tv::u32(1)).add("name", tv::str("y")).add("zz", tv::i8(0));
d = diff(sa, sc);
// sorted: missing "big", extra "zz", then common keys in order -> name differs
CHECK_EQ(d.size(), static_cast<std::size_t>(3));
CHECK_STR(why(d, 0), "missing");
CHECK_STR(path(d, 0), "ret.v.big");
CHECK_STR(why(d, 1), "extra");
CHECK_STR(path(d, 1), "ret.v.zz");
CHECK_STR(why(d, 2), "exact");
CHECK_STR(path(d, 2), "ret.v.name");
// nested path: struct in list
Tv e1 = tv::struct_(), e2 = tv::struct_();
e1.add("id", tv::u32(1));
e2.add("id", tv::u32(2));
d = diff(L({e1}), L({e2}));
CHECK_STR(path(d), "ret.v[0].v.id");
}
static void outputs() {
std::vector<SideEntry> sa;
std::vector<OursSide> sb;
SideEntry e;
e.name = "cfg";
e.after = tv::i32(1);
sa.push_back(e);
e.name = "only_orig";
sa.push_back(e);
sb.push_back({"cfg", tv::i32(2)});
sb.push_back({"only_ours", tv::i32(0)});
std::vector<DiffEntry> d;
CHECK(diff_outputs(tv::boolean(true), sa, tv::boolean(true), sb, HookPolicy{}, d));
CHECK_EQ(d.size(), static_cast<std::size_t>(3));
CHECK_STR(path(d, 0), "side.only_orig.after");
CHECK_STR(why(d, 0), "missing");
CHECK_STR(path(d, 1), "side.only_ours.after");
CHECK_STR(why(d, 1), "extra");
CHECK_STR(path(d, 2), "side.cfg.after");
CHECK_STR(why(d, 2), "exact");
d.clear();
CHECK(!diff_outputs(std::nullopt, {}, std::nullopt, {}, HookPolicy{}, d)); // void == void
CHECK(diff_outputs(std::nullopt, {}, tv::i32(0), {}, HookPolicy{}, d)); // void vs value
CHECK_STR(why(d), "extra");
}
static Tv describe_pair(const void* p, std::size_t n, unsigned) {
Tv s = tv::struct_();
const std::uint8_t* b = static_cast<const std::uint8_t*>(p);
s.add("lo", tv::u8(n > 0 ? b[0] : 0));
s.add("hi", tv::u8(n > 1 ? b[1] : 0));
return s;
}
static void snapshots() {
std::uint8_t mem[4] = {1, 2, 3, 4};
Region r;
r.name = "mem";
r.ptr = mem;
r.size = sizeof mem;
Snapshot before = Snapshot::capture(r);
CHECK_STR(before.name, "mem");
CHECK_EQ(before.data.size(), static_cast<std::size_t>(4));
mem[2] = 9;
Snapshot after = before.recapture(r);
CHECK_EQ(static_cast<int>(before.data[2]), 3); // the snapshot is a copy
CHECK_EQ(static_cast<int>(after.data[2]), 9);
// Scratch is a writable copy of `before`, independent of the live memory
Scratch s({before});
CHECK_EQ(s.count(), static_cast<std::size_t>(1));
CHECK_EQ(s.size(0), static_cast<std::size_t>(4));
s.as<std::uint8_t>(0)[0] = 42;
CHECK_EQ(static_cast<int>(mem[0]), 1);
CHECK_EQ(static_cast<int>(before.data[0]), 1);
CHECK_EQ(static_cast<int>(s.current(0).data[0]), 42);
// default describe = bytes; custom describe = struct
CHECK_STR(before.to_tv(256).type_name(), "bytes");
r.describe = &describe_pair;
Snapshot st = Snapshot::capture(r);
Tv t = st.to_tv(256);
CHECK_STR(t.type_name(), "struct");
CHECK_STR(canonical(t), "{\"t\":\"struct\",\"v\":{\"lo\":{\"t\":\"u8\",\"v\":1},\"hi\":{\"t\":\"u8\",\"v\":2}}}");
// empty / null regions are fine
Region z;
z.name = "z";
Snapshot zs = Snapshot::capture(z);
CHECK(zs.data.empty());
CHECK_STR(canonical(zs.to_tv(256)),
"{\"t\":\"bytes\",\"n\":0,\"sha256\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"hex\":\"\"}");
}
static void config() {
Config c;
std::string err;
CHECK(c.apply("hooks", "compare", &err));
CHECK(c.default_mode == Mode::Compare);
CHECK(c.apply("hook.Game::Foo", "replace", &err));
CHECK(c.apply("hook.Game::Bar", "off", &err));
CHECK(c.apply("hook.Game::Bar", "trace", &err)); // later line wins
CHECK(c.mode_for("Game::Foo") == Mode::Replace);
CHECK(c.mode_for("Game::Bar") == Mode::Trace);
CHECK(c.mode_for("Other") == Mode::Compare);
CHECK(c.apply("trace.path", "C:\\SOTS\\x.jsonl", &err));
CHECK_STR(c.path, "C:\\SOTS\\x.jsonl");
CHECK(c.apply("trace.inline_max", "64", &err));
CHECK_EQ(c.inline_max, 64u);
CHECK(c.apply("trace.flush", "lazy", &err));
CHECK(!c.flush_always);
CHECK(!c.apply("unrelated", "x", &err)); // not ours
err.clear();
CHECK(c.apply("hooks", "sometimes", &err)); // ours, but bad: reported, unchanged
CHECK(!err.empty());
CHECK(c.default_mode == Mode::Compare);
err.clear();
CHECK(c.apply("trace.inline_max", "lots", &err));
CHECK(!err.empty());
CHECK_EQ(c.inline_max, 64u);
Mode m;
CHECK(parse_mode("off", m) && m == Mode::Off);
CHECK(!parse_mode("OFF", m));
CHECK_STR(mode_name(Mode::Replace), "replace");
}
int main() {
scalars();
pointers();
floats();
bytes();
containers();
outputs();
snapshots();
config();
return tracetest::finish("shim_trace_diff");
}

View file

@ -0,0 +1,425 @@
// Emitter golden tests (byte-exact strings derived from TRACE_FORMAT.md section 4 and the
// mkfixture.py reference emitter) + a fixture file that oracle_emit.py regenerates with
// mkfixture's own emit_record() and compares byte for byte.
#include "shim/trace/emitter.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
#include <string>
#include <vector>
#include "check.h"
#include "harness.h"
using namespace shim::trace;
static std::string esc(const std::string& s) {
Buf b;
emit_esc(b, s);
return b.str();
}
static std::string escw(std::vector<std::uint16_t> u) {
Buf b;
emit_esc_w(b, u.data(), u.size());
return b.str();
}
static std::string tvs(const Tv& v) { return canonical(v); }
static const char* kSha512 = "110009dcee21620b166f3abfecb5eff7a873be729d1c2d53822e7acc5f34eb9b";
static void golden_strings() {
// section 4 rule 2; the same inputs test_tracecmp.py feeds mkfixture.esc()
CHECK_STR(esc("plain"), "\"plain\"");
CHECK_STR(esc("R\xe9sum\xe9"), "\"R\\u00e9sum\\u00e9\"");
CHECK_STR(esc("Tab\tKey"), "\"Tab\\tKey\"");
CHECK_STR(esc("Quote\"d"), "\"Quote\\\"d\"");
CHECK_STR(esc("Back\\slash"), "\"Back\\\\slash\"");
CHECK_STR(esc("nl\nx"), "\"nl\\nx\"");
CHECK_STR(esc("cr\rx"), "\"cr\\rx\"");
CHECK_STR(esc("\x01\x7f\xff\x80"), "\"\\u0001\\u007f\\u00ff\\u0080\"");
CHECK_STR(esc("\x08\x0c"), "\"\\u0008\\u000c\"");
CHECK_STR(esc(std::string("a\0b", 3)), "\"a\\u0000b\"");
CHECK_STR(escw({0x41, 0x20ac, 0x7e, 0x7f, 0xffff, 0x0a}), "\"A\\u20ac~\\u007f\\uffff\\n\"");
}
static void golden_numbers() {
CHECK_STR(tvs(tv::f32(1.5f)), "{\"t\":\"f32\",\"v\":1.5}");
CHECK_STR(tvs(tv::f32(0.1f)), "{\"t\":\"f32\",\"v\":0.100000001}");
CHECK_STR(tvs(tv::f32(-2.5e-7f)), "{\"t\":\"f32\",\"v\":-2.49999999e-07}");
CHECK_STR(tvs(tv::f32(1e30f)), "{\"t\":\"f32\",\"v\":1.00000002e+30}");
CHECK_STR(tvs(tv::f32(3.0f)), "{\"t\":\"f32\",\"v\":3}");
CHECK_STR(tvs(tv::f32(123456789.0f)), "{\"t\":\"f32\",\"v\":123456792}");
CHECK_STR(tvs(tv::f32(std::numeric_limits<float>::quiet_NaN())), "{\"t\":\"f32\",\"v\":\"nan\"}");
CHECK_STR(tvs(tv::f32(-std::numeric_limits<float>::infinity())), "{\"t\":\"f32\",\"v\":\"-inf\"}");
CHECK_STR(tvs(tv::f64(std::numeric_limits<double>::infinity())), "{\"t\":\"f64\",\"v\":\"inf\"}");
CHECK_STR(tvs(tv::f64(0.1)), "{\"t\":\"f64\",\"v\":0.10000000000000001}");
CHECK_STR(tvs(tv::f64(1e300)), "{\"t\":\"f64\",\"v\":1.0000000000000001e+300}");
CHECK_STR(tvs(tv::f64(0.0)), "{\"t\":\"f64\",\"v\":0}");
CHECK_STR(tvs(tv::f64(-0.0)), "{\"t\":\"f64\",\"v\":-0}");
CHECK_STR(tvs(tv::u64(18446744073709551615ull)), "{\"t\":\"u64\",\"v\":\"18446744073709551615\"}");
CHECK_STR(tvs(tv::u64(9007199254740992ull)), "{\"t\":\"u64\",\"v\":\"9007199254740992\"}"); // 2^53 -> string
CHECK_STR(tvs(tv::u64(9007199254740991ull)), "{\"t\":\"u64\",\"v\":9007199254740991}"); // 2^53-1 -> int
CHECK_STR(tvs(tv::i64(-9007199254740992ll)), "{\"t\":\"i64\",\"v\":\"-9007199254740992\"}");
CHECK_STR(tvs(tv::i64(-9007199254740991ll)), "{\"t\":\"i64\",\"v\":-9007199254740991}");
CHECK_STR(tvs(tv::i8(-128)), "{\"t\":\"i8\",\"v\":-128}");
CHECK_STR(tvs(tv::i16(32767)), "{\"t\":\"i16\",\"v\":32767}");
CHECK_STR(tvs(tv::i32(-3).named("value")), "{\"t\":\"i32\",\"v\":-3,\"n\":\"value\"}");
CHECK_STR(tvs(tv::u8(255)), "{\"t\":\"u8\",\"v\":255}");
CHECK_STR(tvs(tv::u16(65535)), "{\"t\":\"u16\",\"v\":65535}");
CHECK_STR(tvs(tv::u32(4294967295u)), "{\"t\":\"u32\",\"v\":4294967295}");
CHECK_STR(tvs(tv::boolean(true)), "{\"t\":\"bool\",\"v\":true}");
CHECK_STR(tvs(tv::null()), "{\"t\":\"null\",\"v\":null}");
CHECK_STR(tvs(tv::ptr(static_cast<std::uintptr_t>(0xa36fd0)).named("table")), "{\"t\":\"ptr\",\"v\":\"0x00a36fd0\",\"n\":\"table\"}");
CHECK_STR(tvs(tv::ptr(static_cast<std::uintptr_t>(0))), "{\"t\":\"ptr\",\"v\":\"0x00000000\"}");
CHECK_STR(tvs(tv::enum_(2, "MF_0").named("f")), "{\"t\":\"enum\",\"v\":2,\"name\":\"MF_0\",\"n\":\"f\"}");
CHECK_STR(tvs(tv::enum_(-1)), "{\"t\":\"enum\",\"v\":-1}");
CHECK_STR(tvs(tv::str("R\xe9sum\xe9").named("key")), "{\"t\":\"str\",\"v\":\"R\\u00e9sum\\u00e9\",\"n\":\"key\"}");
CHECK_STR(tvs(tv::str(nullptr)), "{\"t\":\"ptr\",\"v\":\"0x00000000\"}");
const std::uint16_t w[] = {0x41, 0x20ac};
CHECK_STR(tvs(tv::wstr(w, 2)), "{\"t\":\"wstr\",\"v\":\"A\\u20ac\"}");
CHECK_STR(tvs(tv::json("{\"a\":[1,2.5,\"x\"]}")), "{\"t\":\"json\",\"v\":{\"a\":[1,2.5,\"x\"]}}");
}
static void golden_bytes_and_containers() {
CHECK_STR(tvs(tv::bytes("\x00\xff", 2, 256)),
"{\"t\":\"bytes\",\"n\":2,\"sha256\":\"06eb7d6a69ee19e5fbdf749018d3d2abfa04bcbd1365db312eb86dc7169389b8\",\"hex\":\"00ff\"}");
CHECK_STR(tvs(tv::bytes("", 0, 256)),
"{\"t\":\"bytes\",\"n\":0,\"sha256\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"hex\":\"\"}");
std::vector<std::uint8_t> big;
for (int k = 0; k < 2; ++k)
for (int i = 0; i < 256; ++i) big.push_back(static_cast<std::uint8_t>(i));
CHECK_STR(tvs(tv::bytes(big.data(), big.size(), 256)),
std::string("{\"t\":\"bytes\",\"n\":512,\"sha256\":\"") + kSha512 +
"\",\"head\":\"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\"}");
// exactly inline_max stays inline; inline_max+1 does not
CHECK(tvs(tv::bytes(big.data(), 256, 256)).find("\"hex\"") != std::string::npos);
CHECK(tvs(tv::bytes(big.data(), 257, 256)).find("\"head\"") != std::string::npos);
// bytes never carry a name even if one is set
CHECK_STR(tvs(tv::bytes("", 0, 256).named("x")),
"{\"t\":\"bytes\",\"n\":0,\"sha256\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"hex\":\"\"}");
std::vector<Tv> items;
items.push_back(tv::i32(1));
items.push_back(tv::str("a"));
CHECK_STR(tvs(tv::list(items)), "{\"t\":\"list\",\"v\":[{\"t\":\"i32\",\"v\":1},{\"t\":\"str\",\"v\":\"a\"}]}");
CHECK_STR(tvs(tv::set(items).named("s")), "{\"t\":\"set\",\"v\":[{\"t\":\"i32\",\"v\":1},{\"t\":\"str\",\"v\":\"a\"}],\"n\":\"s\"}");
CHECK_STR(tvs(tv::list({})), "{\"t\":\"list\",\"v\":[]}");
Tv st = tv::struct_();
st.add("id", tv::u32(7)).add("na\"me", tv::str("w7"));
CHECK_STR(tvs(st), "{\"t\":\"struct\",\"v\":{\"id\":{\"t\":\"u32\",\"v\":7},\"na\\\"me\":{\"t\":\"str\",\"v\":\"w7\"}}}");
CHECK_STR(tvs(tv::struct_()), "{\"t\":\"struct\",\"v\":{}}");
}
static void golden_records() {
Record r;
r.ts = 1;
r.hook = "X";
r.mode = Mode::Trace;
r.call_id = 900;
r.thread = 1;
Buf b;
emit_record(b, r);
// == mkfixture.invalid_lines()[0], the harness's own minimal valid record
CHECK_STR(b.str(), "{\"ts\":1,\"hook\":\"X\",\"mode\":\"trace\",\"call_id\":900,\"thread\":1,\"args\":[],\"ret\":null,\"side\":{}}\n");
Record c;
c.ts = 1;
c.hook = "X";
c.mode = Mode::Compare;
c.call_id = 4;
c.thread = 1;
c.depth = 0;
c.args.push_back(tv::i32(1).named("a"));
c.ret = tv::boolean(true);
SideEntry se;
se.name = "r";
se.before_kind = SideEntry::Value;
se.before = tv::null();
se.after = tv::i32(2);
c.side.push_back(se);
SideEntry sn;
sn.name = "q";
sn.before_kind = SideEntry::IsNull;
sn.after = tv::i32(0);
c.side.push_back(sn);
c.has_ours = true;
c.ours_ret = tv::boolean(false);
c.ours_side.push_back({"r", tv::i32(3)});
c.ours_side.push_back({"q", tv::i32(0)});
c.diverged = 1;
c.has_diff = true;
DiffEntry d;
d.path = "ret";
d.why = "exact";
d.orig = tv::boolean(true);
d.ours = tv::boolean(false);
c.diff.push_back(d);
DiffEntry d2;
d2.path = "side.r.after";
d2.why = "hash";
d2.first_diff_offset = 1;
d2.note = "x";
c.diff.push_back(d2);
DiffEntry d3;
d3.path = "side.x.after";
d3.why = "len";
d3.orig_raw = "3";
d3.ours_raw = "2";
c.diff.push_back(d3);
c.err = "e";
c.note = "n\xe9";
Buf cb;
emit_record(cb, c);
CHECK_STR(cb.str(),
"{\"ts\":1,\"hook\":\"X\",\"mode\":\"compare\",\"call_id\":4,\"thread\":1,\"depth\":0,"
"\"args\":[{\"t\":\"i32\",\"v\":1,\"n\":\"a\"}],\"ret\":{\"t\":\"bool\",\"v\":true},"
"\"side\":{\"r\":{\"before\":{\"t\":\"null\",\"v\":null},\"after\":{\"t\":\"i32\",\"v\":2}},"
"\"q\":{\"before\":null,\"after\":{\"t\":\"i32\",\"v\":0}}},"
"\"ours\":{\"ret\":{\"t\":\"bool\",\"v\":false},\"side\":{\"r\":{\"after\":{\"t\":\"i32\",\"v\":3}},"
"\"q\":{\"after\":{\"t\":\"i32\",\"v\":0}}}},\"diverged\":true,"
"\"diff\":[{\"path\":\"ret\",\"why\":\"exact\",\"orig\":{\"t\":\"bool\",\"v\":true},\"ours\":{\"t\":\"bool\",\"v\":false}},"
"{\"path\":\"side.r.after\",\"why\":\"hash\",\"orig\":null,\"ours\":null,\"first_diff_offset\":1,\"note\":\"x\"},"
"{\"path\":\"side.x.after\",\"why\":\"len\",\"orig\":3,\"ours\":2}],"
"\"err\":\"e\",\"note\":\"n\\u00e9\"}\n");
// replace record: no ours/diverged/diff; empty diff list still emits "diff":[] when flagged
Record p;
p.ts = 22;
p.hook = "Mars::ParseBlock";
p.mode = Mode::Replace;
p.call_id = 7;
p.thread = 3;
p.has_diff = true;
p.diverged = 0;
Buf pb;
emit_record(pb, p);
CHECK_STR(pb.str(), "{\"ts\":22,\"hook\":\"Mars::ParseBlock\",\"mode\":\"replace\",\"call_id\":7,\"thread\":3,\"args\":[],\"ret\":null,\"side\":{},\"diverged\":false,\"diff\":[]}\n");
Meta m;
m.build = "sots-engine test";
m.exe_sha256 = std::string(64, '0');
m.started = "2026-09-07T00:00:00Z";
m.inline_max = 256;
HookPolicy pa;
HookPolicy pb2;
pb2.ftol = 1e-6;
pb2.ftol_kind = "rel";
pb2.ptr_exact = true;
pb2.unordered = {"ret.v.items"};
m.hooks.emplace_back("Hook::A", pa);
m.hooks.emplace_back("Mars::ParseBlock", pb2);
Buf mb;
emit_meta(mb, m);
CHECK_STR(mb.str(),
"{\"meta\":{\"format\":1,\"build\":\"sots-engine test\",\"exe_sha256\":\"" + std::string(64, '0') +
"\",\"started\":\"2026-09-07T00:00:00Z\",\"inline_max\":256,\"hooks\":{"
"\"Hook::A\":{\"ftol\":0,\"ftol_kind\":\"abs\",\"ptr\":\"ignore\"},"
"\"Mars::ParseBlock\":{\"ftol\":9.9999999999999995e-07,\"ftol_kind\":\"rel\",\"ptr\":\"exact\",\"unordered\":[\"ret.v.items\"]}}}}\n");
Meta m0;
Buf m0b;
emit_meta(m0b, m0);
CHECK_STR(m0b.str(), "{\"meta\":{\"format\":1,\"build\":\"\",\"exe_sha256\":\"\",\"started\":\"\",\"inline_max\":256,\"hooks\":{}}}\n");
}
static void buf_growth() {
Buf b;
std::string big(100000, 'z');
b.put(big.data(), big.size());
b.printf("%s", big.c_str()); // > 128 chars: takes the grow path
CHECK_EQ(b.size(), static_cast<std::size_t>(200000));
CHECK(b.ok());
b.clear();
CHECK_EQ(b.size(), static_cast<std::size_t>(0));
// a 1 MiB bytes value inlines only when inline_max allows
std::vector<std::uint8_t> mb(1 << 20, 7);
Buf big1;
emit_tv(big1, tv::bytes(mb.data(), mb.size(), 1 << 20));
CHECK_EQ(big1.size(), static_cast<std::size_t>(2 * (1 << 20)) + std::string("{\"t\":\"bytes\",\"n\":1048576,\"sha256\":\"\",\"hex\":\"\"}").size() + 64);
CHECK(big1.ok());
}
// ---- oracle fixture: mirrored line for line in oracle_emit.py ----------------------------------
static std::vector<std::uint8_t> range_bytes(int n) {
std::vector<std::uint8_t> v;
for (int i = 0; i < n; ++i) v.push_back(static_cast<std::uint8_t>(i));
return v;
}
static void write_oracle_fixture(const std::string& path) {
Buf out;
Meta m;
m.build = "shim-trace test";
m.exe_sha256 = std::string(64, '0');
m.started = "2026-09-07T00:00:00Z";
m.inline_max = 256;
HookPolicy pa;
HookPolicy pb;
pb.ftol = 1e-6;
pb.ftol_kind = "rel";
pb.ptr_exact = true;
pb.unordered = {"ret.v.items"};
m.hooks.emplace_back("Hook::A", pa);
m.hooks.emplace_back("Mars::ParseBlock", pb);
emit_meta(out, m);
{ // rec0: str/i32/ptr args, bool ret, inline bytes region before/after
Record r;
r.ts = 1000; r.hook = "CfgVar_RegisterKey"; r.mode = Mode::Trace; r.call_id = 0; r.thread = 4120; r.depth = 0;
r.args.push_back(tv::str("R\xe9sum\xe9").named("key"));
r.args.push_back(tv::i32(-3).named("value"));
r.args.push_back(tv::ptr(static_cast<std::uintptr_t>(0x00a36fd0)).named("table"));
r.ret = tv::boolean(true);
auto fwd = range_bytes(16);
auto rev = fwd;
std::reverse(rev.begin(), rev.end());
SideEntry s;
s.name = "cfg_table"; s.before_kind = SideEntry::Value;
s.before = tv::bytes(fwd.data(), fwd.size(), 256);
s.after = tv::bytes(rev.data(), rev.size(), 256);
r.side.push_back(s);
emit_record(out, r);
}
{ // rec1: struct / list / set / enum / big ints / null / wstr
Record r;
r.ts = 1037; r.hook = "Manifest_Load"; r.mode = Mode::Trace; r.call_id = 1; r.thread = 4120;
r.args.push_back(tv::str("Weapons/_weapons.txt").named("path"));
r.ret = tv::i32(2).named("count");
Tv reg = tv::struct_();
std::vector<Tv> entries;
for (unsigned id : {7u, 9u}) {
Tv e = tv::struct_();
e.add("id", tv::u32(id));
e.add("name", tv::str(("w" + std::to_string(id) + ".weapon").c_str()));
entries.push_back(e);
}
reg.add("entries", tv::list(entries));
reg.add("deleted", tv::set({tv::u32(501), tv::u32(550), tv::u32(599)}));
reg.add("flags", tv::enum_(2, "MF_0"));
reg.add("big", tv::u64((1ull << 60) + 1));
reg.add("small", tv::u64(5));
reg.add("edge", tv::u64(1ull << 53));
reg.add("neg", tv::i64(-(1ll << 60)));
reg.add("nothing", tv::null());
const std::uint16_t w[] = {0x41, 0x20ac, 0x0000};
reg.add("w", tv::wstr(w, 3));
SideEntry s;
s.name = "registry";
s.after = reg;
r.side.push_back(s);
emit_record(out, r);
}
{ // rec2: every escape, json ret, f32/f64 lists, nan, before:null, hashed region
Record r;
r.ts = 1074; r.hook = "Mars::ParseBlock"; r.mode = Mode::Trace; r.call_id = 2; r.thread = 4124; r.depth = 1;
r.args.push_back(tv::str("Tab\tKey Quote\"d Back\\slash nl\nx cr\rx \x01\x7f\xff \x80").named("text"));
r.args.push_back(tv::u32(2).named("len"));
r.ret = tv::json("{\"weapon\":{\"count\":3,\"damage\":1.5,\"name\":\"Laser\\u00e9\",\"ok\":true,\"range\":2.25,\"tags\":[\"a\",\"b\"]}}");
SideEntry sc;
sc.name = "scratch";
sc.after = tv::list({tv::f32(1.5f), tv::f32(0.1f), tv::f32(-2.5e-7f), tv::f32(1e30f)});
r.side.push_back(sc);
SideEntry nb;
nb.name = "nanbox";
nb.after = tv::f32(std::numeric_limits<float>::quiet_NaN());
r.side.push_back(nb);
SideEntry inf;
inf.name = "infs";
inf.after = tv::list({tv::f64(std::numeric_limits<double>::infinity()), tv::f64(-std::numeric_limits<double>::infinity()),
tv::f64(0.1), tv::f64(1e300), tv::f64(0.0)});
r.side.push_back(inf);
auto big = range_bytes(256);
big.insert(big.end(), big.begin(), big.end());
SideEntry bl;
bl.name = "blob"; bl.before_kind = SideEntry::IsNull;
bl.after = tv::bytes(big.data(), big.size(), 256);
r.side.push_back(bl);
emit_record(out, r);
}
{ // rec3: compare, clean, small ints
Record r;
r.ts = 1111; r.hook = "CfgVar_RegisterKey"; r.mode = Mode::Compare; r.call_id = 3; r.thread = 4120;
r.args = {tv::i8(-128), tv::i16(32767), tv::u8(255), tv::u16(65535), tv::u64((1ull << 53) - 1)};
r.ret = tv::null();
r.has_ours = true;
r.ours_ret = tv::null();
r.diverged = 0;
r.has_diff = true;
emit_record(out, r);
}
{ // rec4: compare with divergences + note
Record r;
r.ts = 1148; r.hook = "Manifest_Load"; r.mode = Mode::Compare; r.call_id = 4; r.thread = 1;
r.ret = tv::i32(1);
SideEntry s;
s.name = "r";
s.after = tv::bytes("\x01\x02", 2, 256);
r.side.push_back(s);
r.has_ours = true;
r.ours_ret = tv::i32(2);
r.ours_side.push_back({"r", tv::bytes("\x01\x03", 2, 256)});
r.diverged = 1;
r.has_diff = true;
DiffEntry d1;
d1.path = "ret"; d1.why = "exact"; d1.orig = tv::i32(1); d1.ours = tv::i32(2);
r.diff.push_back(d1);
DiffEntry d2;
d2.path = "side.r.after"; d2.why = "hash"; d2.orig = tv::bytes("\x01\x02", 2, 256); d2.ours = tv::bytes("\x01\x03", 2, 256);
d2.first_diff_offset = 1;
r.diff.push_back(d2);
r.note = "hello";
emit_record(out, r);
}
{ // rec5: compare err (no ours)
Record r;
r.ts = 1185; r.hook = "X"; r.mode = Mode::Compare; r.call_id = 5; r.thread = 1;
r.diverged = 1;
r.has_diff = true;
DiffEntry d;
d.path = "call"; d.why = "err"; d.ours_raw = "\"ours threw\"";
r.diff.push_back(d);
r.err = "ours threw";
emit_record(out, r);
}
{ // rec6: replace
Record r;
r.ts = 1222; r.hook = "Y"; r.mode = Mode::Replace; r.call_id = 6; r.thread = 2;
r.args.push_back(tv::boolean(false).named("flag"));
r.ret = tv::ptr(static_cast<std::uintptr_t>(0));
emit_record(out, r);
}
CHECK(out.ok());
std::FILE* f = std::fopen(path.c_str(), "wb");
CHECK(f != nullptr);
if (f) {
std::fwrite(out.data(), 1, out.size(), f);
std::fclose(f);
}
}
int main(int argc, char** argv) {
golden_strings();
golden_numbers();
golden_bytes_and_containers();
golden_records();
buf_growth();
const std::string fixture = argc > 1 ? argv[1] : "emitter_oracle.jsonl";
write_oracle_fixture(fixture);
std::printf("oracle fixture: %s\n", fixture.c_str());
if (tracetest::harness_present()) {
// byte-exact against mkfixture.emit_record / emit_meta
const std::string script = argc > 2 ? argv[2] : "oracle_emit.py";
const int rc = tracetest::run_python(script, tracetest::harness_dir() + " " + fixture);
CHECK_EQ(rc, 0);
// and the harness reads it back as valid, with the injected divergence counted
const int tc = tracetest::run_tracecmp(fixture);
CHECK_EQ(tc, 1);
} else {
std::printf(" SKIP python oracle (harness absent)\n");
}
return tracetest::finish("shim_trace_emitter");
}

View file

@ -0,0 +1,213 @@
// End-to-end: the hook template in every mode over the self-test hook, the tracer writing a
// real JSONL file, and tracecmp.py judging it (exit 0 clean / 1 divergence / 2 invalid).
#include <cstdio>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include "check.h"
#include "harness.h"
#include "shim/trace/hook.h"
#include "shim/trace/selftest.h"
#include "shim/trace/tracer.h"
using namespace shim::trace;
using namespace shim::selftest;
static std::string slurp(const std::string& p) {
std::ifstream f(p, std::ios::binary);
std::stringstream ss;
ss << f.rdbuf();
return ss.str();
}
static std::vector<std::string> lines(const std::string& s) {
std::vector<std::string> out;
std::size_t start = 0;
while (start < s.size()) {
std::size_t nl = s.find('\n', start);
if (nl == std::string::npos) nl = s.size();
out.push_back(s.substr(start, nl - start));
start = nl + 1;
}
return out;
}
static bool has(const std::string& hay, const char* needle) { return hay.find(needle) != std::string::npos; }
using HFill = Hook<FillHook>;
using HWrong = Hook<FillWrongHook>;
using HThrows = Hook<FillThrowsHook>;
static void wire() {
HFill::original = &Fill;
HWrong::original = &Fill;
HThrows::original = &Fill;
}
static std::uint32_t reference(std::uint32_t n, std::uint32_t seed) {
std::vector<std::uint8_t> b(n);
return Fill(b.data(), n, seed);
}
// ---- without a tracer: modes still route correctly, nothing is written ----------------------------
static void modes_without_tracer() {
wire();
std::uint8_t buf[16];
const std::uint32_t want = reference(16, 1);
HFill::mode = Mode::Off;
CHECK_EQ(HFill::detour()(buf, 16, 1), want);
HFill::mode = Mode::Replace;
CHECK_EQ(HFill::detour()(buf, 16, 1), want); // ours agrees with the original
HWrong::mode = Mode::Replace;
std::uint8_t wb[16];
CHECK_EQ(HWrong::detour()(wb, 16, 1), want);
CHECK_EQ(static_cast<int>(wb[15]), (static_cast<int>(buf[15]) + 1) & 0xff); // replace ran ours (the wrong one)
HFill::mode = Mode::Trace;
CHECK_EQ(HFill::detour()(buf, 16, 1), want); // tracer closed: record dropped, result intact
HFill::mode = Mode::Compare;
CHECK_EQ(HFill::detour()(buf, 16, 1), want);
CHECK_EQ(Tracer::instance().records_written(), 0u);
}
// ---- clean log: trace + compare (identical ours) + replace ------------------------------------------
static void clean_log(const std::string& path) {
Config cfg;
cfg.path = path;
cfg.inline_max = 32;
cfg.apply("hook.Shim::SelfTest::Fill", "trace");
Tracer& tr = Tracer::instance();
tr.configure(cfg);
HFill::register_policy(tr);
HWrong::register_policy(tr);
CHECK(tr.open("shim_trace_test", std::string(64, 'a').c_str()));
HFill::configure(tr);
CHECK(HFill::mode == Mode::Trace);
std::uint8_t buf[64];
CHECK_EQ(HFill::detour()(buf, 16, 7), reference(16, 7)); // trace, inline region
CHECK_EQ(HFill::detour()(buf, 64, 8), reference(64, 8)); // trace, hashed region (64 > inline_max 32)
HFill::mode = Mode::Compare;
CHECK_EQ(HFill::detour()(buf, 16, 9), reference(16, 9)); // compare, clean
CHECK_EQ(HFill::detour()(buf, 64, 10), reference(64, 10));
CHECK_EQ(HFill::detour()(buf, 0, 11), reference(0, 11)); // empty region
HFill::mode = Mode::Replace;
CHECK_EQ(HFill::detour()(buf, 16, 12), reference(16, 12)); // replace: nothing emitted
// call ids are process-global and increase across threads; depth stays 0 per thread
std::thread t([&] {
std::uint8_t tb[8];
HFill::mode = Mode::Trace;
HFill::detour()(tb, 8, 13);
});
t.join();
tr.close();
CHECK_EQ(tr.records_written(), 6u);
const std::string text = slurp(path);
const auto ls = lines(text);
CHECK_EQ(ls.size(), static_cast<std::size_t>(7));
CHECK(has(ls[0], "{\"meta\":{\"format\":1,\"build\":\"shim_trace_test\",\"exe_sha256\":\"aaaa"));
CHECK(has(ls[0], "\"inline_max\":32,\"hooks\":{\"Shim::SelfTest::Fill\":{\"ftol\":0,\"ftol_kind\":\"abs\",\"ptr\":\"ignore\"},\"Shim::SelfTest::FillWrong\":{"));
CHECK(has(ls[1], "\"hook\":\"Shim::SelfTest::Fill\",\"mode\":\"trace\",\"call_id\":"));
CHECK(has(ls[1], "\"depth\":0,\"args\":[{\"t\":\"ptr\",\"v\":\"0x"));
CHECK(has(ls[1], "\"n\":\"buf\"},{\"t\":\"u32\",\"v\":16,\"n\":\"n\"},{\"t\":\"u32\",\"v\":7,\"n\":\"seed\"}],\"ret\":{\"t\":\"u32\",\"v\":"));
CHECK(has(ls[1], "\"side\":{\"buf\":{\"before\":{\"t\":\"bytes\",\"n\":16,\"sha256\":\""));
CHECK(has(ls[1], "\"hex\":\""));
CHECK(has(ls[2], "\"n\":64,\"sha256\":\""));
CHECK(has(ls[2], "\"head\":\""));
CHECK(!has(ls[2], "\"hex\":\""));
CHECK(has(ls[3], "\"mode\":\"compare\""));
CHECK(has(ls[3], "\"ours\":{\"ret\":{\"t\":\"u32\",\"v\":"));
CHECK(has(ls[3], "\"diverged\":false,\"diff\":[]}"));
CHECK(has(ls[5], "\"n\":0,\"sha256\":\"e3b0c442"));
CHECK(!has(text, "\"mode\":\"replace\""));
// the thread's record: different thread id, depth 0, a later call_id than the main thread's
CHECK(has(ls[6], "\"mode\":\"trace\""));
CHECK(has(ls[6], "\"v\":13,\"n\":\"seed\"}"));
// ASCII only, LF only
for (char c : text) CHECK(static_cast<unsigned char>(c) < 0x80 && c != '\r');
const int rc = tracetest::run_tracecmp(path);
if (rc != tracetest::kSkipped) CHECK_EQ(rc, 0);
}
// ---- divergent log: wrong ours (region differs), throwing ours (err) --------------------------------
static void bad_log(const std::string& path) {
Config cfg;
cfg.path = path;
cfg.default_mode = Mode::Compare;
Tracer& tr = Tracer::instance();
tr.configure(cfg);
CHECK(tr.open("shim_trace_test", ""));
HWrong::configure(tr);
HThrows::configure(tr);
HFill::configure(tr);
CHECK(HWrong::mode == Mode::Compare);
std::uint8_t buf[16];
CHECK_EQ(HWrong::detour()(buf, 16, 21), reference(16, 21)); // caller still gets the original's result
CHECK_EQ(static_cast<int>(buf[15]), static_cast<int>(buf[15])); // and the original's memory (ours wrote scratch)
std::uint8_t ref[16];
Fill(ref, 16, 21);
CHECK(std::memcmp(buf, ref, 16) == 0);
CHECK_EQ(HThrows::detour()(buf, 16, 22), reference(16, 22)); // a throwing ours is contained
CHECK_EQ(HFill::detour()(buf, 16, 23), reference(16, 23)); // and a clean call after it is still clean
tr.close();
const auto ls = lines(slurp(path));
CHECK_EQ(ls.size(), static_cast<std::size_t>(4));
CHECK(has(ls[1], "\"hook\":\"Shim::SelfTest::FillWrong\",\"mode\":\"compare\""));
CHECK(has(ls[1], "\"diverged\":true,\"diff\":[{\"path\":\"side.buf.after\",\"why\":\"hash\",\"orig\":{\"t\":\"bytes\",\"n\":16"));
CHECK(has(ls[1], "\"first_diff_offset\":15}]}"));
CHECK(has(ls[2], "\"hook\":\"Shim::SelfTest::FillThrows\",\"mode\":\"compare\""));
CHECK(!has(ls[2], ",\"ours\":{\"ret\"")); // err record: no ours block (the diff's "ours" key is the err text)
CHECK(has(ls[2], "\"diverged\":true,\"diff\":[{\"path\":\"call\",\"why\":\"err\",\"orig\":null,\"ours\":\"ours: selftest: deliberate throw\"}],\"err\":\"ours: selftest: deliberate throw\"}"));
CHECK(has(ls[3], "\"diverged\":false,\"diff\":[]}"));
const int rc = tracetest::run_tracecmp(path);
if (rc != tracetest::kSkipped) CHECK_EQ(rc, 1);
// --hook filter on the clean hook only -> clean
const int rc2 = tracetest::run_tracecmp(path, "--hook Shim::SelfTest::Fill");
if (rc2 != tracetest::kSkipped) CHECK_EQ(rc2, 0);
}
// ---- an unusable log (truncated last line) is exit 2 --------------------------------------------------
static void truncated_log(const std::string& src, const std::string& dst) {
std::string text = slurp(src);
text.resize(text.size() - 10);
std::FILE* f = std::fopen(dst.c_str(), "wb");
if (f) {
std::fwrite(text.data(), 1, text.size(), f);
std::fclose(f);
}
const int rc = tracetest::run_tracecmp(dst);
if (rc != tracetest::kSkipped) CHECK_EQ(rc, 2);
const int rc2 = tracetest::run_tracecmp(dst, "--skip-invalid");
if (rc2 != tracetest::kSkipped) CHECK_EQ(rc2, 0);
}
int main(int argc, char** argv) {
const std::string dir = argc > 1 ? argv[1] : ".";
modes_without_tracer();
clean_log(dir + "/hook_clean.jsonl");
bad_log(dir + "/hook_bad.jsonl");
truncated_log(dir + "/hook_clean.jsonl", dir + "/hook_truncated.jsonl");
// the packaged self-test entry point used by the shim at startup
Config cfg;
cfg.path = dir + "/selftest.jsonl";
Tracer::instance().configure(cfg);
CHECK(Tracer::instance().open("x", ""));
CHECK_EQ(run_once(Mode::Compare), reference(64, 0x5eed));
Tracer::instance().close();
const int rc = tracetest::run_tracecmp(dir + "/selftest.jsonl");
if (rc != tracetest::kSkipped) CHECK_EQ(rc, 0);
return tracetest::finish("shim_trace_hook");
}

View file

@ -0,0 +1,57 @@
#include "shim/trace/sha256.h"
#include <cstring>
#include <string>
#include "check.h"
using shim::Sha256;
static std::string hex_of(const std::string& s) {
char h[65];
Sha256::digest_hex(s.data(), s.size(), h);
return h;
}
int main() {
// FIPS 180-2 vectors + the harness's empty-string value (mkfixture test_float_and_bigint_forms).
CHECK_STR(hex_of(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
CHECK_STR(hex_of("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
CHECK_STR(hex_of("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
CHECK_STR(hex_of(std::string(1000000, 'a')), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
CHECK_STR(hex_of(std::string("\x00\xff", 2)), "06eb7d6a69ee19e5fbdf749018d3d2abfa04bcbd1365db312eb86dc7169389b8");
// Incremental updates across block boundaries equal the one-shot digest.
const std::string big(1000000, 'a');
Sha256 s;
std::size_t pos = 0;
const std::size_t steps[] = {1, 63, 64, 65, 1000, 4096, 55, 56, 57, 100000};
while (pos < big.size()) {
for (std::size_t st : steps) {
const std::size_t n = (pos + st > big.size()) ? big.size() - pos : st;
s.update(big.data() + pos, n);
pos += n;
if (pos >= big.size()) break;
}
}
std::uint8_t d[32];
s.finish(d);
char h[65];
Sha256::to_hex(d, h);
CHECK_STR(std::string(h), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
// Exactly 55 / 56 / 64 bytes (padding edge cases) agree with themselves chunked.
for (std::size_t n : {55u, 56u, 63u, 64u, 65u, 119u, 120u, 128u}) {
const std::string m(n, 'x');
Sha256 a;
a.update(m.data(), 20);
a.update(m.data() + 20, n - 20);
std::uint8_t da[32];
a.finish(da);
std::uint8_t db[32];
Sha256::digest(m.data(), n, db);
CHECK(std::memcmp(da, db, 32) == 0);
}
return tracetest::finish("shim_trace_sha256");
}