sots-re/findings/subsystems/unlock-cascade.md
alex bb1f8b4692 lane K: StrategyServer::OnAllCombatDone_Tail mapped, 36 phases from the instruction stream
The second turn driver, 0x007d92a0, read byte for byte. Corrects turn-driver.md
section 5: of the four subsystems it said live here, only bankruptcy does.

- 36-phase map with strides enumerated (EncounterResults 0x178, Encounter 0x74,
  member 0x44) and the arity check that logs but does not return.
- Phase 7 is encounters.clear(), not a filter: the erase pair is the same
  four-argument shape vector<Encounter>::operator= uses, and both arms converge
  three instructions later.
- Bankruptcy: ProcessBankruptcy at phase 15, UpdateBankruptcyLimits at phase 31.
  Three corrections to formula-gaps Q1 - the divisor is the double
  -0.15000000596046448 not -0.15, the per-system income term is clamped at 0
  before summing, and the 3.3 factor lives in .bss and is DB-loaded.
- Turn results are FILLED here (phases 6, 11, 18 write S+0x2f4[PlyrIdx]) but
  rotated by ApplyEncounterResults and dispatched by SynchronizePlayer as event
  0x25 afterwards. sizeof(SETurnResults) = 0x11c, enumerated five ways.
- BuildTurnEvents is misnamed: it is the setup/load/rejoin resync push, gated on
  a pending descriptor, and references no EVENT_ string at all.
- TurnEvents_Write and TurnEvents_Read are swapped in Ghidra (layouts.json is
  right). sizeof(TurnEvents) = 0x18, enumerated four ways.
- The autosave: StrategyHost::Autosave 0x00895210, its four localized paths, the
  rotation that fires only on the post-turn call, the connection detach around
  the write, and why the payload carries nothing time-, name- or machine-derived.
- Two RNG sources in the tail that nothing models: one NextFloat per expired node
  line, plus draws inside the combat resolver. Both run before the autosave.
- S+0x8 advances twice per turn, not once.

Repo-wide correction: the research-event roll costs one or two RNG words, not
one. Fixed in unlock-cascade.md, addresses.json and lane-u.json; the captured
compare artefacts under verify/results are left alone as run records.
2026-09-08 08:47:08 -04:00

12 KiB
Raw Permalink 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);   // 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.