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.
152 lines
7.8 KiB
Markdown
152 lines
7.8 KiB
Markdown
# `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.
|