merge lane PAR (header regenerated, not hand-resolved)
This commit is contained in:
commit
f99488af9f
12 changed files with 932 additions and 1 deletions
|
|
@ -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
115
docs/PAR-predictions.md
Normal 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`.
|
||||
|
|
@ -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
491
src/shim/hooks/ai_rng.cpp
Normal 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
68
src/shim/hooks/ai_rng.h
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
55
src/shim/shim.cfg.parbr
Normal 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
55
src/shim/shim.cfg.parctl
Normal 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
|
||||
56
src/shim/shim.cfg.parorders
Normal file
56
src/shim/shim.cfg.parorders
Normal 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
56
src/shim/shim.cfg.parpin
Normal 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
|
||||
Loading…
Add table
Reference in a new issue