Ghidra does not index ModRM displacements, so `lea reg,[reg+disp]` -- the MSVC idiom for taking a member's address -- is invisible to find-constant-uses. That blind spot parked ServerPlayer+0x274 and covers every non-trivial member of the ~1,600 classes still to map. tools/x86disp.py: full x86-32 length decoder (prefixes, 1/2/3-byte opcodes, ModRM, SIB, sign-extended disp8, disp32, every immediate form) swept from Ghidra's 41,089 function starts so decodes begin on real instruction boundaries. 2,174,504 instructions, 612,166 displacement sites, 100.0% code coverage, 70 desyncs (0.17%), zero unknown opcodes. Excludes no-base disp32 forms (mod=0/rm=5, sib.base=5) which are absolute globals, not member offsets. Commands: build/query/cohort/func/dis/stats/brute. Works off a gitignored local cache in dumps/ rather than hammering CT111. Validated before use: re-finds lea eax,[ecx+0x29c] in ServerPlayer::GetEventStorage (0x0080db00) and both known OnTechResearched +0x29c sites, plus a new one in ProcessTurn. Positive control: the ServerPlayer serializer scores 50/50 known offsets. sizeof(Game::ObservedTech) = 0x2c (44), proven three ways: the exact magic divide 0x2e8ba2e9 sar 3 at 0x0087239f, imul reg,reg,0x2c at 0x0087243a and 0x007b735b, and the search stride add edi,0x2c at 0x007ba257. Append site: 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 realloc through 0x007b5820 is why lane R's guard saw all three vector words move. Element carries a vptr (RTTI .?AVObservedTech@Game@@) at +0 and a 0x18-byte std::string at +0x0c; the four on-disk ints map onto +0x04/+0x06/+0x08/+0x24/+0x28 in an order this read does NOT determine, and is not guessed. Also corrects harness-audit row 11: ComputeBudget has no store to Budget+0x64 (its only +0x64 accesses are loads off a different base), and ProcessResearch's int* overbudget arg is a ProcessTurn stack local, not Budget+0x64. Agrees with lane R's guard seeing 0 changes in 4284 calls. Honest limits are recorded in the note and the board: this is a recall tool, not an oracle. Class-level precision at 0x274 is ~13% by function, i.e. a ~900x search-space cut that still needs one call-graph check. Cohort ranking must not be used as a hard filter -- it would have discarded the correct answer here. Ghidra writeback: labels + plate comments on RecordObservedTech, vector_ObservedTech_push_back, ObservedTech_ctor, vector_ObservedTech_assign, vector_44B_grow, vftable_ObservedTech.
7.8 KiB
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<ObservedTech> 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 isRecordObservedTech+0xdf(0x007ba27f):lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320.RecordObservedTech(0x007ba1a0) is a direct callee ofOnTechResearchedand de-duplicates by tech name before appending. The suspicion recorded below was right:find-constant-usesmissed it because the encoding is aleadisplacement.tools/x86disp.pynow 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.savviaverify/state-checksum/state_checksum.py --tree): each element is{int otnF; int otnL; int odet; string otch; int owith}, and theotchstring 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:165has 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 inSetResearched's, so it is in a callee.find-constant-usesfor 0x274 returns 13 hits and none of them is this vector — the one promising candidate,FUN_00791730(ADD ECX,0x274→ a 4-byte-stridepush_backatFUN_0059f1a0), is reached only from UI/design-validation code (FUN_0057cfc0and friends) and is a different object. Ghidra's constant search does not indexleadisplacements, 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.