sots-re/findings/subsystems/unlock-cascade.md
Alex 5a3f986f38 lane U: the unlock cascade, implemented and live-verified
Reads: TechTree::PrereqsMet 0x0057d8e0 (AND of ORs; zero groups TRUE, an empty
group FALSE), the prerequisite layout at TechDef+0x88/+0x98, the tail collector
0x00587cc3, and the head of OnTechResearched (RecordObservedTech unconditional;
the research-event roll gated on ResT == def && ResearchRollPending).

Live: 35 compared calls over three workloads, 0 divergences, tracecmp exit 0,
End-Turn oracle hashes unchanged. The EVENT_TECHS_UNLOCKED residual lane P
predicted and lane V measured is closed by running the cascade, not by posting
on completion.

12 new addresses in ghidra/addresses.d/lane-u.json (header 615 -> 627).
2026-09-08 06:57:37 -04:00

12 KiB
Raw Blame History

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.

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<TechPrereqEntry> — only its _Myfirst is read; the groups carry the bounds
+0x98 / +0x9c vector<TechPrereqGroup> _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:

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:

RecordObservedTech(...);                       // FIRST statement, UNCONDITIONAL
if (this->ResT /*+0x294*/ == def) {
    if (this->ResearchRollPending /*+0x3b4*/) RollResearchEvent(this);   // exactly one NextFloat
    this->ResearchRollPending = 0;
    this->ResT = 0;
}
  • 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.