From 2947e24ed9b85313628e4dd3f3c9ff8bf164318e Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 17:43:10 -0400 Subject: [PATCH] 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