sots-engine/src/shim/hooks/research.cpp
alex de18d2c91d K: the research-event roll costs one or two RNG words, not one
Every Coverage note in this repo said RollResearchEvent draws "exactly one
NextFloat". That is the cost of REACHING its branch. When the roll beats the
odds, the plague path draws a SECOND word (NextInt) to pick an owned system and
posts EVENT_PLAGUE_OUTBREAK, and the rebellion path allocates an AIRebellion at
ServerPlayer+0x3b8 and cancels the current research. Nothing has caught this
because the branch has not fired in three sessions.

Corrected in research.h (two Coverage entries plus the scope comment, and the
branch entry raised to Risk::High), research.cpp, tech_effects.h and
tech_effects.cpp. Ours still models the first word only; the branch stays
declared unmodelled, now accurately.

Header regenerated from sots-re bb0f990 (750 entries) - lane K's map of the
combat-done tail: the autosave and save-file writer, the bankruptcy limits, the
turn-results accumulator and outbox, and the encounter-block callees.
2026-09-08 08:51:15 -04:00

1115 lines
55 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 "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<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}
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<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
void* scratch_otch = nullptr; // compare mode: the scratch copy of the ObservedTech 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;
// 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<ObservedTech>. Game text, kept inside the
// shim for one purpose only -- deciding whether RecordObservedTech would append -- and never
// emitted into a trace record.
std::vector<std::string> 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<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);
}
// ---- the pre-call reads of the owner's and the tree's mutable state ------------------------
// The names in `vector<ObservedTech>`, 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<std::string>& out, bool& truncated) {
out.clear();
truncated = false;
if (!readable(vec, kVectorHeaderSize)) return false;
const char* first = static_cast<const char*>(ptr_at(vec, 0));
const char* last = static_cast<const char*>(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<std::size_t>(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<TechEdge*>` 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<sots::sim::TechEdgeRef>& out) {
out.clear();
const char* first = static_cast<const char*>(ptr_at(node, A::TechNode_off_Children));
const char* last = static_cast<const char*>(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<std::size_t>(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<const char*>(def) + A::TechDef_off_Prereqs;
if (!readable(block, A::TechPrereqs_off_Groups + 8)) return;
const char* entries = static_cast<const char*>(ptr_at(block, A::TechPrereqs_off_Entries));
const char* gfirst = static_cast<const char*>(ptr_at(block, A::TechPrereqs_off_Groups));
const char* glast = static_cast<const char*>(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<std::size_t>(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<std::size_t>(count) > kMaxPrereqEntries) return;
std::vector<int> group;
group.reserve(static_cast<std::size_t>(count));
if (count > 0) {
const char* base = entries + static_cast<std::size_t>(start) * A::TechPrereqs_entry_stride;
if (!readable(base, static_cast<std::size_t>(count) * A::TechPrereqs_entry_stride)) return;
for (int k = 0; k < count; ++k) {
void* edef = ptr_at(base, static_cast<std::size_t>(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<void*>& 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<const unsigned char*>(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<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.
//
// 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<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();
}
};
// ---- 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<void*>* nodes = nullptr; // the SCRATCH copies in compare mode
sots::sim::TechGraph* graph = nullptr;
std::vector<sots::sim::ResearchNode>* 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<std::string> 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<int> 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<CascadeCtx*>(ctx);
if (nodeIndex < 0 || static_cast<std::size_t>(nodeIndex) >= c.nodes->size())
return sots::sim::kNoResearchCost;
void* p = (*c.nodes)[static_cast<std::size_t>(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<std::size_t>(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<CascadeCtx*>(ctx);
++c.completions;
if (nodeIndex < 0 || static_cast<std::size_t>(nodeIndex) >= c.nodes->size()) return;
void* p = (*c.nodes)[static_cast<std::size_t>(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<const char*>(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<CascadeCtx*>(ctx);
if (nodeIndex < 0 || static_cast<std::size_t>(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<std::size_t>(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<int>((*c.model)[i].state))
(*c.model)[i].state = static_cast<sots::sim::TechState>(gs);
}
}
} // 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"));
// ---- 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<const unsigned char*>(owner) + A::ServerPlayer_off_ResearchRollPending) != 0;
g_pre.observed_ok =
scan_observed_techs(static_cast<const char*>(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<void*>(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<std::uint32_t>(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<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.scratch_otch =
g_call.otch_region >= 0 ? s.ptr(static_cast<std::size_t>(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<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);
}
// ---- 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<std::int32_t>(model[i].flag));
set_word(p, A::TechNode_off_State, static_cast<std::int32_t>(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<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));
}
// 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<std::string> 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<char*>(ptr_at(g_call.scratch_otch, 0));
char* last = static_cast<char*>(ptr_at(g_call.scratch_otch, 4));
if (first && last >= first)
set_ptr(g_call.scratch_otch, 4,
last + static_cast<std::size_t>(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<unsigned>(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<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