sots-engine/src/shim/hooks/tail_rng.cpp

1000 lines
47 KiB
C++

#include "shim/hooks/tail_rng.h"
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <string>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#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<const char*>(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<const char*>(mbi.BaseAddress) + mbi.RegionSize;
}
return true;
#else
return true;
#endif
}
template <class T>
T peek(const void* base, std::size_t off) {
T v{};
std::memcpy(&v, static_cast<const char*>(base) + off, sizeof v);
return v;
}
void* ptr_at(const void* base, std::size_t off) { return peek<void*>(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<std::int32_t>(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<std::int32_t>(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<const char*>(ptr_at(self, off));
const char* last = static_cast<const char*>(ptr_at(self, off + 4));
if (!first || !last || last < first) return -1;
return static_cast<std::int32_t>((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<const char*>(ptr_at(self, off));
const char* last = static_cast<const char*>(ptr_at(self, off + 4));
if (!first || !last || last < first) return -1;
return static_cast<std::int32_t>((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<Tv>& 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<Tv>& 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<trace::Region>& 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<std::int32_t>(rec, kNpType) == 0) return 0x7fffffff;
const std::int32_t life = peek<std::int32_t>(rec, kNpLife);
if (life == 0x7fffffff) return 0x7fffffff;
std::int32_t aged = 0;
const std::int32_t created = peek<std::int32_t>(rec, kNpCreated);
if (created >= 0 && turn >= created) aged = turn - created;
std::int32_t wear = 0;
const std::int32_t div = peek<std::int32_t>(rec, kNpTrafficDiv);
if (div != 0x7fffffff && div > 0) wear = peek<std::int32_t>(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<const char*>(ptr_at(self, kServerOffNodeGraph));
if (!readable(graph, kGraphOffPaths + 8)) return -1;
const char* first = static_cast<const char*>(ptr_at(graph, kGraphOffPaths));
const char* last = static_cast<const char*>(ptr_at(graph, kGraphOffPaths + 4));
if (!first || !last || last < first) return -1;
const std::size_t span = static_cast<std::size_t>(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<std::int32_t>(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<std::int32_t>(r, kNpType) == 0) {
++st.permanent;
continue;
}
if (peek<std::int32_t>(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<Tv>& 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<char*>(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<int>(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<std::int32_t>(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<void*>(live_base + A::RNG_off_State +
static_cast<std::size_t>(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<Tv>& 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<Tv> 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<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); }
void StrategyHostAutosaveHook::regions(std::vector<trace::Region>& 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<StrategyHostAutosaveHook>;
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<Tv>& 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<trace::Region>& 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<StrategyServerProcessTurnHook>;
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<Tv>& out, void* self, void* results) {
g_server = self;
g_tail.entry = observe_entry(self);
// The message payload: `results` is `msg+4`, a vector<EncounterResults> of stride 0x178.
std::int32_t result_count = -1;
if (readable(results, 8)) {
const char* first = static_cast<const char*>(ptr_at(results, 0));
const char* last = static_cast<const char*>(ptr_at(results, 4));
if (first && last && last >= first) result_count = static_cast<std::int32_t>((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<trace::Region>& 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<OnAllCombatDoneTailHook>;
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<Tv>& 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<std::uint8_t>(res, 4)).named("res_no_battle"));
out.push_back(tv::u8(peek<std::uint8_t>(res, 6)).named("res_peaceful"));
out.push_back(tv::u8(peek<std::uint8_t>(res, 7)).named("res_surrendered"));
}
push_server_args(out, self);
push_rng_args(out, g_apply.entry);
}
void ApplyEncounterResultHook::regions(std::vector<trace::Region>& 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<ApplyEncounterResultHook>;
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<Tv>& 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<trace::Region>& 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<NodeLineDecayHook>;
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<std::uintptr_t>(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<const char*>(ptr_at(v, 0));
const char* last = static_cast<const char*>(ptr_at(v, 4));
if (!first || !last || last < first) return -1;
return static_cast<std::int32_t>((last - first) / 4);
}
} // namespace
void EncounterDetectAssignContactsHook::describe_args(std::vector<Tv>& 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<trace::Region>& 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<EncounterDetectAssignContactsHook>;
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<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 ------------------------------------------------
void ProcessNodeSpaceTravelHook::describe_args(std::vector<Tv>& 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<trace::Region>& 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<ProcessNodeSpaceTravelHook>;
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