# U — the unlock cascade, and the prediction for the run that checks it Lane P deliberately did **not** post `EVENT_TECHS_UNLOCKED`. It could have — the trigger was pinned — but evaluating it needs the cascade `ours` does not run, so it handed `PostResearchPassEvents` a **`nullptr`** unlock list ("no list", distinct from an empty one) and predicted the consequence: `next_id` short by exactly 1 on every completion call. Lane V measured that live, twice, exactly. This lane removes the residual by running the cascade, not by posting an event when something completes. The distinction matters for one reason and lane P named it: the shortcut would score perfectly on this save and be wrong the first time a completion unlocks nothing. **Everything in §4 was written before the build was staged on VM140.** §5 is the outcome. --- ## 1. What the cascade actually is Read out of the instruction stream by this lane: `TechTree::SetResearched` (0x00581e10, whole function), `TechTree::PrereqsMet` (0x0057d8e0, whole function), the tail collector inside `TechTree::ProcessResearch` (0x00587cc3), and the head of `ServerPlayer::OnTechResearched` (0x00891790). Details and the decompiled shape are in sots-re `findings/subsystems/setresearched-cascade.md`; the module is `src/game/sim/techgraph.{h,cpp}`. ``` SetResearched(tree, def, flags): n = tree->nodes[def->techId] if (!n) -> nothing (the flags&8 re-lookup path is not on the research path) if (n->state == 4) -> return true, writing NOTHING, not even the order stamp if (!(flags & 2) && !PrereqsMet(def + 0x88)) -> nothing n->state = 4 n->turnResearched = owner ? ModCount : 0 n->order = tree->orderCounter++ owner->OnTechResearched(def, (flags >> 2) & 1) # flags = 2 from ProcessResearch: NOT silent for each edge in n->children: # sweep 1 c = tree->nodes[edge->childDef->techId] if (c->state == 0) c->state = 1 c->costRP = min(c->costRP, edge->costRP) # signed, against an INT_MAX sentinel for each node m in tree->nodes: # sweep 2 if (!m || m->def->[0xb0]) continue p = tree->nodes[m->def->techId] if (p && p->state == 1 && PrereqsMet(p->def + 0x88)): m->state = 2 if (m->turnAvailable == -1 && owner) m->turnAvailable = ModCount if (m->state == 2 && Cost(m) == 0) SetResearched(m->def, flags) # recursion ``` Four properties are easy to get wrong and each is pinned by a host test: * **`turnAvailable` is sticky.** It is written only when it still reads −1. A node that returns to state 2 in a later turn keeps its *first* availability turn — which is the whole reason `EVENT_TECHS_UNLOCKED` can be computed from node state at all, and the reason a tech is never announced twice. * **The cost minimum is signed, against `INT_MAX`.** The first researched parent sets the child's cost; later parents can only lower it. * **`PrereqsMet` is an AND over groups, each group an OR over techs** — and a group with *zero* entries makes the whole test **fail**, because the inner loop cannot break and the outer one then exits with that group uncounted. A "vacuously true" reading gets this backwards. Zero groups, by contrast, is satisfied. * **Both sweeps and the collector re-resolve `tree->nodes[node->def->techId]`** and test *that* node's state, not the iterated node's. For a well-formed tree it comes back to the same node; it is reproduced because the code has it. `def+0xb0` is a byte that excludes a node from sweep 2 entirely. Its write site was **not** found (`MasterTechTree::ParseTech` shows no reference to 0xb0), so `addresses.d/lane-u.json` names it for what it *does* — `TechDef_off_NoAutoAvailable` — and records the `unlock_explicitly` tech-file keyword as a hypothesis, not a fact. Nothing in this lane depends on which it is: the byte is read from the live def either way. ### The tail collector ``` if (tree->owner) { turn = ModCount for each node n: if (n && n->def && (p = tree->nodes[n->def->techId]) && p->state == 2 && n->turnAvailable == turn) collect(n) if (!collected.empty()) PostEvent(EVENT_TECHS_UNLOCKED, ...) } ``` Note the asymmetry — the state test is on the self-resolved node, the turn test on the iterated one — and note that it runs **once, after the whole per-node loop and after the decay sweep**. ### The two halves of `OnTechResearched` this hook can see ``` OnTechResearched(player, def, silent): RecordObservedTech(...) # FIRST statement, unconditional if (player->ResT == def) { if (player->ResearchRollPending) RollResearchEvent(player) # exactly one NextFloat player->ResearchRollPending = 0 player->ResT = 0 } if (!silent) { ... EVENT_RESEARCH_COMPLETE / _UNDERBUDGET ... } ``` `RecordObservedTech` **de-duplicates by tech name**, so "the vector did not grow" is a real outcome and not a failure. `RollResearchEvent` draws one `NextFloat` unconditionally and only then tests it against the odds; clearing `ResT` is what makes a *second* completion in the same pass draw nothing. This is lane V's call-9 deviation, and it is now inside the compare rather than declared out of scope. --- ## 2. What changed | file | change | |---|---| | `src/game/sim/techgraph.{h,cpp}` (new) | the pure cascade: `TechPrereqsMet`, `SetResearched`, `CollectNewlyAvailable` | | `src/game/sim/research.{h,cpp}` | one optional parameter: a completion hook called *inside* the allocation loop, where the original calls `SetResearched` — before the next entry's roll and before the decay sweep | | `src/shim/hooks/research.{h,cpp}` | the pre-call scan the cascade needs, the live→graph transcription, the cascade wiring, four more node write-backs, the unlock list, the observed-tech append, the RNG draw, the rewritten coverage | | `tests/game_sim/test_techgraph.cpp` (new) | 13 cases, 92 checks | | `ghidra/addresses.d/lane-u.json` (sots-re) | 12 new entries; header regenerated 615 → 627 | `tools/clean_room_check.sh` — **OK**. Host `ctest` — **34/34** (was 33/33; `game_sim_techgraph` is new). Run as separate commands. The shim TU is syntax-checked on the host (`-std=c++17 -Wall -Wextra -fsyntax-only`, clean) and cross-built on CT111 before deploy. ### The four pre-call reads, and why each one is a trap `ours` runs **after** the original in compare mode. Four inputs the cascade needs are things the original changes during the call, and each of them reads back a *plausible* wrong answer: | input | read after the original would give | consequence | |---|---|---| | `TechTree+0x20` order counter | the *next* order | every `node.order` off by one per completion | | `ServerPlayer+0x294` (`ResT`) | 0 — cleared by the callback | the extra RNG draw never modelled | | `ServerPlayer+0x3b4` (pending roll) | 0 — cleared in the same block | same | | `ServerPlayer+0x274` observed-tech names | the completing tech already present | the append modelled as a de-duplication, agreeing with a count it did not compute | All four are taken in `describe_args`, which `hook.h` calls immediately before the original, and all four are reported as arguments (`order_counter_in`, `research_target`, `roll_pending_in`, `observed_techs_in`) so a run can be audited without trusting `ours`. ### What is modelled, and what is deliberately not The cascade runs in **compare mode only**. In replace mode every pointer is live game memory, and applying half of `OnTechResearched` — the observed-tech append and the research-event roll, but not the ninety-odd tech-effect field writes — would leave the player in a state no code path produces. Not running it leaves a player missing a cascade, which is a smaller and already declared lie. The same reasoning lane P used for the event counts. `TechTree::Cost` is called, not re-derived: it is the game's own read-only function, and the cascade needs it on the node *as it stands after the child-cost sweep*. Guessing the cost multiplier would put a second unknown inside the thing being measured. The unlock list is still an **input** to `PostResearchPassEvents`, and `nullptr` still means "this caller could not compute it" — now reached when the graph fails to transcribe or the tail's `tree->owner != 0` gate is closed, rather than always. An empty vector means "computed, and nothing became available", which posts nothing. Keeping those apart is what stops a failure to read the tree from scoring as a correct silence. --- ## 3. Why a clean result here would not be vacuous A hook that silently compares nothing is the failure mode to fear when a clean run is *expected*, and this lane is exactly that situation. Three things make it hard here: 1. **The expected values are non-trivial and known in advance.** `cost_rp` must come back 10000, 16000 and 8000 on three specific nodes, `turn_available` 4, `order` 22 and 23. A model that computed nothing leaves them at `INT_MAX`, 0 and −1 — which is precisely what lane V's report shows today. There is no "do nothing" answer that passes. 2. **The collector runs on all fifteen calls, not just the two completions.** On the thirteen calls with no completion it must come back *empty*; if the transcription were wrong in a way that over-collected, `next_id` would be **too high** and those calls would newly diverge. The quiet calls are as much of a check as the loud ones. 3. **The shim log prints the counters.** One line per call: `cascade ok=… completions=… unlocked=… otch_appends=… roll_draws=… failures=… depth=… name_unreadable=…`. A clean compare with all of those at zero on a completion call would be a clean compare of nothing, and it would be visible. --- ## 4. The prediction Written before the build was staged. Recipe unchanged: `shim.cfg.recapb3`, `ref-turn2.sav` → Launch → End Turn, then four more End Turns in the same session. ### 4.1 First End Turn — nothing should change Turn 1 of `ref-turn2` completes no tech (lane V: `observed_techs.bytes` unchanged on all three calls), so the cascade never fires. **3 calls, 3 compared, 0 divergent, `tracecmp` exit 0** — identical to lane V's result, and that is the point: the new code must be inert when nothing completes. | new argument | call 0 | calls 1, 2 | |---|---|---| | `observed_techs_in` | **10** (lane V measured 440 bytes = 10 × 44) | **20** (880 bytes) | | `research_target` | non-null (the player has a funded target) | either | | `roll_pending_in` | **false** | false | | `order_counter_in` | ≥ 0, and not −1 | ≥ 0 | | `observed_scan_failed` / `observed_scan_truncated` | **absent** | absent | Shim log, all three calls: `cascade ok=1 completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0`. If `ok=0` on any call the transcription failed and every clean field below is meaningless. If `unlocked` is non-zero on a call with no completion, the collector is over-collecting and `next_id` will be too high — the opposite failure from lane P's, and worth more than a pass. ### 4.2 The five-turn continuation — the headline **Predicted: 15 calls, 15 compared, 0 divergent, `tracecmp` exit 0.** Every one of the 22 divergent fields lane V recorded, gone. Field for field, against `verify/results/compare/eventlive-b3-t1-5.json`: | call 3 field | orig | ours before | ours now | |---|---|---|---| | `events.next_id` | 7 | 6 | **7** | | `node[132].cost_rp / state / turn_available` | 10000 / 2 / 4 | INT_MAX / 0 / −1 | **10000 / 2 / 4** | | `node[136].cost_rp / state / turn_available` | 16000 / 2 / 4 | INT_MAX / 0 / −1 | **16000 / 2 / 4** | | `node[142].cost_rp / state / turn_available` | 8000 / 2 / 4 | INT_MAX / 0 / −1 | **8000 / 2 / 4** | | `node[144].order / turn_researched` | 22 / 4 | −1 / −1 | **22 / 4** | | `observed_techs.bytes` | 484 | 440 | **484** | | `rng` | not divergent | — | still not divergent | | call 9 field | orig | ours before | ours now | |---|---|---|---| | `events.next_id` | 12 | 11 | **12** | | `node[133].cost_rp / state / turn_available` | 8000 / 2 / 6 | INT_MAX / 0 / −1 | **8000 / 2 / 6** | | `node[142].order / turn_researched` | 23 / 6 | −1 / −1 | **23 / 6** | | `observed_techs.bytes` | 528 | 484 | **528** | | `rng.left / next_index` | 374 / 250 | 375 / 249 | **374 / 250** | And the arguments that say it is right for the right reason — these are the sharp ones, because they are consequences of the model rather than restatements of it: * `order_counter_in` = **22** on call 3 and **23** on call 9. If it is 23 and 24, the counter was read after the original and every `order` will be off by one. * `roll_pending_in` = **false on call 3, true on call 9**. This is forced by lane V's data: call 3 completes with no extra draw and call 9 with one, and the funded tech is the research target on both, so the pending byte is the only thing that can differ. If call 3 comes back `true` the model of the gate is wrong and call 3's `rng` will newly diverge. * `observed_techs_in` = **10** on call 3, **11** on call 9. * Shim log on call 3: `completions=1 unlocked=3 otch_appends=1 roll_draws=0 failures=0`. On call 9: `completions=1 unlocked=1 otch_appends=1 roll_draws=1 failures=0`. `failures`, `depth` and `name_unreadable` must be 0 everywhere. ### 4.3 Coverage and guards — what must NOT change No Result region was added or removed, so the guards see exactly what they saw: * **undeclared writes: 10 in 2 calls**, the same eight spans — `player+0x10c:3`, `+0x110:3`, `+0x114:3`, `+0x124:3`, `+0x294:4`, `+0x196:1`, `+0x3b4:1`, `tree_header+0x20:1`. `+0x294` and `+0x3b4` stay undeclared even though `ours` now *reads* them: reading an input is not modelling a write. `tree_header+0x20` likewise — the counter is seeded and advanced in the model, but the live word is only ever the original's. * The coverage verdict stays **partial**, with **8** unmodelled notes (was 6): the two event-text notes, replace mode, the tech effects, the `RollResearchEvent` branch behind the draw, the ObservedTech element's own fields, the order counter, and the log line. ### 4.4 The End-Turn oracle `(Autosave EndTurn).sav` = `bb4fd9ac89f41e3b…` and `(Autosave).sav` = `978041acd168b56e…`, unchanged. The cascade writes only scratch memory, so the running game must be byte-identical to lane R's and lane V's runs. **If either hash moves, the cascade is writing live memory and the result must be thrown away regardless of how clean the compare is.** ### 4.5 Zuul (`zuul-turn5.sav`, one more End Turn) Less certain — this is new ground, and lane V's save has no completion yet. Predicted: the End Turn reaches a species-5 completion; that call shows `species = 5`, the generator advancing by **two** for the completion roll (the Zuul double roll) plus **one more** if `roll_pending_in` is true, and **0 divergences**. The double roll and the completion path have never been exercised together. ### 4.6 What would falsify the model, and how it would show | if wrong | symptom | |---|---| | `def+0xb0` is not the sweep-2 exclusion | too few nodes unlocked → `state`/`turn_available` diverge and `next_id` is short again | | the prereq offsets (0x88 / +0x00 / +0x10, stride 8) | `PrereqsMet` false everywhere → children stop at state 1 → same symptom | | `TechDef_off_Name` (0x40) | `name_unreadable=1` in the log, no observed-tech write-back, `observed_techs.bytes` diverges as before | | the `ResT == def` gate | `roll_draws=0` on call 9 → `rng` diverges as before | | the collector's predicate is too loose | `next_id` **too high** on a quiet call — a new divergence, not an old one | --- ## 5. Outcome To be filled in from the run.