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

236 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.