// Hook descriptor for the per-turn research pass (B3): // // Game::TechTree::ProcessResearch(this, rng, alloc, overbudget) // // A verified __thiscall in sots_addresses.h, so it goes through Hook<> with // CallConv::Thiscall. It is called once per player per turn, from ServerPlayer::ProcessTurn, // and it is the only caller. Its four parameters are all confirmed at that call site: the // second is the strategy server's Mars::RNG *object* (the function re-bases it to the state // block with +4 before every draw), the third the {tech, points} allocation the budget built, // the fourth an accumulator for the points that would not fit under the 150 % cap. // // Why this call is worth a compare: it exercises the MT19937, the completion-odds formula and // the Zuul double roll in one place, and its RNG consumption is observable. So the declared // regions are // // rng the whole 0x9cc-byte generator object -- mt[624] plus the stream position // overbudget the caller's accumulator // node[i] every non-null TechNode in the tree, 0x34 bytes each // events the owner's inline EventStorage header (ServerPlayer+0x29c): EvNxID and the // turn-bucket count. `ours` posts the pass's events into its own model storage // and writes the counts into this region's scratch copy -- never into the game. // observed_techs the owner's vector header (ServerPlayer+0x274): declared so the // completion append is a named check rather than an undeclared write, and so // its byte delta measures the element stride, which is not yet pinned. // // and the compare is run with our own MT19937 seeded by load_state() from the *pre-call* // snapshot, so both implementations read the same stream. If the post-call generator state // matches as well, we consumed the same words in the same order -- which is the real evidence. // // Scope of `ours`: exactly what ProcessResearch itself writes. On the turn a tech completes, // the original goes on to call TechTree::SetResearched, which stamps the turn/order words, // walks the unlock cascade into the child nodes and invokes the owner's tech-effect callback. // None of that is reproduced (it is its own milestone, and the callback would write live // player state that compare mode must never touch), so a completion record is expected to // diverge in those fields and only in those fields. The effective cost of a node is taken // from the game's own TechTree::Cost, which is read-only -- the cost multiplier is a separate, // lower-confidence formula and not what this milestone is measuring. #pragma once #include #include #include #include "shim/trace/hook.h" namespace shim::hooks { struct TechTreeProcessResearchHook { static constexpr const char* name = "Game::TechTree::ProcessResearch"; static constexpr trace::CallConv conv = trace::CallConv::Thiscall; using Ret = void; // this (TechTree*), rng (Mars::RNG*), alloc (vector<{TechDef*,int}>*), overbudget (int*) using Args = std::tuple; static void describe_args(std::vector& out, void* tree, void* rng, void* alloc, int* overbudget); static void regions(std::vector& out, void* tree, void* rng, void* alloc, int* overbudget); static Args rebind(trace::Scratch& s, void* tree, void* rng, void* alloc, int* overbudget); static void ours(void* tree, void* rng, void* alloc, int* overbudget); static trace::HookPolicy policy() { return trace::HookPolicy{}; } static void coverage(trace::Coverage& c) { // THE B3 DEFECT, now modelled count-only (lane E option (a); see docs/P-events-wiring.md). c.unmodelled("posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours " "reproduces the decision and the id sequence, so region:events compares " "next_id, but the composed EvDsc/EvMsg text is not reproduced and no region " "can see it", trace::Risk::Medium, "text comes from the game's string table, which the engine must not carry; " "ours posts into its own EventStorage and writes only the counts into the " "scratch copy, so no live byte moves and replace mode posts nothing at all", "region:events"); // The one event the count model cannot decide. Its trigger IS pinned -- SetResearched's // second sweep sets state 2 and stamps turnAvailable, and the tail loop collects // state==2 && turnAvailable==currentTurn -- but evaluating it needs the cascade `ours` // does not run, so posting it would be a guess that happens to score. c.unmodelled("posts EVENT_TECHS_UNLOCKED once after the per-node loop, for the nodes " "SetResearched made available this turn", trace::Risk::Medium, "the set comes from the child-unlock cascade, which ours does not run; the " "pass driver takes the unlock list as an input and is given `no list` " "rather than an empty one, so a missing input cannot look like a modelled " "negative. Expect region:events to under-count next_id by exactly 1 on " "every call that completes a tech", "region:events"); c.unmodelled("appends to the owner's vector (ServerPlayer+0x274) on every " "tech completion", trace::Risk::High, "serialized ServerPlayer state that no coverage note in B2 or B3 mentioned " "until lane R's guard caught it. sizeof(ObservedTech) and the append call " "site are both unpinned, so ours cannot produce the bytes; the region " "reports the byte span, whose growth on a completion call measures the " "element stride", "region:observed_techs"); c.unmodelled("TechTree::SetResearched on completion: the turn/order stamps, the child " "unlock cascade, the recursive research of zero-cost children, and the " "owner's OnTechResearched callback", trace::Risk::High, "its own milestone (B2); the callback writes live player state that compare " "mode must not touch, and it consumes one extra RNG word", "guard:player, guard:tree_header"); c.unmodelled("bumps the tree's completion-order counter (TechTree+0x20)", trace::Risk::Medium, "part of SetResearched; the per-node `order` word is compared but the " "counter it comes from was not a region", "guard:tree_header"); c.unmodelled("writes a completion line to the game log", trace::Risk::Low, "log text is not simulation state"); } }; // Process facts the hook needs (exe base for the RVAs, a line logger). Call once before // installing. void init_research(std::uintptr_t exe_base, void (*log_line)(const char* line)); } // namespace shim::hooks