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.
12 KiB
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 norequiresline 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 == endimmediately 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 oneNextFloat; when the roll beats the odds it entersServerPlayer_OnResearchRollSucceeded(0x00889d60), whose plague path draws a second word (NextInt) to pick an owned system and postsEVENT_PLAGUE_OUTBREAK, while the rebellion path allocates anAIRebellionatServerPlayer+0x3b8and 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. Seefindings/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) isodds = 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). ClearingResTis 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:
- 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. - 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_idtoo high and made those calls newly divergent. It did not. - The shim log prints per-call counters.
completions=1 unlocked=3 otch_appends=1on 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
RollResearchEventdraw (§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
ObservedTechelement's own fields —oursdecides the append, it does not build the element. FUN_00585ef0, the refresh helperSetResearchedruns whenflags & 8. No research-path call site sets that bit, so it is unmodelled by call-site, not by omission.