From 1932377b92324b04b48e0cd8c6e04593ed37e1b7 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 12:23:42 -0400 Subject: [PATCH 1/3] lane H: five probe predictions, committed before the build --- docs/H-probes.md | 241 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/H-probes.md diff --git a/docs/H-probes.md b/docs/H-probes.md new file mode 100644 index 0000000..f17db66 --- /dev/null +++ b/docs/H-probes.md @@ -0,0 +1,241 @@ +# 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 ``" 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". + +**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. From a01305fcf2b0dc12419abc995751c1d8fe4b7fe8 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 12:31:29 -0400 Subject: [PATCH 2/3] lane H: P2b-alt, a competing prediction from the callee bodies (still before any run) --- docs/H-probes.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/H-probes.md b/docs/H-probes.md index f17db66..0bd4eb9 100644 --- a/docs/H-probes.md +++ b/docs/H-probes.md @@ -104,6 +104,38 @@ a 200-function closure". * *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 From a26cd183f16acdadbb3662c5d381880c76b76816 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 13:38:59 -0400 Subject: [PATCH 3/3] lane H: entry-probe module, ProcessTeamRecord hook, probes= config, and the outcome of the five predictions --- CMakeLists.txt | 8 +- docs/H-probes.md | 42 ++++++ include/generated/sots_addresses.h | 8 +- src/shim/hooks/probe_entry.cpp | 204 +++++++++++++++++++++++++++++ src/shim/hooks/probe_entry.h | 69 ++++++++++ src/shim/hooks/tail_rng.cpp | 182 +++++++++++++++++++++++++ src/shim/hooks/tail_rng.h | 31 +++++ src/shim/main.cpp | 20 ++- src/shim/shim.cfg.hoff | 3 + src/shim/shim.cfg.hp0 | 52 ++++++++ src/shim/shim.cfg.hp10 | 52 ++++++++ src/shim/shim.cfg.hp11 | 52 ++++++++ src/shim/shim.cfg.hp4 | 52 ++++++++ src/shim/shim.cfg.hp8 | 52 ++++++++ src/shim/shim.cfg.hp9 | 52 ++++++++ src/shim/shim.cfg.hpall | 52 ++++++++ src/shim/shim.cfg.hprobes | 49 +++++++ src/shim/shim.cfg.hprobes2 | 51 ++++++++ 18 files changed, 1027 insertions(+), 4 deletions(-) create mode 100644 src/shim/hooks/probe_entry.cpp create mode 100644 src/shim/hooks/probe_entry.h create mode 100644 src/shim/shim.cfg.hoff create mode 100644 src/shim/shim.cfg.hp0 create mode 100644 src/shim/shim.cfg.hp10 create mode 100644 src/shim/shim.cfg.hp11 create mode 100644 src/shim/shim.cfg.hp4 create mode 100644 src/shim/shim.cfg.hp8 create mode 100644 src/shim/shim.cfg.hp9 create mode 100644 src/shim/shim.cfg.hpall create mode 100644 src/shim/shim.cfg.hprobes create mode 100644 src/shim/shim.cfg.hprobes2 diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f925b7..29e8f91 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,11 +100,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) diff --git a/docs/H-probes.md b/docs/H-probes.md index 0bd4eb9..1577ceb 100644 --- a/docs/H-probes.md +++ b/docs/H-probes.md @@ -271,3 +271,45 @@ count, only a call count — which is deliberate, because the word counts alread 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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index b776e8f..3322600 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 58e3d85, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 52581ef, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1361,6 +1361,12 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980; constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0; // thiscall void** (std::map* this, const int* key) // 125 B, ret 4. MSVC std::map::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified] constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0; +// thiscall int (void* this, int, int) // the ONE callee through which Game::ServerSpyManager vftable 0x00a3073c slot 13 (0x008877b0) reaches the strategic generator. Exactly ONE incoming reference in the image: an unconditional call at 0x00887af2 inside that slot. Real body 0x008408e0..0x00840a59, 370 bytes. Contains three of lane V2's eight draw sites -- Chance 0x00840929, NextInt 0x008409c7, Chance 0x00840a3c -- all loading the generator as [reg+0x16c]. Named for the verified relationship (which slot reaches it, and that it draws), NOT for any semantics: its body was not read. Lane H hooks it with a register-transparent entry counter so that 'the spy path never fired' can be split into 'slot 13 was not entered' and 'slot 13 was entered and gated above this call' [mapped] +constexpr uint32_t SpyManager_Slot13RngCallee = 0x004408e0; +// thiscall uint (void* fleet, char) // first of the two callees through which Game::ServerTradeManagerImpl vftable 0x00a31b74 slot 13 (0x0088ef80) reaches the strategic generator. Exactly ONE incoming reference: an unconditional call at 0x0088f036. Real body 0x00820ca0..0x00820e53, 436 bytes, containing lane V2's NextFloat site 0x00820e18. Its own body loops the vector at arg+0xa4/+0xa8 testing per-element flags, and its boolean result is what gates the call to TradeManager_Slot13RngCalleeB one instruction later. THE CALLER'S LOOP IS OVER THE FLEETS VECTOR at GetServer()+0x64/+0x68, not over a trade-route list -- which is why 'the tail draws nothing because we have no trade routes' does not explain this slot [mapped] +constexpr uint32_t TradeManager_Slot13RngCalleeA = 0x00420ca0; +// thiscall void (void* this, int) // second of the two callees through which Game::ServerTradeManagerImpl slot 13 (0x0088ef80) reaches the strategic generator, called at 0x0088f042 ONLY when TradeManager_Slot13RngCalleeA returned non-zero. Exactly ONE incoming reference. Real body 0x0088b440..0x0088b976, 1323 bytes, containing lane V2's NextInt site 0x0088b613 (`mov ecx,[ecx+0x16c]; add ecx,4`). The two form a two-stage gate: A draws first and decides whether B runs, so a run where A fires and B does not is a MEASUREMENT, not a gap [mapped] +constexpr uint32_t TradeManager_Slot13RngCalleeB = 0x0048b440; // thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified] constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670; // thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified] diff --git a/src/shim/hooks/probe_entry.cpp b/src/shim/hooks/probe_entry.cpp new file mode 100644 index 0000000..123955e --- /dev/null +++ b/src/shim/hooks/probe_entry.cpp @@ -0,0 +1,204 @@ +#include "shim/hooks/probe_entry.h" + +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include + +#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(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(&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(n) < kProbeCount + ? static_cast(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(g_install_count), static_cast(kProbeCount)); + for (std::size_t i = 0; i < g_install_count; ++i) { + void* target = reinterpret_cast(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 diff --git a/src/shim/hooks/probe_entry.h b/src/shim/hooks/probe_entry.h new file mode 100644 index 0000000..cb4c4db --- /dev/null +++ b/src/shim/hooks/probe_entry.h @@ -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 ; +// 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 +#include + +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 diff --git a/src/shim/hooks/tail_rng.cpp b/src/shim/hooks/tail_rng.cpp index 0343370..5264035 100644 --- a/src/shim/hooks/tail_rng.cpp +++ b/src/shim/hooks/tail_rng.cpp @@ -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& 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& 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 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(ptr_at(rec, kRecEntriesFirst)); + const char* last = static_cast(ptr_at(rec, kRecEntriesLast)); + if (!first || !last || last < first) return st; + const std::size_t span = static_cast(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(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(ptr_at(e, 0)); + if (!readable(obj, kObjFc + 4)) continue; + const std::uint8_t fb = peek(obj, kObjFb); + const std::uint8_t fc = peek(obj, kObjFc); + const std::uint32_t fcw = peek(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& 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 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& 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; + 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& out, void* self) { diff --git a/src/shim/hooks/tail_rng.h b/src/shim/hooks/tail_rng.h index e10cd9c..8d3dba3 100644 --- a/src/shim/hooks/tail_rng.h +++ b/src/shim/hooks/tail_rng.h @@ -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; // ctx {S, TechDef*, TechDef*}, TeamRecord* (0x74 B) + + static void describe_args(std::vector& out, void* self, void* rec); + static void regions(std::vector& 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 { diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 617a525..3a65cc0 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -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(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; using NodeSpaceHook = shim::trace::Hook; using AssignContactsHook = shim::trace::Hook; +// Lane H: the caller of AssignContacts, where lane Y's 2-word detection residual lives. +using ProcessTeamRecordHook = shim::trace::Hook; void InstallHooks(shim::trace::Tracer& tracer) { const uintptr_t exeBase = reinterpret_cast(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(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(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()) diff --git a/src/shim/shim.cfg.hoff b/src/shim/shim.cfg.hoff new file mode 100644 index 0000000..a780155 --- /dev/null +++ b/src/shim/shim.cfg.hoff @@ -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 diff --git a/src/shim/shim.cfg.hp0 b/src/shim/shim.cfg.hp0 new file mode 100644 index 0000000..572e8a3 --- /dev/null +++ b/src/shim/shim.cfg.hp0 @@ -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 diff --git a/src/shim/shim.cfg.hp10 b/src/shim/shim.cfg.hp10 new file mode 100644 index 0000000..578f870 --- /dev/null +++ b/src/shim/shim.cfg.hp10 @@ -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 diff --git a/src/shim/shim.cfg.hp11 b/src/shim/shim.cfg.hp11 new file mode 100644 index 0000000..d2d408e --- /dev/null +++ b/src/shim/shim.cfg.hp11 @@ -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 diff --git a/src/shim/shim.cfg.hp4 b/src/shim/shim.cfg.hp4 new file mode 100644 index 0000000..91d2b97 --- /dev/null +++ b/src/shim/shim.cfg.hp4 @@ -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 diff --git a/src/shim/shim.cfg.hp8 b/src/shim/shim.cfg.hp8 new file mode 100644 index 0000000..2464697 --- /dev/null +++ b/src/shim/shim.cfg.hp8 @@ -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 diff --git a/src/shim/shim.cfg.hp9 b/src/shim/shim.cfg.hp9 new file mode 100644 index 0000000..1826c73 --- /dev/null +++ b/src/shim/shim.cfg.hp9 @@ -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 diff --git a/src/shim/shim.cfg.hpall b/src/shim/shim.cfg.hpall new file mode 100644 index 0000000..94b2967 --- /dev/null +++ b/src/shim/shim.cfg.hpall @@ -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 diff --git a/src/shim/shim.cfg.hprobes b/src/shim/shim.cfg.hprobes new file mode 100644 index 0000000..3670a3a --- /dev/null +++ b/src/shim/shim.cfg.hprobes @@ -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 diff --git a/src/shim/shim.cfg.hprobes2 b/src/shim/shim.cfg.hprobes2 new file mode 100644 index 0000000..d2c7013 --- /dev/null +++ b/src/shim/shim.cfg.hprobes2 @@ -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