Compare commits

..

5 commits

Author SHA1 Message Date
alex
52db23ce58 shim: exhaustive configs must also turn off the FPU sampling detours
Lane CR named all 27 template hooks off, passed check_shim_configs.py, and the
shim installed six detours: the M0 asm stub and the FPU-force module's four
sampling detours, on by default under keys that do not start with hook. The
checker was exhaustive over the wrong list. It now requires fpu.sample_turn and
fpu.sample_ticks off in an exhaustive config and names the M0 stub in its OK
line. All five exhaustive configs amended; their past measurements stand because
each was proved byte-neutral against a control.
2026-09-09 10:13:45 -04:00
alex
19f49dc6dc Merge branch 'wip/cr' 2026-09-09 10:07:07 -04:00
alex
00e9266476 tests: ratchet the opaque ITEM COUNT, not the percentage; census baseline 538
The coverage ratchet reads `pct >= 99.99`, and with the last carried bodies
typed it cannot usefully be raised: the only opaque items left are the MT19937
block's two in every save, so the number is 2/items and it still ranges from
99.99400 on a 532 KB save to 99.99746 on a 1.17 MB one.  Moving the bar to
99.995 would fail a save that types perfectly and pass a save twice its size
that had picked up a whole new opaque body.  A percentage ratchet on a
fixed-size debt is a ratchet on save size.

So the percentage stays where it is and a stronger line goes beside it: the
opaque set must be exactly the two RNG items.  That breaks on the save that has
the new body, and `opaque_by_tag` names it.

test_domains: 43 saves give 755 fields observed, 538 varying, 217 constant,
against a baseline of 490 measured at 22 saves.  Nineteen of the new fields are
the bodies typed in the previous commit and only two of them (FTPShID,
FTPDesID) have ever been seen to move -- which is why the constants grew faster
than the varying fields, and is worth leaving visible.
2026-09-09 10:03:59 -04:00
alex
076f71439d stream: the name-gen used-slot pair, and the Sprj and FTPnts bodies
Three bodies, one reason: the corpus grew from 22 saves to 43 and started
carrying content the shapes did not name.

ProjectName read `usnc` as a count of ONE item, on the strength of a comment
saying "usnc is 0 in every save available".  Game::SpecialProjectNameGen::Write
(0x008147e0) says otherwise: each 88-byte record opens with a 32-byte table of
per-suffix use counts, `usnc` is the number of NON-ZERO slots, and the loop then
writes, for every non-zero slot, its INDEX as `usp` and its count byte -- movzx
widened -- as `usc`.  Both go through WriteInt, so both are i32 on the wire.
Twelve corpus saves have one such slot and were round-tripping exactly 12 bytes
short: one i32 item, 4 length + 3 tag + 4 value + 1 pad.

`Sprj` is a polymorphic frame and both halves of its mapping are now measured.
ServerPlayer::Write emits `SprjT` from the plain member at project+0x3c, and
ServerPlayer::Read feeds that value to the factory at 0x008610a0, whose table at
0x008611e8 is 0 BackEngProject / 1 MonitorProject / 2 JewelsProject / 3
TechOfferProject -- and each of those constructors stores its own index back at
+0x3c.  Only SprjT 0 is exercised by any save; the other three arms are typed
from the recovered schema and labelled as the hypotheses they are, and an
unknown SprjT falls to rest() so it shows up as opaque coverage rather than
being mis-read in silence.

FieldTemplate carried its points because no save had ever put one on the wire.
Six now do, so Game::FieldTemplate::Point is typed and bound.  FTPPosX/PosY/Sqd
read 0 in every observed point, so their i32 disk type is still the schema's
word and not the corpus's, and the comment says so.

All 43 saves now round-trip byte-identically; the wire-schema conformance test
binds six new shapes with 0 MISMATCH and every one matching item for item.
2026-09-09 10:03:50 -04:00
alex
618ccb1c25 shim: research.replace_cascade, and a completion counter that prints in replace mode too
The B3 hook's unlock cascade was compare-mode only, so a replace run left the
completed node unstamped and no tech unlocked -- and its only per-call counter was
gated on the cascade, so a replace run had no instrument of its own saying a
completion had happened at all. Lane CR needs both halves to ask whether
ProcessResearch can be displaced.

research.replace_cascade=on (default off, so the shipped behaviour is unchanged and
still measurable) lets a replace-mode call write the four TechNode words
SetResearched stamps and the tree's completion-order counter. The ServerPlayer half
of the callback stays unmodelled and stays declared: no event is posted, no
ObservedTech element is appended, no tech effect is applied. The pair of settings is
the measurement -- same binary, one config line, and the leaf difference between the
two autosaves is the cascade's own contribution.

The per-call log line now prints in every mode and counts completions from the pass's
own step results, so 'a completion fired' is a statement by the instrument rather
than an inference from the artefact under test.
2026-09-09 09:15:47 -04:00
14 changed files with 490 additions and 45 deletions

View file

@ -961,13 +961,154 @@ struct PrEntry {
ar.i32(A("PRBt"), prbt);
}
};
// ---- special projects: Game::SpecialProjectImpl and the four subclasses -----------
//
// `Sprj` is a POLYMORPHIC frame and `SprjT`, written immediately before it, is the
// discriminator. Both halves of the mapping are measured, not guessed:
//
// * ServerPlayer::Write (0x856fe4) writes `SprjT` from the plain member at
// `project+0x3c` -- not from a virtual call -- and then writes the object as
// the `Sprj` frame;
// * ServerPlayer::Read (0x881aa7) reads `SprjT` and passes it as the first
// argument to the factory at 0x8610a0, whose 4-entry jump table (0x8611e8) is
//
// 0 -> new 0x54, ctor(0, ...), vftable 0xa3185c Game::BackEngProject
// 1 -> new 0x54, ctor(...), vftable 0xa3180c Game::MonitorProject
// 2 -> new 0x4c, ctor(...), vftable 0x9fa438 Game::JewelsProject
// 3 -> new 0x60, ctor(3, ...), vftable 0xa31884 Game::TechOfferProject
//
// and each of those constructors stores that same index at `+0x3c`, which
// closes the loop: the value the writer emits is the index the factory takes.
// Any other value creates nothing, so a save carrying one is malformed.
//
// Game::TechProject (vftable 0xa31834) has NO factory entry and cannot come off a
// save; it shares BackEngProject's serializer (Read 0x853280 / Write 0x853390)
// byte for byte, so the two are indistinguishable on the wire anyway.
//
// ONLY SprjT 0 IS EXERCISED by the corpus (one record, in the 12 `ad-*`/`ar-*`/`bp-*`
// saves: a back-engineering project on "Magonian Prophicies" for `DRV_RecFiss`).
// The other three arms are typed from the recovered schema and the factory, and
// are HYPOTHESES until a save exercises them (rule 6). An unknown SprjT falls to
// `rest()`, which shows up as opaque coverage rather than as a silent mis-read.
struct SpecialProject { // Game::SpecialProject -- the `SPi` sub-frame, common to all four
static constexpr const char* kStreamName = "SPi";
int32_t spid = 0, sts = 0;
float cst = 0;
int32_t mxc = 0;
std::string nm;
int32_t trns = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("SPid"), spid);
ar.i32(A("Sts"), sts);
ar.f32(A("Cst"), cst);
ar.i32(A("MxC"), mxc);
ar.str(A("Nm"), nm);
ar.i32(A("Trns"), trns);
}
};
struct BackEngProject { // Game::BackEngProject (SprjT 0); Game::TechProject shares this serializer
static constexpr const char* kStreamName = "Sprj";
int32_t stp = 0;
SpecialProject spi;
float aOdd = 0, aInc = 0;
std::string tch;
int32_t rCst = 0, rDn = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("Stp"), stp);
ar.obj(A("SPi"), spi);
ar.f32(A("AOdd"), aOdd);
ar.f32(A("AInc"), aInc);
ar.str(A("Tch"), tch);
ar.i32(A("RCst"), rCst);
ar.i32(A("RDn"), rDn);
}
};
struct MonitorProject { // Game::MonitorProject (SprjT 1) -- no save exercises it
static constexpr const char* kStreamName = "Sprj";
int32_t stp = 0;
SpecialProject spi;
int32_t sys = 0, rMn = 0, rMx = 0, rDn = 0;
float rMd = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("Stp"), stp);
ar.obj(A("SPi"), spi);
ar.i32(A("Sys"), sys);
ar.i32(A("RMn"), rMn);
ar.i32(A("RMx"), rMx);
ar.i32(A("RDn"), rDn);
ar.f32(A("RMd"), rMd);
}
};
struct JewelsProject { // Game::JewelsProject (SprjT 2) -- no save exercises it
static constexpr const char* kStreamName = "Sprj";
int32_t stp = 0;
SpecialProject spi;
int32_t einv = 0, eapp = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("Stp"), stp);
ar.obj(A("SPi"), spi);
ar.i32(A("einv"), einv);
ar.i32(A("eapp"), eapp);
}
};
struct TechOfferProject { // Game::TechOfferProject (SprjT 3) -- no save exercises it
static constexpr const char* kStreamName = "Sprj";
int32_t stp = 0;
SpecialProject spi;
float aOdd = 0, aInc = 0;
std::string tch;
int32_t rCst = 0, rDn = 0;
int32_t giv = 0, rcp = 0;
bool rcpd2 = false;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("Stp"), stp);
ar.obj(A("SPi"), spi);
ar.f32(A("AOdd"), aOdd);
ar.f32(A("AInc"), aInc);
ar.str(A("Tch"), tch);
ar.i32(A("RCst"), rCst);
ar.i32(A("RDn"), rDn);
ar.i32(A("giv"), giv);
ar.i32(A("rcp"), rcp);
ar.b(A("rcpd2"), rcpd2);
}
};
// The `Sprj` body, selected by the `SprjT` beside it. Same idiom as EncounterObject:
// a switch, not a `when`, so SchemaProbe does not concatenate the arms -- each arm is
// bound to its own wire class in tests/mars_stream/test_wire_schema.cpp.
struct SpecialProjectBody {
static constexpr const char* kStreamName = "Sprj";
int32_t type = 0;
BackEngProject backEng; // 0
MonitorProject monitor; // 1
JewelsProject jewels; // 2
TechOfferProject offer; // 3
std::vector<Node> unknown; // no factory entry: carried, and counted as opaque
void select(int32_t sprjT) { type = sprjT; }
template <class Ar>
void io(Ar& ar) {
switch (type) {
case 0: backEng.io(ar); break;
case 1: monitor.io(ar); break;
case 2: jewels.io(ar); break;
case 3: offer.io(ar); break;
default: ar.rest(unknown); break;
}
}
};
struct SprjEntry {
int32_t sprjT = 0;
Node sprj;
SpecialProjectBody sprj;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("SprjT"), sprjT);
ar.any(A("Sprj"), sprj);
sprj.select(sprjT);
ar.obj(A("Sprj"), sprj);
}
};
struct NexpEntry {
@ -1351,12 +1492,37 @@ struct SpyManager { // Game::ServerSpyManager
ar.rest(extra);
}
};
// One used-suffix slot of a name-generator record: the `usp`/`usc` PAIR.
//
// MEASURED from the writer (Game::SpecialProjectNameGen::Write, 0x8147e0). Each
// record is 0x58 bytes and opens with a 32-byte table of per-suffix use counts.
// The writer counts the NON-ZERO entries (the unrolled byte loop at 0x814850) and
// emits that as `usnc`; it then walks slots 0..31 and, for every non-zero one,
// writes the SLOT INDEX as `usp` (0x8148b1) and that slot's count byte, widened by
// `movzx`, as `usc` (0x8148c2). Both go through WriteInt, so both are i32 on the
// wire (rule 5). `Nm` and `Ntg` follow from record+0x20 and record+0x3c.
//
// The old shape read `usnc` as a count of ONE item and named only `usc`, on the
// strength of "usnc is 0 in every save available". That was rule 6's hypothesis
// and `ad-turn27-two-raiders.sav` falsified it: 102 of its 103 records have an
// empty table and one has `usnc == 1`, so the writer dropped that record's `usp`
// and the save round-tripped exactly 12 bytes short (one i32 item: 4-byte length +
// 3-byte tag + 4-byte value + 1 pad). 12 of the 43 corpus saves carry that record.
struct UsedName {
int32_t slot = 0; // usp -- index into the record's 32-slot use table
int32_t count = 0; // usc -- that slot's use count (a byte in memory, i32 on the wire)
template <class Ar>
void io(Ar& ar) {
ar.i32(A("usp"), slot);
ar.i32(A("usc"), count);
}
};
struct ProjectName { // one entry of Game::SpecialProjectNameGen
std::vector<Node> used; // usnc is 0 in every save available
std::vector<UsedName> used;
std::string nm, ntg;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("usnc"), used, [](Ar& a, Node& e) { a.any(A("usc"), e); });
ar.narr(A("usnc"), used, [](Ar& a, UsedName& e) { e.io(a); });
ar.str(A("Nm"), nm);
ar.str(A("Ntg"), ntg);
}
@ -1692,19 +1858,40 @@ struct ShipEntry {
ar.obj(A("Ship"), ship);
}
};
struct FieldPoint { // Game::FieldTemplate::Point -- one ship's slot in a stored formation
static constexpr const char* kStreamName = "";
int32_t shipID = 0, desID = 0, posX = 0, posY = 0, sqd = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("FTPShID"), shipID);
ar.i32(A("FTPDesID"), desID);
ar.i32(A("FTPPosX"), posX);
ar.i32(A("FTPPosY"), posY);
ar.i32(A("FTPSqd"), sqd);
}
};
struct FieldTemplate { // Game::FieldTemplate, the NULL-named first frame of a Lay
static constexpr const char* kStreamName = "";
// FTPnts is the ONE item in this round's work that the recovery itself marks
// unresolved: it names the element class from the decorated helper name
// (Game::FieldTemplate::Point, whose own serializer is fully recovered --
// FTPShID/FTPDesID/FTPPosX/FTPPosY/FTPSqd) but could not type the item, and
// the count is 0 in every Lay in the corpus. That is exactly the pair of
// conditions under which SysMem, mts and nalat were all typed wrong: the
// element FRAMING is a property of the helper, not of the element class, and
// no save has ever shown one. So the elements stay carried. What settles it
// is a save whose fleet has a stored tactical formation -- set a fleet's
// combat layout in the tactical setup screen, then save.
std::vector<Node> points;
// FTPnts was the ONE item the recovery itself marks unresolved: it names the
// element class from the decorated helper name (Game::FieldTemplate::Point,
// whose own serializer is fully recovered -- FTPShID/FTPDesID/FTPPosX/FTPPosY/
// FTPSqd) but could not type the item, and the count was 0 in every Lay in the
// 22-save corpus. The elements were therefore carried as Nodes rather than
// typed against a framing no save had ever shown -- the trap that mis-typed
// SysMem, mts and nalat.
//
// The workload that note asked for now EXISTS. Six saves in the 43-save corpus
// carry stored tactical formations (`ap-turn22` 2 points, `az-turn23` 3, and
// `ap-turn25` / `ar-oracle-A-pre` / `ar-r1-turn43-post` / `ar-turn37` 1 each),
// and the framing is the ordinary CArr: a `FTPnts` frame holding a "." count
// and that many NULL-named element frames of five i32 items. Typed, and
// byte-identical on all six.
//
// FTPPosX/FTPPosY/FTPSqd read 0 in every point observed so far, so their DISK
// type (i32, from the WriteInt vftable slot) is the schema's word and not the
// corpus's -- a formation with a ship moved off the origin is what would tell
// an i32 grid coordinate from a float one by value.
std::vector<FieldPoint> points;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {

View file

@ -79,6 +79,11 @@ struct Env {
std::uintptr_t exe_base = 0;
void (*log_line)(const char*) = nullptr;
CostFn cost = nullptr;
// `research.replace_cascade=on` (lane CR). Default OFF, so a build behaves exactly as it
// did before this key existed and the two replace configurations differ by configuration
// rather than by binary. See ours() for what the flag does and, just as important, what it
// deliberately still does not do.
bool replace_cascade = false;
};
Env g_env;
@ -934,17 +939,29 @@ void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int*
// 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.
// It ran in COMPARE MODE ONLY until lane CR, for the same reason the event post still 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 -- leaves the player in a state no code path produces.
//
// 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.
// `research.replace_cascade=on` opts a replace run into the half that is entirely inside this
// subsystem: the four TechNode words SetResearched stamps (costRP, turnAvailable,
// turnResearched, order) and the tree's own completion-order counter. Those are TechTree
// state, they are what the compare has been checking for a day, and writing them live is what
// SetResearched does. Everything the callback does to the *player* stays unmodelled and stays
// declared -- the event post, the ObservedTech element and the tech-effect fields -- so a
// replace run with the flag on is still a partial displacement, by exactly the boundary the
// coverage notes name. The point of the flag is to make that boundary measurable: the same
// binary, the same save, two configurations, and the leaf difference between their autosaves
// is the cascade's own contribution.
//
// The graph is transcribed from the node copies -- SCRATCH in compare mode, the live nodes in
// replace mode, where nothing has run yet -- so in both cases what the cascade decides is a
// function of the pre-call tree and not of what the original just did.
sots::sim::TechGraph graph;
CascadeCtx cc;
const bool cascade_possible = compare && g_scan.turn_ok && g_pre.tree_ok;
const bool cascade_possible =
(compare || g_env.replace_cascade) && g_scan.turn_ok && g_pre.tree_ok;
bool cascade_ok = false;
if (cascade_possible) {
cascade_ok = build_graph(nodes, graph);
@ -989,6 +1006,14 @@ void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int*
set_word(p, A::TechNode_off_TurnResearched, graph.nodes[i].turnResearched);
set_word(p, A::TechNode_off_Order, graph.nodes[i].order);
}
// The counter the `order` stamps came from. In compare mode `tree` is the LIVE tree (only
// the nodes are copied), so this must never run there -- writing it would move a byte the
// tree_header guard is there to watch, on a run whose whole value is that ours touched
// nothing. In replace mode nothing else advances it, and leaving it behind would make the
// next completion reuse an order number.
if (!compare && g_pre.tree_ok) {
set_word(tree, A::TechTree_off_OrderCounter, graph.orderCounter);
}
cc.unlocked = sots::sim::CollectNewlyAvailable(graph, g_scan.turn);
}
set_word(overbudget, 0, word_at(overbudget, 0) + r.overbudget);
@ -1094,22 +1119,49 @@ void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int*
// 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);
//
// `steps`/`completed` are counted from the PASS itself and are therefore printed in every
// mode, cascade or no cascade. That is the point: rule 1 says a green verdict on a hook that
// may be comparing nothing is not evidence, and the same holds for a green replace-mode
// ORACLE. Without this line a replace run with the cascade off has no counter at all -- the
// old log line was gated on the cascade -- so "a completion happened" could only ever be
// inferred from the save, which is the artefact under test. Counted here, it is a statement
// by the instrument about what our own code did.
int model_completions = 0;
for (const sots::sim::ResearchStepResult& s : r.steps)
if (s.completed) ++model_completions;
logf("research: mode=%s steps=%u completions=%d overbudget=%d cascade_possible=%d ok=%d "
"cascade_completions=%d unlocked=%u otch_appends=%d roll_draws=%d failures=%d depth=%d "
"name_unreadable=%d",
compare ? "compare" : "replace", static_cast<unsigned>(r.steps.size()), model_completions,
r.overbudget, cascade_possible ? 1 : 0, 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);
}
bool research_config(const char* key, const char* value, std::string* err) {
if (std::strcmp(key, "research.replace_cascade") == 0) {
if (std::strcmp(value, "on") == 0 || std::strcmp(value, "1") == 0) {
g_env.replace_cascade = true;
} else if (std::strcmp(value, "off") == 0 || std::strcmp(value, "0") == 0) {
g_env.replace_cascade = false;
} else if (err) {
*err = "expected on|off";
}
return true;
}
return false;
}
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)",
logf("research: ProcessResearch hook ready (Cost=%p, node=0x%x, rng=0x%x, fpu_cw=0x%04x, "
"replace_cascade=%s)",
reinterpret_cast<void*>(g_env.cost), static_cast<unsigned>(kNodeSize),
static_cast<unsigned>(kRngSize), fpu_control_word());
static_cast<unsigned>(kRngSize), fpu_control_word(),
g_env.replace_cascade ? "on" : "off");
}
} // namespace shim::hooks

View file

@ -43,8 +43,13 @@
// 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.
// REPLACE mode runs none of the cascade by default: there, every pointer is live game memory and
// applying half of the callback would leave the player in a state no code path produces.
// `research.replace_cascade=on` opts a replace run into the TechTree half of it -- the four
// TechNode words SetResearched stamps and the tree's completion-order counter -- and nothing of
// the ServerPlayer half. That is deliberate and it is measurable: the same binary run twice with
// the flag off and on gives two autosaves whose leaf difference IS the cascade's contribution,
// and what both still differ from the original by is the callback's effect on the player.
//
// 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
@ -52,6 +57,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <tuple>
#include <vector>
@ -94,13 +100,16 @@ struct TechTreeProcessResearchHook {
"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",
c.unmodelled("TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and "
"only when research.replace_cascade=on",
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",
"with the flag OFF (the default) nothing of the cascade runs, so a replace "
"run leaves the completed node unstamped and no tech unlocked. With it ON, "
"the four TechNode words (costRP, turnAvailable, turnResearched, order) and "
"the tree's completion-order counter are written live, and the ServerPlayer "
"half is still not: no event is posted, no ObservedTech element is appended "
"and no tech effect is applied. Neither setting is a full displacement of "
"the completion path; the pair measures where the boundary is",
"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 "
@ -146,6 +155,10 @@ struct TechTreeProcessResearchHook {
}
};
// `research.replace_cascade=on|off` (default off). Returns false if `key` is not ours.
// Call BEFORE init_research so the banner reports the value that is in force.
bool research_config(const char* key, const char* value, std::string* err);
// 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));

View file

@ -102,6 +102,9 @@ Config ReadConfig() {
} else if (shim::hooks::ai_orders_config(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val);
} else if (shim::hooks::research_config(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val);
} else if (shim::hooks::watch_apply_config(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val);

View file

@ -116,3 +116,9 @@ aiseed.values=32=156ebbbd,496=fe7b2826,512=0ed341d1,*=deadbeef
# With `aiseed=pin` and no values the module logs "PIN MODE WITH NO PINS -- every seed passes
# through unchanged, so this run is NOT pinned and must not be reported as one". Read that, and
# the three `aiseed call=... pinned=1` lines, in shim.log before trusting any run.
# The FPU-force module installs FOUR sampling detours by default, controlled by keys that
# do not start with hook. -- lane CR found a config naming all 27 template hooks still
# installed six detours. An exhaustive config turns these off explicitly.
fpu.sample_turn=off
fpu.sample_ticks=off

View file

@ -73,3 +73,9 @@ aiseed=pin
# The seeds run C3 (unpinned, same save, same guest, same build) observed for itself. Pinning
# them reproduces a turn that actually happened rather than inventing one.
aiseed.values=32=e70a4703,496=0c63ca36,512=372be4df
# The FPU-force module installs FOUR sampling detours by default, controlled by keys that
# do not start with hook. -- lane CR found a config naming all 27 template hooks still
# installed six detours. An exhaustive config turns these off explicitly.
fpu.sample_turn=off
fpu.sample_ticks=off

View file

@ -0,0 +1,41 @@
# Lane CR, crcompare. ProcessResearch in compare; every other registered hook named off.
# research.replace_cascade=off.
# exhaustive
hooks=trace
hook.Game::EncounterDetect::AssignContacts=off
hook.Game::EncounterDetect::ProcessTeamRecord=off
hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off
hook.Game::SVSOSwarmQueen::OnTurnBegin=off
hook.Game::SVSOSwarmQueen::RegisterHives=off
hook.Game::SVSOSwarmQueen::TickHives=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::ServerSystem::ComputeTotalOutput=off
hook.Game::ServerSystem::GroupOutput=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::StrategyApp::RunAI=off
hook.Game::StrategyHost::Autosave=off
hook.Game::StrategyServer::ApplyEncounterResult=off
hook.Game::StrategyServer::BeginProcessTurn=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::NodeLineDecay=off
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
hook.Game::StrategyServer::ProcessTurn=off
hook.Game::WeaponDictionary::Init=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Mars::RNG::Seed=off
hook.Game::TechTree::ProcessResearch=compare
research.replace_cascade=off
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# The FPU-force module installs FOUR sampling detours by default, controlled by keys that
# do not start with hook. -- lane CR found a config naming all 27 template hooks still
# installed six detours. An exhaustive config turns these off explicitly.
fpu.sample_turn=off
fpu.sample_ticks=off

6
src/shim/shim.cfg.croff Normal file
View file

@ -0,0 +1,6 @@
# Lane CR, the rule-19 control. Same proxy DLL as every measured CR run, same guest, same save,
# same click path -- `hooks=off` installs NOTHING (not even the M0 asm stub), so a difference
# between this run's autosaves and a measured run's is the instrument and nothing else.
# `# exhaustive` is deliberately absent: it is a claim about a `hooks=trace` hook set, and there
# is no hook set here to be exhaustive about.
hooks=off

View file

@ -0,0 +1,41 @@
# Lane CR, crreplace0. ProcessResearch in replace; every other registered hook named off.
# research.replace_cascade=off.
# exhaustive
hooks=trace
hook.Game::EncounterDetect::AssignContacts=off
hook.Game::EncounterDetect::ProcessTeamRecord=off
hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off
hook.Game::SVSOSwarmQueen::OnTurnBegin=off
hook.Game::SVSOSwarmQueen::RegisterHives=off
hook.Game::SVSOSwarmQueen::TickHives=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::ServerSystem::ComputeTotalOutput=off
hook.Game::ServerSystem::GroupOutput=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::StrategyApp::RunAI=off
hook.Game::StrategyHost::Autosave=off
hook.Game::StrategyServer::ApplyEncounterResult=off
hook.Game::StrategyServer::BeginProcessTurn=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::NodeLineDecay=off
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
hook.Game::StrategyServer::ProcessTurn=off
hook.Game::WeaponDictionary::Init=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Mars::RNG::Seed=off
hook.Game::TechTree::ProcessResearch=replace
research.replace_cascade=off
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# The FPU-force module installs FOUR sampling detours by default, controlled by keys that
# do not start with hook. -- lane CR found a config naming all 27 template hooks still
# installed six detours. An exhaustive config turns these off explicitly.
fpu.sample_turn=off
fpu.sample_ticks=off

View file

@ -0,0 +1,41 @@
# Lane CR, crreplace1. ProcessResearch in replace; every other registered hook named off.
# research.replace_cascade=on.
# exhaustive
hooks=trace
hook.Game::EncounterDetect::AssignContacts=off
hook.Game::EncounterDetect::ProcessTeamRecord=off
hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off
hook.Game::SVSOSwarmQueen::OnTurnBegin=off
hook.Game::SVSOSwarmQueen::RegisterHives=off
hook.Game::SVSOSwarmQueen::TickHives=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::ServerSystem::ComputeTotalOutput=off
hook.Game::ServerSystem::GroupOutput=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::StrategyApp::RunAI=off
hook.Game::StrategyHost::Autosave=off
hook.Game::StrategyServer::ApplyEncounterResult=off
hook.Game::StrategyServer::BeginProcessTurn=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::NodeLineDecay=off
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
hook.Game::StrategyServer::ProcessTurn=off
hook.Game::WeaponDictionary::Init=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Mars::RNG::Seed=off
hook.Game::TechTree::ProcessResearch=replace
research.replace_cascade=on
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# The FPU-force module installs FOUR sampling detours by default, controlled by keys that
# do not start with hook. -- lane CR found a config naming all 27 template hooks still
# installed six detours. An exhaustive config turns these off explicitly.
fpu.sample_turn=off
fpu.sample_ticks=off

View file

@ -124,7 +124,17 @@ int main() {
// 2026-09-09, 22 saves: 724 fields observed, 490 vary, 234 do not. Nearly a
// third of the format we call "99.99% typed" has been seen holding exactly
// one value. `tscr` was one of those 234 until a one-turn tech moved it.
const size_t kVaryingBaseline = 490;
//
// 2026-09-09, 43 saves (lane DW): 755 fields observed, 538 vary, 217 do not.
// The corpus doubled and the census grew by 31 fields, of which 19 are this
// lane's newly typed bodies -- the `usp`/`usc` pair of a name-generator record,
// the twelve of a `Sprj` back-engineering project, and the five of a stored
// formation point. Only two of those 19 have ever been seen to move
// (`FTPShID`, `FTPDesID`), which is exactly why the count of CONSTANTS rose
// faster than the count of varying fields: typing a body honestly adds far
// more unexercised fields than exercised ones, and this census is the place
// that stays visible.
const size_t kVaryingBaseline = 538;
CHECK(varying >= kVaryingBaseline);
std::printf("test_domains: %s (%zu save(s), %d failure(s))\n", fails ? "FAILED" : "ok", saves.size(), fails);

View file

@ -142,6 +142,16 @@ static void check_save(const std::string& path, const char* dump_dir) {
// With TurnCommands_v5 typed, the only items left carried as Nodes are the
// two of the MT19937 block, which is deliberately opaque.
CHECK(pct >= 99.99);
// The percentage is the WEAKER half of this ratchet and cannot be raised
// usefully: it is 2/items, so tightening it would fail on a *small* save
// that types perfectly and pass a big one that carries a new opaque body.
// The strong form is the item count itself. 2026-09-09, 43 saves: the only
// opaque items in any save are the MT19937 block's two (`RNG`), so the
// ratchet is that exact set. A newly-carried body breaks this line first,
// and it breaks it on the save that has the body, not on the smallest one.
// Raise it -- by typing the body -- the way rule 27 says; never widen it.
CHECK(cov.opaque == 2);
CHECK(cov.opaque_by_tag.size() == 1 && cov.opaque_by_tag.count("RNG") == 1);
}
// --- round trips ------------------------------------------------------------------

View file

@ -305,6 +305,15 @@ int main() {
check<sh::SpyReport>("SpyReport", "Game::SpyReport");
check<sh::SpyManager>("SpyManager", "Game::ServerSpyManager");
check<sh::ProjectNames>("ProjectNames", "Game::SpecialProjectNameGen");
// The `Sprj` variant: the SELECTION (SprjT -> class) is not a wire item and so
// is not checkable here; it came from the game's own factory at 0x8610a0 (see
// the note on sh::SpecialProjectBody). What is checked is that each arm agrees
// with its class's serializer. Only SprjT 0 is exercised by any save.
check<sh::SpecialProject>("SpecialProject", "Game::SpecialProject");
check<sh::BackEngProject>("BackEngProject", "Game::BackEngProject");
check<sh::MonitorProject>("MonitorProject", "Game::MonitorProject");
check<sh::JewelsProject>("JewelsProject", "Game::JewelsProject");
check<sh::TechOfferProject>("TechOfferProject", "Game::TechOfferProject");
check<sh::TradeManager>("TradeManager", "Game::ServerTradeManagerImpl");
check<sh::TradeSector>("TradeSector", "Game::ServerTradeSector");
check<sh::ShipSectionID>("ShipSectionID", "Game::ShipSectionID");
@ -319,11 +328,11 @@ int main() {
check<sh::TradeRoute>("TradeRoute", "Game::TradeRoute");
check<sh::SpyCraft>("SpyCraft", "Game::SpyCraft");
check<sh::FleetLayout>("FleetLayout", "Game::FleetLayout");
// Game::FieldTemplate::Point is deliberately NOT bound: FTPnts is empty in
// every save and the recovery marks the item unresolved, so its element
// framing is a hypothesis and FieldTemplate carries the elements (see the
// note on sh::FieldTemplate).
// Game::FieldTemplate::Point is bound now that the corpus exercises it: six
// saves carry stored tactical formations, so the element framing is measured
// rather than assumed (see the note on sh::FieldTemplate).
check<sh::FieldTemplate>("FieldTemplate", "Game::FieldTemplate");
check<sh::FieldPoint>("FieldPoint", "Game::FieldTemplate::Point");
check<sh::Crep>("Crep", "Game::CombatReport");
check<sh::CrepPrep>("CrepPrep", "Game::CombatPlayerReport");
check<sh::Srep>("Srep", "Game::CombatShipReport");

View file

@ -38,6 +38,12 @@ HOOK_RE = re.compile(r"^hook\.([^=]+)=")
EXEMPT = {"Game::Foo"}
EXEMPT_PREFIXES = ("Shim::SelfTest::",)
# Detours that are installed by default and are NOT template hooks. The FPU-force module's
# sampling keys accept exactly `on`/`off`. The M0 asm stub has no key at all and is always
# installed when `hooks != off`; it is mentioned in the OK line so nobody reads "27" as "all".
ALWAYS_ON_UNLESS_OFF = ("fpu.sample_turn", "fpu.sample_ticks")
UNNAMEABLE_DETOURS = ("Mars::Application::Initialize (M0 asm stub, always on unless hooks=off)",)
# Settings whose values legitimately end in a period or a path separator.
PROSE_SAFE = ("path", "out", "dir", "file")
@ -82,6 +88,19 @@ def check(cfg, hooks):
f"declares `# exhaustive` but does not name {len(missing)} registered hook(s), "
"which therefore default ON: " + ", ".join(missing)
)
# Template hooks are not the only detours. Lane CR named all 27, passed this check,
# and the shim installed SIX: the M0 Application::Initialize asm stub (unconditional
# whenever hooks != off -- it cannot be named off, so it is reported, not required)
# and the FPU-force module's four sampling detours, which are ON BY DEFAULT and are
# controlled by keys that do not start with `hook.`. An exhaustive config must turn
# those off explicitly, or "exhaustive" is a claim about the wrong list.
kv = {l.split("=", 1)[0].strip(): l.split("=", 1)[1].strip() for l in settings if "=" in l}
for key in ALWAYS_ON_UNLESS_OFF:
if kv.get(key) != "off":
problems.append(
f"declares `# exhaustive` but `{key}` is not `off` (it defaults ON and installs "
"detours no `hook.` key names)"
)
return problems
@ -104,7 +123,8 @@ def main():
for f in failures:
print(" " + f)
return 1
print(f"check_shim_configs: OK ({len(hooks)} registered hooks, {exhaustive} template(s) declared exhaustive)")
print(f"check_shim_configs: OK ({len(hooks)} template hooks + {len(ALWAYS_ON_UNLESS_OFF)} default-on "
f"FPU sampling keys + {len(UNNAMEABLE_DETOURS)} unnameable detour; {exhaustive} template(s) declared exhaustive)")
return 0