251 lines
15 KiB
Markdown
251 lines
15 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)*
|