merge lane L1 (header regenerated from addresses.json + fragments, not hand-resolved)
This commit is contained in:
commit
4f25f1e8c0
11 changed files with 1321 additions and 10 deletions
288
docs/L1-predictions.md
Normal file
288
docs/L1-predictions.md
Normal 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.
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 --
|
||||
|
|
|
|||
31
src/shim/shim.cfg.l1control
Normal file
31
src/shim/shim.cfg.l1control
Normal 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
54
src/shim/shim.cfg.l1hive
Normal 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
54
src/shim/shim.cfg.l1off
Normal 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
47
src/shim/shim.cfg.l1seed
Normal 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
31
src/shim/shim.cfg.l1snlv
Normal 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
|
||||
Loading…
Add table
Reference in a new issue