The second turn driver, 0x007d92a0, read byte for byte. Corrects turn-driver.md section 5: of the four subsystems it said live here, only bankruptcy does. - 36-phase map with strides enumerated (EncounterResults 0x178, Encounter 0x74, member 0x44) and the arity check that logs but does not return. - Phase 7 is encounters.clear(), not a filter: the erase pair is the same four-argument shape vector<Encounter>::operator= uses, and both arms converge three instructions later. - Bankruptcy: ProcessBankruptcy at phase 15, UpdateBankruptcyLimits at phase 31. Three corrections to formula-gaps Q1 - the divisor is the double -0.15000000596046448 not -0.15, the per-system income term is clamped at 0 before summing, and the 3.3 factor lives in .bss and is DB-loaded. - Turn results are FILLED here (phases 6, 11, 18 write S+0x2f4[PlyrIdx]) but rotated by ApplyEncounterResults and dispatched by SynchronizePlayer as event 0x25 afterwards. sizeof(SETurnResults) = 0x11c, enumerated five ways. - BuildTurnEvents is misnamed: it is the setup/load/rejoin resync push, gated on a pending descriptor, and references no EVENT_ string at all. - TurnEvents_Write and TurnEvents_Read are swapped in Ghidra (layouts.json is right). sizeof(TurnEvents) = 0x18, enumerated four ways. - The autosave: StrategyHost::Autosave 0x00895210, its four localized paths, the rotation that fires only on the post-turn call, the connection detach around the write, and why the payload carries nothing time-, name- or machine-derived. - Two RNG sources in the tail that nothing models: one NextFloat per expired node line, plus draws inside the combat resolver. Both run before the autosave. - S+0x8 advances twice per turn, not once. Repo-wide correction: the research-event roll costs one or two RNG words, not one. Fixed in unlock-cascade.md, addresses.json and lane-u.json; the captured compare artefacts under verify/results are left alone as run records.
100 lines
7.7 KiB
JSON
100 lines
7.7 KiB
JSON
{
|
|
"entries": [
|
|
{
|
|
"name": "TechTree_PrereqsMet",
|
|
"addr": "0x0057d8e0",
|
|
"convention": "thiscall",
|
|
"prototype": "bool (TechTree* this, TechPrereqs* prereqs /* = TechDef + 0x88 */) // RET 4. An AND over groups, each group an OR over techs: a group is satisfied by any listed tech whose node exists in this->nodes AND whose state (+0x14) is 4. Zero groups -> TRUE (the function returns satisfied==total with both 0); a group with ZERO entries -> FALSE and the whole test fails, because the inner loop cannot break and the outer one then exits with that group uncounted. Reads only. Called from SetResearched twice: the unforced completion gate on the argument def, and the availability sweep on each node's self-resolved def",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, get-decompilation 0x0057d8e0, whole function 148 B / 36 lines)"
|
|
},
|
|
{
|
|
"name": "TechDef_off_Prereqs",
|
|
"offset": "0x88",
|
|
"convention": "field",
|
|
"prototype": "TechPrereqs prereqs -- the block TechTree::PrereqsMet is called on. Two MSVC vectors back to back: the flat entry array at +0x00 and the group array at +0x10. SetResearched passes `def + 0x88` at both call sites",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, 0x00581e10 + 0x0057d8e0)"
|
|
},
|
|
{
|
|
"name": "TechPrereqs_off_Entries",
|
|
"offset": "0x0",
|
|
"convention": "field",
|
|
"prototype": "std::vector<TechPrereqEntry> at TechPrereqs+0x00; only its _Myfirst is read (`*param_2`), because the groups carry the bounds. Element stride 8, the entry's TechDef* at +0x00; the second word is never read by PrereqsMet",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, 0x0057d8e0: piVar3 = (int*)(*param_2 + iVar5*8), piVar3 += 2 per step)"
|
|
},
|
|
{
|
|
"name": "TechPrereqs_off_Groups",
|
|
"offset": "0x10",
|
|
"convention": "field",
|
|
"prototype": "std::vector<TechPrereqGroup> at TechPrereqs+0x10 (_Myfirst +0x10, _Mylast +0x14). Group count = (last - first) >> 3. Each group is {int start; int count} indexing the entry array: the OR-set is entries[start .. start+count)",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, 0x0057d8e0: iVar2 = param_2[5] - param_2[4] >> 3; iVar6 = piVar4[1] + iVar5)"
|
|
},
|
|
{
|
|
"name": "TechPrereqs_entry_stride",
|
|
"offset": "0x8",
|
|
"convention": "field",
|
|
"prototype": "sizeof(TechPrereqEntry) -- the flat prerequisite entry, {TechDef* def; int unread}",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, 0x0057d8e0)"
|
|
},
|
|
{
|
|
"name": "TechPrereqs_group_stride",
|
|
"offset": "0x8",
|
|
"convention": "field",
|
|
"prototype": "sizeof(TechPrereqGroup) -- {int start; int count} into the entry array",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, 0x0057d8e0)"
|
|
},
|
|
{
|
|
"name": "TechDef_off_NoAutoAvailable",
|
|
"offset": "0xb0",
|
|
"convention": "field",
|
|
"prototype": "BYTE. Non-zero excludes the node from SetResearched's availability sweep entirely (`if ((char)def[0x2c] != 0) continue`, i.e. def+0xb0) -- it can still be completed by an explicit SetResearched and its cost/state are still lowered by the parent-edge sweep, but nothing ever moves it to state 2 and it never stamps turnAvailable, so it can never raise EVENT_TECHS_UNLOCKED. THE WRITE SITE WAS NOT READ: the name records what the byte does, not where it comes from. The tech-file keyword `unlock_explicitly` is the obvious candidate and matches the behaviour exactly, but MasterTechTree::ParseTech 0x0058b050 shows no reference to 0xb0 in its decompilation, so the link is a hypothesis and not a fact",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, 0x00581e10 line 67)"
|
|
},
|
|
{
|
|
"name": "TechDef_off_Name",
|
|
"offset": "0x40",
|
|
"convention": "field",
|
|
"prototype": "std::string name (0x1c bytes, _Mysize at +0x50, _Myres at +0x54: >= 0x10 selects the heap pointer). The substitution for every research event's %s, and the key RecordObservedTech de-duplicates the observed-tech vector on",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/events.md 3.1 (def+0x40, _Myres at def+0x54, read at 0x00587a1d); re-used by lane U for the observed-tech dedup"
|
|
},
|
|
{
|
|
"name": "TechTree_SetResearched_flag_Refresh",
|
|
"offset": "0x8",
|
|
"convention": "constant",
|
|
"prototype": "flags bit 3 of SetResearched(def, flags): after the call, and after each recursive call from the availability sweep, run the refresh helper 0x00585ef0. Also enables the `node slot is NULL` re-lookup path at the head of the function (0x00580e30). No research-path call site sets it -- ProcessResearch passes 2 -- so neither behaviour is modelled",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, 0x00581e10 lines 24-26, 74-77)"
|
|
},
|
|
{
|
|
"name": "TechTree_ProcessResearch_TechsUnlockedCollector",
|
|
"addr": "0x00587cc3",
|
|
"convention": "site",
|
|
"prototype": "site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, get-decompilation 0x005876c0 lines 294-309)"
|
|
},
|
|
{
|
|
"name": "ServerPlayer_OnTechResearched_RecordObservedTech",
|
|
"addr": "0x00891790",
|
|
"convention": "site",
|
|
"prototype": "site at the very head of ServerPlayer::OnTechResearched: RecordObservedTech is the FIRST statement, called unconditionally on every completion -- before the ResT/roll block and before the !silent event post. It de-duplicates by tech name, so the observed-tech vector grows by one 0x2c element per completion of a tech not already observed and by nothing otherwise",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, decompilation of 0x00891790 line 78)"
|
|
},
|
|
{
|
|
"name": "ServerPlayer_OnTechResearched_ResearchRollBlock",
|
|
"addr": "0x00891790",
|
|
"convention": "site",
|
|
"prototype": "site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, decompilation of 0x00891790 lines 79-85, 0x0088df20, 0x00889d60)"
|
|
}
|
|
]
|
|
}
|