From 618ccb1c2510c9dbf5ff54ce6b67c6673792fada Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 9 Sep 2026 09:15:47 -0400 Subject: [PATCH] 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. --- src/shim/hooks/research.cpp | 84 +++++++++++++++++++++++++++++------- src/shim/hooks/research.h | 29 +++++++++---- src/shim/main.cpp | 3 ++ src/shim/shim.cfg.crcompare | 35 +++++++++++++++ src/shim/shim.cfg.croff | 6 +++ src/shim/shim.cfg.crreplace0 | 35 +++++++++++++++ src/shim/shim.cfg.crreplace1 | 35 +++++++++++++++ 7 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 src/shim/shim.cfg.crcompare create mode 100644 src/shim/shim.cfg.croff create mode 100644 src/shim/shim.cfg.crreplace0 create mode 100644 src/shim/shim.cfg.crreplace1 diff --git a/src/shim/hooks/research.cpp b/src/shim/hooks/research.cpp index 2d5639d..15935f8 100644 --- a/src/shim/hooks/research.cpp +++ b/src/shim/hooks/research.cpp @@ -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(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(r.steps.size()), model_completions, + r.overbudget, cascade_possible ? 1 : 0, cascade_ok ? 1 : 0, cc.completions, + static_cast(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(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(g_env.cost), static_cast(kNodeSize), - static_cast(kRngSize), fpu_control_word()); + static_cast(kRngSize), fpu_control_word(), + g_env.replace_cascade ? "on" : "off"); } } // namespace shim::hooks diff --git a/src/shim/hooks/research.h b/src/shim/hooks/research.h index a50182c..d520aab 100644 --- a/src/shim/hooks/research.h +++ b/src/shim/hooks/research.h @@ -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 +#include #include #include @@ -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)); diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 61f03f6..1147b79 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -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); diff --git a/src/shim/shim.cfg.crcompare b/src/shim/shim.cfg.crcompare new file mode 100644 index 0000000..7c8b7f1 --- /dev/null +++ b/src/shim/shim.cfg.crcompare @@ -0,0 +1,35 @@ +# 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 diff --git a/src/shim/shim.cfg.croff b/src/shim/shim.cfg.croff new file mode 100644 index 0000000..19aa34f --- /dev/null +++ b/src/shim/shim.cfg.croff @@ -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 diff --git a/src/shim/shim.cfg.crreplace0 b/src/shim/shim.cfg.crreplace0 new file mode 100644 index 0000000..d35df77 --- /dev/null +++ b/src/shim/shim.cfg.crreplace0 @@ -0,0 +1,35 @@ +# 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 diff --git a/src/shim/shim.cfg.crreplace1 b/src/shim/shim.cfg.crreplace1 new file mode 100644 index 0000000..0375cc8 --- /dev/null +++ b/src/shim/shim.cfg.crreplace1 @@ -0,0 +1,35 @@ +# 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