Compare commits

..

4 commits

17 changed files with 1293 additions and 3 deletions

View file

@ -102,11 +102,15 @@ if(WIN32)
src/shim/hooks/fleet_movement.cpp
src/shim/hooks/player_turn.cpp
src/shim/hooks/tail_rng.cpp
src/shim/hooks/draw_sites.cpp)
src/shim/hooks/draw_sites.cpp
src/shim/hooks/probe_entry.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,
# because a register-transparent asm stub has no C++ prototype for the template to take.
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim
sots_game_effects mars_rng shim_budget shim_techfx
shim_colony shim_movement shim_events
shim_player_turn shim_rng_ledger)
shim_player_turn shim_rng_ledger minhook)
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
add_library(binkw32 SHARED src/shim/main.cpp src/shim/fpu_force.cpp src/shim/binkw32.def)

315
docs/H-probes.md Normal file
View file

@ -0,0 +1,315 @@
# Lane H — five live probes, predicted before the build
Lane H, 2026-09-08. Worktree `wip/probes` off `main` `b2bad30`. Every number below was written
**before** the shim was built and before any run (method rule 2). Each section ends with the ways
the prediction can fail and the symptom of each, so a wrong prediction is informative rather than
merely wrong.
The lane exists because of method rule 18: five open questions that are one probe away and that
other lanes have been circling statically.
---
## P1 — the determinism oracle, regenerated
Lane N's End Turns overwrote the three autosave *contents* on VM140. The file set is unchanged, so
every documented Load-dialog row position still holds, and `ref-turn2.sav` on the VM still hashes
`ab4ac2d7e2977260…` (checked before the run). What is gone is the byte-identical reference pair.
**P1. Loading `ref-turn2.sav` and pressing End Turn once, under build
`recap-7584bad-20260908T0615Z` with `hooks=trace`, writes**
```
(Autosave EndTurn).sav 66,732 B bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948
(Autosave).sav 67,219 B 978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921
```
These are the values `findings/subsystems/determinism-oracle.md` recorded on 2026-09-07 across five
runs in two processes, and which lane R reproduced under the recap build.
**Why this is worth a run of its own.** Everything since has changed the process the oracle runs in:
a different shim build with nineteen template hooks and seven entry-point detours installed, an
x87 control-word module, a Zuul game played to turn 64 in the same `SavedGames` directory, and eight
save files where there were four. If the pair still reproduces, the oracle is revalidated **across
all of that**, and every Phase-2 comparison that leans on it keeps its footing.
**Falsification.**
* *Either hash differs.* Then the oracle is not stable across the session's changes and nothing
downstream of it can be trusted until the cause is found. Candidate causes, in the order they
would be checked: the installed `binkw32.dll` is not the recap build (hash it); `shim.cfg` is not
the recap trace config (read it); `display.cfg` / `Profile_re.ini` changed (they are inputs to
the process, not to the save, so this would be a *new* finding); `ref-turn2.sav` is not the file
it was (already excluded — hashed before the run).
* *Only `(Autosave).sav` differs.* The pre-turn marker is written from loaded state and the
post-turn one from computed state, so a pre-turn match with a post-turn mismatch localises the
problem to the turn computation rather than to the load or the writer.
* *Only `(Autosave EndTurn).sav` differs.* That would be the more alarming of the two: it is the
load → save round trip, which `determinism-oracle.md` proved stable for the *pre-turn* form, and
it would mean the loader or the writer moved.
---
## P2 — the four tail callees that can draw, and have never been seen to
Lane V2 closed lane K's tier-4 blind spot: of the eleven phase-23/33 vtable targets on
`StrategyServer+0x158` / `+0x15c`, **four** reach a draw on the strategic generator at
`StrategyServer+0x16c`, at eight instructions —
| outer callee | slot | inner | draw sites |
|---|---|---|---|
| `ServerSpyManager_vslot13` 0x008877b0 | spy 13 | — | `Chance` 0x00887c8a |
| " | " | 0x008408e0 | `Chance` 0x00840929, `Chance` 0x00840a3c, `NextInt` 0x008409c7 |
| `ServerSpyManager_vslot14` 0x0088db80 | spy 14 | — | `Chance` 0x0088dc43 |
| `ServerTradeManagerImpl_vslot15` 0x0082cca0 | trade 15 | — | `Chance` 0x0082cdb8 |
| `ServerTradeManagerImpl_vslot13` 0x0088ef80 | trade 13 | 0x00820ca0 | `NextFloat` 0x00820e18 |
| " | " | 0x0088b440 | `NextInt` 0x0088b613 |
Lane Z measured **0 tail words on 8 turns**, so `tail-rng-ledger.md`'s "the tail draws nothing" is a
property of *eight turns*, not of the code. Our corpus has no live trade routes and no spy program.
**The distinction this probe exists to make** is the one a word count cannot: *not reached* versus
*reached and gated*. "Never observed firing" is currently ambiguous between them, and the two have
opposite consequences for a reimplementation — the first says the phase is conditional, the second
says the phase always runs and the gate is a data condition our saves never satisfy.
**P2a. Each of the four outer callees is entered exactly once per End Turn, on every turn, on every
save.** The phase-23 and phase-33 dispatch sites (0x007d97a7 … 0x007d9811, 0x007d989b, 0x007d98a8)
are unconditional `call [vptr+n]` in a straight-line block, so entry is not a function of game
state at all. Entry counters read `calls = 1` per End Turn for each of 0x008877b0, 0x0088db80,
0x0082cca0, 0x0088ef80.
**P2b. On the existing corpus none of the eight draw sites fires.** No `draw_sites` row appears with
`ret_rva` in {0x00887c8f, 0x0084092e, 0x00840a41, 0x008409cc, 0x0088dc48, 0x0082cdbd, 0x00820e1d,
0x0088b618} (the return address is the site plus the five bytes of the `call`), and the tail's
bracket cost stays 0 — reproducing lane Z's number under a strictly better instrument.
**P2c. The three inner functions 0x008408e0, 0x00820ca0 and 0x0088b440 are entered 0 times.** This
is the load-bearing half. Together with P2a it says the gate is *inside* the outer callee, above the
inner call — i.e. an empty container (no spy programs, no trade routes) short-circuits before any
draw is reached — and it localises the condition to one function each rather than to "somewhere in
a 200-function closure".
**Falsification.**
* *An outer callee is entered 0 times.* Then phase 23/33 is itself conditional, and lane V2's
"reached ONLY virtually, from `<site>`" needs a gate note added: reachability from that site is
not the same as being called. This is the single most useful way for P2 to be wrong.
* *An inner is entered but draws 0.* The gate is below the inner call, not above it. The word count
is still 0 and lane Z's table is unchanged, but the map moves.
* *Any of the eight sites fires on the existing corpus.* Then `tail-rng-ledger.md` §2's "the tail's
cost on these turns is 0" is still true of those eight turns and false of ours, and the ledger
needs the tail term after all — the standalone's generator model would be short by that many
words on exactly the turns that matter.
* *An entry counter reads more than 1 per turn.* The dispatch block would then be inside a loop
nobody has read, which changes the cost model from "0 or 1 draw per site" to "per iteration".
### P2b-alt — an amendment, written after P2b and before any run
Everything above was committed before I read the four callee bodies. I then read them (to name the
three inner functions for `addresses.d`), and **one of the four does not look like the other three**.
This is recorded as a competing prediction rather than folded into P2b, because retrofitting P2b
after seeing the code would destroy the test.
Read from the decompiled bodies, each confirmed to be a real function start with exactly one caller:
| callee | the loop it is | empty when |
|---|---|---|
| `ServerTradeManagerImpl_vslot15` 0x0082cca0 | over the vector at `tradeManager+0x3c .. +0x40` | no trade routes |
| `ServerSpyManager_vslot14` 0x0088db80 | over the vector at `spyManager+0x10 .. +0x14` | no spy programs |
| `ServerSpyManager_vslot13` 0x008877b0 | 1,869 bytes, same manager, not fully read | — |
| **`ServerTradeManagerImpl_vslot13` 0x0088ef80** | **over `GetServer()+0x64 .. +0x68` — the FLEETS vector** | **no fleets** |
The first two are the shape P2b assumes: an empty container short-circuits the loop and no draw is
reachable, which is exactly "no trade routes and no spy program". **The fourth is not.** Its loop
iterates fleets — every save has fleets — and the draw is gated by a chain of per-fleet tests inside
the body (`0x0088f036` calls 0x00820ca0, whose result gates `0x0088f042`'s call to 0x0088b440).
**P2b-alt. On a save with fleets, `ServerTradeManagerImpl_vslot13`'s loop body runs, and probe row 5
(0x00820ca0) fires with a non-zero count — which means a `NextFloat` at 0x00820e18 and therefore a
NON-ZERO TAIL WORD COUNT.** If so, `tail-rng-ledger.md`'s "the tail's cost on these turns is 0" is
not merely workload-limited, it is limited by a per-fleet gate that our two ledger saves happened to
fail, and the Zuul save named for having 23 fleets is the obvious place to look.
P2b and P2b-alt disagree about the same run, which is what makes it worth making. If P2b holds on a
23-fleet save, the gate is above 0x00820ca0 and the fleet loop is not the reason. If P2b-alt holds,
the four-callee blind spot is not "no trade, no spies" — it is one gate on one fleet property, and
the ledger needs the tail term now rather than after a manufactured workload.
**What this probe cannot settle without a manufactured workload.** Whether the sites draw *when*
trade routes or spy programs exist. That needs a save with both, and building one is the expensive
part of this lane; if it is not built, P2 downgrades from "the tail never draws" to "the tail never
draws on a game with no trade and no espionage", which is a **narrower** claim than the one in the
ledger today and must be written that way (rule 6).
---
## P3 — `DetectEncounters`, the last non-research generator residual
Lane Y's standalone models 16 of a turn's ~20 words; the residual on its two calibrated pairs is 4
and 2, and **2 words on both are encounter detection** — sites 0x0050329d (`NextFloat`, depth 4) and
0x007929a4 (`NextInt`, depth 3), each 1 call and 1 word per turn on all three measured turns. Lane Y
refused to model it: *"fitting 2/turn to three observations is not a model."*
Lane I bounds the containing family at `|contacts| × |detectors|` and established that
`EncounterDetect_AssignContacts` draws one inlined `NextFloat` **per (contact, detector) trial,
before the accept test** — so an untec­hed detector has threshold `0.0f`, burns a word, and can never
accept. But lane Z's site run found `AssignContacts` **never ran**: the gate in
`EncounterDetect_ProcessTeamRecord` (0x007ca640) was not satisfied on any measured turn.
So the probe is on the **caller**. `EncounterDetect_ProcessTeamRecord` is bracketed with the three
quantities lane I derived from its instruction stream, computed at hook entry from the record:
* `gate` — ∃ entry of `rec->(+0x28 .. +0x2c)`, stride 0x44, with `entry[0]->+0xfc != 0`;
* `contacts` — entries whose object has `+0xfc != 0`;
* `detectors` — entries whose object has `+0xfc == 0` **and** `+0xfb == 0`;
* `bound` = `contacts × detectors`, the `AssignContacts` worst case.
**P3a. `ProcessTeamRecord` is called once per 0x74-byte `TeamRecord`** in the vector
`EncounterDetect_Run` receives — the same `StrategyServer+0x1e8` vector the tail hooks count as
`encounters`. On `ref-turn2` that vector holds **1** record by the time the tail runs, so the
expected call count per turn is small and single-digit.
**P3b. The turn's 2 detection words are consumed inside `ProcessTeamRecord`.** Both residual sites
sit at depth 3 and 4 below `DetectEncounters`, and `ProcessTeamRecord` is the only depth-2 function
on that path. The bracket over it therefore reads **2 words per turn**, and the detection residual
outside it is **0**.
**P3c — the one that changes the map. The 2 words are *not* the `AssignContacts` family.** `gate`
reads **false** on every record, `AssignContacts` is not called, and the `|contacts| × |detectors|`
bound is **irrelevant to the observed residual**. If that holds, lane Y's 2-word residual and lane
I's bound are two different things that have been discussed as one, and the residual is a *fixed*
per-record cost (one `NextInt` + one `NextFloat`) somewhere above the gate — which is a formula, and
a much cheaper one than the family bound.
**Falsification.**
* *`ProcessTeamRecord` costs 0 words.* Then the two residual sites are reached by some other path
out of `DetectEncounters` and P3b's depth reasoning is wrong; the next probe is a bracket on
`DetectEncounters` itself.
* *`gate` is true and `AssignContacts` fires.* Excellent outcome, not a failure: `contacts` and
`detectors` are then both recorded live and the bound becomes checkable against a measured word
count for the first time. P3c is wrong and lane I's family is the residual after all.
* *The cost is 2 per record rather than 2 per turn.* Then it scales with the record count and the
standalone needs `2 × |records|`, not `+2`. This is distinguishable only if some turn has more
than one record — which is exactly why the call count is recorded, not assumed.
* *My computed `contacts`/`detectors` disagree with the callee's own when `AssignContacts` does
fire.* Then lane I's classification rules are wrong or the record layout is, and the hook says so
by printing both numbers side by side rather than only mine.
---
## P4 — `CreateRaidEncounter`: separating two hypotheses a word count cannot
A *successful* trade-raid roll calls `ServerTradeManagerImpl` slot 17,
`ServerTradeManager_CreateRaidEncounter` 0x008938a0, which draws a `NextInt` at 0x008939ee to pick a
target but returns at 0x0089391c **without drawing** when the candidate vector is empty. It cost 0
words on all three of lane Z's measured turns.
Lane Z read that as "consistent either with no roll succeeding (≈11% on the defaults, so three quiet
turns in a row is unremarkable) or with an empty candidate list every time".
**A correction to that reading, before the run (rule 11).** 11% is the probability of a quiet
*turn*, not of three. With eight player entries and the image defaults the per-turn probability that
no roll at all succeeds is `0.8^8 × 0.95^8 = 0.1113`; three consecutive quiet turns is `0.00138`, or
about **1 in 720**. The two hypotheses were never equally likely. The expected number of successes
per turn is `8 × 0.2 + 8 × 0.05 = 2.0`.
**P4. `CreateRaidEncounter` is entered on essentially every turn — about 2 calls per turn — and
draws 0 words because the candidate list is empty.** Entry counter ≥ 1 on at least two of three
turns; no `draw_sites` row at `ret_rva` 0x008939f3.
**Falsification.**
* *0 entries across three turns.* Under the model that is a 1-in-720 event, so it would mean the
model is wrong, not that we were unlucky. Two concrete candidates, both checkable next: the odds
are not the image defaults on this save (a `StrategyVar` override in the game data), or one of the
two per-player skip gates lane Z found in `GenerateTradeRaidEncounters` (the `visitedMask` bit
test at 0x00893302, the `>2` pre-filter at 0x008933e0) suppresses the *call* while still spending
the `Chance` word — which would also mean the 16 words are not 16 independent rolls.
* *Entries > 0 and words > 0.* Then a raid target was picked, the candidate list is non-empty, and
the standalone needs the extra word **and** the target-selection draw — the row lane Y flagged as
"the one that will bite" becomes live rather than theoretical.
* *Entries far above 2 per turn.* The roll is not the only caller, or the odds are higher than the
defaults.
---
## P5 — the Zuul word count: what the raid loop actually iterates
Lane Z's free prediction, restated by lane Y as a consequence of its model: the two `Chance` sites in
`GenerateTradeRaidEncounters` cost one word per entry of `StrategyServer::Players`, which holds
**8** on the Human saves and **7** on the Zuul ones (`Sim.NumPlrs`: `#empires + one rebel AI per
distinct empire species + 4 NPC pseudo-players`). Lane N ran a Zuul End Turn but with the RNG hooks
**off**, so it does not answer this.
**P5. On a Zuul save each of 0x00893426 and 0x00893513 records `calls = 7`, `words = 7`, for 14
words at the trade-raid block instead of 16**, and the turn total is `14 + 2 (detection) + 0–2
(research)` = **16–18**, against 18–22 on the Human save.
**Falsification, and this is the point of the probe.**
* *8 calls per site on a 7-entry player vector.* The loop is not iterating the player vector. The
next candidates, in order: the `TeamRecord` vector it is handed as an argument, a species list, or
a fixed NPC count plus empires. This is the outcome that would matter most — lane Y's
`trade_raid.cpp` counts `NumPlrs` and would be wrong on every save.
* *7 calls at one site and 8 at the other.* The two sites have different loop bounds; the NPC site's
player-independent gate (`0.0f < S->+0x1a0`) is already known to be all-or-nothing per turn, so
this would mean something else again.
* *Fewer than 7.* One of the two per-player skip gates fires on the Zuul save and never fired on the
Human one — which would make the 16 an upper bound rather than the hard bound lane Z reported.
---
## What is being measured, and what is not
Every hook in this lane declares the strategic generator and **nothing else**, exactly as lane Z's
family does. None of them makes any claim about whether the turn was computed correctly. The entry
counters are register-transparent asm stubs that log and tail-jump to the trampoline: they cannot
be wrong about a calling convention because they never assume one, and they cannot report a word
count, only a call count — which is deliberate, because the word counts already come from two
independent instruments that agree.
A clean run here is a statement about RNG accounting and coverage, and about nothing else.
---
# Outcome — written after the runs
Everything above this line was committed before the shim was built. This section is the adjudication;
the full report with the evidence is `sots-re/findings/control-flow/tail-probes.md`.
| prediction | verdict |
|---|---|
| **P1** oracle reproduces | **held.** `bb4fd9ac89f41e3b…` and `978041acd168b56e…`, both byte-exact |
| **P2a** four outer callees entered once per End Turn | **held.** 1 per turn, 3 turns, 2 saves, every row |
| **P2b** none of the eight draw sites fires on the corpus | **held.** No draw-site row at any of the eight return addresses; tail cost 0 |
| **P2c** the three inner callees entered 0 times | **held.** The gate is inside each outer body, above the inner call |
| **P2b-alt** the fleet loop makes slot 13 draw on a fleet-rich save | **falsified.** 23 fleets, slot 13 entered, 0x00820ca0 entered zero times — the gate is the per-fleet test chain |
| **P3a** `ProcessTeamRecord` called once per record | **vacuous.** It was called **zero** times; the record vector is empty |
| **P3b** the 2 detection words are inside `ProcessTeamRecord` | **falsified**, exactly as its falsification clause anticipated. They are in 0x007d5150's subtree, one call from `DetectEncounters` |
| **P3c** the 2 words are not the `AssignContacts` family | **held**, and for a stronger reason than predicted: not a failed gate, an empty vector |
| **P4** `CreateRaidEncounter` entered ~2/turn, drawing 0 | **held.** 2 entries on one turn, 1 on the next, 0 words on all of them — candidate list empty |
| **P5** 7 calls / 7 words per site on a Zuul save, 14 not 16 | **held exactly**, on two consecutive turns |
## The thing no prediction covered
**The instrument changed the game.** Three End Turns under the full lane-H build produced 19/18/19
words where lane Z recorded 19/18/20, from a *byte-identical* input save, and an autosave 4 bytes
different from the one lane Z's runs produced twice. Six single-End-Turn runs from `z2-endturn.sav`
bisected it to one address: a MinHook detour on `ServerTradeManager_GenerateTradeRaidEncounters`
0x00893290. `hooks=off`, the template hooks alone, and the first eight probes all reproduce the
un-instrumented result; adding the ninth does not.
Two consequences for this repo:
1. `probes=` was added to `shim.cfg` so the probe set is switchable and bisectable. Every number in
the report was re-taken at `probes=8`, the largest configuration proved byte-identical to the
un-instrumented game.
2. **Lane Z's ledger is behaviour-neutral and its published numbers stand** — that had never been
checked, and it came out the right way.
The mechanism is undetermined. The five bytes MinHook patches at 0x00893290 land on a clean
instruction boundary (`push ebp; mov ebp,esp; push -1`) with no branch target inside them, so a
mangled prologue is not the explanation. The suppressed draw is `RollResearchEvent`'s at 0x0088df4f,
which runs *earlier in the turn* than the hooked function — which is the part to chase.

View file

@ -0,0 +1,204 @@
#include "shim/hooks/probe_entry.h"
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include "MinHook.h"
#endif
#include "generated/sots_addresses.h"
// ---- the counters ------------------------------------------------------------------------------
//
// Plain 32-bit counters, incremented from a stub that has already saved the flags, so no atomic is
// needed for correctness of the arithmetic on x86 and none is used: the turn pipeline is
// single-threaded (lane Z §7) and a torn read here would cost a count, not a crash. If a probe ever
// reads a wild number, that is itself the finding -- it would mean the address is entered off the
// turn thread.
namespace {
constexpr std::size_t kMaxProbes = 16;
std::uint32_t g_calls[kMaxProbes];
std::uint32_t g_total[kMaxProbes];
} // namespace
extern "C" void ProbeHit(int index) {
if (index < 0 || static_cast<std::size_t>(index) >= kMaxProbes) return;
++g_calls[index];
++g_total[index];
}
// ---- one trampoline slot and one asm stub per probe ---------------------------------------------
//
// The stub is written out per index rather than generated at runtime because a runtime-generated
// thunk would need an executable allocation and a relocation, and MinHook already owns that
// problem. Sixteen slots is the cap; `kProbes` below is shorter and the rest are inert.
#if defined(_WIN32)
#define PROBE_STUB(i) \
extern "C" void* g_tr_probe##i; \
void* g_tr_probe##i = nullptr; \
extern "C" void ProbeStub##i(void); \
asm(".text\n" \
".globl _ProbeStub" #i "\n" \
"_ProbeStub" #i ":\n" \
" pushfl\n" \
" pushal\n" \
" pushl $" #i "\n" \
" call _ProbeHit\n" \
" addl $4, %esp\n" \
" popal\n" \
" popfl\n" \
" jmp *_g_tr_probe" #i "\n")
PROBE_STUB(0);
PROBE_STUB(1);
PROBE_STUB(2);
PROBE_STUB(3);
PROBE_STUB(4);
PROBE_STUB(5);
PROBE_STUB(6);
PROBE_STUB(7);
PROBE_STUB(8);
PROBE_STUB(9);
PROBE_STUB(10);
PROBE_STUB(11);
#undef PROBE_STUB
#endif
namespace shim::hooks {
namespace {
namespace A = sots::addr;
struct ProbeDef {
const char* name;
std::uint32_t rva;
void* stub;
void** trampoline;
};
std::uintptr_t g_exe_base = 0;
void (*g_log)(const char*) = nullptr;
bool g_installed[kMaxProbes];
void logf(const char* fmt, ...) {
if (!g_log) return;
char line[512];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(line, sizeof line, fmt, ap);
va_end(ap);
g_log(line);
}
#if defined(_WIN32)
#define PROBE(name, rva, i) ProbeDef{name, rva, reinterpret_cast<void*>(&ProbeStub##i), &g_tr_probe##i}
#else
#define PROBE(name, rva, i) ProbeDef{name, rva, nullptr, nullptr}
#endif
// The probe set. Order is the report order and the stub index, so it is fixed.
//
// Rows 0-3 are the four phase-23/33 callees lane V2 proved can reach the generator; rows 4-6 are
// the three inner functions those bodies reach the draw *through*, so a row-0..3 hit with no
// row-4..6 hit localises the gate to the outer body. Rows 7-8 are the trade-raid pair (P4). Rows
// 9-11 are CONTROLS, and they are the reason a zero in rows 0-6 can be believed: 9 and 10 are two
// phase-23/33 callees lane V2 measured as draw-free, and 11 is `EncounterDetect_Run`, which every
// prior lane has observed running once per turn. If the controls read 0 the instrument is broken,
// not the game (method rule 1 -- a hook that compares nothing prints a clean verdict).
const ProbeDef kProbes[] = {
PROBE("Game::ServerSpyManager::vslot13", A::ServerSpyManager_vslot13, 0),
PROBE("Game::ServerSpyManager::vslot14", A::ServerSpyManager_vslot14, 1),
PROBE("Game::ServerTradeManagerImpl::vslot13", A::ServerTradeManagerImpl_vslot13, 2),
PROBE("Game::ServerTradeManagerImpl::vslot15", A::ServerTradeManagerImpl_vslot15, 3),
PROBE("Game::SpyManager::Slot13RngCallee", A::SpyManager_Slot13RngCallee, 4),
PROBE("Game::TradeManager::Slot13RngCalleeA", A::TradeManager_Slot13RngCalleeA, 5),
PROBE("Game::TradeManager::Slot13RngCalleeB", A::TradeManager_Slot13RngCalleeB, 6),
PROBE("Game::ServerTradeManager::CreateRaidEncounter", A::ServerTradeManager_CreateRaidEncounter, 7),
PROBE("Game::ServerTradeManager::GenerateTradeRaidEncounters",
A::ServerTradeManager_GenerateTradeRaidEncounters, 8),
PROBE("Game::ServerSpyManager::vslot15 [control]", A::ServerSpyManager_vslot15, 9),
PROBE("Game::ServerTradeManagerImpl::vslot14 [control]", A::ServerTradeManagerImpl_vslot14, 10),
PROBE("Game::EncounterDetect::Run [control]", A::EncounterDetect_Run, 11),
};
#undef PROBE
constexpr std::size_t kProbeCount = sizeof kProbes / sizeof kProbes[0];
static_assert(kProbeCount <= kMaxProbes, "add more PROBE_STUB() slots");
} // namespace
namespace {
std::size_t g_install_count = kProbeCount; // `probes=` overrides; default is all of them
} // namespace
bool probe_config(const char* key, const char* value, std::size_t* count_out) {
if (std::strcmp(key, "probes") != 0) return false;
if (std::strcmp(value, "off") == 0 || std::strcmp(value, "none") == 0) {
g_install_count = 0;
} else if (std::strcmp(value, "all") == 0 || std::strcmp(value, "on") == 0) {
g_install_count = kProbeCount;
} else {
char* end = nullptr;
const long n = std::strtol(value, &end, 10);
if (end == value || n < 0) return true; // ours, but unparseable: leave the default
g_install_count = static_cast<std::size_t>(n) < kProbeCount
? static_cast<std::size_t>(n)
: kProbeCount;
}
if (count_out) *count_out = g_install_count;
return true;
}
void init_probe_entries(std::uintptr_t exe_base, void (*log_line)(const char* line)) {
g_exe_base = exe_base;
g_log = log_line;
}
void install_probe_entries() {
#if defined(_WIN32)
logf("probe: installing %u of %u (probes= in shim.cfg)",
static_cast<unsigned>(g_install_count), static_cast<unsigned>(kProbeCount));
for (std::size_t i = 0; i < g_install_count; ++i) {
void* target = reinterpret_cast<void*>(g_exe_base + kProbes[i].rva);
MH_STATUS s1 = MH_CreateHook(target, kProbes[i].stub, kProbes[i].trampoline);
MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1;
g_installed[i] = (s2 == MH_OK);
logf("probe: %s rva=0x%08x -> va=%p create=%s enable=%s", kProbes[i].name, kProbes[i].rva,
target, MH_StatusToString(s1), MH_StatusToString(s2));
if (!g_installed[i])
logf("COVERAGE: probe %s NOT INSTALLED -- its call count is meaningless, not zero",
kProbes[i].name);
}
#else
for (std::size_t i = 0; i < kProbeCount; ++i) g_installed[i] = false;
#endif
}
void probe_entries_reset() {
for (std::size_t i = 0; i < kProbeCount; ++i) g_calls[i] = 0;
}
std::size_t probe_entries_count() { return kProbeCount; }
std::size_t probe_entries_snapshot(ProbeEntryRow* out, std::size_t max) {
const std::size_t n = kProbeCount < max ? kProbeCount : max;
for (std::size_t i = 0; i < n; ++i) {
out[i].name = kProbes[i].name;
out[i].rva = kProbes[i].rva;
out[i].calls = g_calls[i];
out[i].total = g_total[i];
out[i].installed = g_installed[i];
}
return n;
}
} // namespace shim::hooks

View file

@ -0,0 +1,69 @@
// Lane H -- entry counters for functions whose *reachability* is the open question.
//
// WHY THIS IS NOT A TEMPLATE HOOK, AND NOT A DRAW-SITE DETOUR.
//
// Lane V2 found four of the eleven phase-23/33 vtable targets on `StrategyServer+0x158`/`+0x15c`
// reach a draw on the strategic generator, and lane Z measured 0 tail words over eight turns. Those
// two facts leave a question a word count cannot answer: were the callees **not reached**, or
// **reached and gated**? The consequences are opposite. "Not reached" means the phase is
// conditional and a reimplementation may skip it; "reached and gated" means the phase always runs
// and the gate is a data condition our saves never satisfy -- which is a latent divergence exactly
// like the node-line decay lane K found.
//
// The instrument therefore has to answer "did control enter this address", for functions whose
// prototypes are **not verified**. Lane V2 read the dispatch sites, not the bodies: it recorded
// `void (this)` for the vtable slots from the pushes at the call site, and nothing at all for the
// three inner functions. Declaring a C++ detour with a guessed calling convention and then calling
// the trampoline is how a shim corrupts a stack -- and the campaign has already paid for that once
// (`main.cpp`: v1 of the Initialize hook was a thiscall wrapper and crashed the game).
//
// So each probe is a **register-transparent asm stub**: `pushfl; pushal; push <index>;
// call ProbeHit; add esp,4; popal; popfl; jmp *trampoline`. It saves and restores every register
// and the flags, touches nothing but its own counter, and tail-jumps to MinHook's trampoline so
// the ORIGINAL's own `ret` does the stack cleanup. It cannot be wrong about a calling convention
// because it never assumes one, it cannot perturb arguments, and it works for `ret 0`, `ret 4`,
// `cdecl`, `thiscall` and `fastcall` alike.
//
// WHAT IT CANNOT DO, stated because it is the price of that safety: a tail-jumping stub never
// regains control, so it cannot read the generator after the call and **cannot report a word
// count** -- only a call count. That is deliberate and costs nothing here, because the word counts
// already come from two independent instruments that agree (the boundary ledger and the
// return-address detours in `draw_sites.cpp`). A probe entry row and a draw-site row are meant to
// be read together: `calls > 0` with no matching draw-site row is precisely "reached and gated".
#pragma once
#include <cstddef>
#include <cstdint>
namespace shim::hooks {
struct ProbeEntryRow {
const char* name = nullptr;
std::uint32_t rva = 0;
std::uint32_t calls = 0; // entries since the last reset
std::uint32_t total = 0; // entries since process start (never reset)
bool installed = false; // false => MinHook refused; `calls == 0` means NOTHING for this row
};
// `log_line` is used once per probe at install time. Nothing logs per call: some of these run
// inside the turn pipeline and a line per entry would be a different experiment.
void init_probe_entries(std::uintptr_t exe_base, void (*log_line)(const char* line));
// Installs the probes. Safe to call once, after MH_Initialize.
void install_probe_entries();
// Config, from `probes=` in shim.cfg. Returns false if `key` is not ours.
//
// WHY THIS KEY EXISTS, and it is not convenience. Lane H measured a real perturbation: with these
// probes installed, one End Turn from a byte-identical input save produced an autosave 4 bytes
// different from the one the un-instrumented game produces, and spent 19 generator words where the
// game spends 20. An instrument that changes the thing it measures has to be switchable, or the
// question "which of the twelve does it" cannot be asked. `probes=off` installs none; `probes=N`
// installs the first N in the fixed table order, which makes the set bisectable in one build.
bool probe_config(const char* key, const char* value, std::size_t* count_out);
void probe_entries_reset();
std::size_t probe_entries_snapshot(ProbeEntryRow* out, std::size_t max);
std::size_t probe_entries_count();
} // namespace shim::hooks

View file

@ -13,6 +13,7 @@
#include "generated/sots_addresses.h"
#include "mars/rng/mt19937.h"
#include "shim/hooks/draw_sites.h"
#include "shim/hooks/probe_entry.h"
#include "shim/hooks/rng_ledger.h"
namespace shim::hooks {
@ -403,6 +404,7 @@ void StrategyHostAutosaveHook::describe_args(std::vector<Tv>& out, void* self, v
// independently; a shortfall is an unattributed word, which is the whole point of collecting it.
if (end_turn) {
draw_sites_reset();
probe_entries_reset();
return;
}
DrawSiteRow rows[64];
@ -424,6 +426,26 @@ void StrategyHostAutosaveHook::describe_args(std::vector<Tv>& out, void* self, v
out.push_back(tv::u32(draw_sites_other_words()).named("draw_site_words_other_rng"));
out.push_back(tv::u32(draw_sites_other_calls()).named("draw_site_calls_other_rng"));
out.push_back(tv::u32(draw_sites_overflow()).named("draw_site_overflow"));
// Lane H: entry counts for addresses whose reachability -- not their cost -- is the question.
// Emitted on the same marker as the draw sites so the two are read together: a probe row with
// `calls > 0` and no draw-site row for the sites inside it is "reached and gated", which is
// exactly what eight turns of `tail words = 0` could not distinguish from "not reached".
// `installed` is carried on every row because a MinHook failure would otherwise present as a
// confident zero.
ProbeEntryRow probes[16];
const std::size_t np = probe_entries_snapshot(probes, sizeof probes / sizeof probes[0]);
std::vector<Tv> plist;
for (std::size_t i = 0; i < np; ++i) {
Tv one = tv::struct_();
one.add("name", tv::str(probes[i].name));
one.add("rva", tv::u32(probes[i].rva));
one.add("calls", tv::u32(probes[i].calls));
one.add("calls_since_launch", tv::u32(probes[i].total));
one.add("installed", tv::boolean(probes[i].installed));
plist.push_back(std::move(one));
}
out.push_back(tv::list(std::move(plist)).named("probe_entries"));
}
Tv StrategyHostAutosaveHook::describe_ret(void* r) { return tv::ptr(r); }
@ -779,6 +801,166 @@ void EncounterDetectAssignContactsHook::coverage(trace::Coverage& c) {
"arg:max_trials is the upper bound only");
}
// ---- Game::EncounterDetect::ProcessTeamRecord (lane H) -------------------------------------------
namespace {
// The record's entry vector. Lane I: the gate walks `rec->(+0x28 .. +0x2c)` with stride 0x44 and
// tests `entry[0]->+0xfc`. Both bounds are read here rather than assumed, and an implausible span
// is reported as unknown rather than iterated.
constexpr std::size_t kRecEntriesFirst = 0x28;
constexpr std::size_t kRecEntriesLast = 0x2c;
constexpr std::size_t kRecEntryStride = 0x44;
constexpr std::size_t kObjFb = 0xfb;
constexpr std::size_t kObjFc = 0xfc;
constexpr std::size_t kMaxRecEntries = 4096;
constexpr std::size_t kMaxEntryDetail = 12;
struct TeamRecordStats {
std::int32_t entries = -1;
std::int32_t gate = -1; // 1 if any entry object has +0xfc != 0
std::int32_t contacts = -1; // +0xfc != 0
std::int32_t detectors = -1; // +0xfc == 0 && +0xfb == 0
std::int32_t neither = -1; // +0xfc == 0 && +0xfb != 0 -- counted, not silently dropped
std::int32_t bound = -1; // contacts * detectors, lane I's AssignContacts worst case
std::int32_t fc_wide_disagrees = -1; // see the coverage note: byte vs dword at +0xfc
};
// Per-entry detail, so the classification is checkable instead of asserted. Lane I's rules read
// `+0xfc` and `+0xfb` as bytes; if `+0xfc` is really a dword whose low byte happens to be zero,
// every count above is wrong in the same direction and nothing else would show it. So the dword is
// read too and disagreements are counted.
struct EntryDetail {
std::uint8_t fb = 0;
std::uint8_t fc = 0;
std::uint32_t fc_wide = 0;
};
TeamRecordStats team_record_stats(void* rec, EntryDetail* detail, std::size_t* ndetail) {
TeamRecordStats st;
if (ndetail) *ndetail = 0;
if (!readable(rec, kRecEntriesLast + 4)) return st;
const char* first = static_cast<const char*>(ptr_at(rec, kRecEntriesFirst));
const char* last = static_cast<const char*>(ptr_at(rec, kRecEntriesLast));
if (!first || !last || last < first) return st;
const std::size_t span = static_cast<std::size_t>(last - first);
if (span % kRecEntryStride != 0) return st;
const std::size_t n = span / kRecEntryStride;
if (n > kMaxRecEntries || !readable(first, span)) return st;
st.entries = static_cast<std::int32_t>(n);
st.gate = st.contacts = st.detectors = st.neither = st.fc_wide_disagrees = 0;
for (std::size_t i = 0; i < n; ++i) {
const char* e = first + i * kRecEntryStride;
const char* obj = static_cast<const char*>(ptr_at(e, 0));
if (!readable(obj, kObjFc + 4)) continue;
const std::uint8_t fb = peek<std::uint8_t>(obj, kObjFb);
const std::uint8_t fc = peek<std::uint8_t>(obj, kObjFc);
const std::uint32_t fcw = peek<std::uint32_t>(obj, kObjFc);
if ((fc != 0) != (fcw != 0)) ++st.fc_wide_disagrees;
if (fc != 0) {
++st.contacts;
st.gate = 1;
} else if (fb == 0) {
++st.detectors;
} else {
++st.neither;
}
if (detail && ndetail && *ndetail < kMaxEntryDetail) {
detail[*ndetail].fb = fb;
detail[*ndetail].fc = fc;
detail[*ndetail].fc_wide = fcw;
++*ndetail;
}
}
if (st.contacts > 0 && st.detectors > 0) st.bound = st.contacts * st.detectors;
return st;
}
struct TeamRecordState {
RngEntry entry;
};
TeamRecordState g_team_rec;
} // namespace
void EncounterDetectProcessTeamRecordHook::describe_args(std::vector<Tv>& out, void* self,
void* rec) {
void* server = readable(self, 4) ? ptr_at(self, 0) : nullptr;
g_team_rec.entry = observe_entry(server);
EntryDetail detail[kMaxEntryDetail];
std::size_t ndetail = 0;
const TeamRecordStats st = team_record_stats(rec, detail, &ndetail);
out.push_back(tv::ptr(self).named("ctx"));
out.push_back(tv::ptr(server).named("server"));
out.push_back(tv::ptr(rec).named("record"));
out.push_back(tv::i32(st.entries).named("entries"));
// `gate` is the predicate lane I read at 0x007ca671, recomputed here. `false` predicts that
// AssignContacts will NOT be called on this record -- a prediction the AssignContacts hook
// either confirms by staying silent or falsifies by firing.
out.push_back((st.gate < 0 ? tv::null() : tv::boolean(st.gate != 0)).named("gate"));
out.push_back(tv::i32(st.contacts).named("pred_contacts"));
out.push_back(tv::i32(st.detectors).named("pred_detectors"));
out.push_back(tv::i32(st.neither).named("pred_neither"));
out.push_back(tv::i32(st.bound).named("pred_max_trials"));
out.push_back(tv::i32(st.fc_wide_disagrees).named("fc_byte_vs_dword_disagreements"));
std::vector<Tv> ents;
for (std::size_t i = 0; i < ndetail; ++i) {
Tv one = tv::struct_();
one.add("fb", tv::u32(detail[i].fb));
one.add("fc", tv::u32(detail[i].fc));
one.add("fc_dword", tv::u32(detail[i].fc_wide));
ents.push_back(std::move(one));
}
out.push_back(tv::list(std::move(ents)).named("entry_flags"));
push_rng_args(out, g_team_rec.entry);
}
void EncounterDetectProcessTeamRecordHook::regions(std::vector<trace::Region>& out, void*, void*) {
push_rng_region(out, g_team_rec.entry.rng);
}
EncounterDetectProcessTeamRecordHook::Args EncounterDetectProcessTeamRecordHook::rebind(
trace::Scratch&, void* self, void* rec) {
return Args(self, rec);
}
void EncounterDetectProcessTeamRecordHook::ours(void* self, void* rec) {
using H = trace::Hook<EncounterDetectProcessTeamRecordHook>;
if (H::mode == trace::Mode::Replace) {
refuse_replace("EncounterDetect::ProcessTeamRecord");
if (H::original) H::original(self, rec);
}
}
void EncounterDetectProcessTeamRecordHook::coverage(trace::Coverage& c) {
tail_rng_common_coverage(c);
c.unmodelled("the whole body of ProcessTeamRecord: the gate call, the two vector builds and "
"the bucket construction",
trace::Risk::Medium,
"this hook measures the word cost of the call and recomputes three integers the "
"original derives from the same record. It models none of the work and asserts "
"nothing about the contact assignment",
"region:rng; arg:gate/pred_contacts/pred_detectors are a prediction, not a check");
c.unmodelled("`+0xfc` and `+0xfb` are read as BYTES, following lane I's reading of the "
"classifier functions",
trace::Risk::Medium,
"if either is really a wider field, every count here is wrong in the same "
"direction and no cross-check inside this hook would notice. So the dword at "
"+0xfc is read as well and any row where the two disagree is COUNTED, not "
"silently resolved -- a non-zero disagreement count means the byte reading is "
"unsafe on this workload",
"arg:fc_byte_vs_dword_disagreements; arg:entry_flags carries the raw values");
c.unmodelled("an entry whose object pointer is unreadable is skipped",
trace::Risk::Low,
"it is not counted into any of the three classes, so entries != contacts + "
"detectors + neither is the signal that this happened",
"arg:entries against the three class counts");
}
// ---- Game::StrategyServer::ProcessNodeSpaceTravel ------------------------------------------------
void ProcessNodeSpaceTravelHook::describe_args(std::vector<Tv>& out, void* self) {

View file

@ -168,6 +168,37 @@ struct EncounterDetectAssignContactsHook {
static void coverage(trace::Coverage& c);
};
// ---- lane H: the caller of AssignContacts, which is where the residual actually is ------------
//
// `EncounterDetect::ProcessTeamRecord` 0x007ca640, `ret 4`, the ONLY caller of `AssignContacts`
// and the only depth-2 function under `StrategyServer::DetectEncounters`. Lane Y's standalone
// leaves a 2-word residual per turn on both calibrated pairs, attributed by lane Z's site table to
// 0x0050329d (`NextFloat`, depth 4) and 0x007929a4 (`NextInt`, depth 3) -- both below this
// function. Lane Z also found `AssignContacts` **never ran**, because the gate here was never
// satisfied. So the residual is not the `|contacts| x |detectors|` family that lane I bounded; it
// is something above that gate, and no instrument has ever bracketed the function that contains
// both.
//
// This hook computes lane I's three quantities from the record AT ENTRY -- the gate predicate, the
// contact count and the detector count -- and reports them beside the measured word cost. When the
// gate is false they are a prediction of a call that will not happen (and the hook says so); when
// it is true they become checkable against the numbers `AssignContactsHook` reads from the callee's
// own arguments. Two hooks, one function apart, printing the same two integers derived two
// different ways.
struct EncounterDetectProcessTeamRecordHook {
static constexpr const char* name = "Game::EncounterDetect::ProcessTeamRecord";
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
using Ret = void;
using Args = std::tuple<void*, void*>; // ctx {S, TechDef*, TechDef*}, TeamRecord* (0x74 B)
static void describe_args(std::vector<trace::Tv>& out, void* self, void* rec);
static void regions(std::vector<trace::Region>& out, void* self, void* rec);
static Args rebind(trace::Scratch& s, void* self, void* rec);
static void ours(void* self, void* rec);
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
static void coverage(trace::Coverage& c);
};
// `ProcessNodeSpaceTravel` 0x007a0e20, run TWICE a turn (ProcessTurn phase 7 and tail phase 10)
// and never swept for draws by any lane.
struct ProcessNodeSpaceTravelHook {

View file

@ -25,6 +25,7 @@
#include "shim/hooks/global_consts.h"
#include "shim/hooks/research.h"
#include "shim/hooks/draw_sites.h"
#include "shim/hooks/probe_entry.h"
#include "shim/hooks/tail_rng.h"
#include "shim/hooks/tech_effects.h"
#include "shim/trace/hook.h"
@ -84,7 +85,11 @@ Config ReadConfig() {
char* val = eq + 1;
val[std::strcspn(val, "\r\n")] = '\0';
std::string err;
if (shim::fpu::apply_config(p, val, &err)) {
std::size_t probe_n = 0;
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::fpu::apply_config(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val);
} else if (cfg.trace.apply(p, val, &err)) {
@ -180,6 +185,8 @@ using ApplyEncounterHook = shim::trace::Hook<shim::hooks::ApplyEncounterResultHo
using NodeDecayHook = shim::trace::Hook<shim::hooks::NodeLineDecayHook>;
using NodeSpaceHook = shim::trace::Hook<shim::hooks::ProcessNodeSpaceTravelHook>;
using AssignContactsHook = shim::trace::Hook<shim::hooks::EncounterDetectAssignContactsHook>;
// Lane H: the caller of AssignContacts, where lane Y's 2-word detection residual lives.
using ProcessTeamRecordHook = shim::trace::Hook<shim::hooks::EncounterDetectProcessTeamRecordHook>;
void InstallHooks(shim::trace::Tracer& tracer) {
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
@ -252,6 +259,10 @@ void InstallHooks(shim::trace::Tracer& tracer) {
// Lane I's one inlined-draw site inside ProcessTurn's closure. It leaves no call-graph edge, so
// neither a sweep nor the entry-point detours below can see it; only a boundary hook can.
InstallTemplateHook<shim::hooks::EncounterDetectAssignContactsHook>(tracer, exeBase, sots::addr::EncounterDetect_AssignContacts);
// Lane H: its only caller. AssignContacts never ran on any measured turn because the gate
// here was never satisfied, so the 2-word detection residual is above that gate and no
// instrument has bracketed the function containing both.
InstallTemplateHook<shim::hooks::EncounterDetectProcessTeamRecordHook>(tracer, exeBase, sots::addr::EncounterDetect_ProcessTeamRecord);
// Per-call-site attribution: detour the SEVEN generator entry points and record
// __builtin_return_address(0) with the word cost of each call. These are NOT template hooks --
@ -272,6 +283,12 @@ void InstallHooks(shim::trace::Tracer& tracer) {
}
}
// Lane H: register-transparent entry counters. Installed after the draw-site detours and
// before the fpu module; they share no target with either. See probe_entry.h for why these are
// asm stubs and not C++ detours.
shim::hooks::init_probe_entries(exeBase, &ShimLogLine);
shim::hooks::install_probe_entries();
// 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.
shim::fpu::install(exeBase, &ShimLogLine);
@ -331,6 +348,7 @@ void Shim_Init(HMODULE self) {
NodeDecayHook::register_policy(tracer);
NodeSpaceHook::register_policy(tracer);
AssignContactsHook::register_policy(tracer);
ProcessTeamRecordHook::register_policy(tracer);
// A hook that never stated what it does not check is a defect, not a detail: say so in
// shim.log as well as in the trace's meta line (docs/harness-audit.md).
for (const std::string& h : tracer.unstated_hooks())

3
src/shim/shim.cfg.hoff Normal file
View file

@ -0,0 +1,3 @@
# Lane H: the control run. hooks=off installs NOTHING (not even the M0 asm stub), so this
# is the game with a proxy DLL that only forwards Bink exports.
hooks=off

52
src/shim/shim.cfg.hp0 Normal file
View file

@ -0,0 +1,52 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=0

52
src/shim/shim.cfg.hp10 Normal file
View file

@ -0,0 +1,52 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=10

52
src/shim/shim.cfg.hp11 Normal file
View file

@ -0,0 +1,52 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=11

52
src/shim/shim.cfg.hp4 Normal file
View file

@ -0,0 +1,52 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=4

52
src/shim/shim.cfg.hp8 Normal file
View file

@ -0,0 +1,52 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=8

52
src/shim/shim.cfg.hp9 Normal file
View file

@ -0,0 +1,52 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=9

52
src/shim/shim.cfg.hpall Normal file
View file

@ -0,0 +1,52 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=all

49
src/shim/shim.cfg.hprobes Normal file
View file

@ -0,0 +1,49 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyHost::Autosave=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always

View file

@ -0,0 +1,51 @@
# Lane H -- the five probes, TRACE. Copy over C:\SOTS\shim.cfg.
#
# Lane Z's ledger plus two additions:
# * Game::EncounterDetect::ProcessTeamRecord -- the caller of AssignContacts, where lane Y's
# 2-word-per-turn detection residual has to be (both residual sites are below it, and
# AssignContacts itself never ran on any measured turn);
# * the register-transparent entry counters in probe_entry.cpp, which are NOT template hooks
# and are therefore NOT configurable from here. They install unconditionally whenever
# hooks != off, and every one of them logs its MinHook status to shim.log at startup. A probe
# that failed to install carries installed=false into the record, because a MinHook failure
# would otherwise present as a confident zero.
#
# Everything else is off so the log holds only the ledger: eight records per End Turn instead of
# ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each record; the
# difference is the words the strategic generator consumed inside that call.
#
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
# address for the ledger.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::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=trace
hook.Game::StrategyServer::ProcessTurn=trace
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
hook.Game::StrategyServer::ApplyEncounterResult=trace
hook.Game::StrategyServer::NodeLineDecay=trace
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
hook.Game::EncounterDetect::AssignContacts=trace
hook.Game::EncounterDetect::ProcessTeamRecord=trace
fpu.sample_turn=off
fpu.sample_ticks=off
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
# each record ~5 kB of state nobody reads.
trace.inline_max=64
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always