sots-engine/src/shim/hooks/draw_sites.h
alex 2a9b97dcee Z: per-call-site draw ledger -- seven entry points, keyed by return address
The boundary ledger says which phase spends a turn's words; this says which
call site. Each entry point is detoured with its verified prototype and records
__builtin_return_address(0) plus the word cost from left before/after -- two
4-byte reads, no record per draw, since NextFloat alone has 109 call sites.

Each draw is tagged with WHICH generator it came from. That is not a detail:
the first build counted every Mars::RNG instance in the process and reported 44
words against a bracket of 18. The StrategyClient's generator at +0x134 draws 8
times a turn and must not be in the strategic total.

Also hooks EncounterDetect::AssignContacts, lane I's one inlined-draw site in
ProcessTurn's closure -- invisible to both a call-graph sweep and to the entry-
point detours, so only a boundary hook can see it. It did not fire on this
workload, which is consistent with the site sums reconciling exactly.
2026-09-08 10:42:05 -04:00

96 lines
5.2 KiB
C++

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