# `TechTree::SetResearched` — the unlock cascade, and the `silent` flag (lane P, 2026-09-08) Read while wiring the research event posts into `ours` (`sots-engine docs/P-events-wiring.md`). Two things were needed and neither was written down at instruction level: whether the completion event is actually posted from `ProcessResearch`'s call, and what makes a node "available this turn" for `EVENT_TECHS_UNLOCKED`. Source: `tools/reva_call.py get-decompilation` on `0x00581e10`, whole function (443 bytes, 88 decompiled lines). Everything below is from that read unless marked otherwise. It supersedes nothing in `strategic-turn-internals.md:243-247` — it confirms that paragraph and adds the parts it left implicit. ``` uint __thiscall TechTree::SetResearched(TechTree* this, TechDef* def, uint flags) ``` Ten call sites; `ProcessResearch` calls it as `SetResearched(node->def, 2)` (events.md:269). ## 1. `flags` | bit | value | meaning | |---|---|---| | 1 | 0x2 | force: skip the `PrereqsMet(def+0x88)` test and complete unconditionally | | 2 | 0x4 | **`silent`** — passed straight through to the owner callback | | 3 | 0x8 | after the call, and after each recursive call, run `FUN_00585ef0()` (a refresh/notify of some kind; not read) | The callback line is the one that mattered: ``` if (this->owner /*+0xc*/ != NULL) (**(code**)(*(int*)(this+0xc) + 0x10))(def, (byte)(flags >> 2) & 1); ``` `+0x10` is vft slot 4 of `0x00a327a4` = `ServerPlayer::OnTechResearched(def, bool silent)`. So **`silent` is bit 2 of `flags`**, and since `ProcessResearch` passes `flags = 2`, `silent` is **false** and `EVENT_RESEARCH_COMPLETE` / `_UNDERBUDGET` *is* posted on that path. Previously this was only inferable from lane R's observation that a completion call moved `EvNxID` by two. `addresses.json` said "`flags&4`" and `strategic-turn-internals.md:244` said "`flags>>2&1`"; both name bit 2 and both are right. The two new `constant` entries `TechTree_SetResearched_flag_Force` / `_flag_Silent` make it unambiguous. ## 2. What it writes on the completed node ``` node = this->nodes[def->[0]] ; +0x10 is the vector, indexed by the tech's own id if (node && node->state == 4) return ; already researched: nothing at all if (flags & 2 || PrereqsMet(def + 0x88)) { node->state (+0x14) = 4 node->turnResearched (+0x24) = this->owner ? *(int*)(*(int*)(owner+8) + 8) : 0 ; ModCount node->order (+0x28) = this->orderCounter (+0x20) this->orderCounter++ ; harness-audit row 9 owner->OnTechResearched(def, (flags>>2)&1) ... two sweeps ... } ``` The `turnResearched` chain is the same one every event post uses for its turn argument (`events.md` §2 "Turn number"): `player+8` → the second `StrategyServer` base, `+8` → `ModCount`. Two independent reads of the same chain. ## 3. Sweep 1 — the node's own child edges ``` for (i = 0; i < node->children.size(); i++) { ; children vector at node+0x04..+0x0c edge = node->children[i] child = this->nodes[ edge->childDef(+0x40)->[0] ] if (child->state == 0) child->state = 1 ; hidden -> parent researched child->costRP(+0x18) = min(child->costRP, edge->costRP(+0x1c)) } ``` Note the `min` is a plain signed compare against the child's current `costRP`, whose ctor value is `INT_MAX` — so the first researched parent sets it and later parents can only lower it. ## 4. Sweep 2 — every node in the tree, and the source of `turnAvailable` ``` for (i = 0; i < this->nodes.size(); i++) { n = this->nodes[i] if (!n) continue def = n->[0] if (*(char*)(def + 0xb0) != 0) continue ; a per-def exclusion byte; MEANING UNKNOWN if (def && (p = this->nodes[def->[0]]) && p->state == 1 && PrereqsMet(p->def + 0x88)) { n->state = 2 ; available if (n->turnAvailable(+0x20) == -1 && this->owner) n->turnAvailable = *(int*)(*(int*)(owner+8) + 8) ; ModCount } if (n->state == 2 && TechTree::Cost(n) == 0) { ; 0x0057da00 SetResearched(n->def, flags) ; recursion: free techs complete immediately if (flags & 8) FUN_00585ef0() } } ``` `this->nodes[def->[0]]` resolves back to `n` itself — the same self-indexing indirection lane E found in the `EVENT_TECHS_UNLOCKED` collector (`events.md` §3.4). So the state test is on `n`, not on a parent, in **both** loops. There is no parent clause anywhere in this function. **`turnAvailable` is sticky.** It is written only when it currently reads −1, so a node that goes 2 → (something) → 2 keeps its *first* availability turn. That matters for `EVENT_TECHS_UNLOCKED`, whose collector is `state == 2 && turnAvailable == currentTurn`: a node re-entering state 2 in a later turn is **not** re-announced. Unresolved: `def+0xb0`, a byte that excludes a node from sweep 2 entirely. And `FUN_0057d8e0` (`PrereqsMet`, called on `def+0x88`) was not read — only its role. New `addresses.json` entries from this read: `TechNode_off_TurnAvailable` 0x20, `TechNode_off_TurnResearched` 0x24, `TechNode_off_Order` 0x28, `TechNode_off_Children` 0x04, `TechEdge_off_CostRP` 0x1c, `TechEdge_off_ChildDef` 0x40, `TechTree_off_OrderCounter` 0x20. ## 5. `vector otch` at `ServerPlayer+0x274` — ~~still not pinned~~ **PINNED** > **RESOLVED 2026-09-08 by lane X** — see `findings/subsystems/observedtech-append.md`. > `sizeof(Game::ObservedTech) = 0x2c (44)`. The append is `RecordObservedTech+0xdf` (`0x007ba27f`): > `lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320`. `RecordObservedTech` > (`0x007ba1a0`) is a **direct callee of `OnTechResearched`** and de-duplicates by tech name before > appending. The suspicion recorded below was right: `find-constant-uses` missed it because the > encoding is a `lea` displacement. `tools/x86disp.py` now indexes those. > The rest of this section is left as written, as the record of what was known at the time. Lane R's guards caught all three vector words moving on every tech completion, in both the `ProcessResearch` and the `OnTechResearched` player guards, and it is serialized `ServerPlayer` state named in no coverage note anywhere. `sots-engine` now declares it as a Result region (`observed_techs`) on the B3 hook. What is known: * **On disk** (confirmed against `verify/results/saves/turn3-state.sav` via `verify/state-checksum/state_checksum.py --tree`): each element is `{int otnF; int otnL; int odet; string otch; int owith}`, and the `otch` string holds a **tech name** — `WEP_RedLas`, `DRV_Fissn`, `WEP_Nukes`, `DRV_Node`, `CCC_FTLCom`, `CCC_TrnsHum` … The AI player at turn 3 carries ~10 of them (51 leaves, 265 B). `SAVE_FORMAT.md:165` has the same shape. * It grows by one element per completion. What is **not** known, and was looked for: * `sizeof(ObservedTech)`. The on-disk shape bounds an in-memory by-value element at ≥ 4×4 + 0x1c = 0x2c, but nothing measures it. * The **append call site**. It is not in `ServerPlayer::OnTechResearched`'s own decompilation (539 lines, no reference to +0x274) and not in `SetResearched`'s, so it is in a callee. `find-constant-uses` for 0x274 returns 13 hits and **none of them is this vector** — the one promising candidate, `FUN_00791730` (`ADD ECX,0x274` → a 4-byte-stride `push_back` at `FUN_0059f1a0`), is reached only from UI/design-validation code (`FUN_0057cfc0` and friends) and is a different object. Ghidra's constant search does not index `lea` displacements, which is the most likely encoding, so this is a search limitation and not evidence of absence. **The cheapest way to get the stride is now a measurement, not a search:** the `observed_techs` region reports the vector's byte span, and one completion appends one element, so the delta on a completion call names `sizeof(ObservedTech)` directly. See `sots-engine docs/P-events-wiring.md` §4.4. --- ## 6. Continued by lane U (2026-09-08) — `findings/subsystems/unlock-cascade.md` This note stopped at the two functions the sweeps *call*. Lane U read them and implemented the cascade, closing the `EVENT_TECHS_UNLOCKED` residual this note predicted: * **`PrereqsMet` (0x0057d8e0)** is an AND over groups, each group an OR over techs, held at `TechDef+0x88` (flat entries, stride 8) and `TechDef+0x98/+0x9c` (`{start, count}` groups, stride 8). Zero groups is TRUE; a group with **zero entries is FALSE and fails the whole test**. * **`def+0xb0`**, the byte §4 left unresolved, is named `TechDef_off_NoAutoAvailable` for what it does. Its write site is still not read; the `unlock_explicitly` tech-file keyword is a hypothesis, not a fact. * The **tail collector** (0x00587cc3) runs once, after the per-node loop *and* after the decay sweep, gated on `tree->owner != 0`; the state test is on the self-resolved node, the turn test on the iterated one. * **`OnTechResearched`'s head**: `RecordObservedTech` is the FIRST statement and unconditional; the research-event roll is `if (ResT == def) { if (ResearchRollPending) RollResearchEvent(); pending = 0; ResT = 0; }`. Live result: the cascade running inside `ours` gives **0 divergences on 35 compared calls across three workloads**, `tracecmp` exit 0, End-Turn oracle hashes unchanged. §5 of the lane U note has the numbers; `sots-engine docs/U-unlock.md` §4 is the advance prediction and §5 the outcome.