PAR: roll parity -- the AI's per-turn RNG word count is NOT fixed, at any scope

New shim module `ai_rng`: a bracket on StrategyClient::OnResumePlaying over the per-client
generator at +0x134, with two independent measurements per bracket (an observer on the existing
seven draw-site detours, and `left` read straight off the object) plus AI-call-site attribution
through the cl_* facades and an optional seed pin.

Measured on VM140/VM145, six runs, five fresh processes, unpinned autosaves byte-identical to the
published oracle:

  turn2->turn3   human 0, AI 32 -> 3 words, AI 496 -> 0, AI 512 -> 0
  turn1->turn2   human 0, AI 32 -> 7 words, AI 496 -> 0, AI 512 -> 1 word ... or 3

The last row is the answer: 512 makes ONE cl_RandRange call (the research-target tie-break at
0x006a8495, phase 18) and that one call cost 1 word in one process and 3 in another, because
RNG_NextInt is an unbounded rejection loop. The count is not fixed across clients, across turns,
or across processes with the path held fixed.

Also found: cl_RandFloat 0x00579c70, a third cl_* RNG facade that reaches RNG_NextFloat by a TAIL
JUMP and so leaves no rel32 edge for a call-graph sweep -- which is why ai-turn-logic.md 5's "zero
NextFloat calls from the AI module" reads as true when it is not.

The instrument caught its own defect: RNG_Chance calls RNG_NextFloat and both are detoured, so a
drawn word was reported twice. left_delta was never affected, which is the point of having two
measurements.

Gates run separately on a fresh build directory: clean_room_check OK, host ctest 55/55, CT111 shim
cross-build OK.
This commit is contained in:
alex 2026-09-08 19:32:19 -04:00
parent 6ba2c796fc
commit e1995e6b11
12 changed files with 940 additions and 1 deletions

View file

@ -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,

115
docs/PAR-predictions.md Normal file
View file

@ -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`.

View file

@ -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 @ f761fb1, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 954f3ce, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -2083,6 +2083,10 @@ constexpr uint32_t FlightPlan_Waypoint_Set = 0x003006e0;
constexpr uint32_t NodeRoute_Construct = 0x002e1b20;
// cdecl bool (StarFleet* f) // 90 B. FUN_006fe320(f) returns f->LocID(+0xa0) ONLY when the location's kind tag (+0x14) is 2, a DEEP-SPACE POINT -- never a system. Returns true iff any of the three position components differs by exact IEEE comparison (fucompp, test ah,0x44, jp), no epsilon. OrderFleetMove's opening snap is therefore point-only; combat-retreat-pipeline.md 2.5's 'snaps the fleet's position onto its current system' is corrected here -- a fleet parked at a system is never snapped [verified]
constexpr uint32_t StarFleet_PosDiffersFromPointLocation = 0x0040ec50;
// offset int -- the owning player's NET id on a Game::StrategyClient (0x708 bytes). Verified from the instruction stream in Game::StrategyApp::RunPendingAITurns 0x00838c60: the pending-AI vector at app+0x1c..+0x20 is walked in index order and each entry `edi` is matched against the client vector at app+0xc..+0x10 with `mov esi,[edx]; cmp DWORD PTR [esi+0x148],edi; je ...` at 0x00838cf0-0x00838cf8, where esi is a StrategyClient* and edi is a player net id. Corroborated by ClientOrder_FleetTask 0x007634d0, which passes `this->+0x148` as the first argument of the local validate 0x00821cf0. LIVE CROSS-CHECK: read out of the running game by lane PAR's OnResumePlaying bracket, it gives 16 / 32 / 496 / 512 on the reference board -- exactly the player ids the lane-L4 aiorders dump prints from the submitted TurnCommands blocks [verified]
constexpr uint32_t StrategyClient_off_PlayerId = 0x00000148;
// cdecl float () -- the THIRD member of the cl_* RNG facade, alongside cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0. Whole 0x22-byte body: `eax = g_StrategyClients[g_CurrentClientIndex]; fldz; if (!eax) ret 0.0f; ecx = eax->+0x134; fstp st(0); lea ecx,[ecx+4]; JMP RNG_NextFloat` -- it reaches the generator by a TAIL JUMP, not a call, which is why an image-wide rel32 scan for the seven RNG entry points does not see it and why ai-turn-logic.md 5's \"zero calls from the AI module to NextFloat\" reads as true when it is not. Exactly ONE caller in the image, at 0x006ad873 inside the ship-design composer 0x006ad700, where the drawn unit is compared against 0.5. Because of the tail jump the draw-site detour records the return address 0x006ad878 -- the AI call site itself -- so this facade is the one whose consumer is directly visible in a draw-site table. Measured live: it spends exactly one MT word per call, 1 word on the reference turn 2->3 and 2 on turn 1->2 [verified]
constexpr uint32_t cl_RandFloat = 0x00179c70;
// data double 0xBFC3333340000000 = -0.15000000596046448 = (double)(float)-0.15f -- the elimination-limit divisor, loaded by ServerPlayer::UpdateBankruptcyLimits at 0x00818612 with `DD 05` (fld QWORD). It is the negation of g_DebtInterestRate (0x009ed188), and the two are stored separately. Named here because lane N's fix cited the value but not the address [verified]
constexpr uint32_t g_BankruptcyEliminationDivisor = 0x0062ec30;
// data float* -> 0x00b23e28. The config loader's pointer slot for the key BANKRUPTCY_PROTECTION_LIMIT_FACTOR, the same indirection pattern as ADDICTION_INCOME_MOD's (0x00aeca48 -> 0x00aeca44). Read once in the image, by UpdateBankruptcyLimits at 0x0081866e [verified]
@ -2101,6 +2105,10 @@ constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870;
constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0;
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1 [verified]
constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
// label the SECOND of the three per-player gate loops inside StrategySim::ApplyTurnCommandBatch. `esi = block+0x20`; it tests the research-boost gate at +0x20 and applies its {spend, fraction} payload through 0x00820560, bumping ModCount inline at 0x008907bc. It runs AFTER all twenty-seven list loops, not with the other gates -- the six prologue gates are split across three loops at three points in the routine, which is why a port that applies them together as a prologue gets the order wrong [verified]
constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopB = 0x004907b1;
// label the THIRD and last per-player gate loop inside StrategySim::ApplyTurnCommandBatch. `esi = block+0x24`; it tests the group-4 gate at +0x2c and applies its {bool, int} payload through 0x00821b90. It is the final step of the whole batch. Together with the loop-A head at 0x0088fdb0 and the loop-B head at 0x008907b1, and the six inlined ModCount bump sites, this gives nine positions of the thirty-step apply schedule an address-monotonicity check -- the only part of the sequence that can be re-derived rather than inherited from the read of the `add edi, imm` chain [verified]
constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopC = 0x0049080a;
// thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified]
constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0;
// data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified]

491
src/shim/hooks/ai_rng.cpp Normal file
View file

@ -0,0 +1,491 @@
#include "shim/hooks/ai_rng.h"
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#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<void*>(p), n);
}
inline std::uint32_t U32(std::uintptr_t p) {
return Readable(p, 4) ? *reinterpret_cast<volatile std::uint32_t*>(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<std::int32_t>(U32(reinterpret_cast<std::uintptr_t>(obj) + A::RNG_off_Left));
}
std::int32_t IndexOf(const void* obj) {
if (!obj) return -1;
const std::uint32_t next = U32(reinterpret_cast<std::uintptr_t>(obj) + A::RNG_off_Next);
if (!next) return -1;
const std::uintptr_t base = reinterpret_cast<std::uintptr_t>(obj) + A::RNG_off_State;
if (next < base) return -1;
return static_cast<std::int32_t>((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<std::uint32_t>(before - after);
return static_cast<std::uint32_t>(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<std::uint32_t>(reinterpret_cast<std::uintptr_t>(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<ClChanceFn>(g_trClChance)(p);
}
int SHIM_CDECL DetourClRandRange(int lo, int hi) {
NoteCaller(__builtin_return_address(0), 1);
return reinterpret_cast<ClRandRangeFn>(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<std::uintptr_t>(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<const void*>(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<unsigned>(client));
++g_depth;
reinterpret_cast<ResumeFn>(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<SeedFn>(g_exeBase + A::RNG_Seed)(const_cast<void*>(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<ResumeFn>(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<unsigned>(playerId), static_cast<unsigned>(agent),
static_cast<unsigned>(reinterpret_cast<std::uintptr_t>(rng)), g_pin ? "" : "off:",
static_cast<unsigned>(pinned), leftIn, leftOut, idxIn, idxOut, leftDelta, g_observedWords,
g_observedCalls, static_cast<int>(leftDelta) - static_cast<int>(g_observedWords),
g_foreignWords, g_foreignCalls, static_cast<unsigned>(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<unsigned>(playerId), g_sites[i].ret_rva,
static_cast<unsigned>(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<unsigned>(playerId), g_callers[i].ret_rva,
static_cast<unsigned>(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<unsigned>(reinterpret_cast<std::uintptr_t>(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<unsigned>(reinterpret_cast<std::uintptr_t>(g_census[i].gen)),
g_census[i].ret_rva, static_cast<unsigned>(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<std::uint32_t>(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<void*>(exeBase + A::StrategyClient_OnResumePlaying);
MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast<void*>(&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<unsigned>(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<void*>(&DetourClChance), &g_trClChance},
{"cl_RandRange", A::cl_RandRange, reinterpret_cast<void*>(&DetourClRandRange), &g_trClRandRange},
};
for (const FacadeHook& f : facades) {
void* t = reinterpret_cast<void*>(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

68
src/shim/hooks/ai_rng.h Normal file
View file

@ -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=<hex>`, 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 <cstddef>
#include <cstdint>
#include <string>
namespace shim::hooks {
// `airng=on|off`, `airng.out=<path>`, `airng.pin_seed=<hex|off>`. 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

View file

@ -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<std::uint32_t>(reinterpret_cast<std::uintptr_t>(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;

View file

@ -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

View file

@ -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<unsigned>(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()) {

55
src/shim/shim.cfg.parbr Normal file
View file

@ -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=<hex> 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

55
src/shim/shim.cfg.parctl Normal file
View file

@ -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=<hex> 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

View file

@ -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=<hex> 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

56
src/shim/shim.cfg.parpin Normal file
View file

@ -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=<hex> 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