// sots-engine shim: a proxy binkw32.dll the original game loads. // // Every Bink export is forwarded to the real DLL by the linker (see binkw32.def); this file only // exists to get code running inside the game process. M0 scope: log a banner, install one hook on // Mars::Application::Initialize, and prove injection + ASLR relocation + hooking end to end. // // Binary facts (RVAs, calling conventions) come exclusively from the generated header. #include #include #include #include #include #include "MinHook.h" #include "generated/sots_addresses.h" #include "shim/fpu_force.h" #include "shim/hooks/dictionaries.h" #include "shim/hooks/colony_turn.h" #include "shim/hooks/system_output.h" #include "shim/hooks/player_turn.h" #include "shim/hooks/compute_budget.h" #include "shim/hooks/fleet_movement.h" #include "shim/hooks/global_consts.h" #include "shim/hooks/research.h" #include "shim/hooks/draw_sites.h" #include "shim/hooks/probe_entry.h" #include "shim/hooks/tail_rng.h" #include "shim/hooks/tech_effects.h" #include "shim/hooks/ai_orders.h" #include "shim/hooks/ai_rng.h" #include "shim/hooks/watchpoints.h" #include "shim/trace/hook.h" #include "shim/trace/selftest.h" #include "shim/trace/tracer.h" namespace { // ---- logging ------------------------------------------------------------------------------ FILE* g_log = nullptr; char g_dir[MAX_PATH] = {}; // directory the shim DLL lives in (== game dir) void Log(const char* fmt, ...) { if (!g_log) return; SYSTEMTIME st; GetLocalTime(&st); std::fprintf(g_log, "%02u:%02u:%02u.%03u [tid %5lu] ", st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, GetCurrentThreadId()); va_list ap; va_start(ap, fmt); std::vfprintf(g_log, fmt, ap); va_end(ap); std::fputc('\n', g_log); std::fflush(g_log); } // ---- config (shim.cfg next to the DLL; "key=value" per line) ------------------------------ // // `hooks=`, `hook.=`, `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 != 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[512]; while (std::fgets(line, sizeof line, f)) { char* p = line; while (*p == ' ' || *p == '\t') ++p; if (*p == '#' || *p == ';' || *p == '\n' || *p == '\0') continue; char* eq = std::strchr(p, '='); if (!eq) continue; *eq = '\0'; char* val = eq + 1; val[std::strcspn(val, "\r\n")] = '\0'; std::string err; std::size_t probe_n = 0; if (shim::hooks::probe_config(p, val, &probe_n)) { Log("config: %s=%s -> %u lane-H entry probes", p, val, static_cast(probe_n)); } else if (shim::hooks::ai_rng_config(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 if (shim::hooks::ai_orders_config(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 if (shim::hooks::watch_apply_config(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 if (shim::fpu::apply_config(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 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; } // ---- the one M0 hook ---------------------------------------------------------------------- bool g_minhookUp = false; } // namespace // The detour is a register-transparent asm stub rather than a C++ thiscall function: the // prototype in sots_addresses.h is [unverified], and a C++ detour that *calls* the original // bakes in assumptions about stack args / EDX / return value. Calling out for the log with // everything saved and then tail-jumping to the trampoline leaves the original's own `ret` // in charge of cleanup, so the hook is correct for any calling convention. (v1 of this hook // was a thiscall wrapper and crashed the game inside Initialize.) extern "C" void* g_origInitialize; // trampoline, filled in by MH_CreateHook void* g_origInitialize = nullptr; extern "C" void InitializeDetour(); extern "C" void LogInitializeCalled(void* self) { Log("Application::Initialize called (this=%p)", self); } asm(R"( .text .globl _InitializeDetour _InitializeDetour: pushfl pushal pushl %ecx call _LogInitializeCalled addl $4, %esp popal popfl jmp *_g_origInitialize )"); // Line logger handed to the template hooks (they live outside this TU's anonymous namespace). void ShimLogLine(const char* line) { Log("%s", line); } namespace { // ---- template hooks (verified cdecl prototypes; docs/shim-trace.md "Declaring a hook") ---- template void InstallTemplateHook(shim::trace::Tracer& tracer, uintptr_t exeBase, uint32_t rva) { using H = shim::trace::Hook; H::configure(tracer); void* target = reinterpret_cast(exeBase + rva); if (H::mode == shim::trace::Mode::Off) { Log("hook: %s rva=0x%08x mode=off (not installed)", D::name, rva); return; } MH_STATUS st = MH_CreateHook(target, reinterpret_cast(H::detour()), reinterpret_cast(&H::original)); Log("hook: %s rva=0x%08x -> va=%p MH_CreateHook -> %s (trampoline=%p)", D::name, rva, target, MH_StatusToString(st), reinterpret_cast(H::original)); if (st != MH_OK) return; st = MH_EnableHook(target); Log("hook: %s MH_EnableHook -> %s mode=%s", D::name, MH_StatusToString(st), shim::trace::mode_name(H::mode)); } using LoadFileHook = shim::trace::Hook; using WeaponInitHook = shim::trace::Hook; using SectionCtorHook = shim::trace::Hook; using ProcessResearchHook = shim::trace::Hook; using OnTechResearchedHook = shim::trace::Hook; using ComputeBudgetHook = shim::trace::Hook; using ColonyTurnHook = shim::trace::Hook; // Lane N: the population -> base-output term (docs/N-output-term.md). using GroupOutputHook = shim::trace::Hook; using TotalOutputHook = shim::trace::Hook; using PlayerTurnHook = shim::trace::Hook; using MoveFleetHook = shim::trace::Hook; using FleetMovementHook = shim::trace::Hook; // Lane Z: the per-turn RNG ledger (docs/Z-tail-rng.md). using AutosaveHook = shim::trace::Hook; using ServerTurnHook = shim::trace::Hook; using CombatDoneTailHook = shim::trace::Hook; using ApplyEncounterHook = shim::trace::Hook; using NodeDecayHook = shim::trace::Hook; using NodeSpaceHook = shim::trace::Hook; using AssignContactsHook = shim::trace::Hook; // Lane H: the caller of AssignContacts, where lane Y's 2-word detection residual lives. using ProcessTeamRecordHook = shim::trace::Hook; void InstallHooks(shim::trace::Tracer& tracer) { const uintptr_t exeBase = reinterpret_cast(GetModuleHandleA(nullptr)); void* target = reinterpret_cast(exeBase + sots::addr::Mars_Application_Initialize); Log("hook: Mars_Application_Initialize rva=0x%08x -> va=%p", sots::addr::Mars_Application_Initialize, target); MH_STATUS st = MH_Initialize(); Log("hook: MH_Initialize -> %s", MH_StatusToString(st)); if (st != MH_OK) return; g_minhookUp = true; st = MH_CreateHook(target, reinterpret_cast(&InitializeDetour), &g_origInitialize); Log("hook: MH_CreateHook -> %s (trampoline=%p)", MH_StatusToString(st), g_origInitialize); if (st != MH_OK) return; st = MH_EnableHook(target); Log("hook: MH_EnableHook -> %s", MH_StatusToString(st)); // M1: GlobalConsts::LoadFile (cdecl, verified) through the template. shim::hooks::init_global_consts(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::GlobalConsts_LoadFile); // M2: the id-manifest loaders (verified thiscall) through the template's Thiscall conv. shim::hooks::init_dictionaries(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::WeaponDictionary_Init); InstallTemplateHook(tracer, exeBase, sots::addr::SectionDictionary_ctor); // B3: the per-turn research pass (verified thiscall). One call per player per turn. shim::hooks::init_research(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::TechTree_ProcessResearch); // B2: the hard-coded tech-effect callback (verified thiscall, vft slot 4). Fires once // per tech completion, so on most turns not at all. shim::hooks::init_tech_effects(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_OnTechResearched); // B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare. shim::hooks::init_compute_budget(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_ComputeBudget); // B4: the per-system colony turn (once per system per turn) and the two movement entry // points. All three are verified thiscall prototypes with no stack-argument surprises. shim::hooks::init_colony_turn(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerSystem_ProcessTurn); // Lane N: the two side-effect-free output functions. GroupOutput is the population -> // output law itself; ComputeTotalOutput is the sum the budget roll-up ultimately reads. shim::hooks::init_system_output(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerSystem_GroupOutput); InstallTemplateHook(tracer, exeBase, sots::addr::ServerSystem_ComputeTotalOutput); // Lane T: the per-player turn driver (once per player per turn). Verified thiscall with // one ignored float argument; see docs/T-turn-driver.md. shim::hooks::init_player_turn(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_ProcessTurn); shim::hooks::init_fleet_movement(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_MoveFleet); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_ProcessFleetMovement); // Lane Z: the per-turn RNG ledger. Six nested hooks bracketing one End Turn between the two // autosaves; every one declares the strategic generator and nothing else. Installed BEFORE // the fpu module because both want StrategyServer::ProcessTurn and MinHook allows one hook // per target -- `fpu.sample_turn=off` is the config that hands it over cleanly, and if it is // left on the fpu sampler's MH_CreateHook is what fails and says so. shim::hooks::init_tail_rng(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyHost_Autosave); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_ProcessTurn); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_OnAllCombatDone_Tail); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_ApplyEncounterResult); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_NodeLineDecay); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_ProcessNodeSpaceTravel); // Lane I's one inlined-draw site inside ProcessTurn's closure. It leaves no call-graph edge, so // neither a sweep nor the entry-point detours below can see it; only a boundary hook can. InstallTemplateHook(tracer, exeBase, sots::addr::EncounterDetect_AssignContacts); // Lane H: its only caller. AssignContacts never ran on any measured turn because the gate // here was never satisfied, so the 2-word detection residual is above that gate and no // instrument has bracketed the function containing both. InstallTemplateHook(tracer, exeBase, sots::addr::EncounterDetect_ProcessTeamRecord); // Lane L1: the turn-begin driver and the three script-object writers that run inside a turn. // BeginProcessTurn is the interval lane Z's autosave bracket CONTAINED but never attributed -- // nothing had ever been hooked between the pre-turn autosave and ProcessTurn. Lane SV read the // swarm-queen registrar as taking one RNG_NextInt per new hive there, which if true qualifies // "the residual outside the two turn drivers is exactly zero". These five settle it live. InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_BeginProcessTurn); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_OnTurnBegin); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_RegisterHives); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_TickHives); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSlaversRefuel_UpdateDifficultyTier); // Lane L1, the AI seed probe: is `turn1-state -> turn2`'s nondeterminism a per-process SEED or // an ordering effect? Two launches, load only, and diff the ordered (rng, seed) sequences. // Both hooks are off in every config except shim.cfg.l1seed. InstallTemplateHook(tracer, exeBase, sots::addr::RNG_Seed); InstallTemplateHook(tracer, exeBase, sots::addr::StrategyApp_RunAI); // Per-call-site attribution: detour the SEVEN generator entry points and record // __builtin_return_address(0) with the word cost of each call. These are NOT template hooks -- // they write no record and take no snapshot, just two 4-byte reads of `left` -- because // NextFloat alone has 109 call sites and a record per draw would drown the log. The table is // emitted once per turn on the post-turn autosave, where it can be reconciled against the // bracket total the region ledger measured independently. shim::hooks::init_draw_sites(exeBase); { std::size_t nsites = 0; const shim::hooks::DrawSiteHook* sites = shim::hooks::draw_site_hooks(&nsites); for (std::size_t i = 0; i < nsites; ++i) { void* t = reinterpret_cast(exeBase + sites[i].rva); MH_STATUS s1 = MH_CreateHook(t, sites[i].detour, sites[i].trampoline); MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(t) : s1; Log("drawsite: %s rva=0x%08x -> va=%p create=%s enable=%s", sites[i].name, sites[i].rva, t, MH_StatusToString(s1), MH_StatusToString(s2)); } } // Lane H: register-transparent entry counters. Installed after the draw-site detours and // before the fpu module; they share no target with either. See probe_entry.h for why these are // asm stubs and not C++ detours. shim::hooks::init_probe_entries(exeBase, &ShimLogLine); shim::hooks::install_probe_entries(); // Lane W2: hardware data-write watchpoints. One MinHook detour (the arming point); the // watchpoints themselves modify no code at all. Off unless `watch=on` (rule 19). shim::hooks::install_watchpoints(exeBase, g_dir, &ShimLogLine); // Lane L4: the AI command-block dump plus its entry probes. Off unless `aiorders=on`, and // `aiorders=on` alone installs exactly ONE detour -- `aiprobes=` adds the rest, so the two // halves of the instrument can be given separate rule-19 controls. Its batch target // (ApplyTurnCommandBatch) is a different function from the watchpoint module's arming point // (ApplyAllTurnCommands, its only caller), so the two never contend for a MinHook target. shim::hooks::install_ai_orders(exeBase, g_dir, &ShimLogLine); // Lane PAR: the per-client RNG bracket on StrategyClient::OnResumePlaying. Off unless // `airng=on`. Installed AFTER the draw-site detours because it registers an observer on them // rather than hooking the seven entry points a second time (MinHook: one hook per target). // Its target is a client event handler, shared with nothing else in the shim. shim::hooks::install_ai_rng(exeBase, g_dir, &ShimLogLine); // Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler. // Installed last so it is nowhere near the template hooks it is meant to measure. shim::fpu::install(exeBase, &ShimLogLine); } // ---- lifecycle ----------------------------------------------------------------------------- void Shim_Init(HMODULE self) { GetModuleFileNameA(self, g_dir, sizeof g_dir); if (char* slash = std::strrchr(g_dir, '\\')) *slash = '\0'; char logPath[MAX_PATH]; std::snprintf(logPath, sizeof logPath, "%s\\shim.log", g_dir); g_log = std::fopen(logPath, "a"); if (!g_log) return; // nothing we can do; the forwarders still work without us char exePath[MAX_PATH] = {}; GetModuleFileNameA(nullptr, exePath, sizeof exePath); const uintptr_t exeBase = reinterpret_cast(GetModuleHandleA(nullptr)); Log("==== sots-engine shim (binkw32 proxy) build %s ====", SHIM_BUILD_ID); Log("exe: %s", exePath); Log("exe base=0x%08lx (link-time image base 0x%08lx, ASLR delta %+ld) pid=%lu shim=%p", static_cast(exeBase), static_cast(sots::addr::IMAGE_BASE), static_cast(exeBase - sots::addr::IMAGE_BASE), GetCurrentProcessId(), static_cast(self)); Log("addresses: %s", SOTS_ADDR_PROVENANCE); const Config cfg = ReadConfig(); if (!cfg.hooks) { // `hooks=off` turns off the TRACE hooks. It must not turn off the watchpoint module. // Those are two independent instruments: the watchpoints install one arming detour of // their own and write no trace records, and lane L3 wears them for a whole game played // forward, where the template hooks' 30-45 s per End Turn is pure cost and measures // nothing being asked about. Before this, `hooks=off watch=on` silently armed nothing -- // a config that reports "watch=on" in the banner and then measures a confident zero, // which is exactly the failure method rule 1 exists to catch. if (shim::hooks::watch_enabled()) { MH_STATUS st = MH_Initialize(); Log("hook: trace hooks disabled by config; MH_Initialize (watch only) -> %s", MH_StatusToString(st)); if (st == MH_OK) { g_minhookUp = true; shim::hooks::install_watchpoints(exeBase, g_dir, &ShimLogLine); } } else { 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::register_policy(tracer); LoadFileHook::register_policy(tracer); ComputeBudgetHook::register_policy(tracer); WeaponInitHook::register_policy(tracer); SectionCtorHook::register_policy(tracer); ProcessResearchHook::register_policy(tracer); OnTechResearchedHook::register_policy(tracer); ColonyTurnHook::register_policy(tracer); GroupOutputHook::register_policy(tracer); TotalOutputHook::register_policy(tracer); PlayerTurnHook::register_policy(tracer); MoveFleetHook::register_policy(tracer); FleetMovementHook::register_policy(tracer); AutosaveHook::register_policy(tracer); ServerTurnHook::register_policy(tracer); CombatDoneTailHook::register_policy(tracer); ApplyEncounterHook::register_policy(tracer); NodeDecayHook::register_policy(tracer); NodeSpaceHook::register_policy(tracer); AssignContactsHook::register_policy(tracer); ProcessTeamRecordHook::register_policy(tracer); // A hook that never stated what it does not check is a defect, not a detail: say so in // shim.log as well as in the trace's meta line (docs/harness-audit.md). for (const std::string& h : tracer.unstated_hooks()) Log("COVERAGE: hook %s registered with no coverage statement -- its compare results are " "not trustworthy", h.c_str()); 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(tracer); // 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; 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(tracer.records_written())); } } void Shim_Shutdown() { shim::hooks::watch_flush(&ShimLogLine); shim::hooks::ai_orders_flush(&ShimLogLine); shim::hooks::ai_rng_flush(&ShimLogLine); Log("%s", shim::fpu::summary().c_str()); shim::trace::Tracer& tracer = shim::trace::Tracer::instance(); if (tracer.is_open()) { Log("shutdown: trace records=%lu", static_cast(tracer.records_written())); tracer.close(); } if (g_minhookUp) { MH_STATUS st = MH_Uninitialize(); Log("shutdown: MH_Uninitialize -> %s", MH_StatusToString(st)); } Log("==== shim detach ===="); if (g_log) std::fclose(g_log); g_log = nullptr; } } // namespace extern "C" BOOL WINAPI DllMain(HINSTANCE hinst, DWORD reason, LPVOID) { switch (reason) { case DLL_PROCESS_ATTACH: DisableThreadLibraryCalls(hinst); Shim_Init(hinst); break; case DLL_PROCESS_DETACH: Shim_Shutdown(); break; } return TRUE; }