#include "shim/hooks/tech_effects.h" #include #include #include #include #include #include #include #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #include #endif #include "game/effects/tech_effects.h" #include "shim/hooks/tech_effect_fields.h" #include "game/effects/tech_id.h" #include "game/sim/species.h" #include "game/sim/tuning.h" #include "generated/sots_addresses.h" namespace shim::hooks { using trace::Tv; namespace tv = trace::tv; namespace fx = sots::effects; namespace tfx = shim::hooks::techfx; namespace { namespace A = sots::addr; // The two game entry points `ours` leans on. Both are verified, read-only reads of the // tech tree: the same delegation B3 makes to TechTree::Cost. using IsTechFn = bool(SHIM_THISCALL*)(void* master, void* def, int techId); using HasResearchedFn = bool(SHIM_THISCALL*)(void* tree, int techId); // Reselects and (re)allocates the node-bore parameter block. Used only in replace mode, // where `ours` has to leave the game in a consistent state and cannot allocate itself. using UpdateBoreFn = void(SHIM_THISCALL*)(void* self); struct Env { std::uintptr_t exe_base = 0; void (*log_line)(const char*) = nullptr; IsTechFn is_tech = nullptr; HasResearchedFn has_researched = nullptr; UpdateBoreFn update_bore = nullptr; const int* gate_tpgate = nullptr; const int* gate_gatamp = 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 (the guard M2/B3 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 } // The x87 control word in force for the call. The modifier arithmetic is `fld dword; // fadd qword; fstp dword`, so the precision-control field decides whether the middle step // rounds to 53 or to 24 significand bits -- the same open question B3 records. 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 --------------------------------------------------------------------- // // OnTechResearched CAN nest: the Zuul Cruiser-Construction branch grants boarding pods via // SetResearched, which calls the callback again. `ours` never does that (it only reports // the grant), so the statics below are only ever written by the outer invocation of the // *hook*; a nested original call runs inside the original, not inside the hook. The depth // counter makes that assumption checkable rather than assumed. struct CallState { bool compare = false; int region_of[tfx::kRegionCount] = {}; tfx::Views views; // the scratch copies, once rebind() has resolved them }; CallState g_call; void reset_call() { g_call = CallState{}; for (int i = 0; i < tfx::kRegionCount; ++i) g_call.region_of[i] = -1; } // The few reads `ours` makes straight off the live object -- fields the callback never // writes, so "live" and "before" are the same value. std::int32_t i32_at(const void* p, std::size_t off) { std::int32_t v = 0; std::memcpy(&v, static_cast(p) + off, sizeof v); return v; } void* ptr_at(const void* p, std::size_t off) { void* v = nullptr; std::memcpy(&v, static_cast(p) + off, sizeof v); return v; } std::uint8_t u8_at(const void* p, std::size_t off) { return static_cast(p)[off]; } // ---- tech identity ---------------------------------------------------------------------- void* master_tree_of(void* self) { void* tree = ptr_at(self, A::ServerPlayer_off_TechTree); if (!readable(tree, A::TechTree_off_Master + 4)) return nullptr; return ptr_at(tree, A::TechTree_off_Master); } // The TechId of a definition. // // TechDef's first word is NOT the TechId: MasterTechTree::IsTech maps `id - 10000` into the // master table and compares TechDef pointers, while TechTree::HasResearched uses that same // map to reach a TechDef and *then* uses the def's first word as an index into the node // vector. So the first word is a node index in a different, larger key space, and the only // way to get the TechId is the identity test the callback itself uses. 196 calls into a // three-compare function, once per completion. int resolve_tech_id(void* self, void* def) { if (!readable(def, 4)) return -1; void* master = master_tree_of(self); if (!master || !g_env.is_tech) return -1; for (int i = 0; i < fx::kTechIdCount; ++i) { const int id = fx::kTechIdBase + i; if (g_env.is_tech(master, def, id)) return id; } return -1; // a tech that is not in the 196-name table has no code effect } // ---- our state <-> the player object ---------------------------------------------------- // The gate-traffic config words, read from the executable's own storage. sots::sim::TuningTable gate_tuning() { sots::sim::TuningTable t; if (g_env.gate_tpgate) t.PERGATETRAFFIC_DRV_TpGate = *g_env.gate_tpgate; if (g_env.gate_gatamp) t.PERGATETRAFFIC_DRV_GatAmp = *g_env.gate_gatamp; return t; } } // namespace // ---- descriptor --------------------------------------------------------------------------- void ServerPlayerOnTechResearchedHook::describe_args(std::vector& out, void* self, void* def, bool silent) { out.push_back(tv::ptr(self).named("player")); const bool ok = readable(self, tfx::PlayerSpan()); out.push_back(tv::i32(ok ? i32_at(self, A::ServerPlayer_off_PlyrIdx) : -1).named("player_index")); out.push_back(tv::i32(ok ? i32_at(self, A::ServerPlayer_off_Species) : -1).named("species")); out.push_back(tv::ptr(def).named("def")); const int id = ok ? resolve_tech_id(self, def) : -1; out.push_back(tv::i32(id).named("tech_id")); // The def's own first word, so a trace shows the two key spaces side by side (it is the // node-vector index, not the TechId -- see resolve_tech_id). out.push_back(tv::i32(readable(def, 4) ? i32_at(def, A::TechDef_off_TechId) : -1).named("def_node_index")); const char* name = fx::IsValidTechId(id) ? fx::TechIdName(static_cast(id)) : nullptr; out.push_back(tv::str(name).named("tech_name")); out.push_back(tv::boolean(silent).named("silent")); out.push_back(tv::boolean(ok && ptr_at(self, A::ServerPlayer_off_ResearchTarget) == def) .named("is_current_target")); out.push_back(tv::i32(ok ? i32_at(self, A::ServerPlayer_off_OwnedSystems + 4) - i32_at(self, A::ServerPlayer_off_OwnedSystems) : 0) .named("owned_systems_bytes")); out.push_back(tv::boolean(ok && u8_at(self, A::ServerPlayer_off_RebAI) != 0).named("rebel_ai")); out.push_back(tv::boolean(ok && u8_at(self, A::ServerPlayer_off_AIBn) != 0).named("ai_benefit_in")); out.push_back(tv::i32(g_env.gate_tpgate ? *g_env.gate_tpgate : -1).named("gate_traffic_tpgate")); out.push_back(tv::i32(g_env.gate_gatamp ? *g_env.gate_gatamp : -1).named("gate_traffic_gatamp")); out.push_back(tv::u32(fpu_control_word()).named("fpu_cw")); } void ServerPlayerOnTechResearchedHook::regions(std::vector& out, void* self, void* def, bool silent) { (void)def; (void)silent; reset_call(); if (!readable(self, tfx::PlayerSpan())) throw std::runtime_error("ServerPlayer not readable"); const tfx::Views live = tfx::Views::OverPlayer(self); for (int i = 0; i < tfx::kRegionCount; ++i) { const tfx::RegionDef& d = tfx::kRegions[i]; const void* base = live.base[i]; // The node-bore block is declared only when it already exists: a region has to be // snapshottable *before* the call, and the original allocates it on first use. if (!readable(base, d.size)) continue; trace::Region r; r.name = d.name; r.ptr = base; r.size = d.size; r.describe = d.describe; g_call.region_of[i] = static_cast(out.size()); out.push_back(r); } } ServerPlayerOnTechResearchedHook::Args ServerPlayerOnTechResearchedHook::rebind(trace::Scratch& s, void* self, void* def, bool silent) { for (int i = 0; i < tfx::kRegionCount; ++i) { g_call.views.base[i] = g_call.region_of[i] >= 0 ? s.ptr(static_cast(g_call.region_of[i])) : nullptr; } g_call.compare = true; // `self` is passed through unchanged: `ours` only reads never-written fields off it // (species, the tech tree) and takes everything else from the scratch copies above. return Args(self, def, silent); } void ServerPlayerOnTechResearchedHook::ours(void* self, void* def, bool silent) { (void)silent; // only the events depend on it, and events are not reproduced const bool compare = g_call.compare; g_call.compare = false; // a replace-mode call must never inherit a stale mapping if (!readable(self, tfx::PlayerSpan())) throw std::runtime_error("ServerPlayer not readable"); const tfx::Views v = compare ? g_call.views : tfx::Views::OverPlayer(self); if (!compare) reset_call(); const int raw_id = resolve_tech_id(self, def); // The pending plague-cure roll: the two words are cleared either way, but the roll // itself draws from the strategic generator, so `ours` records it instead of running it. const bool would_roll = tfx::ClearResearchTargetIfMatched(v, def); int species_index = i32_at(self, A::ServerPlayer_off_Species); if (species_index < 0 || species_index >= sots::sim::kSpeciesCount) species_index = 0; fx::PlayerEconomyState s = tfx::ReadPlayerState(v, static_cast(species_index)); // The researched set, from the player's own tree. In compare mode the tree is the live // one, i.e. the state the original's tail saw: the completing node is already marked. void* tree = ptr_at(self, A::ServerPlayer_off_TechTree); if (!tree || !g_env.has_researched) throw std::runtime_error("tech tree not available"); for (int i = 0; i < fx::kTechIdCount; ++i) { if (g_env.has_researched(tree, fx::kTechIdBase + i)) s.researched.set(static_cast(i)); } fx::ApplyContext ctx; sots::sim::TuningTable tuning = gate_tuning(); ctx.tuning = &tuning; fx::TechApplyOutcome outcome; if (fx::IsValidTechId(raw_id)) { // The completion callback, not the guarded wrapper: by the time it runs the node is // already state 4, so an already-researched guard would make it a no-op. outcome = fx::ApplyTechCompletion(s, static_cast(raw_id), ctx); } else { // Roughly half the data files are not in the 196-name key space and so have no // branch of their own -- but the callback still runs its whole tail for them. outcome = fx::RunCompletionTail(s); } // The node-track techs are keyed by name in the species table rather than by id; the // callback resolves them with its own helper, which returns the species or -1. for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) { const char* track = fx::NodeTrackTechName(static_cast(sp)); if (track == nullptr) continue; const fx::TechId tid = fx::TechIdFromName(track); if (fx::IsValidTechId(tid) && static_cast(tid) == raw_id) s.nodeTrackMask |= 1u << sp; } tfx::WritePlayerState(v, s); // The two design-option masks are a fresh OR over the researched set on every // completion, which is why they are computed here rather than in the effects table. const fx::DesignOptionMasks dm = fx::ComputeDesignOptionMasks( [&](fx::TechId id) { return g_env.has_researched(tree, static_cast(id)); }); tfx::WriteDesignOptionMasks(v, dm.a, dm.b); // Replace mode only: the node-bore block has to be allocated or freed to keep the game // consistent, and `ours` has no allocator the game's runtime could free. Delegating to // the game's own updater is the same read-only-helper delegation B3 makes for Cost. if (!compare && g_env.update_bore) g_env.update_bore(self); // describe_args runs before the original (and so before `ours`), so the outcome cannot // ride on the record. Completions are rare, so one audit line each is affordable and is // where the un-compared consequences -- the system-side writes, the Zuul grant, the // roll -- are visible at all. logf("techfx: ours mode=%s id=%d granted=%d plague=0x%02x systems_ai=%d civcaps=%d " "temperance=0x%02x bore_changed=%d bore_present=%d roll=%d", compare ? "compare" : "replace", raw_id, static_cast(outcome.grantedTech), outcome.plagueCuredMask, outcome.flagSystemsAI ? 1 : 0, outcome.reevaluateCivilianCaps ? 1 : 0, outcome.temperanceSpeciesMask, outcome.nodeBoreParamsChanged ? 1 : 0, v.base[tfx::R_NODEBORE] ? 1 : 0, would_roll ? 1 : 0); } void init_tech_effects(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.is_tech = reinterpret_cast(exe_base + A::MasterTechTree_IsTech); g_env.has_researched = reinterpret_cast(exe_base + A::TechTree_HasResearched); g_env.update_bore = reinterpret_cast(exe_base + A::ServerPlayer_UpdateNodeBoreParams); g_env.gate_tpgate = reinterpret_cast(exe_base + A::g_PERGATETRAFFIC_DRV_TpGate); g_env.gate_gatamp = reinterpret_cast(exe_base + A::g_PERGATETRAFFIC_DRV_GatAmp); logf("techfx: OnTechResearched hook ready (regions=%d, gate=%d/%d, fpu_cw=0x%04x)", tfx::kRegionCount, g_env.gate_tpgate ? *g_env.gate_tpgate : -1, g_env.gate_gatamp ? *g_env.gate_gatamp : -1, fpu_control_word()); } } // namespace shim::hooks