Replace mode was tried live on a turn that actually completes a tech, with a two-process hooks=off oracle established first on that exact (save, procedure, route). Verdict: game/sim/research stays compared. What displaced: all 13 tech-tree leaves the turn moves -- 2 from the pass itself and 11 from the SetResearched cascade -- produced by our code in live game memory, with the original's ProcessResearch never executing. What did not: 16 leaves, every one written by ServerPlayer::OnTechResearched. Five player tech-effect fields (OutMod, ConMod[0..2], ResTNm), one ObservedTech element, two event records plus EvNxID, and five derived leaves behind them. Also: ref-turn2 + one End Turn does NOT complete a tech, so every research oracle before this one was taken on a quiet turn; and a config that names all 27 registered hooks off and passes check_shim_configs.py still installs six detours, because the M0 stub and the FPU module's four sampling detours have no hook. key.
553 lines
37 KiB
Markdown
553 lines
37 KiB
Markdown
# Taking `TechTree::ProcessResearch` + the unlock cascade from `compare` to `replace` (lane CR)
|
||
|
||
**Lane CR, 2026-09-09. Guest: VM145 (`sots-re-win10-145`, `re@192.168.10.145`) — held by this lane.**
|
||
VM140 is untouched (it is the exclusive reference guest).
|
||
|
||
The board's headline metric is `tools/displacement.py`: **3 replaced / 7 compared / 7 modelled /
|
||
3 mapped**. `TechTree::ProcessResearch + unlock cascade` is the best-evidenced `compared` row — 35
|
||
calls, three workloads, 0 divergences, `tracecmp` exit 0, and an advance prediction that held on a
|
||
*changed* workload. This lane asks the only question that moves the metric: **can our code run
|
||
INSTEAD of the original's, live, with a byte-level oracle holding afterwards?**
|
||
|
||
Everything above the `## 4.` heading was committed **before** the shim was built or staged
|
||
(rule 2). Nothing above that line is edited afterwards; corrections are made below it and named as
|
||
corrections (rule 11).
|
||
|
||
---
|
||
|
||
## 1. The state, and why this one
|
||
|
||
### 1.1 The candidate the brief names, and why it is not sufficient on its own
|
||
|
||
`ref-turn2.sav` (`ab4ac2d7…`, = `verify/results/saves/turn2-state.sav`) is the campaign's oldest and
|
||
most reproduced oracle: one End Turn by the **load** route, `(Autosave EndTurn)` `bb4fd9ac…` /
|
||
`(Autosave)` `978041ac…`, **5 + 1 processes**, certified-pairs row 1.
|
||
|
||
**It does not exercise a research completion.** This is not an inference from the exit code — it is
|
||
read off lane U's own instruments, both of which are in this repo:
|
||
|
||
* `verify/results/compare/unlock-b3-t1.md`: 3 calls, 3 compared, 0 diverged, and
|
||
**`0 undeclared write(s) in 0 call(s)`**. Every completion in every measured run produces
|
||
undeclared writes on the `player` and `tree_header` guards, because that is where
|
||
`OnTechResearched`'s tech effects and the tree's order counter land. Zero undeclared writes is
|
||
zero completions.
|
||
* `verify/results/shim/unlock-shim.log`: the first three `research: cascade ok=1 …` lines of the
|
||
five-turn continuation read `completions=0 unlocked=0 otch_appends=0`. Those three lines *are*
|
||
the `ref-turn2` End Turn.
|
||
|
||
Reading the trace rather than the verdict (rule 1) says what that turn *does* do: call 0 advances
|
||
the generator by one word (`left` 432 → 431), pushes node 144's progress 2879 → 5768, sets its flag
|
||
1 → 2 (`OverBudgetNotified`) and posts one event (`next_id` 3 → 4). Calls 1 and 2 change **nothing
|
||
at all**. So `ref-turn2` + one End Turn is a real workload for the *pass* and an empty one for the
|
||
*completion path*. **A replace-mode oracle taken there would be exactly the green verdict on a hook
|
||
that compared nothing that rule 1 exists to catch.**
|
||
|
||
### 1.2 The state this lane uses
|
||
|
||
**`verify/results/saves/turn3-state.sav`** — 67,219 B, sha256
|
||
`978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921`, `Summary.Turn = 3`,
|
||
`Sim.ModCount = 24`. It is *the output of certified-pairs row 1*: the `(Autosave).sav` that
|
||
`ref-turn2` + one End Turn produces. It is already on VM145 under that hash as `(Autosave).sav`.
|
||
|
||
**Procedure: one End Turn. Route: LOAD.** Stated because the route is part of the pair
|
||
(rule 26(c) fourth term; lane BS): the same turn reached by continuation and by load is two
|
||
different, individually reproducible things, and this lane deliberately does **not** inherit the
|
||
continuation's turn-4 numbers as an assumption — it predicts them and lets the load route falsify
|
||
them (P1).
|
||
|
||
Why this turn: in lane U's five-turn continuation the **fourth-turn** call is the completing one.
|
||
The call that enters with `turn = 4` allocates 2,898 points to tech 144, completes it, and runs the
|
||
cascade — `completions=1 unlocked=3 otch_appends=1` in the shim log, `next_id` 5 → 7 and
|
||
`observed_techs` 440 → 484 bytes in the trace. `turn3-state.sav` is the state that turn starts from.
|
||
|
||
### 1.3 The exposure screen (rule 26(c)) — a screen, not a decision procedure
|
||
|
||
Read from `turn3-state.sav` with `verify/save-reader/save_reader.py`. Eight players; per lane BP the
|
||
engine builds AI clients for net ids **32, 496, 512** only, and the four factions at 528–576 carry
|
||
their signature **inertly**.
|
||
|
||
| player | species | `ResTNm` | `Status` | AI client | pick-turn signature? |
|
||
|---|---|---|---|---|---|
|
||
| 16 (human `re`) | 0 | `''` | 4 | **no** (local human) | **false positive** — no client |
|
||
| 32 | 2 | `IND_Waldo` | 4 | yes | **no** — mid-research |
|
||
| 496 | 0 | `DRV_PlsFiss` | 4 | yes | **no** — mid-research |
|
||
| 512 | 2 | `BIO_GnMod` | 4 | yes | **no** — mid-research |
|
||
| 528, 544, 560, 576 | 4 | `''` | 0 | **no** | **false positive** — no client, `Status 0` |
|
||
|
||
**No player that actually runs enters this turn with an empty research target.** That is the best
|
||
the screen can say. It cannot say the control will agree — rule 26(c)'s own retraction is explicit
|
||
that `candidatesTried` is not readable from a save, that there is a combat term, and that the load
|
||
route is a fourth term that is not a predicate on the save at all. **So the screen is why this turn
|
||
is worth trying, and the two-process control in §4.1 is the only thing that decides it.**
|
||
|
||
The lineage where exposure is saturated (`ad-…`/`ar-…`, every turn carrying a signature) is
|
||
deliberately not used here.
|
||
|
||
### 1.4 What is being asked of `hooks=off`
|
||
|
||
Three fresh-process runs are planned before any replace result is read:
|
||
|
||
| run | config | what it establishes |
|
||
|---|---|---|
|
||
| **C1**, **C2** | `shim.cfg.croff` (`hooks=off`, the proxy loads and installs nothing) | the control agrees with itself across two fresh processes (rule 26) |
|
||
| **N** | `shim.cfg.crcompare` (one detour, `compare`) | the instrument is behaviour-neutral on *this* save and route (rule 19), **and** it is where the completion count comes from the hook itself |
|
||
|
||
Only then the replace runs. C1/C2 use the *same proxy DLL* as every measured run, so the only
|
||
difference between control and measurement is the config (lane CB's shape).
|
||
|
||
---
|
||
|
||
## 2. The four bytes of every float literal on this path (rule 23)
|
||
|
||
Two float literals are on the research pass's arithmetic path, and **both are widened `float`s**.
|
||
Read here directly out of `dumps/sots.exe` (PE image base `0x00400000`, `.rdata` at `0x009dd000`
|
||
file offset `0x5dbe00`), not taken from an earlier note:
|
||
|
||
| VA | bytes (LE) | value as `double` | is it `(double)float`? | engine constant |
|
||
|---|---|---|---|---|
|
||
| `0x009e20c8` | `00 00 00 a0 99 99 e9 3f` | `0.80000001192092896` | **yes** (low 29 bits zero) | `kEarlyCompletionRatio = 0.800000011920929` |
|
||
| `0x009e5060` | `00 00 00 a0 99 99 a9 3f` | `0.05000000074505806` | **yes** | `kDecayFraction = 0.05000000074505806` |
|
||
|
||
Both source literals were re-parsed and re-packed: `0.800000011920929` → `000000a09999e93f` and
|
||
`0.05000000074505806` → `000000a09999a93f`. **Bit-identical to the image.** So the two constants
|
||
`sots-engine/src/game/sim/research.cpp` relies on are exactly the image's, not the exact decimals
|
||
that bit the money chain twice.
|
||
|
||
Two literals that are **not** on this path, and why:
|
||
|
||
* the tech-cost multiplier (`1.0 − 0.25·n`, floor `0.25`) — `ours` never evaluates it. It calls the
|
||
game's own read-only `TechTree::Cost` for every cost it needs, in both modes (`g_env.cost`). This
|
||
is a genuine, declared dependency on the original: the effective cost is **not** displaced.
|
||
* `ResearchSpendFloor` / `Ceiling` are integer `×50/100` and `×150/100` with a 32-bit wrapping
|
||
multiply — no float involved.
|
||
|
||
---
|
||
|
||
## 3. Predictions, committed before the build
|
||
|
||
Falsification symptoms are given for each. Predictions are made **per site** and not at a bracket
|
||
total (rule 23's 2026-09-09 corollary).
|
||
|
||
### P0 — the control agrees with itself
|
||
`turn3-state.sav`, one End Turn, load route, `hooks=off`, **two fresh processes** produce identical
|
||
`(Autosave EndTurn).sav` and `(Autosave).sav`.
|
||
*Falsified if:* the two processes differ. Then this workload is exposed, the screen in §1.3 was
|
||
insufficient (which is what rule 26(c)'s retraction predicts is possible), and the honest move is to
|
||
report that and pin the source of variation — **not** to pick the run that suits.
|
||
|
||
### P1 — the turn is not quiet, and the load route reproduces the continuation's turn-4 call
|
||
Exactly **3** `ProcessResearch` calls, one per AI client, in the same owner order. Allocations:
|
||
**A → {tech 144, 2898 points}**, **B → {tech 90, 0 points}**, **C → {tech 9, 0 points}**. Exactly
|
||
**one completion** (tech 144) and the cascade unlocks exactly **three** nodes:
|
||
|
||
| node | `state` | `cost_rp` | `turn_available` |
|
||
|---|---|---|---|
|
||
| 132 | 0 → 2 | `INT_MAX` → **10000** | −1 → **4** |
|
||
| 136 | 0 → 2 | `INT_MAX` → **16000** | −1 → **4** |
|
||
| 142 | 0 → 2 | `INT_MAX` → **8000** | −1 → **4** |
|
||
|
||
*Falsified if:* a different allocation, a different completion count, or a different unlock set. That
|
||
would be a **route** result — the load route not reproducing the continuation — and it is worth as
|
||
much as the replace result. It is exactly what rule 26(c)'s fourth term says can happen.
|
||
|
||
### P2 — the arithmetic, hand-computed on the boundary
|
||
For node 144: `cost = 5000`, `lo = ResearchSpendFloor = 2500`, `hi = ResearchSpendCeiling = 7500`.
|
||
Entering progress **5768**, allocation **2898**:
|
||
|
||
* `spent = min(2898, 7500 − 5768) = 1732` — the cap binds, so this call lands **exactly on the
|
||
ceiling**, which is the boundary rule 23 says to test by hand rather than trust a compare on.
|
||
* `progress 5768 → 7500`, `overbudget 0 → 1166`.
|
||
* `progress (7500) < hi (7500)` is **false**, so the odds/roll branch is skipped: **zero RNG draws**.
|
||
I predict `rng.left` is **unchanged at 413** across all three calls and the `mt` digest does not
|
||
move on any of them.
|
||
* `ratio = (float)(7500/5000) = 1.5`, and `1.5 < 0.800000011920929` is false → **not** completed
|
||
early → `flag` **unchanged**.
|
||
* `state 3 → 4`, `turn_researched −1 → 4`, `order −1 → 22`, and the tree's order counter
|
||
`22 → 23`.
|
||
|
||
*Falsified if:* any single one of these moves. A wrong `hi` shows up as a wrong `overbudget`; a
|
||
wrong early-completion literal shows up as `flag` moving.
|
||
|
||
### P3 — the instrument is neutral, and the completion is counted by the instrument
|
||
Run N (`crcompare`, one detour) produces autosaves **byte-identical to C1/C2**, and `tracecmp`
|
||
reports **3 calls / 3 compared / 0 diverged / exit 0**. The hook's own per-call log line reads
|
||
`completions=1 … unlocked=3 otch_appends=1` on the completing call and all-zero on the other two.
|
||
Undeclared writes: **6 spans in 1 call** —
|
||
`player +0x10c/3`, `player +0x110/3`, `player +0x114/3`, `player +0x124/3`, `player +0x294/4`
|
||
(`ResT`, the research target being cleared) and `tree_header +0x20/1` (the order counter).
|
||
|
||
*Falsified if:* the autosaves move (then rule 19 bites and nothing below is readable), or the
|
||
undeclared-write set differs (then the workload or the model moved).
|
||
|
||
### P4 — replace with the cascade OFF (`crreplace0`, the shipped behaviour) diverges
|
||
The autosave is **not** byte-identical. Predicted diverging leaves, by name:
|
||
|
||
1. `Player[32]` tech tree: node 144 `turn_researched` and `order` unstamped (−1/−1); nodes 132, 136
|
||
and 142 unchanged at `state 0`, `cost_rp INT_MAX`, `turn_available −1`.
|
||
2. the tree's completion-order counter left at 22.
|
||
3. `Player[32]` events: `next_id` short by **2**, two event records absent.
|
||
4. `Player[32]` `otch`: one `ObservedTech` element absent (**44 bytes**).
|
||
5. `Player[32]` scalar fields at `+0x10c`, `+0x110`, `+0x114`, `+0x124` unchanged, and `ResTNm` still
|
||
reading `IND_Waldo` because `ResT` was never cleared.
|
||
|
||
What **will** be right even here: node 144's `state = 4`, `progress = 7500`, `flag`, the decay sweep
|
||
over every other node, and the `overbudget` accumulator.
|
||
|
||
### P5 — replace with the cascade ON (`crreplace1`) diverges, by strictly less
|
||
Same binary, one config line different (`research.replace_cascade=on`; see §3.1). Predicted:
|
||
**strictly fewer diverging leaves than P4**, with items 1 and 2 of P4 **closed** — node 144 stamped
|
||
`turn_researched 4` / `order 22`, nodes 132/136/142 at `state 2` with `cost_rp` 10000/16000/8000 and
|
||
`turn_available 4`, counter at 23 — and items 3, 4 and 5 **still open**.
|
||
|
||
### P6 — the headline, stated in advance
|
||
**`game/sim/research` does NOT move from `compared` to `replaced` this session, and the named cause
|
||
is `ServerPlayer::OnTechResearched`, not the research model.** The research pass itself — the spend
|
||
cap, the odds branch, the completion, the decay sweep, and (with the flag on) the whole
|
||
`SetResearched` cascade — is displaceable and I expect it to reproduce the original's TechTree state
|
||
exactly. What blocks the oracle is the *callback*: it posts events whose text comes from the game's
|
||
string table, appends an `ObservedTech` element `ours` decides but does not construct, and writes
|
||
~90 `ServerPlayer` fields that are a different subsystem's milestone (B2) and a separate `compared`
|
||
row on the board. A replace of the research pass is **gated on displacing `OnTechResearched`**, and
|
||
that is a statement about a boundary, not about the research model.
|
||
|
||
*Falsified if:* `crreplace1`'s autosave is byte-identical to the oracle. That would mean the
|
||
callback's writes do not reach the save on this workload, P6 is wrong, and the row moves to
|
||
`replaced` — which is the outcome I would rather have and do not expect.
|
||
|
||
### P7 — the numbers I expect to be able to quote honestly afterwards
|
||
**1** distinct completion, **1** distinct tech (144), **3** unlocked nodes, **3** calls of which
|
||
**2** allocate zero points and write nothing at all. That is thin, and it will be reported as
|
||
thin (rule 15/23) whatever the verdict.
|
||
|
||
### 3.1 The one engine change this lane makes, and why it is not a thumb on the scale
|
||
|
||
`sots-engine` worktree `wip/cr` (rule 21), branched from `main` at `e7e2bd6`.
|
||
|
||
* **`research.replace_cascade=on|off`, default `off`.** With it off the binary behaves *exactly* as
|
||
`main` does today, so P4 measures the shipped behaviour rather than a straw man. With it on, a
|
||
**replace**-mode call also writes the four `TechNode` words `SetResearched` stamps and the tree's
|
||
completion-order counter. It does **not** post events, does **not** move the `ObservedTech` vector
|
||
and does **not** apply a single tech effect — those stay gated on compare mode and stay declared
|
||
unmodelled. The flag exists so P4 and P5 differ by a **config line and not by a binary**, which is
|
||
what makes the leaf difference between their autosaves attributable to the cascade.
|
||
* **A per-call log line in every mode.** The old line was gated on the cascade having run, so a
|
||
replace run with the cascade off had no counter at all and "a completion happened" could only be
|
||
inferred from the save — the artefact under test. It now prints `steps`, `completions` (counted
|
||
from the pass's own step results), `overbudget` and the cascade counters, in compare **and**
|
||
replace. This is the instrument answering the brief's "show it fired — a count, from the hook
|
||
itself".
|
||
|
||
No new binary facts: `ghidra/addresses.d/cr.json` is **not** created, because this lane reads no new
|
||
address. Every offset used already exists in the generated header. Host build: 253 targets, **59/59
|
||
tests pass** (the denominator is the healthy 59, per rule 24's signature check); `clean_room_check`
|
||
OK; `tools/check_shim_configs.py` OK with 27 registered hooks and all four CR configs
|
||
(`croff`, `crcompare`, `crreplace0`, `crreplace1`) — the three `hooks=trace` ones marked
|
||
`# exhaustive` and naming all 27.
|
||
|
||
---
|
||
|
||
## 4. Results
|
||
|
||
*(added after the runs; nothing above this line is edited)*
|
||
|
||
### 4.0 The five runs, in order
|
||
|
||
All five on **VM145**, held by this lane, each a **fresh process**, `SavedGames` reset to exactly
|
||
`turn3-state.sav` before every launch, `C:\SOTS\shimdist-cr\binkw32.dll`
|
||
(25,274,723 B, sha256 `d2ad56b32c1b5f6b…`, `BUILD_ID cr-618ccb1-20260909T131556Z`, exports 66/66
|
||
identical to the real `binkw32.dll`), one config per run and nothing else changed. Every screen —
|
||
main menu, Load-Game chooser, the one-row file list, the **lobby**, the loaded map at "Turn 3", the
|
||
post-turn map at "Turn 4" — was verified from a live `qm monitor` screendump before the next click.
|
||
No run was driven by sleeping.
|
||
|
||
| run | config | mode | `research.replace_cascade` | `(Autosave EndTurn).sav` | `(Autosave).sav` |
|
||
|---|---|---|---|---|---|
|
||
| **C1** | `croff` | `hooks=off` | – | 67,212 `e00eed0c…` | 67,811 `79df5047…` |
|
||
| **C2** | `croff` | `hooks=off` | – | 67,212 **`e00eed0c…`** | 67,811 **`79df5047…`** |
|
||
| **N** | `crcompare` | compare | off | 67,212 **`e00eed0c…`** | 67,811 **`79df5047…`** |
|
||
| **R0** | `crreplace0` | **replace** | off | 67,212 `e00eed0c…` | 67,511 **`6b51db99…`** |
|
||
| **R1** | `crreplace1` | **replace** | **on** | 67,212 `e00eed0c…` | 67,537 **`8a4309ee…`** |
|
||
|
||
`(Autosave EndTurn).sav` is the *pre*-turn resave and is identical in all five runs, as it must be —
|
||
nothing has run yet when it is written. The verdict is carried entirely by `(Autosave).sav`.
|
||
|
||
### 4.1 P0 held: the oracle
|
||
|
||
**C1 and C2 agree byte-for-byte in two fresh processes.** The pair, in the standing
|
||
`certified-pairs.md` format:
|
||
|
||
| input | procedure | route | `(Autosave EndTurn)` | `(Autosave)` | processes | evidence |
|
||
|---|---|---|---|---|---:|---|
|
||
| `turn3-state.sav` `978041ac…` | one End Turn | **load** | `e00eed0c…` | `79df5047…` | **3** | lane CR ×2 `hooks=off`, ×1 compare-instrumented |
|
||
|
||
**Masks that must be on the line.** Measured, not assumed —
|
||
`state_checksum.py turn3-state.sav <(Autosave EndTurn)>` gives **exactly 5 leaves**:
|
||
`/Summary/Checksum` and `Player.Status 4 → 0` on each of the four live players (16 `re`,
|
||
32 `Fane Lao`, 496 and 512 `Singularity`). **There is no `/CD[1]/NPrvVa` term on this state** —
|
||
`CD[1]`'s diplomacy block is early-game and the leaf does not move — so the `--mask resave` rule
|
||
holds here in the form the docs originally stated, and the lane BQ exception does not apply.
|
||
|
||
**Exposure facts next to the hashes** (certified-pairs standing rule 4): §1.3's table — no player
|
||
with an AI client enters the turn with `ResTNm == ''`; `NumDes` does not move; the two ships that
|
||
complete join existing fleets; the four factions at 528–576 carry the empty-`ResTNm` signature
|
||
inertly (`Status 0`, no client). The turn does create fleets (`Flt[50]`, `Flt[1808]`) and retire one
|
||
(`Flt[1776]`) — that is a fleet-assignment shape, and it agreed anyway. **Which is the point of
|
||
rule 26(c)'s retraction: the screen said "likely fine" and only the two-process control decided it.**
|
||
|
||
### 4.2 P3 held: the instrument is neutral, and it counts the completion itself
|
||
|
||
Run **N** reproduced the control's two hashes exactly, so rule 19 is satisfied on *this* save and
|
||
*this* route and everything below is read from runs that passed their own check.
|
||
`tracecmp verify/traces/cr-N.jsonl.gz`: **3 calls, 3 compared, 0 diverged, exit 0**, coverage verdict
|
||
`partial`, 8 unmodelled notes — and the undeclared-write set is **exactly the six spans predicted**:
|
||
|
||
```
|
||
player+0x10c:3 player+0x110:3 player+0x114:3 player+0x124:3 player+0x294:4 tree_header+0x20:1
|
||
```
|
||
|
||
The hook's own per-call line, which is the instrument saying a completion fired rather than the save
|
||
being asked to imply it:
|
||
|
||
```
|
||
research: mode=compare steps=1 completions=1 overbudget=1166 cascade_possible=1 ok=1
|
||
cascade_completions=1 unlocked=3 otch_appends=1 roll_draws=0 failures=0 depth=0
|
||
research: mode=compare steps=1 completions=0 … (×2, all zero)
|
||
```
|
||
|
||
**A correction to the campaign's read of the detour count, and it goes the other way from the
|
||
brief's warning.** My config names all **27** registered template hooks and
|
||
`check_shim_configs.py` passes it as `# exhaustive` — and the shim still installed **six** detours,
|
||
not one:
|
||
|
||
| detour | source | named by a `hook.` key? |
|
||
|---|---|---|
|
||
| `Mars::Application::Initialize` | the M0 asm stub, installed unconditionally whenever `hooks != off` | **no** |
|
||
| `Game::TechTree::ProcessResearch` | the one template hook | yes |
|
||
| `StrategyClient::EndTurn`, `StrategyServer::BeginProcessTurn`, `StrategyServer::ProcessTurn`, `DemoApp::OnTick` | the **FPU-force module**, which installs four *sampling* detours by default (`fpu: module init … force=off value=0x0000 sample_ticks=on`, `sample_turn=on`) | **no** |
|
||
|
||
`Shim::SelfTest::Fill` additionally emits one `trace` record at startup; it is an in-shim self-test,
|
||
not a detour on the game. So **`# exhaustive` is exhaustive over the template-hook set only**, and
|
||
`tools/check_shim_configs.py` cannot see the other five. It was the *neutrality check* (N identical
|
||
to C1/C2), not the config check, that made this run safe — which is worth saying plainly, because a
|
||
lane reading "exhaustive, therefore one detour" would be wrong by five.
|
||
|
||
For the record, `fpu_cw = 0x027f` (53-bit, round-to-nearest) in every CR run.
|
||
|
||
### 4.3 P1 and P2 held exactly, on the LOAD route
|
||
|
||
The load route reproduced the continuation's turn-4 call in every particular — allocations, the
|
||
completion, the unlock set, the arithmetic. From `cr-N.jsonl.gz` (compare) and confirmed identically
|
||
in `cr-R1.jsonl.gz` (replace):
|
||
|
||
| call | owner | species | alloc | what moved |
|
||
|---|---|---|---|---|
|
||
| 1 | `Player[32 "Fane Lao"]` | 2 | `{144, 2898}` | the completion (below) |
|
||
| 2 | `Player[496 "Singularity"]` | 0 | `{90, 0}` | **nothing at all** |
|
||
| 3 | `Player[512 "Singularity"]` | 2 | `{9, 0}` | **nothing at all** |
|
||
|
||
Call 1, node **144 `IND_Waldo`**: `state 3 → 4`, `progress 5768 → 7500`, `turn_researched −1 → 4`,
|
||
`order −1 → 22`; `overbudget 0 → 1166`; three nodes unlocked —
|
||
**132 `IND_OrbFound` @ 10000**, **136 `IND_RefCoat` @ 16000**, **142 `IND_TrkStl` @ 8000**, each
|
||
`state 0 → 2`, `cost_rp INT_MAX → …`, `turn_available −1 → 4`; `events.next_id 5 → 7`;
|
||
`observed_techs 440 → 484 bytes`.
|
||
|
||
P2's hand arithmetic is confirmed to the unit: `cost 5000`, `lo 2500`, `hi 7500`,
|
||
`spent = min(2898, 1732) = 1732`, `progress = 7500` **exactly on the ceiling**,
|
||
`overbudget = 2898 − 1732 = 1166`; `progress < hi` false so **no draw**; `ratio = 1.5`, not below
|
||
`0.800000011920929`, so **`flag` unchanged**. The `rng` region **did not move on any of the three
|
||
calls** in any run.
|
||
|
||
That last fact is a coverage hole, not a success — see §4.6.
|
||
|
||
### 4.4 P4 and P5 held: replace diverges, and the cascade closes exactly eleven leaves
|
||
|
||
`state_checksum.py cr-oracle-autosave.sav <replace autosave>`:
|
||
|
||
| run | diverging leaves | file size |
|
||
|---|---:|---|
|
||
| **R0** — replace, cascade **off** (the shipped behaviour) | **27** | 67,511 B |
|
||
| **R1** — replace, cascade **on** | **16** | 67,537 B |
|
||
| (the turn itself, `turn3-state` → oracle, for scale) | 128 | – |
|
||
|
||
**The eleven leaves the cascade closes — R0 has them, R1 does not.** All eleven are `TechTree`:
|
||
|
||
```
|
||
Player[32]/TechTree/St[94] TResCost[94] TUnlck[94] (node 132 IND_OrbFound, 10000)
|
||
Player[32]/TechTree/St[98] TResCost[98] TUnlck[98] (node 136 IND_RefCoat, 16000)
|
||
Player[32]/TechTree/St[104] TResCost[104] TUnlck[104] (node 142 IND_TrkStl, 8000)
|
||
Player[32]/TechTree/TAcq[106] TiAcq[106] (node 144, turn 4 / order 22)
|
||
```
|
||
|
||
The tree is serialised as parallel arrays indexed by **tree slot**, not tech id — slots 94/98/104/106
|
||
are tech ids 132/136/142/144 — and the pass's own two words, `St[106] 3 → 4` and
|
||
`TResDone[106] 5768 → 7500`, are correct in **both** replace runs because
|
||
`ProcessResearchTurn` writes them without the cascade. So of the **13 tech-tree leaves this turn
|
||
moves, our code produced all 13 in live memory with the original's `ProcessResearch` never
|
||
executing** — 2 from the pass, 11 from `SetResearched`.
|
||
|
||
**One prediction I cannot test and must retract as written.** P4 item 2 said the completion-order
|
||
counter would be "left at 22" in R0. `TechTree+0x20` **is not a save leaf** — the counter's value
|
||
surfaces only through the per-node `TiAcq` stamp — so the oracle cannot see it either way. What is
|
||
observable is the trace: R1's only guard hit is `tree_header+0x20:1` (ours writing 22 → 23, the same
|
||
byte the original moves in compare mode) and R0 has **0 undeclared writes in 0 calls**. The counter
|
||
matters for the *next* completion, not for this save.
|
||
|
||
### 4.5 P6 held: the residual is `ServerPlayer::OnTechResearched`, entirely
|
||
|
||
R1's **16** leaves, every one of them named, with nothing left over:
|
||
|
||
| leaf | what it is | modelled by `ours`? |
|
||
|---|---|---|
|
||
| `Player[32]/OutMod` `1.25 → 1.1` | a tech effect | no — B2's milestone |
|
||
| `Player[32]/ConMod[0..2]` `0.9 → 1.0` (×3) | tech effects | no — B2's milestone |
|
||
| `Player[32]/Events/EvNxID` `7 → 5` | the two events not posted | decision modelled, **write is compare-only** |
|
||
| `…/Events/.[EvTurn=4]/Events/.[EvEID=5]`, `.[EvEID=6]` `only-in-A` | the two event records | text comes from the game's string table |
|
||
| `…/Events/.[EvTurn=4]/Events/.[0]` `3 → 1` | that turn's event count | ditto |
|
||
| `Player[32]/otch/.[11]` `only-in-A`, `otch/.[0]` `11 → 10` | the `ObservedTech` element | append **decided** (`otch_appends=1`), element not constructed |
|
||
| `Player[32]/ResTNm` `'' → 'IND_Waldo'` | `ResT` never cleared | inside the callback |
|
||
| `Player[32]/BnkPr`, `BnkEl` | bankruptcy projection | **downstream of `OutMod`** |
|
||
| `Sys[288 "Ke'Dolarra"]/RepCur`, `RepMax` `421640 → 371040` | repair capacity | **downstream of `ConMod`** |
|
||
| `/Summary/Checksum` | derived | derived |
|
||
|
||
So the residual decomposes into **5 primary player fields** (`OutMod`, `ConMod[0..2]`, `ResTNm`),
|
||
**1 `ObservedTech` element**, **2 event records + their id counter**, and **5 derived leaves** that
|
||
follow from those. Every single one is written by `ServerPlayer::OnTechResearched`, none of them by
|
||
`TechTree::ProcessResearch` or by `SetResearched`.
|
||
|
||
That also closes the loop with §4.2's guard: the four `player` spans the compare reported as
|
||
undeclared (`+0x10c`, `+0x110`, `+0x114`, `+0x124`, all three bytes wide — float writes whose top
|
||
byte did not change) plus `+0x294` (`ResT`, four bytes) are **five** writes, and the save shows
|
||
**five** primary player fields. The guard was reporting exactly what the oracle later billed us for.
|
||
|
||
**The input class that breaks it is a completion, and only a completion.** Both replace runs are
|
||
byte-perfect on the two null calls and on every other leaf of the 128 the turn moves. A replace run
|
||
over a turn where research does not complete would be byte-identical — and would prove nothing
|
||
(rule 1), which is why this lane refused to run it on `ref-turn2`.
|
||
|
||
### 4.6 Coverage, reported as loudly as the result (rules 15 and 23)
|
||
|
||
* **1 completion. 1 distinct tech (144 `IND_Waldo`). 3 unlocked nodes. 3 calls.**
|
||
* **2 of the 3 calls allocate zero points and write nothing at all**, in any mode. Their entire
|
||
contribution to "3 calls, 0 diverged" is that two null calls stayed null.
|
||
* **1 of 4 tech trees is exercised.** Player 16's tree is never processed (`ResTNm == ''`); players
|
||
496 and 512 are the null calls.
|
||
* **The RNG region did not move on any call in any run.** The spend cap bound exactly, so the
|
||
odds/roll branch was skipped, and `roll_pending_in` was false on the completing call, so
|
||
`RollResearchEvent` drew nothing (`roll_draws=0`). **`region:rng` — the single strongest check in
|
||
this hook's compare — compared "unchanged against unchanged" on this workload and established
|
||
nothing.** The generator parity evidence for this module is entirely lane U's and lane V's,
|
||
on other turns.
|
||
* **Branches that did not execute here:** the Zuul double roll (species 2, not 5); the
|
||
completed-early flag (`ratio 1.5`); the over-budget notification (`flag` already 2 from turn 3);
|
||
`RollResearchEvent`'s draw and, behind it, the plague / AI-rebellion paths; `SetResearched`'s
|
||
zero-cost recursion; the `def+0xb0` `NoAutoAvailable` skip; an empty prerequisite group; a
|
||
re-observed tech (the dedup's negative case); and the decay sweep, which ran over every node and
|
||
**changed nothing** because no other `Available` node had non-zero progress.
|
||
* **The event model is count-only by construction** and stayed compare-only in replace mode by
|
||
design; the two missing records are two of the sixteen residual leaves.
|
||
* **`TechTree::Cost` is the original's.** `ours` calls the game's read-only `Cost` for every cost it
|
||
needs, in both modes. The effective-cost formula is **not** displaced, and any claim about this
|
||
module inherits that dependency.
|
||
|
||
### 4.7 Verdict
|
||
|
||
**No. `game/sim/research` does not move from `compared` to `replaced`.**
|
||
|
||
The bar is "our code ran instead of the original's **and a byte-level oracle held afterwards**". Our
|
||
code did run instead — `mode=replace`, `completions=1`, `unlocked=3`, the original's
|
||
`ProcessResearch` never executed, and the game finished the turn and wrote a save. The oracle did
|
||
**not** hold: 16 leaves in the best configuration. There is no qualified reading that rescues it,
|
||
because the failure is not a rounding residual — it is a set of writes nobody has implemented.
|
||
|
||
What the lane did establish, and it is worth more than the rung would have been:
|
||
|
||
1. **The research pass and the entire `SetResearched` cascade are displaceable and were displaced.**
|
||
All 13 tech-tree leaves the turn moves were produced by our code in live game memory, on a turn
|
||
with a real completion and a real three-node unlock cascade — which is a strictly stronger
|
||
statement than the 35 compared calls the board already carried, because in a compare the
|
||
original's code still did the work.
|
||
2. **The blocking boundary is named and measured, not guessed:** `ServerPlayer::OnTechResearched`,
|
||
5 player fields + 1 `ObservedTech` element + 2 events, and 5 derived leaves behind them.
|
||
`ProcessResearch` **cannot** reach `replaced` on any workload containing a completion until
|
||
`OnTechResearched` is displaced — and that is B2's milestone and its own `compared` board row,
|
||
not a defect in the research model.
|
||
3. **A new certified pair** on a turn that exercises the completion path, which the campaign did not
|
||
have: `ref-turn2` + one End Turn is a *quiet* turn for research, and every oracle the module had
|
||
been checked against was that one.
|
||
|
||
The cheapest route to the rung, now that the boundary is priced: implement the ~90-field
|
||
`ApplyTechEffect` write-back live (B2 already has `game/effects/tech_effects` host-tested), construct
|
||
the `ObservedTech` element, and decide what to do about the two event records — whose *text* comes
|
||
from the game's string table and therefore cannot be produced clean-room at all. **The event text is
|
||
a hard stop for a byte-identical oracle on any completion turn**, and that should be settled as a
|
||
policy question (call the game's `PostEvent`, and accept the `ComputeBudget`-shaped QUALIFIED
|
||
caveat) before anyone spends another lane on it.
|
||
|
||
---
|
||
|
||
## 5. Proposed board rows
|
||
|
||
**I have not edited `campaign/board.md`.** I *have* added the certified pair to
|
||
`verify/results/saves/certified-pairs.md`, which the brief pointed at as the standing format and
|
||
whose four standing rules for adding a row are all satisfied (two fresh `hooks=off` processes; the
|
||
control run before anything was read from an instrumented run; not an extension of an existing pair
|
||
but its own agreement; exposure facts recorded beside the hashes).
|
||
|
||
### 5.1 `tools/displacement.py` — the rung does NOT move
|
||
|
||
The verdict is **no**, so `"compared"` stays. What I do propose is replacing the evidence and caveat
|
||
strings, which currently understate what is known and do not name the gate:
|
||
|
||
```python
|
||
("TechTree::ProcessResearch + unlock cascade", "compared",
|
||
"35 calls across 3 workloads, 0 divergences, tracecmp exit 0; advance prediction held on a "
|
||
"changed workload (unlock costs no earlier report contained); REPLACE ATTEMPTED live (lane "
|
||
"CR): ours ran instead of the original on a real completion and produced all 13 tech-tree "
|
||
"leaves the turn moves, but the save oracle missed by 16 leaves",
|
||
"compare only. The replace attempt failed on ServerPlayer::OnTechResearched, not on the "
|
||
"research model: 5 player tech-effect fields, 1 ObservedTech element and 2 event records, "
|
||
"plus 5 derived leaves. Gated on B2. Thin: 1 completion, 1 tech, 2 of 3 calls allocate zero "
|
||
"points, and the RNG region did not move at all on the replace workload"),
|
||
```
|
||
|
||
### 5.2 Board rows to add
|
||
|
||
| row | class | status | conf | cov | date | evidence |
|
||
|---|---|---|---|---|---|---|
|
||
| **`ProcessResearch` replace: our code ran instead of the original's, and the oracle missed by 16 leaves — all of them `OnTechResearched`'s** | phase2 | verified | high | 95% | 2026-09-09 | **Lane CR, VM145**, `findings/subsystems/research-replace.md`. Predictions committed before the build (`sots-re` 4b3cc82); engine `wip/cr` 618ccb1 adds `research.replace_cascade=on\|off` (default off) so the shipped and extended behaviours differ by **a config line, not a binary**. New certified pair `turn3-state.sav` → one End Turn, **load** route, `e00eed0c…`/`79df5047…`, 2 `hooks=off` processes + 1 compare. Compare run **3/3/0 exit 0** and byte-identical to the control (rule 19 satisfied), undeclared writes exactly the 6 predicted spans. **Replace, cascade on: 16 diverging leaves; cascade off: 27.** The 11-leaf delta is the whole `SetResearched` cascade and it is **ours**; with the pass's own 2 words that is **13 of 13 tech-tree leaves the turn moves, produced live by our code**. The 16 residual leaves are `OutMod`, `ConMod[0..2]`, `ResTNm`, one `ObservedTech` element, two event records + `EvNxID`, and 5 derived. **Verdict: stays `compared`.** |
|
||
| **`ref-turn2` + one End Turn does NOT exercise a research completion** | verify | verified | high | 100% | 2026-09-09 | Lane CR. The campaign's most-reproduced oracle is a **quiet turn for the completion path**: `unlock-b3-t1.md` reports `0 undeclared write(s) in 0 call(s)` and `unlock-shim.log`'s first three lines read `completions=0`. It *is* a real workload for the pass (one RNG word, `flag 1 → 2`, one over-budget event) — but a replace-mode oracle taken there would be rule 1's green verdict on a hook comparing nothing. The completing turn is the **next** one, from `turn3-state.sav`. |
|
||
| **`# exhaustive` is exhaustive over the 27 template hooks only — six detours are installed, not one** | phase2 | verified | high | 100% | 2026-09-09 | Lane CR. A config naming all 27 registered hooks `off` except one, passing `tools/check_shim_configs.py`, still installs **6** detours: the M0 `Application::Initialize` asm stub (unconditional whenever `hooks != off`) and the **FPU-force module's four sampling detours** (`force=off value=0x0000 sample_ticks=on sample_turn=on`, on by default). `check_shim_configs.py` cannot see either group — no `hook.` key names them. What made lane CR's runs safe was the **neutrality check** (compare run byte-identical to two `hooks=off` controls), not the config check. A lane reading "exhaustive, therefore one detour" is wrong by five. |
|
||
| **A replace of `ProcessResearch` is gated on `ServerPlayer::OnTechResearched`, and partly on a policy question** | phase2 | open | high | – | 2026-09-09 | Lane CR. To reach `replaced`, three things are needed: the ~90-field tech-effect write-back applied live (B2 has `game/effects/tech_effects` host-tested), the `ObservedTech` element constructed (`ours` already decides the append), and the two research event **records** written. The third is not an implementation gap: their `EvDsc`/`EvMsg` text comes from the game's string table, which the engine must not carry, so a byte-identical oracle on any completion turn requires calling the game's own `PostEvent` and accepting a `ComputeBudget`-shaped **QUALIFIED** `replaced`. Settle that before spending a lane. |
|
||
|
||
### 5.3 Artefacts
|
||
|
||
| what | where |
|
||
|---|---|
|
||
| predictions commit (before the build) | `sots-re` `4b3cc82` |
|
||
| engine change | `sots-engine` worktree `wip/cr`, `618ccb1` — `research.replace_cascade`, a mode-independent completion counter, four `shim.cfg.cr*` configs |
|
||
| oracle + replace saves | `verify/results/saves/cr/cr-{oracle-endturn,oracle-autosave,replace0-autosave,replace1-autosave}.sav` |
|
||
| traces | `verify/traces/cr-{N,R0,R1}.jsonl.gz` |
|
||
| `tracecmp` reports | `verify/results/compare/cr-{compare,replace0,replace1}.{md,json}` |
|
||
| shim logs (the per-call counters) | `verify/results/shim/cr/cr-{N,R0,R1}.log` |
|
||
| certified pair | `verify/results/saves/certified-pairs.md`, row 4 |
|
||
|
||
No `ghidra/addresses.d/cr.json`: this lane read no new address. No game data, save, disassembly or
|
||
`FUN_xxxxxxxx` name reached `sots-engine`; `tools/clean_room_check.sh` OK.
|
||
|
||
### 5.4 VM145 released
|
||
|
||
Held for this lane only; **VM140 untouched**. Restored and verified byte-for-byte to as-found:
|
||
|
||
```
|
||
binkw32.dll 15,527,327 B 903527F4A698EEA9FBE25F3A6236657C7D8348F994EEF394696F187E5E554B97 (= shimdist-recap)
|
||
shim.cfg 0AE410CC72FE155837D711C78543E14B1F5743310CD023298EB1E5BBD5457E71 (= shimdist-recap\shim.cfg.recaptrace)
|
||
SavedGames the same 9 files, every size and sha256 identical to the as-found survey
|
||
game not running (as found)
|
||
```
|
||
|
||
Left behind: `C:\SOTS\shimdist-cr\`, `C:\SOTS\ui\cr\` (the input save, the four run outputs and the
|
||
restore set) and `C:\SOTS\ui\cr{deploy,click,grab,snap,restore,find}.ps1`. `click_helper.ps1` is
|
||
unmodified. **One thing not restored:** my deploy helper *deletes* `C:\SOTS\shim.log` rather than
|
||
rotating it to `.prev` the way lane BQ's does, so whatever log was there when I took the guest is
|
||
gone. Logs are not simulation state and no measurement depends on it, but the next lane should
|
||
rotate rather than delete.
|