// 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`: what ProcessResearch itself writes, plus -- in compare mode only -- the // TechTree::SetResearched cascade it calls on a completion. The cascade stamps the completed // node's turn and order, lowers the child costs, walks the availability sweep that writes // `turnAvailable`, and recursively completes any zero-cost node it makes available; the tail // collector then decides EVENT_TECHS_UNLOCKED from `state == 2 && turnAvailable == turn`. All of // it is modelled by the pure game/sim/techgraph module, transcribed from the SCRATCH node copies // so the decision is a function of the pre-call tree and not of what the original just did. // // The owner callback SetResearched invokes is modelled only as far as this hook's regions reach: // the observed-tech append (a de-duplicating append whose byte span region:observed_techs // compares) and the RNG word RollResearchEvent draws when the completing tech is the current // target and the pending-roll byte is set. That is ONE word only while the roll misses: when it // beats the odds the plague branch draws a second word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, // and the rebellion branch cancels the research outright. Its tech-effect field writes are B2's // milestone and remain what the `player` guard reports. // // REPLACE mode runs none of the cascade: there, every pointer is live game memory and applying // half of the callback would leave the player in a state no code path produces. // // 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. The cascade calls it too, on the node as it stands after the child-cost sweep. #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"); // EVENT_TECHS_UNLOCKED is now computed rather than declared missing -- `ours` runs the // cascade. What remains unproven is the same thing as for the other five: the text. c.unmodelled("composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", trace::Risk::Low, "the trigger and the list are modelled (SetResearched's availability sweep " "plus the tail collector, both read off the instruction stream), so " "region:events compares next_id; the names come from the game's string " "table, so the message is composed from node indices instead and is not the " "game's text", "region:events"); c.unmodelled("TechTree::SetResearched in REPLACE mode: nothing of it runs", trace::Risk::High, "the cascade is compare-mode only. In replace mode every pointer is live " "game memory, and applying half of OnTechResearched -- the observed-tech " "append and the research-event roll, but not the tech-effect field writes -- " "would leave the player in a state no code path produces. A replace run " "therefore still leaves the completed node unstamped and no tech unlocked", "guard:player, guard:tree_header"); c.unmodelled("ServerPlayer::OnTechResearched's tech effects: the ~90 hard-coded " "ServerPlayer field writes, the plague-cure masks, the design-option " "bitmasks and the species tech flags", trace::Risk::High, "B2's milestone. `ours` models only the two parts of the callback this " "hook's regions can see -- the observed-tech append and the RNG word " "RollResearchEvent draws before its branch (one word on a missed roll, two " "on a fired plague roll) -- and the rest is what the player guard reports", "guard:player"); c.unmodelled("the research-event branch RollResearchEvent takes when its roll beats the " "odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion " "event paths)", trace::Risk::High, "RollResearchEvent draws one NextFloat unconditionally and that draw IS " "modelled -- but that is only the cost of REACHING the branch. A FIRED roll " "costs one or two words: the plague path draws a SECOND word (NextInt) to " "pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion " "path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current " "research (no further draw). The branch is entered only for the plague and " "AI-rebellion tech families, whose odds are 0 everywhere else, and it has " "never been observed firing in three sessions -- which is why every earlier " "note in this repo said 'exactly one NextFloat' and nothing caught it. " "If it is ever entered, region:rng is the check", "region:rng"); c.unmodelled("constructs the ObservedTech element it appends to ServerPlayer+0x274", trace::Risk::Medium, "`ours` models the append DECISION -- RecordObservedTech de-duplicates by " "tech name, so it decides whether the vector grows -- and moves the scratch " "header's byte span by one 0x2c element per append. The element's own fields " "(turn_first, turn_last, detected, the name string, `with`) are not built, " "and no region can see them", "region:observed_techs"); c.unmodelled("the tree's completion-order counter (TechTree+0x20) is read pre-call, not " "modelled as a region", trace::Risk::Low, "the per-node `order` word IS compared, and it is stamped from a counter " "`ours` seeds from the pre-call read and advances itself; the counter's own " "final value is only seen by the tree_header guard", "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