#include "shim/hooks/research.h" #include #include #include #include #include #include #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #include #endif #include "game/sim/research.h" #include "game/sim/species.h" #include "generated/sots_addresses.h" #include "mars/rng/mt19937.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 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; 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 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); } // ---- 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 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(); } }; } // 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")); } 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); } } 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])); } 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); } const ResearchTurnResult r = 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)); } 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)); } } 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