1000 lines
47 KiB
C++
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
|