diff --git a/docs/L1-predictions.md b/docs/L1-predictions.md new file mode 100644 index 0000000..b8eacdf --- /dev/null +++ b/docs/L1-predictions.md @@ -0,0 +1,288 @@ +# 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. + +--- + +# P7 — the AI seed probe (added 2026-09-08, before the build, at the coordinator's request) + +**The question.** `turn1-state → turn2` is not reproducible across processes; exactly one of the +three AI players picks a different research target each run. Is that a **seed** effect (a per-process +generator seed) or an **ordering** effect (a tie broken by container order under ASLR)? + +**The probe.** Hook `RNG_Seed` 0x0049fdf0 (`thiscall RNG* (RNG* this, uint32 seed)`, `ret 4`) and +`StrategyApp::RunAI` 0x008706f0 (`ret 0x10`, whose 4th argument lane AI1 identified as the AI +client's seed). Launch twice from `turn1-state.sav`, **load only — no End Turn is needed**, because +the client and its generator are constructed on load. Compare the two ordered sequences of +`(this, seed)`. + +**Prediction: the seeds are IDENTICAL across the two processes, and the AI client's seed is 0.** + +Reasoning, and it is lane AI1's rather than mine: `SNMRunAI` takes the seed it passes to `RunAI` from +the static `Mars::RNG` in `.data` whose only static initialiser writes the *`IStreamable`* vftable +rather than the one `RNG_Seed` installs — so **none of the image's six `RNG_Seed` call sites targets +it**, its `mt[624]` is zero-initialised BSS, and an all-zero MT19937 state is a fixed point of the +twist. Every draw from it is 0. A per-process time seed on a *client* generator would also desync +lockstep multiplayer, which this engine has (`multiplayer-tier0-verified.md`). + +So I expect this probe to **rule the seed out** and leave the ordering hypothesis — which agrees with +the coordinator's expectation, and I am saying so explicitly rather than pretending to have arrived +independently. + +**Falsifiers, and each is more interesting than the prediction holding:** + +| symptom | what it would mean | +|---|---| +| the two processes' seed sequences **differ** | AI1's reading is wrong; the seed is per-process and the ordering hypothesis is unnecessary. Find what feeds `RNG_Seed`. | +| seeds identical but the AI client's is **non-zero** | the static generator is seeded somewhere AI1's six-site sweep did not reach. Still constant, still an ordering effect, but AI1's "every draw returns 0" needs correcting. | +| the **number or order** of `RNG_Seed` calls differs between processes | something upstream of the seeding is already process-dependent, which is a bigger finding than either hypothesis. | +| `RunAI` is not entered at all on a load | the AI clients are not (re)constructed on load, so the probe measures nothing and must move to the turn itself. This is the one that would waste the run, so `RunAI` is hooked as much for that as for its argument. | + +**What this probe cannot do:** it cannot confirm the ordering hypothesis, only fail to refute it. +Confirming it needs the candidate list the varying empire builds — several equal-priority candidates +where the other two AIs have a unique best. That is a different hook and is not attempted here. + +## P7 outcome — wrong, and that is the useful part + +**Falsified on the first falsifier row.** Two launches, same save, load only: + +| `RunAI` | net id | process 1 | process 2 | +|---|---|---|---| +| 1 | 32 | `0x75F692C0` | `0x414F415E` | +| 2 | 496 | `0xF2EDAC21` | `0x10B94E78` | +| 3 | 512 | `0x165A2ADB` | `0xC218DBF8` | + +**Every AI client seed is fresh per process.** The record *structure* is identical — 8 records, same +order, same net ids, same personality, same `ai_data` — and one of the four `Seed` calls takes +`seed = 0` and produces a byte-identical state in both runs, which is the built-in control against +"the instrument randomised it". + +So it is a **seed** effect, not an ordering effect, and lane AI1's "every draw from the static +generator returns 0" is falsified by measurement (it was flagged by its own author as arithmetic +rather than measurement, with a prediction attached — this is that prediction coming back negative). + +The coordinator's supporting argument — *"a time-seeded per-client RNG would move all three"* — does +not follow: all three seeds **do** move, and a different stream only shows up in the save where the +decision it feeds actually depends on the draw. Two of the three empires evidently have a unique best +research candidate and the third does not. + +Written up in `sots-re/findings/subsystems/ai-client-seed-is-per-process.md`, including the one thing +this probe did **not** establish — where the seed comes from — and the single hook that would. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 99c07a1..d7b7281 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 @ ea7881a, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 940aeca, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1951,6 +1951,20 @@ constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0; 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; +// thiscall void* __thiscall Game::StrategyAIAgent::SelectResearchTarget(void** outSlot) -- RET 4, the THIRD and last research producer tried by Process Turn phase 18 (0x006caf70), reached only when 0x006a84f0 and 0x006c27c0 both answer null. Body: (1) `if (agent->+0x10->+0x150 /*ClientPlayer*/ ->+0x294 != 0) return 0` -- a player that already has a target picks nothing; (2) build a std::vector of 0x0c-stride candidates with 0x006c2490(agent, &vec); (3) walk it FRONT TO BACK calling 0x006c8580(ecx = outSlot, edx = cand[1], stack: agent, cand[0]) and return the FIRST non-null answer; (4) if none answered, fall through to the three-arm rotation seeded by 0x006b36e0(agent) & 7 over the .data tables at 0x00a1a544 / 0x00a1a564. THERE IS NO SORT AND NO SCORE IN THE WALK: the vector's arrival order IS the priority, which is what makes a per-process ordering difference change the outcome [unverified] +constexpr uint32_t StrategyAIAgent_SelectResearchTarget = 0x002c8890; +// cdecl void __cdecl Game::StrategyAIAgent::BuildResearchCandidates(StrategyAIAgent* agent, std::vector* out) -- constructs a working object on the stack (0x006bcca0(agent)), emits the candidate vector into `out` (0x006bc500), destroys it (0x006bcef0). Element stride 0x0c. The emitter is a nested walk over groups with NO std::sort at that level, so the output order is the walk order [unverified] +constexpr uint32_t StrategyAIAgent_BuildResearchCandidates = 0x002c2490; +// fastcall void* __fastcall Game::StrategyAIAgent::TryResearchCandidate(void** outSlot /*ecx*/, int candWord1 /*edx*/, StrategyAIAgent* agent, int candWord0) -- called once per candidate in arrival order by SelectResearchTarget; the FIRST call that returns non-null ends the walk and that return value is the chosen tech object (its +0x4 is the std::string the order method is given). An entry probe on this address therefore records the whole candidate stream in the order the selector sees it [unverified] +constexpr uint32_t StrategyAIAgent_TryResearchCandidate = 0x002c8580; +// cdecl void* __cdecl (StrategyAIAgent* agent) -- the FIRST research producer tried by phase 18. Body unread; probed for reachability only, because a non-null answer here means the candidate walk never runs [unverified] +constexpr uint32_t StrategyAIAgent_ResearchProducerA = 0x002a84f0; +// thiscall void* __thiscall (StrategyAIAgent* this) -- the SECOND research producer tried by phase 18, entered with `mov eax,esi; call` (the receiver is in EAX at the call site, so the real convention is not confirmed). Body unread; probed for reachability only [unverified] +constexpr uint32_t StrategyAIAgent_ResearchProducerB = 0x002c27c0; +// thiscall int __thiscall (StrategyAIAgent* this) -- SelectResearchTarget's fallback index source, masked with 0x80000007 and used to index the eight-entry .data tables at 0x00a1a544 (2 0 2 0 2 1 2 0) and 0x00a1a564 (0 2 0 2 1 0 2 0). It is NOT an RNG draw: it reads ClientPlayer+0xf4 and calls 0x0080da80. A hit here means the research pick came from the three-arm rotation, not from the candidate list [unverified] +constexpr uint32_t StrategyAIAgent_ResearchFallbackIndex = 0x002b36e0; +// fastcall void* __fastcall (void** outSlot /*ecx*/, StrategyAIAgent* agent) -- one arm of SelectResearchTarget's three-arm fallback rotation; the arm index is (i + table[seed]) % 3 for i = 0,1,2 and the first non-null answer wins. Its outcome space is therefore at most three [unverified] +constexpr uint32_t StrategyAIAgent_ResearchFallbackArm = 0x002c8670; // 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 for this player. VERIFIED LIVE on VM146: read at ComputeBudget entry on every one of the 8 players of turn1-state across 3,895 calls, it is 1 on all eight, which is the difficulty level the whole corpus carries and the level whose AI column holds the 1.1x income modifier [verified] constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368; // offset DifficultyMods* -- a POINTER to the per-player difficulty record, NOT the record inline. This entry exists to correct a lane L5 mistake and to save the next lane the run. addresses.json already records DifficultyMods_Select 0x0059b490 being called as Select(p->diffMods /*+0x36c*/, p), and lane L5 read the 0x1c bytes AT +0x36c expecting {int id; float ai[3]; float other[3]}. They are not that: the first dword is 0x0da0ec20..0x0da13360 on the eight players of turn1-state -- eight distinct heap addresses within 18 KB of each other and of the ServerPlayer allocations themselves (the player object was at 0x0da08600) -- and the floats behind it decode as denormal garbage (1.7e-38, 1.4e-43). So the field is a pointer and the record is one dereference further on. HYPOTHESIS, not verified: this run did not follow the pointer, so it is consistent with +0x36c being a pointer to the DifficultyMods record and equally consistent with its being some other per-player sub-object pointer. What IS established is the thing the reading was for: the difficulty row is reachable from a ServerPlayer, so compute_budget.h's old coverage note ('not reachable from a ServerPlayer, so the two relevant entries are fitted constants') is wrong as written. The fitted pair {3.0, 1.5} / {1.0, 1.0} remains UNVERIFIED against the running game; the next run should dereference this field behind a readability guard and compare the selected triple against those constants [hypothesis] diff --git a/src/shim/hooks/tail_rng.cpp b/src/shim/hooks/tail_rng.cpp index 5264035..98ca786 100644 --- a/src/shim/hooks/tail_rng.cpp +++ b/src/shim/hooks/tail_rng.cpp @@ -997,4 +997,589 @@ 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"); +} + +// ---- lane L1: the AI seed probe ---------------------------------------------------------------- + +namespace { + +// The seeded state, so two processes can be compared on the generator itself and not only on the +// argument. `mt[0]` IS the seed (RNG_Seed writes `mt[0] = seed` before the Knuth fill), so a run +// where mt[0] disagrees with `seed` means the object moved between entry and exit. +Tv describe_seeded_rng(const void* p, std::size_t size, unsigned) { + Tv s = tv::struct_(); + if (size < A::RNG_off_Left + 4) return s; + std::uint32_t mt[4] = {0, 0, 0, 0}; + std::memcpy(mt, static_cast(p) + A::RNG_off_State, sizeof mt); + s.add("mt0", tv::u32(mt[0])); + s.add("mt1", tv::u32(mt[1])); + s.add("mt2", tv::u32(mt[2])); + s.add("mt623_left", tv::i32(peek(p, A::RNG_off_Left))); + return s; +} + +} // namespace + +void RngSeedHook::describe_args(std::vector& out, void* self, std::uint32_t seed) { + out.push_back(tv::ptr(self).named("rng")); + out.push_back(tv::u32(seed).named("seed")); + // Whether this is the strategic generator is decidable here and nowhere else in the log. + out.push_back(tv::boolean(g_server != nullptr && rng_of_server(g_server) == self) + .named("is_strategic_generator")); +} + +trace::Tv RngSeedHook::describe_ret(void* r) { return tv::ptr(r); } + +void RngSeedHook::regions(std::vector& out, void* self, std::uint32_t) { + if (!readable(self, kRngSize)) return; + trace::Region r; + r.name = "rng"; + r.ptr = self; + r.size = kRngSize; + r.describe = &describe_seeded_rng; + out.push_back(r); +} + +RngSeedHook::Args RngSeedHook::rebind(trace::Scratch&, void* self, std::uint32_t seed) { + return Args(self, seed); +} + +void* RngSeedHook::ours(void* self, std::uint32_t seed) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("Mars::RNG::Seed"); + if (H::original) return H::original(self, seed); + } + return nullptr; +} + +void RngSeedHook::coverage(trace::Coverage& c) { + c.unmodelled("this hook exists to compare two PROCESSES, not to check one", + trace::Risk::Low, + "a single run's record says nothing. The result is the diff between the ordered " + "(rng, seed) sequences of two launches from the same save: identical sequences " + "rule the seed out as the source of the turn1->turn2 nondeterminism and leave " + "the ordering hypothesis", + "arg:seed and region:rng.after.mt0, which must agree"); + c.unmodelled("the caller of each Seed call is not recorded", + trace::Risk::Medium, + "the image has six call sites and this hook cannot say which one it is in. " + "`is_strategic_generator` distinguishes the one that matters most, and the " + "RunAI hook names the AI client's seed independently; the rest are identified " + "by their position in the sequence, which is only valid while the sequence is " + "stable -- and whether it is stable is exactly what is being measured", + "arg:is_strategic_generator; the RunAI record"); +} + +void StrategyAppRunAIHook::describe_args(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, + std::uint32_t rngSeed) { + out.push_back(tv::ptr(self).named("app")); + out.push_back(tv::i32(netId).named("player_net_id")); + out.push_back((readable(customData, 1) ? tv::str(customData) : tv::null()).named("ai_data")); + out.push_back(tv::u32(personality).named("personality")); + out.push_back(tv::u32(rngSeed).named("rng_seed")); +} + +void StrategyAppRunAIHook::regions(std::vector&, void*, std::int32_t, const char*, + std::uint8_t, std::uint32_t) {} + +StrategyAppRunAIHook::Args StrategyAppRunAIHook::rebind(trace::Scratch&, void* self, + std::int32_t netId, const char* customData, + std::uint8_t personality, + std::uint32_t rngSeed) { + return Args(self, netId, customData, personality, rngSeed); +} + +void StrategyAppRunAIHook::ours(void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("StrategyApp::RunAI"); + if (H::original) H::original(self, netId, customData, personality, rngSeed); + } +} + +void StrategyAppRunAIHook::coverage(trace::Coverage& c) { + c.unmodelled("everything RunAI does is unmodelled; only its arguments are read", + trace::Risk::Low, + "it allocates the StrategyClient, seeds its generator, builds the AI agent and " + "loads the save's AI blob. This hook declares no region and checks nothing -- it " + "reports the seed and proves the path was taken on a load at all", + "arg:rng_seed"); + c.unmodelled("`ai_data` is read as a NUL-terminated string with no length bound", + trace::Risk::Low, + "lane AI1 types the second argument as `const char* aiCustomDataName`. A " + "non-string pointer here would be reported as garbage text rather than as an " + "error; the pointer itself is not printed separately", + "none -- read the value with suspicion if it is not a plausible name"); +} + } // namespace shim::hooks diff --git a/src/shim/hooks/tail_rng.h b/src/shim/hooks/tail_rng.h index 8d3dba3..a5a4afa 100644 --- a/src/shim/hooks/tail_rng.h +++ b/src/shim/hooks/tail_rng.h @@ -215,6 +215,164 @@ 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); +}; + +// ---- lane L1: the AI seed probe ---------------------------------------------------------------- +// +// `turn1-state -> turn2` is not reproducible across processes: exactly one of the three AI players +// picks a different research target every run, including with `hooks=off` +// (`findings/subsystems/turn1-to-turn2-nondeterminism.md`). Two hypotheses -- a per-process +// generator SEED, or an ORDERING effect (a tie broken by container order under ASLR) -- and these +// two hooks separate them in two launches with no End Turn, because the AI client and its +// generator are built on load. +// +// `Mars::RNG::Seed` 0x0049fdf0, `thiscall RNG* (RNG* this, uint32 seed)`, `ret 4`. Six call sites +// image-wide, so this is a handful of records per load, not a flood. +struct RngSeedHook { + static constexpr const char* name = "Mars::RNG::Seed"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void*; // returns `this` + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self, std::uint32_t seed); + static trace::Tv describe_ret(void* r); + static void regions(std::vector& out, void* self, std::uint32_t seed); + static Args rebind(trace::Scratch& s, void* self, std::uint32_t seed); + static void* ours(void* self, std::uint32_t seed); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + +// `Game::StrategyApp::RunAI` 0x008706f0, `ret 0x10` (lane AI1). Its FOURTH argument is the seed the +// `StrategyClient` constructor hands to `RNG_Seed` for that client's generator. Hooked as much to +// prove the AI clients are constructed on load at all as for the value: if this never fires, the +// seed probe measured nothing and must move into the turn. +struct StrategyAppRunAIHook { + static constexpr const char* name = "Game::StrategyApp::RunAI"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, + std::uint32_t rngSeed); + static void regions(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, std::uint32_t rngSeed); + static Args rebind(trace::Scratch& s, void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed); + static void ours(void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + // Process facts the hooks need (exe base for RVAs, a line logger). Call once before installing. void init_tail_rng(std::uintptr_t exe_base, void (*log_line)(const char* line)); diff --git a/src/shim/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 efeb7f1..076a4e0 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -267,6 +267,21 @@ 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); + // Lane L1, the AI seed probe: is `turn1-state -> turn2`'s nondeterminism a per-process SEED or + // an ordering effect? Two launches, load only, and diff the ordered (rng, seed) sequences. + // Both hooks are off in every config except shim.cfg.l1seed. + InstallTemplateHook(tracer, exeBase, sots::addr::RNG_Seed); + InstallTemplateHook(tracer, exeBase, sots::addr::StrategyApp_RunAI); // Per-call-site attribution: detour the SEVEN generator entry points and record // __builtin_return_address(0) with the word cost of each call. These are NOT template hooks -- diff --git a/src/shim/shim.cfg.l1control b/src/shim/shim.cfg.l1control new file mode 100644 index 0000000..aedb766 --- /dev/null +++ b/src/shim/shim.cfg.l1control @@ -0,0 +1,31 @@ +# 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 +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off diff --git a/src/shim/shim.cfg.l1hive b/src/shim/shim.cfg.l1hive new file mode 100644 index 0000000..cd1cc48 --- /dev/null +++ b/src/shim/shim.cfg.l1hive @@ -0,0 +1,54 @@ +# 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 +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off + +# The generator is 0x9cc bytes and every record carries it twice; `describe` already reduces it to +# {left, index, block, words, block_hash}. +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..fb9b85f --- /dev/null +++ b/src/shim/shim.cfg.l1off @@ -0,0 +1,54 @@ +# 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 +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off diff --git a/src/shim/shim.cfg.l1seed b/src/shim/shim.cfg.l1seed new file mode 100644 index 0000000..41cb98f --- /dev/null +++ b/src/shim/shim.cfg.l1seed @@ -0,0 +1,47 @@ +# Lane L1 -- the AI seed probe. Copy over C:\SOTS\shim.cfg, launch, LOAD `l1-turn1.sav`, and STOP. +# No End Turn is needed: the AI clients and their generators are built on load. +# +# The result is the diff between two launches' ordered (rng, seed) sequences. Identical sequences +# rule the seed out as the source of the turn1->turn2 nondeterminism +# (findings/subsystems/turn1-to-turn2-nondeterminism.md) and leave the ordering hypothesis. +# +# `hooks=trace` with everything named turned off, rather than `hooks=off` with two overrides, +# because that is the pattern every other config in this tree uses and is known to open the log. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off + +hook.Mars::RNG::Seed=trace +hook.Game::StrategyApp::RunAI=trace + +fpu.sample_turn=off +fpu.sample_ticks=off +watch=off +probes=off +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always diff --git a/src/shim/shim.cfg.l1snlv b/src/shim/shim.cfg.l1snlv new file mode 100644 index 0000000..1596bef --- /dev/null +++ b/src/shim/shim.cfg.l1snlv @@ -0,0 +1,31 @@ +# 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 +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off