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.
37 KiB
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, and0 undeclared write(s) in 0 call(s). Every completion in every measured run produces undeclared writes on theplayerandtree_headerguards, because that is whereOnTechResearched's tech effects and the tree's order counter land. Zero undeclared writes is zero completions.verify/results/shim/unlock-shim.log: the first threeresearch: cascade ok=1 …lines of the five-turn continuation readcompletions=0 unlocked=0 otch_appends=0. Those three lines are theref-turn2End 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 floats.
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, floor0.25) —oursnever evaluates it. It calls the game's own read-onlyTechTree::Costfor 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/Ceilingare integer×50/100and×150/100with 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 predictrng.leftis unchanged at 413 across all three calls and themtdigest does not move on any of them.ratio = (float)(7500/5000) = 1.5, and1.5 < 0.800000011920929is false → not completed early →flagunchanged.state 3 → 4,turn_researched −1 → 4,order −1 → 22, and the tree's order counter22 → 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:
Player[32]tech tree: node 144turn_researchedandorderunstamped (−1/−1); nodes 132, 136 and 142 unchanged atstate 0,cost_rp INT_MAX,turn_available −1.- the tree's completion-order counter left at 22.
Player[32]events:next_idshort by 2, two event records absent.Player[32]otch: oneObservedTechelement absent (44 bytes).Player[32]scalar fields at+0x10c,+0x110,+0x114,+0x124unchanged, andResTNmstill readingIND_WaldobecauseResTwas 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, defaultoff. With it off the binary behaves exactly asmaindoes today, so P4 measures the shipped behaviour rather than a straw man. With it on, a replace-mode call also writes the fourTechNodewordsSetResearchedstamps and the tree's completion-order counter. It does not post events, does not move theObservedTechvector 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),overbudgetand 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_inwas false on the completing call, soRollResearchEventdrew 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 (flagalready 2 from turn 3);RollResearchEvent's draw and, behind it, the plague / AI-rebellion paths;SetResearched's zero-cost recursion; thedef+0xb0NoAutoAvailableskip; 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 otherAvailablenode 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::Costis the original's.ourscalls the game's read-onlyCostfor 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:
- The research pass and the entire
SetResearchedcascade 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. - The blocking boundary is named and measured, not guessed:
ServerPlayer::OnTechResearched, 5 player fields + 1ObservedTechelement + 2 events, and 5 derived leaves behind them.ProcessResearchcannot reachreplacedon any workload containing a completion untilOnTechResearchedis displaced — and that is B2's milestone and its owncomparedboard row, not a defect in the research model. - 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:
("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.