sots-engine/src/shim/hooks/tech_effects.cpp

335 lines
15 KiB
C++

#include "shim/hooks/tech_effects.h"
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <string>
#include <vector>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#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<const char*>(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<const char*>(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<const char*>(p) + off, sizeof v);
return v;
}
void* ptr_at(const void* p, std::size_t off) {
void* v = nullptr;
std::memcpy(&v, static_cast<const char*>(p) + off, sizeof v);
return v;
}
std::uint8_t u8_at(const void* p, std::size_t off) {
return static_cast<const std::uint8_t*>(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<Tv>& 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<fx::TechId>(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<trace::Region>& 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<int>(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<std::size_t>(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<sots::sim::Species>(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<std::size_t>(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<fx::TechId>(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<sots::sim::Species>(sp));
if (track == nullptr) continue;
const fx::TechId tid = fx::TechIdFromName(track);
if (fx::IsValidTechId(tid) && static_cast<int>(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<int>(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<int>(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<IsTechFn>(exe_base + A::MasterTechTree_IsTech);
g_env.has_researched = reinterpret_cast<HasResearchedFn>(exe_base + A::TechTree_HasResearched);
g_env.update_bore = reinterpret_cast<UpdateBoreFn>(exe_base + A::ServerPlayer_UpdateNodeBoreParams);
g_env.gate_tpgate = reinterpret_cast<const int*>(exe_base + A::g_PERGATETRAFFIC_DRV_TpGate);
g_env.gate_gatamp = reinterpret_cast<const int*>(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