sots-engine/src/shim/hooks/research.h
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

153 lines
10 KiB
C++

// Hook descriptor for the per-turn research pass (B3):
//
// Game::TechTree::ProcessResearch(this, rng, alloc, overbudget)
//
// A verified __thiscall in sots_addresses.h, so it goes through Hook<> with
// CallConv::Thiscall. It is called once per player per turn, from ServerPlayer::ProcessTurn,
// and it is the only caller. Its four parameters are all confirmed at that call site: the
// second is the strategy server's Mars::RNG *object* (the function re-bases it to the state
// block with +4 before every draw), the third the {tech, points} allocation the budget built,
// the fourth an accumulator for the points that would not fit under the 150 % cap.
//
// Why this call is worth a compare: it exercises the MT19937, the completion-odds formula and
// the Zuul double roll in one place, and its RNG consumption is observable. So the declared
// regions are
//
// rng the whole 0x9cc-byte generator object -- mt[624] plus the stream position
// overbudget the caller's accumulator
// node[i] every non-null TechNode in the tree, 0x34 bytes each
// events the owner's inline EventStorage header (ServerPlayer+0x29c): EvNxID and the
// turn-bucket count. `ours` posts the pass's events into its own model storage
// and writes the counts into this region's scratch copy -- never into the game.
// observed_techs the owner's vector<ObservedTech> header (ServerPlayer+0x274): declared so the
// completion append is a named check rather than an undeclared write, and so
// its byte delta measures the element stride, which is not yet pinned.
//
// and the compare is run with our own MT19937 seeded by load_state() from the *pre-call*
// snapshot, so both implementations read the same stream. If the post-call generator state
// matches as well, we consumed the same words in the same order -- which is the real evidence.
//
// Scope of `ours`: what ProcessResearch itself writes, plus -- in compare mode only -- the
// TechTree::SetResearched cascade it calls on a completion. The cascade stamps the completed
// node's turn and order, lowers the child costs, walks the availability sweep that writes
// `turnAvailable`, and recursively completes any zero-cost node it makes available; the tail
// collector then decides EVENT_TECHS_UNLOCKED from `state == 2 && turnAvailable == turn`. All of
// it is modelled by the pure game/sim/techgraph module, transcribed from the SCRATCH node copies
// so the decision is a function of the pre-call tree and not of what the original just did.
//
// The owner callback SetResearched invokes is modelled only as far as this hook's regions reach:
// the observed-tech append (a de-duplicating append whose byte span region:observed_techs
// compares) and the RNG word RollResearchEvent draws when the completing tech is the current
// target and the pending-roll byte is set. That is ONE word only while the roll misses: when it
// beats the odds the plague branch draws a second word (NextInt) and posts EVENT_PLAGUE_OUTBREAK,
// and the rebellion branch cancels the research outright. Its tech-effect field writes are B2's
// milestone and remain what the `player` guard reports.
//
// REPLACE mode runs none of the cascade: there, every pointer is live game memory and applying
// half of the callback would leave the player in a state no code path produces.
//
// The effective cost of a node is taken from the game's own TechTree::Cost, which is read-only --
// the cost multiplier is a separate, lower-confidence formula and not what this milestone is
// measuring. The cascade calls it too, on the node as it stands after the child-cost sweep.
#pragma once
#include <cstdint>
#include <tuple>
#include <vector>
#include "shim/trace/hook.h"
namespace shim::hooks {
struct TechTreeProcessResearchHook {
static constexpr const char* name = "Game::TechTree::ProcessResearch";
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
using Ret = void;
// this (TechTree*), rng (Mars::RNG*), alloc (vector<{TechDef*,int}>*), overbudget (int*)
using Args = std::tuple<void*, void*, void*, int*>;
static void describe_args(std::vector<trace::Tv>& out, void* tree, void* rng, void* alloc,
int* overbudget);
static void regions(std::vector<trace::Region>& out, void* tree, void* rng, void* alloc,
int* overbudget);
static Args rebind(trace::Scratch& s, void* tree, void* rng, void* alloc, int* overbudget);
static void ours(void* tree, void* rng, void* alloc, int* overbudget);
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
static void coverage(trace::Coverage& c) {
// THE B3 DEFECT, now modelled count-only (lane E option (a); see docs/P-events-wiring.md).
c.unmodelled("posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours "
"reproduces the decision and the id sequence, so region:events compares "
"next_id, but the composed EvDsc/EvMsg text is not reproduced and no region "
"can see it",
trace::Risk::Medium,
"text comes from the game's string table, which the engine must not carry; "
"ours posts into its own EventStorage and writes only the counts into the "
"scratch copy, so no live byte moves and replace mode posts nothing at all",
"region:events");
// EVENT_TECHS_UNLOCKED is now computed rather than declared missing -- `ours` runs the
// cascade. What remains unproven is the same thing as for the other five: the text.
c.unmodelled("composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names",
trace::Risk::Low,
"the trigger and the list are modelled (SetResearched's availability sweep "
"plus the tail collector, both read off the instruction stream), so "
"region:events compares next_id; the names come from the game's string "
"table, so the message is composed from node indices instead and is not the "
"game's text",
"region:events");
c.unmodelled("TechTree::SetResearched in REPLACE mode: nothing of it runs",
trace::Risk::High,
"the cascade is compare-mode only. In replace mode every pointer is live "
"game memory, and applying half of OnTechResearched -- the observed-tech "
"append and the research-event roll, but not the tech-effect field writes -- "
"would leave the player in a state no code path produces. A replace run "
"therefore still leaves the completed node unstamped and no tech unlocked",
"guard:player, guard:tree_header");
c.unmodelled("ServerPlayer::OnTechResearched's tech effects: the ~90 hard-coded "
"ServerPlayer field writes, the plague-cure masks, the design-option "
"bitmasks and the species tech flags",
trace::Risk::High,
"B2's milestone. `ours` models only the two parts of the callback this "
"hook's regions can see -- the observed-tech append and the RNG word "
"RollResearchEvent draws before its branch (one word on a missed roll, two "
"on a fired plague roll) -- and the rest is what the player guard reports",
"guard:player");
c.unmodelled("the research-event branch RollResearchEvent takes when its roll beats the "
"odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion "
"event paths)",
trace::Risk::High,
"RollResearchEvent draws one NextFloat unconditionally and that draw IS "
"modelled -- but that is only the cost of REACHING the branch. A FIRED roll "
"costs one or two words: the plague path draws a SECOND word (NextInt) to "
"pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion "
"path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current "
"research (no further draw). The branch is entered only for the plague and "
"AI-rebellion tech families, whose odds are 0 everywhere else, and it has "
"never been observed firing in three sessions -- which is why every earlier "
"note in this repo said 'exactly one NextFloat' and nothing caught it. "
"If it is ever entered, region:rng is the check",
"region:rng");
c.unmodelled("constructs the ObservedTech element it appends to ServerPlayer+0x274",
trace::Risk::Medium,
"`ours` models the append DECISION -- RecordObservedTech de-duplicates by "
"tech name, so it decides whether the vector grows -- and moves the scratch "
"header's byte span by one 0x2c element per append. The element's own fields "
"(turn_first, turn_last, detected, the name string, `with`) are not built, "
"and no region can see them",
"region:observed_techs");
c.unmodelled("the tree's completion-order counter (TechTree+0x20) is read pre-call, not "
"modelled as a region",
trace::Risk::Low,
"the per-node `order` word IS compared, and it is stamped from a counter "
"`ours` seeds from the pre-call read and advances itself; the counter's own "
"final value is only seen by the tree_header guard",
"guard:tree_header");
c.unmodelled("writes a completion line to the game log",
trace::Risk::Low, "log text is not simulation state");
}
};
// Process facts the hook needs (exe base for the RVAs, a line logger). Call once before
// installing.
void init_research(std::uintptr_t exe_base, void (*log_line)(const char* line));
} // namespace shim::hooks