#include "shim/hooks/research.h" #include #include #include #include #include #include #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #include #endif #include "game/events/event_log.h" #include "game/events/research_events.h" #include "game/sim/research.h" #include "game/sim/species.h" #include "game/sim/techgraph.h" #include "generated/sots_addresses.h" #include "mars/rng/mt19937.h" #include "shim/hooks/event_inputs.h" namespace shim::hooks { using trace::Tv; namespace tv = trace::tv; namespace { namespace A = sots::addr; constexpr std::size_t kNodeSize = A::TechNode_size; // 0x34 constexpr std::size_t kRngSize = A::RNG_size; // 0x9cc constexpr std::size_t kTreeHeadSize = A::TechTree_off_Nodes + 0xc; // owner + the node vector constexpr std::size_t kMaxNodes = 8192; // loop guard for a garbage vector header // The owner's inline Game::EventStorage: a vector at +4 and the next-event id // EvNxID at +0x14. Posting an event bumps EvNxID and grows the turn's list -- the write B3's // compare could not see. These four were promoted out of this file and into the generated // header once lane E read them off the instruction stream (ServerPlayer::GetEventStorage is // literally `lea eax,[ecx+0x29c]; ret`, and PostEvent's counter update names +0x14). constexpr std::size_t kPlayerEventsOff = A::ServerPlayer_off_Events; // 0x29c constexpr std::size_t kEventStorageSize = A::EventStorage_sizeof; // 0x1c constexpr std::size_t kEventsVecOff = A::EventStorage_off_Events; // 0x04 constexpr std::size_t kEventsNextIdOff = A::EventStorage_off_EvNxID; // 0x14 // The owner's vector (save tag `otch`). Lane R's player guard caught all three of // its words moving on both completion calls, in both this hook and OnTechResearched -- a third // serialized list append in the same neighbourhood as the event list, named in no coverage note // anywhere. Declaring it turns an undeclared write into a check. constexpr std::size_t kObservedTechsOff = A::ServerPlayer_off_ObservedTechs; // 0x274 constexpr std::size_t kVectorHeaderSize = 0xc; // {first,last,end} constexpr std::size_t kObservedTechSize = A::ObservedTech_sizeof; // 0x2c constexpr std::size_t kMaxObservedTechs = 4096; // loop guard on a garbage vector header // TechDef, as far as the cascade needs it: the tech id it is indexed by, the name the // observed-tech list is de-duplicated on, the prerequisite block PrereqsMet walks, and the byte // that keeps a node out of the availability sweep. Reading def+0xb0 is the deepest of these, so // one probe of that length covers the lot. constexpr std::size_t kTechDefProbe = A::TechDef_off_NoAutoAvailable + 1; // 0xb1 constexpr std::size_t kTechEdgeProbe = A::TechEdge_off_ChildDef + 4; // 0x44 constexpr std::size_t kMaxEdgesPerNode = 256; constexpr std::size_t kMaxPrereqGroups = 64; constexpr std::size_t kMaxPrereqEntries = 256; // The turn every event is filed under: the owner's second StrategyServer base is at // ServerPlayer+8 and the turn counter (ModCount) sits at +8 in it. Read out of the instruction // stream twice -- lane E on every research-path post site, and again at 0x00581e10 where // SetResearched stamps node.turnResearched from the same chain. constexpr std::size_t kPlayerServerOff = 8; // Whole-object guard spans. ServerPlayer is 0x3e0 and the TechTree header we care about ends // at the order counter (+0x20). Source: findings/control-flow/turn-spine.md object table. constexpr std::size_t kPlayerSize = 0x3e0; constexpr std::size_t kTreeGuardSize = 0x24; constexpr std::size_t kMaxAlloc = 1024; constexpr int kMtWords = mars::rng::MT19937::N; using CostFn = int(SHIM_THISCALL*)(void* tree, void* node); struct Env { std::uintptr_t exe_base = 0; void (*log_line)(const char*) = nullptr; CostFn cost = 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); } // ---- safe pointer chasing (same guard the M2 describers use) ------------------------------- bool readable(const void* p, std::size_t n) { if (!p) return false; if (n == 0) return true; #if defined(_WIN32) const char* c = static_cast(p); const char* const end = c + n; while (c < end) { MEMORY_BASIC_INFORMATION mbi; if (!VirtualQuery(c, &mbi, sizeof mbi)) return false; if (mbi.State != MEM_COMMIT) return false; if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false; const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; if (!(mbi.Protect & ok)) return false; c = static_cast(mbi.BaseAddress) + mbi.RegionSize; } return true; #else return true; #endif } std::int32_t word_at(const void* obj, std::size_t off) { std::int32_t v = 0; std::memcpy(&v, static_cast(obj) + off, sizeof v); return v; } void set_word(void* obj, std::size_t off, std::int32_t v) { std::memcpy(static_cast(obj) + off, &v, sizeof v); } void* ptr_at(const void* obj, std::size_t off) { void* v = nullptr; std::memcpy(&v, static_cast(obj) + off, sizeof v); return v; } void set_ptr(void* obj, std::size_t off, void* v) { std::memcpy(static_cast(obj) + off, &v, sizeof v); } // The x87 control word as the hooked call finds it. The precision-control field decides // whether the generator's multiply and the odds division round to 53 or to 24 significand // bits, which is the one remaining float-parity unknown; recording it settles it from a // single run instead of guessing. std::uint32_t fpu_control_word() { #if defined(__i386__) || defined(__x86_64__) unsigned short cw = 0; __asm__ __volatile__("fnstcw %0" : "=m"(cw)); return cw; #else return 0; #endif } // ---- per-call state -------------------------------------------------------------------- // // ProcessResearch is called once per player from the single-threaded turn pass and is never // re-entrant, so the mapping regions() builds can be handed to rebind()/ours() in statics -- // the same concession M1 makes, and with the same caveat: do not reuse this for a hook that // can nest. struct CallState { bool compare = false; // set by rebind, consumed (and cleared) by ours std::uintptr_t rng_base = 0; // the LIVE generator address, for next-pointer math std::size_t rng_region = 0; std::size_t ob_region = 1; int events_region = -1; // the owner's EventStorage, -1 when the owner is unreadable int otch_region = -1; // the owner's vector void* scratch_events = nullptr; // compare mode: the scratch copy of the EventStorage header void* scratch_otch = nullptr; // compare mode: the scratch copy of the ObservedTech header std::vector node_region; // node index -> region index, -1 when the slot is null std::vector names; // stable storage for Region::name std::vector scratch_nodes; }; CallState g_call; // The pre-call read of the owner's event list. // // It lives outside CallState on purpose. `describe_args` runs immediately before `regions()` for // the same call (hook.h), and `regions()` resets CallState -- so the scan is taken in // describe_args, where its numbers can also be reported as arguments in *every* mode, and read // back in regions()/ours(). ProcessResearch is called once per player from the single-threaded // turn pass and is never re-entrant, the same concession the CallState mapping already makes. struct EventScan { bool owner_ok = false; bool turn_ok = false; int turn = 0; EventStorageScan storage; }; EventScan g_scan; // Everything else the cascade needs that the ORIGINAL changes during the call, and that `ours` // therefore cannot read when it runs afterwards. Same reasoning, and the same trap, as the event // scan above -- and the trap here is sharper, because each of these reads back a *plausible* // wrong answer rather than an obviously broken one: // // * the tree's completion-order counter is post-incremented by SetResearched, so reading it // after the original yields the next order, not this one, and node.order would be off by // one per completion; // * `ServerPlayer::ResT` is zeroed by OnTechResearched, so the "is this the current research // target" test that gates the extra RNG draw would answer no on exactly the calls where the // original answers yes; // * the pending-roll byte is cleared in the same block; // * the observed-tech list has already been appended to, so a de-duplication check taken then // would find the tech present and model no append -- agreeing with a count it did not // compute. struct PreCallScan { bool tree_ok = false; int order_counter = 0; // TechTree+0x20 bool has_owner = false; // TechTree+0x0c != NULL bool player_ok = false; const void* research_target = nullptr; // ServerPlayer+0x294 (ResT), a TechDef* bool roll_pending = false; // ServerPlayer+0x3b4 bool observed_ok = false; bool observed_truncated = false; // The tech names already in the owner's vector. Game text, kept inside the // shim for one purpose only -- deciding whether RecordObservedTech would append -- and never // emitted into a trace record. std::vector observed_names; }; PreCallScan g_pre; // The turn the events are filed under; `ok` is false when the chain is not walkable. int current_turn(const void* owner, bool& ok) { ok = false; if (!readable(owner, kPlayerServerOff + 4)) return 0; const void* srv = ptr_at(owner, kPlayerServerOff); if (!readable(srv, A::StrategyServer_off_ModCount + 4)) return 0; ok = true; return word_at(srv, A::StrategyServer_off_ModCount); } // The tree's node vector, or false when the header does not look like one. bool tree_nodes(const void* tree, std::vector& out) { out.clear(); if (!readable(tree, kTreeHeadSize)) return false; void** begin = static_cast(ptr_at(tree, A::TechTree_off_Nodes)); void** end = static_cast(ptr_at(tree, A::TechTree_off_Nodes + 4)); if (!begin && !end) return true; const std::uintptr_t b = reinterpret_cast(begin); const std::uintptr_t e = reinterpret_cast(end); if (!begin || e < b || (b & 3) || (e & 3)) return false; const std::size_t n = (e - b) / sizeof(void*); if (n > kMaxNodes) return false; if (n && !readable(begin, n * sizeof(void*))) return false; out.assign(begin, begin + n); return true; } // The {TechDef* target, int points} entries the budget built. struct AllocEntry { void* target = nullptr; int points = 0; }; bool alloc_entries(const void* alloc, std::vector& out) { out.clear(); if (!readable(alloc, 0xc)) return false; const char* begin = static_cast(ptr_at(alloc, 0)); const char* end = static_cast(ptr_at(alloc, 4)); if (!begin && !end) return true; if (!begin || end < begin) return false; const std::size_t n = static_cast(end - begin) / A::ResearchAlloc_stride; if (n > kMaxAlloc) return false; if (n && !readable(begin, n * A::ResearchAlloc_stride)) return false; for (std::size_t i = 0; i < n; ++i) { const char* e = begin + i * A::ResearchAlloc_stride; AllocEntry a; a.target = ptr_at(e, 0); a.points = word_at(e, 4); out.push_back(a); } return true; } int tech_id_of(void* def) { if (!readable(def, 4)) return -1; return word_at(def, A::TechDef_off_TechId); } // ---- the pre-call reads of the owner's and the tree's mutable state ------------------------ // The names in `vector`, or `ok = false` when the header does not look like one. // Only the name string of each element is read; the rest of the element is untouched. bool scan_observed_techs(const void* vec, std::vector& out, bool& truncated) { out.clear(); truncated = false; if (!readable(vec, kVectorHeaderSize)) return false; const char* first = static_cast(ptr_at(vec, 0)); const char* last = static_cast(ptr_at(vec, 4)); if (!first && !last) return true; // an empty vector is a successful scan of nothing if (!first || last < first) return false; const std::size_t bytes = static_cast(last - first); if (bytes % kObservedTechSize) return false; const std::size_t n = bytes / kObservedTechSize; if (n > kMaxObservedTechs) { truncated = true; return false; } if (!readable(first, bytes)) return false; out.reserve(n); for (std::size_t i = 0; i < n; ++i) { std::string name; if (!ReadStdStringMapped(first + i * kObservedTechSize + A::ObservedTech_off_Name, &readable, &IdentityToHost, name)) { truncated = true; return false; } out.push_back(std::move(name)); } return true; } // ---- transcribing the live tree into the pure graph ---------------------------------------- // A node's outgoing edges: `vector` at TechNode+0x04. Each edge carries the child's // TechDef at +0x40 (indexed back into the node vector by its tech id) and the RP cost of the // child when reached through this parent at +0x1c. bool read_children(const void* node, std::vector& out) { out.clear(); const char* first = static_cast(ptr_at(node, A::TechNode_off_Children)); const char* last = static_cast(ptr_at(node, A::TechNode_off_Children + 4)); if (!first && !last) return true; if (!first || last < first) return false; const std::size_t bytes = static_cast(last - first); if (bytes % sizeof(void*)) return false; const std::size_t n = bytes / sizeof(void*); if (n > kMaxEdgesPerNode) return false; if (n && !readable(first, bytes)) return false; out.reserve(n); for (std::size_t i = 0; i < n; ++i) { void* edge = ptr_at(first, i * sizeof(void*)); if (!readable(edge, kTechEdgeProbe)) return false; void* childDef = ptr_at(edge, A::TechEdge_off_ChildDef); sots::sim::TechEdgeRef e; e.childIndex = tech_id_of(childDef); e.costRP = word_at(edge, A::TechEdge_off_CostRP); out.push_back(e); } return true; } // The prerequisite structure at TechDef+0x88: a flat entry array plus a vector of {start, count} // groups indexing it. A read that cannot be completed is reported as `unreadable`, which the // model treats as "not met" -- the safe direction, since a false "met" would unlock techs the // original leaves alone. void read_prereqs(const void* def, sots::sim::TechPrereqs& out) { out.groups.clear(); out.unreadable = true; const char* block = static_cast(def) + A::TechDef_off_Prereqs; if (!readable(block, A::TechPrereqs_off_Groups + 8)) return; const char* entries = static_cast(ptr_at(block, A::TechPrereqs_off_Entries)); const char* gfirst = static_cast(ptr_at(block, A::TechPrereqs_off_Groups)); const char* glast = static_cast(ptr_at(block, A::TechPrereqs_off_Groups + 4)); if (!gfirst && !glast) { // no groups at all: PrereqsMet returns true out.unreadable = false; return; } if (!gfirst || glast < gfirst) return; const std::size_t gbytes = static_cast(glast - gfirst); if (gbytes % A::TechPrereqs_group_stride) return; const std::size_t ngroups = gbytes / A::TechPrereqs_group_stride; if (ngroups > kMaxPrereqGroups) return; if (ngroups && !readable(gfirst, gbytes)) return; out.groups.reserve(ngroups); for (std::size_t g = 0; g < ngroups; ++g) { const char* gp = gfirst + g * A::TechPrereqs_group_stride; const int start = word_at(gp, 0); const int count = word_at(gp, 4); if (start < 0 || count < 0 || static_cast(count) > kMaxPrereqEntries) return; std::vector group; group.reserve(static_cast(count)); if (count > 0) { const char* base = entries + static_cast(start) * A::TechPrereqs_entry_stride; if (!readable(base, static_cast(count) * A::TechPrereqs_entry_stride)) return; for (int k = 0; k < count; ++k) { void* edef = ptr_at(base, static_cast(k) * A::TechPrereqs_entry_stride); group.push_back(tech_id_of(edef)); // -1 for a null def: satisfies nothing } } out.groups.push_back(std::move(group)); } out.unreadable = false; } // Build the whole graph from the node pointers the caller hands us -- the SCRATCH copies in // compare mode, so nothing the cascade decides is contaminated by what the original just did. // The defs, the edges and the prerequisite arrays are static data the original never writes, so // those are read live. // // Returns false if any node's shape could not be read. The caller must then pass "no unlock // list" rather than an empty one: a graph we could not build must not look like a graph with // nothing in it. bool build_graph(const std::vector& nodes, sots::sim::TechGraph& g) { g.nodes.assign(nodes.size(), sots::sim::TechGraphNode{}); for (std::size_t i = 0; i < nodes.size(); ++i) { void* p = nodes[i]; if (!p) continue; // a null slot: present stays false and both sweeps skip it sots::sim::TechGraphNode& n = g.nodes[i]; n.present = true; n.state = word_at(p, A::TechNode_off_State); n.costRP = word_at(p, A::TechNode_off_CostRP); n.turnAvailable = word_at(p, A::TechNode_off_TurnAvailable); n.turnResearched = word_at(p, A::TechNode_off_TurnResearched); n.order = word_at(p, A::TechNode_off_Order); if (!read_children(p, n.children)) return false; void* def = ptr_at(p, A::TechNode_off_Def); if (!readable(def, kTechDefProbe)) return false; n.selfIndex = word_at(def, A::TechDef_off_TechId); n.excludedFromSweep = *(static_cast(def) + A::TechDef_off_NoAutoAvailable) != 0; read_prereqs(def, n.prereqs); } return true; } // ---- describers ------------------------------------------------------------------------- Tv describe_rng(const void* p, std::size_t, unsigned inline_max) { Tv s = tv::struct_(); s.add("vptr", tv::ptr(ptr_at(p, 0))); // The whole untempered block, hashed (it is far larger than inline_max): equality of the // hash plus equality of `left` is exactly "the two generators are in the same place in // the same stream". s.add("mt", tv::bytes(static_cast(p) + A::RNG_off_State, static_cast(kMtWords) * 4, inline_max)); const std::int32_t left = word_at(p, A::RNG_off_Left); s.add("left", tv::i32(left)); // `next` is a heap address, so it is reported as its index into mt -- which is what it // means, and what survives being written by a reimplementation. const std::uintptr_t next = reinterpret_cast(ptr_at(p, A::RNG_off_Next)); const std::uintptr_t base = g_call.rng_base + A::RNG_off_State; std::int64_t index = -1; if (g_call.rng_base && next >= base) index = static_cast((next - base) / 4); s.add("next_index", tv::i64(index)); return s; } Tv describe_node(const void* p, std::size_t, unsigned) { Tv s = tv::struct_(); void* def = ptr_at(p, A::TechNode_off_Def); s.add("def", tv::ptr(def)); s.add("tech_id", tv::i32(tech_id_of(def))); // +4..+0xc is the children vector (three words) and +0x10 / +0x30 are not modelled; // emitted as opaque pointers so a change still shows without creating a false divergence. s.add("kids_begin", tv::ptr(ptr_at(p, 0x4))); s.add("kids_end", tv::ptr(ptr_at(p, 0x8))); s.add("kids_cap", tv::ptr(ptr_at(p, 0xc))); s.add("unk10", tv::ptr(ptr_at(p, 0x10))); s.add("state", tv::i32(word_at(p, A::TechNode_off_State))); s.add("cost_rp", tv::i32(word_at(p, A::TechNode_off_CostRP))); s.add("progress", tv::i32(word_at(p, A::TechNode_off_Progress))); s.add("turn_available", tv::i32(word_at(p, 0x20))); s.add("turn_researched", tv::i32(word_at(p, 0x24))); s.add("order", tv::i32(word_at(p, 0x28))); s.add("flag", tv::i32(word_at(p, A::TechNode_off_Flag))); s.add("unk30", tv::ptr(ptr_at(p, 0x30))); return s; } Tv describe_i32(const void* p, std::size_t, unsigned) { Tv s = tv::struct_(); s.add("v", tv::i32(word_at(p, 0))); return s; } // The owner's inline EventStorage. The three vector words are heap pointers (ignored by the // default policy), so what the diff actually compares is `turns` -- how many TurnEvents the // list holds -- and `next_id`, the counter every posted event bumps. // // `ours` now posts the research events into a model storage seeded from the pre-call scan and // writes the resulting counts back into the *scratch* copy of this header, so both sides of the // diff are produced the same way. Before that wiring, this region reported the defect; now it // checks it. `turns_bytes` was an element count the harness audit could not compute because the // TurnEvents stride was unknown -- lane E read it (0x18), so `turns` is emitted too. Tv describe_events(const void* p, std::size_t, unsigned) { Tv s = tv::struct_(); const char* begin = static_cast(ptr_at(p, kEventsVecOff)); const char* end = static_cast(ptr_at(p, kEventsVecOff + 4)); const std::int32_t bytes = end >= begin ? static_cast(end - begin) : -1; s.add("turns_bytes", tv::i32(bytes)); s.add("turns", tv::i32(bytes >= 0 ? bytes / static_cast(A::TurnEvents_sizeof) : -1)); s.add("next_id", tv::i32(word_at(p, kEventsNextIdOff))); s.add("vec_begin", tv::ptr(ptr_at(p, kEventsVecOff))); s.add("vec_end", tv::ptr(ptr_at(p, kEventsVecOff + 4))); s.add("vec_cap", tv::ptr(ptr_at(p, kEventsVecOff + 8))); return s; } // The owner's `vector otch` (ServerPlayer+0x274) -- serialized state that grows on // every tech completion and that no coverage note in B2 or B3 mentioned. // // The element is now fully pinned: `sizeof(ObservedTech)` = `A::ObservedTech_sizeof` = 0x2c (44), // laid out as { vptr; u16 turn_first; u16 turn_last; bool detected; std::string tech_name (0x1c); // int with }. `ours` still does not append to it -- appending is a behavioural change this hook // deliberately does not make -- so the region stays declared-not-modelled and the **byte delta it // reports is the check**: one completion appends exactly one element, so `bytes` must grow by // exactly 44. Anything else means the stride assumption or the append count is wrong. // // When this IS modelled: the original's `RecordObservedTech` **de-duplicates by tech name** before // appending, so a naive `push_back` diverges the second time the same tech is observed. Tv describe_otch(const void* p, std::size_t, unsigned) { Tv s = tv::struct_(); const char* begin = static_cast(ptr_at(p, 0)); const char* end = static_cast(ptr_at(p, 4)); s.add("bytes", tv::i32(end >= begin ? static_cast(end - begin) : -1)); s.add("vec_begin", tv::ptr(ptr_at(p, 0))); s.add("vec_end", tv::ptr(ptr_at(p, 4))); s.add("vec_cap", tv::ptr(ptr_at(p, 8))); return s; } // ---- the generator seen by ours ---------------------------------------------------------- struct ShimRandom final : sots::sim::IRandom { mars::rng::MT19937 gen; unsigned draws = 0; float NextFloat() override { ++draws; return gen.next_float(); } std::uint32_t NextIntInclusive(std::uint32_t n) override { ++draws; return gen.next_int_inclusive(n); } std::uint32_t NextUInt32() override { ++draws; return gen.next_u32(); } }; // ---- the cascade's environment ------------------------------------------------------------- // // `sots::sim::SetResearched` is pure: it asks for two things it cannot know. This binds them to // the running game. struct CascadeCtx { void* tree = nullptr; const std::vector* nodes = nullptr; // the SCRATCH copies in compare mode sots::sim::TechGraph* graph = nullptr; std::vector* model = nullptr; ShimRandom* rand = nullptr; // Pre-call copies, consumed exactly as the original consumes them. const void* research_target = nullptr; bool roll_pending = false; std::vector observed_names; // What the model decided, for the report and the write-backs. int completions = 0; int observed_appends = 0; int roll_draws = 0; int cascade_failures = 0; // a completion the cascade declined to run: an inconsistency bool depth_exceeded = false; bool name_unreadable = false; std::vector unlocked; }; // TechTree::Cost, called on the node as the cascade has it *now* -- its costRP may have just been // lowered by the first sweep, and the cost is a function of that. The game's own implementation // is read-only (no RNG, no writes; see the address entry), so it is called rather than // re-derived: the cost multiplier is a separate, lower-confidence formula and guessing it here // would put a second unknown inside the one being measured. int CascadeCost(void* ctx, int nodeIndex) { CascadeCtx& c = *static_cast(ctx); if (nodeIndex < 0 || static_cast(nodeIndex) >= c.nodes->size()) return sots::sim::kNoResearchCost; void* p = (*c.nodes)[static_cast(nodeIndex)]; if (!p || !g_env.cost) return sots::sim::kNoResearchCost; // Publish the model's costRP into the copy first, so Cost reads the value the cascade has // reached and not the one it started from. In compare mode this is the scratch copy, which // the write-back at the end of `ours` overwrites with the same value. set_word(p, A::TechNode_off_CostRP, c.graph->nodes[static_cast(nodeIndex)].costRP); return g_env.cost(c.tree, p); } // ServerPlayer::OnTechResearched, modelled only as far as this hook's regions can see it: // the observed-tech append and the research-event RNG draw. The tech effects it also applies -- // the ~90 hard-coded ServerPlayer field writes -- are B2's milestone, are not reproduced, and // are what the `player` guard reports. void CascadeOnResearched(void* ctx, int nodeIndex, bool /*silent*/) { CascadeCtx& c = *static_cast(ctx); ++c.completions; if (nodeIndex < 0 || static_cast(nodeIndex) >= c.nodes->size()) return; void* p = (*c.nodes)[static_cast(nodeIndex)]; if (!p) return; void* def = ptr_at(p, A::TechNode_off_Def); // 1. RecordObservedTech, the first statement of OnTechResearched, unconditional and // de-duplicating by tech NAME. "No delta" is a real outcome, not a failure, so the check // has to be the name and not just "did something complete". std::string name; if (readable(def, A::TechDef_off_Name + kStdStringSize) && ReadStdStringMapped(static_cast(def) + A::TechDef_off_Name, &readable, &IdentityToHost, name)) { bool seen = false; for (const std::string& s : c.observed_names) if (s == name) { seen = true; break; } if (!seen) { c.observed_names.push_back(name); ++c.observed_appends; } } else { // Never guess an append: an unread name is reported and the count is left alone, which // shows up as a divergence rather than as a silent agreement. c.name_unreadable = true; } // 2. `if (ResT == def) { if (ResearchRollPending) RollResearchEvent(); pending = 0; ResT = 0; }` // RollResearchEvent draws one NextFloat unconditionally. That single word is the cost of // REACHING its branch, NOT the cost of a fired roll: when the roll beats the odds the // plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, and the // rebellion path cancels the research. `ours` models the first word only, and the branch // is declared unmodelled. Clearing ResT is what makes a second completion in the same // pass draw nothing. if (def && def == c.research_target) { if (c.roll_pending) { c.rand->NextFloat(); ++c.roll_draws; } c.roll_pending = false; c.research_target = nullptr; } } // Run the cascade at the moment ProcessResearch would call SetResearched. void OnStepCompleted(void* ctx, std::size_t /*stepIndex*/, int nodeIndex) { CascadeCtx& c = *static_cast(ctx); if (nodeIndex < 0 || static_cast(nodeIndex) >= c.graph->nodes.size()) return; sots::sim::TechCascadeEnv env; env.cost = &CascadeCost; env.onResearched = &CascadeOnResearched; env.ctx = &c; // SetResearched is called with the node's *def*, and re-derives the node from the def's tech // id -- so the argument is the slot's selfIndex, not the slot. const int defIndex = c.graph->nodes[static_cast(nodeIndex)].selfIndex; const sots::sim::TechCascadeResult r = sots::sim::SetResearched(*c.graph, defIndex, sots::sim::kTechForce, env); if (!r.ran) ++c.cascade_failures; if (r.depthExceeded) c.depth_exceeded = true; // Reflect the cascade's state changes into the pass model, so the decay sweep that follows // sees the tree the original's decay sweep sees. States only ever rise on this path // (Hidden -> ParentResearched -> Available -> Researched, and ApplyResearchPoints' own jump // straight to Researched), so a monotone merge is both faithful and incapable of undoing // what the pass just decided. for (std::size_t i = 0; i < c.model->size() && i < c.graph->nodes.size(); ++i) { const int gs = c.graph->nodes[i].state; if (gs > static_cast((*c.model)[i].state)) (*c.model)[i].state = static_cast(gs); } } } // namespace // ---- descriptor --------------------------------------------------------------------------- void TechTreeProcessResearchHook::describe_args(std::vector& out, void* tree, void* rng, void* alloc, int* overbudget) { out.push_back(tv::ptr(tree).named("tree")); void* owner = readable(tree, kTreeHeadSize) ? ptr_at(tree, A::TechTree_off_Owner) : nullptr; out.push_back(tv::ptr(owner).named("owner")); const int species = readable(owner, A::ServerPlayer_off_Species + 4) ? word_at(owner, A::ServerPlayer_off_Species) : -1; out.push_back(tv::i32(species).named("species")); std::vector nodes; const bool nodes_ok = tree_nodes(tree, nodes); out.push_back(tv::u32(static_cast(nodes.size())).named("node_count")); if (!nodes_ok) out.push_back(tv::boolean(true).named("nodes_invalid")); out.push_back(tv::ptr(rng).named("rng")); out.push_back(tv::i32(readable(rng, kRngSize) ? word_at(rng, A::RNG_off_Left) : -1).named("rng_left_in")); std::vector entries; const bool alloc_ok = alloc_entries(alloc, entries); std::vector items; items.reserve(entries.size()); for (const AllocEntry& e : entries) { Tv t = tv::struct_(); t.add("tech_id", tv::i32(tech_id_of(e.target))); t.add("points", tv::i32(e.points)); items.push_back(std::move(t)); } out.push_back(tv::list(std::move(items)).named("alloc")); if (!alloc_ok) out.push_back(tv::boolean(true).named("alloc_invalid")); out.push_back(tv::i32(readable(overbudget, 4) ? word_at(overbudget, 0) : 0).named("overbudget_in")); // The x87 precision mode in force for this call (see fpu_control_word above). out.push_back(tv::u32(fpu_control_word()).named("fpu_cw")); // ---- the pre-call read of the owner's event list ---------------------------------------- // // Taken here, not in ours(): in compare mode ours runs AFTER the original, so a scan taken // then would see the original's own posts and our model would deduplicate against them -- // posting nothing and "agreeing" for exactly the wrong reason. g_scan = EventScan{}; g_scan.owner_ok = readable(owner, kPlayerEventsOff + kEventStorageSize); g_scan.turn = current_turn(owner, g_scan.turn_ok); if (g_scan.owner_ok && g_scan.turn_ok) { // The shim is a 32-bit build living in the game's own address space, so a game pointer // maps to a host pointer by a cast (the adapter static_asserts the width). g_scan.storage = ScanEventStorage(static_cast(owner) + kPlayerEventsOff, g_scan.turn, &readable, &IdentityToHost); } out.push_back(tv::i32(g_scan.turn_ok ? g_scan.turn : -1).named("turn")); out.push_back(tv::i32(g_scan.storage.ok ? g_scan.storage.nextId : -1).named("events_next_id_in")); out.push_back(tv::u32(static_cast(g_scan.storage.bucketTurns.size())) .named("events_turns_in")); out.push_back(tv::boolean(g_scan.storage.turnBucketExists).named("events_turn_bucket_exists")); out.push_back(tv::u32(static_cast(g_scan.storage.eventsInTurnBucket)) .named("events_in_turn_bucket")); // THE assumption behind a count-only model, measured instead of assumed. Our model rebuilds // the turn buckets but not their contents, so it can only ever fail to deduplicate against a // record that was already there -- and only a record carrying one of the six research // `EvImg` identifiers can be a duplicate of anything we post. 0 means count-only is exact on // this call; anything else means our id count is a lower bound and the run must say so. out.push_back(tv::u32(static_cast(g_scan.storage.researchEventsInTurnBucket)) .named("events_dedup_risk")); if (g_scan.storage.scanTruncated) out.push_back(tv::boolean(true).named("events_scan_truncated")); // ---- the pre-call reads the unlock cascade needs ----------------------------------------- // // All four are state the ORIGINAL changes during the call. Taken here for the same reason the // event scan is, and reported as arguments so a run can be audited without trusting `ours`: // if `order_counter_in` is not one less than the first `node[*].order` the original writes, // or `roll_pending_in` is false on a call whose `rng` moved by an extra word, the model was // fed the wrong inputs and every clean field below it is meaningless. g_pre = PreCallScan{}; g_pre.tree_ok = readable(tree, kTreeGuardSize); if (g_pre.tree_ok) { g_pre.order_counter = word_at(tree, A::TechTree_off_OrderCounter); g_pre.has_owner = ptr_at(tree, A::TechTree_off_Owner) != nullptr; } g_pre.player_ok = readable(owner, kPlayerSize); if (g_pre.player_ok) { g_pre.research_target = ptr_at(owner, A::ServerPlayer_off_ResearchTarget); g_pre.roll_pending = *(static_cast(owner) + A::ServerPlayer_off_ResearchRollPending) != 0; g_pre.observed_ok = scan_observed_techs(static_cast(owner) + kObservedTechsOff, g_pre.observed_names, g_pre.observed_truncated); } out.push_back(tv::i32(g_pre.tree_ok ? g_pre.order_counter : -1).named("order_counter_in")); out.push_back(tv::ptr(const_cast(g_pre.research_target)).named("research_target")); out.push_back(tv::boolean(g_pre.roll_pending).named("roll_pending_in")); out.push_back(tv::u32(static_cast(g_pre.observed_names.size())) .named("observed_techs_in")); if (!g_pre.observed_ok) out.push_back(tv::boolean(true).named("observed_scan_failed")); if (g_pre.observed_truncated) out.push_back(tv::boolean(true).named("observed_scan_truncated")); } void TechTreeProcessResearchHook::regions(std::vector& out, void* tree, void* rng, void* alloc, int* overbudget) { (void)alloc; g_call = CallState{}; g_call.rng_base = reinterpret_cast(rng); if (!readable(rng, kRngSize)) throw std::runtime_error("rng object not readable"); if (!readable(overbudget, 4)) throw std::runtime_error("overbudget not readable"); trace::Region r; r.name = "rng"; r.ptr = rng; r.size = kRngSize; r.describe = &describe_rng; g_call.rng_region = out.size(); out.push_back(r); r = trace::Region{}; r.name = "overbudget"; r.ptr = overbudget; r.size = sizeof(int); r.describe = &describe_i32; g_call.ob_region = out.size(); out.push_back(r); std::vector nodes; if (!tree_nodes(tree, nodes)) throw std::runtime_error("tech-tree node vector not readable"); // Reserve once: Region::name holds a pointer into these strings for the whole call, so the // vector must never reallocate afterwards. g_call.names.resize(nodes.size()); g_call.node_region.assign(nodes.size(), -1); for (std::size_t i = 0; i < nodes.size(); ++i) { char buf[32]; std::snprintf(buf, sizeof buf, "node[%u]", static_cast(i)); g_call.names[i] = buf; void* p = nodes[i]; if (!readable(p, kNodeSize)) continue; // a null slot in the tree: nothing to compare g_call.node_region[i] = static_cast(out.size()); trace::Region n; n.name = g_call.names[i].c_str(); n.ptr = p; n.size = kNodeSize; n.describe = &describe_node; out.push_back(n); } // The owner's event storage. This is the region whose absence made B3's compare clean while // replace mode diverged: the over-budget branch posts EVENT_RESEARCH_OVERBUDGET, which bumps // EvNxID, and nothing declared here could see it. `ours` now posts into a model storage and // writes the counts into this region's scratch copy, so the region is a check and not just an // alarm -- see ours() and docs/P-events-wiring.md. void* owner = readable(tree, kTreeHeadSize) ? ptr_at(tree, A::TechTree_off_Owner) : nullptr; g_call.events_region = -1; if (readable(owner, kPlayerEventsOff + kEventStorageSize)) { g_call.events_region = static_cast(out.size()); trace::Region ev; ev.name = "events"; ev.ptr = static_cast(owner) + kPlayerEventsOff; ev.size = kEventStorageSize; ev.describe = &describe_events; out.push_back(ev); } // The owner's vector. Declared, not modelled: see describe_otch. g_call.otch_region = -1; if (readable(owner, kObservedTechsOff + kVectorHeaderSize)) { g_call.otch_region = static_cast(out.size()); trace::Region ot; ot.name = "observed_techs"; ot.ptr = static_cast(owner) + kObservedTechsOff; ot.size = kVectorHeaderSize; ot.describe = &describe_otch; out.push_back(ot); } // ---- guards: pushed LAST so every recorded index above is also a Scratch index ---------- // // Coarse spans that no reimplementation writes. Anything the ORIGINAL moves inside them and // outside every Result region above is reported as an undeclared write -- which is how the // next B3 gets found before a save-hash oracle has to find it. if (readable(owner, kPlayerSize)) { trace::Region g; g.name = "player"; g.ptr = owner; g.size = kPlayerSize; g.kind = trace::Region::Kind::Guard; out.push_back(g); } if (readable(tree, kTreeGuardSize)) { // Catches the completion-order counter at TechTree+0x20, which SetResearched bumps and // which the per-node `order` word is read from. trace::Region g; g.name = "tree_header"; g.ptr = tree; g.size = kTreeGuardSize; g.kind = trace::Region::Kind::Guard; out.push_back(g); } } TechTreeProcessResearchHook::Args TechTreeProcessResearchHook::rebind(trace::Scratch& s, void* tree, void* rng, void* alloc, int* overbudget) { (void)rng; (void)overbudget; g_call.scratch_nodes.assign(g_call.node_region.size(), nullptr); for (std::size_t i = 0; i < g_call.node_region.size(); ++i) { if (g_call.node_region[i] >= 0) g_call.scratch_nodes[i] = s.ptr(static_cast(g_call.node_region[i])); } // The event storage is not one of the four parameters, so its scratch copy travels the same // way the node copies do: through CallState, resolved here where Scratch is in hand. g_call.scratch_events = g_call.events_region >= 0 ? s.ptr(static_cast(g_call.events_region)) : nullptr; g_call.scratch_otch = g_call.otch_region >= 0 ? s.ptr(static_cast(g_call.otch_region)) : nullptr; g_call.compare = true; // The tree pointer is passed through unchanged: ours only reads it (owner, node count) and // hands it to the game's own read-only Cost. Every node it writes is a scratch copy. return Args(tree, s.ptr(g_call.rng_region), alloc, static_cast(s.ptr(g_call.ob_region))); } void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int* overbudget) { using namespace sots::sim; const bool compare = g_call.compare; g_call.compare = false; // replace-mode calls must not inherit a stale mapping std::vector live; if (!tree_nodes(tree, live)) throw std::runtime_error("tech-tree node vector not readable"); const std::vector& nodes = compare ? g_call.scratch_nodes : live; if (nodes.size() != live.size()) throw std::runtime_error("scratch node mapping is stale"); void* owner = ptr_at(tree, A::TechTree_off_Owner); const int species_index = readable(owner, A::ServerPlayer_off_Species + 4) ? word_at(owner, A::ServerPlayer_off_Species) : -1; const Species owner_species = (species_index >= 0 && species_index < kSpeciesCount) ? static_cast(species_index) : static_cast(kSpeciesCount); // Model every slot. A slot the tree does not have stays Hidden so the decay pass skips it, // exactly as the original's null check does. std::vector model(nodes.size()); for (std::size_t i = 0; i < nodes.size(); ++i) { void* p = nodes[i]; if (!p) { model[i].state = TechState::Hidden; model[i].cost = 0; continue; } model[i].state = static_cast(word_at(p, A::TechNode_off_State)); model[i].progress = word_at(p, A::TechNode_off_Progress); model[i].flag = static_cast(word_at(p, A::TechNode_off_Flag)); model[i].cost = 0; } std::vector raw; if (!alloc_entries(alloc, raw)) throw std::runtime_error("research allocation not readable"); std::vector entries; entries.reserve(raw.size()); for (const AllocEntry& e : raw) entries.push_back({tech_id_of(e.target), e.points}); // Effective cost, from the game's own read-only TechTree::Cost, for every node the // original would ask about: the allocation targets and every available node. if (!g_env.cost) throw std::runtime_error("TechTree::Cost address not initialised"); auto fill_cost = [&](int i) { if (i < 0 || static_cast(i) >= nodes.size() || !nodes[i]) return; model[i].cost = g_env.cost(tree, nodes[i]); }; for (const ResearchAllocEntry& e : entries) fill_cost(e.nodeIndex); for (std::size_t i = 0; i < nodes.size(); ++i) if (model[i].state == TechState::Available) fill_cost(static_cast(i)); // Seed our generator from the pre-call state so both implementations read one stream. ShimRandom rand; { std::uint32_t mt[mars::rng::MT19937::N]; std::memcpy(mt, static_cast(rng) + A::RNG_off_State, sizeof mt); const std::int32_t left = word_at(rng, A::RNG_off_Left); if (left < 0 || left > kMtWords) throw std::runtime_error("generator `left` out of range"); rand.gen.load_state(mt, left); } // ---- the unlock cascade ------------------------------------------------------------------- // // `TechTree::SetResearched` is what ProcessResearch calls the instant a node completes, and // it is the source of five things this hook could previously only watch go past: the // completed node's turn/order stamps, the child costs and states, the availability stamp // EVENT_TECHS_UNLOCKED is computed from, the observed-tech append, and one RNG word. // // It runs in COMPARE MODE ONLY, for the same reason the event post does. In replace mode // every pointer here is live game memory, and running half of OnTechResearched -- the // observed-tech append and the research-event roll, but not the ninety-odd tech-effect field // writes -- would leave the player in a state no code path produces. Not running it leaves a // player missing a cascade, which is a smaller and already-declared lie. // // The graph is transcribed from the SCRATCH node copies, so what the cascade decides is a // function of the pre-call tree and not of what the original just did to the live one. sots::sim::TechGraph graph; CascadeCtx cc; const bool cascade_possible = compare && g_scan.turn_ok && g_pre.tree_ok; bool cascade_ok = false; if (cascade_possible) { cascade_ok = build_graph(nodes, graph); graph.orderCounter = g_pre.order_counter; graph.hasOwner = owner != nullptr; graph.turn = g_scan.turn; cc.tree = tree; cc.nodes = &nodes; cc.graph = &graph; cc.model = &model; cc.rand = &rand; cc.research_target = g_pre.research_target; cc.roll_pending = g_pre.roll_pending; cc.observed_names = g_pre.observed_names; // An observed-tech scan that failed is not an empty list: without it the de-duplication // cannot be decided, so the append is not modelled at all and the region keeps reporting // the difference. if (!g_pre.observed_ok) cc.name_unreadable = true; } const ResearchTurnResult r = cascade_ok ? ProcessResearchTurn(model, entries, owner_species, rand, &OnStepCompleted, &cc) : ProcessResearchTurn(model, entries, owner_species, rand); // Write back exactly the words the original function itself writes... for (std::size_t i = 0; i < nodes.size(); ++i) { void* p = nodes[i]; if (!p) continue; set_word(p, A::TechNode_off_Progress, model[i].progress); set_word(p, A::TechNode_off_Flag, static_cast(model[i].flag)); set_word(p, A::TechNode_off_State, static_cast(model[i].state)); } // ...and, when the cascade ran, the four words SetResearched writes. These are skipped // wholesale if the graph could not be transcribed: a partially built graph would write // default sentinels over real values, which is worse than not writing at all. if (cascade_ok) { for (std::size_t i = 0; i < nodes.size(); ++i) { void* p = nodes[i]; if (!p || !graph.nodes[i].present) continue; set_word(p, A::TechNode_off_CostRP, graph.nodes[i].costRP); set_word(p, A::TechNode_off_TurnAvailable, graph.nodes[i].turnAvailable); set_word(p, A::TechNode_off_TurnResearched, graph.nodes[i].turnResearched); set_word(p, A::TechNode_off_Order, graph.nodes[i].order); } cc.unlocked = sots::sim::CollectNewlyAvailable(graph, g_scan.turn); } set_word(overbudget, 0, word_at(overbudget, 0) + r.overbudget); // ... and the generator, in the object's own layout. `next` is rebuilt against the LIVE // base so the describer's index arithmetic is the same for both sides. { std::uint8_t blob[mars::rng::MT19937::kStateBytes]; rand.gen.save_state(blob); std::memcpy(static_cast(rng) + A::RNG_off_State, blob, static_cast(kMtWords) * 4); set_word(rng, A::RNG_off_Left, rand.gen.left()); const std::uintptr_t base = (compare ? g_call.rng_base : reinterpret_cast(rng)) + A::RNG_off_State; set_ptr(rng, A::RNG_off_Next, reinterpret_cast(base + static_cast(rand.gen.index()) * 4)); } // ---- the events this pass posts (count-only) -------------------------------------------- // // Lane E's option (a): `ours` posts into its OWN sots::events::EventStorage, seeded from the // pre-call scan, and the counts go into the SCRATCH copy of the owner's storage header. The // game's own EventStorage::PostEvent is never called and no live byte is written, so this // cannot perturb the VM -- and it is the reason the write below is gated on `compare`. // // What that proves and what it does not: it proves the *decision* (did this pass post, how // many, in what id order) and it makes `region:events` a check. It does not prove the text, // which the game composes from its own string table -- see docs/P-events-wiring.md. // // In replace mode nothing is written: an EvNxID bumped without a serialized record behind it // would corrupt the save the oracle hashes, which is strictly worse than the missing event. if (compare && g_call.scratch_events && g_scan.storage.ok && g_scan.turn_ok) { sots::events::EventStorage log = SeedFromScan(g_scan.storage); const sots::events::EventText text = sots::events::KeylessEventText(); // ProcessResearchTurn emits one step per allocation entry **that was in range**, so the // steps are not index-aligned with `entries` when the budget names a tech this tree does // not have. Mirror its filter to recover which node each step belongs to. (Duplicating // the predicate is the price of not adding a field to the shared sim header while lane M // is working in src/game/sim.) std::vector step_node; step_node.reserve(entries.size()); for (const ResearchAllocEntry& e : entries) { if (e.nodeIndex < 0 || static_cast(e.nodeIndex) >= model.size()) continue; step_node.push_back(e.nodeIndex); } if (step_node.size() != r.steps.size()) throw std::runtime_error("research step/entry mapping is stale"); std::vector outcomes; outcomes.reserve(r.steps.size()); for (std::size_t i = 0; i < r.steps.size(); ++i) { sots::events::ResearchPassOutcome o; // The token only has to separate techs whose messages the original separates; the // node's own index does that and needs no string table. See KeylessEventText. char buf[24]; std::snprintf(buf, sizeof buf, "%d", step_node[i]); o.techName = buf; o.overbudgetEvent = r.steps[i].overbudgetEvent; o.completed = r.steps[i].completed; o.completedEarly = r.steps[i].completedEarly; outcomes.push_back(std::move(o)); } // The EVENT_TECHS_UNLOCKED set. `nullptr` still means "this caller could not compute it" // -- when the graph did not transcribe, or the tail's `tree->owner != 0` gate is closed. // An empty vector means "computed, and nothing became available this turn", which is a // modelled negative and posts nothing. Keeping the two apart is what stops a failure to // read the tree from scoring as a correct silence. std::vector unlocked_names; const bool unlocked_known = cascade_ok && graph.hasOwner; if (unlocked_known) { unlocked_names.reserve(cc.unlocked.size()); for (int idx : cc.unlocked) { // The same keyless token the other events use: the node index, which separates // exactly the techs the original's names separate, and carries no game text. char buf[24]; std::snprintf(buf, sizeof buf, "%d", idx); unlocked_names.emplace_back(buf); } } sots::events::PostResearchPassEvents(log, text, outcomes, unlocked_known ? &unlocked_names : nullptr, g_scan.turn); WriteBackCounts(g_call.scratch_events, log, &readable); } // ---- the observed-tech append --------------------------------------------------------- // // Same shape as the event counts and the same limits: the element is not constructed, only // the vector's byte span in the SCRATCH header is moved, by exactly one 0x2c element per // append the model decided on. The decision is the check -- RecordObservedTech de-duplicates // by tech name, so "no delta" is a real outcome and a model that always added 44 would score // on this workload and be wrong the first time a tech is re-observed. if (compare && g_call.scratch_otch && cascade_ok && cc.observed_appends > 0 && !cc.name_unreadable) { char* first = static_cast(ptr_at(g_call.scratch_otch, 0)); char* last = static_cast(ptr_at(g_call.scratch_otch, 4)); if (first && last >= first) set_ptr(g_call.scratch_otch, 4, last + static_cast(cc.observed_appends) * kObservedTechSize); } // A line per call, so a run can be read without the trace: these are the counts that say the // cascade actually ran, and a clean compare with all of them at zero would be a clean compare // of nothing. if (cascade_possible) { logf("research: cascade ok=%d completions=%d unlocked=%u otch_appends=%d roll_draws=%d " "failures=%d depth=%d name_unreadable=%d", cascade_ok ? 1 : 0, cc.completions, static_cast(cc.unlocked.size()), cc.observed_appends, cc.roll_draws, cc.cascade_failures, cc.depth_exceeded ? 1 : 0, cc.name_unreadable ? 1 : 0); } } void init_research(std::uintptr_t exe_base, void (*log_line)(const char* line)) { g_env.exe_base = exe_base; g_env.log_line = log_line; g_env.cost = reinterpret_cast(exe_base + A::TechTree_Cost); logf("research: ProcessResearch hook ready (Cost=%p, node=0x%x, rng=0x%x, fpu_cw=0x%04x)", reinterpret_cast(g_env.cost), static_cast(kNodeSize), static_cast(kRngSize), fpu_control_word()); } } // namespace shim::hooks