#include "shim/hooks/tail_rng.h" #include #include #include #include #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #include #endif #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 { using trace::Tv; namespace tv = trace::tv; namespace { namespace A = sots::addr; constexpr std::size_t kRngSize = A::RNG_size; constexpr int kMtWords = mars::rng::MT19937::N; // The `S` frame. Every StrategyServer_off_* in the generated header is an S+4 offset except // StrategyServer_off_RNG, which is already S-relative. Adding 4 is therefore correct for every // use below EXCEPT the generator, and each site says which it is using. constexpr std::size_t kSFrame = 4; // Game::NodePath, stride 0x30, in the vector at ServerNodeGraph+0x8/+0xc. Field offsets and the // expiry formula are an instruction read of NodePath::RemainingLife 0x006e2130 (whole 122-byte // body) plus the loop at 0x007ae07a..0x007ae0af. constexpr std::size_t kServerOffNodeGraph = kSFrame + A::StrategyServer_off_NodeGraph; // S+0x154 constexpr std::size_t kGraphOffPaths = A::ServerNodeGraph_off_Paths; constexpr std::size_t kNodePathStride = 0x30; constexpr std::size_t kNpType = 0x04; // npt -- 0 means the line never expires constexpr std::size_t kNpCreated = 0x14; // npctm -- creation turn constexpr std::size_t kNpLife = 0x1c; // npdtn -- lifetime budget; INT_MAX means immortal constexpr std::size_t kNpTrafficDiv = 0x20; // npdtf constexpr std::size_t kNpTraffic = 0x24; // nptf // A sane bound on the node-line count; a wilder number means we are reading the wrong object. constexpr std::size_t kMaxNodePaths = 65536; struct Env { std::uintptr_t exe_base = 0; void (*log_line)(const char*) = nullptr; }; Env g_env; void logf(const char* fmt, ...) { if (!g_env.log_line) return; char line[512]; va_list ap; va_start(ap, fmt); std::vsnprintf(line, sizeof line, fmt, ap); va_end(ap); g_env.log_line(line); } bool readable(const void* p, std::size_t n) { if (!p) return false; if (n == 0) return true; #if defined(_WIN32) const char* c = static_cast(p); const char* const end = c + n; while (c < end) { MEMORY_BASIC_INFORMATION mbi; if (!VirtualQuery(c, &mbi, sizeof mbi)) return false; if (mbi.State != MEM_COMMIT) return false; if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false; const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; if (!(mbi.Protect & ok)) return false; c = static_cast(mbi.BaseAddress) + mbi.RegionSize; } return true; #else return true; #endif } template T peek(const void* base, std::size_t off) { T v{}; std::memcpy(&v, static_cast(base) + off, sizeof v); return v; } void* ptr_at(const void* base, std::size_t off) { return peek(base, off); } // ---- the generator --------------------------------------------------------------------------- // The last StrategyServer any of these hooks saw, so the Autosave markers -- whose `this` is a // StrategyHost, not the server -- can reach the generator. Deliberately a cache rather than a // derivation: `StrategyHost::Autosave`'s `this` is the global at 0x00b29f98 and it has never // been proved to be the same object whose +0x54 holds the StrategyServer. The Autosave hook // records BOTH the cached pointer and the +0x54 candidate so a run says whether they agree. void* g_server = nullptr; void* rng_of_server(void* self) { if (!readable(self, A::StrategyServer_off_RNG + 4)) return nullptr; void* r = ptr_at(self, A::StrategyServer_off_RNG); // S frame already; no +4 return readable(r, kRngSize) ? r : nullptr; } std::int32_t turn_of(void* self) { if (!readable(self, kSFrame + A::StrategyServer_off_ModCount + 4)) return -1; return peek(self, kSFrame + A::StrategyServer_off_ModCount); } std::int32_t phase_counter_of(void* self) { if (!readable(self, kSFrame + A::StrategyServer_off_PhaseCounter + 4)) return -1; return peek(self, kSFrame + A::StrategyServer_off_PhaseCounter); } std::int32_t encounter_count_of(void* self) { const std::size_t off = kSFrame + A::StrategyServer_off_TeamRecords; // S+0x1e8 if (!readable(self, off + 8)) return -1; const char* first = static_cast(ptr_at(self, off)); const char* last = static_cast(ptr_at(self, off + 4)); if (!first || !last || last < first) return -1; return static_cast((last - first) / 0x74); } std::int32_t player_count_of(void* self) { const std::size_t off = kSFrame + A::StrategyServer_off_Players; // S+0x54 if (!readable(self, off + 8)) return -1; const char* first = static_cast(ptr_at(self, off)); const char* last = static_cast(ptr_at(self, off + 4)); if (!first || !last || last < first) return -1; return static_cast((last - first) / 4); } // The record every hook here emits as arguments: where the generator is when the call starts. // Observing HERE, at entry, is what makes the nested `before` snapshots resolvable -- Hook<> // renders them after the original returns, by which time the ledger's frontier has moved on and // walking backwards is impossible. See rng_ledger.h. struct RngEntry { void* rng = nullptr; RngPos pos; bool have = false; }; RngEntry observe_entry(void* self) { RngEntry e; e.rng = rng_of_server(self); if (!e.rng) return e; e.pos = RngLedger::instance().observe_object(e.rng, kRngSize); e.have = true; // Tell the per-call-site detours which generator is the strategic one, so their rows can be // split. Without it they count every RNG instance in the process and cannot reconcile. draw_sites_set_generator(e.rng); return e; } void push_rng_args(std::vector& out, const RngEntry& e) { out.push_back(tv::ptr(e.rng).named("rng")); out.push_back((e.have && e.pos.known ? tv::i64(e.pos.words) : tv::null()).named("rng_words_in")); out.push_back((e.have ? tv::i32(e.pos.left) : tv::null()).named("rng_left_in")); } void push_server_args(std::vector& out, void* self) { out.push_back(tv::i32(turn_of(self)).named("turn")); // S+0xc ModCount out.push_back(tv::i32(phase_counter_of(self)).named("phase_counter")); // S+0x8, lane K §7.1 out.push_back(tv::i32(player_count_of(self)).named("players")); out.push_back(tv::i32(encounter_count_of(self)).named("encounters")); } // The region describe. Called once on the `before` snapshot and once on the `after` snapshot, // both after the original returns; `words` is the ledger position, so the difference between // the two is exactly the number of words this call consumed. Tv describe_rng(const void* p, std::size_t size, unsigned) { const RngPos pos = RngLedger::instance().observe_object(p, size); Tv s = tv::struct_(); s.add("left", tv::i32(pos.left)); s.add("index", tv::i32(RngLedger::kWordsPerBlock - pos.left)); if (pos.known) { s.add("block", tv::i32(pos.block)); s.add("words", tv::i64(pos.words)); } else { // Not on the indexed chain. Said plainly rather than guessed: this is the signal that a // second generator exists, or that the gap exceeded the ledger's search bound. s.add("block", tv::null()); s.add("words", tv::null()); } s.add("block_hash", tv::u64(pos.hash)); return s; } void push_rng_region(std::vector& out, void* rng) { if (!rng || !readable(rng, kRngSize)) return; trace::Region r; r.name = "rng"; r.ptr = rng; r.size = kRngSize; r.describe = &describe_rng; out.push_back(r); } // ---- the node-line expiry model --------------------------------------------------------------- // NodePath::RemainingLife 0x006e2130, whole body read as instructions. Two escape hatches return // INT_MAX (never expires); otherwise `npdtn - nptf/npdtf - (turn - npctm)`, clamped at 0 by the // callee. The division is a signed `idiv` and `nptf` is never sign-checked, so this reproduces // signed truncation deliberately. std::int32_t node_path_remaining_life(const void* rec, std::int32_t turn) { if (peek(rec, kNpType) == 0) return 0x7fffffff; const std::int32_t life = peek(rec, kNpLife); if (life == 0x7fffffff) return 0x7fffffff; std::int32_t aged = 0; const std::int32_t created = peek(rec, kNpCreated); if (created >= 0 && turn >= created) aged = turn - created; std::int32_t wear = 0; const std::int32_t div = peek(rec, kNpTrafficDiv); if (div != 0x7fffffff && div > 0) wear = peek(rec, kNpTraffic) / div; const std::int32_t rem = life - wear - aged; return rem > 0 ? rem : 0; } // How close the node-line population is to producing a draw at all. Without this, "we ran N turns // and phase 11 never fired" is an anecdote; with it, the report can say whether any reachable save // could have fired it and how far away the nearest line is. `min_life` is the smallest positive // remaining life over the lines that can expire at all -- the number of turns of pure ageing // between this save and the first phase-11 draw, if nothing adds traffic. struct NodePathStats { std::int32_t paths = -1; std::int32_t expired = -1; // remaining life <= 0 -> one word each std::int32_t permanent = -1; // npt == 0: never expires std::int32_t immortal = -1; // npdtn == INT_MAX: never expires std::int32_t mortal = -1; // the rest: the only ones that can ever draw std::int32_t min_life = -1; // smallest positive remaining life among the mortal ones std::int32_t within5 = -1; // mortal lines with remaining life <= 5 }; // Words node-line decay 0x007ae010 will consume, predicted from the state at entry. The gate on the draw is // the expiry test and nothing else: the `Chance` result and the "a fleet with flag 0x20000 is // riding this line" scan both run AFTER the draw at 0x007ae095 and gate only the collapse. // (That corrects combat-done-tail.md §3, which reads as if the fleet check suppressed the roll.) // Returns -1 when the state could not be read, which is reported as unknown rather than as 0. std::int32_t predicted_node_line_words(void* self, NodePathStats* out) { NodePathStats st; if (out) *out = st; if (!readable(self, kServerOffNodeGraph + 4)) return -1; const char* graph = static_cast(ptr_at(self, kServerOffNodeGraph)); if (!readable(graph, kGraphOffPaths + 8)) return -1; const char* first = static_cast(ptr_at(graph, kGraphOffPaths)); const char* last = static_cast(ptr_at(graph, kGraphOffPaths + 4)); if (!first || !last || last < first) return -1; const std::size_t span = static_cast(last - first); if (span % kNodePathStride != 0) return -1; const std::size_t n = span / kNodePathStride; if (n > kMaxNodePaths || !readable(first, span)) return -1; const std::int32_t turn = turn_of(self); st.paths = static_cast(n); st.expired = st.permanent = st.immortal = st.mortal = st.within5 = 0; st.min_life = -1; for (std::size_t i = 0; i < n; ++i) { const char* r = first + i * kNodePathStride; if (peek(r, kNpType) == 0) { ++st.permanent; continue; } if (peek(r, kNpLife) == 0x7fffffff) { ++st.immortal; continue; } ++st.mortal; const std::int32_t life = node_path_remaining_life(r, turn); if (life <= 0) { ++st.expired; continue; } if (life <= 5) ++st.within5; if (st.min_life < 0 || life < st.min_life) st.min_life = life; } if (out) *out = st; return st.expired; } void push_node_path_args(std::vector& out, const NodePathStats& st, std::int32_t predicted, const char* predict_name) { out.push_back(tv::i32(st.paths).named("node_paths")); out.push_back(tv::i32(st.permanent).named("np_permanent")); out.push_back(tv::i32(st.immortal).named("np_immortal")); out.push_back(tv::i32(st.mortal).named("np_mortal")); out.push_back(tv::i32(st.min_life).named("np_min_life")); out.push_back(tv::i32(st.within5).named("np_within5")); out.push_back(tv::i32(predicted).named(predict_name)); } // Advance a scratch copy of the RNG object by `words` words, the way the original would. void advance_scratch_rng(void* scratch, std::uintptr_t live_base, int words) { if (!scratch || words < 0) return; char* dst = static_cast(scratch); std::uint32_t mt[kMtWords]; std::memcpy(mt, dst + A::RNG_off_State, sizeof mt); std::int32_t left = 0; std::memcpy(&left, dst + A::RNG_off_Left, 4); mars::rng::MT19937 g; g.load_state(mt, static_cast(left)); for (int i = 0; i < words; ++i) (void)g.next_u32(); std::memcpy(dst + A::RNG_off_State, g.state(), sizeof mt); const std::int32_t new_left = static_cast(g.left()); std::memcpy(dst + A::RNG_off_Left, &new_left, 4); // `next` is an absolute cursor into the live object's own state block, so it is rebuilt // against the live base rather than the scratch's. void* next = reinterpret_cast(live_base + A::RNG_off_State + static_cast(kMtWords - new_left) * 4); std::memcpy(dst + A::RNG_off_Next, &next, sizeof next); } // ---- per-hook state --------------------------------------------------------------------------- // // Turn processing is single-threaded and every hook reads its own slot between describe_args and // regions/rebind/ours in the same call, so a plain global per hook is safe and matches the // pattern the other hook TUs use. struct CallState { RngEntry entry; }; CallState g_autosave, g_process_turn, g_tail, g_apply, g_nodespace; struct NodeLineState { RngEntry entry; std::int32_t predicted = -1; NodePathStats stats; void* s_rng = nullptr; bool compare = false; }; NodeLineState g_nld; void refuse_replace(const char* who) { static bool warned = false; if (warned) return; warned = true; logf("tail-rng: replace mode is not supported for %s; falling back to the original", who); } } // namespace void init_tail_rng(std::uintptr_t exe_base, void (*log_line)(const char* line)) { g_env.exe_base = exe_base; g_env.log_line = log_line; } void tail_rng_common_coverage(trace::Coverage& c) { c.unmodelled("everything the original writes except the strategic generator", trace::Risk::High, "this hook family measures ONE thing -- how many words the generator advances " "and where. It declares no region over game state and makes no claim about it. " "A clean run here says the RNG accounting is right and says nothing whatever " "about whether the turn was computed correctly", "region:rng is the only check; the turn's own correctness is B1/B3/B4's job"); c.unmodelled("the ledger reports WORDS, not draws", trace::Risk::Low, "a NextInt that rejects three times is four words and one call. Words are the " "unit that decides whether a save reproduces; they are the wrong unit for " "counting decisions, and nothing here should be read as a draw count", "region:rng carries left/block/words, never a call count"); c.unmodelled("the ledger is deliberately blind to WHICH primitive spent a word", trace::Risk::Low, "that is the design, and it is why this instrument was preferred to hooking " "the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, " "NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of " "which appears in no previous lane's primitive set) plus inlined draws in at " "least twelve functions, two of them reachable from the turn roots. A " "primitive-counting hook would have silently undercounted every one of those", "region:rng reads the state, so an inlined draw is as visible as a called one"); c.unmodelled("a generator position the ledger cannot place reads `words: null`", trace::Risk::Medium, "a block more than 4096 twists ahead of the frontier, or any state behind the " "anchor, is reported unknown rather than guessed. A null in a ledger field is a " "measurement failure and must not be read as zero", "region:rng emits null explicitly; tracecmp shows it as a value, not a gap"); } // ---- Game::StrategyHost::Autosave -------------------------------------------------------------- void StrategyHostAutosaveHook::describe_args(std::vector& out, void* self, void* name_out, bool end_turn) { // `this` is the global at 0x00b29f98, hardcoded by both call sites -- not an object handed // in, and not proved to be the same StrategyHost whose +0x54 holds the StrategyServer. So // the generator is reached through the pointer the turn drivers cached, and the +0x54 // candidate is recorded beside it so one run settles whether the two are the same object. void* from_this = readable(self, 0x58) ? ptr_at(self, 0x54) : nullptr; g_autosave.entry = observe_entry(g_server); out.push_back(tv::ptr(self).named("host")); out.push_back(tv::ptr(name_out).named("out_name")); out.push_back(tv::boolean(end_turn).named("end_turn")); out.push_back(tv::ptr(g_server).named("server_cached")); out.push_back(tv::ptr(from_this).named("host_plus_0x54")); out.push_back(tv::boolean(from_this != nullptr && from_this == g_server).named("server_agrees")); push_rng_args(out, g_autosave.entry); // The per-call-site ledger covers exactly the bracket: reset at the pre-turn marker, emitted at // the post-turn one. Its total must equal the bracket total the region ledger measures // 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]; const std::size_t n = draw_sites_snapshot(rows, sizeof rows / sizeof rows[0]); std::vector sites; for (std::size_t i = 0; i < n; ++i) { Tv one = tv::struct_(); one.add("entry", tv::str(draw_entry_name(rows[i].entry))); one.add("ret_rva", tv::u32(rows[i].ret_rva)); one.add("calls", tv::u32(rows[i].calls)); one.add("words", tv::u32(rows[i].words)); one.add("no_draw_calls", tv::u32(rows[i].zero_calls)); one.add("strategic", tv::boolean(rows[i].strategic)); sites.push_back(std::move(one)); } out.push_back(tv::list(std::move(sites)).named("draw_sites")); out.push_back(tv::u32(draw_sites_total_words()).named("draw_site_words")); out.push_back(tv::u32(draw_sites_total_calls()).named("draw_site_calls")); 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); } void StrategyHostAutosaveHook::regions(std::vector& out, void*, void*, bool) { push_rng_region(out, g_autosave.entry.rng); } StrategyHostAutosaveHook::Args StrategyHostAutosaveHook::rebind(trace::Scratch&, void* self, void* name_out, bool end_turn) { return Args(self, name_out, end_turn); } void* StrategyHostAutosaveHook::ours(void* self, void* name_out, bool end_turn) { using H = trace::Hook; if (H::mode == trace::Mode::Replace) { refuse_replace("StrategyHost::Autosave"); if (H::original) return H::original(self, name_out, end_turn); } // Compare mode: ours writes nothing. The autosave is a marker, not a model -- the useful // output is the pair of ledger positions in `side.rng`, and a model that "predicted" the // generator does not move here would be a check of nothing. The NRV slot is echoed back so // the return value is the one the caller expects. return name_out; } void StrategyHostAutosaveHook::coverage(trace::Coverage& c) { tail_rng_common_coverage(c); c.unmodelled("the whole save write: four path buffers, the ENDTURN pair removal, the " "backup rotation, the per-player connection detach/reattach, and " "SaveGame_WriteFile 0x00877070 itself", trace::Risk::Low, "this hook exists to timestamp the generator at the two moments the two save " "files are written. It is a marker and models nothing", "region:rng only"); c.unmodelled("the generator is reached through a CACHED StrategyServer pointer, not from " "this call's own arguments", trace::Risk::Medium, "`this` is the global at 0x00b29f98, hardcoded by both call sites, and no " "argument here names the server. On the first pre-turn autosave after a load " "no turn driver has run yet, so the cache is empty and that record carries no " "ledger position -- the FIRST BRACKET OF A SESSION IS INCOMPLETE BY " "CONSTRUCTION and must not be read as a zero-cost turn", "arg:server_cached / host_plus_0x54 / server_agrees say which pointer was used " "and whether the +0x54 candidate is the same object"); } // ---- Game::StrategyServer::ProcessTurn --------------------------------------------------------- void StrategyServerProcessTurnHook::describe_args(std::vector& out, void* self, float dt) { g_server = self; g_process_turn.entry = observe_entry(self); out.push_back(tv::ptr(self).named("server")); out.push_back(tv::f32(dt).named("dt")); push_server_args(out, self); push_rng_args(out, g_process_turn.entry); } void StrategyServerProcessTurnHook::regions(std::vector& out, void*, float) { push_rng_region(out, g_process_turn.entry.rng); } StrategyServerProcessTurnHook::Args StrategyServerProcessTurnHook::rebind(trace::Scratch&, void* self, float dt) { return Args(self, dt); } void StrategyServerProcessTurnHook::ours(void* self, float dt) { using H = trace::Hook; if (H::mode == trace::Mode::Replace) { refuse_replace("StrategyServer::ProcessTurn"); if (H::original) H::original(self, dt); } } void StrategyServerProcessTurnHook::coverage(trace::Coverage& c) { tail_rng_common_coverage(c); c.unmodelled("no model of the turn's RNG cost: 32 phases, each of which may draw", trace::Risk::High, "ProcessResearch's completion roll, RollResearchAccident's NextInt(100), the " "ResearchRollPending roll (one word, or two on the plague path), and whatever " "ProcessStations / ProcessSurrenders / ProcessMissions / ProcessSpecialProjects " "spend -- none of which has ever been measured. `ours` predicts nothing and the " "record reports the measurement", "region:rng measures the total; the per-phase split is not resolved here"); c.unmodelled("this hook takes the address the fpu module also wants to sample", trace::Risk::Low, "MinHook allows one hook per target. `fpu.sample_turn=off` releases " "StrategyServer::ProcessTurn so this hook can install; with it on, this hook " "fails to install and the trace is missing half the ledger", "shim.log records the MH_CreateHook status for both"); } // ---- Game::StrategyServer::OnAllCombatDone_Tail ------------------------------------------------- void OnAllCombatDoneTailHook::describe_args(std::vector& out, void* self, void* results) { g_server = self; g_tail.entry = observe_entry(self); // The message payload: `results` is `msg+4`, a vector of stride 0x178. std::int32_t result_count = -1; if (readable(results, 8)) { const char* first = static_cast(ptr_at(results, 0)); const char* last = static_cast(ptr_at(results, 4)); if (first && last && last >= first) result_count = static_cast((last - first) / 0x178); } NodePathStats st; const std::int32_t predicted = predicted_node_line_words(self, &st); out.push_back(tv::ptr(self).named("server")); out.push_back(tv::ptr(results).named("results")); out.push_back(tv::i32(result_count).named("result_count")); push_server_args(out, self); // The tail's only predictable draw source, evaluated before the tail runs: if phase 11 is // the whole story on a quiet turn, the tail's measured word delta equals this number. push_node_path_args(out, st, predicted, "predict_nodeline_words"); push_rng_args(out, g_tail.entry); } void OnAllCombatDoneTailHook::regions(std::vector& out, void*, void*) { push_rng_region(out, g_tail.entry.rng); } OnAllCombatDoneTailHook::Args OnAllCombatDoneTailHook::rebind(trace::Scratch&, void* self, void* results) { return Args(self, results); } void OnAllCombatDoneTailHook::ours(void* self, void* results) { using H = trace::Hook; if (H::mode == trace::Mode::Replace) { refuse_replace("StrategyServer::OnAllCombatDone_Tail"); if (H::original) H::original(self, results); } } void OnAllCombatDoneTailHook::coverage(trace::Coverage& c) { tail_rng_common_coverage(c); c.unmodelled("36 phases, of which two can draw and neither is modelled here", trace::Risk::High, "phase 6 reaches the unread 7499-byte combat resolver 0x007d5af0 (NextInt on " "the node-cannon path, Twist plus NextInt on the salvage path) and phase 11 " "draws one word per expired node line. `predict_nodeline_words` covers only the " "second, and the nested ApplyEncounterResult / NodeLineDecay hooks are what " "attribute the split", "region:rng plus the two nested hooks"); c.unmodelled("whether this handler runs on a turn with NO combat is what this hook is here " "to settle, and until it has run it is a hypothesis", trace::Risk::Medium, "combat-done-tail.md §6 infers it from the determinism note -- the post-turn " "autosave appears on every End Turn and this handler is its only reachable " "caller -- not from the instruction stream", "arg:encounters says how many encounters this call saw; a call with 0 settles " "it"); } // ---- Game::StrategyServer::ApplyEncounterResult ------------------------------------------------- void ApplyEncounterResultHook::describe_args(std::vector& out, void* self, void* enc, void* res) { g_apply.entry = observe_entry(self); out.push_back(tv::ptr(self).named("server")); out.push_back(tv::ptr(enc).named("encounter")); out.push_back(tv::ptr(res).named("result")); // The three dispatch bytes. `+0x4 != 0` makes the whole function a no-op, so a call with it // set that still moves the generator would be a real surprise. if (readable(res, 8)) { out.push_back(tv::u8(peek(res, 4)).named("res_no_battle")); out.push_back(tv::u8(peek(res, 6)).named("res_peaceful")); out.push_back(tv::u8(peek(res, 7)).named("res_surrendered")); } push_server_args(out, self); push_rng_args(out, g_apply.entry); } void ApplyEncounterResultHook::regions(std::vector& out, void*, void*, void*) { push_rng_region(out, g_apply.entry.rng); } ApplyEncounterResultHook::Args ApplyEncounterResultHook::rebind(trace::Scratch&, void* self, void* enc, void* res) { return Args(self, enc, res); } void ApplyEncounterResultHook::ours(void* self, void* enc, void* res) { using H = trace::Hook; if (H::mode == trace::Mode::Replace) { refuse_replace("StrategyServer::ApplyEncounterResult"); if (H::original) H::original(self, enc, res); } } void ApplyEncounterResultHook::coverage(trace::Coverage& c) { tail_rng_common_coverage(c); c.unmodelled("the combat resolver 0x007d5af0 (7499 B) is completely unread", trace::Risk::High, "this hook measures what its subtree spends and models none of it. Nothing " "about combat determinism can be settled until that function is read; this " "only puts a number on the hole", "region:rng measures the subtotal"); c.unmodelled("the ~0xea0-byte combat report, the CombatReport list append at S+0x1fc, the " "ClientEncounterResults push into S+0x2f4, the per-ship turn stamps and the " "pairwise engagement bits", trace::Risk::Medium, "all of it is game state this hook does not declare and does not check", ""); } // ---- Game::StrategyServer::NodeLineDecay -------------------------------------------------------- void NodeLineDecayHook::describe_args(std::vector& out, void* self) { g_nld.entry = observe_entry(self); g_nld.predicted = predicted_node_line_words(self, &g_nld.stats); out.push_back(tv::ptr(self).named("server")); push_server_args(out, self); // `predict_words` is written before the original runs: it is the falsifiable claim, not a // report of what happened. If the measured delta on `rng` is not this number, the model is // wrong. The np_* fields say how close the population is to firing at all, so "phase 11 // never drew" can be reported as a distance rather than as an absence. push_node_path_args(out, g_nld.stats, g_nld.predicted, "predict_words"); push_rng_args(out, g_nld.entry); } void NodeLineDecayHook::regions(std::vector& out, void*) { push_rng_region(out, g_nld.entry.rng); } NodeLineDecayHook::Args NodeLineDecayHook::rebind(trace::Scratch& s, void* self) { g_nld.compare = true; g_nld.s_rng = (s.count() > 0 && s.size(0) >= kRngSize) ? s.ptr(0) : nullptr; return Args(self); } void NodeLineDecayHook::ours(void* self) { using H = trace::Hook; const bool compare = g_nld.compare; g_nld.compare = false; if (H::mode == trace::Mode::Replace) { refuse_replace("StrategyServer::NodeLineDecay"); if (H::original) H::original(self); return; } if (!compare) return; // The model: one word per expired node line, and nothing else in the 1117-byte body reaches // a generator (direct-call sweep to depth 5 over 140 functions, one hit). If the prediction // failed to read the graph it advances nothing, which diverges loudly rather than quietly. if (g_nld.s_rng && g_nld.entry.have && g_nld.predicted >= 0) advance_scratch_rng(g_nld.s_rng, reinterpret_cast(g_nld.entry.rng), g_nld.predicted); } void NodeLineDecayHook::coverage(trace::Coverage& c) { tail_rng_common_coverage(c); c.unmodelled("the collapse itself: 0x007a92e0 (690 B) and 0x007a4700 (2244 B) destroy " "or halt fleets and post EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE / " "_HALTED / _HALTED_VIANODE, and loop 3 posts two more decay-stage events", trace::Risk::High, "ours advances the generator and writes nothing else. In compare mode that is " "the intent -- the check is the word count -- but it means a clean verdict here " "says nothing about which lines actually collapsed", "region:rng only"); c.unmodelled("the draw-count model is verified by a DIRECT-call sweep of the downstream " "pair; their subtrees contain unresolved indirect call sites", trace::Risk::Medium, "if one of those vtable slots reaches a generator, the measured delta will " "exceed `predict_words` and this hook will diverge -- which is the correct " "outcome, and the reason the prediction is recorded as an argument", "arg:predict_words vs region:rng is exactly that check"); c.unmodelled("the expiry formula reproduces a signed idiv on nptf/npdtf without knowing " "whether nptf can be negative", trace::Risk::Low, "the original never sign-checks the traffic accumulator. The model truncates " "toward zero the same way; if the field is always non-negative the question " "never arises, and no save has been observed with a negative one", ""); } // ---- Game::EncounterDetect::AssignContacts ------------------------------------------------------- namespace { struct AssignState { RngEntry entry; std::int32_t detectors = -1; std::int32_t contacts = -1; }; AssignState g_assign; std::int32_t ptr_vector_size(void* v) { if (!readable(v, 8)) return -1; const char* first = static_cast(ptr_at(v, 0)); const char* last = static_cast(ptr_at(v, 4)); if (!first || !last || last < first) return -1; return static_cast((last - first) / 4); } } // namespace void EncounterDetectAssignContactsHook::describe_args(std::vector& out, void* self, void* buckets, void* det, void* con) { // ctx = {StrategyServer* S, TechDef*, TechDef*}; the generator is the server's, as always. void* server = readable(self, 4) ? ptr_at(self, 0) : nullptr; g_assign.entry = observe_entry(server); g_assign.detectors = ptr_vector_size(det); g_assign.contacts = ptr_vector_size(con); out.push_back(tv::ptr(self).named("ctx")); out.push_back(tv::ptr(server).named("server")); out.push_back(tv::ptr(buckets).named("out_buckets")); out.push_back(tv::i32(g_assign.detectors).named("detectors")); out.push_back(tv::i32(g_assign.contacts).named("contacts")); // Lane I's worst case: one inlined NextFloat per (contact, detector) trial, drawn BEFORE the // accept test, so an unteched detector (threshold 0.0f) still costs the word. Recorded as a // bound, not a prediction -- the accept short-circuits the inner loop, so the measured cost // should be at most this. const std::int32_t bound = (g_assign.detectors > 0 && g_assign.contacts > 0) ? g_assign.detectors * g_assign.contacts : -1; out.push_back(tv::i32(bound).named("max_trials")); push_rng_args(out, g_assign.entry); } void EncounterDetectAssignContactsHook::regions(std::vector& out, void*, void*, void*, void*) { push_rng_region(out, g_assign.entry.rng); } EncounterDetectAssignContactsHook::Args EncounterDetectAssignContactsHook::rebind( trace::Scratch&, void* self, void* buckets, void* det, void* con) { return Args(self, buckets, det, con); } void EncounterDetectAssignContactsHook::ours(void* self, void* buckets, void* det, void* con) { using H = trace::Hook; if (H::mode == trace::Mode::Replace) { refuse_replace("EncounterDetect::AssignContacts"); if (H::original) H::original(self, buckets, det, con); } } void EncounterDetectAssignContactsHook::coverage(trace::Coverage& c) { tail_rng_common_coverage(c); c.unmodelled("the contact-to-detector assignment itself, and the two-pass outer loop", trace::Risk::Medium, "this hook exists because the draw here is INLINED and therefore invisible to " "every call-graph sweep and to the entry-point detours -- it is the one site in " "ProcessTurn's closure that neither instrument can see. It measures the word " "cost of the whole call and models nothing", "region:rng; arg:detectors/contacts/max_trials bound the expected count"); c.unmodelled("the per-trial threshold is 0.25f or 0.0f depending on two tech lookups, and the " "accept test short-circuits the inner loop", trace::Risk::Low, "so the measured cost is between |contacts| and |contacts| x |detectors| and the " "exact number depends on tech state this hook does not read", "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) { g_nodespace.entry = observe_entry(self); out.push_back(tv::ptr(self).named("server")); push_server_args(out, self); push_rng_args(out, g_nodespace.entry); } void ProcessNodeSpaceTravelHook::regions(std::vector& out, void*) { push_rng_region(out, g_nodespace.entry.rng); } ProcessNodeSpaceTravelHook::Args ProcessNodeSpaceTravelHook::rebind(trace::Scratch&, void* self) { return Args(self); } void ProcessNodeSpaceTravelHook::ours(void* self) { using H = trace::Hook; if (H::mode == trace::Mode::Replace) { refuse_replace("StrategyServer::ProcessNodeSpaceTravel"); if (H::original) H::original(self); } } void ProcessNodeSpaceTravelHook::coverage(trace::Coverage& c) { tail_rng_common_coverage(c); c.unmodelled("2945 bytes of node-space movement, entirely unmodelled and never swept for " "RNG by any lane", trace::Risk::Medium, "it is hooked here only because it runs TWICE a turn -- ProcessTurn phase 7 and " "tail phase 10 -- so a draw inside it would be double-counted by anyone " "modelling it once. The record says whether it draws at all", "region:rng"); } } // namespace shim::hooks