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.
116 lines
7.2 KiB
C++
116 lines
7.2 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. sizeof(ObservedTech) and the append call "
|
|
"site are both unpinned, so ours cannot produce the bytes; the region "
|
|
"reports the byte span, whose growth on a completion call measures the "
|
|
"element stride",
|
|
"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
|