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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
This commit is contained in:
alex 2026-09-08 17:43:10 -04:00
parent 011749571b
commit 2947e24ed9
10 changed files with 1059 additions and 10 deletions

221
docs/L1-predictions.md Normal file
View file

@ -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
<fall through to 0x5158a6 = THE EPILOGUE, NO STORE>
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.

View file

@ -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 <cstdint>
@ -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<EncounterResults> 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<HiveInfo> -- {_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<T> 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]

View file

@ -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<const void*>(g_env.exe_base + rva);
if (!readable(slot, 4)) return c;
c.ptr = peek<std::uint32_t>(slot, 0);
const void* p = reinterpret_cast<const void*>(static_cast<std::uintptr_t>(c.ptr));
if (!readable(p, 4)) return c;
c.value = peek<std::int32_t>(p, 0);
c.have = true;
return c;
}
void push_config_int(std::vector<Tv>& 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<const char*>(ptr_at(obj, off));
const char* last = static_cast<const char*>(ptr_at(obj, off + 4));
if (!first || !last || last < first) return -1;
const std::size_t span = static_cast<std::size_t>(last - first);
if (stride == 0 || span % stride) return -1;
return static_cast<std::int32_t>(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<Tv> items;
const char* first = static_cast<const char*>(ptr_at(queen, kQueenOffHives));
for (std::int32_t i = 0; i < n && i < kMaxHivesReported; ++i) {
const char* h = first + static_cast<std::size_t>(i) * kHiveStride;
if (!readable(h, kHiveStride)) break;
Tv one = tv::struct_();
const void* sys = peek<void*>(h, kHiveOffSys);
one.add("sys", tv::ptr(sys));
one.add("egg_scio", readable(sys, kSysOffEggScio + 4)
? tv::i32(peek<std::int32_t>(sys, kSysOffEggScio))
: tv::null());
one.add("next_q", tv::i32(peek<std::int32_t>(h, kHiveOffNextQ)));
one.add("queen", tv::ptr(peek<void*>(h, kHiveOffQueen)));
items.push_back(one);
}
return tv::list(std::move(items));
}
void push_queen_args(std::vector<Tv>& out, void* queen) {
out.push_back(tv::ptr(queen).named("queen"));
out.push_back((readable(queen, kQueenOffTag + 4)
? tv::i32(peek<std::int32_t>(queen, kQueenOffTag))
: tv::null())
.named("scenario_tag"));
out.push_back((readable(queen, kQueenOffEncId + 4)
? tv::i32(peek<std::int32_t>(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<std::int32_t>(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<const char*>(ptr_at(server, sysOff));
const char* sl = static_cast<const char*>(ptr_at(server, sysOff + 4));
if (!sf || !sl || sl < sf || (sl - sf) % 4) return -1;
const std::size_t nsys = static_cast<std::size_t>(sl - sf) / 4;
if (nsys > 4096 || !readable(sf, nsys * 4)) return -1;
if (systems_out) *systems_out = static_cast<std::int32_t>(nsys);
const std::int32_t nh = vector_count(queen, kQueenOffHives, kHiveStride);
const char* hf = nh > 0 ? static_cast<const char*>(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<void*>(sf, i * 4);
if (!readable(sys, kSysOffEggScio + 4)) continue;
if (peek<std::int32_t>(sys, kSysOffEggScio) != tag) continue;
++tagged;
bool referenced = false;
for (std::int32_t k = 0; hf && k < nh; ++k) {
const char* h = hf + static_cast<std::size_t>(k) * kHiveStride;
if (readable(h, kHiveStride) && peek<const void*>(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<std::int32_t>(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<std::int32_t>(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<Tv>& 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<trace::Region>& 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<const char*>(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<StrategyServerBeginProcessTurnHook>;
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<Tv>& 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<trace::Region>& 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<SwarmQueenOnTurnBeginHook>;
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<Tv>& 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<trace::Region>& 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<SwarmQueenRegisterHivesHook>;
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<Tv>& 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<trace::Region>& 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<SwarmQueenTickHivesHook>;
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<Tv>& 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<std::int32_t>(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<trace::Region>& 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<const char*>(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<SlaversRefuelUpdateDifficultyTierHook>;
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

View file

@ -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<void*>; // this (StrategyServer* S)
static void describe_args(std::vector<trace::Tv>& out, void* self);
static void regions(std::vector<trace::Region>& 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<void*>; // this (SVSOSwarmQueen*)
static void describe_args(std::vector<trace::Tv>& out, void* self);
static void regions(std::vector<trace::Region>& 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<void*>;
static void describe_args(std::vector<trace::Tv>& out, void* self);
static void regions(std::vector<trace::Region>& 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<void*>;
static void describe_args(std::vector<trace::Tv>& out, void* self);
static void regions(std::vector<trace::Region>& 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<void*>;
static void describe_args(std::vector<trace::Tv>& out, void* self);
static void regions(std::vector<trace::Region>& 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));

View file

@ -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<unsigned>(sys), nm, af, p.size, nve, static_cast<unsigned>(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<unsigned>(q))) & 3u;
lv[q * 2] = static_cast<char>('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<unsigned>(sys), nm, af, snlv, lv, p.size, nve,
static_cast<unsigned>(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) {

View file

@ -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<shim::hooks::EncounterDetectProcessTeamRecordHook>(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<shim::hooks::StrategyServerBeginProcessTurnHook>(tracer, exeBase, sots::addr::StrategyServer_BeginProcessTurn);
InstallTemplateHook<shim::hooks::SwarmQueenOnTurnBeginHook>(tracer, exeBase, sots::addr::SVSOSwarmQueen_OnTurnBegin);
InstallTemplateHook<shim::hooks::SwarmQueenRegisterHivesHook>(tracer, exeBase, sots::addr::SVSOSwarmQueen_RegisterHives);
InstallTemplateHook<shim::hooks::SwarmQueenTickHivesHook>(tracer, exeBase, sots::addr::SVSOSwarmQueen_TickHives);
InstallTemplateHook<shim::hooks::SlaversRefuelUpdateDifficultyTierHook>(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 --

View file

@ -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

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

@ -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

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

@ -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

29
src/shim/shim.cfg.l1snlv Normal file
View file

@ -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