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.
236 lines
12 KiB
Markdown
236 lines
12 KiB
Markdown
# 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<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`:
|
||
|
||
```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.
|