lane H: entry-probe module, ProcessTeamRecord hook, probes= config, and the outcome of the five predictions

This commit is contained in:
alex 2026-09-08 13:38:59 -04:00
parent a01305fcf2
commit a26cd183f1
18 changed files with 1027 additions and 4 deletions

View file

@ -100,11 +100,15 @@ if(WIN32)
src/shim/hooks/fleet_movement.cpp src/shim/hooks/fleet_movement.cpp
src/shim/hooks/player_turn.cpp src/shim/hooks/player_turn.cpp
src/shim/hooks/tail_rng.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 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 sots_game_effects mars_rng shim_budget shim_techfx
shim_colony shim_movement shim_events 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) 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) add_library(binkw32 SHARED src/shim/main.cpp src/shim/fpu_force.cpp src/shim/binkw32.def)

View file

@ -271,3 +271,45 @@ count, only a call count — which is deliberate, because the word counts alread
independent instruments that agree. independent instruments that agree.
A clean run here is a statement about RNG accounting and coverage, and about nothing else. 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.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). // 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). // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once #pragma once
#include <cstdint> #include <cstdint>
@ -1361,6 +1361,12 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0; constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::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<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified] // thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::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<int,void*> 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; 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] // 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; 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] // 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]

View file

@ -0,0 +1,204 @@
#include "shim/hooks/probe_entry.h"
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#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<std::size_t>(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<void*>(&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<std::size_t>(n) < kProbeCount
? static_cast<std::size_t>(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<unsigned>(g_install_count), static_cast<unsigned>(kProbeCount));
for (std::size_t i = 0; i < g_install_count; ++i) {
void* target = reinterpret_cast<void*>(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

View file

@ -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 <index>;
// 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 <cstddef>
#include <cstdint>
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

View file

@ -13,6 +13,7 @@
#include "generated/sots_addresses.h" #include "generated/sots_addresses.h"
#include "mars/rng/mt19937.h" #include "mars/rng/mt19937.h"
#include "shim/hooks/draw_sites.h" #include "shim/hooks/draw_sites.h"
#include "shim/hooks/probe_entry.h"
#include "shim/hooks/rng_ledger.h" #include "shim/hooks/rng_ledger.h"
namespace shim::hooks { namespace shim::hooks {
@ -403,6 +404,7 @@ void StrategyHostAutosaveHook::describe_args(std::vector<Tv>& out, void* self, v
// independently; a shortfall is an unattributed word, which is the whole point of collecting it. // independently; a shortfall is an unattributed word, which is the whole point of collecting it.
if (end_turn) { if (end_turn) {
draw_sites_reset(); draw_sites_reset();
probe_entries_reset();
return; return;
} }
DrawSiteRow rows[64]; DrawSiteRow rows[64];
@ -424,6 +426,26 @@ void StrategyHostAutosaveHook::describe_args(std::vector<Tv>& 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_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_other_calls()).named("draw_site_calls_other_rng"));
out.push_back(tv::u32(draw_sites_overflow()).named("draw_site_overflow")); 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<Tv> 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); } 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"); "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<const char*>(ptr_at(rec, kRecEntriesFirst));
const char* last = static_cast<const char*>(ptr_at(rec, kRecEntriesLast));
if (!first || !last || last < first) return st;
const std::size_t span = static_cast<std::size_t>(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<std::int32_t>(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<const char*>(ptr_at(e, 0));
if (!readable(obj, kObjFc + 4)) continue;
const std::uint8_t fb = peek<std::uint8_t>(obj, kObjFb);
const std::uint8_t fc = peek<std::uint8_t>(obj, kObjFc);
const std::uint32_t fcw = peek<std::uint32_t>(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<Tv>& 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<Tv> 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<trace::Region>& 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<EncounterDetectProcessTeamRecordHook>;
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 ------------------------------------------------ // ---- Game::StrategyServer::ProcessNodeSpaceTravel ------------------------------------------------
void ProcessNodeSpaceTravelHook::describe_args(std::vector<Tv>& out, void* self) { void ProcessNodeSpaceTravelHook::describe_args(std::vector<Tv>& out, void* self) {

View file

@ -168,6 +168,37 @@ struct EncounterDetectAssignContactsHook {
static void coverage(trace::Coverage& c); 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<void*, void*>; // ctx {S, TechDef*, TechDef*}, TeamRecord* (0x74 B)
static void describe_args(std::vector<trace::Tv>& out, void* self, void* rec);
static void regions(std::vector<trace::Region>& 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) // `ProcessNodeSpaceTravel` 0x007a0e20, run TWICE a turn (ProcessTurn phase 7 and tail phase 10)
// and never swept for draws by any lane. // and never swept for draws by any lane.
struct ProcessNodeSpaceTravelHook { struct ProcessNodeSpaceTravelHook {

View file

@ -25,6 +25,7 @@
#include "shim/hooks/global_consts.h" #include "shim/hooks/global_consts.h"
#include "shim/hooks/research.h" #include "shim/hooks/research.h"
#include "shim/hooks/draw_sites.h" #include "shim/hooks/draw_sites.h"
#include "shim/hooks/probe_entry.h"
#include "shim/hooks/tail_rng.h" #include "shim/hooks/tail_rng.h"
#include "shim/hooks/tech_effects.h" #include "shim/hooks/tech_effects.h"
#include "shim/trace/hook.h" #include "shim/trace/hook.h"
@ -84,7 +85,11 @@ Config ReadConfig() {
char* val = eq + 1; char* val = eq + 1;
val[std::strcspn(val, "\r\n")] = '\0'; val[std::strcspn(val, "\r\n")] = '\0';
std::string err; 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<unsigned>(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()); if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val); else Log("config: %s=%s", p, val);
} else if (cfg.trace.apply(p, val, &err)) { } else if (cfg.trace.apply(p, val, &err)) {
@ -180,6 +185,8 @@ using ApplyEncounterHook = shim::trace::Hook<shim::hooks::ApplyEncounterResultHo
using NodeDecayHook = shim::trace::Hook<shim::hooks::NodeLineDecayHook>; using NodeDecayHook = shim::trace::Hook<shim::hooks::NodeLineDecayHook>;
using NodeSpaceHook = shim::trace::Hook<shim::hooks::ProcessNodeSpaceTravelHook>; using NodeSpaceHook = shim::trace::Hook<shim::hooks::ProcessNodeSpaceTravelHook>;
using AssignContactsHook = shim::trace::Hook<shim::hooks::EncounterDetectAssignContactsHook>; using AssignContactsHook = shim::trace::Hook<shim::hooks::EncounterDetectAssignContactsHook>;
// Lane H: the caller of AssignContacts, where lane Y's 2-word detection residual lives.
using ProcessTeamRecordHook = shim::trace::Hook<shim::hooks::EncounterDetectProcessTeamRecordHook>;
void InstallHooks(shim::trace::Tracer& tracer) { void InstallHooks(shim::trace::Tracer& tracer) {
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr)); const uintptr_t exeBase = reinterpret_cast<uintptr_t>(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 // 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. // neither a sweep nor the entry-point detours below can see it; only a boundary hook can.
InstallTemplateHook<shim::hooks::EncounterDetectAssignContactsHook>(tracer, exeBase, sots::addr::EncounterDetect_AssignContacts); InstallTemplateHook<shim::hooks::EncounterDetectAssignContactsHook>(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<shim::hooks::EncounterDetectProcessTeamRecordHook>(tracer, exeBase, sots::addr::EncounterDetect_ProcessTeamRecord);
// Per-call-site attribution: detour the SEVEN generator entry points and record // 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 -- // __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. // 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. // Installed last so it is nowhere near the template hooks it is meant to measure.
shim::fpu::install(exeBase, &ShimLogLine); shim::fpu::install(exeBase, &ShimLogLine);
@ -331,6 +348,7 @@ void Shim_Init(HMODULE self) {
NodeDecayHook::register_policy(tracer); NodeDecayHook::register_policy(tracer);
NodeSpaceHook::register_policy(tracer); NodeSpaceHook::register_policy(tracer);
AssignContactsHook::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 // 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). // shim.log as well as in the trace's meta line (docs/harness-audit.md).
for (const std::string& h : tracer.unstated_hooks()) for (const std::string& h : tracer.unstated_hooks())

3
src/shim/shim.cfg.hoff Normal file
View file

@ -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

52
src/shim/shim.cfg.hp0 Normal file
View file

@ -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

52
src/shim/shim.cfg.hp10 Normal file
View file

@ -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

52
src/shim/shim.cfg.hp11 Normal file
View file

@ -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

52
src/shim/shim.cfg.hp4 Normal file
View file

@ -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

52
src/shim/shim.cfg.hp8 Normal file
View file

@ -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

52
src/shim/shim.cfg.hp9 Normal file
View file

@ -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

52
src/shim/shim.cfg.hpall Normal file
View file

@ -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

49
src/shim/shim.cfg.hprobes Normal file
View file

@ -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

View file

@ -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