diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f925b7..29e8f91 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,11 +100,15 @@ if(WIN32) src/shim/hooks/fleet_movement.cpp src/shim/hooks/player_turn.cpp src/shim/hooks/tail_rng.cpp - src/shim/hooks/draw_sites.cpp) + src/shim/hooks/draw_sites.cpp + src/shim/hooks/probe_entry.cpp) + # `minhook` is here for its include directory: lane H's probe_entry.cpp installs its own + # detours (MH_CreateHook/MH_EnableHook) rather than handing descriptors back to main.cpp, + # because a register-transparent asm stub has no C++ prototype for the template to take. target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim sots_game_effects mars_rng shim_budget shim_techfx shim_colony shim_movement shim_events - shim_player_turn shim_rng_ledger) + shim_player_turn shim_rng_ledger minhook) target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror) add_library(binkw32 SHARED src/shim/main.cpp src/shim/fpu_force.cpp src/shim/binkw32.def) diff --git a/docs/H-probes.md b/docs/H-probes.md index 0bd4eb9..1577ceb 100644 --- a/docs/H-probes.md +++ b/docs/H-probes.md @@ -271,3 +271,45 @@ count, only a call count — which is deliberate, because the word counts alread independent instruments that agree. A clean run here is a statement about RNG accounting and coverage, and about nothing else. + +--- + +# Outcome — written after the runs + +Everything above this line was committed before the shim was built. This section is the adjudication; +the full report with the evidence is `sots-re/findings/control-flow/tail-probes.md`. + +| prediction | verdict | +|---|---| +| **P1** oracle reproduces | **held.** `bb4fd9ac89f41e3b…` and `978041acd168b56e…`, both byte-exact | +| **P2a** four outer callees entered once per End Turn | **held.** 1 per turn, 3 turns, 2 saves, every row | +| **P2b** none of the eight draw sites fires on the corpus | **held.** No draw-site row at any of the eight return addresses; tail cost 0 | +| **P2c** the three inner callees entered 0 times | **held.** The gate is inside each outer body, above the inner call | +| **P2b-alt** the fleet loop makes slot 13 draw on a fleet-rich save | **falsified.** 23 fleets, slot 13 entered, 0x00820ca0 entered zero times — the gate is the per-fleet test chain | +| **P3a** `ProcessTeamRecord` called once per record | **vacuous.** It was called **zero** times; the record vector is empty | +| **P3b** the 2 detection words are inside `ProcessTeamRecord` | **falsified**, exactly as its falsification clause anticipated. They are in 0x007d5150's subtree, one call from `DetectEncounters` | +| **P3c** the 2 words are not the `AssignContacts` family | **held**, and for a stronger reason than predicted: not a failed gate, an empty vector | +| **P4** `CreateRaidEncounter` entered ~2/turn, drawing 0 | **held.** 2 entries on one turn, 1 on the next, 0 words on all of them — candidate list empty | +| **P5** 7 calls / 7 words per site on a Zuul save, 14 not 16 | **held exactly**, on two consecutive turns | + +## The thing no prediction covered + +**The instrument changed the game.** Three End Turns under the full lane-H build produced 19/18/19 +words where lane Z recorded 19/18/20, from a *byte-identical* input save, and an autosave 4 bytes +different from the one lane Z's runs produced twice. Six single-End-Turn runs from `z2-endturn.sav` +bisected it to one address: a MinHook detour on `ServerTradeManager_GenerateTradeRaidEncounters` +0x00893290. `hooks=off`, the template hooks alone, and the first eight probes all reproduce the +un-instrumented result; adding the ninth does not. + +Two consequences for this repo: + +1. `probes=` was added to `shim.cfg` so the probe set is switchable and bisectable. Every number in + the report was re-taken at `probes=8`, the largest configuration proved byte-identical to the + un-instrumented game. +2. **Lane Z's ledger is behaviour-neutral and its published numbers stand** — that had never been + checked, and it came out the right way. + +The mechanism is undetermined. The five bytes MinHook patches at 0x00893290 land on a clean +instruction boundary (`push ebp; mov ebp,esp; push -1`) with no branch target inside them, so a +mangled prologue is not the explanation. The suppressed draw is `RollResearchEvent`'s at 0x0088df4f, +which runs *earlier in the turn* than the hooked function — which is the part to chase. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index b776e8f..3322600 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 58e3d85, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 52581ef, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1361,6 +1361,12 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980; constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0; // thiscall void** (std::map* this, const int* key) // 125 B, ret 4. MSVC std::map::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified] constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0; +// thiscall int (void* this, int, int) // the ONE callee through which Game::ServerSpyManager vftable 0x00a3073c slot 13 (0x008877b0) reaches the strategic generator. Exactly ONE incoming reference in the image: an unconditional call at 0x00887af2 inside that slot. Real body 0x008408e0..0x00840a59, 370 bytes. Contains three of lane V2's eight draw sites -- Chance 0x00840929, NextInt 0x008409c7, Chance 0x00840a3c -- all loading the generator as [reg+0x16c]. Named for the verified relationship (which slot reaches it, and that it draws), NOT for any semantics: its body was not read. Lane H hooks it with a register-transparent entry counter so that 'the spy path never fired' can be split into 'slot 13 was not entered' and 'slot 13 was entered and gated above this call' [mapped] +constexpr uint32_t SpyManager_Slot13RngCallee = 0x004408e0; +// thiscall uint (void* fleet, char) // first of the two callees through which Game::ServerTradeManagerImpl vftable 0x00a31b74 slot 13 (0x0088ef80) reaches the strategic generator. Exactly ONE incoming reference: an unconditional call at 0x0088f036. Real body 0x00820ca0..0x00820e53, 436 bytes, containing lane V2's NextFloat site 0x00820e18. Its own body loops the vector at arg+0xa4/+0xa8 testing per-element flags, and its boolean result is what gates the call to TradeManager_Slot13RngCalleeB one instruction later. THE CALLER'S LOOP IS OVER THE FLEETS VECTOR at GetServer()+0x64/+0x68, not over a trade-route list -- which is why 'the tail draws nothing because we have no trade routes' does not explain this slot [mapped] +constexpr uint32_t TradeManager_Slot13RngCalleeA = 0x00420ca0; +// thiscall void (void* this, int) // second of the two callees through which Game::ServerTradeManagerImpl slot 13 (0x0088ef80) reaches the strategic generator, called at 0x0088f042 ONLY when TradeManager_Slot13RngCalleeA returned non-zero. Exactly ONE incoming reference. Real body 0x0088b440..0x0088b976, 1323 bytes, containing lane V2's NextInt site 0x0088b613 (`mov ecx,[ecx+0x16c]; add ecx,4`). The two form a two-stage gate: A draws first and decides whether B runs, so a run where A fires and B does not is a MEASUREMENT, not a gap [mapped] +constexpr uint32_t TradeManager_Slot13RngCalleeB = 0x0048b440; // thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified] constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670; // thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified] diff --git a/src/shim/hooks/probe_entry.cpp b/src/shim/hooks/probe_entry.cpp new file mode 100644 index 0000000..123955e --- /dev/null +++ b/src/shim/hooks/probe_entry.cpp @@ -0,0 +1,204 @@ +#include "shim/hooks/probe_entry.h" + +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include + +#include "MinHook.h" +#endif + +#include "generated/sots_addresses.h" + +// ---- the counters ------------------------------------------------------------------------------ +// +// Plain 32-bit counters, incremented from a stub that has already saved the flags, so no atomic is +// needed for correctness of the arithmetic on x86 and none is used: the turn pipeline is +// single-threaded (lane Z §7) and a torn read here would cost a count, not a crash. If a probe ever +// reads a wild number, that is itself the finding -- it would mean the address is entered off the +// turn thread. + +namespace { +constexpr std::size_t kMaxProbes = 16; +std::uint32_t g_calls[kMaxProbes]; +std::uint32_t g_total[kMaxProbes]; +} // namespace + +extern "C" void ProbeHit(int index) { + if (index < 0 || static_cast(index) >= kMaxProbes) return; + ++g_calls[index]; + ++g_total[index]; +} + +// ---- one trampoline slot and one asm stub per probe --------------------------------------------- +// +// The stub is written out per index rather than generated at runtime because a runtime-generated +// thunk would need an executable allocation and a relocation, and MinHook already owns that +// problem. Sixteen slots is the cap; `kProbes` below is shorter and the rest are inert. + +#if defined(_WIN32) +#define PROBE_STUB(i) \ + extern "C" void* g_tr_probe##i; \ + void* g_tr_probe##i = nullptr; \ + extern "C" void ProbeStub##i(void); \ + asm(".text\n" \ + ".globl _ProbeStub" #i "\n" \ + "_ProbeStub" #i ":\n" \ + " pushfl\n" \ + " pushal\n" \ + " pushl $" #i "\n" \ + " call _ProbeHit\n" \ + " addl $4, %esp\n" \ + " popal\n" \ + " popfl\n" \ + " jmp *_g_tr_probe" #i "\n") + +PROBE_STUB(0); +PROBE_STUB(1); +PROBE_STUB(2); +PROBE_STUB(3); +PROBE_STUB(4); +PROBE_STUB(5); +PROBE_STUB(6); +PROBE_STUB(7); +PROBE_STUB(8); +PROBE_STUB(9); +PROBE_STUB(10); +PROBE_STUB(11); +#undef PROBE_STUB +#endif + +namespace shim::hooks { + +namespace { + +namespace A = sots::addr; + +struct ProbeDef { + const char* name; + std::uint32_t rva; + void* stub; + void** trampoline; +}; + +std::uintptr_t g_exe_base = 0; +void (*g_log)(const char*) = nullptr; +bool g_installed[kMaxProbes]; + +void logf(const char* fmt, ...) { + if (!g_log) return; + char line[512]; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(line, sizeof line, fmt, ap); + va_end(ap); + g_log(line); +} + +#if defined(_WIN32) +#define PROBE(name, rva, i) ProbeDef{name, rva, reinterpret_cast(&ProbeStub##i), &g_tr_probe##i} +#else +#define PROBE(name, rva, i) ProbeDef{name, rva, nullptr, nullptr} +#endif + +// The probe set. Order is the report order and the stub index, so it is fixed. +// +// Rows 0-3 are the four phase-23/33 callees lane V2 proved can reach the generator; rows 4-6 are +// the three inner functions those bodies reach the draw *through*, so a row-0..3 hit with no +// row-4..6 hit localises the gate to the outer body. Rows 7-8 are the trade-raid pair (P4). Rows +// 9-11 are CONTROLS, and they are the reason a zero in rows 0-6 can be believed: 9 and 10 are two +// phase-23/33 callees lane V2 measured as draw-free, and 11 is `EncounterDetect_Run`, which every +// prior lane has observed running once per turn. If the controls read 0 the instrument is broken, +// not the game (method rule 1 -- a hook that compares nothing prints a clean verdict). +const ProbeDef kProbes[] = { + PROBE("Game::ServerSpyManager::vslot13", A::ServerSpyManager_vslot13, 0), + PROBE("Game::ServerSpyManager::vslot14", A::ServerSpyManager_vslot14, 1), + PROBE("Game::ServerTradeManagerImpl::vslot13", A::ServerTradeManagerImpl_vslot13, 2), + PROBE("Game::ServerTradeManagerImpl::vslot15", A::ServerTradeManagerImpl_vslot15, 3), + PROBE("Game::SpyManager::Slot13RngCallee", A::SpyManager_Slot13RngCallee, 4), + PROBE("Game::TradeManager::Slot13RngCalleeA", A::TradeManager_Slot13RngCalleeA, 5), + PROBE("Game::TradeManager::Slot13RngCalleeB", A::TradeManager_Slot13RngCalleeB, 6), + PROBE("Game::ServerTradeManager::CreateRaidEncounter", A::ServerTradeManager_CreateRaidEncounter, 7), + PROBE("Game::ServerTradeManager::GenerateTradeRaidEncounters", + A::ServerTradeManager_GenerateTradeRaidEncounters, 8), + PROBE("Game::ServerSpyManager::vslot15 [control]", A::ServerSpyManager_vslot15, 9), + PROBE("Game::ServerTradeManagerImpl::vslot14 [control]", A::ServerTradeManagerImpl_vslot14, 10), + PROBE("Game::EncounterDetect::Run [control]", A::EncounterDetect_Run, 11), +}; +#undef PROBE + +constexpr std::size_t kProbeCount = sizeof kProbes / sizeof kProbes[0]; +static_assert(kProbeCount <= kMaxProbes, "add more PROBE_STUB() slots"); + +} // namespace + +namespace { +std::size_t g_install_count = kProbeCount; // `probes=` overrides; default is all of them +} // namespace + +bool probe_config(const char* key, const char* value, std::size_t* count_out) { + if (std::strcmp(key, "probes") != 0) return false; + if (std::strcmp(value, "off") == 0 || std::strcmp(value, "none") == 0) { + g_install_count = 0; + } else if (std::strcmp(value, "all") == 0 || std::strcmp(value, "on") == 0) { + g_install_count = kProbeCount; + } else { + char* end = nullptr; + const long n = std::strtol(value, &end, 10); + if (end == value || n < 0) return true; // ours, but unparseable: leave the default + g_install_count = static_cast(n) < kProbeCount + ? static_cast(n) + : kProbeCount; + } + if (count_out) *count_out = g_install_count; + return true; +} + +void init_probe_entries(std::uintptr_t exe_base, void (*log_line)(const char* line)) { + g_exe_base = exe_base; + g_log = log_line; +} + +void install_probe_entries() { +#if defined(_WIN32) + logf("probe: installing %u of %u (probes= in shim.cfg)", + static_cast(g_install_count), static_cast(kProbeCount)); + for (std::size_t i = 0; i < g_install_count; ++i) { + void* target = reinterpret_cast(g_exe_base + kProbes[i].rva); + MH_STATUS s1 = MH_CreateHook(target, kProbes[i].stub, kProbes[i].trampoline); + MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1; + g_installed[i] = (s2 == MH_OK); + logf("probe: %s rva=0x%08x -> va=%p create=%s enable=%s", kProbes[i].name, kProbes[i].rva, + target, MH_StatusToString(s1), MH_StatusToString(s2)); + if (!g_installed[i]) + logf("COVERAGE: probe %s NOT INSTALLED -- its call count is meaningless, not zero", + kProbes[i].name); + } +#else + for (std::size_t i = 0; i < kProbeCount; ++i) g_installed[i] = false; +#endif +} + +void probe_entries_reset() { + for (std::size_t i = 0; i < kProbeCount; ++i) g_calls[i] = 0; +} + +std::size_t probe_entries_count() { return kProbeCount; } + +std::size_t probe_entries_snapshot(ProbeEntryRow* out, std::size_t max) { + const std::size_t n = kProbeCount < max ? kProbeCount : max; + for (std::size_t i = 0; i < n; ++i) { + out[i].name = kProbes[i].name; + out[i].rva = kProbes[i].rva; + out[i].calls = g_calls[i]; + out[i].total = g_total[i]; + out[i].installed = g_installed[i]; + } + return n; +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/probe_entry.h b/src/shim/hooks/probe_entry.h new file mode 100644 index 0000000..cb4c4db --- /dev/null +++ b/src/shim/hooks/probe_entry.h @@ -0,0 +1,69 @@ +// Lane H -- entry counters for functions whose *reachability* is the open question. +// +// WHY THIS IS NOT A TEMPLATE HOOK, AND NOT A DRAW-SITE DETOUR. +// +// Lane V2 found four of the eleven phase-23/33 vtable targets on `StrategyServer+0x158`/`+0x15c` +// reach a draw on the strategic generator, and lane Z measured 0 tail words over eight turns. Those +// two facts leave a question a word count cannot answer: were the callees **not reached**, or +// **reached and gated**? The consequences are opposite. "Not reached" means the phase is +// conditional and a reimplementation may skip it; "reached and gated" means the phase always runs +// and the gate is a data condition our saves never satisfy -- which is a latent divergence exactly +// like the node-line decay lane K found. +// +// The instrument therefore has to answer "did control enter this address", for functions whose +// prototypes are **not verified**. Lane V2 read the dispatch sites, not the bodies: it recorded +// `void (this)` for the vtable slots from the pushes at the call site, and nothing at all for the +// three inner functions. Declaring a C++ detour with a guessed calling convention and then calling +// the trampoline is how a shim corrupts a stack -- and the campaign has already paid for that once +// (`main.cpp`: v1 of the Initialize hook was a thiscall wrapper and crashed the game). +// +// So each probe is a **register-transparent asm stub**: `pushfl; pushal; push ; +// call ProbeHit; add esp,4; popal; popfl; jmp *trampoline`. It saves and restores every register +// and the flags, touches nothing but its own counter, and tail-jumps to MinHook's trampoline so +// the ORIGINAL's own `ret` does the stack cleanup. It cannot be wrong about a calling convention +// because it never assumes one, it cannot perturb arguments, and it works for `ret 0`, `ret 4`, +// `cdecl`, `thiscall` and `fastcall` alike. +// +// WHAT IT CANNOT DO, stated because it is the price of that safety: a tail-jumping stub never +// regains control, so it cannot read the generator after the call and **cannot report a word +// count** -- only a call count. That is deliberate and costs nothing here, because the word counts +// already come from two independent instruments that agree (the boundary ledger and the +// return-address detours in `draw_sites.cpp`). A probe entry row and a draw-site row are meant to +// be read together: `calls > 0` with no matching draw-site row is precisely "reached and gated". +#pragma once + +#include +#include + +namespace shim::hooks { + +struct ProbeEntryRow { + const char* name = nullptr; + std::uint32_t rva = 0; + std::uint32_t calls = 0; // entries since the last reset + std::uint32_t total = 0; // entries since process start (never reset) + bool installed = false; // false => MinHook refused; `calls == 0` means NOTHING for this row +}; + +// `log_line` is used once per probe at install time. Nothing logs per call: some of these run +// inside the turn pipeline and a line per entry would be a different experiment. +void init_probe_entries(std::uintptr_t exe_base, void (*log_line)(const char* line)); + +// Installs the probes. Safe to call once, after MH_Initialize. +void install_probe_entries(); + +// Config, from `probes=` in shim.cfg. Returns false if `key` is not ours. +// +// WHY THIS KEY EXISTS, and it is not convenience. Lane H measured a real perturbation: with these +// probes installed, one End Turn from a byte-identical input save produced an autosave 4 bytes +// different from the one the un-instrumented game produces, and spent 19 generator words where the +// game spends 20. An instrument that changes the thing it measures has to be switchable, or the +// question "which of the twelve does it" cannot be asked. `probes=off` installs none; `probes=N` +// installs the first N in the fixed table order, which makes the set bisectable in one build. +bool probe_config(const char* key, const char* value, std::size_t* count_out); + +void probe_entries_reset(); +std::size_t probe_entries_snapshot(ProbeEntryRow* out, std::size_t max); +std::size_t probe_entries_count(); + +} // namespace shim::hooks diff --git a/src/shim/hooks/tail_rng.cpp b/src/shim/hooks/tail_rng.cpp index 0343370..5264035 100644 --- a/src/shim/hooks/tail_rng.cpp +++ b/src/shim/hooks/tail_rng.cpp @@ -13,6 +13,7 @@ #include "generated/sots_addresses.h" #include "mars/rng/mt19937.h" #include "shim/hooks/draw_sites.h" +#include "shim/hooks/probe_entry.h" #include "shim/hooks/rng_ledger.h" namespace shim::hooks { @@ -403,6 +404,7 @@ void StrategyHostAutosaveHook::describe_args(std::vector& out, void* self, v // independently; a shortfall is an unattributed word, which is the whole point of collecting it. if (end_turn) { draw_sites_reset(); + probe_entries_reset(); return; } DrawSiteRow rows[64]; @@ -424,6 +426,26 @@ void StrategyHostAutosaveHook::describe_args(std::vector& out, void* self, v out.push_back(tv::u32(draw_sites_other_words()).named("draw_site_words_other_rng")); out.push_back(tv::u32(draw_sites_other_calls()).named("draw_site_calls_other_rng")); out.push_back(tv::u32(draw_sites_overflow()).named("draw_site_overflow")); + + // Lane H: entry counts for addresses whose reachability -- not their cost -- is the question. + // Emitted on the same marker as the draw sites so the two are read together: a probe row with + // `calls > 0` and no draw-site row for the sites inside it is "reached and gated", which is + // exactly what eight turns of `tail words = 0` could not distinguish from "not reached". + // `installed` is carried on every row because a MinHook failure would otherwise present as a + // confident zero. + ProbeEntryRow probes[16]; + const std::size_t np = probe_entries_snapshot(probes, sizeof probes / sizeof probes[0]); + std::vector plist; + for (std::size_t i = 0; i < np; ++i) { + Tv one = tv::struct_(); + one.add("name", tv::str(probes[i].name)); + one.add("rva", tv::u32(probes[i].rva)); + one.add("calls", tv::u32(probes[i].calls)); + one.add("calls_since_launch", tv::u32(probes[i].total)); + one.add("installed", tv::boolean(probes[i].installed)); + plist.push_back(std::move(one)); + } + out.push_back(tv::list(std::move(plist)).named("probe_entries")); } Tv StrategyHostAutosaveHook::describe_ret(void* r) { return tv::ptr(r); } @@ -779,6 +801,166 @@ void EncounterDetectAssignContactsHook::coverage(trace::Coverage& c) { "arg:max_trials is the upper bound only"); } +// ---- Game::EncounterDetect::ProcessTeamRecord (lane H) ------------------------------------------- + +namespace { + +// The record's entry vector. Lane I: the gate walks `rec->(+0x28 .. +0x2c)` with stride 0x44 and +// tests `entry[0]->+0xfc`. Both bounds are read here rather than assumed, and an implausible span +// is reported as unknown rather than iterated. +constexpr std::size_t kRecEntriesFirst = 0x28; +constexpr std::size_t kRecEntriesLast = 0x2c; +constexpr std::size_t kRecEntryStride = 0x44; +constexpr std::size_t kObjFb = 0xfb; +constexpr std::size_t kObjFc = 0xfc; +constexpr std::size_t kMaxRecEntries = 4096; +constexpr std::size_t kMaxEntryDetail = 12; + +struct TeamRecordStats { + std::int32_t entries = -1; + std::int32_t gate = -1; // 1 if any entry object has +0xfc != 0 + std::int32_t contacts = -1; // +0xfc != 0 + std::int32_t detectors = -1; // +0xfc == 0 && +0xfb == 0 + std::int32_t neither = -1; // +0xfc == 0 && +0xfb != 0 -- counted, not silently dropped + std::int32_t bound = -1; // contacts * detectors, lane I's AssignContacts worst case + std::int32_t fc_wide_disagrees = -1; // see the coverage note: byte vs dword at +0xfc +}; + +// Per-entry detail, so the classification is checkable instead of asserted. Lane I's rules read +// `+0xfc` and `+0xfb` as bytes; if `+0xfc` is really a dword whose low byte happens to be zero, +// every count above is wrong in the same direction and nothing else would show it. So the dword is +// read too and disagreements are counted. +struct EntryDetail { + std::uint8_t fb = 0; + std::uint8_t fc = 0; + std::uint32_t fc_wide = 0; +}; + +TeamRecordStats team_record_stats(void* rec, EntryDetail* detail, std::size_t* ndetail) { + TeamRecordStats st; + if (ndetail) *ndetail = 0; + if (!readable(rec, kRecEntriesLast + 4)) return st; + const char* first = static_cast(ptr_at(rec, kRecEntriesFirst)); + const char* last = static_cast(ptr_at(rec, kRecEntriesLast)); + if (!first || !last || last < first) return st; + const std::size_t span = static_cast(last - first); + if (span % kRecEntryStride != 0) return st; + const std::size_t n = span / kRecEntryStride; + if (n > kMaxRecEntries || !readable(first, span)) return st; + + st.entries = static_cast(n); + st.gate = st.contacts = st.detectors = st.neither = st.fc_wide_disagrees = 0; + for (std::size_t i = 0; i < n; ++i) { + const char* e = first + i * kRecEntryStride; + const char* obj = static_cast(ptr_at(e, 0)); + if (!readable(obj, kObjFc + 4)) continue; + const std::uint8_t fb = peek(obj, kObjFb); + const std::uint8_t fc = peek(obj, kObjFc); + const std::uint32_t fcw = peek(obj, kObjFc); + if ((fc != 0) != (fcw != 0)) ++st.fc_wide_disagrees; + if (fc != 0) { + ++st.contacts; + st.gate = 1; + } else if (fb == 0) { + ++st.detectors; + } else { + ++st.neither; + } + if (detail && ndetail && *ndetail < kMaxEntryDetail) { + detail[*ndetail].fb = fb; + detail[*ndetail].fc = fc; + detail[*ndetail].fc_wide = fcw; + ++*ndetail; + } + } + if (st.contacts > 0 && st.detectors > 0) st.bound = st.contacts * st.detectors; + return st; +} + +struct TeamRecordState { + RngEntry entry; +}; +TeamRecordState g_team_rec; + +} // namespace + +void EncounterDetectProcessTeamRecordHook::describe_args(std::vector& out, void* self, + void* rec) { + void* server = readable(self, 4) ? ptr_at(self, 0) : nullptr; + g_team_rec.entry = observe_entry(server); + + EntryDetail detail[kMaxEntryDetail]; + std::size_t ndetail = 0; + const TeamRecordStats st = team_record_stats(rec, detail, &ndetail); + + out.push_back(tv::ptr(self).named("ctx")); + out.push_back(tv::ptr(server).named("server")); + out.push_back(tv::ptr(rec).named("record")); + out.push_back(tv::i32(st.entries).named("entries")); + // `gate` is the predicate lane I read at 0x007ca671, recomputed here. `false` predicts that + // AssignContacts will NOT be called on this record -- a prediction the AssignContacts hook + // either confirms by staying silent or falsifies by firing. + out.push_back((st.gate < 0 ? tv::null() : tv::boolean(st.gate != 0)).named("gate")); + out.push_back(tv::i32(st.contacts).named("pred_contacts")); + out.push_back(tv::i32(st.detectors).named("pred_detectors")); + out.push_back(tv::i32(st.neither).named("pred_neither")); + out.push_back(tv::i32(st.bound).named("pred_max_trials")); + out.push_back(tv::i32(st.fc_wide_disagrees).named("fc_byte_vs_dword_disagreements")); + + std::vector ents; + for (std::size_t i = 0; i < ndetail; ++i) { + Tv one = tv::struct_(); + one.add("fb", tv::u32(detail[i].fb)); + one.add("fc", tv::u32(detail[i].fc)); + one.add("fc_dword", tv::u32(detail[i].fc_wide)); + ents.push_back(std::move(one)); + } + out.push_back(tv::list(std::move(ents)).named("entry_flags")); + push_rng_args(out, g_team_rec.entry); +} + +void EncounterDetectProcessTeamRecordHook::regions(std::vector& out, void*, void*) { + push_rng_region(out, g_team_rec.entry.rng); +} + +EncounterDetectProcessTeamRecordHook::Args EncounterDetectProcessTeamRecordHook::rebind( + trace::Scratch&, void* self, void* rec) { + return Args(self, rec); +} + +void EncounterDetectProcessTeamRecordHook::ours(void* self, void* rec) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("EncounterDetect::ProcessTeamRecord"); + if (H::original) H::original(self, rec); + } +} + +void EncounterDetectProcessTeamRecordHook::coverage(trace::Coverage& c) { + tail_rng_common_coverage(c); + c.unmodelled("the whole body of ProcessTeamRecord: the gate call, the two vector builds and " + "the bucket construction", + trace::Risk::Medium, + "this hook measures the word cost of the call and recomputes three integers the " + "original derives from the same record. It models none of the work and asserts " + "nothing about the contact assignment", + "region:rng; arg:gate/pred_contacts/pred_detectors are a prediction, not a check"); + c.unmodelled("`+0xfc` and `+0xfb` are read as BYTES, following lane I's reading of the " + "classifier functions", + trace::Risk::Medium, + "if either is really a wider field, every count here is wrong in the same " + "direction and no cross-check inside this hook would notice. So the dword at " + "+0xfc is read as well and any row where the two disagree is COUNTED, not " + "silently resolved -- a non-zero disagreement count means the byte reading is " + "unsafe on this workload", + "arg:fc_byte_vs_dword_disagreements; arg:entry_flags carries the raw values"); + c.unmodelled("an entry whose object pointer is unreadable is skipped", + trace::Risk::Low, + "it is not counted into any of the three classes, so entries != contacts + " + "detectors + neither is the signal that this happened", + "arg:entries against the three class counts"); +} + // ---- Game::StrategyServer::ProcessNodeSpaceTravel ------------------------------------------------ void ProcessNodeSpaceTravelHook::describe_args(std::vector& out, void* self) { diff --git a/src/shim/hooks/tail_rng.h b/src/shim/hooks/tail_rng.h index e10cd9c..8d3dba3 100644 --- a/src/shim/hooks/tail_rng.h +++ b/src/shim/hooks/tail_rng.h @@ -168,6 +168,37 @@ struct EncounterDetectAssignContactsHook { static void coverage(trace::Coverage& c); }; +// ---- lane H: the caller of AssignContacts, which is where the residual actually is ------------ +// +// `EncounterDetect::ProcessTeamRecord` 0x007ca640, `ret 4`, the ONLY caller of `AssignContacts` +// and the only depth-2 function under `StrategyServer::DetectEncounters`. Lane Y's standalone +// leaves a 2-word residual per turn on both calibrated pairs, attributed by lane Z's site table to +// 0x0050329d (`NextFloat`, depth 4) and 0x007929a4 (`NextInt`, depth 3) -- both below this +// function. Lane Z also found `AssignContacts` **never ran**, because the gate here was never +// satisfied. So the residual is not the `|contacts| x |detectors|` family that lane I bounded; it +// is something above that gate, and no instrument has ever bracketed the function that contains +// both. +// +// This hook computes lane I's three quantities from the record AT ENTRY -- the gate predicate, the +// contact count and the detector count -- and reports them beside the measured word cost. When the +// gate is false they are a prediction of a call that will not happen (and the hook says so); when +// it is true they become checkable against the numbers `AssignContactsHook` reads from the callee's +// own arguments. Two hooks, one function apart, printing the same two integers derived two +// different ways. +struct EncounterDetectProcessTeamRecordHook { + static constexpr const char* name = "Game::EncounterDetect::ProcessTeamRecord"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; // ctx {S, TechDef*, TechDef*}, TeamRecord* (0x74 B) + + static void describe_args(std::vector& out, void* self, void* rec); + static void regions(std::vector& out, void* self, void* rec); + static Args rebind(trace::Scratch& s, void* self, void* rec); + static void ours(void* self, void* rec); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + // `ProcessNodeSpaceTravel` 0x007a0e20, run TWICE a turn (ProcessTurn phase 7 and tail phase 10) // and never swept for draws by any lane. struct ProcessNodeSpaceTravelHook { diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 617a525..3a65cc0 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -25,6 +25,7 @@ #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/trace/hook.h" @@ -84,7 +85,11 @@ Config ReadConfig() { char* val = eq + 1; val[std::strcspn(val, "\r\n")] = '\0'; std::string err; - if (shim::fpu::apply_config(p, val, &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::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)) { @@ -180,6 +185,8 @@ using ApplyEncounterHook = 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)); @@ -252,6 +259,10 @@ void InstallHooks(shim::trace::Tracer& tracer) { // 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); // 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 -- @@ -272,6 +283,12 @@ void InstallHooks(shim::trace::Tracer& tracer) { } } + // 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 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); @@ -331,6 +348,7 @@ void Shim_Init(HMODULE self) { 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()) diff --git a/src/shim/shim.cfg.hoff b/src/shim/shim.cfg.hoff new file mode 100644 index 0000000..a780155 --- /dev/null +++ b/src/shim/shim.cfg.hoff @@ -0,0 +1,3 @@ +# Lane H: the control run. hooks=off installs NOTHING (not even the M0 asm stub), so this +# is the game with a proxy DLL that only forwards Bink exports. +hooks=off diff --git a/src/shim/shim.cfg.hp0 b/src/shim/shim.cfg.hp0 new file mode 100644 index 0000000..572e8a3 --- /dev/null +++ b/src/shim/shim.cfg.hp0 @@ -0,0 +1,52 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=0 diff --git a/src/shim/shim.cfg.hp10 b/src/shim/shim.cfg.hp10 new file mode 100644 index 0000000..578f870 --- /dev/null +++ b/src/shim/shim.cfg.hp10 @@ -0,0 +1,52 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=10 diff --git a/src/shim/shim.cfg.hp11 b/src/shim/shim.cfg.hp11 new file mode 100644 index 0000000..d2d408e --- /dev/null +++ b/src/shim/shim.cfg.hp11 @@ -0,0 +1,52 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=11 diff --git a/src/shim/shim.cfg.hp4 b/src/shim/shim.cfg.hp4 new file mode 100644 index 0000000..91d2b97 --- /dev/null +++ b/src/shim/shim.cfg.hp4 @@ -0,0 +1,52 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=4 diff --git a/src/shim/shim.cfg.hp8 b/src/shim/shim.cfg.hp8 new file mode 100644 index 0000000..2464697 --- /dev/null +++ b/src/shim/shim.cfg.hp8 @@ -0,0 +1,52 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=8 diff --git a/src/shim/shim.cfg.hp9 b/src/shim/shim.cfg.hp9 new file mode 100644 index 0000000..1826c73 --- /dev/null +++ b/src/shim/shim.cfg.hp9 @@ -0,0 +1,52 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=9 diff --git a/src/shim/shim.cfg.hpall b/src/shim/shim.cfg.hpall new file mode 100644 index 0000000..94b2967 --- /dev/null +++ b/src/shim/shim.cfg.hpall @@ -0,0 +1,52 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=all diff --git a/src/shim/shim.cfg.hprobes b/src/shim/shim.cfg.hprobes new file mode 100644 index 0000000..3670a3a --- /dev/null +++ b/src/shim/shim.cfg.hprobes @@ -0,0 +1,49 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always diff --git a/src/shim/shim.cfg.hprobes2 b/src/shim/shim.cfg.hprobes2 new file mode 100644 index 0000000..d2c7013 --- /dev/null +++ b/src/shim/shim.cfg.hprobes2 @@ -0,0 +1,51 @@ +# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's ledger plus two additions: +# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's +# 2-word-per-turn detection residual has to be (both residual sites are below it, and +# AssignContacts itself never ran on any measured turn); +# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks +# and are therefore NOT configurable from here. They install unconditionally whenever +# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe +# that failed to install carries installed=false into the record, because a MinHook failure +# would otherwise present as a confident zero. +# +# Everything else is off so the log holds only the ledger: eight records per End Turn instead of +# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the +# difference is the words the strategic generator consumed inside that call. +# +# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at +# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that +# address for the ledger. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off + +hook.Game::StrategyHost::Autosave=trace +hook.Game::StrategyServer::ProcessTurn=trace +hook.Game::StrategyServer::OnAllCombatDone_Tail=trace +hook.Game::StrategyServer::ApplyEncounterResult=trace +hook.Game::StrategyServer::NodeLineDecay=trace +hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace +hook.Game::EncounterDetect::AssignContacts=trace +hook.Game::EncounterDetect::ProcessTeamRecord=trace + +fpu.sample_turn=off +fpu.sample_ticks=off + +# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make +# each record ~5 kB of state nobody reads. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always