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.
This commit is contained in:
Alex 2026-09-08 06:26:00 -04:00
parent 0f7aba1278
commit 405ba41a1e
11 changed files with 1584 additions and 50 deletions

285
docs/U-unlock.md Normal file
View file

@ -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.

View file

@ -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 <cstdint>
@ -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<TechPrereqEntry> at TechPrereqs+0x00; only its _Myfirst is read (`*param_2`), because the groups carry the bounds. Element stride 8, the entry's TechDef* at +0x00; the second word is never read by PrereqsMet [verified]
constexpr uint32_t TechPrereqs_off_Entries = 0x00000000;
// field std::vector<TechPrereqGroup> at TechPrereqs+0x10 (_Myfirst +0x10, _Mylast +0x14). Group count = (last - first) >> 3. Each group is {int start; int count} indexing the entry array: the OR-set is entries[start .. start+count) [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

View file

@ -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)

View file

@ -117,7 +117,8 @@ void DecayAllResearch(std::vector<ResearchNode>& nodes) {
ResearchTurnResult ProcessResearchTurn(std::vector<ResearchNode>& nodes,
const std::vector<ResearchAllocEntry>& 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<ResearchNode>& 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;

View file

@ -145,6 +145,16 @@ struct ResearchTurnResult {
std::vector<ResearchStepResult> 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<ResearchNode>& nodes,
const std::vector<ResearchAllocEntry>& alloc, Species owner,
IRandom& rng);
IRandom& rng, ResearchCompletionHook onCompleted = nullptr,
void* hookCtx = nullptr);
// ---------------------------------------------------------------------------------------
// Lab accidents

166
src/game/sim/techgraph.cpp Normal file
View file

@ -0,0 +1,166 @@
#include "game/sim/techgraph.h"
#include <algorithm>
#include <cstddef>
namespace sots::sim {
namespace {
bool in_range(const TechGraph& g, int i) {
return i >= 0 && static_cast<std::size_t>(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<std::size_t>(n.selfIndex)];
return p.present ? &p : nullptr;
}
constexpr int kStateHidden = static_cast<int>(TechState::Hidden);
constexpr int kStateParentResearched = static_cast<int>(TechState::ParentResearched);
constexpr int kStateAvailable = static_cast<int>(TechState::Available);
constexpr int kStateResearched = static_cast<int>(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<std::size_t>(defIndex)].prereqs;
if (p.unreadable) return false;
for (const std::vector<int>& 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<std::size_t>(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<std::size_t>(defIndex)].present) return r;
if (g.nodes[static_cast<std::size_t>(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<std::size_t>(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<TechEdgeRef> children = g.nodes[static_cast<std::size_t>(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<std::size_t>(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<int>(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<int>(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<int> CollectNewlyAvailable(const TechGraph& g, int currentTurn) {
std::vector<int> 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<int>(i));
}
return out;
}
} // namespace sots::sim

200
src/game/sim/techgraph.h Normal file
View file

@ -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 <cstddef>
#include <vector>
#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<std::vector<int>> 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<TechEdgeRef> children; // +0x04 vector<TechEdge*>
TechPrereqs prereqs; // def+0x88
};
struct TechGraph {
std::vector<TechGraphNode> 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<int> 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<int> madeAvailable;
// Indices whose costRP the first sweep lowered.
std::vector<int> 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<int> CollectNewlyAvailable(const TechGraph& g, int currentTurn);
} // namespace sots::sim

View file

@ -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<ObservedTech>
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<int> node_region; // node index -> region index, -1 when the slot is null
std::vector<std::string> names; // stable storage for Region::name
std::vector<void*> 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<ObservedTech>. Game text, kept inside the
// shim for one purpose only -- deciding whether RecordObservedTech would append -- and never
// emitted into a trace record.
std::vector<std::string> 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<ObservedTech>`, 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<std::string>& out, bool& truncated) {
out.clear();
truncated = false;
if (!readable(vec, kVectorHeaderSize)) return false;
const char* first = static_cast<const char*>(ptr_at(vec, 0));
const char* last = static_cast<const char*>(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<std::size_t>(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<TechEdge*>` 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<sots::sim::TechEdgeRef>& out) {
out.clear();
const char* first = static_cast<const char*>(ptr_at(node, A::TechNode_off_Children));
const char* last = static_cast<const char*>(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<std::size_t>(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<const char*>(def) + A::TechDef_off_Prereqs;
if (!readable(block, A::TechPrereqs_off_Groups + 8)) return;
const char* entries = static_cast<const char*>(ptr_at(block, A::TechPrereqs_off_Entries));
const char* gfirst = static_cast<const char*>(ptr_at(block, A::TechPrereqs_off_Groups));
const char* glast = static_cast<const char*>(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<std::size_t>(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<std::size_t>(count) > kMaxPrereqEntries) return;
std::vector<int> group;
group.reserve(static_cast<std::size_t>(count));
if (count > 0) {
const char* base = entries + static_cast<std::size_t>(start) * A::TechPrereqs_entry_stride;
if (!readable(base, static_cast<std::size_t>(count) * A::TechPrereqs_entry_stride)) return;
for (int k = 0; k < count; ++k) {
void* edef = ptr_at(base, static_cast<std::size_t>(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<void*>& 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<const unsigned char*>(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<void*>* nodes = nullptr; // the SCRATCH copies in compare mode
sots::sim::TechGraph* graph = nullptr;
std::vector<sots::sim::ResearchNode>* 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<std::string> 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<int> 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<CascadeCtx*>(ctx);
if (nodeIndex < 0 || static_cast<std::size_t>(nodeIndex) >= c.nodes->size())
return sots::sim::kNoResearchCost;
void* p = (*c.nodes)[static_cast<std::size_t>(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<std::size_t>(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<CascadeCtx*>(ctx);
++c.completions;
if (nodeIndex < 0 || static_cast<std::size_t>(nodeIndex) >= c.nodes->size()) return;
void* p = (*c.nodes)[static_cast<std::size_t>(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<const char*>(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<CascadeCtx*>(ctx);
if (nodeIndex < 0 || static_cast<std::size_t>(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<std::size_t>(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<int>((*c.model)[i].state))
(*c.model)[i].state = static_cast<sots::sim::TechState>(gs);
}
}
} // namespace
// ---- descriptor ---------------------------------------------------------------------------
@ -409,6 +708,36 @@ void TechTreeProcessResearchHook::describe_args(std::vector<Tv>& out, void* tree
out.push_back(tv::u32(static_cast<std::uint32_t>(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<const unsigned char*>(owner) + A::ServerPlayer_off_ResearchRollPending) != 0;
g_pre.observed_ok =
scan_observed_techs(static_cast<const char*>(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<void*>(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<std::uint32_t>(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<trace::Region>& 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<std::size_t>(g_call.events_region)) : nullptr;
g_call.scratch_otch =
g_call.otch_region >= 0 ? s.ptr(static_cast<std::size_t>(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<std::int32_t>(model[i].flag));
set_word(p, A::TechNode_off_State, static_cast<std::int32_t>(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<std::string> 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<char*>(ptr_at(g_call.scratch_otch, 0));
char* last = static_cast<char*>(ptr_at(g_call.scratch_otch, 4));
if (first && last >= first)
set_ptr(g_call.scratch_otch, 4,
last + static_cast<std::size_t>(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<unsigned>(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)) {

View file

@ -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 <cstdint>
@ -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<ObservedTech> (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");

View file

@ -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})

View file

@ -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 <string>
#include <vector>
#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<int>(i);
}
g.hasOwner = true;
g.turn = turn;
return g;
}
void link(TechGraph& g, int parent, int child, int costRP) {
g.nodes[static_cast<std::size_t>(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<int> freeNodes;
int costCalls = 0;
std::vector<int> researchedCallbacks;
std::vector<bool> silentSeen;
static int Cost(void* ctx, int i) {
Env& e = *static_cast<Env*>(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<Env*>(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<int>(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<int>(TechState::Available);
CHECK(!TechPrereqsMet(g, 0));
// Groups are an AND: every one must be satisfied.
g.nodes[1].state = static_cast<int>(TechState::Researched);
g.nodes[0].prereqs.groups = {{1}, {2}};
CHECK(!TechPrereqsMet(g, 0));
g.nodes[2].state = static_cast<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(TechState::Available));
CHECK_EQ(g.nodes[2].state, static_cast<int>(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<int> 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<int>(TechState::ParentResearched);
g.turn = 9;
TechGraph g2 = g;
g2.nodes[0].state = static_cast<int>(TechState::CurrentTarget);
Env e2;
SetResearched(g2, 0, kTechForce, e2.env());
CHECK_EQ(g2.nodes[1].state, static_cast<int>(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<int>(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<int>(TechState::Researched));
CHECK_EQ(g.nodes[1].order, 11);
CHECK_EQ(g.nodes[2].state, static_cast<int>(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<int> 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<int>(TechState::Researched));
CHECK_EQ(g.nodes[1].state, static_cast<int>(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<int>(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<int>(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<int>(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<int>(TechState::Available));
const std::vector<int> 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<int>(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<std::size_t>(i)].state, static_cast<int>(TechState::Available));
CHECK_EQ(g.nodes[static_cast<std::size_t>(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");
}