diff --git a/findings/subsystems/setresearched-cascade.md b/findings/subsystems/setresearched-cascade.md new file mode 100644 index 0000000..ca80269 --- /dev/null +++ b/findings/subsystems/setresearched-cascade.md @@ -0,0 +1,144 @@ +# `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. diff --git a/ghidra/addresses.json b/ghidra/addresses.json index ba911a4..69238f2 100644 --- a/ghidra/addresses.json +++ b/ghidra/addresses.json @@ -954,6 +954,22 @@ "status": "verified", "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, + { + "name": "TechTree_SetResearched_flag_Force", + "offset": "0x2", + "convention": "constant", + "prototype": "flags bit 1 of SetResearched(def, flags): skip the PrereqsMet test and complete unconditionally. TechTree::ProcessResearch passes flags = 2", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, + { + "name": "TechTree_SetResearched_flag_Silent", + "offset": "0x4", + "convention": "constant", + "prototype": "flags bit 2 of SetResearched(def, flags): the owner callback is invoked as vft+0x10(def, (flags>>2)&1), so this bit IS OnTechResearched's `silent` argument. ProcessResearch passes flags = 2, so silent = FALSE and the completion event IS posted", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, { "name": "TechTree_off_Owner", "offset": "0xc", @@ -970,6 +986,14 @@ "status": "verified", "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, + { + "name": "TechTree_off_OrderCounter", + "offset": "0x20", + "convention": "offset", + "prototype": "int completion-order counter; SetResearched stamps node+0x28 from it and post-increments it. Harness-audit row 9", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, { "name": "TechNode_size", "offset": "0x34", @@ -1010,6 +1034,54 @@ "status": "verified", "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, + { + "name": "TechNode_off_TurnAvailable", + "offset": "0x20", + "convention": "offset", + "prototype": "int turnAvailable; SetResearched's second sweep stamps it with the server ModCount ONLY when it currently reads -1 (a sticky first-availability stamp). EVENT_TECHS_UNLOCKED collects nodes with state==2 && turnAvailable==currentTurn", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, + { + "name": "TechNode_off_TurnResearched", + "offset": "0x24", + "convention": "offset", + "prototype": "int turnResearched; SetResearched writes the server ModCount", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, + { + "name": "TechNode_off_Order", + "offset": "0x28", + "convention": "offset", + "prototype": "int order; SetResearched writes the tree's completion-order counter, then bumps it", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, + { + "name": "TechNode_off_Children", + "offset": "0x04", + "convention": "offset", + "prototype": "std::vector children (3 words at +0x04/+0x08/+0x0c)", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, + { + "name": "TechEdge_off_CostRP", + "offset": "0x1c", + "convention": "offset", + "prototype": "int RP cost carried by the edge; SetResearched sets child.costRP = min(child.costRP, edge.costRP)", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, + { + "name": "TechEdge_off_ChildDef", + "offset": "0x40", + "convention": "offset", + "prototype": "TechDef* the edge's child tech; SetResearched indexes tree->nodes by childDef->[0]", + "status": "verified", + "source": "lane P own disassembly pass 2026-09-08 (reva_call get-decompilation): TechTree::SetResearched 0x00581e10" + }, { "name": "TechNode_off_Flag", "offset": "0x2c", @@ -1378,6 +1450,14 @@ "status": "verified-by-save", "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" }, + { + "name": "ServerPlayer_off_ObservedTechs", + "offset": "0x274", + "convention": "offset", + "prototype": "std::vector otch (3 words {first,last,end}); save tag otch, element {int otnF, otnL, odet; string otch; int owith}. All three words move on every tech completion (a realloc) -- observed live by both the ProcessResearch and the OnTechResearched player guards. NOT PINNED: the append call site and sizeof(ObservedTech); the byte span the observed_techs region reports is what will measure the stride", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/golden-trace-recapture.md (player guard, both completion calls)" + }, { "name": "ServerPlayer_off_IncMod", "offset": "0x30c", diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index 6aef2f0..c528e84 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 1b893e5, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ b0ef139, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -245,10 +245,16 @@ constexpr uint32_t TechTree_Cost = 0x0017da00; constexpr uint32_t ServerPlayer_TechCostMult = 0x0040db50; // thiscall void (TechTree* this, TechDef* def, int flags) // state 4 + turn/order stamps + owner callback + child unlock cascade. Makes no direct RNG draw; the owner callback is not audited, so a compare that runs it is out of scope [verified] constexpr uint32_t TechTree_SetResearched = 0x00181e10; +// constant flags bit 1 of SetResearched(def, flags): skip the PrereqsMet test and complete unconditionally. TechTree::ProcessResearch passes flags = 2 [verified] +constexpr uint32_t TechTree_SetResearched_flag_Force = 0x00000002; +// constant flags bit 2 of SetResearched(def, flags): the owner callback is invoked as vft+0x10(def, (flags>>2)&1), so this bit IS OnTechResearched's `silent` argument. ProcessResearch passes flags = 2, so silent = FALSE and the completion event IS posted [verified] +constexpr uint32_t TechTree_SetResearched_flag_Silent = 0x00000004; // offset ServerPlayer* owner (0 for a tree with no player) [verified] constexpr uint32_t TechTree_off_Owner = 0x0000000c; // offset std::vector indexed by tech id (MSVC2010: 3 words {first@+0x10, last@+0x14, end@+0x18}); entries may be NULL [verified] constexpr uint32_t TechTree_off_Nodes = 0x00000010; +// offset int completion-order counter; SetResearched stamps node+0x28 from it and post-increments it. Harness-audit row 9 [verified] +constexpr uint32_t TechTree_off_OrderCounter = 0x00000020; // offset sizeof(TechNode) -- the ctor's operator new argument [verified] constexpr uint32_t TechNode_size = 0x00000034; // offset TechDef* def; *(int*)def is the tech id used to index TechTree_off_Nodes [verified] @@ -259,6 +265,18 @@ constexpr uint32_t TechNode_off_State = 0x00000014; constexpr uint32_t TechNode_off_CostRP = 0x00000018; // offset int progress in RP; the only node word ProcessResearch itself writes besides the flag [verified] constexpr uint32_t TechNode_off_Progress = 0x0000001c; +// offset int turnAvailable; SetResearched's second sweep stamps it with the server ModCount ONLY when it currently reads -1 (a sticky first-availability stamp). EVENT_TECHS_UNLOCKED collects nodes with state==2 && turnAvailable==currentTurn [verified] +constexpr uint32_t TechNode_off_TurnAvailable = 0x00000020; +// offset int turnResearched; SetResearched writes the server ModCount [verified] +constexpr uint32_t TechNode_off_TurnResearched = 0x00000024; +// offset int order; SetResearched writes the tree's completion-order counter, then bumps it [verified] +constexpr uint32_t TechNode_off_Order = 0x00000028; +// offset std::vector children (3 words at +0x04/+0x08/+0x0c) [verified] +constexpr uint32_t TechNode_off_Children = 0x00000004; +// offset int RP cost carried by the edge; SetResearched sets child.costRP = min(child.costRP, edge.costRP) [verified] +constexpr uint32_t TechEdge_off_CostRP = 0x0000001c; +// offset TechDef* the edge's child tech; SetResearched indexes tree->nodes by childDef->[0] [verified] +constexpr uint32_t TechEdge_off_ChildDef = 0x00000040; // offset int flag (1 default from the ctor, 0 completed below 80% of cost, 2 over-budget event raised) [verified] constexpr uint32_t TechNode_off_Flag = 0x0000002c; // thiscall void (MasterTechTree* this) /* fills TechDef*[196] at this+0 from g_TechIdNames */ [verified] @@ -351,6 +369,8 @@ constexpr uint32_t ServerPlayer_off_SetupResearchMult = 0x0000022c; constexpr uint32_t ServerPlayer_off_Sav = 0x00000284; // offset Tech* current research target (ResT); NULL = none [verified-by-save] constexpr uint32_t ServerPlayer_off_ResearchTarget = 0x00000294; +// offset std::vector otch (3 words {first,last,end}); save tag otch, element {int otnF, otnL, odet; string otch; int owith}. All three words move on every tech completion (a realloc) -- observed live by both the ProcessResearch and the OnTechResearched player guards. NOT PINNED: the append call site and sizeof(ObservedTech); the byte span the observed_techs region reports is what will measure the stride [verified-by-save] +constexpr uint32_t ServerPlayer_off_ObservedTechs = 0x00000274; // offset float IncMod [verified-by-save] constexpr uint32_t ServerPlayer_off_IncMod = 0x0000030c; // offset std::vector (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive}) [verified]