diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fb9ded..93efd06 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,7 @@ if(WIN32) src/shim/hooks/draw_sites.cpp src/shim/hooks/probe_entry.cpp src/shim/hooks/ai_orders.cpp + src/shim/hooks/ai_rng.cpp src/shim/hooks/watchpoints.cpp) # `minhook` is here for its include directory: lane H's probe_entry.cpp installs its own # detours (MH_CreateHook/MH_EnableHook) rather than handing descriptors back to main.cpp, diff --git a/docs/PAR-predictions.md b/docs/PAR-predictions.md new file mode 100644 index 0000000..c0c2d7b --- /dev/null +++ b/docs/PAR-predictions.md @@ -0,0 +1,115 @@ +# Lane PAR — predictions, written before the build + +Question, as redirected by the coordinator: **is there *roll* parity?** Does one run of an AI client +consume a **fixed number of RNG words regardless of the path it takes** — the classic lockstep +discipline of drawing unconditionally so peers stay aligned? + +Instrument: a bracket on `StrategyClient::OnResumePlaying 0x00777480` (the whole AI turn runs inside +it), measuring the per-client generator at `StrategyClient+0x134` two ways — the observer sum over +the seven hooked entry points, and the raw `left` delta on the object — plus per-return-address +attribution inside the bracket. Optional `airng.pin_seed` re-seeds that generator at bracket entry +so two runs start from an identical state and any remaining difference is *path*, not seed. + +All predictions below are written before the instrument was built and before any run. + +--- + +## P1 — the count is **not** constant across clients on the same turn + +On `turn2-state.sav` there are three AI clients: **32** (owns a homeworld, fleets, a build queue) and +**496** / **512** (two Singularity shadow empires that own nothing at all — `Sav = 0`, no colonies, +no fleets, no systems, per `ai-order-emission.md` §2). + +**Prediction:** client 32's bracket consumes **strictly more** words than 496's and 512's, and 496 +and 512 consume the **same** count as each other. + +*Falsified if:* all three consume the same count ⇒ the AI really does draw unconditionally and the +lockstep hypothesis survives its first and cheapest test. That would be the strong result. +*Also falsified, differently, if:* 496 and 512 differ from each other ⇒ the count depends on +something finer than "owns nothing", and the property is not even constant across identical boards. + +## P2 — the human client's bracket is exactly 0 words + +`OnResumePlaying` runs for the human client too; the AI branch is `if (this->+0x12c)`. + +**Prediction:** the human client (pid 16) shows `agent=0` and **0 words**. +*Falsified if:* it draws ⇒ there is client-side RNG on the human path and the bracket is not +measuring what I think it is. + +## P3 — the surrender roll costs **0** words, not 1 + +`RNG_Chance 0x008e6dd0` is documented `verified` as returning at `p <= 0` and `p >= 1` **without a +draw**. Process Turn phase 12 is `cl_Chance` on the surrender probability, which is 0 on a healthy +empire. + +**Prediction:** no bracket on the reference board attributes a word to phase 12's return address. +*Falsified if:* a `Chance` row appears with `words > 0` ⇒ the surrender probability is non-zero on +this board, which is itself interesting. +*Why it matters:* the resolver's list of "draws whose result never reaches the save" names "the +surrender `Chance` at 0 %". If `Chance` early-outs, that entry is **wrong** and the ~8 words/turn +figure needs re-deriving. This is a rule-11 correction if it lands. + +## P4 — the count is seed-dependent even when the path is fixed + +`RNG_NextInt 0x004271c0` is documented `verified` as a **rejection loop**: mask = smallest `2^k-1 >= +n`, redraw while `(y & mask) > n`. So a `NextInt` whose bound is not `2^k-1` costs 1 word with +probability `(n+1)/(mask+1)` and more otherwise. + +**Prediction:** with the *same save and the same path*, pinning two different seeds produces two +different bracket totals for at least one client, differing by a small number of words. +*Falsified if:* the total is invariant under the pinned seed ⇒ either no `NextInt` with a +non-power-of-two bound runs on this path, or `NextInt`'s body is not what the header says. +*Why it matters:* it separates the two things that could be meant by "fixed count". Seed-dependence +is **harmless** for a reimplementation (we reproduce the rejection loop bit-for-bit). +Path-dependence is the fatal one. P1 and P4 must be read together: only P1 tests the hypothesis. + +## P5 — the one-shot schedule draw is **outside** this bracket + +`0x0069dbb0` (`if (agent->+0x36c == 0) sched = turn + 3 + NextInt(0..37)`, two `NextInt` sites at +`0x0069dbfd`/`0x0069dc29`) sits in the **Prepare Turn** body, and `SEAIPrepareTurn` is raised only at +agent construction (`RunAI` / `CreateGame`), never per turn. + +**Prediction:** those two return addresses never appear in an `OnResumePlaying` bracket; they fire +once per agent per process, on load. So the *load* consumes words that no turn does, and a +per-turn count that ignored the load would be wrong about the generator's position. +*Falsified if:* they appear in a bracket ⇒ Prepare Turn is reachable per-turn after all and +`ai-turn-logic.md` §1's "not a per-turn event" is wrong. + +## P6 — the residual: `left`-delta will exceed the observer sum on at least one bracket + +Rule 16: inlined draws are invisible to a call-graph sweep, and the seven entry-point detours are a +call-graph sweep by another name. The bracket also reads `left` on the object directly. + +**Prediction:** for the AI clients the two agree exactly (lane I's inlined-draw scan found no hit in +the AI band 0x00680000–0x006e0000), and the instrument prints the residual on every bracket so a +disagreement cannot hide. +*Falsified if:* a residual appears ⇒ there is an inlined draw on the AI path that the campaign's +draw-site inventory does not have, and the whole per-site table is a lower bound. + +## P7 — the overall verdict, predicted + +Static reading already shows at least three genuinely conditional constructs on the AI path +(`Chance`'s two early-outs, `NextInt`'s rejection loop, the `+0x36c` one-shot guard) and the task +system is a *variable-length list* dispatched twice. + +**Prediction: the answer is (d) genuinely state-dependent, not (a)/(b)/(c)** — but with a useful +qualification: the *variation is small and localised*, so the engine's problem is not "the count is +unpredictable" but "the count is a function of the decisions", i.e. `game/ai` must get the decisions +right after all, and C-exact does **not** get cheaper. +*Falsified if:* P1 comes back with all three clients equal. + +--- + +## What would make me wrong in a way I would not notice + +* **Rule 26.** A single run is not a control. Every configuration below is run in **two fresh + processes** and must agree with itself before it is used. The canonical pair is + `turn2-state → turn3-state`; `turn1-state` is known non-deterministic and is used only *with* + pinned seeds. +* **Rule 19.** The bracket detour is a MinHook detour on a function that runs the AI turn. If it + perturbs, the autosave moves. The instrumented run's autosave is compared against the published + oracle (`bb4fd9ac…` / `978041ac…`) on the unpinned configuration; a pinned run is *expected* to + differ and is never compared against the oracle. +* **Rule 20.** A bracket that shows 0 words for a client cannot distinguish "the client did not run" + from "it ran and drew nothing". The bracket therefore records entry/exit unconditionally and + prints a row even when the delta is 0, and records whether `agent != 0`. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 5c28af6..6ea9c68 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 97f4cc7, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ e966a72, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include diff --git a/src/shim/hooks/ai_rng.cpp b/src/shim/hooks/ai_rng.cpp new file mode 100644 index 0000000..3ab0edf --- /dev/null +++ b/src/shim/hooks/ai_rng.cpp @@ -0,0 +1,491 @@ +#include "shim/hooks/ai_rng.h" + +#include +#include +#include +#include + +#define WIN32_LEAN_AND_MEAN +#include + +#include "MinHook.h" + +#include "generated/sots_addresses.h" +#include "shim/hooks/draw_sites.h" +#include "shim/trace/platform.h" + +namespace shim::hooks { +namespace { + +namespace A = sots::addr; + +constexpr std::int32_t kBlock = 624; +constexpr std::size_t kMaxSites = 64; + +// ---- configuration --------------------------------------------------------------------------- + +bool g_enabled = false; +bool g_pin = false; +std::uint32_t g_pinSeed = 0; +char g_outPath[MAX_PATH] = {}; +FILE* g_out = nullptr; +void (*g_log)(const char*) = nullptr; +std::uintptr_t g_exeBase = 0; + +void LogF(const char* fmt, ...) { + char buf[1024]; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + if (g_log) g_log(buf); + if (g_out) { + std::fputs(buf, g_out); + std::fputc('\n', g_out); + std::fflush(g_out); + } +} + +// ---- guarded reads --------------------------------------------------------------------------- +// +// Nothing is dereferenced without a probe. A wrong offset must print a zero, never fault inside a +// detour that the game is standing in the middle of. + +inline bool Readable(std::uintptr_t p, std::size_t n) { + return p != 0 && !IsBadReadPtr(reinterpret_cast(p), n); +} +inline std::uint32_t U32(std::uintptr_t p) { + return Readable(p, 4) ? *reinterpret_cast(p) : 0u; +} + +// `left` and `next` live at RNG+0x9c8 / +0x9c4 (object base). `next` is a POINTER into `mt`, so the +// block index is (next - (obj+4))/4 -- printed because it makes two runs' generator positions +// directly comparable without trusting `left` alone. +std::int32_t LeftOf(const void* obj) { + if (!obj) return -1; + return static_cast(U32(reinterpret_cast(obj) + A::RNG_off_Left)); +} +std::int32_t IndexOf(const void* obj) { + if (!obj) return -1; + const std::uint32_t next = U32(reinterpret_cast(obj) + A::RNG_off_Next); + if (!next) return -1; + const std::uintptr_t base = reinterpret_cast(obj) + A::RNG_off_State; + if (next < base) return -1; + return static_cast((next - base) / 4u); +} + +// Words consumed between two `left` readings of the same generator, on the single-twist assumption +// documented in draw_sites.h. `left` before and after are both printed, so a bracket that spent a +// whole block -- where this would alias -- is visible in the row rather than silently wrong. +std::uint32_t WordsBetween(std::int32_t before, std::int32_t after) { + if (before < 0 || after < 0) return 0; + if (after <= before) return static_cast(before - after); + return static_cast(before + kBlock - after); +} + +// ---- the per-bracket site table -------------------------------------------------------------- +// +// Filled by the draw observer, which `draw_sites` calls under its own lock. The bracket is +// single-threaded (the AI turn runs on the main thread inside one call), so the table needs no lock +// of its own; `g_active` gates it so draws from outside a bracket, and from other generators, are +// ignored. + +struct SiteRow { + std::uint32_t ret_rva = 0; + DrawEntry entry = DrawEntry::Count; + std::uint32_t calls = 0; + std::uint32_t words = 0; + std::uint32_t zero_calls = 0; +}; + +// ---- the lifetime census ----------------------------------------------------------------------- +// +// `left` is a position INSIDE a 624-word block, so a bracket delta is unambiguous only while the +// bracket spends fewer than 624 words, and an ABSOLUTE position cannot be recovered from it at all: +// idx 9 and idx 633 read identically. The first run of this instrument showed three AI clients +// sitting at idx 9 / 422 / 387 when their turn began, which is a fact about the LOAD, not the turn, +// and one that `left` alone cannot quantify. So the observer also keeps a per-generator lifetime +// counter from process start, which is exact and needs no assumption. +// +// It is a census and not a log: one row per generator instance the process has ever drawn from, +// with a per-site breakdown, dumped at every bracket exit. That makes the construction-time cost of +// each AI client visible without a second instrument. + +struct GenRow { + const void* gen = nullptr; + std::uint32_t words = 0; + std::uint32_t calls = 0; +}; +constexpr std::size_t kMaxGens = 24; +GenRow g_gens[kMaxGens]; +std::size_t g_ngens = 0; +std::uint32_t g_genOverflow = 0; + +struct CensusRow { + const void* gen = nullptr; + std::uint32_t ret_rva = 0; + DrawEntry entry = DrawEntry::Count; + std::uint32_t calls = 0; + std::uint32_t words = 0; +}; +constexpr std::size_t kMaxCensus = 192; +CensusRow g_census[kMaxCensus]; +std::size_t g_ncensus = 0; +std::uint32_t g_censusOverflow = 0; + +GenRow* GenFor(const void* gen) { + for (std::size_t i = 0; i < g_ngens; ++i) + if (g_gens[i].gen == gen) return &g_gens[i]; + if (g_ngens >= kMaxGens) { + ++g_genOverflow; + return nullptr; + } + g_gens[g_ngens].gen = gen; + return &g_gens[g_ngens++]; +} + +void CensusAdd(const void* gen, std::uint32_t ret_rva, DrawEntry entry, std::uint32_t words) { + for (std::size_t i = 0; i < g_ncensus; ++i) { + if (g_census[i].gen == gen && g_census[i].ret_rva == ret_rva && g_census[i].entry == entry) { + ++g_census[i].calls; + g_census[i].words += words; + return; + } + } + if (g_ncensus >= kMaxCensus) { + ++g_censusOverflow; + return; + } + CensusRow& r = g_census[g_ncensus++]; + r.gen = gen; + r.ret_rva = ret_rva; + r.entry = entry; + r.calls = 1; + r.words = words; +} + +std::uint32_t LifetimeWords(const void* gen) { + for (std::size_t i = 0; i < g_ngens; ++i) + if (g_gens[i].gen == gen) return g_gens[i].words; + return 0; +} + +const void* g_active = nullptr; // the generator this bracket is watching +SiteRow g_sites[kMaxSites]; +std::size_t g_nsites = 0; +std::uint32_t g_observedWords = 0; +std::uint32_t g_observedCalls = 0; +std::uint32_t g_siteOverflow = 0; +// Draws that happened inside the bracket but on some OTHER generator. Non-zero would mean the AI +// turn reaches a generator that is not its own client's -- which is exactly the claim +// `ai-turn-logic.md` §5 makes ("the strategic AI never touches the strategic generator"), so this +// counter is that claim's live control and is printed even when it is zero. +std::uint32_t g_foreignWords = 0; +std::uint32_t g_foreignCalls = 0; + +// NESTED ENTRY POINTS -- the double count draw_sites.h warned about, now observed. +// `RNG_Chance`'s own body calls `RNG_NextFloat`, and BOTH are detoured, so one drawn word is +// reported twice: once against the game's call site and once against 0x008e6e09, the instruction +// after Chance's internal call. `IntRangeBell` and `GaussianRange` nest the same way. The +// bracket's `left_delta` is measured on the object and was never affected, which is exactly why +// there are two measurements -- the first run to contain a `Chance` showed observed=8 against +// left_delta=7 and localised the defect in one line of output. +// +// Rejecting the INNER row (rather than the outer) keeps the attribution on the game's own call +// site, which is the address a reader can look up. +bool IsNestedEntryReturn(std::uint32_t ret_rva) { + const std::uint32_t va = A::IMAGE_BASE + ret_rva; + return (va >= 0x008e6dd0u && va < 0x008e6e30u) // inside RNG_Chance + || (va >= 0x008e6d80u && va < 0x008e6dd0u) // inside RNG_IntRangeBell + || (va >= 0x008e6e30u && va < 0x008e6ec0u); // inside RNG_GaussianRange +} + +void ObserveDraw(DrawEntry entry, std::uint32_t ret_rva, const void* generator, + std::uint32_t words) { + if (IsNestedEntryReturn(ret_rva)) { + // Still recorded, with its words zeroed, so the row's CALL COUNT stays visible as evidence + // that the nesting happened -- a silently dropped row would be indistinguishable from a + // site that never fired (method rule 20). + CensusAdd(generator, ret_rva, entry, 0); + if (g_active == generator) { + for (std::size_t i = 0; i < g_nsites; ++i) + if (g_sites[i].ret_rva == ret_rva && g_sites[i].entry == entry) { + ++g_sites[i].calls; + return; + } + if (g_nsites < kMaxSites) { + SiteRow& r = g_sites[g_nsites++]; + r.ret_rva = ret_rva; + r.entry = entry; + r.calls = 1; + r.words = 0; + r.zero_calls = 0; + } + } + return; + } + // The census runs whether or not a bracket is open: the construction-time draws happen on load, + // long before the first `OnResumePlaying`, and they are half the answer. + if (GenRow* g = GenFor(generator)) { + g->words += words; + ++g->calls; + } + CensusAdd(generator, ret_rva, entry, words); + if (!g_active) return; + if (generator != g_active) { + g_foreignWords += words; + ++g_foreignCalls; + return; + } + g_observedWords += words; + ++g_observedCalls; + for (std::size_t i = 0; i < g_nsites; ++i) { + if (g_sites[i].ret_rva == ret_rva && g_sites[i].entry == entry) { + ++g_sites[i].calls; + g_sites[i].words += words; + if (words == 0) ++g_sites[i].zero_calls; + return; + } + } + if (g_nsites >= kMaxSites) { + ++g_siteOverflow; + return; + } + SiteRow& r = g_sites[g_nsites++]; + r.ret_rva = ret_rva; + r.entry = entry; + r.calls = 1; + r.words = words; + r.zero_calls = words == 0 ? 1 : 0; +} + +void ResetBracket(const void* generator) { + g_nsites = 0; + g_observedWords = 0; + g_observedCalls = 0; + g_siteOverflow = 0; + g_foreignWords = 0; + g_foreignCalls = 0; + g_active = generator; +} + +// ---- facade caller attribution ----------------------------------------------------------------- +// +// A draw-site return address is the instruction after the call to the RNG ENTRY POINT, so every +// draw the AI makes through `cl_RandRange` is recorded at one address inside the facade +// (0x00579915) and the AI call site that asked for it is invisible. That is the difference between +// "this turn drew one word" and "this turn drew one word BECAUSE THE RESEARCH PICK WAS TIED". +// +// The three `cl_*` RNG facades are, verified by an image-wide boundary-accurate rel32/rel8 scan, +// called from NOWHERE BUT the AI band 0x00680000..0x006e0000 -- cl_Chance 18 sites, cl_RandRange +// 10, cl_RandFloat 1. So detouring the facades themselves gives clean AI-module attribution and +// costs nothing outside an AI turn. +// +// `cl_RandFloat 0x00579c70` is deliberately NOT hooked. It reaches RNG_NextFloat by a TAIL JUMP, +// so the draw-site table ALREADY records its real AI call site (0x006ad878) instead of an address +// inside the facade -- and detouring it would DESTROY that attribution, because the return address +// the NextFloat detour then sees is the one my own trampoline call pushed. It has exactly one +// caller in the image, so nothing is lost by leaving it alone. + +constexpr std::size_t kMaxCallers = 48; +struct CallerRow { + std::uint32_t ret_rva = 0; + std::uint8_t which = 0; // 0 cl_Chance, 1 cl_RandRange, 2 cl_RandFloat + std::uint32_t calls = 0; +}; +CallerRow g_callers[kMaxCallers]; +std::size_t g_ncallers = 0; + +const char* caller_kind(std::uint8_t w) { + return w == 0 ? "cl_Chance" : w == 1 ? "cl_RandRange" : "cl_RandFloat"; +} + +void NoteCaller(const void* ret, std::uint8_t which) { + if (!g_active) return; // only inside a bracket + const std::uint32_t rva = + static_cast(reinterpret_cast(ret) - g_exeBase); + for (std::size_t i = 0; i < g_ncallers; ++i) + if (g_callers[i].ret_rva == rva && g_callers[i].which == which) { + ++g_callers[i].calls; + return; + } + if (g_ncallers >= kMaxCallers) return; + g_callers[g_ncallers].ret_rva = rva; + g_callers[g_ncallers].which = which; + g_callers[g_ncallers].calls = 1; + ++g_ncallers; +} + +using ClChanceFn = bool(SHIM_CDECL*)(float); +using ClRandRangeFn = int(SHIM_CDECL*)(int, int); +void* g_trClChance = nullptr; +void* g_trClRandRange = nullptr; + +bool SHIM_CDECL DetourClChance(float p) { + NoteCaller(__builtin_return_address(0), 0); + return reinterpret_cast(g_trClChance)(p); +} +int SHIM_CDECL DetourClRandRange(int lo, int hi) { + NoteCaller(__builtin_return_address(0), 1); + return reinterpret_cast(g_trClRandRange)(lo, hi); +} +// ---- the bracket detour ------------------------------------------------------------------------ + +using ResumeFn = void(SHIM_THISCALL*)(void*, void*); +using SeedFn = void*(SHIM_THISCALL*)(void*, std::uint32_t); + +void* g_trResume = nullptr; +std::uint32_t g_seq = 0; +int g_depth = 0; + +void SHIM_THISCALL DetourOnResumePlaying(void* self, void* ev) { + const std::uintptr_t client = reinterpret_cast(self); + const std::uint32_t agent = U32(client + A::StrategyClient_off_AIAgent); + const std::uint32_t playerId = U32(client + A::StrategyClient_off_PlayerId); + const void* rng = reinterpret_cast(U32(client + A::StrategyClient_off_RNG)); + const std::uint32_t seq = ++g_seq; + + // Reentrancy is not expected -- `RunPendingAITurns` steps clients one at a time on the main + // thread -- but a nested bracket would silently corrupt the table, so it is refused and said so. + if (g_depth != 0) { + LogF("airng seq=%u NESTED depth=%d client=0x%08x -- bracket skipped, table not disturbed", + seq, g_depth, static_cast(client)); + ++g_depth; + reinterpret_cast(g_trResume)(self, ev); + --g_depth; + return; + } + + std::uint32_t pinned = 0; + if (g_pin && agent != 0 && rng != nullptr) { + // Re-seed at bracket entry so every measured turn starts from the same generator state. + // Seeded with pin_seed + (seq-1) so the three AI clients of one turn do NOT share a stream + // -- sharing one would make "the two shadow empires drew the same number of words" a + // property of the seed rather than of the board. + pinned = g_pinSeed + (seq - 1); + reinterpret_cast(g_exeBase + A::RNG_Seed)(const_cast(rng), pinned); + } + + const std::int32_t leftIn = LeftOf(rng); + const std::int32_t idxIn = IndexOf(rng); + const std::uint32_t lifeIn = LifetimeWords(rng); + ResetBracket(rng); + g_ncallers = 0; // declared below ResetBracket; cleared here, at the same moment + ++g_depth; + reinterpret_cast(g_trResume)(self, ev); + --g_depth; + const void* watched = g_active; + g_active = nullptr; + const std::int32_t leftOut = LeftOf(rng); + const std::int32_t idxOut = IndexOf(rng); + const std::uint32_t leftDelta = WordsBetween(leftIn, leftOut); + + LogF("airng seq=%u pid=%u agent=0x%08x rng=0x%08x pin=%s%08x left=%d->%d idx=%d->%d " + "left_delta=%u observed=%u calls=%u residual=%d foreign_words=%u foreign_calls=%u " + "sites=%u overflow=%u life_in=%u life_out=%u", + seq, static_cast(playerId), static_cast(agent), + static_cast(reinterpret_cast(rng)), g_pin ? "" : "off:", + static_cast(pinned), leftIn, leftOut, idxIn, idxOut, leftDelta, g_observedWords, + g_observedCalls, static_cast(leftDelta) - static_cast(g_observedWords), + g_foreignWords, g_foreignCalls, static_cast(g_nsites), g_siteOverflow, lifeIn, + LifetimeWords(rng)); + for (std::size_t i = 0; i < g_nsites; ++i) { + LogF("airngsite seq=%u pid=%u ret_rva=0x%08x va=0x%08x entry=%s calls=%u words=%u zero=%u", + seq, static_cast(playerId), g_sites[i].ret_rva, + static_cast(A::IMAGE_BASE + g_sites[i].ret_rva), + draw_entry_name(g_sites[i].entry), g_sites[i].calls, g_sites[i].words, + g_sites[i].zero_calls); + } + for (std::size_t i = 0; i < g_ncallers; ++i) + LogF("airngcall seq=%u pid=%u ret_rva=0x%08x va=0x%08x facade=%s calls=%u", seq, + static_cast(playerId), g_callers[i].ret_rva, + static_cast(A::IMAGE_BASE + g_callers[i].ret_rva), + caller_kind(g_callers[i].which), g_callers[i].calls); + if (watched != rng) + LogF("airng seq=%u WARNING watched generator moved during the bracket", seq); + // The census, every bracket: every generator the process has drawn from, and every site that + // spent a word on it. At seq=1 this is a complete account of what LOADING the save cost. + for (std::size_t i = 0; i < g_ngens; ++i) + LogF("airngen seq=%u rng=0x%08x life_words=%u life_calls=%u%s", seq, + static_cast(reinterpret_cast(g_gens[i].gen)), + g_gens[i].words, g_gens[i].calls, g_gens[i].gen == rng ? " <-- this bracket" : ""); + for (std::size_t i = 0; i < g_ncensus; ++i) + LogF("airngcensus seq=%u rng=0x%08x ret_rva=0x%08x va=0x%08x entry=%s calls=%u words=%u", + seq, static_cast(reinterpret_cast(g_census[i].gen)), + g_census[i].ret_rva, static_cast(A::IMAGE_BASE + g_census[i].ret_rva), + draw_entry_name(g_census[i].entry), g_census[i].calls, g_census[i].words); + if (g_genOverflow || g_censusOverflow) + LogF("airng seq=%u CENSUS OVERFLOW gens=%u sites=%u -- the totals above are a LOWER BOUND", + seq, g_genOverflow, g_censusOverflow); +} + +} // namespace + +bool ai_rng_config(const char* key, const char* value, std::string* err) { + if (std::strcmp(key, "airng") == 0) { + g_enabled = std::strcmp(value, "on") == 0 || std::strcmp(value, "1") == 0; + return true; + } + if (std::strcmp(key, "airng.out") == 0) { + std::snprintf(g_outPath, sizeof g_outPath, "%s", value); + return true; + } + if (std::strcmp(key, "airng.pin_seed") == 0) { + if (std::strcmp(value, "off") == 0 || std::strcmp(value, "0") == 0) { + g_pin = false; + return true; + } + char* end = nullptr; + const unsigned long v = std::strtoul(value, &end, 16); + if (end == value) { + if (err) *err = "expected a hex value or 'off'"; + return true; + } + g_pin = true; + g_pinSeed = static_cast(v); + return true; + } + return false; +} + +void install_ai_rng(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)) { + g_log = log; + g_exeBase = exeBase; + if (!g_enabled) return; + if (!g_outPath[0]) std::snprintf(g_outPath, sizeof g_outPath, "%s\\shim.airng.txt", gameDir); + g_out = std::fopen(g_outPath, "w"); + if (!g_out) LogF("airng: cannot open %s -- output goes to shim.log only", g_outPath); + + void* target = reinterpret_cast(exeBase + A::StrategyClient_OnResumePlaying); + MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast(&DetourOnResumePlaying), + &g_trResume); + MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1; + LogF("airng: bracket StrategyClient::OnResumePlaying rva=0x%08x va=%p create=%s enable=%s", + A::StrategyClient_OnResumePlaying, target, MH_StatusToString(s1), MH_StatusToString(s2)); + LogF("airng: out=%s pin_seed=%s%08x", g_outPath, g_pin ? "" : "off:", + static_cast(g_pinSeed)); + if (g_pin) + LogF("airng: PINNING IS ON -- this run's autosave is EXPECTED to differ from the published " + "oracle and must not be compared against it"); + // The two hooked AI-exclusive RNG facades, for call-site attribution (see above). + struct FacadeHook { const char* name; std::uint32_t rva; void* detour; void** tramp; }; + const FacadeHook facades[] = { + {"cl_Chance", A::cl_Chance, reinterpret_cast(&DetourClChance), &g_trClChance}, + {"cl_RandRange", A::cl_RandRange, reinterpret_cast(&DetourClRandRange), &g_trClRandRange}, + }; + for (const FacadeHook& f : facades) { + void* t = reinterpret_cast(exeBase + f.rva); + MH_STATUS a = MH_CreateHook(t, f.detour, f.tramp); + MH_STATUS b = a == MH_OK ? MH_EnableHook(t) : a; + LogF("airng: facade %s rva=0x%08x va=%p create=%s enable=%s", f.name, f.rva, t, + MH_StatusToString(a), MH_StatusToString(b)); + } + draw_sites_set_observer(&ObserveDraw); +} + +void ai_rng_flush(void (*log)(const char*)) { + (void)log; + if (g_out) std::fflush(g_out); +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/ai_rng.h b/src/shim/hooks/ai_rng.h new file mode 100644 index 0000000..9d2353c --- /dev/null +++ b/src/shim/hooks/ai_rng.h @@ -0,0 +1,68 @@ +// Lane PAR -- ROLL PARITY. Does one run of a strategic AI client consume a fixed number of RNG +// words regardless of the path it takes? +// +// WHY A BRACKET AND NOT A LEDGER. +// +// Lane Z's `RngLedger` brackets the *strategic* generator at `StrategyServer+0x16c` across the turn +// drivers, and lane H's `draw_sites` attributes every draw in the process to a return address. Both +// answer questions about the server. Roll parity is a question about a **client**: each AI player +// owns a private `Mars::RNG` at `StrategyClient+0x134`, seeded per process, and the entire AI turn +// runs synchronously inside `StrategyClient::OnResumePlaying 0x00777480` +// (`if (this->+0x12c) agent->vt[1](0x26, ev)` at +0x9d). One detour on that function is therefore an +// exact bracket around one client's whole turn, and `this` names the client, its player id +// (`+0x148`), its agent (`+0x12c`, non-null only on an AI client) and its generator. +// +// TWO INDEPENDENT MEASUREMENTS OF THE SAME NUMBER, because method rule 1 says a green number that +// compared nothing is worse than no number: +// +// * `observed` -- the sum over the seven hooked entry points, via a `draw_sites` observer, with +// per-return-address attribution so a difference between two brackets can be localised to a +// site rather than merely reported as a total; +// * `left_delta` -- `left` (RNG+0x9c8) read directly off the object at entry and exit. +// +// They must agree. A positive residual (`left_delta > observed`) is an **inlined draw on the AI +// path** -- exactly what method rule 16 warns a call-graph sweep cannot see, and the seven detours +// are a call-graph sweep by another name. The residual is printed on every bracket, including when +// it is zero, so it cannot hide. +// +// METHOD RULE 20, BUILT IN. A client that drew nothing and a client that never ran look identical +// in a word count. Every entry to `OnResumePlaying` emits a row whether or not it drew, carrying +// `agent=` so "no AI on this client" is distinguishable from "the AI ran and drew nothing". +// +// SEED PINNING (`airng.pin_seed=`, off by default). Lane L1 established that every AI client's +// generator is seeded with a DIFFERENT value in every process, so two runs of the same save are not +// comparable draw-for-draw. When pinning is on, this module re-seeds the client generator at +// bracket ENTRY, so every measured turn starts from an identical generator state and any remaining +// difference in the count is a difference of PATH, which is the whole question. This deliberately +// changes the game's behaviour: a pinned run's autosave is expected to differ from the published +// oracle and must never be compared against it (method rule 19 -- the point is that the perturbation +// is intended, declared, and confined to one configuration). +// +// WHAT THIS CANNOT DO, stated because it is the price of the design: +// * it measures a whole turn, not a phase. It cannot say which of the ~34 Process Turn phases +// spent a word except through the return-address table, which resolves to a call site and not +// to a phase; +// * `left` is a block position, so a bracket that consumed 624 words or more would alias. An AI +// turn spends single digits; the row prints `left` before and after so an aliasing bracket is +// visible rather than silently wrong; +// * it says nothing about *why* a path was taken. Pair it with `aiorders=on` for that. +#pragma once + +#include +#include +#include + +namespace shim::hooks { + +// `airng=on|off`, `airng.out=`, `airng.pin_seed=`. Returns false if `key` is not +// ours. +bool ai_rng_config(const char* key, const char* value, std::string* err); + +// Installs the `OnResumePlaying` bracket and registers the draw observer. No-op unless `airng=on`. +// Call after MH_Initialize and after `init_draw_sites`. +void install_ai_rng(std::uintptr_t exe_base, const char* game_dir, void (*log_line)(const char*)); + +// Flushes the output file. Safe at shutdown. +void ai_rng_flush(void (*log_line)(const char*)); + +} // namespace shim::hooks diff --git a/src/shim/hooks/draw_sites.cpp b/src/shim/hooks/draw_sites.cpp index 139b8b8..f204278 100644 --- a/src/shim/hooks/draw_sites.cpp +++ b/src/shim/hooks/draw_sites.cpp @@ -24,6 +24,7 @@ std::uint32_t g_other_words = 0; std::uint32_t g_other_calls = 0; std::uint32_t g_overflow = 0; const void* g_strategic = nullptr; +DrawObserver g_observer = nullptr; // `left` lives at RNG+0x9c8 (the object base). Every entry point below normalises its argument to // that base before reading it, because the image uses BOTH conventions: the inner primitives take @@ -47,6 +48,9 @@ void record(DrawEntry e, const void* ret, const void* obj, std::uint32_t words) const std::uint32_t rva = static_cast(reinterpret_cast(ret) - g_exe_base); LockGuard g(g_mu); + // Lane PAR: hand the raw draw to the per-generator observer before it is folded into the + // two-class accumulator, which cannot tell one client's generator from another's. + if (g_observer) g_observer(e, rva, obj, words); const bool strategic = g_strategic != nullptr && obj == g_strategic; if (strategic) { g_total_words += words; @@ -200,6 +204,11 @@ const DrawSiteHook* draw_site_hooks(std::size_t* count) { void init_draw_sites(std::uintptr_t exe_base) { g_exe_base = exe_base; } +void draw_sites_set_observer(DrawObserver obs) { + LockGuard g(g_mu); + g_observer = obs; +} + void draw_sites_set_generator(const void* strategic_rng) { LockGuard g(g_mu); g_strategic = strategic_rng; diff --git a/src/shim/hooks/draw_sites.h b/src/shim/hooks/draw_sites.h index 5e97f43..b811645 100644 --- a/src/shim/hooks/draw_sites.h +++ b/src/shim/hooks/draw_sites.h @@ -93,4 +93,19 @@ std::uint32_t draw_sites_other_calls(); // Sites seen since reset that did not fit in the table (a non-zero value invalidates the sum). std::uint32_t draw_sites_overflow(); +// ---- lane PAR: a second consumer of the same detours ----------------------------------------- +// +// The accumulator above buckets every draw into exactly two classes, "the strategic generator" and +// "everything else", because that is all the boundary ledger needed. Roll parity is a question +// about **one client's** generator, and on the reference board three AI clients plus the human each +// own a distinct `Mars::RNG` at `StrategyClient+0x134`, all of which land in "everything else". +// +// Rather than hook the seven entry points a second time -- MinHook allows one hook per target, and +// these detours are installed unconditionally -- `ai_rng` registers an observer and does its own +// bucketing by generator instance. The observer runs INSIDE the accumulator's lock, so it must not +// call back into this module and must not block. +using DrawObserver = void (*)(DrawEntry entry, std::uint32_t ret_rva, const void* generator, + std::uint32_t words); +void draw_sites_set_observer(DrawObserver obs); + } // namespace shim::hooks diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 1802396..373c9f3 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -29,6 +29,7 @@ #include "shim/hooks/tail_rng.h" #include "shim/hooks/tech_effects.h" #include "shim/hooks/ai_orders.h" +#include "shim/hooks/ai_rng.h" #include "shim/hooks/watchpoints.h" #include "shim/trace/hook.h" #include "shim/trace/selftest.h" @@ -91,6 +92,9 @@ Config ReadConfig() { if (shim::hooks::probe_config(p, val, &probe_n)) { Log("config: %s=%s -> %u lane-H entry probes", p, val, static_cast(probe_n)); + } else if (shim::hooks::ai_rng_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::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); @@ -320,6 +324,11 @@ void InstallHooks(shim::trace::Tracer& tracer) { // (ApplyTurnCommandBatch) is a different function from the watchpoint module's arming point // (ApplyAllTurnCommands, its only caller), so the two never contend for a MinHook target. shim::hooks::install_ai_orders(exeBase, g_dir, &ShimLogLine); + // Lane PAR: the per-client RNG bracket on StrategyClient::OnResumePlaying. Off unless + // `airng=on`. Installed AFTER the draw-site detours because it registers an observer on them + // rather than hooking the seven entry points a second time (MinHook: one hook per target). + // Its target is a client event handler, shared with nothing else in the shim. + shim::hooks::install_ai_rng(exeBase, g_dir, &ShimLogLine); // Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler. // Installed last so it is nowhere near the template hooks it is meant to measure. @@ -412,6 +421,7 @@ void Shim_Init(HMODULE self) { void Shim_Shutdown() { shim::hooks::watch_flush(&ShimLogLine); shim::hooks::ai_orders_flush(&ShimLogLine); + shim::hooks::ai_rng_flush(&ShimLogLine); Log("%s", shim::fpu::summary().c_str()); shim::trace::Tracer& tracer = shim::trace::Tracer::instance(); if (tracer.is_open()) { diff --git a/src/shim/shim.cfg.parbr b/src/shim/shim.cfg.parbr new file mode 100644 index 0000000..d607692 --- /dev/null +++ b/src/shim/shim.cfg.parbr @@ -0,0 +1,55 @@ +# Lane PAR -- ROLL PARITY. THE THREE CONFIGS parctl / parbr / parpin DIFFER BY ONE KEY EACH. +# +# parctl airng=off the rule-19 control +# parbr airng=on the bracket, UNPINNED (the game's own seeds) +# parpin airng=on + airng.pin_seed= the bracket with the client generator re-seeded +# at bracket entry, so two runs start identical +# +# parctl and parbr must produce the SAME autosave. parpin is EXPECTED to differ -- it deliberately +# changes the AI's random stream, which is the experimental variable, and its autosave is never +# compared against the published oracle (method rule 19: the perturbation is intended, declared and +# confined to one configuration). +# +# Every template hook is off: this lane wants nothing in the log but the bracket. The seven +# draw-site detours are installed by main.cpp whenever hooks != off and are therefore present in +# ALL THREE configs, which is what makes the pair a real single-key control. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=off +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=on diff --git a/src/shim/shim.cfg.parctl b/src/shim/shim.cfg.parctl new file mode 100644 index 0000000..c1eb8a5 --- /dev/null +++ b/src/shim/shim.cfg.parctl @@ -0,0 +1,55 @@ +# Lane PAR -- ROLL PARITY. THE THREE CONFIGS parctl / parbr / parpin DIFFER BY ONE KEY EACH. +# +# parctl airng=off the rule-19 control +# parbr airng=on the bracket, UNPINNED (the game's own seeds) +# parpin airng=on + airng.pin_seed= the bracket with the client generator re-seeded +# at bracket entry, so two runs start identical +# +# parctl and parbr must produce the SAME autosave. parpin is EXPECTED to differ -- it deliberately +# changes the AI's random stream, which is the experimental variable, and its autosave is never +# compared against the published oracle (method rule 19: the perturbation is intended, declared and +# confined to one configuration). +# +# Every template hook is off: this lane wants nothing in the log but the bracket. The seven +# draw-site detours are installed by main.cpp whenever hooks != off and are therefore present in +# ALL THREE configs, which is what makes the pair a real single-key control. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=off +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=off diff --git a/src/shim/shim.cfg.parorders b/src/shim/shim.cfg.parorders new file mode 100644 index 0000000..aea8ff0 --- /dev/null +++ b/src/shim/shim.cfg.parorders @@ -0,0 +1,56 @@ +# Lane PAR -- ROLL PARITY. THE THREE CONFIGS parctl / parbr / parpin DIFFER BY ONE KEY EACH. +# +# parctl airng=off the rule-19 control +# parbr airng=on the bracket, UNPINNED (the game's own seeds) +# parpin airng=on + airng.pin_seed= the bracket with the client generator re-seeded +# at bracket entry, so two runs start identical +# +# parctl and parbr must produce the SAME autosave. parpin is EXPECTED to differ -- it deliberately +# changes the AI's random stream, which is the experimental variable, and its autosave is never +# compared against the published oracle (method rule 19: the perturbation is intended, declared and +# confined to one configuration). +# +# Every template hook is off: this lane wants nothing in the log but the bracket. The seven +# draw-site detours are installed by main.cpp whenever hooks != off and are therefore present in +# ALL THREE configs, which is what makes the pair a real single-key control. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=on +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=on +aiorders.out=C:\SOTS\shim.aiorders.txt diff --git a/src/shim/shim.cfg.parpin b/src/shim/shim.cfg.parpin new file mode 100644 index 0000000..2550e7e --- /dev/null +++ b/src/shim/shim.cfg.parpin @@ -0,0 +1,56 @@ +# Lane PAR -- ROLL PARITY. THE THREE CONFIGS parctl / parbr / parpin DIFFER BY ONE KEY EACH. +# +# parctl airng=off the rule-19 control +# parbr airng=on the bracket, UNPINNED (the game's own seeds) +# parpin airng=on + airng.pin_seed= the bracket with the client generator re-seeded +# at bracket entry, so two runs start identical +# +# parctl and parbr must produce the SAME autosave. parpin is EXPECTED to differ -- it deliberately +# changes the AI's random stream, which is the experimental variable, and its autosave is never +# compared against the published oracle (method rule 19: the perturbation is intended, declared and +# confined to one configuration). +# +# Every template hook is off: this lane wants nothing in the log but the bracket. The seven +# draw-site detours are installed by main.cpp whenever hooks != off and are therefore present in +# ALL THREE configs, which is what makes the pair a real single-key control. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=off +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=on +airng.pin_seed=5A17C0DE