lane P: SetResearched cascade read; 10 new addresses.json entries
Read TechTree::SetResearched (0x00581e10) end to end while wiring the research event posts into ours (sots-engine wip/post-events). Two things it settles: - SetResearched invokes the owner callback as vft+0x10(def, (flags>>2)&1), so bit 2 of flags IS OnTechResearched's "silent" argument. ProcessResearch passes flags = 2, so silent is FALSE and the completion event IS posted. Previously this was only inferable from lane R's observation that a completion call moved EvNxID by two. - The unlock cascade in full: the node's child edges set child state 0->1 and child.costRP = min(child.costRP, edge.costRP); then a whole-tree sweep sets state 2 and stamps turnAvailable from ModCount ONLY when it currently reads -1 (so it is sticky, and a node re-entering state 2 is not re-announced), and recurses into any zero-cost available node. Both loops test the node itself, not a parent -- there is no parent clause anywhere in the function, confirming lane E's correction. Also records what is and is not known about vector<ObservedTech> at ServerPlayer+0x274: the on-disk element shape is confirmed against turn3-state.sav (its string holds a tech name), while sizeof(ObservedTech) and the append call site are still unpinned. The cheapest route to the stride is now a measurement from the new observed_techs region, not a search -- find-constant-uses does not index lea displacements. New entries: ServerPlayer_off_ObservedTechs, TechNode_off_TurnAvailable / TurnResearched / Order / Children, TechEdge_off_CostRP / ChildDef, TechTree_off_OrderCounter, TechTree_SetResearched_flag_Force / _flag_Silent. Generated header regenerated with tools/gen_addresses.py (386 -> 396 entries).
This commit is contained in:
parent
b0ef139928
commit
0cd84690af
3 changed files with 245 additions and 1 deletions
144
findings/subsystems/setresearched-cascade.md
Normal file
144
findings/subsystems/setresearched-cascade.md
Normal file
|
|
@ -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<ObservedTech> 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.
|
||||
|
|
@ -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<TechEdge*> 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<ObservedTech> 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",
|
||||
|
|
|
|||
|
|
@ -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 <cstdint>
|
||||
|
|
@ -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<TechNode*> 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<TechEdge*> 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<ObservedTech> 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<PlayerAid> (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive}) [verified]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue