merge lane PAR (header regenerated, not hand-resolved)

This commit is contained in:
alex 2026-09-08 19:38:32 -04:00
commit f99488af9f
12 changed files with 932 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 @ 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 <cstdint>

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