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