# The tech unlock cascade — the code, and the live verification (lane U, 2026-09-08) Lane P read `TechTree::SetResearched` (0x00581e10) and wrote `findings/subsystems/setresearched-cascade.md`, which settled the `silent` flag and sketched both sweeps. It stopped short of the two functions the sweeps *call*, and it deliberately did not post `EVENT_TECHS_UNLOCKED` — it handed the pass driver a `nullptr` unlock list and predicted a residual of exactly 1 on `next_id` per completion. Lane V measured that residual live, twice, exactly. This note (a) finishes the instruction-level read — `PrereqsMet`, the prerequisite structure, the tail collector, and the head of `OnTechResearched` — and (b) records the live verification of the whole thing running inside `ours`. **Result: the residual is gone. 0 divergences on all three workloads, `tracecmp` exit 0, and the End-Turn oracle hashes unchanged.** Engine side: `sots-engine docs/U-unlock.md`, `src/game/sim/techgraph.{h,cpp}`. --- ## 1. `TechTree::PrereqsMet` — 0x0057d8e0 `bool __thiscall PrereqsMet(TechTree* this, TechPrereqs* p /* = def + 0x88 */)`, 148 bytes, whole function read. Both `SetResearched` call sites pass `def + 0x88`. ```c satisfied = 0; total = (p->groups._Mylast - p->groups._Myfirst) >> 3; // p+0x14, p+0x10, stride 8 for (g = p->groups._Myfirst; satisfied < total; g += 2) { i = g[0]; // start index into the flat entry array end = g[1] + i; // + count if (i < end) { e = (int*)(p->entries + i * 8); // p+0x00, stride 8 do { if (e[0] != NULL && (n = this->nodes[ ((int*)e[0])[0] ]) != NULL && n->state == 4) break; // this group is satisfied i++; e += 2; } while (i < end); } if (i == end) break; // group NOT satisfied -> leave it uncounted satisfied++; } return satisfied == total; ``` So a prerequisite set is **an AND over groups, each group an OR over techs**, and a tech counts only when its node exists in *this* tree and is in state 4. Two edge cases are the code's, not a simplification, and both are easy to get backwards: * **Zero groups → true.** `0 == 0`. A tech with no `requires` line is gated only by its parent edge. * **A group with zero entries → false, and the whole test fails.** The inner loop cannot run, so `i == end` immediately and the outer loop breaks with that group uncounted. A "vacuously true" reading of an empty OR gets this exactly wrong. ### The structure at `TechDef + 0x88` Two MSVC vectors back to back: | offset (from `def`) | what | |---|---| | `+0x88` | `vector` — only its `_Myfirst` is read; the groups carry the bounds | | `+0x98` / `+0x9c` | `vector` `_Myfirst` / `_Mylast` | `TechPrereqEntry` is 8 bytes, `TechDef*` at +0x00 (the second word is never read here). `TechPrereqGroup` is 8 bytes, `{int start; int count}` indexing the entry array. ## 2. The tail collector — 0x00587cc3, inside `ProcessResearch` Runs **once**, after the per-node allocation loop *and* after the decay sweep, and only when `tree->owner != 0`: ```c turn = ModCount; for (i = 0; i < nodes.size(); i++) { n = nodes[i]; if (n && n->def && (p = nodes[ n->def->techId ]) != NULL && 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 `p`, the **turn** test on the iterated node `n`. This confirms lane E's read in `events.md` §3.4 and adds the `owner` gate and the "after the decay sweep" ordering. ## 3. The head of `ServerPlayer::OnTechResearched` — 0x00891790 The two statements before the `!silent` event post, and the only two parts of the callback the B3 hook's regions can see: ```c RecordObservedTech(...); // FIRST statement, UNCONDITIONAL if (this->ResT /*+0x294*/ == def) { if (this->ResearchRollPending /*+0x3b4*/) RollResearchEvent(this); // ONE or TWO RNG words this->ResearchRollPending = 0; this->ResT = 0; } ``` > **Correction (lane K, 2026-09-08).** This line used to read "exactly one NextFloat". That is the > cost of *reaching* the branch, not the cost of a fired roll. `RollResearchEvent` (0x0088df20) > draws one `NextFloat`; when the roll beats the odds it enters > `ServerPlayer_OnResearchRollSucceeded` (0x00889d60), whose **plague path draws a second word > (`NextInt`)** to pick an owned system and posts `EVENT_PLAGUE_OUTBREAK`, while the **rebellion > path** allocates an `AIRebellion` at `ServerPlayer+0x3b8` and **cancels the current research** > (no further draw). Any RNG accounting that assumes one word is wrong the first time that branch > fires; it has never fired in three sessions, which is why nothing caught it. > See `findings/control-flow/turn-driver.md` §3.1. * `RecordObservedTech` (0x007ba1a0, lane X) de-duplicates by tech name, so "the vector did not grow" is a real outcome. Its unconditionality was not previously written down. * `RollResearchEvent` (0x0088df20) is `odds = ResearchEventOdds(this, this->ResT); roll = rand01(); if (roll < odds) FUN_00889d60(this);` — **one draw, unconditional**, and the branch behind it is normally dead (odds are 0 outside the plague and AI-rebellion families). Clearing `ResT` is what makes a *second* completion in the same pass draw nothing. ## 4. `TechDef + 0xb0` — named, not explained The byte that makes the availability sweep `continue`. A node carrying it can still be completed by an explicit `SetResearched`, and sweep 1 still lowers its cost and raises its state — but nothing ever moves it to state 2, so it never stamps `turnAvailable` and can never raise `EVENT_TECHS_UNLOCKED`. `addresses.d/lane-u.json` names it **`TechDef_off_NoAutoAvailable`** for what it does. The tech-file keyword `unlock_explicitly` (parsed by `sots-engine src/game/data/techtree.cpp:152`) matches the behaviour exactly and is the obvious candidate, but `MasterTechTree::ParseTech` (0x0058b050) shows no reference to 0xb0 in its decompilation, so **the link is a hypothesis, not a fact**. Nothing depends on it: the byte is read from the live def either way. --- ## 5. Live verification Build `unlock-405ba41-20260908T1026Z`, cross-built on CT111, staged `C:\SOTS\shimdist-u`, recipe `shim.cfg.recapb3` unchanged. The prediction was written into `sots-engine docs/U-unlock.md` §4 **before** the build was staged; §5 there is the field-by-field comparison. | run | calls | compared | diverged | exit | |---|---|---|---|---| | first End Turn, `ref-turn2` | 3 | 3 | **0** | 0 | | five-turn continuation, turn 2 → 7 | 15 | 15 | **0** | 0 | | Zuul, `zuul-turn5`, turn 5 → 15 | 20 | 20 | **0** | 0 | Reports `verify/results/compare/unlock-b3-{t1,t1-5,zuul}.{md,json}`; traces `verify/traces/unlock-b3-*`; shim log `verify/results/shim/unlock-shim.log`. **The End-Turn oracle held.** `(Autosave EndTurn).sav` = `bb4fd9ac89f41e3b…`, `(Autosave).sav` = `978041acd168b56e…` — the same two hashes lane R and lane V recorded. Checked first, because a clean compare from a build that moved the game would be worthless. ### 5.1 What the five-turn run actually did All 22 divergent fields lane V recorded are gone. Call 3 reproduced lane V's numbers exactly (node 144 completes with `order` 22 / `turn_researched` 4; nodes 132/136/142 go 0 → 2 with `cost_rp` 10000/16000/8000 and `turn_available` 4; `next_id` 5 → 7; `observed_techs` 440 → 484). **Call 9 was a different completion from lane V's**, and that is the stronger evidence. From turn 5 the AI picked a different target — lane R's documented trap #2. Lane V's call 9 completed tech 142 and unlocked one node; this run completed **tech 9** (`order` 23, `turn_researched` 6) and unlocked **three**: node 3 at 13000, node 12 at 35000, node 18 at 4000, all `turn_available` 6. Those three costs appear in no earlier report and were predicted by no one. The model reproduced them with zero divergences on a case it had never seen — so it cannot be scoring by having memorised lane V's run. Call 12 then allocates to node 18, the tech the cascade had just unlocked. ### 5.2 The check against a compare that compares nothing A clean result was *expected* here, which is exactly when a hook that silently models nothing slips through. Three things make that hard to hide, and all three fired: 1. The expected values are non-trivial and the "did nothing" answer is known: it is `INT_MAX / 0 / −1`, which is precisely what lane V's report shows. There is no null model that passes. 2. The collector ran on all 35 calls, not just the four completions. On the 31 quiet calls it had to come back **empty**; an over-collecting transcription would have pushed `next_id` *too high* and made those calls newly divergent. It did not. 3. The shim log prints per-call counters. `completions=1 unlocked=3 otch_appends=1` on the completion calls and all-zero elsewhere — a clean compare with zeros on a completion call would have been visible as a clean compare of nothing. ### 5.3 The Zuul completion — double roll and cascade together Lane V's `zuul-turn5.sav` was one End Turn short of a completion; this run took ten. **20 calls, 0 divergences, two Zuul completions.** Call 2 (turn 7, species 5, alloc {144, 1376}): the generator advances by **two** (`left` 449 → 447, `next_index` 175 → 177 — the species-5 double roll) *and* the completion cascade runs, in the same call, and `ours` reproduces the post-state bit for bit. The Zuul tree unlocks only 132 and 136 from tech 144 where the Human tree also unlocks 142, so this is an independent instance of the cascade rather than a repeat. ### 5.4 The `RollResearchEvent` draw — implemented, inside the compare, NOT exercised `roll_draws` was **0 on all 35 compared calls**. The reason is a real finding, visible in the trace: `ResearchRollPending` is normally consumed by `ServerPlayer::ProcessTurn` *before* `ProcessResearch` runs, because that call site fires when the progress ratio crosses a threshold — which is exactly the turns approaching completion. In the Zuul run `roll_pending_in` is `true` on the funded call for turns 8–12 and flips to **false** on turn 13, the turn before the tech completes on turn 14. So lane V's call-9 extra draw was the *rare* case (a tech that jumped from below the threshold to complete in one turn), not the normal one — which also explains lane R's "RNG matched 15 of 15". Three sessions, three answers: 0, 1, 0. What is established: the draw is modelled at the right point in the stream, its two inputs are read **pre-call** and reported in every record (`research_target`, `roll_pending_in`), and `region:rng` compared clean on all 35 calls, so `ours` is not drawing a word the original does not. What is **not** established: that the branch has been seen to fire. The boundary is inside the compare; the compare has not yet had the chance to test it. It needs a save where a funded tech is below the ProcessTurn threshold and completes in one turn. ### 5.5 Guards Undeclared writes: **9 in 2 calls** (five-turn), **8 in 2 calls** (Zuul). The span list differs from lane V's by the workload, not the code: `player+0x3b4` is gone because the pending-roll byte was already 0 when the completing call ran, and `+0x196` (design-option mask B) is replaced by `+0x130` (`PopMod`) because a different tech completed. Both are `OnTechResearched` tech-effect writes, both declared unmodelled. `tree_header+0x20` is still undeclared and still expected: `ours` seeds the order counter pre-call and advances its own copy; it does not model the live word. Coverage verdict `partial`, 8 unmodelled notes (was 6). --- ## 6. Still owed * A workload that fires the `RollResearchEvent` draw (§5.4). * `TechTree::GetProgressRatio` (0x0057e950) — the COMPLETE/UNDERBUDGET split remains analogy. It is count-neutral, so no compare can see it. * `def+0xb0`'s write site (§4). * `FUN_00889d60`, the branch behind the research-event roll; the temperance sweep; `EventStorage::PruneOldTurns`; replace mode on this hook. All untouched. * The `ObservedTech` element's own fields — `ours` decides the append, it does not build the element. * `FUN_00585ef0`, the refresh helper `SetResearched` runs when `flags & 8`. No research-path call site sets that bit, so it is unmodelled by call-site, not by omission.