The header's ObservedTech notes carried a std::string size of 0x18. That was a mis-attribution: the element's +0x24 word is the name string's trailing (empty) allocator, not a data field. sizeof(std::string) is 0x1c binary-wide, confirmed against every serializer in the original. ObservedTech is now fully mapped, so the two research-hook comments that said sizeof and the append site were unpinned are corrected. The region stays declared-not-modelled -- appending is a behavioural change this hook does not make -- but its byte delta now has an exact expected value (44 per completion), and the note records that the original's append de-duplicates by tech name, so a naive push_back would diverge on re-observation. clean_room_check: OK. host ctest: 33/33.
117 lines
7.3 KiB
C++
117 lines
7.3 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`: exactly what ProcessResearch itself writes. On the turn a tech completes,
|
|
// the original goes on to call TechTree::SetResearched, which stamps the turn/order words,
|
|
// walks the unlock cascade into the child nodes and invokes the owner's tech-effect callback.
|
|
// None of that is reproduced (it is its own milestone, and the callback would write live
|
|
// player state that compare mode must never touch), so a completion record is expected to
|
|
// diverge in those fields and only in those fields. 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.
|
|
#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");
|
|
// The one event the count model cannot decide. Its trigger IS pinned -- SetResearched's
|
|
// second sweep sets state 2 and stamps turnAvailable, and the tail loop collects
|
|
// state==2 && turnAvailable==currentTurn -- but evaluating it needs the cascade `ours`
|
|
// does not run, so posting it would be a guess that happens to score.
|
|
c.unmodelled("posts EVENT_TECHS_UNLOCKED once after the per-node loop, for the nodes "
|
|
"SetResearched made available this turn",
|
|
trace::Risk::Medium,
|
|
"the set comes from the child-unlock cascade, which ours does not run; the "
|
|
"pass driver takes the unlock list as an input and is given `no list` "
|
|
"rather than an empty one, so a missing input cannot look like a modelled "
|
|
"negative. Expect region:events to under-count next_id by exactly 1 on "
|
|
"every call that completes a tech",
|
|
"region:events");
|
|
c.unmodelled("appends to the owner's vector<ObservedTech> (ServerPlayer+0x274) on every "
|
|
"tech completion",
|
|
trace::Risk::High,
|
|
"serialized ServerPlayer state that no coverage note in B2 or B3 mentioned "
|
|
"until lane R's guard caught it. The element is now fully pinned (sizeof 44, "
|
|
"{u16 turn_first, u16 turn_last, bool detected, string tech_name, int with}) "
|
|
"and the append de-duplicates by tech name, but ours still does not append; "
|
|
"the region reports the byte span, which must grow by exactly 44 per "
|
|
"completion",
|
|
"region:observed_techs");
|
|
c.unmodelled("TechTree::SetResearched on completion: the turn/order stamps, the child "
|
|
"unlock cascade, the recursive research of zero-cost children, and the "
|
|
"owner's OnTechResearched callback",
|
|
trace::Risk::High,
|
|
"its own milestone (B2); the callback writes live player state that compare "
|
|
"mode must not touch, and it consumes one extra RNG word",
|
|
"guard:player, guard:tree_header");
|
|
c.unmodelled("bumps the tree's completion-order counter (TechTree+0x20)",
|
|
trace::Risk::Medium,
|
|
"part of SetResearched; the per-node `order` word is compared but the "
|
|
"counter it comes from was not a region",
|
|
"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
|