First End Turn 3/3/0, five-turn continuation 15/15/0, Zuul 20/20/0, tracecmp exit 0 on all three. The End-Turn oracle hashes are unchanged, so the cascade does not perturb the game. All 22 divergent fields lane V recorded are gone. The prediction in section 4 held field for field on the deterministic half; call 9 turned out to be a different completion from lane V's (the AI picked another target from turn 5), which the model reproduced anyway - three unlock costs that appear in no earlier report. Honest limit: roll_draws was 0 on all 35 calls. ResearchRollPending is normally consumed by ProcessTurn before ProcessResearch runs, so the RollResearchEvent draw is modelled and inside the compare but has never been seen to fire.
430 lines
24 KiB
Markdown
430 lines
24 KiB
Markdown
# 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
|
||
|
||
Build `unlock-405ba41-20260908T1026Z`, cross-built on CT111
|
||
(`/srv/re-lab/build/sots-engine-u`, exports byte-identical to the real `binkw32.dll`), staged
|
||
`C:\SOTS\shimdist-u` on VM140. Recipe `shim.cfg.recapb3`, unchanged.
|
||
|
||
**The residual is gone. All three runs are 0 divergent, `tracecmp` exit 0.**
|
||
|
||
| run | calls | compared | diverged | exit |
|
||
|---|---|---|---|---|
|
||
| first End Turn (`ref-turn2` → Launch → End Turn) | 3 | 3 | **0** | 0 |
|
||
| five-turn continuation (turn 2 → 7) | 15 | 15 | **0** | 0 |
|
||
| Zuul (`zuul-turn5` → 10 End Turns, turn 5 → 15) | 20 | 20 | **0** | 0 |
|
||
|
||
Reports `verify/results/compare/unlock-b3-{t1,t1-5,zuul}.md` in the RE repo; traces
|
||
`verify/traces/unlock-b3-*`; shim log `verify/results/shim/unlock-shim.log`.
|
||
|
||
### 5.1 The oracle first
|
||
|
||
`(Autosave EndTurn).sav` = `bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948`,
|
||
`(Autosave).sav` = `978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921` — **the same two
|
||
hashes lane R and lane V recorded**. The cascade does not perturb the running game. That check
|
||
comes first because a clean compare from a build that moved the game would be worthless.
|
||
|
||
### 5.2 First End Turn — §4.1 held field for field
|
||
|
||
3 calls, 0 divergent, exit 0, and every predicted argument:
|
||
|
||
| | predicted | observed |
|
||
|---|---|---|
|
||
| `observed_techs_in` | 10 / 20 / 20 | **10 / 20 / 20** ✓ |
|
||
| `roll_pending_in` (call 0) | false | **false** ✓ |
|
||
| `order_counter_in` (call 0) | ≥ 0, not −1 | **22** ✓ |
|
||
| `research_target` | non-null | `0x049812f8` ✓ |
|
||
| `observed_scan_failed` / `_truncated` | absent | absent ✓ |
|
||
| shim log, all 3 calls | `ok=1 completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0` | exactly that ✓ |
|
||
|
||
The collector ran on all three and came back empty, which is the §3.2 check: it is only silent
|
||
because nothing became available, not because it was not asked.
|
||
|
||
### 5.3 Five turns — §4.2 held on the deterministic half, and the rest was better than predicted
|
||
|
||
Call 3 is exactly the call lane P and lane V pinned, and every number matched:
|
||
|
||
| call 3 | predicted | observed |
|
||
|---|---|---|
|
||
| `events.next_id` | 5 → **7** | 5 → **7** ✓ |
|
||
| `node[132]` state / cost_rp / turn_available | 0→2 / INT_MAX→10000 / −1→4 | ✓ |
|
||
| `node[136]` | 0→2 / INT_MAX→16000 / −1→4 | ✓ |
|
||
| `node[142]` | 0→2 / INT_MAX→8000 / −1→4 | ✓ |
|
||
| `node[144]` state / turn_researched / order | 3→4 / 4 / **22** | ✓ |
|
||
| `observed_techs.bytes` | 440 → **484** | ✓ |
|
||
| `order_counter_in` | **22** | **22** ✓ |
|
||
| `roll_pending_in` | **false** | **false** ✓ |
|
||
| `observed_techs_in` | **10** | **10** ✓ |
|
||
| shim log | `completions=1 unlocked=3 otch_appends=1 roll_draws=0` | exactly that ✓ |
|
||
|
||
**Call 9 was not the call I predicted, and that is the more interesting result.** From turn 5 the
|
||
AI picked a different research target than in lane V's session — lane R's documented trap #2, and
|
||
the reason the board calls the continuation *a* run and not *the* run. Lane V's call 9 completed
|
||
tech 142 and unlocked one node; mine completed **tech 9** and unlocked **three**:
|
||
|
||
```
|
||
node[9] state 3->4 progress 3064->6000 turn_researched -1->6 order -1->23
|
||
node[3] state 0->2 cost_rp INT_MAX->13000 turn_available -1->6
|
||
node[12] state 0->2 cost_rp INT_MAX->35000 turn_available -1->6
|
||
node[18] state 0->2 cost_rp INT_MAX->4000 turn_available -1->6
|
||
```
|
||
|
||
`next_id` 10 → **12** and `observed_techs.bytes` 484 → **528** as predicted, but the three costs —
|
||
13000, 35000, 4000 — appear in no earlier report and were not predicted by anyone. The model
|
||
reproduced them with zero divergences on a case it had never seen. An unrehearsed instance is
|
||
worth more than a rehearsed one, and it is the answer to "did the model just memorise lane V's
|
||
numbers": it cannot have, because lane V's numbers are not what happened.
|
||
|
||
Call 12 then allocates to node 18 — the 4000-cost tech the cascade had just unlocked — which is
|
||
the cascade's output feeding the next turn's budget.
|
||
|
||
### 5.4 One prediction missed: the guard count
|
||
|
||
Predicted **10 undeclared writes in 2 calls**, observed **9 in 2 calls**, and the span list is not
|
||
the same one:
|
||
|
||
```
|
||
lane V: player+0x10c:3 +0x110:3 +0x114:3 +0x124:3 +0x294:4 +0x196:1 +0x3b4:1 tree_header+0x20:1
|
||
lane U: player+0x10c:3 +0x110:3 +0x114:3 +0x124:3 +0x294:4 +0x130:3 tree_header+0x20:1
|
||
```
|
||
|
||
This is the workload difference, not the code. `player+0x3b4` is gone because the pending-roll
|
||
byte was **already 0** when the completing call ran, so the original never wrote it (see §5.5).
|
||
`+0x196` (design-option mask B) is replaced by `+0x130` (`PopMod`) because a different tech
|
||
completed and different tech effects fired. Both are `OnTechResearched`'s writes and both are
|
||
declared unmodelled. `tree_header+0x20` is still there and still undeclared, exactly as §4.3 said
|
||
it would be: `ours` seeds the counter and advances its own copy, it does not model the live word.
|
||
|
||
Coverage verdict `partial` with **8** unmodelled notes, as predicted.
|
||
|
||
### 5.5 The `RollResearchEvent` draw — modelled, inside the compare, but NOT exercised
|
||
|
||
This is the honest limit of the run, and it should not be read as more than it is.
|
||
|
||
`roll_draws` was **0 on all 35 compared calls**. Not one completion in three runs had the pending
|
||
byte still set. The reason is visible in the trace and it is a real finding: `ResearchRollPending`
|
||
is normally consumed by `ServerPlayer::ProcessTurn` *before* `ProcessResearch` runs, because that
|
||
call site fires when the progress ratio crosses a threshold — which is precisely the turns
|
||
approaching completion. In the Zuul run `roll_pending_in` is `true` on the funded call for turns
|
||
8–12 and flips to **false** on turn 13, the turn before the tech completes on turn 14.
|
||
|
||
So lane V's call-9 draw was the *rare* case (a tech that jumped past the threshold to completion
|
||
in one turn), not the normal one — which also explains lane R's "RNG matched 15 of 15". Three
|
||
sessions, three different answers: 0 extra draws, 1, and 0.
|
||
|
||
What can be claimed: the draw is implemented at the right point in the stream (inside the owner
|
||
callback, between this entry's roll and the next entry's), its two inputs are read pre-call and
|
||
**reported in every record** (`research_target`, `roll_pending_in`), and `region:rng` compared
|
||
clean on all 35 calls — so the model is not drawing a word the original does not. What cannot be
|
||
claimed: that the branch has been seen to fire live. **The boundary is inside the compare, but the
|
||
compare has not yet had the chance to test it.** It needs a workload where a tech goes from below
|
||
the ProcessTurn threshold to complete in a single turn.
|
||
|
||
### 5.6 Zuul — the double roll and the completion path, together
|
||
|
||
20 calls, 0 divergent, exit 0, turn 5 → 15, **two** Zuul completions. Call 2 is the one lane V's
|
||
save was one End Turn short of:
|
||
|
||
```
|
||
call 2 turn 7 species 5 alloc {144, 1376} left 449 -> 447 next_index 175 -> 177
|
||
node[144] state 3->4 turn_researched 7 order 21
|
||
node[132] state 0->2 cost_rp INT_MAX->10000 turn_available 7
|
||
node[136] state 0->2 cost_rp INT_MAX->16000 turn_available 7
|
||
events.next_id 10 -> 12 observed_techs 396 -> 440
|
||
```
|
||
|
||
The generator advances by **two** (the species-5 double roll) *and* the completion cascade runs,
|
||
in the same call, and `ours` reproduces the post-state bit for bit. Note the Zuul tree unlocks
|
||
only 132 and 136 from tech 144 where the Human tree also unlocks 142 — a per-species tree, so this
|
||
is an independent instance of the cascade and not a repeat of §5.3. A second completion follows on
|
||
call 16 (turn 14, `next_id` 26 → 28, `observed_techs` 440 → 484).
|
||
|
||
### 5.7 What is now owed
|
||
|
||
* The `RollResearchEvent` draw needs a workload that fires it (§5.5).
|
||
* `TechTree::GetProgressRatio` (0x0057e950) — the COMPLETE/UNDERBUDGET split is still analogy.
|
||
Count-neutral, so no run can see it.
|
||
* `def+0xb0`'s write site. The byte is read and honoured; where it comes from is not read.
|
||
* The `0x00889d60` branch behind the research-event roll, the temperance sweep, `PruneOldTurns`,
|
||
and replace mode on this hook — all still untouched.
|
||
* The ObservedTech element's own fields. `ours` decides the append; it does not build the element.
|