From 2947e24ed9b85313628e4dd3f3c9ff8bf164318e Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 17:43:10 -0400 Subject: [PATCH 1/2] L1: hook BeginProcessTurn and the three script-object writers a turn reaches Lane SV recovered the script-object subsystem statically and predicted that SVSOSwarmQueen::RegisterHives takes one RNG_NextInt per new hive inside StrategyServer::BeginProcessTurn -- which runs inside lane Z's autosave bracket and outside every one of its subtotals, so a draw there had never been attributed by anything. Five nested trace hooks, each declaring the strategic generator as a region and each carrying a model evaluated at entry so the record can disagree with it: StrategyServer::BeginProcessTurn the unhooked interval, plus a region over Frame so the increment is a fact SVSOSwarmQueen::OnTurnBegin evt 0x13, vtable slot +0x60 SVSOSwarmQueen::RegisterHives predict_new_hives from the original's own two predicates; reads the LO/HI config pointers live SVSOSwarmQueen::TickHives the NextQ slip and its three gates SVSOSlaversRefuel::UpdateDifficultyTier tail phase 20; a 4-byte region over CDiff so a store and its ABSENCE are distinguishable (method rule 20) Also watch.mode=snlv: one arming line moves slot 1 from the NVO map's _Mysize to the target system's SnLv, and the arming sweep now prints SnLv with its decoded per-player 2-bit levels for every system, which costs no debug register. Measured on VM140: hive creation costs 2 words in BeginProcessTurn and the residual outside the two turn drivers is 2, not 0; the next turn it is 0 again. LO=20 HI=30. Spica's SnLv reads 0x200 with AFlags 0. The oracle reproduced byte for byte with every one of these detours live. Predictions and outcomes: docs/L1-predictions.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ --- docs/L1-predictions.md | 221 ++++++++++++++ include/generated/sots_addresses.h | 46 ++- src/shim/hooks/tail_rng.cpp | 469 +++++++++++++++++++++++++++++ src/shim/hooks/tail_rng.h | 109 +++++++ src/shim/hooks/watchpoints.cpp | 52 +++- src/shim/main.cpp | 10 + src/shim/shim.cfg.l1control | 29 ++ src/shim/shim.cfg.l1hive | 52 ++++ src/shim/shim.cfg.l1off | 52 ++++ src/shim/shim.cfg.l1snlv | 29 ++ 10 files changed, 1059 insertions(+), 10 deletions(-) create mode 100644 docs/L1-predictions.md create mode 100644 src/shim/shim.cfg.l1control create mode 100644 src/shim/shim.cfg.l1hive create mode 100644 src/shim/shim.cfg.l1off create mode 100644 src/shim/shim.cfg.l1snlv diff --git a/docs/L1-predictions.md b/docs/L1-predictions.md new file mode 100644 index 0000000..1c3caf7 --- /dev/null +++ b/docs/L1-predictions.md @@ -0,0 +1,221 @@ +# Lane L1 — predictions, committed before the build + +Written 2026-09-08 against `main` `0117495`, before any code was written and before any VM run. +Instrument: `src/shim/hooks/tail_rng.cpp` (five new nested hooks), `src/shim/hooks/watchpoints.cpp` +(one new arming mode). Workloads: `turn1-state.sav` (hives), `ref-turn2.sav` (oracle control and +`SnLv`). + +The point of writing this first is that every number below can be wrong in a way that is visible in +the trace. Each prediction carries its falsifier. + +--- + +## 0. What the static reading says, re-derived independently + +Lane SV recovered all of this without running anything. Before predicting, its instruction reading +was re-done from `dumps/sots.exe` with `objdump -M intel` at real instruction boundaries (rule 17 — +disassembled past Ghidra's reported end to the `int3` padding in every case). **Everything below was +confirmed byte for byte**, with two small corrections of address, not of substance. + +* `StrategyServer::BeginProcessTurn` **0x007d98e0 .. 0x007d9ae8** (`ret`, thiscall, no stack args). + `inc [esi+0xc]` at **0x007d990a** is `Frame++`. The last thing the function does before its + epilogue is the script two-step at **0x007d9ab8**: `vft[0x10](0x13, 0)` then `vft[0x60]()`. `+0x60` + is exactly what lane SV's jump table gives for event 0x13. Everything else in the body is two + container walks (`S+0x44` systems, `S+0x64` fleets) and an event post (`push 0x24`). +* `SVSOSwarmQueen_OnTurnBegin` **0x00529930 .. 0x0052999f** (`ret`): `RegisterHives` → + prune-by-`EggScio` → `TickHives` → `0x00505100`. +* `SVSOSwarmQueen_RegisterHives` **0x00527630 .. 0x00527761** (`ret`). Per system in + `GetGame()->+0x44..+0x48`, `[sys+0x184] == this->+0x4` and no existing hive whose `+0x4` is that + system; then + ``` + 5276e1 mov ecx,ds:0xae0204 ; &LO + 5276e7 mov edx,ds:0xae0208 ; &HI + 5276ed mov eax,[eax+0xc] ; frame (eax = GetGame()) + 5276f0 mov edi,[ecx] ; LO + 5276f2 mov esi,[edx] ; HI + 5276f4 add edi,eax ; add esi,eax + 5276f8 call GetGame ; mov eax,[eax+0x16c] + 527707 sub esi,edi ; bound = HI - LO + 527709 lea ecx,[eax+4] ; ECX = &mt + 52770f call 0x4271c0 ; RNG_NextInt <-- THE DRAW, one per new hive + 527717 add eax,edi ; nextQ = frame + LO + draw + 52771f call 0x523080 ; push_back + ``` + **Correction to `svsctob-writers.md` §4.3:** the draw call is at **0x0052770f** (return address + 0x00527714), not 0x0052770c — 0x0052770c is the `mov [ebp-0x14],esi` that stores the bound. The + substance is unchanged and `GetGame()` (0x00578050) returns the **`S` frame**: `[+0xc]` is `Frame` + and `[+0x16c]` is the strategic generator, the same object lane Z's ledger watches. +* `SVSOSwarmQueen_TickHives` **0x00527770 .. 0x005279a1** (`ret`). Four gate-failure edges + (`0x005277e3`, `0x005277fe`, `0x00527848`, `0x0052784f`) all land on **`inc [esi+0x8]`** at + **0x0052785a**. Gates read `[0x00ae0210]` (a frame floor), a queens-vector cap over + `(this->+0x24 − this->+0x20)/12`, and `[0x00ae0228]`; the spawn arm reads `[0x00ae0220]`. + **No RNG primitive is called anywhere in the body.** +* `SVSOSlaversRefuel_UpdateDifficultyTier` **0x00515820**. The stack table, read off the stores: + `{1,1,1} {50,2,3} {100,2,5}` at `[ebp-0x34]`, `[ebp-0x28]`, `[ebp-0x1c]`. Only the **threshold** + column is ever loaded (`cmp [ecx],edx`); the other two are dead in this function. The scan is + ``` + 515893 xor eax,eax ; lea ecx,[ebp-0x34] + 515898 cmp [ecx],edx ; jg 0x5158c2 ; first threshold > frame + 51589c add eax,ebx ; add ecx,0xc ; cmp eax,3 ; jl 0x515898 + + 5158c2 test eax,eax ; jle 0x5158a6 ; index 0 -> NO STORE + 5158c6 dec eax ; cmp eax,-1 ; je ... ; (redundant with the jle) + 5158cc cmp [edi+0x38],eax ; je ... ; unchanged -> NO STORE + 5158d1 mov [edi+0x38],eax ; CDiff = index - 1 + ``` + and the class's ctor at **0x0051a820** stores `[eax+0x38] = 0xffffffff`, which is exactly the + `CDiff = -1` that `turn1-state.sav` carries. **Lane SV's off-by-one reading is confirmed + independently: `CDiff` can only ever hold 0 or 1.** +* `SVSOSlaversRefuel_HandleEvent` **0x0051a800** is nine instructions, `ret 8`: + `if (evt == 0x14) UpdateDifficultyTier(this)`. + +## 1. The corpus fact this lane is built on + +`turn1-state.sav` (Frame 1) has `SvSctOb / EncID 10 / Hives` **count 0**. `turn2-state.sav` +(Frame 2) has **two** hives, `(HiveID 336, QueenID 0, NextQ 31)` and `(400, 0, 29)`. +`turn3-state.sav` has the same two with `NextQ` **32 / 30**. The two systems with `EggScio == 3` in +`turn1-state.sav` are 336 and 400. + +So the End Turn taken from `turn1-state.sav` is **the hive-creation turn**, and the End Turn after it +is **the slip turn**. Both are one click. + +--- + +## P1 — hive creation draws, and lane Z's "residual exactly zero" is a statement about its workload + +On the End Turn taken from `turn1-state.sav`: + +1. `SVSOSwarmQueen_RegisterHives` is **entered exactly once**, inside `BeginProcessTurn`, before + `StrategyServer::ProcessTurn` runs. +2. It reports `hives_in = 0` and `hives_out = 2`. +3. Its measured word cost is **2** — one `RNG_NextInt` per new hive. (`NextInt` is a + reject-on-`(y & mask) > n` loop, so 2 is the *minimum*; see the falsifier.) +4. `SVSOSwarmQueen_OnTurnBegin`'s cost is the same 2 (`TickHives` adds nothing). +5. `StrategyServer::BeginProcessTurn`'s cost is the same 2. +6. **The bracket residual is 2, not 0**: `Autosave(endTurn=1) → Autosave(endTurn=0)` total minus + `ProcessTurn` minus the tail equals **2**. +7. The two hives written carry `NextQ` **31** and **29**, matching `turn2-state.sav` exactly. + +**What this would mean.** `tail-rng-ledger.md`'s headline — *"the residual outside the two turn +drivers is exactly zero"* — is correct for turns 4, 5, 6, 18, 19, 20, 52-55 and 62-64 of two saves +whose hives already existed, and **wrong as a statement about the code**. That is the same shape as +"the tail draws nothing", which was true of eight turns and false of turn 64. + +### Falsifiers + +| symptom | what it would mean | +|---|---| +| `RegisterHives` **not entered** | the evt-0x13 delivery does not reach `SVSOSwarmQueen` on this save. SV's `evt -> slot` map or its class table would need correcting. The entry probe separates this from "entered and drew nothing" (rule 20). | +| entered, `hives_in = 0`, **0 words** | the draw is not taken — e.g. the bound `HI-LO` is 0, in which case `NextInt` may return without consuming. Lane Z's zero would stand and SV's claim would be **wrong**. | +| entered, `hives_in = 2` already | the hives exist before `BeginProcessTurn`, so `turn1-state.sav` is not the pre-creation state I read it as. | +| cost **> 2** | `RNG_NextInt`'s rejection loop fired. Expected: with `bound = HI-LO`, `mask = 2^ceil(log2(bound+1)) − 1`, cost per draw is `(mask+1)/(bound+1)` words on average. A cost of 3 on two hives is unremarkable and does **not** falsify the claim; it falsifies only the literal "2". | +| residual **2** but `BeginProcessTurn` cost **0** | the draw is between the autosave markers but outside `BeginProcessTurn`. | +| `NextQ` ≠ 31/29 | the state I loaded is not the state `turn2-state.sav` came from (different queued orders). Does not falsify the draw; does kill P3. | + +## P2 — the control: the second End Turn costs 0 outside the drivers + +On the End Turn taken from the state P1 produced (frame 2 → 3): + +* `RegisterHives` is **entered**, finds both systems already referenced, and costs **0 words**. +* `TickHives` is **entered**, takes a gate-failure edge on both hives, costs **0 words**, and + `NextQ` reads **32 / 30** afterwards — SV's slip rule, live. +* `BeginProcessTurn`'s cost is **0** and the bracket residual returns to **0**. + +**This is what makes P1 mean "creation draws" rather than "`BeginProcessTurn` always draws".** If +P2's residual is also 2, the draw is not hive creation and P1's attribution is wrong even if its +number is right. + +Falsifier for the slip specifically: if `NextQ` moves by anything other than +1 per hive, or if +`TickHives` costs words, the `inc`-only model is wrong. + +## P3 — `LO` and `HI`, which no save can show + +The shim prints `*(int*)0x00ae0204` and `*(int*)0x00ae0208` at `RegisterHives` entry. Lane SV could +not follow these to an initialiser and correctly refused to fit them from one two-hive observation. +Reading them live closes the leaf outright. + +Prediction: `NextQ = frame + LO + NextInt(HI − LO)` with `frame = 2` reproduces 31 and 29, i.e. +`LO + r₁ = 29` and `LO + r₂ = 27`, with both `r` in `[0, HI − LO]`. Falsifier: any `(LO, HI)` for +which those two are impossible. + +## P4 — `CDiff`: two edges, one of them unreachable by playing forward + +Model, transcribed above: + +| frame at the tail's evt 0x14 | scan index | stored | +|---|---|---| +| ≤ 0 | 0 | **nothing** | +| 1 … 49 | 1 | `CDiff = 0` | +| 50 … 99 | 2 | `CDiff = 1` | +| ≥ 100 | falls off the end | **nothing** | + +Predictions, in increasing cost: + +* **P4a (free, this run).** `UpdateDifficultyTier` is **entered once per End Turn** on + `turn1-state.sav`, stores `0` on the first (frame 2, `CDiff` −1 → 0) and stores **nothing** on the + second (frame 3, unchanged). The `this+0x38` region shows the store and its absence. This is rule + 20's distinction applied to a *store*: "entered and stored nothing" is not "not entered". +* **P4b (~48 End Turns).** The first End Turn whose frame lands in 50…99 stores `CDiff = 1`, and + every later turn below 100 stores nothing. +* **P4c (~98 End Turns).** At frame ≥ 100 the function is still **entered every turn** and stores + **nothing**; `CDiff` stays 1 forever. Tier 2 is unreachable. +* **P4d.** The `frame ≤ 0` edge is unreachable in a real game: `Frame` starts at 1 and the tail + never runs before the first End Turn, which is exactly why `turn1-state.sav` carries the ctor's + −1. This is recorded as a code property, not a workload gap. + +Falsifier for P4b/P4c: `CDiff` reading **2** at any frame falsifies the whole reading. + +## P5 — `SnLv`, one arming line + +`nvo-tshn-visible-owner.md` §6 marks *"the sensor branch is what refreshes Spica"* as **[H]** because +`SnLv` (`ServerSystem+0x24`) is not serialised and has never been measured. The watchpoint module's +`tshn` mode already finds Spica by predicate at arm time; the new `snlv` mode points slot 1 at +`sys+0x24` instead of the map's `_Mysize`, and additionally **logs `SnLv` for all 28 systems at arm +time**, which costs no debug register at all. + +Prediction: Spica's `SnLv` has a **non-zero** 2-bit field for the player index its `NVO` record is +keyed on (4 on `ref-turn2.sav`), i.e. `(SnLv >> 8) & 3 != 0`; and Bismol's is **zero** for player 1. +That makes the sensor branch the live refresher of Spica ([H] → [V]) and settles which conjunct +fails at Bismol. + +Falsifiers: Spica's field reads 0 → the refresh comes from somewhere else entirely and the model in +§5 of that document is wrong about the mechanism even though it fits the corpus. Bismol's field +reads non-zero → the failing conjunct is `HasResearched(CCC_AdvSens)` alone, which the corpus already +proves true, and the "outside the sensor envelope" alternative is dead. + +## P6 — the instrument is behaviour-neutral (rule 19), and I expect it might not be + +Five new MinHook detours are installed. Lane H proved one detour on a clean prologue boundary +changed the autosave by 4 bytes. So this is a genuine prediction and not a formality: + +**One End Turn from `ref-turn2.sav` with the full L1 hook set live reproduces the oracle:** +`(Autosave EndTurn).sav` = `bb4fd9ac89f41e3b…`, `(Autosave).sav` = `978041acd168b56e…`. + +If it does not, every number in P1–P4 is void and the correct report is which hook broke it, found +by bisecting the five. The `hooks=off` run on the same workload is taken as well, so the +hive-creation autosave has an uninstrumented twin to compare against. + +--- + +# Outcomes, appended after the runs (2026-09-08) + +Recorded here beside the predictions rather than only in the finding, so the pair can be read +together. Full account: `sots-re/findings/control-flow/hive-creation-rng.md` and +`sots-re/findings/subsystems/snlv-measured.md`. + +| prediction | outcome | +|---|---| +| **P1** hive creation draws 2 words in `BeginProcessTurn`; residual 2 | **held exactly.** `RegisterHives` entered once at depth 2, `predict_new_hives = 2`, words 170 → 172; `BeginProcessTurn` 2, `ProcessTurn` 20. The draw-site ledger independently shows a new row at return address 0x00527714, `NextInt`, 2 calls / 2 words. | +| **P1** `NextQ` 31 / 29 | **held**, via a route the prediction did not spell out: `RegisterHives` writes 30 / 28 and `TickHives`' slip takes them to 31 / 29 **on the same turn**. | +| **P2** the next turn costs 0 outside the drivers | **held.** `RegisterHives` entered, `predict_new_hives = 0`, 0 words; `BeginProcessTurn` 0; bracket 192 → 211 = `ProcessTurn`'s 19; residual 0. `NextQ` 32 / 30. | +| **P3** `LO`/`HI` read live | **`LO = 20`, `HI = 30`** ⇒ `NextQ = frame + 20 + NextInt(10)`, inclusive. Both draws land in range (8 and 6). The three `TickHives` gate constants are 10, 5 and 3. | +| **P4a** `UpdateDifficultyTier` entered every turn, stores on frame 2 only | **held.** Frame 2: `predict_path = store`, region `cdiff` −1 → 0. Frame 3: `predict_path = no_store_unchanged`, 0 → 0, **entered and stored nothing**. | +| **P4b / P4c** the frame-50 and frame-100 edges | see the finding; the table scan was re-read independently and `CDiff` can only hold 0 or 1. | +| **P5** Spica's `SnLv` non-zero for its `NVO` key player | **held.** `AFlags = 0x0`, `SnLv = 0x00000200`, level **2** for player 4. Bismol is not on this map, so that half is still open. | +| **P5** the level is 1 | **wrong, and informatively.** The observed non-visible level is **2**; `ComputeContactLevel` has four return tails and the documented "else 1" names only one. | +| **P6** the instrument is behaviour-neutral | **held.** `ref-turn2.sav` + one End Turn with all five new detours live reproduced `bb4fd9ac…` / `978041ac…` exactly, and again with the four watchpoints armed on top. | + +**The one thing no prediction anticipated**, and it is the most important by-product: loading +`turn1-state.sav` and ending one turn produces a **different** post-turn autosave in every process, +including with `hooks=off` and nothing installed. One field moves — player 3's research target — and +the strategic generator does not. See the finding's §6.1. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index b38e10d..5d670b9 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 @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 2d61848, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1913,6 +1913,50 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0; constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0; // note Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED) [verified] constexpr uint32_t SNMAllCombatDone_layout = 0x00624758; +// site call RNG_NextInt (0x004271c0) inside SVSOSwarmQueen_RegisterHives, ECX = GetGame()->RNG(+0x16c) + 4, bound = HI - LO passed BY POINTER at [ebp-0x14]; return address 0x00527714, then `add eax,edi` where edi = frame + LO. ONE STRATEGIC-GENERATOR WORD PER NEW HIVE, taken inside StrategyServer::BeginProcessTurn -- outside BOTH turn drivers and before either. Corrects svsctob-writers.md, which put the call at 0x0052770c (that is the `mov [ebp-0x14],esi` storing the bound) [verified] +constexpr uint32_t SVSOSwarmQueen_RegisterHives_DrawSite = 0x0012770f; +// site `inc DWORD PTR [esi+0x8]` -- the NextQ slip. FOUR gate-failure edges converge here: 0x005277e3 (frame <= *SwarmQueen_GateFrameFloor_ptr), 0x005277fe (queens count >= the per-turn allowance), 0x00527848 (age < *SwarmQueen_GateQueenAge_ptr), 0x0052784f (hive's system handle is 0). Consumes NO generator word on any of them; TickHives calls no RNG primitive anywhere in its body (0x00527770..0x005279a1 read whole) [verified] +constexpr uint32_t SVSOSwarmQueen_TickHives_SlipSite = 0x0012785a; +// data int* -- pointer to the LOW end of the new-hive queen-countdown offset. RegisterHives computes nextQ = frame + *this + RNG_NextInt(*SwarmQueen_HiveNextQOffsetHi_ptr - *this). Behind a pointer, so no .text or .data reference initialises it in a form a static reader can follow; the value is read live by the shim at RegisterHives entry [mapped] +constexpr uint32_t SwarmQueen_HiveNextQOffsetLo_ptr = 0x006e0204; +// data int* -- pointer to the HIGH end of the new-hive queen-countdown offset. The RNG_NextInt bound is (*this - *SwarmQueen_HiveNextQOffsetLo_ptr) and NextInt is INCLUSIVE of its bound, so the countdown lands in [frame+LO, frame+HI] [mapped] +constexpr uint32_t SwarmQueen_HiveNextQOffsetHi_ptr = 0x006e0208; +// data int* -- TickHives gate 1: `if (GetGame()->Frame(+0xc) <= *this) slip`. No queen can spawn at or below this frame [mapped] +constexpr uint32_t SwarmQueen_GateFrameFloor_ptr = 0x006e0210; +// data int* -- read in TickHives' SPAWN arm (0x005278d8), after a queen has been created: when *this <= 0 the vector at queen+0x30 is trimmed to 0 entries, otherwise to *this. Never exercised on any corpus save (no hive has ever had a queen) [mapped] +constexpr uint32_t SwarmQueen_SpawnListCap_ptr = 0x006e0220; +// data int* -- TickHives gate 3: an age in turns, compared against `GetGame()->Frame - resolvedQueen->+0xc`; below it the hive slips instead of spawning [mapped] +constexpr uint32_t SwarmQueen_GateQueenAge_ptr = 0x006e0228; +// field std::vector -- {_Myfirst 0x10, _Mylast 0x14, _Myend 0x18, _Alval 0x1c}, element stride 0x10: {void* vptr = 0x009f1a68, StarSystem* sys(+4), int nextQ(+8), Queen* queen(+0xc)}. The WIRE order is HiveID / QueenID / NextQ, which is NOT the member order [verified] +constexpr uint32_t SVSOSwarmQueen_off_Hives = 0x00000010; +// field std::vector at +0x20..+0x24, element stride 0xc -- TickHives' second gate divides the byte span by 12. Empty on every corpus save [verified] +constexpr uint32_t SVSOSwarmQueen_off_Queens = 0x00000020; +// field int -- the difficulty tier, wire tag "CDiff". Set to -1 by the ctor 0x0051a820 and written ONLY by SVSOSlaversRefuel_UpdateDifficultyTier, which stores (scanIndex - 1) and only when it differs. Reachable values are 0 (frame 1..49) and 1 (frame 50..99) ONLY: frame <= 0 exits at index 0 and frame >= 100 walks off the end of the 3-entry threshold table, so BOTH edges store nothing and the tier can never reach 2 [verified] +constexpr uint32_t SVSOSlaversRefuel_off_CDiff = 0x00000038; +// thiscall void __thiscall Game::AITColonize::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITColonizeGoal::Execute is the same address. 48 bytes; a forwarder to the parameterised worker 0x0068b280 called as worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10) with `edi = this->+0xc` passed as an IMPLICIT REGISTER ARGUMENT (whole-program-optimised custom convention; a reimplementation that ports only the stack arguments passes garbage). Reaches the colonize order (list 7) at depth 4 via 0x0068b280 -> 0x006930f0 -> 0x00578ff0 -> 0x00769640 [unverified] +constexpr uint32_t AITColonize_Execute = 0x0028b400; +// thiscall void __thiscall Game::AITEscortGateInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITEscortGateInvadeGoal::Execute is the same address. 80 bytes; forwards to 0x0068c5d0 as __fastcall(ecx = this->+0xc, edx = agent) plus 8 stack arguments. Reaches list 7 at depth 4 [unverified] +constexpr uint32_t AITEscortGateInvade_Execute = 0x0028c7c0; +// thiscall void __thiscall Game::AITInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITInvadeGoal::Execute is the same address. 160 bytes; forwards to 0x0068d460 with 11 arguments, then maintains this->+0x38 and this->+0x39. +0x39 is computed at 0x0068d80f-0x0068d82a as (0x006a6380(agent, this->+0xc) < 2 * 0x006a6260(agent, this->+0xc)) and is what IAITask slot 11 returns for this class. Reaches list 14 at depth 5 [unverified] +constexpr uint32_t AITInvade_Execute = 0x0028d7a0; +// thiscall void __thiscall Game::AITNodeBore::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Does its setup (0x00685810) and its finaliser (0x0068e090) only when pass == 0, and forwards `pass` to 0x0068a520 in both passes. Reaches list 14 at depth 5 via 0x0068a520 -> 0x006ceef0 -> 0x006c16c0 -> 0x006987e0 -> 0x007634d0. Zuul-flavoured task (the node-bore is a Zuul mechanic), so on a non-Zuul board it may be absent from the task list entirely [unverified] +constexpr uint32_t AITNodeBore_Execute = 0x0028e590; +// thiscall void __thiscall Game::AITRaid::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. THE ONE TASK WHOSE PASS-0 BEHAVIOUR IS NOT SETTLED: at 0x0068e89e it calls 0x006b76a0 and then at 0x0068e8b8 loops the list-16 order method 0x007635f0(client, fleetId, 1) over the returned fleets, and NEITHER the call nor the loop carries a `pass` guard of its own. AI3 §2.3's argument says the fleet vector at [ebp-0x28] is empty on pass 0 because it is downstream of the same hub, but [ebp-0x28] was never traced to closure. This address exists as an address so that a zero at 0x007635f0 can be read: entered-and-silent and never-entered are opposite answers (method rule 20) [unverified] +constexpr uint32_t AITRaid_Execute = 0x0028e670; +// thiscall void __thiscall Game::AITAdvanceIdleShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Whole body is pass-1 only: `if (pass != 1) return` at 0x0068f25a. Table priority 0, so it is always the LAST task in the descending-priority sweep -- the AI sweeps up whatever is still idle only after every other task has taken both its minimum and its desired force, which is what the two-tier quota model predicts [unverified] +constexpr uint32_t AITAdvanceIdleShips_Execute = 0x0028f230; +// thiscall void __thiscall Game::AITBuildDeepScanShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6 via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0, the same chain as AITBuildPoliceShips [unverified] +constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0; +// thiscall void __thiscall Game::AITBuildPoliceShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6, identical chain to AITBuildDeepScanShips. One of the two candidate producers of the single list-3 element the reference AI emits on turn 1 [unverified] +constexpr uint32_t AITBuildPoliceShips_Execute = 0x00290380; +// thiscall bool __thiscall Game::StrategyClient::OrderList16(int objectId, bool flag) -- RET 8. THE LIST-16 PRODUCER. Opens with `cmp byte [this+0x15c], 0` -- the End-Turn latch StrategyClient::EndTurn 0x00783be0 sets at 0x00783c59 before BuildTurnCommands runs -- so like every other order method it refuses once the turn has been submitted. Element record for list 16 is {i32, bool} (lane Q), matching the (objectId, flag) pair; list 16 is in the PAYING half of the ModCount table (bump at 0x00821e23 in its applier 0x00821e20). Reached from AITRaid::Execute at depth 1. The method is named for the list it fills, NOT for what the flag means: no save has ever carried a list-16 element and nothing has been observed [unverified] +constexpr uint32_t StrategyClient_OrderList16 = 0x003635f0; +// offset DifficultyMods rec -- the per-player difficulty record, 0x1c bytes: {int id; float ai[3]; float other[3]}. THE RECORD IS PER-PLAYER AND REACHABLE FROM A ServerPlayer, which is the correction this lane makes: src/shim/hooks/compute_budget.h's coverage note says the difficulty row is 'not reachable from a ServerPlayer, so the two relevant entries are fitted constants measured from the B1 trace rather than snapshotted inputs'. It is reachable. DifficultyMods_Select 0x0059b490 is called as `Select(p->+0x36c, p)` by every one of the three consumers (ComputeBudget 0x0086338b, StrategyServer_IncomeDifficultyMod 0x0080f470, 0x0080e229), and LoadDifficultyRow 0x005a3990 fills it from ServerPlayer::Read 0x008804d0 at 0x00880fa3 gated on 0 <= aidf < 3 [verified] +constexpr uint32_t ServerPlayer_off_DiffMods = 0x0000036c; +// data double 0x3FEB333340000000 = 0.85000002384185791 = (double)0.85f -- the research-yield factor, multiplied in by ComputeBudget at 0x00863601 with `DC 0D` (fmul QWORD). THE WIDTH IS THE FINDING, and what makes it a rule-23 case rather than a curiosity is its two NEIGHBOURS in the same product: 1.15 at 0x00a1a4b8 (66 66 66 66 66 66 f2 3f) and 0.5 at 0x009e20a0 are stored as EXACT doubles, while this one is a widened float. Three literals, one expression, two widths -- so a C++ port that writes all three as decimals is wrong in exactly one place. The exact-decimal product (money/50 x 1.15 x 0.5 x 0.85) is integral when researchMoney is a multiple of 40,000, and the image's larger constant lands one ABOVE it there: at 40,000 the game gives 391 research points and the decimal gives 390. Not visible on the corpus -- the reference turn's research money is not a multiple of 40,000 -- so this is an instruction-stream reading, thin-covered by construction [verified] +constexpr uint32_t g_ResearchYieldFactor = 0x0062d818; +// offset int aidf -- the difficulty level (0..2) stored by ServerPlayer::Read 0x008804d0 at 0x00880fa3, the selector LoadDifficultyRow 0x005a3990 uses to pick the row it copies into ServerPlayer+0x36c. It sits immediately below the DifficultyMods record it selects [verified] +constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368; // thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified] constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480; // thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified] diff --git a/src/shim/hooks/tail_rng.cpp b/src/shim/hooks/tail_rng.cpp index 5264035..cae9deb 100644 --- a/src/shim/hooks/tail_rng.cpp +++ b/src/shim/hooks/tail_rng.cpp @@ -997,4 +997,473 @@ void ProcessNodeSpaceTravelHook::coverage(trace::Coverage& c) { "region:rng"); } +// ================================================================================================ +// Lane L1 -- BeginProcessTurn and the three script-object writers inside a turn +// ================================================================================================ +// +// Everything below is derived from an instruction read of the four bodies (docs/L1-predictions.md +// §0), and every derived quantity is emitted BESIDE the measurement rather than instead of it, so +// the record can disagree with the model. + +namespace { + +// The two globals RegisterHives reads the queen-countdown window from, and the three TickHives +// gates. All five are POINTERS to ints, so the value is one extra indirection; both levels are +// reported because "the pointer is null" and "the value is 0" are different findings. +struct ConfigInt { + std::uint32_t ptr = 0; + std::int32_t value = 0; + bool have = false; +}; + +ConfigInt read_config_int(std::uint32_t rva) { + ConfigInt c; + const void* slot = reinterpret_cast(g_env.exe_base + rva); + if (!readable(slot, 4)) return c; + c.ptr = peek(slot, 0); + const void* p = reinterpret_cast(static_cast(c.ptr)); + if (!readable(p, 4)) return c; + c.value = peek(p, 0); + c.have = true; + return c; +} + +void push_config_int(std::vector& out, const char* name, std::uint32_t rva) { + const ConfigInt c = read_config_int(rva); + Tv s = tv::struct_(); + s.add("rva", tv::u32(rva)); + s.add("ptr", tv::u32(c.ptr)); + s.add("value", c.have ? tv::i32(c.value) : tv::null()); + out.push_back(s.named(name)); +} + +// SVSOSwarmQueen: scenario tag +0x4, own EncID +0x8, Hives vector +0x10..+0x18 (stride 0x10), +// Queens vector +0x20..+0x24 (stride 0xc). HiveInfo = {vptr, StarSystem* sys, int nextQ, +// Queen* queen}; the WIRE order (HiveID / QueenID / NextQ) is not the member order. +constexpr std::size_t kQueenOffTag = 0x4; +constexpr std::size_t kQueenOffEncId = 0x8; +constexpr std::size_t kQueenOffHives = A::SVSOSwarmQueen_off_Hives; // 0x10 +constexpr std::size_t kQueenOffQueens = A::SVSOSwarmQueen_off_Queens; // 0x20 +constexpr std::size_t kHiveStride = A::sizeof_Game_SVSOSwarmQueen_HiveInfo; // 0x10 +constexpr std::size_t kHiveOffSys = 0x4; +constexpr std::size_t kHiveOffNextQ = 0x8; +constexpr std::size_t kHiveOffQueen = 0xc; +constexpr std::size_t kSysOffEggScio = 0x184; +constexpr int kMaxHivesReported = 16; + +// NOTE, and it is deliberately not "fixed" after the fact: a vector that has never allocated has +// `_Myfirst == _Mylast == nullptr`, and this returns **-1** for it, not 0. Every `-1` in the +// lane-L1 records of 2026-09-08 means exactly that -- an empty vector -- and `hives: null` beside +// it says the same thing a second way. The code is left as it was when those numbers were taken +// (rule 19: a different instrument is a different measurement), and this comment is the fix. +std::int32_t vector_count(void* obj, std::size_t off, std::size_t stride) { + if (!readable(obj, off + 8)) return -1; + const char* first = static_cast(ptr_at(obj, off)); + const char* last = static_cast(ptr_at(obj, off + 4)); + if (!first || !last || last < first) return -1; + const std::size_t span = static_cast(last - first); + if (stride == 0 || span % stride) return -1; + return static_cast(span / stride); +} + +// The hive vector, rendered. Reported at entry on both queen hooks so the slip is visible as a +// difference between two records of the same turn rather than inferred from the save file. +Tv hives_tv(void* queen) { + const std::int32_t n = vector_count(queen, kQueenOffHives, kHiveStride); + if (n < 0) return tv::null(); + std::vector items; + const char* first = static_cast(ptr_at(queen, kQueenOffHives)); + for (std::int32_t i = 0; i < n && i < kMaxHivesReported; ++i) { + const char* h = first + static_cast(i) * kHiveStride; + if (!readable(h, kHiveStride)) break; + Tv one = tv::struct_(); + const void* sys = peek(h, kHiveOffSys); + one.add("sys", tv::ptr(sys)); + one.add("egg_scio", readable(sys, kSysOffEggScio + 4) + ? tv::i32(peek(sys, kSysOffEggScio)) + : tv::null()); + one.add("next_q", tv::i32(peek(h, kHiveOffNextQ))); + one.add("queen", tv::ptr(peek(h, kHiveOffQueen))); + items.push_back(one); + } + return tv::list(std::move(items)); +} + +void push_queen_args(std::vector& out, void* queen) { + out.push_back(tv::ptr(queen).named("queen")); + out.push_back((readable(queen, kQueenOffTag + 4) + ? tv::i32(peek(queen, kQueenOffTag)) + : tv::null()) + .named("scenario_tag")); + out.push_back((readable(queen, kQueenOffEncId + 4) + ? tv::i32(peek(queen, kQueenOffEncId)) + : tv::null()) + .named("enc_id")); + out.push_back(tv::i32(vector_count(queen, kQueenOffHives, kHiveStride)).named("hives_in")); + out.push_back(tv::i32(vector_count(queen, kQueenOffQueens, 0xc)).named("queens_in")); + out.push_back(hives_tv(queen).named("hives")); +} + +// How many hives RegisterHives will append, evaluated at entry by transcribing its own two +// predicates over the live Systems vector. This is the prediction the word count is checked +// against: one RNG_NextInt per appended hive, so `predict_new_hives` should equal the measured +// word cost when the generator's rejection loop does not fire. +std::int32_t predict_new_hives(void* queen, void* server, std::int32_t* systems_out, + std::int32_t* tagged_out) { + if (systems_out) *systems_out = -1; + if (tagged_out) *tagged_out = -1; + if (!readable(queen, kQueenOffTag + 4) || !server) return -1; + const std::int32_t tag = peek(queen, kQueenOffTag); + + // GetGame() returns the S frame, and RegisterHives walks GetGame()->+0x44..+0x48. In this + // file's base convention that is the S+4 frame's +0x40 -- the same vector lane W3's arming + // code reaches as S+4+kServerOffSystems. + const std::size_t sysOff = kSFrame + 0x40; + if (!readable(server, sysOff + 8)) return -1; + const char* sf = static_cast(ptr_at(server, sysOff)); + const char* sl = static_cast(ptr_at(server, sysOff + 4)); + if (!sf || !sl || sl < sf || (sl - sf) % 4) return -1; + const std::size_t nsys = static_cast(sl - sf) / 4; + if (nsys > 4096 || !readable(sf, nsys * 4)) return -1; + if (systems_out) *systems_out = static_cast(nsys); + + const std::int32_t nh = vector_count(queen, kQueenOffHives, kHiveStride); + const char* hf = nh > 0 ? static_cast(ptr_at(queen, kQueenOffHives)) : nullptr; + + std::int32_t tagged = 0, fresh = 0; + for (std::size_t i = 0; i < nsys; ++i) { + const void* sys = peek(sf, i * 4); + if (!readable(sys, kSysOffEggScio + 4)) continue; + if (peek(sys, kSysOffEggScio) != tag) continue; + ++tagged; + bool referenced = false; + for (std::int32_t k = 0; hf && k < nh; ++k) { + const char* h = hf + static_cast(k) * kHiveStride; + if (readable(h, kHiveStride) && peek(h, kHiveOffSys) == sys) { + referenced = true; + break; + } + } + if (!referenced) ++fresh; + } + if (tagged_out) *tagged_out = tagged; + return fresh; +} + +// UpdateDifficultyTier's threshold table, transcribed. Only the threshold column is ever loaded by +// the original; the other two dwords of each record are dead in that function and are not modelled. +// Returns the value that WILL be stored, or INT32_MIN for "no store", so the three no-store paths +// are distinguishable from a stored 0. +constexpr std::int32_t kNoStore = static_cast(0x80000000u); +constexpr std::int32_t kTierThresholds[3] = {1, 50, 100}; + +std::int32_t predict_difficulty_tier(std::int32_t frame, std::int32_t current) { + int idx = 0; + while (idx < 3 && !(kTierThresholds[idx] > frame)) ++idx; + if (idx >= 3) return kNoStore; // frame >= 100: the scan runs off the end + if (idx <= 0) return kNoStore; // frame <= 0: `test eax,eax; jle` + const std::int32_t tier = idx - 1; + if (tier == current) return kNoStore; // unchanged + return tier; +} + +// A 4-byte int region, so the record shows a store or its absence rather than a hash moving. +Tv describe_i32(const void* p, std::size_t size, unsigned) { + Tv s = tv::struct_(); + s.add("value", size >= 4 ? tv::i32(peek(p, 0)) : tv::null()); + return s; +} + +CallState g_begin_turn, g_queen_begin, g_register_hives, g_tick_hives, g_difficulty; + +// The queen hooks are entered with `this` = the script object, not the server, so they reach the +// generator through the pointer BeginProcessTurn cached one frame out. If that pointer is null the +// record says `rng: null` rather than reporting a zero cost -- a null here is a measurement +// failure, exactly as it is on the first pre-turn autosave of a session. +RngEntry observe_entry_via_server() { return observe_entry(g_server); } + +} // namespace + +// ---- Game::StrategyServer::BeginProcessTurn ---------------------------------------------------- + +void StrategyServerBeginProcessTurnHook::describe_args(std::vector& out, void* self) { + g_server = self; + g_begin_turn.entry = observe_entry(self); + out.push_back(tv::ptr(self).named("server")); + // Frame is read AT ENTRY, i.e. before `inc [esi+0xc]` at 0x007d990a. The turn this call is + // about is frame_in + 1, and that is the value every callee below will read from GetGame(). + push_server_args(out, self); + push_rng_args(out, g_begin_turn.entry); +} + +void StrategyServerBeginProcessTurnHook::regions(std::vector& out, void* self) { + push_rng_region(out, g_begin_turn.entry.rng); + // Frame itself, so the increment is a recorded fact and not an inference from a later hook. + if (readable(self, kSFrame + A::StrategyServer_off_ModCount + 4)) { + trace::Region r; + r.name = "frame"; + r.ptr = static_cast(self) + kSFrame + A::StrategyServer_off_ModCount; + r.size = 4; + r.describe = &describe_i32; + out.push_back(r); + } +} + +StrategyServerBeginProcessTurnHook::Args StrategyServerBeginProcessTurnHook::rebind(trace::Scratch&, + void* self) { + return Args(self); +} + +void StrategyServerBeginProcessTurnHook::ours(void* self) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("StrategyServer::BeginProcessTurn"); + if (H::original) H::original(self); + } +} + +void StrategyServerBeginProcessTurnHook::coverage(trace::Coverage& c) { + tail_rng_common_coverage(c); + c.unmodelled("this is the interval lane Z's bracket contained but never attributed", + trace::Risk::High, + "tail-rng-ledger.md measured a residual of exactly 0 outside the two turn " + "drivers on eight turns of two saves. BeginProcessTurn runs between the two " + "autosave markers and before ProcessTurn, so any draw here was inside that " + "bracket and outside every subtotal -- it would have shown up as residual, and " + "on those turns there was none. This hook says whether that is a property of " + "the code or of the workload", + "region:rng plus the three nested script-object hooks"); + c.unmodelled("two container walks and an event post are unmodelled", + trace::Risk::Medium, + "the body iterates S+0x44 (systems, three calls each) and S+0x64 (fleets, one " + "call each) and posts event 0x24 before the script two-step. None of that is " + "declared; if any of it draws, the cost lands on this hook and NOT on the " + "nested queen hooks, which is exactly how it would announce itself", + "region:rng on this hook vs the nested subtotals"); +} + +// ---- Game::SVSOSwarmQueen::OnTurnBegin --------------------------------------------------------- + +void SwarmQueenOnTurnBeginHook::describe_args(std::vector& out, void* self) { + g_queen_begin.entry = observe_entry_via_server(); + push_queen_args(out, self); + push_rng_args(out, g_queen_begin.entry); +} + +void SwarmQueenOnTurnBeginHook::regions(std::vector& out, void*) { + push_rng_region(out, g_queen_begin.entry.rng); +} + +SwarmQueenOnTurnBeginHook::Args SwarmQueenOnTurnBeginHook::rebind(trace::Scratch&, void* self) { + return Args(self); +} + +void SwarmQueenOnTurnBeginHook::ours(void* self) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("SVSOSwarmQueen::OnTurnBegin"); + if (H::original) H::original(self); + } +} + +void SwarmQueenOnTurnBeginHook::coverage(trace::Coverage& c) { + tail_rng_common_coverage(c); + c.unmodelled("the prune pass and 0x00505100 are unmodelled", + trace::Risk::Low, + "between RegisterHives and TickHives the handler drops hives whose system's " + "EggScio no longer matches the queen's tag, and afterwards calls 0x00505100, " + "which no lane has read. If either draws, this hook's cost exceeds the sum of " + "the two nested ones", + "region:rng here vs region:rng on RegisterHives + TickHives"); + c.unmodelled("`this` is the script object, so the generator is reached through a cached " + "server pointer", + trace::Risk::Medium, + "BeginProcessTurn caches it one frame out. A record with `rng: null` means the " + "cache was cold, not that the call was free", + "arg:rng / arg:rng_words_in are null when the pointer was unavailable"); +} + +// ---- Game::SVSOSwarmQueen::RegisterHives ------------------------------------------------------- + +void SwarmQueenRegisterHivesHook::describe_args(std::vector& out, void* self) { + g_register_hives.entry = observe_entry_via_server(); + push_queen_args(out, self); + + std::int32_t systems = -1, tagged = -1; + const std::int32_t fresh = predict_new_hives(self, g_server, &systems, &tagged); + out.push_back(tv::i32(systems).named("systems")); + out.push_back(tv::i32(tagged).named("systems_tagged")); + // One RNG_NextInt per appended hive (0x0052770f). NextInt rejects on (y & mask) > n, so this + // is the minimum word cost, not necessarily the exact one -- stated as a prediction, checked + // against region:rng. + out.push_back(tv::i32(fresh).named("predict_new_hives")); + out.push_back(tv::i32(fresh).named("predict_min_words")); + + // The two constants lane SV could not follow to an initialiser and correctly refused to fit + // from a single two-hive observation. Reading them live closes that leaf outright. + push_config_int(out, "hive_nextq_lo", A::SwarmQueen_HiveNextQOffsetLo_ptr); + push_config_int(out, "hive_nextq_hi", A::SwarmQueen_HiveNextQOffsetHi_ptr); + push_rng_args(out, g_register_hives.entry); +} + +void SwarmQueenRegisterHivesHook::regions(std::vector& out, void*) { + push_rng_region(out, g_register_hives.entry.rng); +} + +SwarmQueenRegisterHivesHook::Args SwarmQueenRegisterHivesHook::rebind(trace::Scratch&, void* self) { + return Args(self); +} + +void SwarmQueenRegisterHivesHook::ours(void* self) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("SVSOSwarmQueen::RegisterHives"); + if (H::original) H::original(self); + } +} + +void SwarmQueenRegisterHivesHook::coverage(trace::Coverage& c) { + tail_rng_common_coverage(c); + c.unmodelled("the appended HiveInfo is not declared as a region", + trace::Risk::Medium, + "the vector reallocates, so the element's address does not exist at entry and " + "a Result region cannot be declared over it. The hive list is reported as an " + "ARGUMENT at entry on this hook and again on TickHives, so the append is " + "visible as a difference between two records of the same turn", + "arg:hives on this hook and on TickHives"); + c.unmodelled("`predict_min_words` is a lower bound, deliberately", + trace::Risk::Low, + "RNG_NextInt is a rejection loop: with bound n it masks to " + "2^ceil(log2(n+1))-1 and redraws while the value exceeds n, so a single call " + "costs 1 word with probability (n+1)/(mask+1) and more otherwise. A measured " + "cost above the prediction is the loop, not a second site", + "region:rng against arg:predict_min_words and the two config values"); +} + +// ---- Game::SVSOSwarmQueen::TickHives ----------------------------------------------------------- + +void SwarmQueenTickHivesHook::describe_args(std::vector& out, void* self) { + g_tick_hives.entry = observe_entry_via_server(); + push_queen_args(out, self); + push_config_int(out, "gate_frame_floor", A::SwarmQueen_GateFrameFloor_ptr); + push_config_int(out, "gate_queen_age", A::SwarmQueen_GateQueenAge_ptr); + push_config_int(out, "spawn_list_cap", A::SwarmQueen_SpawnListCap_ptr); + // The slip model: every hive with queen == 0 whose gates fail gets `++nextQ`. The gate that + // is checkable from here is the first one, and on our saves it is the one that fails. + const ConfigInt floor = read_config_int(A::SwarmQueen_GateFrameFloor_ptr); + const std::int32_t frame = turn_of(g_server); + out.push_back((floor.have ? tv::boolean(frame <= floor.value) : tv::null()) + .named("predict_gate1_fails")); + out.push_back(tv::i32(0).named("predict_words")); + push_rng_args(out, g_tick_hives.entry); +} + +void SwarmQueenTickHivesHook::regions(std::vector& out, void*) { + push_rng_region(out, g_tick_hives.entry.rng); +} + +SwarmQueenTickHivesHook::Args SwarmQueenTickHivesHook::rebind(trace::Scratch&, void* self) { + return Args(self); +} + +void SwarmQueenTickHivesHook::ours(void* self) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("SVSOSwarmQueen::TickHives"); + if (H::original) H::original(self); + } +} + +void SwarmQueenTickHivesHook::coverage(trace::Coverage& c) { + tail_rng_common_coverage(c); + c.unmodelled("the SPAWN arm has never run under any instrument", + trace::Risk::High, + "no hive in the corpus has a queen, so only the slip edge has ever executed. " + "The spawn arm calls 0x0050dfc0 and 0x004fe810, neither of which has been read " + "for draws. `predict_words = 0` is a claim about the SLIP path only, and if a " + "queen ever spawns this hook's cost is the first evidence of what that costs", + "arg:hives shows every hive's queen pointer; a non-null one means the arm ran"); + c.unmodelled("only the first of three gates is evaluated here", + trace::Risk::Low, + "gate 2 divides the queens vector's byte span by 12 against a value the " + "original computes at entry through 0x004fe8d0, which this hook does not call; " + "gate 3 needs a handle resolve. `predict_gate1_fails` is therefore sufficient " + "but not necessary for the slip", + "arg:gate_frame_floor and arg:queens_in carry the raw inputs"); +} + +// ---- Game::SVSOSlaversRefuel::UpdateDifficultyTier ---------------------------------------------- + +void SlaversRefuelUpdateDifficultyTierHook::describe_args(std::vector& out, void* self) { + g_difficulty.entry = observe_entry_via_server(); + const std::int32_t frame = turn_of(g_server); + const std::int32_t cur = readable(self, A::SVSOSlaversRefuel_off_CDiff + 4) + ? peek(self, A::SVSOSlaversRefuel_off_CDiff) + : -0x7fffffff; + const std::int32_t pred = predict_difficulty_tier(frame, cur); + + out.push_back(tv::ptr(self).named("slavers")); + out.push_back(tv::i32(frame).named("frame")); + out.push_back(tv::i32(cur).named("cdiff_in")); + out.push_back(tv::list({tv::i32(kTierThresholds[0]), tv::i32(kTierThresholds[1]), + tv::i32(kTierThresholds[2])}) + .named("thresholds")); + // The three no-store paths are named rather than collapsed into a value, because "stored 0" + // and "stored nothing" are the entire question at both ends of the table (method rule 20). + const char* why = "store"; + if (pred == kNoStore) { + if (frame <= 0) why = "no_store_index0_frame_le_0"; + else if (frame >= kTierThresholds[2]) why = "no_store_ran_off_end_frame_ge_100"; + else why = "no_store_unchanged"; + } + out.push_back(tv::str(why).named("predict_path")); + out.push_back((pred == kNoStore ? tv::null() : tv::i32(pred)).named("predict_cdiff_out")); + out.push_back(tv::i32(0).named("predict_words")); + push_rng_args(out, g_difficulty.entry); +} + +void SlaversRefuelUpdateDifficultyTierHook::regions(std::vector& out, void* self) { + // CDiff first: a Result region, so a store shows as a diff on a named field. + if (readable(self, A::SVSOSlaversRefuel_off_CDiff + 4)) { + trace::Region r; + r.name = "cdiff"; + r.ptr = static_cast(self) + A::SVSOSlaversRefuel_off_CDiff; + r.size = 4; + r.describe = &describe_i32; + out.push_back(r); + } + push_rng_region(out, g_difficulty.entry.rng); +} + +SlaversRefuelUpdateDifficultyTierHook::Args SlaversRefuelUpdateDifficultyTierHook::rebind( + trace::Scratch&, void* self) { + return Args(self); +} + +void SlaversRefuelUpdateDifficultyTierHook::ours(void* self) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("SVSOSlaversRefuel::UpdateDifficultyTier"); + if (H::original) H::original(self); + } +} + +void SlaversRefuelUpdateDifficultyTierHook::coverage(trace::Coverage& c) { + tail_rng_common_coverage(c); + c.unmodelled("the per-system pass at 0x005158d4 is not declared at all", + trace::Risk::Medium, + "it runs ONLY when CDiff changed, walks GetGame()->Systems and writes something " + "this object does not serialise -- NAsg, NTD and NAD are unchanged across every " + "corpus pair. Whether it writes anything elsewhere is a labelled hypothesis and " + "this hook declares no guard that could catch it", + "region:cdiff covers the tier and nothing else"); + c.unmodelled("tier 2 is claimed unreachable from a 3-entry table read, not from a run", + trace::Risk::Medium, + "at frame >= 100 the scan exits the loop with index 3 and falls straight into " + "the epilogue. `predict_path` names that branch, so a run at any frame states " + "which arm it took -- but reaching frame 100 needs a workload no corpus save " + "provides", + "arg:predict_path against region:cdiff before/after"); +} + } // namespace shim::hooks diff --git a/src/shim/hooks/tail_rng.h b/src/shim/hooks/tail_rng.h index 8d3dba3..78e7f17 100644 --- a/src/shim/hooks/tail_rng.h +++ b/src/shim/hooks/tail_rng.h @@ -215,6 +215,115 @@ struct ProcessNodeSpaceTravelHook { static void coverage(trace::Coverage& c); }; +// ---- lane L1: the turn-begin driver and the script objects that write inside it --------------- +// +// WHY THESE FIVE. `findings/objects/svsctob-writers.md` (lane SV) recovered the script-object event +// bus statically and made a claim that contradicts a finding this campaign treats as closed: +// lane Z's "the residual outside the two turn drivers is exactly zero" was measured on turns where +// the swarm hives already existed, and **hive creation draws inside `BeginProcessTurn`** -- outside +// both drivers and before either. None of lane SV's subsystem had ever run under an instrument. +// +// The five hooks nest, so the subtotals attribute: +// +// Autosave(endTurn=1) +// StrategyServer::BeginProcessTurn ........ NEW. The interval lane Z's bracket contains but +// SVSOSwarmQueen::OnTurnBegin ........... never attributed, because no hook sat in it. +// SVSOSwarmQueen::RegisterHives ....... one RNG_NextInt per NEW hive <-- the question +// SVSOSwarmQueen::TickHives ........... the NextQ slip; predicted 0 words +// StrategyServer::ProcessTurn ............. (lane Z) +// StrategyServer::OnAllCombatDone_Tail .... (lane Z) +// SVSOSlaversRefuel::UpdateDifficultyTier tail phase 20, evt 0x14; predicted 0 words +// Autosave(endTurn=0) +// +// Each carries a MODEL evaluated at entry, so a measurement can disagree with it: the hive count and +// the two config constants for RegisterHives, the per-hive NextQ values and the three gate constants +// for TickHives, and the whole threshold-table scan for UpdateDifficultyTier. + +// `StrategyServer::BeginProcessTurn` 0x007d98e0, `ret` (thiscall, no stack args). Body read whole, +// 0x007d98e0..0x007d9ae8: `inc [esi+0xc]` (Frame++) at 0x007d990a, two container walks, an event +// post, and then the script two-step `vft[0x10](0x13,0); vft[0x60]()` at 0x007d9ab8 as the last +// thing before the epilogue. +struct StrategyServerBeginProcessTurnHook { + static constexpr const char* name = "Game::StrategyServer::BeginProcessTurn"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; // this (StrategyServer* S) + + static void describe_args(std::vector& out, void* self); + static void regions(std::vector& out, void* self); + static Args rebind(trace::Scratch& s, void* self); + static void ours(void* self); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + +// `SVSOSwarmQueen::OnTurnBegin` 0x00529930, `ret`. vtable slot +0x60 = event 0x13's slot. +// RegisterHives -> prune by EggScio -> TickHives -> 0x00505100. +struct SwarmQueenOnTurnBeginHook { + static constexpr const char* name = "Game::SVSOSwarmQueen::OnTurnBegin"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; // this (SVSOSwarmQueen*) + + static void describe_args(std::vector& out, void* self); + static void regions(std::vector& out, void* self); + static Args rebind(trace::Scratch& s, void* self); + static void ours(void* self); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + +// `SVSOSwarmQueen::RegisterHives` 0x00527630, `ret`. THE DRAW. One RNG_NextInt at 0x0052770f per +// system whose EggScio(+0x184) equals the queen's scenario tag(+0x4) and that no existing hive +// already references. +struct SwarmQueenRegisterHivesHook { + static constexpr const char* name = "Game::SVSOSwarmQueen::RegisterHives"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self); + static void regions(std::vector& out, void* self); + static Args rebind(trace::Scratch& s, void* self); + static void ours(void* self); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + +// `SVSOSwarmQueen::TickHives` 0x00527770, `ret`. The NextQ slip: four gate-failure edges converge +// on `inc [hive+8]` at 0x0052785a. No RNG primitive is called anywhere in the body. +struct SwarmQueenTickHivesHook { + static constexpr const char* name = "Game::SVSOSwarmQueen::TickHives"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self); + static void regions(std::vector& out, void* self); + static Args rebind(trace::Scratch& s, void* self); + static void ours(void* self); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + +// `SVSOSlaversRefuel::UpdateDifficultyTier` 0x00515820, `ret`. Tail phase 20, event 0x14, reached +// through the class's GENERIC handler 0x0051a800 (`if (evt == 0x14) call this`). Writes CDiff at +// this+0x38, and only on a change -- so an entry probe is the only way to tell "did not run" from +// "ran and stored nothing", which is the whole question at both ends of its threshold table. +struct SlaversRefuelUpdateDifficultyTierHook { + static constexpr const char* name = "Game::SVSOSlaversRefuel::UpdateDifficultyTier"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self); + static void regions(std::vector& out, void* self); + static Args rebind(trace::Scratch& s, void* self); + static void ours(void* self); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + // Process facts the hooks need (exe base for RVAs, a line logger). Call once before installing. void init_tail_rng(std::uintptr_t exe_base, void (*log_line)(const char* line)); diff --git a/src/shim/hooks/watchpoints.cpp b/src/shim/hooks/watchpoints.cpp index 9300c9e..1c858fe 100644 --- a/src/shim/hooks/watchpoints.cpp +++ b/src/shim/hooks/watchpoints.cpp @@ -21,7 +21,7 @@ char g_outPath[MAX_PATH] = {}; // Which four addresses the single arming point computes. `modcount` is lane W2's set and is the // default so its run stays reproducible byte for byte; `tshn` is lane W3's. -enum class Mode { ModCount, Tshn }; +enum class Mode { ModCount, Tshn, Snlv }; Mode g_mode = Mode::ModCount; // ---- what is being watched ------------------------------------------------------------------ @@ -200,6 +200,12 @@ bool g_armed = false; constexpr std::uint32_t kSysNvoHead = 0x274; // ServerSystem-relative constexpr std::uint32_t kSysAFlags = 0xd4; +// Lane L1: `SnLv` -- ServerSystem+0x24, the packed 2-bit-per-player sensor contact level written by +// SensorPhase_ComputeContactLevel at 0x0086a789 and read by ServerSystem::IsKnownTo at 0x007463c0. +// `nvo-tshn-visible-owner.md` §6 marks "the sensor branch is what refreshes Spica" as a HYPOTHESIS +// because this word is NOT SERIALISED and no measurement of it exists. It is one add from the +// arming point, and printing it for every system costs no debug register at all. +constexpr std::uint32_t kSysSnLv = 0x24; constexpr std::uint32_t kSysName = 0xa8; // std::string, 0x1c bytes constexpr std::uint32_t kSysNveSize = 0x288; constexpr std::uint32_t kNodeIsNil = 0x8d; // an NVO node is ~0x90 bytes; NVE's is 0x20 @@ -324,11 +330,24 @@ void ArmTshnSlots(std::uintptr_t S) { Readable(sys + kSysAFlags - d, 4) ? U32(sys + kSysAFlags - d) : 0xffffffffu; const std::uint32_t nve = Readable(sys + kSysNveSize - d, 4) ? U32(sys + kSysNveSize - d) : 0xffffffffu; + const std::uint32_t snlv = + Readable(sys + kSysSnLv - d, 4) ? U32(sys + kSysSnLv - d) : 0xffffffffu; char nm[40]; ReadStdString(sys + kSysName - d, nm, sizeof nm); - LogF("watch: sys[%d] @0x%08x '%s' AFlags=0x%x NVO=%u NVE=%u root=0x%08x key=%u ok=%d", i, - static_cast(sys), nm, af, p.size, nve, static_cast(p.root), p.key, - p.ok ? 1 : 0); + // The 2-bit contact level per player index, decoded on the spot. IsKnownTo's sensor + // conjunct is `PlyrIdx < 15 && ((SnLv >> 2*PlyrIdx) & 3) != 0 && HasResearched(AdvSens)`, + // so this string is the first of those three conjuncts made visible. + char lv[16 * 2 + 1]; + for (int q = 0; q < 15; ++q) { + const unsigned two = (snlv >> (2u * static_cast(q))) & 3u; + lv[q * 2] = static_cast('0' + two); + lv[q * 2 + 1] = ' '; + } + lv[30] = '\0'; + LogF("watch: sys[%d] @0x%08x '%s' AFlags=0x%x SnLv=0x%08x lv[0..14]=%s NVO=%u NVE=%u " + "root=0x%08x key=%u ok=%d", + i, static_cast(sys), nm, af, snlv, lv, p.size, nve, + static_cast(p.root), p.key, p.ok ? 1 : 0); if (!target && af == 0 && p.ok) { target = sys; tp = p; @@ -338,11 +357,25 @@ void ArmTshnSlots(std::uintptr_t S) { if (target) { g_watchAddr[0] = tp.root + kNodeValue; - g_watchAddr[1] = target + kSysNvoHead + 4 - d; std::snprintf(g_slotName[0], sizeof g_slotName[0], "'%s' NVO root+0x10 {touched:i16,TShn:i16} key=player %u", tname, tp.key); - std::snprintf(g_slotName[1], sizeof g_slotName[1], "'%s' NVO._Mysize (=%u at arm)", tname, - tp.size); + if (g_mode == Mode::Snlv) { + // Lane L1 / lane W3's stated next step: ONE ARMING LINE. Slot 1 moves off the map's + // _Mysize and onto the target system's SnLv, so the sensor contact level that + // IsKnownTo's second conjunct reads is measured rather than assumed. The cost is that + // this run can no longer tell a node reallocation from a silent leaf -- which is why + // it is a separate mode and not an edit to lane W3's. + g_watchAddr[1] = target + kSysSnLv - d; + const std::uint32_t snlv0 = Readable(g_watchAddr[1], 4) ? U32(g_watchAddr[1]) : 0; + std::snprintf(g_slotName[1], sizeof g_slotName[1], + "'%s' SnLv (sys+0x%x) = 0x%08x at arm; key player %u level %u", tname, + kSysSnLv, snlv0, tp.key, + tp.key < 15 ? ((snlv0 >> (2u * tp.key)) & 3u) : 0xffffffffu); + } else { + g_watchAddr[1] = target + kSysNvoHead + 4 - d; + std::snprintf(g_slotName[1], sizeof g_slotName[1], "'%s' NVO._Mysize (=%u at arm)", + tname, tp.size); + } } else { LogF("watch: NO system has AFlags==0 with a non-empty NVO -- slots 0/1 UNSET. That is a " "FAILED TARGET SELECTION, not a measurement; every zero below is unmeasured."); @@ -411,7 +444,7 @@ extern "C" void WatchOnApplyAll(void* self) { g_watchAddr[2] = 0; g_watchAddr[3] = 0; - if (g_mode == Mode::Tshn) { + if (g_mode == Mode::Tshn || g_mode == Mode::Snlv) { ArmTshnSlots(S); } else { @@ -501,7 +534,8 @@ bool watch_apply_config(const char* key, const char* value, std::string* err) { if (std::strcmp(key, "watch.mode") == 0) { if (std::strcmp(value, "modcount") == 0) g_mode = Mode::ModCount; else if (std::strcmp(value, "tshn") == 0) g_mode = Mode::Tshn; - else if (err) *err = "expected modcount|tshn"; + else if (std::strcmp(value, "snlv") == 0) g_mode = Mode::Snlv; + else if (err) *err = "expected modcount|tshn|snlv"; return true; } if (std::strcmp(key, "watch.out") == 0) { diff --git a/src/shim/main.cpp b/src/shim/main.cpp index be0ac29..aaa5121 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -267,6 +267,16 @@ void InstallHooks(shim::trace::Tracer& tracer) { // 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); + // Lane L1: the turn-begin driver and the three script-object writers that run inside a turn. + // BeginProcessTurn is the interval lane Z's autosave bracket CONTAINED but never attributed -- + // nothing had ever been hooked between the pre-turn autosave and ProcessTurn. Lane SV read the + // swarm-queen registrar as taking one RNG_NextInt per new hive there, which if true qualifies + // "the residual outside the two turn drivers is exactly zero". These five settle it live. + InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_BeginProcessTurn); + InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_OnTurnBegin); + InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_RegisterHives); + InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_TickHives); + InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSlaversRefuel_UpdateDifficultyTier); // 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 -- diff --git a/src/shim/shim.cfg.l1control b/src/shim/shim.cfg.l1control new file mode 100644 index 0000000..b5163e0 --- /dev/null +++ b/src/shim/shim.cfg.l1control @@ -0,0 +1,29 @@ +# Lane L1: lane W3's watchpoint arming with ONE LINE CHANGED -- slot 1 moves from the NVO map's +# _Mysize to the target system's SnLv (ServerSystem+0x24). `nvo-tshn-visible-owner.md` §6 says that +# is exactly what settles "the sensor branch is what refreshes Spica", which is a hypothesis today +# because SnLv is not serialised and has never been measured. +# +# The arming sweep also now prints SnLv and its decoded per-player 2-bit levels for EVERY system, +# which costs no debug register, so one run answers the Spica question and the Bismol question +# together. +# +# Identical to shim.cfg.l1control except for the single key `watch=`, so the pair is a real rule-19 +# control. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::TechTree::ProcessResearch=trace +hook.Game::ServerPlayer::ComputeBudget=trace +hook.Game::ServerPlayer::OnTechResearched=trace +hook.Game::ServerSystem::ProcessTurn=trace +hook.Game::StrategyServer::MoveFleet=trace +trace.path=C:\SOTS\shim.trace.jsonl +trace.inline_max=256 +trace.flush=always +watch=off +watch.players=2 +watch.mode=snlv +watch.out=C:\SOTS\shim.watch.txt diff --git a/src/shim/shim.cfg.l1hive b/src/shim/shim.cfg.l1hive new file mode 100644 index 0000000..c21449f --- /dev/null +++ b/src/shim/shim.cfg.l1hive @@ -0,0 +1,52 @@ +# Lane L1 -- the turn-begin ledger. Copy over C:\SOTS\shim.cfg. +# +# Lane Z's config plus five hooks that sit in the one interval its bracket contained and never +# attributed: StrategyServer::BeginProcessTurn, and the three script-object writers reached from it +# (SVSOSwarmQueen::OnTurnBegin / RegisterHives / TickHives) plus the tail's +# SVSOSlaversRefuel::UpdateDifficultyTier. +# +# Read `side.rng.before.words` and `side.rng.after.words` on each record; the difference is the +# words that call consumed. The question this config exists to answer is whether +# BeginProcessTurn's difference is ever non-zero. +# +# fpu.sample_turn=off is REQUIRED (MinHook allows one hook per target and the fpu sampler wants +# StrategyServer::ProcessTurn). +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::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=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 + +hook.Game::StrategyServer::BeginProcessTurn=trace +hook.Game::SVSOSwarmQueen::OnTurnBegin=trace +hook.Game::SVSOSwarmQueen::RegisterHives=trace +hook.Game::SVSOSwarmQueen::TickHives=trace +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=trace + +fpu.sample_turn=off +fpu.sample_ticks=off +watch=off + +# The generator is 0x9cc bytes and every record carries it twice; `describe` already reduces it to +# {left, index, block, words, block_hash}. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always diff --git a/src/shim/shim.cfg.l1off b/src/shim/shim.cfg.l1off new file mode 100644 index 0000000..cf6684b --- /dev/null +++ b/src/shim/shim.cfg.l1off @@ -0,0 +1,52 @@ +# Lane L1 -- the HOOKS=OFF CONTROL for the turn-begin ledger (rule 19). Copy over C:\SOTS\shim.cfg. +# +# Lane Z's config plus five hooks that sit in the one interval its bracket contained and never +# attributed: StrategyServer::BeginProcessTurn, and the three script-object writers reached from it +# (SVSOSwarmQueen::OnTurnBegin / RegisterHives / TickHives) plus the tail's +# SVSOSlaversRefuel::UpdateDifficultyTier. +# +# Read `side.rng.before.words` and `side.rng.after.words` on each record; the difference is the +# words that call consumed. The question this config exists to answer is whether +# BeginProcessTurn's difference is ever non-zero. +# +# fpu.sample_turn=off is REQUIRED (MinHook allows one hook per target and the fpu sampler wants +# StrategyServer::ProcessTurn). +hooks=off +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::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=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 + +hook.Game::StrategyServer::BeginProcessTurn=trace +hook.Game::SVSOSwarmQueen::OnTurnBegin=trace +hook.Game::SVSOSwarmQueen::RegisterHives=trace +hook.Game::SVSOSwarmQueen::TickHives=trace +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=trace + +fpu.sample_turn=off +fpu.sample_ticks=off +watch=off + +# The generator is 0x9cc bytes and every record carries it twice; `describe` already reduces it to +# {left, index, block, words, block_hash}. +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always diff --git a/src/shim/shim.cfg.l1snlv b/src/shim/shim.cfg.l1snlv new file mode 100644 index 0000000..27a89c8 --- /dev/null +++ b/src/shim/shim.cfg.l1snlv @@ -0,0 +1,29 @@ +# Lane L1: lane W3's watchpoint arming with ONE LINE CHANGED -- slot 1 moves from the NVO map's +# _Mysize to the target system's SnLv (ServerSystem+0x24). `nvo-tshn-visible-owner.md` §6 says that +# is exactly what settles "the sensor branch is what refreshes Spica", which is a hypothesis today +# because SnLv is not serialised and has never been measured. +# +# The arming sweep also now prints SnLv and its decoded per-player 2-bit levels for EVERY system, +# which costs no debug register, so one run answers the Spica question and the Bismol question +# together. +# +# Identical to shim.cfg.l1control except for the single key `watch=`, so the pair is a real rule-19 +# control. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::TechTree::ProcessResearch=trace +hook.Game::ServerPlayer::ComputeBudget=trace +hook.Game::ServerPlayer::OnTechResearched=trace +hook.Game::ServerSystem::ProcessTurn=trace +hook.Game::StrategyServer::MoveFleet=trace +trace.path=C:\SOTS\shim.trace.jsonl +trace.inline_max=256 +trace.flush=always +watch=on +watch.players=2 +watch.mode=snlv +watch.out=C:\SOTS\shim.watch.txt From fa53e02b53b2e3e1b9606d81ba5a7eb30114f676 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 18:22:14 -0400 Subject: [PATCH 2/2] L1: probe the AI client seed across two processes -- it is fresh every time Two hooks, Mars::RNG::Seed 0x0049fdf0 and StrategyApp::RunAI 0x008706f0, and a config that turns everything else off. Two launches from the same save, load only -- the AI clients are constructed on load, so no End Turn is needed. Result: every AI client's generator seed differs between processes (net 32, 496 and 512 all move), while the record structure is byte-for-byte the same shape and one Seed call with seed=0 produces an identical state in both runs. So the turn1-state -> turn2 nondeterminism is a SEED effect, not the ordering effect that was predicted, and lane AI1's 'every draw from the static generator returns 0' is falsified by measurement. The prediction said the opposite and is left in docs/L1-predictions.md with its outcome underneath. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ --- docs/L1-predictions.md | 67 +++++++++++++++++++++ src/shim/hooks/tail_rng.cpp | 116 ++++++++++++++++++++++++++++++++++++ src/shim/hooks/tail_rng.h | 49 +++++++++++++++ src/shim/main.cpp | 5 ++ src/shim/shim.cfg.l1control | 2 + src/shim/shim.cfg.l1hive | 2 + src/shim/shim.cfg.l1off | 2 + src/shim/shim.cfg.l1seed | 47 +++++++++++++++ src/shim/shim.cfg.l1snlv | 2 + 9 files changed, 292 insertions(+) create mode 100644 src/shim/shim.cfg.l1seed diff --git a/docs/L1-predictions.md b/docs/L1-predictions.md index 1c3caf7..b8eacdf 100644 --- a/docs/L1-predictions.md +++ b/docs/L1-predictions.md @@ -219,3 +219,70 @@ together. Full account: `sots-re/findings/control-flow/hive-creation-rng.md` and `turn1-state.sav` and ending one turn produces a **different** post-turn autosave in every process, including with `hooks=off` and nothing installed. One field moves — player 3's research target — and the strategic generator does not. See the finding's §6.1. + +--- + +# P7 — the AI seed probe (added 2026-09-08, before the build, at the coordinator's request) + +**The question.** `turn1-state → turn2` is not reproducible across processes; exactly one of the +three AI players picks a different research target each run. Is that a **seed** effect (a per-process +generator seed) or an **ordering** effect (a tie broken by container order under ASLR)? + +**The probe.** Hook `RNG_Seed` 0x0049fdf0 (`thiscall RNG* (RNG* this, uint32 seed)`, `ret 4`) and +`StrategyApp::RunAI` 0x008706f0 (`ret 0x10`, whose 4th argument lane AI1 identified as the AI +client's seed). Launch twice from `turn1-state.sav`, **load only — no End Turn is needed**, because +the client and its generator are constructed on load. Compare the two ordered sequences of +`(this, seed)`. + +**Prediction: the seeds are IDENTICAL across the two processes, and the AI client's seed is 0.** + +Reasoning, and it is lane AI1's rather than mine: `SNMRunAI` takes the seed it passes to `RunAI` from +the static `Mars::RNG` in `.data` whose only static initialiser writes the *`IStreamable`* vftable +rather than the one `RNG_Seed` installs — so **none of the image's six `RNG_Seed` call sites targets +it**, its `mt[624]` is zero-initialised BSS, and an all-zero MT19937 state is a fixed point of the +twist. Every draw from it is 0. A per-process time seed on a *client* generator would also desync +lockstep multiplayer, which this engine has (`multiplayer-tier0-verified.md`). + +So I expect this probe to **rule the seed out** and leave the ordering hypothesis — which agrees with +the coordinator's expectation, and I am saying so explicitly rather than pretending to have arrived +independently. + +**Falsifiers, and each is more interesting than the prediction holding:** + +| symptom | what it would mean | +|---|---| +| the two processes' seed sequences **differ** | AI1's reading is wrong; the seed is per-process and the ordering hypothesis is unnecessary. Find what feeds `RNG_Seed`. | +| seeds identical but the AI client's is **non-zero** | the static generator is seeded somewhere AI1's six-site sweep did not reach. Still constant, still an ordering effect, but AI1's "every draw returns 0" needs correcting. | +| the **number or order** of `RNG_Seed` calls differs between processes | something upstream of the seeding is already process-dependent, which is a bigger finding than either hypothesis. | +| `RunAI` is not entered at all on a load | the AI clients are not (re)constructed on load, so the probe measures nothing and must move to the turn itself. This is the one that would waste the run, so `RunAI` is hooked as much for that as for its argument. | + +**What this probe cannot do:** it cannot confirm the ordering hypothesis, only fail to refute it. +Confirming it needs the candidate list the varying empire builds — several equal-priority candidates +where the other two AIs have a unique best. That is a different hook and is not attempted here. + +## P7 outcome — wrong, and that is the useful part + +**Falsified on the first falsifier row.** Two launches, same save, load only: + +| `RunAI` | net id | process 1 | process 2 | +|---|---|---|---| +| 1 | 32 | `0x75F692C0` | `0x414F415E` | +| 2 | 496 | `0xF2EDAC21` | `0x10B94E78` | +| 3 | 512 | `0x165A2ADB` | `0xC218DBF8` | + +**Every AI client seed is fresh per process.** The record *structure* is identical — 8 records, same +order, same net ids, same personality, same `ai_data` — and one of the four `Seed` calls takes +`seed = 0` and produces a byte-identical state in both runs, which is the built-in control against +"the instrument randomised it". + +So it is a **seed** effect, not an ordering effect, and lane AI1's "every draw from the static +generator returns 0" is falsified by measurement (it was flagged by its own author as arithmetic +rather than measurement, with a prediction attached — this is that prediction coming back negative). + +The coordinator's supporting argument — *"a time-seeded per-client RNG would move all three"* — does +not follow: all three seeds **do** move, and a different stream only shows up in the save where the +decision it feeds actually depends on the draw. Two of the three empires evidently have a unique best +research candidate and the third does not. + +Written up in `sots-re/findings/subsystems/ai-client-seed-is-per-process.md`, including the one thing +this probe did **not** establish — where the seed comes from — and the single hook that would. diff --git a/src/shim/hooks/tail_rng.cpp b/src/shim/hooks/tail_rng.cpp index cae9deb..98ca786 100644 --- a/src/shim/hooks/tail_rng.cpp +++ b/src/shim/hooks/tail_rng.cpp @@ -1466,4 +1466,120 @@ void SlaversRefuelUpdateDifficultyTierHook::coverage(trace::Coverage& c) { "arg:predict_path against region:cdiff before/after"); } +// ---- lane L1: the AI seed probe ---------------------------------------------------------------- + +namespace { + +// The seeded state, so two processes can be compared on the generator itself and not only on the +// argument. `mt[0]` IS the seed (RNG_Seed writes `mt[0] = seed` before the Knuth fill), so a run +// where mt[0] disagrees with `seed` means the object moved between entry and exit. +Tv describe_seeded_rng(const void* p, std::size_t size, unsigned) { + Tv s = tv::struct_(); + if (size < A::RNG_off_Left + 4) return s; + std::uint32_t mt[4] = {0, 0, 0, 0}; + std::memcpy(mt, static_cast(p) + A::RNG_off_State, sizeof mt); + s.add("mt0", tv::u32(mt[0])); + s.add("mt1", tv::u32(mt[1])); + s.add("mt2", tv::u32(mt[2])); + s.add("mt623_left", tv::i32(peek(p, A::RNG_off_Left))); + return s; +} + +} // namespace + +void RngSeedHook::describe_args(std::vector& out, void* self, std::uint32_t seed) { + out.push_back(tv::ptr(self).named("rng")); + out.push_back(tv::u32(seed).named("seed")); + // Whether this is the strategic generator is decidable here and nowhere else in the log. + out.push_back(tv::boolean(g_server != nullptr && rng_of_server(g_server) == self) + .named("is_strategic_generator")); +} + +trace::Tv RngSeedHook::describe_ret(void* r) { return tv::ptr(r); } + +void RngSeedHook::regions(std::vector& out, void* self, std::uint32_t) { + if (!readable(self, kRngSize)) return; + trace::Region r; + r.name = "rng"; + r.ptr = self; + r.size = kRngSize; + r.describe = &describe_seeded_rng; + out.push_back(r); +} + +RngSeedHook::Args RngSeedHook::rebind(trace::Scratch&, void* self, std::uint32_t seed) { + return Args(self, seed); +} + +void* RngSeedHook::ours(void* self, std::uint32_t seed) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("Mars::RNG::Seed"); + if (H::original) return H::original(self, seed); + } + return nullptr; +} + +void RngSeedHook::coverage(trace::Coverage& c) { + c.unmodelled("this hook exists to compare two PROCESSES, not to check one", + trace::Risk::Low, + "a single run's record says nothing. The result is the diff between the ordered " + "(rng, seed) sequences of two launches from the same save: identical sequences " + "rule the seed out as the source of the turn1->turn2 nondeterminism and leave " + "the ordering hypothesis", + "arg:seed and region:rng.after.mt0, which must agree"); + c.unmodelled("the caller of each Seed call is not recorded", + trace::Risk::Medium, + "the image has six call sites and this hook cannot say which one it is in. " + "`is_strategic_generator` distinguishes the one that matters most, and the " + "RunAI hook names the AI client's seed independently; the rest are identified " + "by their position in the sequence, which is only valid while the sequence is " + "stable -- and whether it is stable is exactly what is being measured", + "arg:is_strategic_generator; the RunAI record"); +} + +void StrategyAppRunAIHook::describe_args(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, + std::uint32_t rngSeed) { + out.push_back(tv::ptr(self).named("app")); + out.push_back(tv::i32(netId).named("player_net_id")); + out.push_back((readable(customData, 1) ? tv::str(customData) : tv::null()).named("ai_data")); + out.push_back(tv::u32(personality).named("personality")); + out.push_back(tv::u32(rngSeed).named("rng_seed")); +} + +void StrategyAppRunAIHook::regions(std::vector&, void*, std::int32_t, const char*, + std::uint8_t, std::uint32_t) {} + +StrategyAppRunAIHook::Args StrategyAppRunAIHook::rebind(trace::Scratch&, void* self, + std::int32_t netId, const char* customData, + std::uint8_t personality, + std::uint32_t rngSeed) { + return Args(self, netId, customData, personality, rngSeed); +} + +void StrategyAppRunAIHook::ours(void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("StrategyApp::RunAI"); + if (H::original) H::original(self, netId, customData, personality, rngSeed); + } +} + +void StrategyAppRunAIHook::coverage(trace::Coverage& c) { + c.unmodelled("everything RunAI does is unmodelled; only its arguments are read", + trace::Risk::Low, + "it allocates the StrategyClient, seeds its generator, builds the AI agent and " + "loads the save's AI blob. This hook declares no region and checks nothing -- it " + "reports the seed and proves the path was taken on a load at all", + "arg:rng_seed"); + c.unmodelled("`ai_data` is read as a NUL-terminated string with no length bound", + trace::Risk::Low, + "lane AI1 types the second argument as `const char* aiCustomDataName`. A " + "non-string pointer here would be reported as garbage text rather than as an " + "error; the pointer itself is not printed separately", + "none -- read the value with suspicion if it is not a plausible name"); +} + } // namespace shim::hooks diff --git a/src/shim/hooks/tail_rng.h b/src/shim/hooks/tail_rng.h index 78e7f17..a5a4afa 100644 --- a/src/shim/hooks/tail_rng.h +++ b/src/shim/hooks/tail_rng.h @@ -324,6 +324,55 @@ struct SlaversRefuelUpdateDifficultyTierHook { static void coverage(trace::Coverage& c); }; +// ---- lane L1: the AI seed probe ---------------------------------------------------------------- +// +// `turn1-state -> turn2` is not reproducible across processes: exactly one of the three AI players +// picks a different research target every run, including with `hooks=off` +// (`findings/subsystems/turn1-to-turn2-nondeterminism.md`). Two hypotheses -- a per-process +// generator SEED, or an ORDERING effect (a tie broken by container order under ASLR) -- and these +// two hooks separate them in two launches with no End Turn, because the AI client and its +// generator are built on load. +// +// `Mars::RNG::Seed` 0x0049fdf0, `thiscall RNG* (RNG* this, uint32 seed)`, `ret 4`. Six call sites +// image-wide, so this is a handful of records per load, not a flood. +struct RngSeedHook { + static constexpr const char* name = "Mars::RNG::Seed"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void*; // returns `this` + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self, std::uint32_t seed); + static trace::Tv describe_ret(void* r); + static void regions(std::vector& out, void* self, std::uint32_t seed); + static Args rebind(trace::Scratch& s, void* self, std::uint32_t seed); + static void* ours(void* self, std::uint32_t seed); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + +// `Game::StrategyApp::RunAI` 0x008706f0, `ret 0x10` (lane AI1). Its FOURTH argument is the seed the +// `StrategyClient` constructor hands to `RNG_Seed` for that client's generator. Hooked as much to +// prove the AI clients are constructed on load at all as for the value: if this never fires, the +// seed probe measured nothing and must move into the turn. +struct StrategyAppRunAIHook { + static constexpr const char* name = "Game::StrategyApp::RunAI"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, + std::uint32_t rngSeed); + static void regions(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, std::uint32_t rngSeed); + static Args rebind(trace::Scratch& s, void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed); + static void ours(void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + // Process facts the hooks need (exe base for RVAs, a line logger). Call once before installing. void init_tail_rng(std::uintptr_t exe_base, void (*log_line)(const char* line)); diff --git a/src/shim/main.cpp b/src/shim/main.cpp index aaa5121..0431f0e 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -277,6 +277,11 @@ void InstallHooks(shim::trace::Tracer& tracer) { InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_RegisterHives); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_TickHives); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSlaversRefuel_UpdateDifficultyTier); + // Lane L1, the AI seed probe: is `turn1-state -> turn2`'s nondeterminism a per-process SEED or + // an ordering effect? Two launches, load only, and diff the ordered (rng, seed) sequences. + // Both hooks are off in every config except shim.cfg.l1seed. + InstallTemplateHook(tracer, exeBase, sots::addr::RNG_Seed); + InstallTemplateHook(tracer, exeBase, sots::addr::StrategyApp_RunAI); // 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 -- diff --git a/src/shim/shim.cfg.l1control b/src/shim/shim.cfg.l1control index b5163e0..aedb766 100644 --- a/src/shim/shim.cfg.l1control +++ b/src/shim/shim.cfg.l1control @@ -27,3 +27,5 @@ watch=off watch.players=2 watch.mode=snlv watch.out=C:\SOTS\shim.watch.txt +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off diff --git a/src/shim/shim.cfg.l1hive b/src/shim/shim.cfg.l1hive index c21449f..cd1cc48 100644 --- a/src/shim/shim.cfg.l1hive +++ b/src/shim/shim.cfg.l1hive @@ -44,6 +44,8 @@ hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=trace fpu.sample_turn=off fpu.sample_ticks=off watch=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off # The generator is 0x9cc bytes and every record carries it twice; `describe` already reduces it to # {left, index, block, words, block_hash}. diff --git a/src/shim/shim.cfg.l1off b/src/shim/shim.cfg.l1off index cf6684b..fb9b85f 100644 --- a/src/shim/shim.cfg.l1off +++ b/src/shim/shim.cfg.l1off @@ -50,3 +50,5 @@ watch=off trace.inline_max=64 trace.path=C:\SOTS\shim.trace.jsonl trace.flush=always +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off diff --git a/src/shim/shim.cfg.l1seed b/src/shim/shim.cfg.l1seed new file mode 100644 index 0000000..41cb98f --- /dev/null +++ b/src/shim/shim.cfg.l1seed @@ -0,0 +1,47 @@ +# Lane L1 -- the AI seed probe. Copy over C:\SOTS\shim.cfg, launch, LOAD `l1-turn1.sav`, and STOP. +# No End Turn is needed: the AI clients and their generators are built on load. +# +# The result is the diff between two launches' ordered (rng, seed) sequences. Identical sequences +# rule the seed out as the source of the turn1->turn2 nondeterminism +# (findings/subsystems/turn1-to-turn2-nondeterminism.md) and leave the ordering hypothesis. +# +# `hooks=trace` with everything named turned off, rather than `hooks=off` with two overrides, +# because that is the pattern every other config in this tree uses and is known to open the log. +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::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off + +hook.Mars::RNG::Seed=trace +hook.Game::StrategyApp::RunAI=trace + +fpu.sample_turn=off +fpu.sample_ticks=off +watch=off +probes=off +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always diff --git a/src/shim/shim.cfg.l1snlv b/src/shim/shim.cfg.l1snlv index 27a89c8..1596bef 100644 --- a/src/shim/shim.cfg.l1snlv +++ b/src/shim/shim.cfg.l1snlv @@ -27,3 +27,5 @@ watch=on watch.players=2 watch.mode=snlv watch.out=C:\SOTS\shim.watch.txt +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off