merge lane L1 (header regenerated from addresses.json + fragments, not hand-resolved)

This commit is contained in:
alex 2026-09-08 18:25:53 -04:00
commit 4f25f1e8c0
11 changed files with 1321 additions and 10 deletions

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

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

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 @ 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 <cstdint>
@ -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<Candidate>* 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]

View file

@ -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<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");
}
// ---- 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<const char*>(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<std::int32_t>(p, A::RNG_off_Left)));
return s;
}
} // namespace
void RngSeedHook::describe_args(std::vector<Tv>& 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<trace::Region>& 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<RngSeedHook>;
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<Tv>& 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<trace::Region>&, 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<StrategyAppRunAIHook>;
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

View file

@ -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<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);
};
// ---- 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<void*, std::uint32_t>;
static void describe_args(std::vector<trace::Tv>& out, void* self, std::uint32_t seed);
static trace::Tv describe_ret(void* r);
static void regions(std::vector<trace::Region>& 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<void*, std::int32_t, const char*, std::uint8_t, std::uint32_t>;
static void describe_args(std::vector<trace::Tv>& out, void* self, std::int32_t netId,
const char* customData, std::uint8_t personality,
std::uint32_t rngSeed);
static void regions(std::vector<trace::Region>& 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));

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,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<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);
// 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<shim::hooks::RngSeedHook>(tracer, exeBase, sots::addr::RNG_Seed);
InstallTemplateHook<shim::hooks::StrategyAppRunAIHook>(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 --

View file

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

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

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

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

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

47
src/shim/shim.cfg.l1seed Normal file
View file

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

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

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