sots-re/findings/subsystems/research-replace.md

15 KiB
Raw Blame History

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 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, 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)