Pure module game/sim/techgraph: PrereqsMet (AND of ORs, empty group fails), SetResearched (stamps, child-cost sweep, sticky turnAvailable, zero-cost recursion) and the newly-available collector, all read out of 0x00581e10, 0x0057d8e0 and 0x00587cc3. Wired into the B3 hook in compare mode only, over the scratch node copies: four more node write-backs, the EVENT_TECHS_UNLOCKED list (still an input, still nullptr when it could not be computed), the de-duplicating observed-tech append and the one RNG word RollResearchEvent draws. docs/U-unlock.md section 4 is the prediction, written before the build was staged.
16 KiB
U — the unlock cascade, and the prediction for the run that checks it
Lane P deliberately did not post EVENT_TECHS_UNLOCKED. It could have — the trigger was
pinned — but evaluating it needs the cascade ours does not run, so it handed
PostResearchPassEvents a nullptr unlock list ("no list", distinct from an empty one) and
predicted the consequence: next_id short by exactly 1 on every completion call. Lane V measured
that live, twice, exactly.
This lane removes the residual by running the cascade, not by posting an event when something completes. The distinction matters for one reason and lane P named it: the shortcut would score perfectly on this save and be wrong the first time a completion unlocks nothing.
Everything in §4 was written before the build was staged on VM140. §5 is the outcome.
1. What the cascade actually is
Read out of the instruction stream by this lane: TechTree::SetResearched (0x00581e10, whole
function), TechTree::PrereqsMet (0x0057d8e0, whole function), the tail collector inside
TechTree::ProcessResearch (0x00587cc3), and the head of ServerPlayer::OnTechResearched
(0x00891790). Details and the decompiled shape are in sots-re
findings/subsystems/setresearched-cascade.md; the module is src/game/sim/techgraph.{h,cpp}.
SetResearched(tree, def, flags):
n = tree->nodes[def->techId]
if (!n) -> nothing (the flags&8 re-lookup path is not on the research path)
if (n->state == 4) -> return true, writing NOTHING, not even the order stamp
if (!(flags & 2) && !PrereqsMet(def + 0x88)) -> nothing
n->state = 4
n->turnResearched = owner ? ModCount : 0
n->order = tree->orderCounter++
owner->OnTechResearched(def, (flags >> 2) & 1) # flags = 2 from ProcessResearch: NOT silent
for each edge in n->children: # sweep 1
c = tree->nodes[edge->childDef->techId]
if (c->state == 0) c->state = 1
c->costRP = min(c->costRP, edge->costRP) # signed, against an INT_MAX sentinel
for each node m in tree->nodes: # sweep 2
if (!m || m->def->[0xb0]) continue
p = tree->nodes[m->def->techId]
if (p && p->state == 1 && PrereqsMet(p->def + 0x88)):
m->state = 2
if (m->turnAvailable == -1 && owner) m->turnAvailable = ModCount
if (m->state == 2 && Cost(m) == 0) SetResearched(m->def, flags) # recursion
Four properties are easy to get wrong and each is pinned by a host test:
turnAvailableis sticky. It is written only when it still reads −1. A node that returns to state 2 in a later turn keeps its first availability turn — which is the whole reasonEVENT_TECHS_UNLOCKEDcan be computed from node state at all, and the reason a tech is never announced twice.- The cost minimum is signed, against
INT_MAX. The first researched parent sets the child's cost; later parents can only lower it. PrereqsMetis an AND over groups, each group an OR over techs — and a group with zero entries makes the whole test fail, because the inner loop cannot break and the outer one then exits with that group uncounted. A "vacuously true" reading gets this backwards. Zero groups, by contrast, is satisfied.- Both sweeps and the collector re-resolve
tree->nodes[node->def->techId]and test that node's state, not the iterated node's. For a well-formed tree it comes back to the same node; it is reproduced because the code has it.
def+0xb0 is a byte that excludes a node from sweep 2 entirely. Its write site was not found
(MasterTechTree::ParseTech shows no reference to 0xb0), so addresses.d/lane-u.json names it
for what it does — TechDef_off_NoAutoAvailable — and records the unlock_explicitly tech-file
keyword as a hypothesis, not a fact. Nothing in this lane depends on which it is: the byte is read
from the live def either way.
The tail collector
if (tree->owner) {
turn = ModCount
for each node n: if (n && n->def && (p = tree->nodes[n->def->techId]) &&
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, the turn test on the iterated one — and note that it runs once, after the whole per-node loop and after the decay sweep.
The two halves of OnTechResearched this hook can see
OnTechResearched(player, def, silent):
RecordObservedTech(...) # FIRST statement, unconditional
if (player->ResT == def) {
if (player->ResearchRollPending) RollResearchEvent(player) # exactly one NextFloat
player->ResearchRollPending = 0
player->ResT = 0
}
if (!silent) { ... EVENT_RESEARCH_COMPLETE / _UNDERBUDGET ... }
RecordObservedTech de-duplicates by tech name, so "the vector did not grow" is a real
outcome and not a failure. RollResearchEvent draws one NextFloat unconditionally and only then
tests it against the odds; clearing ResT is what makes a second completion in the same pass
draw nothing. This is lane V's call-9 deviation, and it is now inside the compare rather than
declared out of scope.
2. What changed
| file | change |
|---|---|
src/game/sim/techgraph.{h,cpp} (new) |
the pure cascade: TechPrereqsMet, SetResearched, CollectNewlyAvailable |
src/game/sim/research.{h,cpp} |
one optional parameter: a completion hook called inside the allocation loop, where the original calls SetResearched — before the next entry's roll and before the decay sweep |
src/shim/hooks/research.{h,cpp} |
the pre-call scan the cascade needs, the live→graph transcription, the cascade wiring, four more node write-backs, the unlock list, the observed-tech append, the RNG draw, the rewritten coverage |
tests/game_sim/test_techgraph.cpp (new) |
13 cases, 92 checks |
ghidra/addresses.d/lane-u.json (sots-re) |
12 new entries; header regenerated 615 → 627 |
tools/clean_room_check.sh — OK. Host ctest — 34/34 (was 33/33; game_sim_techgraph is
new). Run as separate commands. The shim TU is syntax-checked on the host
(-std=c++17 -Wall -Wextra -fsyntax-only, clean) and cross-built on CT111 before deploy.
The four pre-call reads, and why each one is a trap
ours runs after the original in compare mode. Four inputs the cascade needs are things the
original changes during the call, and each of them reads back a plausible wrong answer:
| input | read after the original would give | consequence |
|---|---|---|
TechTree+0x20 order counter |
the next order | every node.order off by one per completion |
ServerPlayer+0x294 (ResT) |
0 — cleared by the callback | the extra RNG draw never modelled |
ServerPlayer+0x3b4 (pending roll) |
0 — cleared in the same block | same |
ServerPlayer+0x274 observed-tech names |
the completing tech already present | the append modelled as a de-duplication, agreeing with a count it did not compute |
All four are taken in describe_args, which hook.h calls immediately before the original, and
all four are reported as arguments (order_counter_in, research_target, roll_pending_in,
observed_techs_in) so a run can be audited without trusting ours.
What is modelled, and what is deliberately not
The cascade runs in compare mode only. In replace mode every pointer is live game memory, and
applying half of OnTechResearched — the observed-tech append and the research-event roll, but
not the ninety-odd tech-effect field writes — would leave the player in a state no code path
produces. Not running it leaves a player missing a cascade, which is a smaller and already
declared lie. The same reasoning lane P used for the event counts.
TechTree::Cost is called, not re-derived: it is the game's own read-only function, and the
cascade needs it on the node as it stands after the child-cost sweep. Guessing the cost
multiplier would put a second unknown inside the thing being measured.
The unlock list is still an input to PostResearchPassEvents, and nullptr still means "this
caller could not compute it" — now reached when the graph fails to transcribe or the tail's
tree->owner != 0 gate is closed, rather than always. An empty vector means "computed, and
nothing became available", which posts nothing. Keeping those apart is what stops a failure to
read the tree from scoring as a correct silence.
3. Why a clean result here would not be vacuous
A hook that silently compares nothing is the failure mode to fear when a clean run is expected, and this lane is exactly that situation. Three things make it hard here:
- The expected values are non-trivial and known in advance.
cost_rpmust come back 10000, 16000 and 8000 on three specific nodes,turn_available4,order22 and 23. A model that computed nothing leaves them atINT_MAX, 0 and −1 — which is precisely what lane V's report shows today. There is no "do nothing" answer that passes. - The collector runs on all fifteen calls, not just the two completions. On the thirteen
calls with no completion it must come back empty; if the transcription were wrong in a way
that over-collected,
next_idwould be too high and those calls would newly diverge. The quiet calls are as much of a check as the loud ones. - The shim log prints the counters. One line per call:
cascade ok=… completions=… unlocked=… otch_appends=… roll_draws=… failures=… depth=… name_unreadable=…. A clean compare with all of those at zero on a completion call would be a clean compare of nothing, and it would be visible.
4. The prediction
Written before the build was staged. Recipe unchanged: shim.cfg.recapb3, ref-turn2.sav →
Launch → End Turn, then four more End Turns in the same session.
4.1 First End Turn — nothing should change
Turn 1 of ref-turn2 completes no tech (lane V: observed_techs.bytes unchanged on all three
calls), so the cascade never fires. 3 calls, 3 compared, 0 divergent, tracecmp exit 0 —
identical to lane V's result, and that is the point: the new code must be inert when nothing
completes.
| new argument | call 0 | calls 1, 2 |
|---|---|---|
observed_techs_in |
10 (lane V measured 440 bytes = 10 × 44) | 20 (880 bytes) |
research_target |
non-null (the player has a funded target) | either |
roll_pending_in |
false | false |
order_counter_in |
≥ 0, and not −1 | ≥ 0 |
observed_scan_failed / observed_scan_truncated |
absent | absent |
Shim log, all three calls: cascade ok=1 completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0.
If ok=0 on any call the transcription failed and every clean field below is meaningless. If
unlocked is non-zero on a call with no completion, the collector is over-collecting and
next_id will be too high — the opposite failure from lane P's, and worth more than a pass.
4.2 The five-turn continuation — the headline
Predicted: 15 calls, 15 compared, 0 divergent, tracecmp exit 0. Every one of the 22
divergent fields lane V recorded, gone. Field for field, against
verify/results/compare/eventlive-b3-t1-5.json:
| call 3 field | orig | ours before | ours now |
|---|---|---|---|
events.next_id |
7 | 6 | 7 |
node[132].cost_rp / state / turn_available |
10000 / 2 / 4 | INT_MAX / 0 / −1 | 10000 / 2 / 4 |
node[136].cost_rp / state / turn_available |
16000 / 2 / 4 | INT_MAX / 0 / −1 | 16000 / 2 / 4 |
node[142].cost_rp / state / turn_available |
8000 / 2 / 4 | INT_MAX / 0 / −1 | 8000 / 2 / 4 |
node[144].order / turn_researched |
22 / 4 | −1 / −1 | 22 / 4 |
observed_techs.bytes |
484 | 440 | 484 |
rng |
not divergent | — | still not divergent |
| call 9 field | orig | ours before | ours now |
|---|---|---|---|
events.next_id |
12 | 11 | 12 |
node[133].cost_rp / state / turn_available |
8000 / 2 / 6 | INT_MAX / 0 / −1 | 8000 / 2 / 6 |
node[142].order / turn_researched |
23 / 6 | −1 / −1 | 23 / 6 |
observed_techs.bytes |
528 | 484 | 528 |
rng.left / next_index |
374 / 250 | 375 / 249 | 374 / 250 |
And the arguments that say it is right for the right reason — these are the sharp ones, because they are consequences of the model rather than restatements of it:
order_counter_in= 22 on call 3 and 23 on call 9. If it is 23 and 24, the counter was read after the original and everyorderwill be off by one.roll_pending_in= false on call 3, true on call 9. This is forced by lane V's data: call 3 completes with no extra draw and call 9 with one, and the funded tech is the research target on both, so the pending byte is the only thing that can differ. If call 3 comes backtruethe model of the gate is wrong and call 3'srngwill newly diverge.observed_techs_in= 10 on call 3, 11 on call 9.- Shim log on call 3:
completions=1 unlocked=3 otch_appends=1 roll_draws=0 failures=0. On call 9:completions=1 unlocked=1 otch_appends=1 roll_draws=1 failures=0.failures,depthandname_unreadablemust be 0 everywhere.
4.3 Coverage and guards — what must NOT change
No Result region was added or removed, so the guards see exactly what they saw:
- undeclared writes: 10 in 2 calls, the same eight spans —
player+0x10c:3,+0x110:3,+0x114:3,+0x124:3,+0x294:4,+0x196:1,+0x3b4:1,tree_header+0x20:1.+0x294and+0x3b4stay undeclared even thoughoursnow reads them: reading an input is not modelling a write.tree_header+0x20likewise — the counter is seeded and advanced in the model, but the live word is only ever the original's. - The coverage verdict stays partial, with 8 unmodelled notes (was 6): the two event-text
notes, replace mode, the tech effects, the
RollResearchEventbranch behind the draw, the ObservedTech element's own fields, the order counter, and the log line.
4.4 The End-Turn oracle
(Autosave EndTurn).sav = bb4fd9ac89f41e3b… and (Autosave).sav = 978041acd168b56e…,
unchanged. The cascade writes only scratch memory, so the running game must be byte-identical to
lane R's and lane V's runs. If either hash moves, the cascade is writing live memory and the
result must be thrown away regardless of how clean the compare is.
4.5 Zuul (zuul-turn5.sav, one more End Turn)
Less certain — this is new ground, and lane V's save has no completion yet. Predicted: the
End Turn reaches a species-5 completion; that call shows species = 5, the generator advancing by
two for the completion roll (the Zuul double roll) plus one more if roll_pending_in is
true, and 0 divergences. The double roll and the completion path have never been exercised
together.
4.6 What would falsify the model, and how it would show
| if wrong | symptom |
|---|---|
def+0xb0 is not the sweep-2 exclusion |
too few nodes unlocked → state/turn_available diverge and next_id is short again |
| the prereq offsets (0x88 / +0x00 / +0x10, stride 8) | PrereqsMet false everywhere → children stop at state 1 → same symptom |
TechDef_off_Name (0x40) |
name_unreadable=1 in the log, no observed-tech write-back, observed_techs.bytes diverges as before |
the ResT == def gate |
roll_draws=0 on call 9 → rng diverges as before |
| the collector's predicate is too loose | next_id too high on a quiet call — a new divergence, not an old one |
5. Outcome
To be filled in from the run.