From 405ba41a1e366162ec08fa676b9c75572debe097 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Sep 2026 06:26:00 -0400 Subject: [PATCH] U: the SetResearched unlock cascade, and the prediction for the run that checks it Pure module game/sim/techgraph: PrereqsMet (AND of ORs, empty group fails), SetResearched (stamps, child-cost sweep, sticky turnAvailable, zero-cost recursion) and the newly-available collector, all read out of 0x00581e10, 0x0057d8e0 and 0x00587cc3. Wired into the B3 hook in compare mode only, over the scratch node copies: four more node write-backs, the EVENT_TECHS_UNLOCKED list (still an input, still nullptr when it could not be computed), the de-duplicating observed-tech append and the one RNG word RollResearchEvent draws. docs/U-unlock.md section 4 is the prediction, written before the build was staged. --- docs/U-unlock.md | 285 +++++++++++++++++++ include/generated/sots_addresses.h | 26 +- src/game/sim/CMakeLists.txt | 5 +- src/game/sim/research.cpp | 5 +- src/game/sim/research.h | 13 +- src/game/sim/techgraph.cpp | 166 +++++++++++ src/game/sim/techgraph.h | 200 +++++++++++++ src/shim/hooks/research.cpp | 440 ++++++++++++++++++++++++++++- src/shim/hooks/research.h | 102 ++++--- tests/game_sim/CMakeLists.txt | 2 +- tests/game_sim/test_techgraph.cpp | 390 +++++++++++++++++++++++++ 11 files changed, 1584 insertions(+), 50 deletions(-) create mode 100644 docs/U-unlock.md create mode 100644 src/game/sim/techgraph.cpp create mode 100644 src/game/sim/techgraph.h create mode 100644 tests/game_sim/test_techgraph.cpp diff --git a/docs/U-unlock.md b/docs/U-unlock.md new file mode 100644 index 0000000..9348b4f --- /dev/null +++ b/docs/U-unlock.md @@ -0,0 +1,285 @@ +# U — the unlock cascade, and the prediction for the run that checks it + +Lane P deliberately did **not** post `EVENT_TECHS_UNLOCKED`. It could have — the trigger was +pinned — but evaluating it needs the cascade `ours` does not run, so it handed +`PostResearchPassEvents` a **`nullptr`** unlock list ("no list", distinct from an empty one) and +predicted the consequence: `next_id` short by exactly 1 on every completion call. Lane V measured +that live, twice, exactly. + +This lane removes the residual by running the cascade, not by posting an event when something +completes. The distinction matters for one reason and lane P named it: the shortcut would score +perfectly on this save and be wrong the first time a completion unlocks nothing. + +**Everything in §4 was written before the build was staged on VM140.** §5 is the outcome. + +--- + +## 1. What the cascade actually is + +Read out of the instruction stream by this lane: `TechTree::SetResearched` (0x00581e10, whole +function), `TechTree::PrereqsMet` (0x0057d8e0, whole function), the tail collector inside +`TechTree::ProcessResearch` (0x00587cc3), and the head of `ServerPlayer::OnTechResearched` +(0x00891790). Details and the decompiled shape are in sots-re +`findings/subsystems/setresearched-cascade.md`; the module is `src/game/sim/techgraph.{h,cpp}`. + +``` +SetResearched(tree, def, flags): + n = tree->nodes[def->techId] + if (!n) -> nothing (the flags&8 re-lookup path is not on the research path) + if (n->state == 4) -> return true, writing NOTHING, not even the order stamp + if (!(flags & 2) && !PrereqsMet(def + 0x88)) -> nothing + + n->state = 4 + n->turnResearched = owner ? ModCount : 0 + n->order = tree->orderCounter++ + owner->OnTechResearched(def, (flags >> 2) & 1) # flags = 2 from ProcessResearch: NOT silent + + for each edge in n->children: # sweep 1 + c = tree->nodes[edge->childDef->techId] + if (c->state == 0) c->state = 1 + c->costRP = min(c->costRP, edge->costRP) # signed, against an INT_MAX sentinel + + for each node m in tree->nodes: # sweep 2 + if (!m || m->def->[0xb0]) continue + p = tree->nodes[m->def->techId] + if (p && p->state == 1 && PrereqsMet(p->def + 0x88)): + m->state = 2 + if (m->turnAvailable == -1 && owner) m->turnAvailable = ModCount + if (m->state == 2 && Cost(m) == 0) SetResearched(m->def, flags) # recursion +``` + +Four properties are easy to get wrong and each is pinned by a host test: + +* **`turnAvailable` is sticky.** It is written only when it still reads −1. A node that returns to + state 2 in a later turn keeps its *first* availability turn — which is the whole reason + `EVENT_TECHS_UNLOCKED` can be computed from node state at all, and the reason a tech is never + announced twice. +* **The cost minimum is signed, against `INT_MAX`.** The first researched parent sets the child's + cost; later parents can only lower it. +* **`PrereqsMet` is an AND over groups, each group an OR over techs** — and a group with *zero* + entries makes the whole test **fail**, because the inner loop cannot break and the outer one + then exits with that group uncounted. A "vacuously true" reading gets this backwards. Zero + groups, by contrast, is satisfied. +* **Both sweeps and the collector re-resolve `tree->nodes[node->def->techId]`** and test *that* + node's state, not the iterated node's. For a well-formed tree it comes back to the same node; + it is reproduced because the code has it. + +`def+0xb0` is a byte that excludes a node from sweep 2 entirely. Its write site was **not** found +(`MasterTechTree::ParseTech` shows no reference to 0xb0), so `addresses.d/lane-u.json` names it +for what it *does* — `TechDef_off_NoAutoAvailable` — and records the `unlock_explicitly` tech-file +keyword as a hypothesis, not a fact. Nothing in this lane depends on which it is: the byte is read +from the live def either way. + +### The tail collector + +``` +if (tree->owner) { + turn = ModCount + for each node n: if (n && n->def && (p = tree->nodes[n->def->techId]) && + p->state == 2 && n->turnAvailable == turn) collect(n) + if (!collected.empty()) PostEvent(EVENT_TECHS_UNLOCKED, ...) +} +``` + +Note the asymmetry — the state test is on the self-resolved node, the turn test on the iterated +one — and note that it runs **once, after the whole per-node loop and after the decay sweep**. + +### The two halves of `OnTechResearched` this hook can see + +``` +OnTechResearched(player, def, silent): + RecordObservedTech(...) # FIRST statement, unconditional + if (player->ResT == def) { + if (player->ResearchRollPending) RollResearchEvent(player) # exactly one NextFloat + player->ResearchRollPending = 0 + player->ResT = 0 + } + if (!silent) { ... EVENT_RESEARCH_COMPLETE / _UNDERBUDGET ... } +``` + +`RecordObservedTech` **de-duplicates by tech name**, so "the vector did not grow" is a real +outcome and not a failure. `RollResearchEvent` draws one `NextFloat` unconditionally and only then +tests it against the odds; clearing `ResT` is what makes a *second* completion in the same pass +draw nothing. This is lane V's call-9 deviation, and it is now inside the compare rather than +declared out of scope. + +--- + +## 2. What changed + +| file | change | +|---|---| +| `src/game/sim/techgraph.{h,cpp}` (new) | the pure cascade: `TechPrereqsMet`, `SetResearched`, `CollectNewlyAvailable` | +| `src/game/sim/research.{h,cpp}` | one optional parameter: a completion hook called *inside* the allocation loop, where the original calls `SetResearched` — before the next entry's roll and before the decay sweep | +| `src/shim/hooks/research.{h,cpp}` | the pre-call scan the cascade needs, the live→graph transcription, the cascade wiring, four more node write-backs, the unlock list, the observed-tech append, the RNG draw, the rewritten coverage | +| `tests/game_sim/test_techgraph.cpp` (new) | 13 cases, 92 checks | +| `ghidra/addresses.d/lane-u.json` (sots-re) | 12 new entries; header regenerated 615 → 627 | + +`tools/clean_room_check.sh` — **OK**. Host `ctest` — **34/34** (was 33/33; `game_sim_techgraph` is +new). Run as separate commands. The shim TU is syntax-checked on the host +(`-std=c++17 -Wall -Wextra -fsyntax-only`, clean) and cross-built on CT111 before deploy. + +### The four pre-call reads, and why each one is a trap + +`ours` runs **after** the original in compare mode. Four inputs the cascade needs are things the +original changes during the call, and each of them reads back a *plausible* wrong answer: + +| input | read after the original would give | consequence | +|---|---|---| +| `TechTree+0x20` order counter | the *next* order | every `node.order` off by one per completion | +| `ServerPlayer+0x294` (`ResT`) | 0 — cleared by the callback | the extra RNG draw never modelled | +| `ServerPlayer+0x3b4` (pending roll) | 0 — cleared in the same block | same | +| `ServerPlayer+0x274` observed-tech names | the completing tech already present | the append modelled as a de-duplication, agreeing with a count it did not compute | + +All four are taken in `describe_args`, which `hook.h` calls immediately before the original, and +all four are reported as arguments (`order_counter_in`, `research_target`, `roll_pending_in`, +`observed_techs_in`) so a run can be audited without trusting `ours`. + +### What is modelled, and what is deliberately not + +The cascade runs in **compare mode only**. In replace mode every pointer is live game memory, and +applying half of `OnTechResearched` — the observed-tech append and the research-event roll, but +not the ninety-odd tech-effect field writes — would leave the player in a state no code path +produces. Not running it leaves a player missing a cascade, which is a smaller and already +declared lie. The same reasoning lane P used for the event counts. + +`TechTree::Cost` is called, not re-derived: it is the game's own read-only function, and the +cascade needs it on the node *as it stands after the child-cost sweep*. Guessing the cost +multiplier would put a second unknown inside the thing being measured. + +The unlock list is still an **input** to `PostResearchPassEvents`, and `nullptr` still means "this +caller could not compute it" — now reached when the graph fails to transcribe or the tail's +`tree->owner != 0` gate is closed, rather than always. An empty vector means "computed, and +nothing became available", which posts nothing. Keeping those apart is what stops a failure to +read the tree from scoring as a correct silence. + +--- + +## 3. Why a clean result here would not be vacuous + +A hook that silently compares nothing is the failure mode to fear when a clean run is *expected*, +and this lane is exactly that situation. Three things make it hard here: + +1. **The expected values are non-trivial and known in advance.** `cost_rp` must come back 10000, + 16000 and 8000 on three specific nodes, `turn_available` 4, `order` 22 and 23. A model that + computed nothing leaves them at `INT_MAX`, 0 and −1 — which is precisely what lane V's report + shows today. There is no "do nothing" answer that passes. +2. **The collector runs on all fifteen calls, not just the two completions.** On the thirteen + calls with no completion it must come back *empty*; if the transcription were wrong in a way + that over-collected, `next_id` would be **too high** and those calls would newly diverge. The + quiet calls are as much of a check as the loud ones. +3. **The shim log prints the counters.** One line per call: + `cascade ok=… completions=… unlocked=… otch_appends=… roll_draws=… failures=… depth=… + name_unreadable=…`. A clean compare with all of those at zero on a completion call would be a + clean compare of nothing, and it would be visible. + +--- + +## 4. The prediction + +Written before the build was staged. Recipe unchanged: `shim.cfg.recapb3`, `ref-turn2.sav` → +Launch → End Turn, then four more End Turns in the same session. + +### 4.1 First End Turn — nothing should change + +Turn 1 of `ref-turn2` completes no tech (lane V: `observed_techs.bytes` unchanged on all three +calls), so the cascade never fires. **3 calls, 3 compared, 0 divergent, `tracecmp` exit 0** — +identical to lane V's result, and that is the point: the new code must be inert when nothing +completes. + +| new argument | call 0 | calls 1, 2 | +|---|---|---| +| `observed_techs_in` | **10** (lane V measured 440 bytes = 10 × 44) | **20** (880 bytes) | +| `research_target` | non-null (the player has a funded target) | either | +| `roll_pending_in` | **false** | false | +| `order_counter_in` | ≥ 0, and not −1 | ≥ 0 | +| `observed_scan_failed` / `observed_scan_truncated` | **absent** | absent | + +Shim log, all three calls: `cascade ok=1 completions=0 unlocked=0 otch_appends=0 roll_draws=0 +failures=0 depth=0 name_unreadable=0`. + +If `ok=0` on any call the transcription failed and every clean field below is meaningless. If +`unlocked` is non-zero on a call with no completion, the collector is over-collecting and +`next_id` will be too high — the opposite failure from lane P's, and worth more than a pass. + +### 4.2 The five-turn continuation — the headline + +**Predicted: 15 calls, 15 compared, 0 divergent, `tracecmp` exit 0.** Every one of the 22 +divergent fields lane V recorded, gone. Field for field, against +`verify/results/compare/eventlive-b3-t1-5.json`: + +| call 3 field | orig | ours before | ours now | +|---|---|---|---| +| `events.next_id` | 7 | 6 | **7** | +| `node[132].cost_rp / state / turn_available` | 10000 / 2 / 4 | INT_MAX / 0 / −1 | **10000 / 2 / 4** | +| `node[136].cost_rp / state / turn_available` | 16000 / 2 / 4 | INT_MAX / 0 / −1 | **16000 / 2 / 4** | +| `node[142].cost_rp / state / turn_available` | 8000 / 2 / 4 | INT_MAX / 0 / −1 | **8000 / 2 / 4** | +| `node[144].order / turn_researched` | 22 / 4 | −1 / −1 | **22 / 4** | +| `observed_techs.bytes` | 484 | 440 | **484** | +| `rng` | not divergent | — | still not divergent | + +| call 9 field | orig | ours before | ours now | +|---|---|---|---| +| `events.next_id` | 12 | 11 | **12** | +| `node[133].cost_rp / state / turn_available` | 8000 / 2 / 6 | INT_MAX / 0 / −1 | **8000 / 2 / 6** | +| `node[142].order / turn_researched` | 23 / 6 | −1 / −1 | **23 / 6** | +| `observed_techs.bytes` | 528 | 484 | **528** | +| `rng.left / next_index` | 374 / 250 | 375 / 249 | **374 / 250** | + +And the arguments that say it is right for the right reason — these are the sharp ones, because +they are consequences of the model rather than restatements of it: + +* `order_counter_in` = **22** on call 3 and **23** on call 9. If it is 23 and 24, the counter was + read after the original and every `order` will be off by one. +* `roll_pending_in` = **false on call 3, true on call 9**. This is forced by lane V's data: call 3 + completes with no extra draw and call 9 with one, and the funded tech is the research target on + both, so the pending byte is the only thing that can differ. If call 3 comes back `true` the + model of the gate is wrong and call 3's `rng` will newly diverge. +* `observed_techs_in` = **10** on call 3, **11** on call 9. +* Shim log on call 3: `completions=1 unlocked=3 otch_appends=1 roll_draws=0 failures=0`. + On call 9: `completions=1 unlocked=1 otch_appends=1 roll_draws=1 failures=0`. + `failures`, `depth` and `name_unreadable` must be 0 everywhere. + +### 4.3 Coverage and guards — what must NOT change + +No Result region was added or removed, so the guards see exactly what they saw: + +* **undeclared writes: 10 in 2 calls**, the same eight spans — `player+0x10c:3`, `+0x110:3`, + `+0x114:3`, `+0x124:3`, `+0x294:4`, `+0x196:1`, `+0x3b4:1`, `tree_header+0x20:1`. `+0x294` and + `+0x3b4` stay undeclared even though `ours` now *reads* them: reading an input is not modelling + a write. `tree_header+0x20` likewise — the counter is seeded and advanced in the model, but the + live word is only ever the original's. +* The coverage verdict stays **partial**, with **8** unmodelled notes (was 6): the two event-text + notes, replace mode, the tech effects, the `RollResearchEvent` branch behind the draw, the + ObservedTech element's own fields, the order counter, and the log line. + +### 4.4 The End-Turn oracle + +`(Autosave EndTurn).sav` = `bb4fd9ac89f41e3b…` and `(Autosave).sav` = `978041acd168b56e…`, +unchanged. The cascade writes only scratch memory, so the running game must be byte-identical to +lane R's and lane V's runs. **If either hash moves, the cascade is writing live memory and the +result must be thrown away regardless of how clean the compare is.** + +### 4.5 Zuul (`zuul-turn5.sav`, one more End Turn) + +Less certain — this is new ground, and lane V's save has no completion yet. Predicted: the +End Turn reaches a species-5 completion; that call shows `species = 5`, the generator advancing by +**two** for the completion roll (the Zuul double roll) plus **one more** if `roll_pending_in` is +true, and **0 divergences**. The double roll and the completion path have never been exercised +together. + +### 4.6 What would falsify the model, and how it would show + +| if wrong | symptom | +|---|---| +| `def+0xb0` is not the sweep-2 exclusion | too few nodes unlocked → `state`/`turn_available` diverge and `next_id` is short again | +| the prereq offsets (0x88 / +0x00 / +0x10, stride 8) | `PrereqsMet` false everywhere → children stop at state 1 → same symptom | +| `TechDef_off_Name` (0x40) | `name_unreadable=1` in the log, no observed-tech write-back, `observed_techs.bytes` diverges as before | +| the `ResT == def` gate | `roll_draws=0` on call 9 → `rng` diverges as before | +| the collector's predicate is too loose | `next_id` **too high** on a quiet call — a new divergence, not an old one | + +--- + +## 5. Outcome + +To be filled in from the run. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 4d59dc5..d0091f3 100644 --- a/include/generated/sots_addresses.h +++ b/include/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 @ 857db34, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 545c715, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1237,5 +1237,29 @@ constexpr uint32_t Game_StarSystem_OutputRates_Write = 0x00345190; constexpr uint32_t Game_StarSystem_OutputRates_Read = 0x003472a0; // layout sizeof(Game::StarSystem::OutputRates) -- enumeration meets embedding [verified] constexpr uint32_t sizeof_Game_StarSystem_OutputRates = 0x0000001c; +// thiscall 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 [verified] +constexpr uint32_t TechTree_PrereqsMet = 0x0017d8e0; +// field 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 [verified] +constexpr uint32_t TechDef_off_Prereqs = 0x00000088; +// field std::vector 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 [verified] +constexpr uint32_t TechPrereqs_off_Entries = 0x00000000; +// field std::vector 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) [verified] +constexpr uint32_t TechPrereqs_off_Groups = 0x00000010; +// field sizeof(TechPrereqEntry) -- the flat prerequisite entry, {TechDef* def; int unread} [verified] +constexpr uint32_t TechPrereqs_entry_stride = 0x00000008; +// field sizeof(TechPrereqGroup) -- {int start; int count} into the entry array [verified] +constexpr uint32_t TechPrereqs_group_stride = 0x00000008; +// field 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 [verified] +constexpr uint32_t TechDef_off_NoAutoAvailable = 0x000000b0; +// field 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 [verified] +constexpr uint32_t TechDef_off_Name = 0x00000040; +// constant 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 [verified] +constexpr uint32_t TechTree_SetResearched_flag_Refresh = 0x00000008; +// site 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 [verified] +constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3; +// site 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 [verified] +constexpr uint32_t ServerPlayer_OnTechResearched_RecordObservedTech = 0x00491790; +// site 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 EXACTLY ONE NextFloat unconditionally and then enters 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. This is the one extra RNG word a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified] +constexpr uint32_t ServerPlayer_OnTechResearched_ResearchRollBlock = 0x00491790; } // namespace sots::addr diff --git a/src/game/sim/CMakeLists.txt b/src/game/sim/CMakeLists.txt index e0dc2dd..8cdbaf4 100644 --- a/src/game/sim/CMakeLists.txt +++ b/src/game/sim/CMakeLists.txt @@ -6,7 +6,8 @@ add_library(sots_game_sim STATIC economy.cpp research.cpp colony.cpp - movement.cpp) + movement.cpp + techgraph.cpp) target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..) target_compile_features(sots_game_sim PUBLIC cxx_std_17) if(NOT MSVC) @@ -16,7 +17,7 @@ endif() option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) if(SOTS_GAME_SIM_TESTS) enable_testing() - set(_sim_tests economy research colony movement) + set(_sim_tests economy research colony movement techgraph) foreach(_t IN LISTS _sim_tests) add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) diff --git a/src/game/sim/research.cpp b/src/game/sim/research.cpp index 32e91d2..2d74ef7 100644 --- a/src/game/sim/research.cpp +++ b/src/game/sim/research.cpp @@ -117,7 +117,8 @@ void DecayAllResearch(std::vector& nodes) { ResearchTurnResult ProcessResearchTurn(std::vector& nodes, const std::vector& alloc, Species owner, - IRandom& rng) { + IRandom& rng, ResearchCompletionHook onCompleted, + void* hookCtx) { ResearchTurnResult out; out.steps.reserve(alloc.size()); for (const ResearchAllocEntry& e : alloc) { @@ -125,6 +126,8 @@ ResearchTurnResult ProcessResearchTurn(std::vector& nodes, ResearchStepResult s = ApplyResearchPoints(nodes[e.nodeIndex], e.points, owner, rng); out.overbudget += s.overbudget; out.steps.push_back(s); + // SetResearched runs here in the original -- inside the loop, before the decay sweep. + if (s.completed && onCompleted) onCompleted(hookCtx, out.steps.size() - 1, e.nodeIndex); } DecayAllResearch(nodes); return out; diff --git a/src/game/sim/research.h b/src/game/sim/research.h index 2b9f86f..93d162d 100644 --- a/src/game/sim/research.h +++ b/src/game/sim/research.h @@ -145,6 +145,16 @@ struct ResearchTurnResult { std::vector steps; // one per allocation entry, in order }; +// Called the instant an allocation entry completes its node -- after the roll, before the pass +// moves to the next entry and before the decay sweep. That is exactly where the original calls +// `TechTree::SetResearched`, and the position matters twice over: the cascade's own effects (a +// child made available cannot then decay) and the owner callback's RNG draw both have to land +// between this entry's roll and the next one's. `stepIndex` indexes `ResearchTurnResult::steps`. +// +// A caller that models the cascade may write node states through it; the pass only ever raises a +// state, so a hook that raises more of them stays consistent with what follows. +using ResearchCompletionHook = void (*)(void* ctx, std::size_t stepIndex, int nodeIndex); + // The per-turn pass: apply every allocation entry to its node in order, then decay every // available tech that has progress. `nodes[i].cost` must already hold the node's effective // cost; a slot that does not exist in the tree should be left in state Hidden so the decay @@ -152,7 +162,8 @@ struct ResearchTurnResult { // CONFIDENCE: high. ResearchTurnResult ProcessResearchTurn(std::vector& nodes, const std::vector& alloc, Species owner, - IRandom& rng); + IRandom& rng, ResearchCompletionHook onCompleted = nullptr, + void* hookCtx = nullptr); // --------------------------------------------------------------------------------------- // Lab accidents diff --git a/src/game/sim/techgraph.cpp b/src/game/sim/techgraph.cpp new file mode 100644 index 0000000..075cca7 --- /dev/null +++ b/src/game/sim/techgraph.cpp @@ -0,0 +1,166 @@ +#include "game/sim/techgraph.h" + +#include +#include + +namespace sots::sim { + +namespace { + +bool in_range(const TechGraph& g, int i) { + return i >= 0 && static_cast(i) < g.nodes.size(); +} + +// `tree->nodes[node->def->techId]` -- the self-resolving indirection both sweeps and the +// collector perform. Returns null when the index is out of the vector or the slot is empty, +// which is exactly the null the original's two defensive checks catch. +const TechGraphNode* resolve_self(const TechGraph& g, const TechGraphNode& n) { + if (!in_range(g, n.selfIndex)) return nullptr; + const TechGraphNode& p = g.nodes[static_cast(n.selfIndex)]; + return p.present ? &p : nullptr; +} + +constexpr int kStateHidden = static_cast(TechState::Hidden); +constexpr int kStateParentResearched = static_cast(TechState::ParentResearched); +constexpr int kStateAvailable = static_cast(TechState::Available); +constexpr int kStateResearched = static_cast(TechState::Researched); + +TechCascadeResult SetResearchedAt(TechGraph& g, int defIndex, unsigned flags, + const TechCascadeEnv& env, int depth); + +} // namespace + +bool TechPrereqsMet(const TechGraph& g, int defIndex) { + if (!in_range(g, defIndex)) return false; + const TechPrereqs& p = g.nodes[static_cast(defIndex)].prereqs; + if (p.unreadable) return false; + for (const std::vector& group : p.groups) { + // A group is satisfied by any one of its techs being researched. An EMPTY group is + // satisfied by nothing, and the original's outer loop then breaks with the group + // uncounted -- so the whole test fails. That is why this is `any_of` and not a + // vacuous truth. + bool satisfied = false; + for (int idx : group) { + if (!in_range(g, idx)) continue; // a null entry def, or a tech this tree lacks + const TechGraphNode& t = g.nodes[static_cast(idx)]; + if (t.present && t.state == kStateResearched) { + satisfied = true; + break; + } + } + if (!satisfied) return false; + } + return true; +} + +namespace { + +TechCascadeResult SetResearchedAt(TechGraph& g, int defIndex, unsigned flags, + const TechCascadeEnv& env, int depth) { + TechCascadeResult r; + if (depth > env.maxDepth) { + r.depthExceeded = true; + return r; + } + if (!in_range(g, defIndex)) return r; + if (!g.nodes[static_cast(defIndex)].present) return r; + if (g.nodes[static_cast(defIndex)].state == kStateResearched) { + // The original returns true here having written nothing -- not even the order stamp. + r.alreadyResearched = true; + r.ran = true; + return r; + } + if (!(flags & kTechForce) && !TechPrereqsMet(g, defIndex)) return r; + + { + TechGraphNode& n = g.nodes[static_cast(defIndex)]; + n.state = kStateResearched; + // With no owner the turn stamp is a literal 0, not the turn: the original reads the turn + // through the owner pointer and substitutes 0 when there is none. + n.turnResearched = g.hasOwner ? g.turn : 0; + n.order = g.orderCounter; + g.orderCounter += 1; + } + r.ran = true; + r.completed.push_back(defIndex); + + if (g.hasOwner && env.onResearched) + env.onResearched(env.ctx, defIndex, (flags & kTechSilent) != 0); + + // ---- sweep 1: the completed node's own outgoing edges -------------------------------- + // + // Read after the callback, deliberately: the original re-reads the children vector from the + // node on every iteration, so a callback that changed the tree would be seen. Nothing on the + // research path does, and this copy of the edge list is taken from the node as it stands. + { + const std::vector children = g.nodes[static_cast(defIndex)].children; + for (const TechEdgeRef& e : children) { + if (!in_range(g, e.childIndex)) continue; // the original does not null-check here + TechGraphNode& c = g.nodes[static_cast(e.childIndex)]; + if (c.state == kStateHidden) c.state = kStateParentResearched; + if (e.costRP < c.costRP) { + c.costRP = e.costRP; + r.costLowered.push_back(e.childIndex); + } + } + } + + // ---- sweep 2: every node in the tree -------------------------------------------------- + for (std::size_t i = 0; i < g.nodes.size(); ++i) { + if (!g.nodes[i].present) continue; + if (g.nodes[i].excludedFromSweep) continue; + + bool becameAvailable = false; + { + const TechGraphNode* self = resolve_self(g, g.nodes[i]); + if (self && self->state == kStateParentResearched && + TechPrereqsMet(g, g.nodes[i].selfIndex)) { + TechGraphNode& n = g.nodes[i]; + n.state = kStateAvailable; + becameAvailable = true; + // Sticky: written only when it still reads the sentinel, so a node that returns + // to Available in a later turn keeps its FIRST availability turn and is never + // announced twice. + if (n.turnAvailable == -1 && g.hasOwner) n.turnAvailable = g.turn; + } + } + if (becameAvailable) r.madeAvailable.push_back(static_cast(i)); + + // Outside the branch above: a node that was already available and costs nothing is + // researched too, on every cascade, not only on the one that unlocked it. + if (g.nodes[i].state == kStateAvailable && env.cost && + env.cost(env.ctx, static_cast(i)) == 0) { + TechCascadeResult sub = SetResearchedAt(g, g.nodes[i].selfIndex, flags, env, depth + 1); + r.completed.insert(r.completed.end(), sub.completed.begin(), sub.completed.end()); + r.madeAvailable.insert(r.madeAvailable.end(), sub.madeAvailable.begin(), + sub.madeAvailable.end()); + r.costLowered.insert(r.costLowered.end(), sub.costLowered.begin(), + sub.costLowered.end()); + r.depthExceeded = r.depthExceeded || sub.depthExceeded; + } + } + return r; +} + +} // namespace + +TechCascadeResult SetResearched(TechGraph& g, int defIndex, unsigned flags, + const TechCascadeEnv& env) { + return SetResearchedAt(g, defIndex, flags, env, 0); +} + +std::vector CollectNewlyAvailable(const TechGraph& g, int currentTurn) { + std::vector out; + for (std::size_t i = 0; i < g.nodes.size(); ++i) { + const TechGraphNode& n = g.nodes[i]; + if (!n.present) continue; + const TechGraphNode* self = resolve_self(g, n); + if (!self) continue; + if (self->state != kStateAvailable) continue; + if (n.turnAvailable != currentTurn) continue; + out.push_back(static_cast(i)); + } + return out; +} + +} // namespace sots::sim diff --git a/src/game/sim/techgraph.h b/src/game/sim/techgraph.h new file mode 100644 index 0000000..140ab79 --- /dev/null +++ b/src/game/sim/techgraph.h @@ -0,0 +1,200 @@ +// The runtime tech graph and `TechTree::SetResearched`'s unlock cascade. +// +// `research.h` models what `TechTree::ProcessResearch` writes to a single node: points in, +// odds, roll, completion. This header models what happens *after* a node completes -- the +// function ProcessResearch calls at that moment, `TechTree::SetResearched(def, flags)`, which +// stamps the completed node, tells the owner, and then walks the graph making other nodes +// visible and available. It is the piece that decides whether the turn raises +// EVENT_TECHS_UNLOCKED, and it is the only place `turnAvailable` is ever written. +// +// Everything here was read out of the instruction stream at 0x00581e10 (the cascade), +// 0x0057d8e0 (the prerequisite test) and 0x005876c0's tail (the collector). See sots-re +// findings/subsystems/setresearched-cascade.md and docs/U-unlock.md. Nothing here is inferred +// from the shipped tech-tree data files: the file format has an `allows` edge and a `requires` +// line, but the *semantics* -- which of the two gates availability, in what order, and with what +// stickiness -- come from the code, and they are not what the file layout suggests. +// +// The module is pure: indices, ints and callbacks. It owns no game memory and reads none. The +// caller (the shim) transcribes the live tree into `TechGraph`, runs the cascade, and copies the +// results back into whatever it is allowed to write. +#pragma once + +#include +#include + +#include "game/sim/research.h" + +namespace sots::sim { + +// --------------------------------------------------------------------------------------- +// Flags +// --------------------------------------------------------------------------------------- +// +// `SetResearched`'s third argument. Bit 0 is unused by every call site the project has read. +// ProcessResearch passes exactly `kForce` (2), which is why a research completion is never +// silent and never refreshes. +enum TechResearchFlags : unsigned { + // Skip the prerequisite test and complete unconditionally. ProcessResearch sets it: the + // completion roll has already been won, so the graph does not get a second veto. + kTechForce = 0x2, + // Passed straight through to the owner callback as its `silent` argument: + // `vft+0x10(def, (flags >> 2) & 1)`. CONFIDENCE: high -- read off the call instruction. + kTechSilent = 0x4, + // After the call, and after each recursive call, the original runs a refresh helper + // (0x00585ef0). It is not modelled and no research-path call site sets this bit. + kTechRefresh = 0x8, +}; + +// --------------------------------------------------------------------------------------- +// The graph +// --------------------------------------------------------------------------------------- + +// One outgoing edge of a node -- the game's `TechEdge`, of which a node holds a vector at +0x04. +// Only two of its fields matter to the cascade: which tech it leads to, and what that tech costs +// when reached through this parent. +struct TechEdgeRef { + int childIndex = -1; // tree->nodes index of `edge->childDef->techId` + int costRP = 0; // TechEdge+0x1c +}; + +// A prerequisite set is an AND over groups, each group an OR over techs: a group is satisfied by +// *any* listed tech being researched, and every group must be satisfied. Read out of 0x0057d8e0, +// which walks a vector of {start, count} groups indexing a flat entry array. +// +// Two edge cases are the original's, not a simplification: +// * **no groups at all** -> satisfied (the function returns `count == count` with both zero); +// * **a group with no entries** -> NOT satisfied, and the whole test fails immediately. The +// inner loop cannot break, so `i == end` breaks the outer loop with the group uncounted. +struct TechPrereqs { + std::vector> groups; + // The caller could not read the prerequisite structure. Treated as "not met", and reported + // rather than guessed: a false "met" would unlock techs the original leaves alone. + bool unreadable = false; +}; + +// A node of the tree as the cascade sees it. Field names follow the game's own layout +// (TechNode+0x14 state, +0x18 costRP, +0x20 turnAvailable, +0x24 turnResearched, +0x28 order). +struct TechGraphNode { + // `tree->nodes[i] != NULL`. The vector is indexed by tech id and is sparse: a species' tree + // holds a null for every tech that species cannot reach. Both sweeps skip nulls. + bool present = false; + + // TechDef+0xb0, a byte that excludes the node from the availability sweep entirely -- it can + // still be completed by an explicit SetResearched, but nothing ever makes it *available*. + // The byte's data-file source was not read; see docs/U-unlock.md for what is and is not known. + bool excludedFromSweep = false; + + // `def->techId` (TechDef+0x00). Both sweeps re-resolve `tree->nodes[node->def->techId]` and + // test the state of *that* node rather than of the node they are iterating. For a well-formed + // tree it resolves back to the same node; the indirection is reproduced because the code has + // it, and because a tree where it did not hold would behave differently. + int selfIndex = -1; + + int state = 0; // TechState, but the raw int: the game stores 0..4 + int costRP = kNoResearchCost; // +0x18; INT_MAX until a researched parent lowers it + int turnAvailable = -1; // +0x20; written once, then sticky (see below) + int turnResearched = -1; // +0x24 + int order = -1; // +0x28 + + std::vector children; // +0x04 vector + TechPrereqs prereqs; // def+0x88 +}; + +struct TechGraph { + std::vector nodes; + int orderCounter = 0; // TechTree+0x20; SetResearched stamps then post-increments it + bool hasOwner = false; // TechTree+0x0c != NULL + int turn = 0; // the owner's server ModCount, the value both stamps are written from +}; + +// --------------------------------------------------------------------------------------- +// The prerequisite test -- TechTree::PrereqsMet, 0x0057d8e0 +// --------------------------------------------------------------------------------------- +// +// `defIndex` names the node whose *def* carries the prerequisite structure. A listed tech counts +// as satisfying its group only when its node exists AND its state is Researched (4). +bool TechPrereqsMet(const TechGraph& g, int defIndex); + +// --------------------------------------------------------------------------------------- +// The cascade -- TechTree::SetResearched, 0x00581e10 +// --------------------------------------------------------------------------------------- + +// What the caller must supply, because it lives outside the graph. +// +// `Cost` is `TechTree::Cost` (0x0057da00), which the cascade consults once per node in its +// second sweep: a node that is available and costs nothing completes immediately, recursively. +// It is a function of the node's *current* costRP -- which the first sweep has just lowered -- +// so it cannot be pre-computed, and a caller with the game in hand should call the game's own +// read-only implementation rather than re-deriving the cost multiplier. +// +// `OnResearched` is the owner callback, `ServerPlayer::OnTechResearched(def, silent)` (vft slot 4 +// of the owner at TechTree+0x0c). The cascade invokes it *before* either sweep, which is the only +// thing that makes its position in the RNG stream and in the event order well defined. It is a +// callback and not modelled here because what it does -- tech effects, the observed-tech append, +// possibly one RNG draw -- is the owner's business, not the graph's. +struct TechCascadeEnv { + int (*cost)(void* ctx, int nodeIndex) = nullptr; + void (*onResearched)(void* ctx, int nodeIndex, bool silent) = nullptr; + void* ctx = nullptr; + + // The original has no recursion guard: a zero-cost cycle would hang the game. A + // reimplementation running inside that game must not hang it, so the depth is capped and + // exceeding the cap is reported (see `TechCascadeResult::depthExceeded`) rather than + // silently truncated. 64 is far past anything a real tree reaches -- the deepest observed + // cascade is 1. + int maxDepth = 64; +}; + +struct TechCascadeResult { + // Indices set to Researched by this call, in the order they completed -- the first is the + // node the caller named, the rest are zero-cost nodes the second sweep swept up. + std::vector completed; + // Indices the second sweep moved into state Available (2). A node already available is not + // listed: the sweep's write is guarded by the node being in state 1. + std::vector madeAvailable; + // Indices whose costRP the first sweep lowered. + std::vector costLowered; + bool ran = false; // false: the node was absent, or the prerequisite test failed + bool alreadyResearched = false; + bool depthExceeded = false; +}; + +// Complete `defIndex` and run the cascade. Returns the original's boolean: true when the node +// was already researched (a no-op) or when it completed, false when it could not. +// +// Order, exactly as coded, because every part of it is observable somewhere: +// 1. absent node -> nothing (the `flags & 8` re-lookup path is not modelled; no research call +// site sets that bit); +// 2. state already Researched -> return true, write nothing at all; +// 3. unless forced, the prerequisite test must pass; +// 4. state = 4; turnResearched = owner ? turn : 0; order = orderCounter++; +// 5. the owner callback, if there is an owner; +// 6. sweep 1, over the completed node's own edges: a child in state Hidden becomes +// ParentResearched, and every child's costRP is lowered to the edge's cost if the edge is +// cheaper. The `min` is signed against a costRP whose initial value is INT_MAX, so the +// first researched parent sets it and later parents can only lower it; +// 7. sweep 2, over *every* node in the tree: a node whose self-resolved node is in state +// ParentResearched and whose prerequisites are now met becomes Available, and stamps +// `turnAvailable` **only if it currently reads -1**. That stickiness is what stops a node +// from being announced twice, and it is why EVENT_TECHS_UNLOCKED can be computed from the +// node state alone. Then, still in the same iteration and regardless of whether the node +// just changed, an Available node that costs nothing is researched recursively. +TechCascadeResult SetResearched(TechGraph& g, int defIndex, unsigned flags, + const TechCascadeEnv& env); + +// --------------------------------------------------------------------------------------- +// The collector -- the tail of TechTree::ProcessResearch, 0x00587cc3 +// --------------------------------------------------------------------------------------- + +// Every node that is available *and* was made available this turn, in tree order. The original +// posts EVENT_TECHS_UNLOCKED once if this list is non-empty, and not at all if it is empty -- +// which is why a caller must be able to tell "computed, and empty" from "not computed". +// +// The state test is on the self-resolved node and the turn test on the iterated one, which is +// how the original writes it. Note this reads only state and turnAvailable: a node made +// available on an earlier turn is not re-announced even if something else about it changed, and +// a node made available this turn by an *earlier* completion in the same pass is announced by +// the later one too -- both are consequences of running the collector once, after the loop. +std::vector CollectNewlyAvailable(const TechGraph& g, int currentTurn); + +} // namespace sots::sim diff --git a/src/shim/hooks/research.cpp b/src/shim/hooks/research.cpp index dd45150..b8e5b21 100644 --- a/src/shim/hooks/research.cpp +++ b/src/shim/hooks/research.cpp @@ -16,6 +16,7 @@ #include "game/events/research_events.h" #include "game/sim/research.h" #include "game/sim/species.h" +#include "game/sim/techgraph.h" #include "generated/sots_addresses.h" #include "mars/rng/mt19937.h" #include "shim/hooks/event_inputs.h" @@ -49,6 +50,17 @@ constexpr std::size_t kEventsNextIdOff = A::EventStorage_off_EvNxID; // 0x14 // anywhere. Declaring it turns an undeclared write into a check. constexpr std::size_t kObservedTechsOff = A::ServerPlayer_off_ObservedTechs; // 0x274 constexpr std::size_t kVectorHeaderSize = 0xc; // {first,last,end} +constexpr std::size_t kObservedTechSize = A::ObservedTech_sizeof; // 0x2c +constexpr std::size_t kMaxObservedTechs = 4096; // loop guard on a garbage vector header +// TechDef, as far as the cascade needs it: the tech id it is indexed by, the name the +// observed-tech list is de-duplicated on, the prerequisite block PrereqsMet walks, and the byte +// that keeps a node out of the availability sweep. Reading def+0xb0 is the deepest of these, so +// one probe of that length covers the lot. +constexpr std::size_t kTechDefProbe = A::TechDef_off_NoAutoAvailable + 1; // 0xb1 +constexpr std::size_t kTechEdgeProbe = A::TechEdge_off_ChildDef + 4; // 0x44 +constexpr std::size_t kMaxEdgesPerNode = 256; +constexpr std::size_t kMaxPrereqGroups = 64; +constexpr std::size_t kMaxPrereqEntries = 256; // The turn every event is filed under: the owner's second StrategyServer base is at // ServerPlayer+8 and the turn counter (ModCount) sits at +8 in it. Read out of the instruction // stream twice -- lane E on every research-path post site, and again at 0x00581e10 where @@ -150,6 +162,7 @@ struct CallState { int events_region = -1; // the owner's EventStorage, -1 when the owner is unreadable int otch_region = -1; // the owner's vector void* scratch_events = nullptr; // compare mode: the scratch copy of the EventStorage header + void* scratch_otch = nullptr; // compare mode: the scratch copy of the ObservedTech header std::vector node_region; // node index -> region index, -1 when the slot is null std::vector names; // stable storage for Region::name std::vector scratch_nodes; @@ -171,6 +184,37 @@ struct EventScan { }; EventScan g_scan; +// Everything else the cascade needs that the ORIGINAL changes during the call, and that `ours` +// therefore cannot read when it runs afterwards. Same reasoning, and the same trap, as the event +// scan above -- and the trap here is sharper, because each of these reads back a *plausible* +// wrong answer rather than an obviously broken one: +// +// * the tree's completion-order counter is post-incremented by SetResearched, so reading it +// after the original yields the next order, not this one, and node.order would be off by +// one per completion; +// * `ServerPlayer::ResT` is zeroed by OnTechResearched, so the "is this the current research +// target" test that gates the extra RNG draw would answer no on exactly the calls where the +// original answers yes; +// * the pending-roll byte is cleared in the same block; +// * the observed-tech list has already been appended to, so a de-duplication check taken then +// would find the tech present and model no append -- agreeing with a count it did not +// compute. +struct PreCallScan { + bool tree_ok = false; + int order_counter = 0; // TechTree+0x20 + bool has_owner = false; // TechTree+0x0c != NULL + bool player_ok = false; + const void* research_target = nullptr; // ServerPlayer+0x294 (ResT), a TechDef* + bool roll_pending = false; // ServerPlayer+0x3b4 + bool observed_ok = false; + bool observed_truncated = false; + // The tech names already in the owner's vector. Game text, kept inside the + // shim for one purpose only -- deciding whether RecordObservedTech would append -- and never + // emitted into a trace record. + std::vector observed_names; +}; +PreCallScan g_pre; + // The turn the events are filed under; `ok` is false when the chain is not walkable. int current_turn(const void* owner, bool& ok) { ok = false; @@ -228,6 +272,142 @@ int tech_id_of(void* def) { return word_at(def, A::TechDef_off_TechId); } +// ---- the pre-call reads of the owner's and the tree's mutable state ------------------------ + +// The names in `vector`, or `ok = false` when the header does not look like one. +// Only the name string of each element is read; the rest of the element is untouched. +bool scan_observed_techs(const void* vec, std::vector& out, bool& truncated) { + out.clear(); + truncated = false; + if (!readable(vec, kVectorHeaderSize)) return false; + const char* first = static_cast(ptr_at(vec, 0)); + const char* last = static_cast(ptr_at(vec, 4)); + if (!first && !last) return true; // an empty vector is a successful scan of nothing + if (!first || last < first) return false; + const std::size_t bytes = static_cast(last - first); + if (bytes % kObservedTechSize) return false; + const std::size_t n = bytes / kObservedTechSize; + if (n > kMaxObservedTechs) { + truncated = true; + return false; + } + if (!readable(first, bytes)) return false; + out.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + std::string name; + if (!ReadStdStringMapped(first + i * kObservedTechSize + A::ObservedTech_off_Name, + &readable, &IdentityToHost, name)) { + truncated = true; + return false; + } + out.push_back(std::move(name)); + } + return true; +} + +// ---- transcribing the live tree into the pure graph ---------------------------------------- + +// A node's outgoing edges: `vector` at TechNode+0x04. Each edge carries the child's +// TechDef at +0x40 (indexed back into the node vector by its tech id) and the RP cost of the +// child when reached through this parent at +0x1c. +bool read_children(const void* node, std::vector& out) { + out.clear(); + const char* first = static_cast(ptr_at(node, A::TechNode_off_Children)); + const char* last = static_cast(ptr_at(node, A::TechNode_off_Children + 4)); + if (!first && !last) return true; + if (!first || last < first) return false; + const std::size_t bytes = static_cast(last - first); + if (bytes % sizeof(void*)) return false; + const std::size_t n = bytes / sizeof(void*); + if (n > kMaxEdgesPerNode) return false; + if (n && !readable(first, bytes)) return false; + out.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + void* edge = ptr_at(first, i * sizeof(void*)); + if (!readable(edge, kTechEdgeProbe)) return false; + void* childDef = ptr_at(edge, A::TechEdge_off_ChildDef); + sots::sim::TechEdgeRef e; + e.childIndex = tech_id_of(childDef); + e.costRP = word_at(edge, A::TechEdge_off_CostRP); + out.push_back(e); + } + return true; +} + +// The prerequisite structure at TechDef+0x88: a flat entry array plus a vector of {start, count} +// groups indexing it. A read that cannot be completed is reported as `unreadable`, which the +// model treats as "not met" -- the safe direction, since a false "met" would unlock techs the +// original leaves alone. +void read_prereqs(const void* def, sots::sim::TechPrereqs& out) { + out.groups.clear(); + out.unreadable = true; + const char* block = static_cast(def) + A::TechDef_off_Prereqs; + if (!readable(block, A::TechPrereqs_off_Groups + 8)) return; + const char* entries = static_cast(ptr_at(block, A::TechPrereqs_off_Entries)); + const char* gfirst = static_cast(ptr_at(block, A::TechPrereqs_off_Groups)); + const char* glast = static_cast(ptr_at(block, A::TechPrereqs_off_Groups + 4)); + if (!gfirst && !glast) { // no groups at all: PrereqsMet returns true + out.unreadable = false; + return; + } + if (!gfirst || glast < gfirst) return; + const std::size_t gbytes = static_cast(glast - gfirst); + if (gbytes % A::TechPrereqs_group_stride) return; + const std::size_t ngroups = gbytes / A::TechPrereqs_group_stride; + if (ngroups > kMaxPrereqGroups) return; + if (ngroups && !readable(gfirst, gbytes)) return; + out.groups.reserve(ngroups); + for (std::size_t g = 0; g < ngroups; ++g) { + const char* gp = gfirst + g * A::TechPrereqs_group_stride; + const int start = word_at(gp, 0); + const int count = word_at(gp, 4); + if (start < 0 || count < 0 || static_cast(count) > kMaxPrereqEntries) return; + std::vector group; + group.reserve(static_cast(count)); + if (count > 0) { + const char* base = entries + static_cast(start) * A::TechPrereqs_entry_stride; + if (!readable(base, static_cast(count) * A::TechPrereqs_entry_stride)) return; + for (int k = 0; k < count; ++k) { + void* edef = ptr_at(base, static_cast(k) * A::TechPrereqs_entry_stride); + group.push_back(tech_id_of(edef)); // -1 for a null def: satisfies nothing + } + } + out.groups.push_back(std::move(group)); + } + out.unreadable = false; +} + +// Build the whole graph from the node pointers the caller hands us -- the SCRATCH copies in +// compare mode, so nothing the cascade decides is contaminated by what the original just did. +// The defs, the edges and the prerequisite arrays are static data the original never writes, so +// those are read live. +// +// Returns false if any node's shape could not be read. The caller must then pass "no unlock +// list" rather than an empty one: a graph we could not build must not look like a graph with +// nothing in it. +bool build_graph(const std::vector& nodes, sots::sim::TechGraph& g) { + g.nodes.assign(nodes.size(), sots::sim::TechGraphNode{}); + for (std::size_t i = 0; i < nodes.size(); ++i) { + void* p = nodes[i]; + if (!p) continue; // a null slot: present stays false and both sweeps skip it + sots::sim::TechGraphNode& n = g.nodes[i]; + n.present = true; + n.state = word_at(p, A::TechNode_off_State); + n.costRP = word_at(p, A::TechNode_off_CostRP); + n.turnAvailable = word_at(p, A::TechNode_off_TurnAvailable); + n.turnResearched = word_at(p, A::TechNode_off_TurnResearched); + n.order = word_at(p, A::TechNode_off_Order); + if (!read_children(p, n.children)) return false; + void* def = ptr_at(p, A::TechNode_off_Def); + if (!readable(def, kTechDefProbe)) return false; + n.selfIndex = word_at(def, A::TechDef_off_TechId); + n.excludedFromSweep = + *(static_cast(def) + A::TechDef_off_NoAutoAvailable) != 0; + read_prereqs(def, n.prereqs); + } + return true; +} + // ---- describers ------------------------------------------------------------------------- Tv describe_rng(const void* p, std::size_t, unsigned inline_max) { @@ -344,6 +524,125 @@ struct ShimRandom final : sots::sim::IRandom { } }; +// ---- the cascade's environment ------------------------------------------------------------- +// +// `sots::sim::SetResearched` is pure: it asks for two things it cannot know. This binds them to +// the running game. +struct CascadeCtx { + void* tree = nullptr; + const std::vector* nodes = nullptr; // the SCRATCH copies in compare mode + sots::sim::TechGraph* graph = nullptr; + std::vector* model = nullptr; + ShimRandom* rand = nullptr; + + // Pre-call copies, consumed exactly as the original consumes them. + const void* research_target = nullptr; + bool roll_pending = false; + std::vector observed_names; + + // What the model decided, for the report and the write-backs. + int completions = 0; + int observed_appends = 0; + int roll_draws = 0; + int cascade_failures = 0; // a completion the cascade declined to run: an inconsistency + bool depth_exceeded = false; + bool name_unreadable = false; + std::vector unlocked; +}; + +// TechTree::Cost, called on the node as the cascade has it *now* -- its costRP may have just been +// lowered by the first sweep, and the cost is a function of that. The game's own implementation +// is read-only (no RNG, no writes; see the address entry), so it is called rather than +// re-derived: the cost multiplier is a separate, lower-confidence formula and guessing it here +// would put a second unknown inside the one being measured. +int CascadeCost(void* ctx, int nodeIndex) { + CascadeCtx& c = *static_cast(ctx); + if (nodeIndex < 0 || static_cast(nodeIndex) >= c.nodes->size()) + return sots::sim::kNoResearchCost; + void* p = (*c.nodes)[static_cast(nodeIndex)]; + if (!p || !g_env.cost) return sots::sim::kNoResearchCost; + // Publish the model's costRP into the copy first, so Cost reads the value the cascade has + // reached and not the one it started from. In compare mode this is the scratch copy, which + // the write-back at the end of `ours` overwrites with the same value. + set_word(p, A::TechNode_off_CostRP, c.graph->nodes[static_cast(nodeIndex)].costRP); + return g_env.cost(c.tree, p); +} + +// ServerPlayer::OnTechResearched, modelled only as far as this hook's regions can see it: +// the observed-tech append and the research-event RNG draw. The tech effects it also applies -- +// the ~90 hard-coded ServerPlayer field writes -- are B2's milestone, are not reproduced, and +// are what the `player` guard reports. +void CascadeOnResearched(void* ctx, int nodeIndex, bool /*silent*/) { + CascadeCtx& c = *static_cast(ctx); + ++c.completions; + if (nodeIndex < 0 || static_cast(nodeIndex) >= c.nodes->size()) return; + void* p = (*c.nodes)[static_cast(nodeIndex)]; + if (!p) return; + void* def = ptr_at(p, A::TechNode_off_Def); + + // 1. RecordObservedTech, the first statement of OnTechResearched, unconditional and + // de-duplicating by tech NAME. "No delta" is a real outcome, not a failure, so the check + // has to be the name and not just "did something complete". + std::string name; + if (readable(def, A::TechDef_off_Name + kStdStringSize) && + ReadStdStringMapped(static_cast(def) + A::TechDef_off_Name, &readable, + &IdentityToHost, name)) { + bool seen = false; + for (const std::string& s : c.observed_names) + if (s == name) { seen = true; break; } + if (!seen) { + c.observed_names.push_back(name); + ++c.observed_appends; + } + } else { + // Never guess an append: an unread name is reported and the count is left alone, which + // shows up as a divergence rather than as a silent agreement. + c.name_unreadable = true; + } + + // 2. `if (ResT == def) { if (ResearchRollPending) RollResearchEvent(); pending = 0; ResT = 0; }` + // RollResearchEvent draws exactly one NextFloat unconditionally. Clearing ResT is what + // makes a second completion in the same pass draw nothing. + if (def && def == c.research_target) { + if (c.roll_pending) { + c.rand->NextFloat(); + ++c.roll_draws; + } + c.roll_pending = false; + c.research_target = nullptr; + } +} + +// Run the cascade at the moment ProcessResearch would call SetResearched. +void OnStepCompleted(void* ctx, std::size_t /*stepIndex*/, int nodeIndex) { + CascadeCtx& c = *static_cast(ctx); + if (nodeIndex < 0 || static_cast(nodeIndex) >= c.graph->nodes.size()) return; + + sots::sim::TechCascadeEnv env; + env.cost = &CascadeCost; + env.onResearched = &CascadeOnResearched; + env.ctx = &c; + + // SetResearched is called with the node's *def*, and re-derives the node from the def's tech + // id -- so the argument is the slot's selfIndex, not the slot. + const int defIndex = c.graph->nodes[static_cast(nodeIndex)].selfIndex; + const sots::sim::TechCascadeResult r = + sots::sim::SetResearched(*c.graph, defIndex, sots::sim::kTechForce, env); + if (!r.ran) ++c.cascade_failures; + if (r.depthExceeded) c.depth_exceeded = true; + + // Reflect the cascade's state changes into the pass model, so the decay sweep that follows + // sees the tree the original's decay sweep sees. States only ever rise on this path + // (Hidden -> ParentResearched -> Available -> Researched, and ApplyResearchPoints' own jump + // straight to Researched), so a monotone merge is both faithful and incapable of undoing + // what the pass just decided. + for (std::size_t i = 0; i < c.model->size() && i < c.graph->nodes.size(); ++i) { + const int gs = c.graph->nodes[i].state; + if (gs > static_cast((*c.model)[i].state)) + (*c.model)[i].state = static_cast(gs); + } +} + } // namespace // ---- descriptor --------------------------------------------------------------------------- @@ -409,6 +708,36 @@ void TechTreeProcessResearchHook::describe_args(std::vector& out, void* tree out.push_back(tv::u32(static_cast(g_scan.storage.researchEventsInTurnBucket)) .named("events_dedup_risk")); if (g_scan.storage.scanTruncated) out.push_back(tv::boolean(true).named("events_scan_truncated")); + + // ---- the pre-call reads the unlock cascade needs ----------------------------------------- + // + // All four are state the ORIGINAL changes during the call. Taken here for the same reason the + // event scan is, and reported as arguments so a run can be audited without trusting `ours`: + // if `order_counter_in` is not one less than the first `node[*].order` the original writes, + // or `roll_pending_in` is false on a call whose `rng` moved by an extra word, the model was + // fed the wrong inputs and every clean field below it is meaningless. + g_pre = PreCallScan{}; + g_pre.tree_ok = readable(tree, kTreeGuardSize); + if (g_pre.tree_ok) { + g_pre.order_counter = word_at(tree, A::TechTree_off_OrderCounter); + g_pre.has_owner = ptr_at(tree, A::TechTree_off_Owner) != nullptr; + } + g_pre.player_ok = readable(owner, kPlayerSize); + if (g_pre.player_ok) { + g_pre.research_target = ptr_at(owner, A::ServerPlayer_off_ResearchTarget); + g_pre.roll_pending = + *(static_cast(owner) + A::ServerPlayer_off_ResearchRollPending) != 0; + g_pre.observed_ok = + scan_observed_techs(static_cast(owner) + kObservedTechsOff, + g_pre.observed_names, g_pre.observed_truncated); + } + out.push_back(tv::i32(g_pre.tree_ok ? g_pre.order_counter : -1).named("order_counter_in")); + out.push_back(tv::ptr(const_cast(g_pre.research_target)).named("research_target")); + out.push_back(tv::boolean(g_pre.roll_pending).named("roll_pending_in")); + out.push_back(tv::u32(static_cast(g_pre.observed_names.size())) + .named("observed_techs_in")); + if (!g_pre.observed_ok) out.push_back(tv::boolean(true).named("observed_scan_failed")); + if (g_pre.observed_truncated) out.push_back(tv::boolean(true).named("observed_scan_truncated")); } void TechTreeProcessResearchHook::regions(std::vector& out, void* tree, void* rng, @@ -525,6 +854,8 @@ TechTreeProcessResearchHook::Args TechTreeProcessResearchHook::rebind(trace::Scr // way the node copies do: through CallState, resolved here where Scratch is in hand. g_call.scratch_events = g_call.events_region >= 0 ? s.ptr(static_cast(g_call.events_region)) : nullptr; + g_call.scratch_otch = + g_call.otch_region >= 0 ? s.ptr(static_cast(g_call.otch_region)) : nullptr; g_call.compare = true; // The tree pointer is passed through unchanged: ours only reads it (owner, node count) and // hands it to the game's own read-only Cost. Every node it writes is a scratch copy. @@ -592,9 +923,49 @@ void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int* rand.gen.load_state(mt, left); } - const ResearchTurnResult r = ProcessResearchTurn(model, entries, owner_species, rand); + // ---- the unlock cascade ------------------------------------------------------------------- + // + // `TechTree::SetResearched` is what ProcessResearch calls the instant a node completes, and + // it is the source of five things this hook could previously only watch go past: the + // completed node's turn/order stamps, the child costs and states, the availability stamp + // EVENT_TECHS_UNLOCKED is computed from, the observed-tech append, and one RNG word. + // + // It runs in COMPARE MODE ONLY, for the same reason the event post does. In replace mode + // every pointer here is live game memory, and running half of OnTechResearched -- the + // observed-tech append and the research-event roll, but not the ninety-odd tech-effect field + // writes -- would leave the player in a state no code path produces. Not running it leaves a + // player missing a cascade, which is a smaller and already-declared lie. + // + // The graph is transcribed from the SCRATCH node copies, so what the cascade decides is a + // function of the pre-call tree and not of what the original just did to the live one. + sots::sim::TechGraph graph; + CascadeCtx cc; + const bool cascade_possible = compare && g_scan.turn_ok && g_pre.tree_ok; + bool cascade_ok = false; + if (cascade_possible) { + cascade_ok = build_graph(nodes, graph); + graph.orderCounter = g_pre.order_counter; + graph.hasOwner = owner != nullptr; + graph.turn = g_scan.turn; + cc.tree = tree; + cc.nodes = &nodes; + cc.graph = &graph; + cc.model = &model; + cc.rand = &rand; + cc.research_target = g_pre.research_target; + cc.roll_pending = g_pre.roll_pending; + cc.observed_names = g_pre.observed_names; + // An observed-tech scan that failed is not an empty list: without it the de-duplication + // cannot be decided, so the append is not modelled at all and the region keeps reporting + // the difference. + if (!g_pre.observed_ok) cc.name_unreadable = true; + } - // Write back exactly the words the original function itself writes. + const ResearchTurnResult r = + cascade_ok ? ProcessResearchTurn(model, entries, owner_species, rand, &OnStepCompleted, &cc) + : ProcessResearchTurn(model, entries, owner_species, rand); + + // Write back exactly the words the original function itself writes... for (std::size_t i = 0; i < nodes.size(); ++i) { void* p = nodes[i]; if (!p) continue; @@ -602,6 +973,20 @@ void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int* set_word(p, A::TechNode_off_Flag, static_cast(model[i].flag)); set_word(p, A::TechNode_off_State, static_cast(model[i].state)); } + // ...and, when the cascade ran, the four words SetResearched writes. These are skipped + // wholesale if the graph could not be transcribed: a partially built graph would write + // default sentinels over real values, which is worse than not writing at all. + if (cascade_ok) { + for (std::size_t i = 0; i < nodes.size(); ++i) { + void* p = nodes[i]; + if (!p || !graph.nodes[i].present) continue; + set_word(p, A::TechNode_off_CostRP, graph.nodes[i].costRP); + set_word(p, A::TechNode_off_TurnAvailable, graph.nodes[i].turnAvailable); + set_word(p, A::TechNode_off_TurnResearched, graph.nodes[i].turnResearched); + set_word(p, A::TechNode_off_Order, graph.nodes[i].order); + } + cc.unlocked = sots::sim::CollectNewlyAvailable(graph, g_scan.turn); + } set_word(overbudget, 0, word_at(overbudget, 0) + r.overbudget); // ... and the generator, in the object's own layout. `next` is rebuilt against the LIVE @@ -663,12 +1048,55 @@ void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int* o.completedEarly = r.steps[i].completedEarly; outcomes.push_back(std::move(o)); } - // nullptr, not an empty list: the EVENT_TECHS_UNLOCKED set comes from SetResearched's - // child-unlock cascade, which `ours` deliberately does not run. Passing an empty list - // would claim we computed the set and found it empty. - sots::events::PostResearchPassEvents(log, text, outcomes, nullptr, g_scan.turn); + // The EVENT_TECHS_UNLOCKED set. `nullptr` still means "this caller could not compute it" + // -- when the graph did not transcribe, or the tail's `tree->owner != 0` gate is closed. + // An empty vector means "computed, and nothing became available this turn", which is a + // modelled negative and posts nothing. Keeping the two apart is what stops a failure to + // read the tree from scoring as a correct silence. + std::vector unlocked_names; + const bool unlocked_known = cascade_ok && graph.hasOwner; + if (unlocked_known) { + unlocked_names.reserve(cc.unlocked.size()); + for (int idx : cc.unlocked) { + // The same keyless token the other events use: the node index, which separates + // exactly the techs the original's names separate, and carries no game text. + char buf[24]; + std::snprintf(buf, sizeof buf, "%d", idx); + unlocked_names.emplace_back(buf); + } + } + sots::events::PostResearchPassEvents(log, text, outcomes, + unlocked_known ? &unlocked_names : nullptr, + g_scan.turn); WriteBackCounts(g_call.scratch_events, log, &readable); } + + // ---- the observed-tech append --------------------------------------------------------- + // + // Same shape as the event counts and the same limits: the element is not constructed, only + // the vector's byte span in the SCRATCH header is moved, by exactly one 0x2c element per + // append the model decided on. The decision is the check -- RecordObservedTech de-duplicates + // by tech name, so "no delta" is a real outcome and a model that always added 44 would score + // on this workload and be wrong the first time a tech is re-observed. + if (compare && g_call.scratch_otch && cascade_ok && cc.observed_appends > 0 && + !cc.name_unreadable) { + char* first = static_cast(ptr_at(g_call.scratch_otch, 0)); + char* last = static_cast(ptr_at(g_call.scratch_otch, 4)); + if (first && last >= first) + set_ptr(g_call.scratch_otch, 4, + last + static_cast(cc.observed_appends) * kObservedTechSize); + } + + // A line per call, so a run can be read without the trace: these are the counts that say the + // cascade actually ran, and a clean compare with all of them at zero would be a clean compare + // of nothing. + if (cascade_possible) { + logf("research: cascade ok=%d completions=%d unlocked=%u otch_appends=%d roll_draws=%d " + "failures=%d depth=%d name_unreadable=%d", + cascade_ok ? 1 : 0, cc.completions, static_cast(cc.unlocked.size()), + cc.observed_appends, cc.roll_draws, cc.cascade_failures, cc.depth_exceeded ? 1 : 0, + cc.name_unreadable ? 1 : 0); + } } void init_research(std::uintptr_t exe_base, void (*log_line)(const char* line)) { diff --git a/src/shim/hooks/research.h b/src/shim/hooks/research.h index 7f9e745..07d30b8 100644 --- a/src/shim/hooks/research.h +++ b/src/shim/hooks/research.h @@ -27,14 +27,26 @@ // snapshot, so both implementations read the same stream. If the post-call generator state // matches as well, we consumed the same words in the same order -- which is the real evidence. // -// Scope of `ours`: exactly what ProcessResearch itself writes. On the turn a tech completes, -// the original goes on to call TechTree::SetResearched, which stamps the turn/order words, -// walks the unlock cascade into the child nodes and invokes the owner's tech-effect callback. -// None of that is reproduced (it is its own milestone, and the callback would write live -// player state that compare mode must never touch), so a completion record is expected to -// diverge in those fields and only in those fields. The effective cost of a node is taken -// from the game's own TechTree::Cost, which is read-only -- the cost multiplier is a separate, -// lower-confidence formula and not what this milestone is measuring. +// Scope of `ours`: what ProcessResearch itself writes, plus -- in compare mode only -- the +// TechTree::SetResearched cascade it calls on a completion. The cascade stamps the completed +// node's turn and order, lowers the child costs, walks the availability sweep that writes +// `turnAvailable`, and recursively completes any zero-cost node it makes available; the tail +// collector then decides EVENT_TECHS_UNLOCKED from `state == 2 && turnAvailable == turn`. All of +// it is modelled by the pure game/sim/techgraph module, transcribed from the SCRATCH node copies +// so the decision is a function of the pre-call tree and not of what the original just did. +// +// The owner callback SetResearched invokes is modelled only as far as this hook's regions reach: +// the observed-tech append (a de-duplicating append whose byte span region:observed_techs +// compares) and the single RNG word RollResearchEvent draws when the completing tech is the +// current target and the pending-roll byte is set. Its tech-effect field writes are B2's +// milestone and remain what the `player` guard reports. +// +// REPLACE mode runs none of the cascade: there, every pointer is live game memory and applying +// half of the callback would leave the player in a state no code path produces. +// +// The effective cost of a node is taken from the game's own TechTree::Cost, which is read-only -- +// the cost multiplier is a separate, lower-confidence formula and not what this milestone is +// measuring. The cascade calls it too, on the node as it stands after the child-cost sweep. #pragma once #include @@ -70,40 +82,54 @@ struct TechTreeProcessResearchHook { "ours posts into its own EventStorage and writes only the counts into the " "scratch copy, so no live byte moves and replace mode posts nothing at all", "region:events"); - // The one event the count model cannot decide. Its trigger IS pinned -- SetResearched's - // second sweep sets state 2 and stamps turnAvailable, and the tail loop collects - // state==2 && turnAvailable==currentTurn -- but evaluating it needs the cascade `ours` - // does not run, so posting it would be a guess that happens to score. - c.unmodelled("posts EVENT_TECHS_UNLOCKED once after the per-node loop, for the nodes " - "SetResearched made available this turn", - trace::Risk::Medium, - "the set comes from the child-unlock cascade, which ours does not run; the " - "pass driver takes the unlock list as an input and is given `no list` " - "rather than an empty one, so a missing input cannot look like a modelled " - "negative. Expect region:events to under-count next_id by exactly 1 on " - "every call that completes a tech", + // EVENT_TECHS_UNLOCKED is now computed rather than declared missing -- `ours` runs the + // cascade. What remains unproven is the same thing as for the other five: the text. + c.unmodelled("composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", + trace::Risk::Low, + "the trigger and the list are modelled (SetResearched's availability sweep " + "plus the tail collector, both read off the instruction stream), so " + "region:events compares next_id; the names come from the game's string " + "table, so the message is composed from node indices instead and is not the " + "game's text", "region:events"); - c.unmodelled("appends to the owner's vector (ServerPlayer+0x274) on every " - "tech completion", + c.unmodelled("TechTree::SetResearched in REPLACE mode: nothing of it runs", trace::Risk::High, - "serialized ServerPlayer state that no coverage note in B2 or B3 mentioned " - "until lane R's guard caught it. The element is now fully pinned (sizeof 44, " - "{u16 turn_first, u16 turn_last, bool detected, string tech_name, int with}) " - "and the append de-duplicates by tech name, but ours still does not append; " - "the region reports the byte span, which must grow by exactly 44 per " - "completion", - "region:observed_techs"); - c.unmodelled("TechTree::SetResearched on completion: the turn/order stamps, the child " - "unlock cascade, the recursive research of zero-cost children, and the " - "owner's OnTechResearched callback", - trace::Risk::High, - "its own milestone (B2); the callback writes live player state that compare " - "mode must not touch, and it consumes one extra RNG word", + "the cascade is compare-mode only. In replace mode every pointer is live " + "game memory, and applying half of OnTechResearched -- the observed-tech " + "append and the research-event roll, but not the tech-effect field writes -- " + "would leave the player in a state no code path produces. A replace run " + "therefore still leaves the completed node unstamped and no tech unlocked", "guard:player, guard:tree_header"); - c.unmodelled("bumps the tree's completion-order counter (TechTree+0x20)", + c.unmodelled("ServerPlayer::OnTechResearched's tech effects: the ~90 hard-coded " + "ServerPlayer field writes, the plague-cure masks, the design-option " + "bitmasks and the species tech flags", + trace::Risk::High, + "B2's milestone. `ours` models only the two parts of the callback this " + "hook's regions can see -- the observed-tech append and the one RNG word " + "RollResearchEvent draws -- and the rest is what the player guard reports", + "guard:player"); + c.unmodelled("the research-event branch RollResearchEvent takes when its roll beats the " + "odds (0x00889d60: the plague and AI-rebellion event paths)", trace::Risk::Medium, - "part of SetResearched; the per-node `order` word is compared but the " - "counter it comes from was not a region", + "the one NextFloat is drawn unconditionally and is modelled; the branch " + "behind it is entered only for the plague and AI-rebellion tech families, " + "whose odds are 0 everywhere else. If it is ever entered, region:rng is the " + "check -- it would consume draws ours does not", + "region:rng"); + c.unmodelled("constructs the ObservedTech element it appends to ServerPlayer+0x274", + trace::Risk::Medium, + "`ours` models the append DECISION -- RecordObservedTech de-duplicates by " + "tech name, so it decides whether the vector grows -- and moves the scratch " + "header's byte span by one 0x2c element per append. The element's own fields " + "(turn_first, turn_last, detected, the name string, `with`) are not built, " + "and no region can see them", + "region:observed_techs"); + c.unmodelled("the tree's completion-order counter (TechTree+0x20) is read pre-call, not " + "modelled as a region", + trace::Risk::Low, + "the per-node `order` word IS compared, and it is stamped from a counter " + "`ours` seeds from the pre-call read and advances itself; the counter's own " + "final value is only seen by the tree_header guard", "guard:tree_header"); c.unmodelled("writes a completion line to the game log", trace::Risk::Low, "log text is not simulation state"); diff --git a/tests/game_sim/CMakeLists.txt b/tests/game_sim/CMakeLists.txt index 8183227..3ba54f5 100644 --- a/tests/game_sim/CMakeLists.txt +++ b/tests/game_sim/CMakeLists.txt @@ -1,5 +1,5 @@ # game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON). -foreach(_t economy research colony movement) +foreach(_t economy research colony movement techgraph) add_executable(game_sim_test_${_t} test_${_t}.cpp) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/tests/game_sim/test_techgraph.cpp b/tests/game_sim/test_techgraph.cpp new file mode 100644 index 0000000..eb00bfa --- /dev/null +++ b/tests/game_sim/test_techgraph.cpp @@ -0,0 +1,390 @@ +// TechTree::SetResearched's unlock cascade, the prerequisite test and the newly-available +// collector. Every case here is a property read out of the instruction stream, not a guess about +// what a tech tree "should" do -- the four that are easiest to get wrong are the sticky +// turnAvailable, the signed costRP minimum against an INT_MAX sentinel, the empty prerequisite +// group, and the self-resolving index both sweeps go through. +#include "game/sim/techgraph.h" + +#include +#include + +#include "check.h" + +using namespace sots::sim; + +namespace { + +// A tree of `n` nodes, every slot present, every node resolving to itself, all hidden. +TechGraph make_tree(std::size_t n, int turn = 5) { + TechGraph g; + g.nodes.resize(n); + for (std::size_t i = 0; i < n; ++i) { + g.nodes[i].present = true; + g.nodes[i].selfIndex = static_cast(i); + } + g.hasOwner = true; + g.turn = turn; + return g; +} + +void link(TechGraph& g, int parent, int child, int costRP) { + g.nodes[static_cast(parent)].children.push_back(TechEdgeRef{child, costRP}); +} + +// Counts how many times the cost hook and the owner callback were reached, and lets a test make +// a chosen node free so the recursive branch fires. +struct Env { + std::vector freeNodes; + int costCalls = 0; + std::vector researchedCallbacks; + std::vector silentSeen; + + static int Cost(void* ctx, int i) { + Env& e = *static_cast(ctx); + ++e.costCalls; + for (int f : e.freeNodes) + if (f == i) return 0; + return 1000; + } + static void OnResearched(void* ctx, int i, bool silent) { + Env& e = *static_cast(ctx); + e.researchedCallbacks.push_back(i); + e.silentSeen.push_back(silent); + } + TechCascadeEnv env() { + TechCascadeEnv v; + v.cost = &Cost; + v.onResearched = &OnResearched; + v.ctx = this; + return v; + } +}; + +// --------------------------------------------------------------------------------------- + +void test_prereqs() { + TechGraph g = make_tree(5); + // No groups at all: satisfied. The original returns `count == count` with both zero. + CHECK(TechPrereqsMet(g, 0)); + + // One group, one tech, not researched. + g.nodes[0].prereqs.groups = {{3}}; + CHECK(!TechPrereqsMet(g, 0)); + g.nodes[3].state = static_cast(TechState::Researched); + CHECK(TechPrereqsMet(g, 0)); + + // A group is an OR: any one member satisfies it. + g.nodes[0].prereqs.groups = {{1, 2, 3}}; + CHECK(TechPrereqsMet(g, 0)); + g.nodes[3].state = static_cast(TechState::Available); + CHECK(!TechPrereqsMet(g, 0)); + + // Groups are an AND: every one must be satisfied. + g.nodes[1].state = static_cast(TechState::Researched); + g.nodes[0].prereqs.groups = {{1}, {2}}; + CHECK(!TechPrereqsMet(g, 0)); + g.nodes[2].state = static_cast(TechState::Researched); + CHECK(TechPrereqsMet(g, 0)); + + // An EMPTY group fails the whole test -- the inner loop cannot break, so the outer one + // breaks with the group uncounted. This is the case a "vacuously true" reading gets wrong. + g.nodes[0].prereqs.groups = {{1}, {}}; + CHECK(!TechPrereqsMet(g, 0)); + + // A listed tech whose node the tree does not hold satisfies nothing. + g.nodes[0].prereqs.groups = {{4}}; + g.nodes[4].present = false; + g.nodes[4].state = static_cast(TechState::Researched); + CHECK(!TechPrereqsMet(g, 0)); + + // An unreadable structure is "not met", never "met by default". + g.nodes[0].prereqs.groups.clear(); + g.nodes[0].prereqs.unreadable = true; + CHECK(!TechPrereqsMet(g, 0)); +} + +void test_completion_stamps() { + TechGraph g = make_tree(3, /*turn=*/7); + g.orderCounter = 22; + g.nodes[0].state = static_cast(TechState::CurrentTarget); + Env e; + + const TechCascadeResult r = SetResearched(g, 0, kTechForce, e.env()); + CHECK(r.ran); + CHECK(!r.alreadyResearched); + CHECK_EQ(g.nodes[0].state, static_cast(TechState::Researched)); + CHECK_EQ(g.nodes[0].turnResearched, 7); + CHECK_EQ(g.nodes[0].order, 22); + CHECK_EQ(g.orderCounter, 23); + // ProcessResearch passes flags = 2, so bit 2 is clear and the callback is NOT silent -- + // which is what makes the completion event fire. + CHECK_EQ(e.researchedCallbacks.size(), std::size_t{1}); + CHECK(!e.silentSeen[0]); + + // Re-completing writes nothing at all: not the order stamp, not the counter. + Env e2; + const TechCascadeResult again = SetResearched(g, 0, kTechForce, e2.env()); + CHECK(again.alreadyResearched); + CHECK(again.ran); + CHECK_EQ(g.nodes[0].order, 22); + CHECK_EQ(g.orderCounter, 23); + CHECK_EQ(e2.researchedCallbacks.size(), std::size_t{0}); + + // With no owner the turn stamp is a literal 0, not the turn. + TechGraph h = make_tree(1, /*turn=*/7); + h.hasOwner = false; + Env e3; + SetResearched(h, 0, kTechForce, e3.env()); + CHECK_EQ(h.nodes[0].turnResearched, 0); + CHECK_EQ(e3.researchedCallbacks.size(), std::size_t{0}); // no owner, no callback +} + +void test_force_and_prereq_gate() { + TechGraph g = make_tree(2); + g.nodes[0].prereqs.groups = {{1}}; // node 1 is not researched + Env e; + // Unforced, the gate holds and nothing is written. + CHECK(!SetResearched(g, 0, 0u, e.env()).ran); + CHECK_EQ(g.nodes[0].state, static_cast(TechState::Hidden)); + CHECK_EQ(g.orderCounter, 0); + // ProcessResearch's flags = 2 skips the gate: the roll has already been won. + CHECK(SetResearched(g, 0, kTechForce, e.env()).ran); + CHECK_EQ(g.nodes[0].state, static_cast(TechState::Researched)); + + // An absent node is a no-op, not a completion. + TechGraph h = make_tree(1); + h.nodes[0].present = false; + Env e2; + const TechCascadeResult r = SetResearched(h, 0, kTechForce, e2.env()); + CHECK(!r.ran); + CHECK(!r.alreadyResearched); +} + +void test_sweep1_costs_and_states() { + TechGraph g = make_tree(4, /*turn=*/4); + link(g, 0, 1, 10000); + link(g, 0, 2, 16000); + g.nodes[3].state = static_cast(TechState::Available); // untouched by this cascade + g.nodes[3].turnAvailable = 2; + Env e; + + SetResearched(g, 0, kTechForce, e.env()); + + // Hidden children become ParentResearched, then the second sweep makes them Available and + // stamps this turn. + CHECK_EQ(g.nodes[1].costRP, 10000); + CHECK_EQ(g.nodes[2].costRP, 16000); + CHECK_EQ(g.nodes[1].state, static_cast(TechState::Available)); + CHECK_EQ(g.nodes[2].state, static_cast(TechState::Available)); + CHECK_EQ(g.nodes[1].turnAvailable, 4); + CHECK_EQ(g.nodes[2].turnAvailable, 4); + // A node that was already available keeps its original availability turn. + CHECK_EQ(g.nodes[3].turnAvailable, 2); + + // The collector sees the two new ones and not the old one. + const std::vector unlocked = CollectNewlyAvailable(g, 4); + CHECK_EQ(unlocked.size(), std::size_t{2}); + CHECK_EQ(unlocked[0], 1); + CHECK_EQ(unlocked[1], 2); + CHECK_EQ(CollectNewlyAvailable(g, 2).size(), std::size_t{1}); +} + +void test_cost_minimum_is_signed_and_monotone() { + // Two parents reach the same child at different costs; the sentinel is INT_MAX, the compare + // is signed, so the FIRST researched parent sets the cost and later parents only lower it. + TechGraph g = make_tree(3); + link(g, 0, 2, 8000); + link(g, 1, 2, 12000); + Env e; + + CHECK_EQ(g.nodes[2].costRP, kNoResearchCost); + SetResearched(g, 1, kTechForce, e.env()); + CHECK_EQ(g.nodes[2].costRP, 12000); + SetResearched(g, 0, kTechForce, e.env()); + CHECK_EQ(g.nodes[2].costRP, 8000); // cheaper: lowered + // And the expensive parent cannot raise it back. + TechGraph h = make_tree(3); + link(h, 0, 2, 8000); + link(h, 1, 2, 12000); + Env e2; + SetResearched(h, 0, kTechForce, e2.env()); + SetResearched(h, 1, kTechForce, e2.env()); + CHECK_EQ(h.nodes[2].costRP, 8000); +} + +void test_turn_available_is_sticky() { + TechGraph g = make_tree(2, /*turn=*/3); + link(g, 0, 1, 500); + Env e; + SetResearched(g, 0, kTechForce, e.env()); + CHECK_EQ(g.nodes[1].turnAvailable, 3); + + // Push the child back to ParentResearched and re-run the cascade on a later turn. The state + // returns to Available but turnAvailable keeps its first value, so the collector does NOT + // re-announce it. This is the property that makes EVENT_TECHS_UNLOCKED fire once per tech. + g.nodes[1].state = static_cast(TechState::ParentResearched); + g.turn = 9; + TechGraph g2 = g; + g2.nodes[0].state = static_cast(TechState::CurrentTarget); + Env e2; + SetResearched(g2, 0, kTechForce, e2.env()); + CHECK_EQ(g2.nodes[1].state, static_cast(TechState::Available)); + CHECK_EQ(g2.nodes[1].turnAvailable, 3); + CHECK_EQ(CollectNewlyAvailable(g2, 9).size(), std::size_t{0}); +} + +void test_excluded_byte_blocks_the_sweep() { + TechGraph g = make_tree(2, /*turn=*/6); + link(g, 0, 1, 500); + g.nodes[1].excludedFromSweep = true; + Env e; + SetResearched(g, 0, kTechForce, e.env()); + // Sweep 1 still runs on it -- the exclusion byte is only tested in sweep 2 -- so the state + // and the cost move, but it never becomes Available and never stamps a turn. + CHECK_EQ(g.nodes[1].costRP, 500); + CHECK_EQ(g.nodes[1].state, static_cast(TechState::ParentResearched)); + CHECK_EQ(g.nodes[1].turnAvailable, -1); + CHECK_EQ(CollectNewlyAvailable(g, 6).size(), std::size_t{0}); +} + +void test_free_child_completes_recursively() { + TechGraph g = make_tree(3, /*turn=*/5); + g.orderCounter = 10; + link(g, 0, 1, 0); + link(g, 1, 2, 700); + Env e; + e.freeNodes = {1}; + + const TechCascadeResult r = SetResearched(g, 0, kTechForce, e.env()); + // Node 1 became available at zero cost, so the sweep researched it, and ITS cascade unlocked + // node 2 in the same call. + CHECK_EQ(r.completed.size(), std::size_t{2}); + CHECK_EQ(r.completed[0], 0); + CHECK_EQ(r.completed[1], 1); + CHECK_EQ(g.nodes[1].state, static_cast(TechState::Researched)); + CHECK_EQ(g.nodes[1].order, 11); + CHECK_EQ(g.nodes[2].state, static_cast(TechState::Available)); + CHECK_EQ(g.nodes[2].costRP, 700); + CHECK_EQ(e.researchedCallbacks.size(), std::size_t{2}); + // The free node never appears in the unlocked list: it left state 2 before the collector ran. + const std::vector unlocked = CollectNewlyAvailable(g, 5); + CHECK_EQ(unlocked.size(), std::size_t{1}); + CHECK_EQ(unlocked[0], 2); + CHECK(!r.depthExceeded); +} + +void test_zero_cost_cycle_terminates() { + // A zero-cost cycle does NOT hang: the state-4 early return is what makes the recursion + // well founded, and it is the only thing that does. Worth pinning, because the depth cap + // below would otherwise look like the reason. + TechGraph g = make_tree(2); + link(g, 0, 1, 0); + link(g, 1, 0, 0); + Env e; + e.freeNodes = {0, 1}; + const TechCascadeResult r = SetResearched(g, 0, kTechForce, e.env()); + CHECK(!r.depthExceeded); + CHECK_EQ(r.completed.size(), std::size_t{2}); + CHECK_EQ(g.nodes[0].state, static_cast(TechState::Researched)); + CHECK_EQ(g.nodes[1].state, static_cast(TechState::Researched)); +} + +void test_recursion_is_capped_not_hung() { + // A long enough chain of free techs recurses once per link. The original has no guard at + // all; a reimplementation running inside the game must report rather than blow the stack. + TechGraph g = make_tree(10); + for (int i = 0; i + 1 < 10; ++i) link(g, i, i + 1, 0); + Env e; + for (int i = 0; i < 10; ++i) e.freeNodes.push_back(i); + TechCascadeEnv env = e.env(); + env.maxDepth = 4; + const TechCascadeResult r = SetResearched(g, 0, kTechForce, env); + CHECK(r.depthExceeded); + // With the cap lifted the same tree completes the whole chain. + TechGraph h = make_tree(10); + for (int i = 0; i + 1 < 10; ++i) link(h, i, i + 1, 0); + Env e2; + for (int i = 0; i < 10; ++i) e2.freeNodes.push_back(i); + const TechCascadeResult r2 = SetResearched(h, 0, kTechForce, e2.env()); + CHECK(!r2.depthExceeded); + CHECK_EQ(r2.completed.size(), std::size_t{10}); +} + +void test_self_index_indirection() { + // Both sweeps and the collector test the state of `nodes[node->def->techId]`, not of the + // node they are iterating. A tree where those differ behaves accordingly; this pins that the + // indirection is reproduced rather than shortcut. + TechGraph g = make_tree(3, /*turn=*/8); + link(g, 0, 1, 400); + g.nodes[1].selfIndex = 2; // node 1 resolves to node 2 + g.nodes[2].state = static_cast(TechState::ParentResearched); + g.nodes[2].excludedFromSweep = true; // so the sweep cannot promote node 2 on its own pass + Env e; + SetResearched(g, 0, kTechForce, e.env()); + // Node 1's own state went to ParentResearched via sweep 1, but sweep 2 tests node 2's state + // (which IS ParentResearched), so it is node 1 that is written to Available. + CHECK_EQ(g.nodes[1].state, static_cast(TechState::Available)); + CHECK_EQ(g.nodes[1].turnAvailable, 8); + // ...and the collector then tests node 2's state again, which is still 1, not 2, so node 1 + // is not collected even though node 1 itself is available. + CHECK_EQ(g.nodes[2].state, static_cast(TechState::ParentResearched)); + CHECK_EQ(CollectNewlyAvailable(g, 8).size(), std::size_t{0}); +} + +void test_absent_slots_are_skipped() { + TechGraph g = make_tree(4, /*turn=*/2); + link(g, 0, 1, 100); + link(g, 0, 3, 200); + g.nodes[1].present = false; // a tech this species' tree does not hold + Env e; + SetResearched(g, 0, kTechForce, e.env()); + CHECK_EQ(g.nodes[3].state, static_cast(TechState::Available)); + const std::vector unlocked = CollectNewlyAvailable(g, 2); + CHECK_EQ(unlocked.size(), std::size_t{1}); + CHECK_EQ(unlocked[0], 3); +} + +// The shape lane V measured live on 2026-09-08: one completion unlocking three children, whose +// costs come from the edges and whose availability turn is the completion turn. Reproduced here +// as a regression pin on the numbers that appeared in the compare report. +void test_live_shape_call3() { + TechGraph g = make_tree(150, /*turn=*/4); + g.orderCounter = 22; + g.nodes[144].state = static_cast(TechState::CurrentTarget); + link(g, 144, 132, 10000); + link(g, 144, 136, 16000); + link(g, 144, 142, 8000); + Env e; + + SetResearched(g, 144, kTechForce, e.env()); + + CHECK_EQ(g.nodes[144].order, 22); + CHECK_EQ(g.nodes[144].turnResearched, 4); + CHECK_EQ(g.nodes[132].costRP, 10000); + CHECK_EQ(g.nodes[136].costRP, 16000); + CHECK_EQ(g.nodes[142].costRP, 8000); + for (int i : {132, 136, 142}) { + CHECK_EQ(g.nodes[static_cast(i)].state, static_cast(TechState::Available)); + CHECK_EQ(g.nodes[static_cast(i)].turnAvailable, 4); + } + CHECK_EQ(CollectNewlyAvailable(g, 4).size(), std::size_t{3}); +} + +} // namespace + +int main() { + test_prereqs(); + test_completion_stamps(); + test_force_and_prereq_gate(); + test_sweep1_costs_and_states(); + test_cost_minimum_is_signed_and_monotone(); + test_turn_available_is_sticky(); + test_excluded_byte_blocks_the_sweep(); + test_free_child_completes_recursively(); + test_zero_cost_cycle_terminates(); + test_recursion_is_capped_not_hung(); + test_self_index_indirection(); + test_absent_slots_are_skipped(); + test_live_shape_call3(); + return simtest::finish("game_sim_techgraph"); +}