// Per-call-site attribution of every strategic RNG word, by return address. // // The boundary ledger (rng_ledger.h + tail_rng.h) answers "how many words did this turn spend, and // in which phase". It cannot answer "which call site spent them", because it deliberately never // looks at calls. This does the other half: it detours the generator's **entry points** and records // `__builtin_return_address(0)` — the instruction after the game's own `call` — together with the // word cost of that one call, taken from `left` before and after. // // WHY THIS IS ATTRIBUTION AND NOT DISCOVERY. Lane I closed the search space // (`sots-re/findings/control-flow/inlined-draws.md`): **seven** entry points, and eleven game // functions carrying inlined draws over 28 sites, with recall proved by a brute byte scan finding // zero orphans. Of those, `StrategyServer::ProcessTurn`'s direct-call closure contains exactly 22 // draw sites — 21 calls to an entry point plus one inlined site. Twenty-one of the twenty-two are // therefore visible here by construction; the twenty-second (`EncounterDetect_AssignContacts` // 0x007aa240) is covered by its own boundary hook in tail_rng.h, and the one inlined site under // the combat resolver is covered by the `ApplyEncounterResult` boundary hook. // // **The check that makes this a fact rather than a model:** the per-site words must sum to the // bracket total the boundary ledger measured independently. A shortfall is an unattributed word, // and an unattributed word is evidence about the one thing lane I could not settle — indirect-call // reachability. // // COST. Two 4-byte reads per draw, no hashing, no allocation, no record written. The table is a // small fixed array scanned linearly; a turn touches a handful of entries. // // THE ONE ASSUMPTION, stated because it is the only place this can be wrong: a single call is taken // to cross at most one block boundary, so `left_after > left_before` means exactly one twist. That // holds for every entry point whose cost is bounded (all but two) and for a rejection loop unless // it spends 624+ words in one call, which would be a 1-in-2^624 event for `NextInt`. `GaussianRange` // is unbounded in attempts and is the one place the assumption could genuinely break; it is // reachable from no turn driver by a direct call, and if it ever fires the boundary ledger's total // will disagree with the site sum and say so. #pragma once #include #include namespace shim::hooks { // The seven entry points, in the order lane I lists them. enum class DrawEntry : std::uint8_t { NextFloat, // 0x0047d830 thiscall(&mt) 1 word NextInt, // 0x004271c0 thiscall(&mt, uint* bound) 1 + rejections Chance, // 0x008e6dd0 thiscall(obj, float p) 0 or 1 NextUInt, // 0x004f7670 thiscall(obj) 1 FloatRange, // 0x0047d8a0 thiscall(obj, float, float) 1 IntRangeBell, // 0x008e6d80 cdecl(obj, int, int) >= 2 GaussianRange, // 0x008e6e30 cdecl(obj, int, int, int) 2 per attempt, unbounded Count }; const char* draw_entry_name(DrawEntry e); struct DrawSiteRow { std::uint32_t ret_rva = 0; // the game instruction after the call DrawEntry entry = DrawEntry::Count; std::uint32_t calls = 0; std::uint32_t words = 0; std::uint32_t zero_calls = 0; // calls that consumed nothing (only Chance's two early-outs) // Whether the draw came from the STRATEGIC generator at StrategyServer+0x16c. The first run of // this instrument recorded 44 words against a bracket of 18 because it counted every generator // in the process; a site that draws from both appears as two rows. bool strategic = false; }; // What main.cpp needs to install the detours with MinHook. struct DrawSiteHook { const char* name; std::uint32_t rva; void* detour; void** trampoline; }; const DrawSiteHook* draw_site_hooks(std::size_t* count); // Call once before installing: the exe base, so return addresses are recorded as RVAs. void init_draw_sites(std::uintptr_t exe_base); // Told by the boundary hooks whenever they resolve StrategyServer+0x16c, so each recorded draw can // say whether it came from the strategic generator or from another instance (the StrategyClient's // at +0x134, the tactical CombatSim's at +0x108, or a map-generation temporary). Without this the // per-site total cannot be reconciled against the bracket, because the bracket watches one object // and the detours see them all. void draw_sites_set_generator(const void* strategic_rng); // Accumulator control. `reset` is called at the pre-turn autosave and `snapshot` at the post-turn // one, so a snapshot covers exactly the interval the boundary ledger's bracket covers. void draw_sites_reset(); std::size_t draw_sites_snapshot(DrawSiteRow* out, std::size_t max); std::uint32_t draw_sites_total_words(); // strategic generator only std::uint32_t draw_sites_total_calls(); // strategic generator only std::uint32_t draw_sites_other_words(); // every other generator, summed std::uint32_t draw_sites_other_calls(); // Sites seen since reset that did not fit in the table (a non-zero value invalidates the sum). std::uint32_t draw_sites_overflow(); } // namespace shim::hooks