sots-engine/src/shim/hooks/research.cpp
lane P f3c6d551de lane P: post the research events from ours (count-only), declare ObservedTech
TechTree::ProcessResearch's events region now compares a modelled value instead of
reporting a known defect. ours posts the pass's events into its own
sots::events::EventStorage, seeded from a scan of the owner's list taken BEFORE the
original runs, and writes only the counts into the region's scratch copy. The game's
PostEvent is never called and no live byte moves; replace mode still posts nothing,
because a bumped EvNxID with no record behind it would corrupt the oracle's save.

- game/events: PostResearchPassEvents (the decision half, pure) + KeylessEventText
- shim/hooks/event_inputs (new lib shim_events, host-tested): the live<->model adapter,
  carrying game pointers as explicit uint32 so a 64-bit host build cannot alias them
- research hook: the wiring, a new observed_techs region for ServerPlayer+0x274, and
  turn / events_next_id_in / events_dedup_risk in the args so the count model's own
  assumption is measured rather than assumed
- EVENT_TECHS_UNLOCKED is NOT posted: its trigger is pinned but needs SetResearched's
  unlock cascade, which ours does not run. The driver takes the unlock list as an input
  and is handed 'no list', so a missing input cannot look like a modelled negative.
  Predicted residual: next_id short by exactly 1 on a completion call.

ctest 33/33 (shim_events_unit is new), clean_room_check OK. The shim TU is
syntax-checked only: no MinGW cross toolchain on this box.

See docs/P-events-wiring.md for the exact prediction for the next VM run.
2026-09-08 04:16:22 -04:00

680 lines
32 KiB
C++

#include "shim/hooks/research.h"
#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/events/event_log.h"
#include "game/events/research_events.h"
#include "game/sim/research.h"
#include "game/sim/species.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<TurnEvents> 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<ObservedTech> (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}
// 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<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
}
std::int32_t word_at(const void* obj, std::size_t off) {
std::int32_t v = 0;
std::memcpy(&v, static_cast<const char*>(obj) + off, sizeof v);
return v;
}
void set_word(void* obj, std::size_t off, std::int32_t v) {
std::memcpy(static_cast<char*>(obj) + off, &v, sizeof v);
}
void* ptr_at(const void* obj, std::size_t off) {
void* v = nullptr;
std::memcpy(&v, static_cast<const char*>(obj) + off, sizeof v);
return v;
}
void set_ptr(void* obj, std::size_t off, void* v) {
std::memcpy(static_cast<char*>(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<ObservedTech>
void* scratch_events = nullptr; // compare mode: the scratch copy of the EventStorage header
std::vector<int> node_region; // node index -> region index, -1 when the slot is null
std::vector<std::string> names; // stable storage for Region::name
std::vector<void*> 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;
// 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<void*>& out) {
out.clear();
if (!readable(tree, kTreeHeadSize)) return false;
void** begin = static_cast<void**>(ptr_at(tree, A::TechTree_off_Nodes));
void** end = static_cast<void**>(ptr_at(tree, A::TechTree_off_Nodes + 4));
if (!begin && !end) return true;
const std::uintptr_t b = reinterpret_cast<std::uintptr_t>(begin);
const std::uintptr_t e = reinterpret_cast<std::uintptr_t>(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<AllocEntry>& out) {
out.clear();
if (!readable(alloc, 0xc)) return false;
const char* begin = static_cast<const char*>(ptr_at(alloc, 0));
const char* end = static_cast<const char*>(ptr_at(alloc, 4));
if (!begin && !end) return true;
if (!begin || end < begin) return false;
const std::size_t n = static_cast<std::size_t>(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<const char*>(p) + A::RNG_off_State,
static_cast<std::size_t>(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<std::uintptr_t>(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<std::int64_t>((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<const char*>(ptr_at(p, kEventsVecOff));
const char* end = static_cast<const char*>(ptr_at(p, kEventsVecOff + 4));
const std::int32_t bytes = end >= begin ? static_cast<std::int32_t>(end - begin) : -1;
s.add("turns_bytes", tv::i32(bytes));
s.add("turns", tv::i32(bytes >= 0 ? bytes / static_cast<std::int32_t>(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<ObservedTech> otch` (ServerPlayer+0x274) -- serialized state that grows on
// every tech completion and that no coverage note in B2 or B3 mentioned.
//
// `ours` cannot append to it: `sizeof(ObservedTech)` and the append call site are both unpinned
// (the element is a Streamable with four ints and a string on disk, which bounds it but does not
// name it). So this region is declared knowing it will diverge on a completion call -- and the
// **byte delta it reports is what measures the stride**: one completion appends one element, so
// `bytes` growing by n names sizeof(ObservedTech) = n. That is the point of declaring it now
// rather than waiting for the element layout.
Tv describe_otch(const void* p, std::size_t, unsigned) {
Tv s = tv::struct_();
const char* begin = static_cast<const char*>(ptr_at(p, 0));
const char* end = static_cast<const char*>(ptr_at(p, 4));
s.add("bytes", tv::i32(end >= begin ? static_cast<std::int32_t>(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();
}
};
} // namespace
// ---- descriptor ---------------------------------------------------------------------------
void TechTreeProcessResearchHook::describe_args(std::vector<Tv>& 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<void*> nodes;
const bool nodes_ok = tree_nodes(tree, nodes);
out.push_back(tv::u32(static_cast<std::uint32_t>(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<AllocEntry> entries;
const bool alloc_ok = alloc_entries(alloc, entries);
std::vector<Tv> 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<const char*>(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<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(g_scan.storage.researchEventsInTurnBucket))
.named("events_dedup_risk"));
if (g_scan.storage.scanTruncated) out.push_back(tv::boolean(true).named("events_scan_truncated"));
}
void TechTreeProcessResearchHook::regions(std::vector<trace::Region>& out, void* tree, void* rng,
void* alloc, int* overbudget) {
(void)alloc;
g_call = CallState{};
g_call.rng_base = reinterpret_cast<std::uintptr_t>(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<void*> 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<unsigned>(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<int>(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<int>(out.size());
trace::Region ev;
ev.name = "events";
ev.ptr = static_cast<char*>(owner) + kPlayerEventsOff;
ev.size = kEventStorageSize;
ev.describe = &describe_events;
out.push_back(ev);
}
// The owner's vector<ObservedTech>. Declared, not modelled: see describe_otch.
g_call.otch_region = -1;
if (readable(owner, kObservedTechsOff + kVectorHeaderSize)) {
g_call.otch_region = static_cast<int>(out.size());
trace::Region ot;
ot.name = "observed_techs";
ot.ptr = static_cast<char*>(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<std::size_t>(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<std::size_t>(g_call.events_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<int*>(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<void*> live;
if (!tree_nodes(tree, live)) throw std::runtime_error("tech-tree node vector not readable");
const std::vector<void*>& 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>(species_index)
: static_cast<Species>(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<ResearchNode> 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<TechState>(word_at(p, A::TechNode_off_State));
model[i].progress = word_at(p, A::TechNode_off_Progress);
model[i].flag = static_cast<TechFlag>(word_at(p, A::TechNode_off_Flag));
model[i].cost = 0;
}
std::vector<AllocEntry> raw;
if (!alloc_entries(alloc, raw)) throw std::runtime_error("research allocation not readable");
std::vector<ResearchAllocEntry> 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<std::size_t>(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<int>(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<const char*>(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<std::int32_t>(model[i].flag));
set_word(p, A::TechNode_off_State, static_cast<std::int32_t>(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<char*>(rng) + A::RNG_off_State, blob,
static_cast<std::size_t>(kMtWords) * 4);
set_word(rng, A::RNG_off_Left, rand.gen.left());
const std::uintptr_t base =
(compare ? g_call.rng_base : reinterpret_cast<std::uintptr_t>(rng)) + A::RNG_off_State;
set_ptr(rng, A::RNG_off_Next,
reinterpret_cast<void*>(base + static_cast<std::uintptr_t>(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<int> step_node;
step_node.reserve(entries.size());
for (const ResearchAllocEntry& e : entries) {
if (e.nodeIndex < 0 || static_cast<std::size_t>(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<sots::events::ResearchPassOutcome> 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));
}
// nullptr, not an empty list: the EVENT_TECHS_UNLOCKED set comes from SetResearched's
// child-unlock cascade, which `ours` deliberately does not run. Passing an empty list
// would claim we computed the set and found it empty.
sots::events::PostResearchPassEvents(log, text, outcomes, nullptr, g_scan.turn);
WriteBackCounts(g_call.scratch_events, log, &readable);
}
}
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<CostFn>(exe_base + A::TechTree_Cost);
logf("research: ProcessResearch hook ready (Cost=%p, node=0x%x, rng=0x%x, fpu_cw=0x%04x)",
reinterpret_cast<void*>(g_env.cost), static_cast<unsigned>(kNodeSize),
static_cast<unsigned>(kRngSize), fpu_control_word());
}
} // namespace shim::hooks