sots-re/findings/subsystems/setresearched-cascade.md
Alex 5a3f986f38 lane U: the unlock cascade, implemented and live-verified
Reads: TechTree::PrereqsMet 0x0057d8e0 (AND of ORs; zero groups TRUE, an empty
group FALSE), the prerequisite layout at TechDef+0x88/+0x98, the tail collector
0x00587cc3, and the head of OnTechResearched (RecordObservedTech unconditional;
the research-event roll gated on ResT == def && ResearchRollPending).

Live: 35 compared calls over three workloads, 0 divergences, tracecmp exit 0,
End-Turn oracle hashes unchanged. The EVENT_TECHS_UNLOCKED residual lane P
predicted and lane V measured is closed by running the cascade, not by posting
on completion.

12 new addresses in ghidra/addresses.d/lane-u.json (header 615 -> 627).
2026-09-08 06:57:37 -04:00

9.3 KiB
Raw Blame History

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