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