diff --git a/findings/subsystems/research-replace.md b/findings/subsystems/research-replace.md index 8c06c5b..0aab482 100644 --- a/findings/subsystems/research-replace.md +++ b/findings/subsystems/research-replace.md @@ -249,3 +249,305 @@ OK; `tools/check_shim_configs.py` OK with 27 registered hooks and all four CR co ## 4. Results *(added after the runs; nothing above this line is edited)* + +### 4.0 The five runs, in order + +All five on **VM145**, held by this lane, each a **fresh process**, `SavedGames` reset to exactly +`turn3-state.sav` before every launch, `C:\SOTS\shimdist-cr\binkw32.dll` +(25,274,723 B, sha256 `d2ad56b32c1b5f6b…`, `BUILD_ID cr-618ccb1-20260909T131556Z`, exports 66/66 +identical to the real `binkw32.dll`), one config per run and nothing else changed. Every screen — +main menu, Load-Game chooser, the one-row file list, the **lobby**, the loaded map at "Turn 3", the +post-turn map at "Turn 4" — was verified from a live `qm monitor` screendump before the next click. +No run was driven by sleeping. + +| run | config | mode | `research.replace_cascade` | `(Autosave EndTurn).sav` | `(Autosave).sav` | +|---|---|---|---|---|---| +| **C1** | `croff` | `hooks=off` | – | 67,212 `e00eed0c…` | 67,811 `79df5047…` | +| **C2** | `croff` | `hooks=off` | – | 67,212 **`e00eed0c…`** | 67,811 **`79df5047…`** | +| **N** | `crcompare` | compare | off | 67,212 **`e00eed0c…`** | 67,811 **`79df5047…`** | +| **R0** | `crreplace0` | **replace** | off | 67,212 `e00eed0c…` | 67,511 **`6b51db99…`** | +| **R1** | `crreplace1` | **replace** | **on** | 67,212 `e00eed0c…` | 67,537 **`8a4309ee…`** | + +`(Autosave EndTurn).sav` is the *pre*-turn resave and is identical in all five runs, as it must be — +nothing has run yet when it is written. The verdict is carried entirely by `(Autosave).sav`. + +### 4.1 P0 held: the oracle + +**C1 and C2 agree byte-for-byte in two fresh processes.** The pair, in the standing +`certified-pairs.md` format: + +| input | procedure | route | `(Autosave EndTurn)` | `(Autosave)` | processes | evidence | +|---|---|---|---|---|---:|---| +| `turn3-state.sav` `978041ac…` | one End Turn | **load** | `e00eed0c…` | `79df5047…` | **3** | lane CR ×2 `hooks=off`, ×1 compare-instrumented | + +**Masks that must be on the line.** Measured, not assumed — +`state_checksum.py turn3-state.sav <(Autosave EndTurn)>` gives **exactly 5 leaves**: +`/Summary/Checksum` and `Player.Status 4 → 0` on each of the four live players (16 `re`, +32 `Fane Lao`, 496 and 512 `Singularity`). **There is no `/CD[1]/NPrvVa` term on this state** — +`CD[1]`'s diplomacy block is early-game and the leaf does not move — so the `--mask resave` rule +holds here in the form the docs originally stated, and the lane BQ exception does not apply. + +**Exposure facts next to the hashes** (certified-pairs standing rule 4): §1.3's table — no player +with an AI client enters the turn with `ResTNm == ''`; `NumDes` does not move; the two ships that +complete join existing fleets; the four factions at 528–576 carry the empty-`ResTNm` signature +inertly (`Status 0`, no client). The turn does create fleets (`Flt[50]`, `Flt[1808]`) and retire one +(`Flt[1776]`) — that is a fleet-assignment shape, and it agreed anyway. **Which is the point of +rule 26(c)'s retraction: the screen said "likely fine" and only the two-process control decided it.** + +### 4.2 P3 held: the instrument is neutral, and it counts the completion itself + +Run **N** reproduced the control's two hashes exactly, so rule 19 is satisfied on *this* save and +*this* route and everything below is read from runs that passed their own check. +`tracecmp verify/traces/cr-N.jsonl.gz`: **3 calls, 3 compared, 0 diverged, exit 0**, coverage verdict +`partial`, 8 unmodelled notes — and the undeclared-write set is **exactly the six spans predicted**: + +``` +player+0x10c:3 player+0x110:3 player+0x114:3 player+0x124:3 player+0x294:4 tree_header+0x20:1 +``` + +The hook's own per-call line, which is the instrument saying a completion fired rather than the save +being asked to imply it: + +``` +research: mode=compare steps=1 completions=1 overbudget=1166 cascade_possible=1 ok=1 + cascade_completions=1 unlocked=3 otch_appends=1 roll_draws=0 failures=0 depth=0 +research: mode=compare steps=1 completions=0 … (×2, all zero) +``` + +**A correction to the campaign's read of the detour count, and it goes the other way from the +brief's warning.** My config names all **27** registered template hooks and +`check_shim_configs.py` passes it as `# exhaustive` — and the shim still installed **six** detours, +not one: + +| detour | source | named by a `hook.` key? | +|---|---|---| +| `Mars::Application::Initialize` | the M0 asm stub, installed unconditionally whenever `hooks != off` | **no** | +| `Game::TechTree::ProcessResearch` | the one template hook | yes | +| `StrategyClient::EndTurn`, `StrategyServer::BeginProcessTurn`, `StrategyServer::ProcessTurn`, `DemoApp::OnTick` | the **FPU-force module**, which installs four *sampling* detours by default (`fpu: module init … force=off value=0x0000 sample_ticks=on`, `sample_turn=on`) | **no** | + +`Shim::SelfTest::Fill` additionally emits one `trace` record at startup; it is an in-shim self-test, +not a detour on the game. So **`# exhaustive` is exhaustive over the template-hook set only**, and +`tools/check_shim_configs.py` cannot see the other five. It was the *neutrality check* (N identical +to C1/C2), not the config check, that made this run safe — which is worth saying plainly, because a +lane reading "exhaustive, therefore one detour" would be wrong by five. + +For the record, `fpu_cw = 0x027f` (53-bit, round-to-nearest) in every CR run. + +### 4.3 P1 and P2 held exactly, on the LOAD route + +The load route reproduced the continuation's turn-4 call in every particular — allocations, the +completion, the unlock set, the arithmetic. From `cr-N.jsonl.gz` (compare) and confirmed identically +in `cr-R1.jsonl.gz` (replace): + +| call | owner | species | alloc | what moved | +|---|---|---|---|---| +| 1 | `Player[32 "Fane Lao"]` | 2 | `{144, 2898}` | the completion (below) | +| 2 | `Player[496 "Singularity"]` | 0 | `{90, 0}` | **nothing at all** | +| 3 | `Player[512 "Singularity"]` | 2 | `{9, 0}` | **nothing at all** | + +Call 1, node **144 `IND_Waldo`**: `state 3 → 4`, `progress 5768 → 7500`, `turn_researched −1 → 4`, +`order −1 → 22`; `overbudget 0 → 1166`; three nodes unlocked — +**132 `IND_OrbFound` @ 10000**, **136 `IND_RefCoat` @ 16000**, **142 `IND_TrkStl` @ 8000**, each +`state 0 → 2`, `cost_rp INT_MAX → …`, `turn_available −1 → 4`; `events.next_id 5 → 7`; +`observed_techs 440 → 484 bytes`. + +P2's hand arithmetic is confirmed to the unit: `cost 5000`, `lo 2500`, `hi 7500`, +`spent = min(2898, 1732) = 1732`, `progress = 7500` **exactly on the ceiling**, +`overbudget = 2898 − 1732 = 1166`; `progress < hi` false so **no draw**; `ratio = 1.5`, not below +`0.800000011920929`, so **`flag` unchanged**. The `rng` region **did not move on any of the three +calls** in any run. + +That last fact is a coverage hole, not a success — see §4.6. + +### 4.4 P4 and P5 held: replace diverges, and the cascade closes exactly eleven leaves + +`state_checksum.py cr-oracle-autosave.sav `: + +| run | diverging leaves | file size | +|---|---:|---| +| **R0** — replace, cascade **off** (the shipped behaviour) | **27** | 67,511 B | +| **R1** — replace, cascade **on** | **16** | 67,537 B | +| (the turn itself, `turn3-state` → oracle, for scale) | 128 | – | + +**The eleven leaves the cascade closes — R0 has them, R1 does not.** All eleven are `TechTree`: + +``` +Player[32]/TechTree/St[94] TResCost[94] TUnlck[94] (node 132 IND_OrbFound, 10000) +Player[32]/TechTree/St[98] TResCost[98] TUnlck[98] (node 136 IND_RefCoat, 16000) +Player[32]/TechTree/St[104] TResCost[104] TUnlck[104] (node 142 IND_TrkStl, 8000) +Player[32]/TechTree/TAcq[106] TiAcq[106] (node 144, turn 4 / order 22) +``` + +The tree is serialised as parallel arrays indexed by **tree slot**, not tech id — slots 94/98/104/106 +are tech ids 132/136/142/144 — and the pass's own two words, `St[106] 3 → 4` and +`TResDone[106] 5768 → 7500`, are correct in **both** replace runs because +`ProcessResearchTurn` writes them without the cascade. So of the **13 tech-tree leaves this turn +moves, our code produced all 13 in live memory with the original's `ProcessResearch` never +executing** — 2 from the pass, 11 from `SetResearched`. + +**One prediction I cannot test and must retract as written.** P4 item 2 said the completion-order +counter would be "left at 22" in R0. `TechTree+0x20` **is not a save leaf** — the counter's value +surfaces only through the per-node `TiAcq` stamp — so the oracle cannot see it either way. What is +observable is the trace: R1's only guard hit is `tree_header+0x20:1` (ours writing 22 → 23, the same +byte the original moves in compare mode) and R0 has **0 undeclared writes in 0 calls**. The counter +matters for the *next* completion, not for this save. + +### 4.5 P6 held: the residual is `ServerPlayer::OnTechResearched`, entirely + +R1's **16** leaves, every one of them named, with nothing left over: + +| leaf | what it is | modelled by `ours`? | +|---|---|---| +| `Player[32]/OutMod` `1.25 → 1.1` | a tech effect | no — B2's milestone | +| `Player[32]/ConMod[0..2]` `0.9 → 1.0` (×3) | tech effects | no — B2's milestone | +| `Player[32]/Events/EvNxID` `7 → 5` | the two events not posted | decision modelled, **write is compare-only** | +| `…/Events/.[EvTurn=4]/Events/.[EvEID=5]`, `.[EvEID=6]` `only-in-A` | the two event records | text comes from the game's string table | +| `…/Events/.[EvTurn=4]/Events/.[0]` `3 → 1` | that turn's event count | ditto | +| `Player[32]/otch/.[11]` `only-in-A`, `otch/.[0]` `11 → 10` | the `ObservedTech` element | append **decided** (`otch_appends=1`), element not constructed | +| `Player[32]/ResTNm` `'' → 'IND_Waldo'` | `ResT` never cleared | inside the callback | +| `Player[32]/BnkPr`, `BnkEl` | bankruptcy projection | **downstream of `OutMod`** | +| `Sys[288 "Ke'Dolarra"]/RepCur`, `RepMax` `421640 → 371040` | repair capacity | **downstream of `ConMod`** | +| `/Summary/Checksum` | derived | derived | + +So the residual decomposes into **5 primary player fields** (`OutMod`, `ConMod[0..2]`, `ResTNm`), +**1 `ObservedTech` element**, **2 event records + their id counter**, and **5 derived leaves** that +follow from those. Every single one is written by `ServerPlayer::OnTechResearched`, none of them by +`TechTree::ProcessResearch` or by `SetResearched`. + +That also closes the loop with §4.2's guard: the four `player` spans the compare reported as +undeclared (`+0x10c`, `+0x110`, `+0x114`, `+0x124`, all three bytes wide — float writes whose top +byte did not change) plus `+0x294` (`ResT`, four bytes) are **five** writes, and the save shows +**five** primary player fields. The guard was reporting exactly what the oracle later billed us for. + +**The input class that breaks it is a completion, and only a completion.** Both replace runs are +byte-perfect on the two null calls and on every other leaf of the 128 the turn moves. A replace run +over a turn where research does not complete would be byte-identical — and would prove nothing +(rule 1), which is why this lane refused to run it on `ref-turn2`. + +### 4.6 Coverage, reported as loudly as the result (rules 15 and 23) + +* **1 completion. 1 distinct tech (144 `IND_Waldo`). 3 unlocked nodes. 3 calls.** +* **2 of the 3 calls allocate zero points and write nothing at all**, in any mode. Their entire + contribution to "3 calls, 0 diverged" is that two null calls stayed null. +* **1 of 4 tech trees is exercised.** Player 16's tree is never processed (`ResTNm == ''`); players + 496 and 512 are the null calls. +* **The RNG region did not move on any call in any run.** The spend cap bound exactly, so the + odds/roll branch was skipped, and `roll_pending_in` was false on the completing call, so + `RollResearchEvent` drew nothing (`roll_draws=0`). **`region:rng` — the single strongest check in + this hook's compare — compared "unchanged against unchanged" on this workload and established + nothing.** The generator parity evidence for this module is entirely lane U's and lane V's, + on other turns. +* **Branches that did not execute here:** the Zuul double roll (species 2, not 5); the + completed-early flag (`ratio 1.5`); the over-budget notification (`flag` already 2 from turn 3); + `RollResearchEvent`'s draw and, behind it, the plague / AI-rebellion paths; `SetResearched`'s + zero-cost recursion; the `def+0xb0` `NoAutoAvailable` skip; an empty prerequisite group; a + re-observed tech (the dedup's negative case); and the decay sweep, which ran over every node and + **changed nothing** because no other `Available` node had non-zero progress. +* **The event model is count-only by construction** and stayed compare-only in replace mode by + design; the two missing records are two of the sixteen residual leaves. +* **`TechTree::Cost` is the original's.** `ours` calls the game's read-only `Cost` for every cost it + needs, in both modes. The effective-cost formula is **not** displaced, and any claim about this + module inherits that dependency. + +### 4.7 Verdict + +**No. `game/sim/research` does not move from `compared` to `replaced`.** + +The bar is "our code ran instead of the original's **and a byte-level oracle held afterwards**". Our +code did run instead — `mode=replace`, `completions=1`, `unlocked=3`, the original's +`ProcessResearch` never executed, and the game finished the turn and wrote a save. The oracle did +**not** hold: 16 leaves in the best configuration. There is no qualified reading that rescues it, +because the failure is not a rounding residual — it is a set of writes nobody has implemented. + +What the lane did establish, and it is worth more than the rung would have been: + +1. **The research pass and the entire `SetResearched` cascade are displaceable and were displaced.** + All 13 tech-tree leaves the turn moves were produced by our code in live game memory, on a turn + with a real completion and a real three-node unlock cascade — which is a strictly stronger + statement than the 35 compared calls the board already carried, because in a compare the + original's code still did the work. +2. **The blocking boundary is named and measured, not guessed:** `ServerPlayer::OnTechResearched`, + 5 player fields + 1 `ObservedTech` element + 2 events, and 5 derived leaves behind them. + `ProcessResearch` **cannot** reach `replaced` on any workload containing a completion until + `OnTechResearched` is displaced — and that is B2's milestone and its own `compared` board row, + not a defect in the research model. +3. **A new certified pair** on a turn that exercises the completion path, which the campaign did not + have: `ref-turn2` + one End Turn is a *quiet* turn for research, and every oracle the module had + been checked against was that one. + +The cheapest route to the rung, now that the boundary is priced: implement the ~90-field +`ApplyTechEffect` write-back live (B2 already has `game/effects/tech_effects` host-tested), construct +the `ObservedTech` element, and decide what to do about the two event records — whose *text* comes +from the game's string table and therefore cannot be produced clean-room at all. **The event text is +a hard stop for a byte-identical oracle on any completion turn**, and that should be settled as a +policy question (call the game's `PostEvent`, and accept the `ComputeBudget`-shaped QUALIFIED +caveat) before anyone spends another lane on it. + +--- + +## 5. Proposed board rows + +**I have not edited `campaign/board.md`.** I *have* added the certified pair to +`verify/results/saves/certified-pairs.md`, which the brief pointed at as the standing format and +whose four standing rules for adding a row are all satisfied (two fresh `hooks=off` processes; the +control run before anything was read from an instrumented run; not an extension of an existing pair +but its own agreement; exposure facts recorded beside the hashes). + +### 5.1 `tools/displacement.py` — the rung does NOT move + +The verdict is **no**, so `"compared"` stays. What I do propose is replacing the evidence and caveat +strings, which currently understate what is known and do not name the gate: + +```python + ("TechTree::ProcessResearch + unlock cascade", "compared", + "35 calls across 3 workloads, 0 divergences, tracecmp exit 0; advance prediction held on a " + "changed workload (unlock costs no earlier report contained); REPLACE ATTEMPTED live (lane " + "CR): ours ran instead of the original on a real completion and produced all 13 tech-tree " + "leaves the turn moves, but the save oracle missed by 16 leaves", + "compare only. The replace attempt failed on ServerPlayer::OnTechResearched, not on the " + "research model: 5 player tech-effect fields, 1 ObservedTech element and 2 event records, " + "plus 5 derived leaves. Gated on B2. Thin: 1 completion, 1 tech, 2 of 3 calls allocate zero " + "points, and the RNG region did not move at all on the replace workload"), +``` + +### 5.2 Board rows to add + +| row | class | status | conf | cov | date | evidence | +|---|---|---|---|---|---|---| +| **`ProcessResearch` replace: our code ran instead of the original's, and the oracle missed by 16 leaves — all of them `OnTechResearched`'s** | phase2 | verified | high | 95% | 2026-09-09 | **Lane CR, VM145**, `findings/subsystems/research-replace.md`. Predictions committed before the build (`sots-re` 4b3cc82); engine `wip/cr` 618ccb1 adds `research.replace_cascade=on\|off` (default off) so the shipped and extended behaviours differ by **a config line, not a binary**. New certified pair `turn3-state.sav` → one End Turn, **load** route, `e00eed0c…`/`79df5047…`, 2 `hooks=off` processes + 1 compare. Compare run **3/3/0 exit 0** and byte-identical to the control (rule 19 satisfied), undeclared writes exactly the 6 predicted spans. **Replace, cascade on: 16 diverging leaves; cascade off: 27.** The 11-leaf delta is the whole `SetResearched` cascade and it is **ours**; with the pass's own 2 words that is **13 of 13 tech-tree leaves the turn moves, produced live by our code**. The 16 residual leaves are `OutMod`, `ConMod[0..2]`, `ResTNm`, one `ObservedTech` element, two event records + `EvNxID`, and 5 derived. **Verdict: stays `compared`.** | +| **`ref-turn2` + one End Turn does NOT exercise a research completion** | verify | verified | high | 100% | 2026-09-09 | Lane CR. The campaign's most-reproduced oracle is a **quiet turn for the completion path**: `unlock-b3-t1.md` reports `0 undeclared write(s) in 0 call(s)` and `unlock-shim.log`'s first three lines read `completions=0`. It *is* a real workload for the pass (one RNG word, `flag 1 → 2`, one over-budget event) — but a replace-mode oracle taken there would be rule 1's green verdict on a hook comparing nothing. The completing turn is the **next** one, from `turn3-state.sav`. | +| **`# exhaustive` is exhaustive over the 27 template hooks only — six detours are installed, not one** | phase2 | verified | high | 100% | 2026-09-09 | Lane CR. A config naming all 27 registered hooks `off` except one, passing `tools/check_shim_configs.py`, still installs **6** detours: the M0 `Application::Initialize` asm stub (unconditional whenever `hooks != off`) and the **FPU-force module's four sampling detours** (`force=off value=0x0000 sample_ticks=on sample_turn=on`, on by default). `check_shim_configs.py` cannot see either group — no `hook.` key names them. What made lane CR's runs safe was the **neutrality check** (compare run byte-identical to two `hooks=off` controls), not the config check. A lane reading "exhaustive, therefore one detour" is wrong by five. | +| **A replace of `ProcessResearch` is gated on `ServerPlayer::OnTechResearched`, and partly on a policy question** | phase2 | open | high | – | 2026-09-09 | Lane CR. To reach `replaced`, three things are needed: the ~90-field tech-effect write-back applied live (B2 has `game/effects/tech_effects` host-tested), the `ObservedTech` element constructed (`ours` already decides the append), and the two research event **records** written. The third is not an implementation gap: their `EvDsc`/`EvMsg` text comes from the game's string table, which the engine must not carry, so a byte-identical oracle on any completion turn requires calling the game's own `PostEvent` and accepting a `ComputeBudget`-shaped **QUALIFIED** `replaced`. Settle that before spending a lane. | + +### 5.3 Artefacts + +| what | where | +|---|---| +| predictions commit (before the build) | `sots-re` `4b3cc82` | +| engine change | `sots-engine` worktree `wip/cr`, `618ccb1` — `research.replace_cascade`, a mode-independent completion counter, four `shim.cfg.cr*` configs | +| oracle + replace saves | `verify/results/saves/cr/cr-{oracle-endturn,oracle-autosave,replace0-autosave,replace1-autosave}.sav` | +| traces | `verify/traces/cr-{N,R0,R1}.jsonl.gz` | +| `tracecmp` reports | `verify/results/compare/cr-{compare,replace0,replace1}.{md,json}` | +| shim logs (the per-call counters) | `verify/results/shim/cr/cr-{N,R0,R1}.log` | +| certified pair | `verify/results/saves/certified-pairs.md`, row 4 | + +No `ghidra/addresses.d/cr.json`: this lane read no new address. No game data, save, disassembly or +`FUN_xxxxxxxx` name reached `sots-engine`; `tools/clean_room_check.sh` OK. + +### 5.4 VM145 released + +Held for this lane only; **VM140 untouched**. Restored and verified byte-for-byte to as-found: + +``` +binkw32.dll 15,527,327 B 903527F4A698EEA9FBE25F3A6236657C7D8348F994EEF394696F187E5E554B97 (= shimdist-recap) +shim.cfg 0AE410CC72FE155837D711C78543E14B1F5743310CD023298EB1E5BBD5457E71 (= shimdist-recap\shim.cfg.recaptrace) +SavedGames the same 9 files, every size and sha256 identical to the as-found survey +game not running (as found) +``` + +Left behind: `C:\SOTS\shimdist-cr\`, `C:\SOTS\ui\cr\` (the input save, the four run outputs and the +restore set) and `C:\SOTS\ui\cr{deploy,click,grab,snap,restore,find}.ps1`. `click_helper.ps1` is +unmodified. **One thing not restored:** my deploy helper *deletes* `C:\SOTS\shim.log` rather than +rotating it to `.prev` the way lane BQ's does, so whatever log was there when I took the guest is +gone. Logs are not simulation state and no measurement depends on it, but the next lane should +rotate rather than delete. diff --git a/verify/results/compare/cr-compare.json b/verify/results/compare/cr-compare.json new file mode 100644 index 0000000..9bc1e94 --- /dev/null +++ b/verify/results/compare/cr-compare.json @@ -0,0 +1,1336 @@ +{ + "coverage_contradicted": [], + "coverage_unstated": [], + "format": 1, + "hooks": { + "Game::TechTree::ProcessResearch": { + "calls": 3, + "compared": 3, + "coverage": { + "checked_regions": [ + "events", + "node[0]", + "node[105]", + "node[106]", + "node[107]", + "node[108]", + "node[109]", + "node[10]", + "node[110]", + "node[111]", + "node[112]", + "node[113]", + "node[114]", + "node[115]", + "node[116]", + "node[117]", + "node[118]", + "node[119]", + "node[11]", + "node[120]", + "node[121]", + "node[122]", + "node[123]", + "node[124]", + "node[125]", + "node[126]", + "node[127]", + "node[128]", + "node[129]", + "node[12]", + "node[130]", + "node[131]", + "node[132]", + "node[133]", + "node[134]", + "node[135]", + "node[136]", + "node[137]", + "node[138]", + "node[139]", + "node[13]", + "node[140]", + "node[141]", + "node[142]", + "node[143]", + "node[144]", + "node[145]", + "node[146]", + "node[147]", + "node[148]", + "node[149]", + "node[14]", + "node[151]", + "node[152]", + "node[153]", + "node[154]", + "node[155]", + "node[156]", + "node[157]", + "node[158]", + "node[159]", + "node[15]", + "node[160]", + "node[161]", + "node[162]", + "node[163]", + "node[164]", + "node[165]", + "node[166]", + "node[167]", + "node[168]", + "node[169]", + "node[16]", + "node[170]", + "node[171]", + "node[172]", + "node[173]", + "node[174]", + "node[175]", + "node[177]", + "node[178]", + "node[179]", + "node[17]", + "node[180]", + "node[181]", + "node[182]", + "node[183]", + "node[184]", + "node[185]", + "node[186]", + "node[187]", + "node[188]", + "node[189]", + "node[18]", + "node[190]", + "node[191]", + "node[192]", + "node[193]", + "node[194]", + "node[195]", + "node[196]", + "node[197]", + "node[198]", + "node[199]", + "node[19]", + "node[1]", + "node[200]", + "node[201]", + "node[202]", + "node[203]", + "node[204]", + "node[205]", + "node[206]", + "node[207]", + "node[208]", + "node[209]", + "node[20]", + "node[210]", + "node[211]", + "node[212]", + "node[213]", + "node[214]", + "node[215]", + "node[216]", + "node[217]", + "node[218]", + "node[219]", + "node[21]", + "node[220]", + "node[221]", + "node[222]", + "node[223]", + "node[224]", + "node[225]", + "node[226]", + "node[227]", + "node[228]", + "node[229]", + "node[22]", + "node[230]", + "node[231]", + "node[232]", + "node[233]", + "node[234]", + "node[235]", + "node[236]", + "node[237]", + "node[238]", + "node[239]", + "node[23]", + "node[240]", + "node[241]", + "node[242]", + "node[243]", + "node[244]", + "node[245]", + "node[246]", + "node[247]", + "node[248]", + "node[249]", + "node[24]", + "node[250]", + "node[251]", + "node[252]", + "node[253]", + "node[254]", + "node[255]", + "node[256]", + "node[257]", + "node[258]", + "node[259]", + "node[25]", + "node[260]", + "node[261]", + "node[262]", + "node[263]", + "node[264]", + "node[265]", + "node[266]", + "node[267]", + "node[268]", + "node[269]", + "node[26]", + "node[270]", + "node[271]", + "node[272]", + "node[273]", + "node[274]", + "node[275]", + "node[276]", + "node[277]", + "node[278]", + "node[279]", + "node[27]", + "node[280]", + "node[281]", + "node[282]", + "node[283]", + "node[284]", + "node[285]", + "node[286]", + "node[287]", + "node[288]", + "node[289]", + "node[28]", + "node[290]", + "node[291]", + "node[292]", + "node[29]", + "node[2]", + "node[30]", + "node[31]", + "node[32]", + "node[33]", + "node[34]", + "node[35]", + "node[36]", + "node[37]", + "node[38]", + "node[39]", + "node[3]", + "node[40]", + "node[41]", + "node[42]", + "node[43]", + "node[44]", + "node[45]", + "node[46]", + "node[47]", + "node[48]", + "node[49]", + "node[4]", + "node[50]", + "node[51]", + "node[52]", + "node[53]", + "node[54]", + "node[55]", + "node[56]", + "node[57]", + "node[58]", + "node[5]", + "node[60]", + "node[61]", + "node[62]", + "node[63]", + "node[64]", + "node[65]", + "node[66]", + "node[67]", + "node[68]", + "node[69]", + "node[6]", + "node[70]", + "node[72]", + "node[74]", + "node[77]", + "node[78]", + "node[7]", + "node[80]", + "node[81]", + "node[85]", + "node[86]", + "node[87]", + "node[88]", + "node[89]", + "node[8]", + "node[90]", + "node[91]", + "node[92]", + "node[94]", + "node[98]", + "node[99]", + "node[9]", + "observed_techs", + "overbudget", + "rng" + ], + "guarded_calls": 3, + "guards": [ + "player", + "tree_header" + ], + "spans": { + "compare": [ + "player+0x10c:3", + "player+0x110:3", + "player+0x114:3", + "player+0x124:3", + "player+0x294:4", + "tree_header+0x20:1" + ] + }, + "state": "partial", + "undeclared_calls": 1, + "undeclared_writes": 6, + "unmodelled": [ + { + "mitigation": "region:events", + "risk": "medium", + "what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it", + "why": "text comes from the game's string table, which the engine must not carry; 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" + }, + { + "mitigation": "region:events", + "risk": "low", + "what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", + "why": "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" + }, + { + "mitigation": "guard:player, guard:tree_header", + "risk": "high", + "what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on", + "why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is" + }, + { + "mitigation": "guard:player", + "risk": "high", + "what": "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", + "why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports" + }, + { + "mitigation": "region:rng", + "risk": "high", + "what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)", + "why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check" + }, + { + "mitigation": "region:observed_techs", + "risk": "medium", + "what": "constructs the ObservedTech element it appends to ServerPlayer+0x274", + "why": "`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" + }, + { + "mitigation": "guard:tree_header", + "risk": "low", + "what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region", + "why": "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" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes a completion line to the game log", + "why": "log text is not simulation state" + } + ], + "verdict": "partial", + "why": "" + }, + "diffs": [], + "diverged": 0, + "diverged_call_ids": [], + "errors": 0, + "modes": { + "compare": 3 + } + }, + "Shim::SelfTest::Fill": { + "calls": 1, + "compared": 0, + "coverage": { + "checked_regions": [ + "buf" + ], + "guarded_calls": 0, + "guards": [], + "spans": {}, + "state": "complete", + "undeclared_calls": 0, + "undeclared_writes": 0, + "unmodelled": [], + "verdict": "complete", + "why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region" + }, + "diffs": [], + "diverged": 0, + "diverged_call_ids": [], + "errors": 0, + "modes": { + "trace": 1 + } + } + }, + "inputs": [ + "/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad/out/N.trace.jsonl" + ], + "invalid": [], + "kind": "report", + "meta": [ + { + "build": "cr-618ccb1-20260909T131556Z", + "exe_sha256": "970b7de729956a53094c7eb98aba4270aee98e2fed5daf0d39e290013c90c841", + "format": 1, + "hooks": { + "Game::EncounterDetect::AssignContacts": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng; arg:detectors/contacts/max_trials bound the expected count", + "risk": "medium", + "what": "the contact-to-detector assignment itself, and the two-pass outer loop", + "why": "this hook exists because the draw here is INLINED and therefore invisible to every call-graph sweep and to the entry-point detours -- it is the one site in ProcessTurn's closure that neither instrument can see. It measures the word cost of the whole call and models nothing" + }, + { + "mitigation": "arg:max_trials is the upper bound only", + "risk": "low", + "what": "the per-trial threshold is 0.25f or 0.0f depending on two tech lookups, and the accept test short-circuits the inner loop", + "why": "so the measured cost is between |contacts| and |contacts| x |detectors| and the exact number depends on tech state this hook does not read" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::EncounterDetect::ProcessTeamRecord": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng; arg:gate/pred_contacts/pred_detectors are a prediction, not a check", + "risk": "medium", + "what": "the whole body of ProcessTeamRecord: the gate call, the two vector builds and the bucket construction", + "why": "this hook measures the word cost of the call and recomputes three integers the original derives from the same record. It models none of the work and asserts nothing about the contact assignment" + }, + { + "mitigation": "arg:fc_byte_vs_dword_disagreements; arg:entry_flags carries the raw values", + "risk": "medium", + "what": "`+0xfc` and `+0xfb` are read as BYTES, following lane I's reading of the classifier functions", + "why": "if either is really a wider field, every count here is wrong in the same direction and no cross-check inside this hook would notice. So the dword at +0xfc is read as well and any row where the two disagree is COUNTED, not silently resolved -- a non-zero disagreement count means the byte reading is unsafe on this workload" + }, + { + "mitigation": "arg:entries against the three class counts", + "risk": "low", + "what": "an entry whose object pointer is unreadable is skipped", + "why": "it is not counted into any of the three classes, so entries != contacts + detectors + neither is the signal that this happened" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::SectionDictionary::SectionDictionary": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "see docs/M2.md; compare mode for this hook is not safe to run", + "risk": "high", + "what": "LoadSection registers each section with the string table and the live TechTree, and may append to the dictionary's own vector", + "why": "M3 scope; ours delegates to the game's LoadSection after the original has already built all 885 definitions, so the second pass registers duplicates -- the leading hypothesis for this hook's compare-mode crash" + }, + { + "mitigation": "", + "risk": "medium", + "what": "post-load validation pass over every definition's @-token against the string table", + "why": "runs after the loop and touches no declared region" + }, + { + "mitigation": "", + "risk": "low", + "what": "allocates 885 SectionDef objects (0x3d8 bytes each) on the game heap", + "why": "they do not exist at hook entry; compared by index/species/id/token" + }, + { + "mitigation": "guard:dict", + "risk": "low", + "what": "the word at dictionary+0x14", + "why": "not modelled; emitted as an ignored pointer" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the before-snapshot of the object is uninitialised heap", + "why": "the hook is on the constructor, so `before` is meaningless and only `after` carries information" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::ComputeBudget": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary; see budget_inputs.h", + "risk": "high", + "what": "slots 1, 2, 3, 4, 7 and 11 are produced by callees this milestone does not model (per-system output, trade, ship-carried population, a second manager, the build-queue spend)", + "why": "they are copied out of the original's own output and back into the same slots, so they match BY CONSTRUCTION and prove nothing" + }, + { + "mitigation": "guard:budget_object does not reach the ships; unverified", + "risk": "high", + "what": "ServerSystem::ComputeOutput repairs damaged ships in orbit", + "why": "replace mode runs the original a second time on a scratch Budget to harvest the six unmodelled slots, so that repair happens TWICE per turn in replace mode and nothing in the trace would show it" + }, + { + "mitigation": "logged as `inputs.live_difficulty` on every record", + "risk": "low", + "what": "the difficulty-mods row still DRIVES `ours` as two fitted constants", + "why": "the old note here said the row was 'not reachable from a ServerPlayer'. That was wrong: the record is at ServerPlayer+0x36c and every consumer calls DifficultyMods_Select(p->+0x36c, p). Lane L5 now snapshots it and emits it as `inputs.live_difficulty` alongside the fitted pair, so a run SHOWS whether the fit is right instead of assuming it. `ours` was deliberately left on the fitted constants so that this run is evidence about them; switching it over is the next lane's one-line change" + }, + { + "mitigation": "the record carries `inputs.live_consts` so the widths are readable, and tests/game_sim/test_economy.cpp pins all three at their boundaries", + "risk": "medium", + "what": "three of the four float widths this routine turns on are unexercised by any reference turn", + "why": "the savings-interest rate is a widened 0.01f and its boundary IS in the corpus (a treasury of 50,000 earns 499, not 500). The research yield factor is a widened 0.85f whose boundary needs a research money that is a multiple of 40,000, and the three research modifiers are summed in SINGLE precision, which needs two of them non-zero -- the corpus has shrm = TRM = 0. So a green compare here verifies one of the three and says nothing about the other two" + }, + { + "mitigation": "", + "risk": "low", + "what": "the research-allocation vector's heap block", + "why": "only the element count is compared; the three words are heap pointers the default policy ignores" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::OnTechResearched": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:player (EventStorage is inline at ServerPlayer+0x29c)", + "risk": "high", + "what": "posts EVENT_RESEARCH_COMPLETE / _UNDERBUDGET / _TEMPERANCE on the owner's EventStorage when !silent", + "why": "the same class of write as B3's defect, and this hook has no replace-mode oracle that could catch it: gotcha 4 in docs/B2.md says a changed save hash on a completion turn is expected and therefore not a finding" + }, + { + "mitigation": "", + "risk": "high", + "what": "writes every owned system's AI flag (CCC_AIVrus / CCC_AISlv), re-evaluates the arcology civilian cap, cures addiction and clears plague across systems AND ships", + "why": "writes through pointers to other objects; compare mode must not touch live state, and no region reaches them" + }, + { + "mitigation": "this is the extra draw B3 observed on a completion", + "risk": "high", + "what": "the pending plague-cure roll (ServerPlayer::RollResearchEvent)", + "why": "it draws one word from the strategic generator unconditionally, and a SECOND word (NextInt) when the roll beats the odds and takes the plague path -- which also posts EVENT_PLAGUE_OUTBREAK, while the rebellion path cancels the research. So a fired roll costs one or two words, not one; running it in compare mode would consume real randomness. The two words it guards are still cleared and the record says whether it would have fired" + }, + { + "mitigation": "", + "risk": "medium", + "what": "TechTree::SetResearched for the Zuul boarding-pod grant", + "why": "it would mutate the live tree, and it recurses" + }, + { + "mitigation": "region:node_bore, declared only when the block already exists", + "risk": "medium", + "what": "allocates or frees the node-bore block at ServerPlayer+0x308", + "why": "ours has no allocator the game's runtime could free, so replace mode calls the game's own updater -- which means replace mode never exercises our node-bore selection at all" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:player reports Sav, +0xc8 and +0xcc moving", + "risk": "high", + "what": "phases 2, 3 and 6 -- the savings apply Sav = SatAdd(budget[0], net), the three aid/trade records copied out of the budget, and the research refund -- are NOT declared and NOT compared", + "why": "all three are pure functions of ComputeBudget's 22-slot output and of ProcessResearch's overBudget out-parameter, and both live in the original's own stack frame ([ebp-0x90] and [ebp-0x14]). The three ways to reach them are each worse than not having them: call ComputeBudget ourselves (it repairs damaged ships in orbit -- harness-audit #6), read them out of the nested B1/B3 hooks (harness-audit #5, the self-fulfilling compare), or infer them from the observed Sav delta (the same trap). The formulas ARE written and unit-tested in player_turn_inputs.cpp; they are simply not wired into the verdict" + }, + { + "mitigation": "run hook.Game::ServerPlayer::ComputeBudget=compare alongside", + "risk": "high", + "what": "ComputeBudget (0x00863030) itself, including ServerSystem::ComputeOutput, which REPAIRS DAMAGED SHIPS IN ORBIT", + "why": "B1 is the hook that checks the budget; its ship-repair side effect happens inside this call too, once per system per player per turn" + }, + { + "mitigation": "guard:player, and hook.Game::TechTree::ProcessResearch=compare", + "risk": "high", + "what": "TechTree::ProcessResearch and the whole SetResearched / OnTechResearched cascade it can trigger: progress, the completion roll, the 5% decay sweep, the child-unlock cascade, ~90 tech-effect field writes, EVENT_RESEARCH_* and EVENT_TECHS_UNLOCKED", + "why": "declared input boundary. B3/U own it; this hook reports the pre-call research state and lets the player guard say how much moved" + }, + { + "mitigation": "region:rng shows the draws; nothing shows the systems it wrote", + "risk": "high", + "what": "RollResearchAccident (0x00889dc0) draws Mars::RNG::NextInt(100) whenever the research boost is non-zero, can apply progress loss across EVERY system, and posts EVENT_LABACCIDENT_{SMALL,MEDIUM,LARGE}", + "why": "it runs before ProcessResearch and gates it entirely. Its draws move the declared `rng` region, so they are visible -- but as an unexplained RNG divergence, not as their cause" + }, + { + "mitigation": "region:rng + region:roll_flags + guard:player; ours never rolls", + "risk": "high", + "what": "the ResearchRollPending roll: RollResearchEvent draws one NextFloat, and when it BEATS the odds the plague branch draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, while the rebellion branch allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the current research", + "why": "every previous coverage note in this repo says 'exactly one NextFloat'. That is the cost of REACHING the branch; a fired roll costs one or two words. The branch has never been observed firing. `predict_roll` says whether we expected the roll, `region:rng` says what it actually cost, and `roll_flags` says whether ResErrRoll was consumed" + }, + { + "mitigation": "guard:player", + "risk": "medium", + "what": "ServerPlayer::ProcessSpecialProjects (0x00840fe0) and, for an AI player, ConstructionSpend (0x00817f90)", + "why": "1015 bytes of special-project state, plus the log line \"SpecialProject: %s completed investigation phase\"" + }, + { + "mitigation": "guard:player reports the EvNxID bump", + "risk": "medium", + "what": "the EVENT_NO_RESEARCH post at 0x0089168c, which appends to the player's EventStorage (+0x29c) and bumps EvNxID (+0x2b0)", + "why": "B3's exact failure mode: a list append outside every Result region. Its condition is fully known (ResT == 0, nothing completed this turn, and at least one state-2 tech exists) but ours posts nothing" + }, + { + "mitigation": "guard:player sees the header move only", + "risk": "low", + "what": "PruneRaidTargets (0x00863cf0) erases from the heap vector at +0x338", + "why": "the vector header is inside the player guard, but its elements are heap memory no region reaches, and the erase runs each removed element's scalar destructor" + }, + { + "mitigation": "region:bonus_header", + "risk": "low", + "what": "the timed-bonus element regions are captured from the PRE-CALL element addresses, and the sweep may shrink the vector under them", + "why": "erase moves data down inside the same buffer and only lowers _Mylast, so the addresses stay valid and readable. Elements past the new _Mylast are compared as stale bytes; `bonus_header` is the region that carries the count, and ours writes the same stale tail the original leaves" + }, + { + "mitigation": "", + "risk": "medium", + "what": "replace mode is refused for this hook", + "why": "ours models the driver's own writes and none of ComputeBudget, ProcessResearch, ProcessSpecialProjects or the raid-target prune. A replace run would leave a player in a state no code path produces, and there is therefore no oracle layer behind this compare" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::ComputeTotalOutput": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary", + "risk": "high", + "what": "the station count, as for GroupOutput", + "why": "`ours` assumes zero stations, so a system with an imperial population and a station diverges by the station factor" + }, + { + "mitigation": "declared input boundary", + "risk": "high", + "what": "the slave population and its xenotech adjustment", + "why": "the original's slave count is not a plain field: it runs the count through a per-species xenotech factor. `ours` takes the slave term as ZERO, so any system holding slaves diverges. The record logs the raw group-2 population sums so a divergence can be attributed" + }, + { + "mitigation": "declared input boundary", + "risk": "medium", + "what": "the capacity surplus the civilian term adds for the owner's own species", + "why": "the original calls the carrying-capacity helper twice with different out-parameter slots and adds max(0, B - A) to the civilian count; `ours` uses the raw civilian population. The surplus is zero except when the colony is at its cap" + }, + { + "mitigation": "", + "risk": "low", + "what": "the addiction phase", + "why": "`ours` assumes it is below 3, so ADDICTION_OUTPUT_MOD never applies; the record logs the system's addiction table length so the case is visible" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::GroupOutput": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary; the trace makes it recoverable", + "risk": "high", + "what": "the station count that scales the imperial term", + "why": "the original gets it from a helper that walks the system's fleets and their ships through virtual calls and takes the system in EBX, which a hook cannot call portably. `ours` therefore assumes ZERO stations. A divergence on an imperial row is expected to be exactly the station factor, and the record carries `count` and the return, so the factor is MEASURED from the trace rather than fitted" + }, + { + "mitigation": "logged as `tuning` on every record", + "risk": "medium", + "what": "the slave row's output modifier, and every value the data files supply", + "why": "SLAVES_OUTPUT_MOD, the two morale thresholds and their two modifiers, and STATION_BONUS_IMPERIAL_OUTPUT are read out of the live process's globals and logged with every record, so the record says which value drove it" + } + ], + "why": "the imperial and civilian output modifiers, the 1.8 factor and the 500000 divisor are literals inside the executable, so nothing about them is assumed from the data files" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:system", + "risk": "high", + "what": "the addiction sweep raises MoraleEvents, which are constructed and appended to the system's capped morale history", + "why": "the same class of write as B3's defect. sim::ProcessColonyTurn does compute the morale events (ColonyTurnResult), but the hook never emits them: DescribeMoraleEvents is dead code, so they are neither compared nor logged" + }, + { + "mitigation": "guard:system covers the system object only, not the other objects", + "risk": "high", + "what": "every callee: the plague pass, imperial and civilian growth, the resource debit, in-orbit refuel, slaves, rebellion and the build queue", + "why": "declared input boundary -- ProcessTurn is a dispatcher and only the words it writes itself are modelled. The callees raise EVENT_SLAVES_DEAD, EVENT_SYSTEM_REBELLION_CONTINUES, the plague events and SEBuildCompleted, create ships and bump per-player ShipRecords counters" + }, + { + "mitigation": "", + "risk": "medium", + "what": "ApplyInfraBonus / ApplyPopBonus read the owner's home-system id, and the build queue writes the owning ServerPlayer", + "why": "writes through a pointer to another object; no region reaches the player" + }, + { + "mitigation": "", + "risk": "low", + "what": "ProcessRebellion is the pass's only RNG consumer and its draw count is data-dependent", + "why": "the generator IS a declared region, so a moved post-state is visible and names the system whose rebellion fired -- it is reported, not modelled" + }, + { + "mitigation": "", + "risk": "medium", + "what": "replace mode is refused for this hook", + "why": "our side models the dispatcher's own writes and none of the callees, so a replace run would silently skip a colony's whole turn. There is therefore no oracle layer behind the compare for this hook" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyHost::Autosave": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng only", + "risk": "low", + "what": "the whole save write: four path buffers, the ENDTURN pair removal, the backup rotation, the per-player connection detach/reattach, and SaveGame_WriteFile 0x00877070 itself", + "why": "this hook exists to timestamp the generator at the two moments the two save files are written. It is a marker and models nothing" + }, + { + "mitigation": "arg:server_cached / host_plus_0x54 / server_agrees say which pointer was used and whether the +0x54 candidate is the same object", + "risk": "medium", + "what": "the generator is reached through a CACHED StrategyServer pointer, not from this call's own arguments", + "why": "`this` is the global at 0x00b29f98, hardcoded by both call sites, and no argument here names the server. On the first pre-turn autosave after a load no turn driver has run yet, so the cache is empty and that record carries no ledger position -- the FIRST BRACKET OF A SESSION IS INCOMPLETE BY CONSTRUCTION and must not be read as a zero-cost turn" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ApplyEncounterResult": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng measures the subtotal", + "risk": "high", + "what": "the combat resolver 0x007d5af0 (7499 B) is completely unread", + "why": "this hook measures what its subtree spends and models none of it. Nothing about combat determinism can be settled until that function is read; this only puts a number on the hole" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the ~0xea0-byte combat report, the CombatReport list append at S+0x1fc, the ClientEncounterResults push into S+0x2f4, the per-ship turn stamps and the pairwise engagement bits", + "why": "all of it is game state this hook does not declare and does not check" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::MoveFleet": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:fleet sees the fleet's own words; the event and the system do not", + "risk": "high", + "what": "on arrival: dispatches SEFleetArrived and runs one of three arrival handlers by destination kind (enter system / join fleet / stop at point)", + "why": "declared input boundary -- an arriving call is expected to differ in all of it, and none of it is declared, so the compare says nothing about arrivals" + }, + { + "mitigation": "", + "risk": "high", + "what": "on departure: cancels every still-acting ship (with a log line each) and calls ServerSystem::FleetDeparts, which rewrites the system's ownership bits", + "why": "writes through pointers to ships and to the system" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the tanker top-up refuels other ships in the fleet", + "why": "the per-ship range regions would show it, but ours does not model it, so a fleet with a tanker diverges for a known reason" + }, + { + "mitigation": "declared gap: docs/B4.md", + "risk": "medium", + "what": "a node-line waypoint's step comes from the stutter profile", + "why": "NodeLineStep / BuildStutterSegments are written and unit-tested but not wired in; the hook steps every waypoint type as speed x dt, so a node-line leg is knowingly mis-stepped and only its type is recorded" + }, + { + "mitigation": "", + "risk": "medium", + "what": "a missed probabilistic jump scatters the fleet in a random direction", + "why": "the direction is a second draw whose mapping is not modelled; ours leaves the position alone and reports the scatter distance, so the generator region diverges by one word on a miss" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the route revalidation and the waypoint list itself", + "why": "declared input boundary; the waypoint vector is not a region" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::NodeLineDecay": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng only", + "risk": "high", + "what": "the collapse itself: 0x007a92e0 (690 B) and 0x007a4700 (2244 B) destroy or halt fleets and post EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE / _HALTED / _HALTED_VIANODE, and loop 3 posts two more decay-stage events", + "why": "ours advances the generator and writes nothing else. In compare mode that is the intent -- the check is the word count -- but it means a clean verdict here says nothing about which lines actually collapsed" + }, + { + "mitigation": "arg:predict_words vs region:rng is exactly that check", + "risk": "medium", + "what": "the draw-count model is verified by a DIRECT-call sweep of the downstream pair; their subtrees contain unresolved indirect call sites", + "why": "if one of those vtable slots reaches a generator, the measured delta will exceed `predict_words` and this hook will diverge -- which is the correct outcome, and the reason the prediction is recorded as an argument" + }, + { + "mitigation": "", + "risk": "low", + "what": "the expiry formula reproduces a signed idiv on nptf/npdtf without knowing whether nptf can be negative", + "why": "the original never sign-checks the traffic accumulator. The model truncates toward zero the same way; if the field is always non-negative the question never arises, and no save has been observed with a negative one" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::OnAllCombatDone_Tail": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng plus the two nested hooks", + "risk": "high", + "what": "36 phases, of which two can draw and neither is modelled here", + "why": "phase 6 reaches the unread 7499-byte combat resolver 0x007d5af0 (NextInt on the node-cannon path, Twist plus NextInt on the salvage path) and phase 11 draws one word per expired node line. `predict_nodeline_words` covers only the second, and the nested ApplyEncounterResult / NodeLineDecay hooks are what attribute the split" + }, + { + "mitigation": "arg:encounters says how many encounters this call saw; a call with 0 settles it", + "risk": "medium", + "what": "whether this handler runs on a turn with NO combat is what this hook is here to settle, and until it has run it is a hypothesis", + "why": "combat-done-tail.md \u00c2\u00a76 infers it from the determinism note -- the post-turn autosave appears on every End Turn and this handler is its only reachable caller -- not from the instruction stream" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessFleetMovement": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "", + "risk": "high", + "what": "`ours` re-reads the LIVE fleet list after the original has run", + "why": "the gate-traffic total is computed by the original at the very end of the pass, so a pre-call snapshot would diverge for the wrong reason. It breaks the compare invariant that ours never touches live memory, and it makes this hook's verdict partly self-fulfilling: the input to our arithmetic is the original's own post-move state" + }, + { + "mitigation": "", + "risk": "high", + "what": "drives MoveFleet up to five times per fleet", + "why": "every undeclared effect of MoveFleet happens inside this call too; the pass schedule is recorded in the arguments but never compared" + }, + { + "mitigation": "", + "risk": "high", + "what": "writes FPdpos into every fleet and clears flags 0x2 and 0x100 on every fleet", + "why": "no region covers the fleets, only the players' gate-traffic words" + }, + { + "mitigation": "", + "risk": "high", + "what": "OnFleetArrived posts EVENT_FLEET_ARRIVED", + "why": "the same class of write as B3's defect, and there is no replace mode for this hook, so nothing behind the compare could catch it either" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the original accumulates by player->index but writes back by the player's position in the server vector, into a fixed 32-int array with no bounds check", + "why": "a real latent bug in the original that our side reproduces only while index == position; the reference save never separates them" + }, + { + "mitigation": "", + "risk": "medium", + "what": "PassSchedule() is never called by the hook, and FleetSummary::targetFleetId / relation are never filled", + "why": "the header claims ours predicts the call order for a trace to check; that prediction is not actually emitted" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessNodeSpaceTravel": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng", + "risk": "medium", + "what": "2945 bytes of node-space movement, entirely unmodelled and never swept for RNG by any lane", + "why": "it is hooked here only because it runs TWICE a turn -- ProcessTurn phase 7 and tail phase 10 -- so a draw inside it would be double-counted by anyone modelling it once. The record says whether it draws at all" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng measures the total; the per-phase split is not resolved here", + "risk": "high", + "what": "no model of the turn's RNG cost: 32 phases, each of which may draw", + "why": "ProcessResearch's completion roll, RollResearchAccident's NextInt(100), the ResearchRollPending roll (one word, or two on the plague path), and whatever ProcessStations / ProcessSurrenders / ProcessMissions / ProcessSpecialProjects spend -- none of which has ever been measured. `ours` predicts nothing and the record reports the measurement" + }, + { + "mitigation": "shim.log records the MH_CreateHook status for both", + "risk": "low", + "what": "this hook takes the address the fpu module also wants to sample", + "why": "MinHook allows one hook per target. `fpu.sample_turn=off` releases StrategyServer::ProcessTurn so this hook can install; with it on, this hook fails to install and the trace is missing half the ledger" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::TechTree::ProcessResearch": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:events", + "risk": "medium", + "what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it", + "why": "text comes from the game's string table, which the engine must not carry; 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" + }, + { + "mitigation": "region:events", + "risk": "low", + "what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", + "why": "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" + }, + { + "mitigation": "guard:player, guard:tree_header", + "risk": "high", + "what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on", + "why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is" + }, + { + "mitigation": "guard:player", + "risk": "high", + "what": "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", + "why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports" + }, + { + "mitigation": "region:rng", + "risk": "high", + "what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)", + "why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check" + }, + { + "mitigation": "region:observed_techs", + "risk": "medium", + "what": "constructs the ObservedTech element it appends to ServerPlayer+0x274", + "why": "`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" + }, + { + "mitigation": "guard:tree_header", + "risk": "low", + "what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region", + "why": "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" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes a completion line to the game log", + "why": "log text is not simulation state" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::WeaponDictionary::Init": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "suspected cause of the sibling section hook's compare crash (docs/M2.md)", + "risk": "high", + "what": "LoadWeapon -> WeaponDef::ParseScript registers each weapon's name with the string table and resolves `requires` against the live TechTree", + "why": "per-file parsing is M3 scope; ours delegates to the game's own LoadWeapon, so a compare run performs the registration a SECOND time and neither the string table nor the tech tree is a declared region" + }, + { + "mitigation": "", + "risk": "low", + "what": "allocates 123 WeaponDef objects (0x278 bytes each) on the game heap", + "why": "the definitions do not exist when the hook is entered, so they cannot be a before-snapshot; the dictionary region compares them by id/name/path" + }, + { + "mitigation": "guard:dict", + "risk": "low", + "what": "the word at dictionary+0x14", + "why": "not modelled; emitted as an opaque pointer, which the default policy ignores -- a change is visible in a trace but never a divergence" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes lines to the game log for a missing manifest", + "why": "log text is not simulation state" + }, + { + "mitigation": "", + "risk": "low", + "what": "std::sort tie order for equal weapon names", + "why": "msvc_sort.h replays MSVC 2010's introsort, but the shipped data has no tied names, so the tie rule is unexercised rather than verified" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Mars::GlobalConsts::LoadFile": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "LoadAll's post-state would have to be hooked to see it", + "risk": "medium", + "what": "erases each consumed key from the caller's std::map", + "why": "the map is a LoadAll temporary; declaring a red-black tree as a region is not possible before the call. First-occurrence-wins is reproduced in game::config::apply instead, so the *effect* is modelled, the container is not" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes three kinds of line to the game log (unrecognised key, applied key, expected-but-not-found)", + "why": "log text is not part of the simulation state" + }, + { + "mitigation": "", + "risk": "low", + "what": "opens the file through the VFS and allocates/releases a refcounted buffer", + "why": "ours performs the same two calls, so allocation behaviour matches by construction rather than by comparison" + }, + { + "mitigation": "", + "risk": "low", + "what": "String slots assign through the engine's own std::string, leaking one heap block per long string in compare mode", + "why": "start-up only; documented in docs/M1.md" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Shim::SelfTest::Fill": { + "coverage": { + "state": "complete", + "unmodelled": [], + "why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + } + }, + "inline_max": 256, + "started": "2026-09-09T13:33:54Z" + } + ], + "totals": { + "calls": 4, + "compared": 3, + "coverage_contradicted": 0, + "coverage_unstated": 0, + "diverged": 0, + "guarded_calls": 3, + "invalid_records": 0, + "undeclared_calls": 1, + "undeclared_writes": 6 + }, + "warnings": [] +} diff --git a/verify/results/compare/cr-compare.md b/verify/results/compare/cr-compare.md new file mode 100644 index 0000000..5ad972c --- /dev/null +++ b/verify/results/compare/cr-compare.md @@ -0,0 +1,28 @@ +## tracecmp report: N.trace.jsonl + +- build: cr-618ccb1-20260909T131556Z started: 2026-09-09T13:33:54Z inline_max: 256 +- calls: 4 compared: 3 diverged: 0 invalid records: 0 warnings: 0 +- coverage: 3 guarded call(s), 6 undeclared write(s) in 1 call(s); 0 hook(s) unstated, 0 contradicted + +| hook | calls | modes | compared | diverged | errors | +|---|---|---|---|---|---| +| Game::TechTree::ProcessResearch | 3 | compare:3 | 3 | 0 | 0 | +| Shim::SelfTest::Fill | 1 | trace:1 | 0 | 0 | 0 | + +### coverage + +| hook | verdict | compared regions | guards | undeclared writes | unmodelled | +|---|---|---|---|---|---| +| Game::TechTree::ProcessResearch | partial | events, node[0], node[105], node[106], node[107], node[108], +271 | player, tree_header | 6 in 1 call(s) | 8 | +| Shim::SelfTest::Fill | complete | buf | - | not watched | 0 | + +#### Game::TechTree::ProcessResearch — not checked by this run +- (medium) posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it — text comes from the game's string table, which the engine must not carry; 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] +- (low) composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names — 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] +- (high) TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on — with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is [guard:player, guard:tree_header] +- (high) 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 — B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports [guard:player] +- (high) the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths) — RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check [region:rng] +- (medium) constructs the ObservedTech element it appends to ServerPlayer+0x274 — `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] +- (low) the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region — 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] +- (low) writes a completion line to the game log — log text is not simulation state +- guard hits in compare mode: player+0x10c:3, player+0x110:3, player+0x114:3, player+0x124:3, player+0x294:4, tree_header+0x20:1 diff --git a/verify/results/compare/cr-replace0.json b/verify/results/compare/cr-replace0.json new file mode 100644 index 0000000..0f50787 --- /dev/null +++ b/verify/results/compare/cr-replace0.json @@ -0,0 +1,1329 @@ +{ + "coverage_contradicted": [], + "coverage_unstated": [], + "format": 1, + "hooks": { + "Game::TechTree::ProcessResearch": { + "calls": 3, + "compared": 0, + "coverage": { + "checked_regions": [ + "events", + "node[0]", + "node[105]", + "node[106]", + "node[107]", + "node[108]", + "node[109]", + "node[10]", + "node[110]", + "node[111]", + "node[112]", + "node[113]", + "node[114]", + "node[115]", + "node[116]", + "node[117]", + "node[118]", + "node[119]", + "node[11]", + "node[120]", + "node[121]", + "node[122]", + "node[123]", + "node[124]", + "node[125]", + "node[126]", + "node[127]", + "node[128]", + "node[129]", + "node[12]", + "node[130]", + "node[131]", + "node[132]", + "node[133]", + "node[134]", + "node[135]", + "node[136]", + "node[137]", + "node[138]", + "node[139]", + "node[13]", + "node[140]", + "node[141]", + "node[142]", + "node[143]", + "node[144]", + "node[145]", + "node[146]", + "node[147]", + "node[148]", + "node[149]", + "node[14]", + "node[151]", + "node[152]", + "node[153]", + "node[154]", + "node[155]", + "node[156]", + "node[157]", + "node[158]", + "node[159]", + "node[15]", + "node[160]", + "node[161]", + "node[162]", + "node[163]", + "node[164]", + "node[165]", + "node[166]", + "node[167]", + "node[168]", + "node[169]", + "node[16]", + "node[170]", + "node[171]", + "node[172]", + "node[173]", + "node[174]", + "node[175]", + "node[177]", + "node[178]", + "node[179]", + "node[17]", + "node[180]", + "node[181]", + "node[182]", + "node[183]", + "node[184]", + "node[185]", + "node[186]", + "node[187]", + "node[188]", + "node[189]", + "node[18]", + "node[190]", + "node[191]", + "node[192]", + "node[193]", + "node[194]", + "node[195]", + "node[196]", + "node[197]", + "node[198]", + "node[199]", + "node[19]", + "node[1]", + "node[200]", + "node[201]", + "node[202]", + "node[203]", + "node[204]", + "node[205]", + "node[206]", + "node[207]", + "node[208]", + "node[209]", + "node[20]", + "node[210]", + "node[211]", + "node[212]", + "node[213]", + "node[214]", + "node[215]", + "node[216]", + "node[217]", + "node[218]", + "node[219]", + "node[21]", + "node[220]", + "node[221]", + "node[222]", + "node[223]", + "node[224]", + "node[225]", + "node[226]", + "node[227]", + "node[228]", + "node[229]", + "node[22]", + "node[230]", + "node[231]", + "node[232]", + "node[233]", + "node[234]", + "node[235]", + "node[236]", + "node[237]", + "node[238]", + "node[239]", + "node[23]", + "node[240]", + "node[241]", + "node[242]", + "node[243]", + "node[244]", + "node[245]", + "node[246]", + "node[247]", + "node[248]", + "node[249]", + "node[24]", + "node[250]", + "node[251]", + "node[252]", + "node[253]", + "node[254]", + "node[255]", + "node[256]", + "node[257]", + "node[258]", + "node[259]", + "node[25]", + "node[260]", + "node[261]", + "node[262]", + "node[263]", + "node[264]", + "node[265]", + "node[266]", + "node[267]", + "node[268]", + "node[269]", + "node[26]", + "node[270]", + "node[271]", + "node[272]", + "node[273]", + "node[274]", + "node[275]", + "node[276]", + "node[277]", + "node[278]", + "node[279]", + "node[27]", + "node[280]", + "node[281]", + "node[282]", + "node[283]", + "node[284]", + "node[285]", + "node[286]", + "node[287]", + "node[288]", + "node[289]", + "node[28]", + "node[290]", + "node[291]", + "node[292]", + "node[29]", + "node[2]", + "node[30]", + "node[31]", + "node[32]", + "node[33]", + "node[34]", + "node[35]", + "node[36]", + "node[37]", + "node[38]", + "node[39]", + "node[3]", + "node[40]", + "node[41]", + "node[42]", + "node[43]", + "node[44]", + "node[45]", + "node[46]", + "node[47]", + "node[48]", + "node[49]", + "node[4]", + "node[50]", + "node[51]", + "node[52]", + "node[53]", + "node[54]", + "node[55]", + "node[56]", + "node[57]", + "node[58]", + "node[5]", + "node[60]", + "node[61]", + "node[62]", + "node[63]", + "node[64]", + "node[65]", + "node[66]", + "node[67]", + "node[68]", + "node[69]", + "node[6]", + "node[70]", + "node[72]", + "node[74]", + "node[77]", + "node[78]", + "node[7]", + "node[80]", + "node[81]", + "node[85]", + "node[86]", + "node[87]", + "node[88]", + "node[89]", + "node[8]", + "node[90]", + "node[91]", + "node[92]", + "node[94]", + "node[98]", + "node[99]", + "node[9]", + "observed_techs", + "overbudget", + "rng" + ], + "guarded_calls": 3, + "guards": [ + "player", + "tree_header" + ], + "spans": { + "replace": [] + }, + "state": "partial", + "undeclared_calls": 0, + "undeclared_writes": 0, + "unmodelled": [ + { + "mitigation": "region:events", + "risk": "medium", + "what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it", + "why": "text comes from the game's string table, which the engine must not carry; 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" + }, + { + "mitigation": "region:events", + "risk": "low", + "what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", + "why": "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" + }, + { + "mitigation": "guard:player, guard:tree_header", + "risk": "high", + "what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on", + "why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is" + }, + { + "mitigation": "guard:player", + "risk": "high", + "what": "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", + "why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports" + }, + { + "mitigation": "region:rng", + "risk": "high", + "what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)", + "why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check" + }, + { + "mitigation": "region:observed_techs", + "risk": "medium", + "what": "constructs the ObservedTech element it appends to ServerPlayer+0x274", + "why": "`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" + }, + { + "mitigation": "guard:tree_header", + "risk": "low", + "what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region", + "why": "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" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes a completion line to the game log", + "why": "log text is not simulation state" + } + ], + "verdict": "partial", + "why": "" + }, + "diffs": [], + "diverged": 0, + "diverged_call_ids": [], + "errors": 0, + "modes": { + "replace": 3 + } + }, + "Shim::SelfTest::Fill": { + "calls": 1, + "compared": 0, + "coverage": { + "checked_regions": [ + "buf" + ], + "guarded_calls": 0, + "guards": [], + "spans": {}, + "state": "complete", + "undeclared_calls": 0, + "undeclared_writes": 0, + "unmodelled": [], + "verdict": "complete", + "why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region" + }, + "diffs": [], + "diverged": 0, + "diverged_call_ids": [], + "errors": 0, + "modes": { + "trace": 1 + } + } + }, + "inputs": [ + "/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad/out/R0.trace.jsonl" + ], + "invalid": [], + "kind": "report", + "meta": [ + { + "build": "cr-618ccb1-20260909T131556Z", + "exe_sha256": "970b7de729956a53094c7eb98aba4270aee98e2fed5daf0d39e290013c90c841", + "format": 1, + "hooks": { + "Game::EncounterDetect::AssignContacts": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng; arg:detectors/contacts/max_trials bound the expected count", + "risk": "medium", + "what": "the contact-to-detector assignment itself, and the two-pass outer loop", + "why": "this hook exists because the draw here is INLINED and therefore invisible to every call-graph sweep and to the entry-point detours -- it is the one site in ProcessTurn's closure that neither instrument can see. It measures the word cost of the whole call and models nothing" + }, + { + "mitigation": "arg:max_trials is the upper bound only", + "risk": "low", + "what": "the per-trial threshold is 0.25f or 0.0f depending on two tech lookups, and the accept test short-circuits the inner loop", + "why": "so the measured cost is between |contacts| and |contacts| x |detectors| and the exact number depends on tech state this hook does not read" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::EncounterDetect::ProcessTeamRecord": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng; arg:gate/pred_contacts/pred_detectors are a prediction, not a check", + "risk": "medium", + "what": "the whole body of ProcessTeamRecord: the gate call, the two vector builds and the bucket construction", + "why": "this hook measures the word cost of the call and recomputes three integers the original derives from the same record. It models none of the work and asserts nothing about the contact assignment" + }, + { + "mitigation": "arg:fc_byte_vs_dword_disagreements; arg:entry_flags carries the raw values", + "risk": "medium", + "what": "`+0xfc` and `+0xfb` are read as BYTES, following lane I's reading of the classifier functions", + "why": "if either is really a wider field, every count here is wrong in the same direction and no cross-check inside this hook would notice. So the dword at +0xfc is read as well and any row where the two disagree is COUNTED, not silently resolved -- a non-zero disagreement count means the byte reading is unsafe on this workload" + }, + { + "mitigation": "arg:entries against the three class counts", + "risk": "low", + "what": "an entry whose object pointer is unreadable is skipped", + "why": "it is not counted into any of the three classes, so entries != contacts + detectors + neither is the signal that this happened" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::SectionDictionary::SectionDictionary": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "see docs/M2.md; compare mode for this hook is not safe to run", + "risk": "high", + "what": "LoadSection registers each section with the string table and the live TechTree, and may append to the dictionary's own vector", + "why": "M3 scope; ours delegates to the game's LoadSection after the original has already built all 885 definitions, so the second pass registers duplicates -- the leading hypothesis for this hook's compare-mode crash" + }, + { + "mitigation": "", + "risk": "medium", + "what": "post-load validation pass over every definition's @-token against the string table", + "why": "runs after the loop and touches no declared region" + }, + { + "mitigation": "", + "risk": "low", + "what": "allocates 885 SectionDef objects (0x3d8 bytes each) on the game heap", + "why": "they do not exist at hook entry; compared by index/species/id/token" + }, + { + "mitigation": "guard:dict", + "risk": "low", + "what": "the word at dictionary+0x14", + "why": "not modelled; emitted as an ignored pointer" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the before-snapshot of the object is uninitialised heap", + "why": "the hook is on the constructor, so `before` is meaningless and only `after` carries information" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::ComputeBudget": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary; see budget_inputs.h", + "risk": "high", + "what": "slots 1, 2, 3, 4, 7 and 11 are produced by callees this milestone does not model (per-system output, trade, ship-carried population, a second manager, the build-queue spend)", + "why": "they are copied out of the original's own output and back into the same slots, so they match BY CONSTRUCTION and prove nothing" + }, + { + "mitigation": "guard:budget_object does not reach the ships; unverified", + "risk": "high", + "what": "ServerSystem::ComputeOutput repairs damaged ships in orbit", + "why": "replace mode runs the original a second time on a scratch Budget to harvest the six unmodelled slots, so that repair happens TWICE per turn in replace mode and nothing in the trace would show it" + }, + { + "mitigation": "logged as `inputs.live_difficulty` on every record", + "risk": "low", + "what": "the difficulty-mods row still DRIVES `ours` as two fitted constants", + "why": "the old note here said the row was 'not reachable from a ServerPlayer'. That was wrong: the record is at ServerPlayer+0x36c and every consumer calls DifficultyMods_Select(p->+0x36c, p). Lane L5 now snapshots it and emits it as `inputs.live_difficulty` alongside the fitted pair, so a run SHOWS whether the fit is right instead of assuming it. `ours` was deliberately left on the fitted constants so that this run is evidence about them; switching it over is the next lane's one-line change" + }, + { + "mitigation": "the record carries `inputs.live_consts` so the widths are readable, and tests/game_sim/test_economy.cpp pins all three at their boundaries", + "risk": "medium", + "what": "three of the four float widths this routine turns on are unexercised by any reference turn", + "why": "the savings-interest rate is a widened 0.01f and its boundary IS in the corpus (a treasury of 50,000 earns 499, not 500). The research yield factor is a widened 0.85f whose boundary needs a research money that is a multiple of 40,000, and the three research modifiers are summed in SINGLE precision, which needs two of them non-zero -- the corpus has shrm = TRM = 0. So a green compare here verifies one of the three and says nothing about the other two" + }, + { + "mitigation": "", + "risk": "low", + "what": "the research-allocation vector's heap block", + "why": "only the element count is compared; the three words are heap pointers the default policy ignores" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::OnTechResearched": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:player (EventStorage is inline at ServerPlayer+0x29c)", + "risk": "high", + "what": "posts EVENT_RESEARCH_COMPLETE / _UNDERBUDGET / _TEMPERANCE on the owner's EventStorage when !silent", + "why": "the same class of write as B3's defect, and this hook has no replace-mode oracle that could catch it: gotcha 4 in docs/B2.md says a changed save hash on a completion turn is expected and therefore not a finding" + }, + { + "mitigation": "", + "risk": "high", + "what": "writes every owned system's AI flag (CCC_AIVrus / CCC_AISlv), re-evaluates the arcology civilian cap, cures addiction and clears plague across systems AND ships", + "why": "writes through pointers to other objects; compare mode must not touch live state, and no region reaches them" + }, + { + "mitigation": "this is the extra draw B3 observed on a completion", + "risk": "high", + "what": "the pending plague-cure roll (ServerPlayer::RollResearchEvent)", + "why": "it draws one word from the strategic generator unconditionally, and a SECOND word (NextInt) when the roll beats the odds and takes the plague path -- which also posts EVENT_PLAGUE_OUTBREAK, while the rebellion path cancels the research. So a fired roll costs one or two words, not one; running it in compare mode would consume real randomness. The two words it guards are still cleared and the record says whether it would have fired" + }, + { + "mitigation": "", + "risk": "medium", + "what": "TechTree::SetResearched for the Zuul boarding-pod grant", + "why": "it would mutate the live tree, and it recurses" + }, + { + "mitigation": "region:node_bore, declared only when the block already exists", + "risk": "medium", + "what": "allocates or frees the node-bore block at ServerPlayer+0x308", + "why": "ours has no allocator the game's runtime could free, so replace mode calls the game's own updater -- which means replace mode never exercises our node-bore selection at all" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:player reports Sav, +0xc8 and +0xcc moving", + "risk": "high", + "what": "phases 2, 3 and 6 -- the savings apply Sav = SatAdd(budget[0], net), the three aid/trade records copied out of the budget, and the research refund -- are NOT declared and NOT compared", + "why": "all three are pure functions of ComputeBudget's 22-slot output and of ProcessResearch's overBudget out-parameter, and both live in the original's own stack frame ([ebp-0x90] and [ebp-0x14]). The three ways to reach them are each worse than not having them: call ComputeBudget ourselves (it repairs damaged ships in orbit -- harness-audit #6), read them out of the nested B1/B3 hooks (harness-audit #5, the self-fulfilling compare), or infer them from the observed Sav delta (the same trap). The formulas ARE written and unit-tested in player_turn_inputs.cpp; they are simply not wired into the verdict" + }, + { + "mitigation": "run hook.Game::ServerPlayer::ComputeBudget=compare alongside", + "risk": "high", + "what": "ComputeBudget (0x00863030) itself, including ServerSystem::ComputeOutput, which REPAIRS DAMAGED SHIPS IN ORBIT", + "why": "B1 is the hook that checks the budget; its ship-repair side effect happens inside this call too, once per system per player per turn" + }, + { + "mitigation": "guard:player, and hook.Game::TechTree::ProcessResearch=compare", + "risk": "high", + "what": "TechTree::ProcessResearch and the whole SetResearched / OnTechResearched cascade it can trigger: progress, the completion roll, the 5% decay sweep, the child-unlock cascade, ~90 tech-effect field writes, EVENT_RESEARCH_* and EVENT_TECHS_UNLOCKED", + "why": "declared input boundary. B3/U own it; this hook reports the pre-call research state and lets the player guard say how much moved" + }, + { + "mitigation": "region:rng shows the draws; nothing shows the systems it wrote", + "risk": "high", + "what": "RollResearchAccident (0x00889dc0) draws Mars::RNG::NextInt(100) whenever the research boost is non-zero, can apply progress loss across EVERY system, and posts EVENT_LABACCIDENT_{SMALL,MEDIUM,LARGE}", + "why": "it runs before ProcessResearch and gates it entirely. Its draws move the declared `rng` region, so they are visible -- but as an unexplained RNG divergence, not as their cause" + }, + { + "mitigation": "region:rng + region:roll_flags + guard:player; ours never rolls", + "risk": "high", + "what": "the ResearchRollPending roll: RollResearchEvent draws one NextFloat, and when it BEATS the odds the plague branch draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, while the rebellion branch allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the current research", + "why": "every previous coverage note in this repo says 'exactly one NextFloat'. That is the cost of REACHING the branch; a fired roll costs one or two words. The branch has never been observed firing. `predict_roll` says whether we expected the roll, `region:rng` says what it actually cost, and `roll_flags` says whether ResErrRoll was consumed" + }, + { + "mitigation": "guard:player", + "risk": "medium", + "what": "ServerPlayer::ProcessSpecialProjects (0x00840fe0) and, for an AI player, ConstructionSpend (0x00817f90)", + "why": "1015 bytes of special-project state, plus the log line \"SpecialProject: %s completed investigation phase\"" + }, + { + "mitigation": "guard:player reports the EvNxID bump", + "risk": "medium", + "what": "the EVENT_NO_RESEARCH post at 0x0089168c, which appends to the player's EventStorage (+0x29c) and bumps EvNxID (+0x2b0)", + "why": "B3's exact failure mode: a list append outside every Result region. Its condition is fully known (ResT == 0, nothing completed this turn, and at least one state-2 tech exists) but ours posts nothing" + }, + { + "mitigation": "guard:player sees the header move only", + "risk": "low", + "what": "PruneRaidTargets (0x00863cf0) erases from the heap vector at +0x338", + "why": "the vector header is inside the player guard, but its elements are heap memory no region reaches, and the erase runs each removed element's scalar destructor" + }, + { + "mitigation": "region:bonus_header", + "risk": "low", + "what": "the timed-bonus element regions are captured from the PRE-CALL element addresses, and the sweep may shrink the vector under them", + "why": "erase moves data down inside the same buffer and only lowers _Mylast, so the addresses stay valid and readable. Elements past the new _Mylast are compared as stale bytes; `bonus_header` is the region that carries the count, and ours writes the same stale tail the original leaves" + }, + { + "mitigation": "", + "risk": "medium", + "what": "replace mode is refused for this hook", + "why": "ours models the driver's own writes and none of ComputeBudget, ProcessResearch, ProcessSpecialProjects or the raid-target prune. A replace run would leave a player in a state no code path produces, and there is therefore no oracle layer behind this compare" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::ComputeTotalOutput": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary", + "risk": "high", + "what": "the station count, as for GroupOutput", + "why": "`ours` assumes zero stations, so a system with an imperial population and a station diverges by the station factor" + }, + { + "mitigation": "declared input boundary", + "risk": "high", + "what": "the slave population and its xenotech adjustment", + "why": "the original's slave count is not a plain field: it runs the count through a per-species xenotech factor. `ours` takes the slave term as ZERO, so any system holding slaves diverges. The record logs the raw group-2 population sums so a divergence can be attributed" + }, + { + "mitigation": "declared input boundary", + "risk": "medium", + "what": "the capacity surplus the civilian term adds for the owner's own species", + "why": "the original calls the carrying-capacity helper twice with different out-parameter slots and adds max(0, B - A) to the civilian count; `ours` uses the raw civilian population. The surplus is zero except when the colony is at its cap" + }, + { + "mitigation": "", + "risk": "low", + "what": "the addiction phase", + "why": "`ours` assumes it is below 3, so ADDICTION_OUTPUT_MOD never applies; the record logs the system's addiction table length so the case is visible" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::GroupOutput": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary; the trace makes it recoverable", + "risk": "high", + "what": "the station count that scales the imperial term", + "why": "the original gets it from a helper that walks the system's fleets and their ships through virtual calls and takes the system in EBX, which a hook cannot call portably. `ours` therefore assumes ZERO stations. A divergence on an imperial row is expected to be exactly the station factor, and the record carries `count` and the return, so the factor is MEASURED from the trace rather than fitted" + }, + { + "mitigation": "logged as `tuning` on every record", + "risk": "medium", + "what": "the slave row's output modifier, and every value the data files supply", + "why": "SLAVES_OUTPUT_MOD, the two morale thresholds and their two modifiers, and STATION_BONUS_IMPERIAL_OUTPUT are read out of the live process's globals and logged with every record, so the record says which value drove it" + } + ], + "why": "the imperial and civilian output modifiers, the 1.8 factor and the 500000 divisor are literals inside the executable, so nothing about them is assumed from the data files" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:system", + "risk": "high", + "what": "the addiction sweep raises MoraleEvents, which are constructed and appended to the system's capped morale history", + "why": "the same class of write as B3's defect. sim::ProcessColonyTurn does compute the morale events (ColonyTurnResult), but the hook never emits them: DescribeMoraleEvents is dead code, so they are neither compared nor logged" + }, + { + "mitigation": "guard:system covers the system object only, not the other objects", + "risk": "high", + "what": "every callee: the plague pass, imperial and civilian growth, the resource debit, in-orbit refuel, slaves, rebellion and the build queue", + "why": "declared input boundary -- ProcessTurn is a dispatcher and only the words it writes itself are modelled. The callees raise EVENT_SLAVES_DEAD, EVENT_SYSTEM_REBELLION_CONTINUES, the plague events and SEBuildCompleted, create ships and bump per-player ShipRecords counters" + }, + { + "mitigation": "", + "risk": "medium", + "what": "ApplyInfraBonus / ApplyPopBonus read the owner's home-system id, and the build queue writes the owning ServerPlayer", + "why": "writes through a pointer to another object; no region reaches the player" + }, + { + "mitigation": "", + "risk": "low", + "what": "ProcessRebellion is the pass's only RNG consumer and its draw count is data-dependent", + "why": "the generator IS a declared region, so a moved post-state is visible and names the system whose rebellion fired -- it is reported, not modelled" + }, + { + "mitigation": "", + "risk": "medium", + "what": "replace mode is refused for this hook", + "why": "our side models the dispatcher's own writes and none of the callees, so a replace run would silently skip a colony's whole turn. There is therefore no oracle layer behind the compare for this hook" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyHost::Autosave": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng only", + "risk": "low", + "what": "the whole save write: four path buffers, the ENDTURN pair removal, the backup rotation, the per-player connection detach/reattach, and SaveGame_WriteFile 0x00877070 itself", + "why": "this hook exists to timestamp the generator at the two moments the two save files are written. It is a marker and models nothing" + }, + { + "mitigation": "arg:server_cached / host_plus_0x54 / server_agrees say which pointer was used and whether the +0x54 candidate is the same object", + "risk": "medium", + "what": "the generator is reached through a CACHED StrategyServer pointer, not from this call's own arguments", + "why": "`this` is the global at 0x00b29f98, hardcoded by both call sites, and no argument here names the server. On the first pre-turn autosave after a load no turn driver has run yet, so the cache is empty and that record carries no ledger position -- the FIRST BRACKET OF A SESSION IS INCOMPLETE BY CONSTRUCTION and must not be read as a zero-cost turn" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ApplyEncounterResult": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng measures the subtotal", + "risk": "high", + "what": "the combat resolver 0x007d5af0 (7499 B) is completely unread", + "why": "this hook measures what its subtree spends and models none of it. Nothing about combat determinism can be settled until that function is read; this only puts a number on the hole" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the ~0xea0-byte combat report, the CombatReport list append at S+0x1fc, the ClientEncounterResults push into S+0x2f4, the per-ship turn stamps and the pairwise engagement bits", + "why": "all of it is game state this hook does not declare and does not check" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::MoveFleet": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:fleet sees the fleet's own words; the event and the system do not", + "risk": "high", + "what": "on arrival: dispatches SEFleetArrived and runs one of three arrival handlers by destination kind (enter system / join fleet / stop at point)", + "why": "declared input boundary -- an arriving call is expected to differ in all of it, and none of it is declared, so the compare says nothing about arrivals" + }, + { + "mitigation": "", + "risk": "high", + "what": "on departure: cancels every still-acting ship (with a log line each) and calls ServerSystem::FleetDeparts, which rewrites the system's ownership bits", + "why": "writes through pointers to ships and to the system" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the tanker top-up refuels other ships in the fleet", + "why": "the per-ship range regions would show it, but ours does not model it, so a fleet with a tanker diverges for a known reason" + }, + { + "mitigation": "declared gap: docs/B4.md", + "risk": "medium", + "what": "a node-line waypoint's step comes from the stutter profile", + "why": "NodeLineStep / BuildStutterSegments are written and unit-tested but not wired in; the hook steps every waypoint type as speed x dt, so a node-line leg is knowingly mis-stepped and only its type is recorded" + }, + { + "mitigation": "", + "risk": "medium", + "what": "a missed probabilistic jump scatters the fleet in a random direction", + "why": "the direction is a second draw whose mapping is not modelled; ours leaves the position alone and reports the scatter distance, so the generator region diverges by one word on a miss" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the route revalidation and the waypoint list itself", + "why": "declared input boundary; the waypoint vector is not a region" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::NodeLineDecay": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng only", + "risk": "high", + "what": "the collapse itself: 0x007a92e0 (690 B) and 0x007a4700 (2244 B) destroy or halt fleets and post EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE / _HALTED / _HALTED_VIANODE, and loop 3 posts two more decay-stage events", + "why": "ours advances the generator and writes nothing else. In compare mode that is the intent -- the check is the word count -- but it means a clean verdict here says nothing about which lines actually collapsed" + }, + { + "mitigation": "arg:predict_words vs region:rng is exactly that check", + "risk": "medium", + "what": "the draw-count model is verified by a DIRECT-call sweep of the downstream pair; their subtrees contain unresolved indirect call sites", + "why": "if one of those vtable slots reaches a generator, the measured delta will exceed `predict_words` and this hook will diverge -- which is the correct outcome, and the reason the prediction is recorded as an argument" + }, + { + "mitigation": "", + "risk": "low", + "what": "the expiry formula reproduces a signed idiv on nptf/npdtf without knowing whether nptf can be negative", + "why": "the original never sign-checks the traffic accumulator. The model truncates toward zero the same way; if the field is always non-negative the question never arises, and no save has been observed with a negative one" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::OnAllCombatDone_Tail": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng plus the two nested hooks", + "risk": "high", + "what": "36 phases, of which two can draw and neither is modelled here", + "why": "phase 6 reaches the unread 7499-byte combat resolver 0x007d5af0 (NextInt on the node-cannon path, Twist plus NextInt on the salvage path) and phase 11 draws one word per expired node line. `predict_nodeline_words` covers only the second, and the nested ApplyEncounterResult / NodeLineDecay hooks are what attribute the split" + }, + { + "mitigation": "arg:encounters says how many encounters this call saw; a call with 0 settles it", + "risk": "medium", + "what": "whether this handler runs on a turn with NO combat is what this hook is here to settle, and until it has run it is a hypothesis", + "why": "combat-done-tail.md \u00c2\u00a76 infers it from the determinism note -- the post-turn autosave appears on every End Turn and this handler is its only reachable caller -- not from the instruction stream" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessFleetMovement": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "", + "risk": "high", + "what": "`ours` re-reads the LIVE fleet list after the original has run", + "why": "the gate-traffic total is computed by the original at the very end of the pass, so a pre-call snapshot would diverge for the wrong reason. It breaks the compare invariant that ours never touches live memory, and it makes this hook's verdict partly self-fulfilling: the input to our arithmetic is the original's own post-move state" + }, + { + "mitigation": "", + "risk": "high", + "what": "drives MoveFleet up to five times per fleet", + "why": "every undeclared effect of MoveFleet happens inside this call too; the pass schedule is recorded in the arguments but never compared" + }, + { + "mitigation": "", + "risk": "high", + "what": "writes FPdpos into every fleet and clears flags 0x2 and 0x100 on every fleet", + "why": "no region covers the fleets, only the players' gate-traffic words" + }, + { + "mitigation": "", + "risk": "high", + "what": "OnFleetArrived posts EVENT_FLEET_ARRIVED", + "why": "the same class of write as B3's defect, and there is no replace mode for this hook, so nothing behind the compare could catch it either" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the original accumulates by player->index but writes back by the player's position in the server vector, into a fixed 32-int array with no bounds check", + "why": "a real latent bug in the original that our side reproduces only while index == position; the reference save never separates them" + }, + { + "mitigation": "", + "risk": "medium", + "what": "PassSchedule() is never called by the hook, and FleetSummary::targetFleetId / relation are never filled", + "why": "the header claims ours predicts the call order for a trace to check; that prediction is not actually emitted" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessNodeSpaceTravel": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng", + "risk": "medium", + "what": "2945 bytes of node-space movement, entirely unmodelled and never swept for RNG by any lane", + "why": "it is hooked here only because it runs TWICE a turn -- ProcessTurn phase 7 and tail phase 10 -- so a draw inside it would be double-counted by anyone modelling it once. The record says whether it draws at all" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng measures the total; the per-phase split is not resolved here", + "risk": "high", + "what": "no model of the turn's RNG cost: 32 phases, each of which may draw", + "why": "ProcessResearch's completion roll, RollResearchAccident's NextInt(100), the ResearchRollPending roll (one word, or two on the plague path), and whatever ProcessStations / ProcessSurrenders / ProcessMissions / ProcessSpecialProjects spend -- none of which has ever been measured. `ours` predicts nothing and the record reports the measurement" + }, + { + "mitigation": "shim.log records the MH_CreateHook status for both", + "risk": "low", + "what": "this hook takes the address the fpu module also wants to sample", + "why": "MinHook allows one hook per target. `fpu.sample_turn=off` releases StrategyServer::ProcessTurn so this hook can install; with it on, this hook fails to install and the trace is missing half the ledger" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::TechTree::ProcessResearch": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:events", + "risk": "medium", + "what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it", + "why": "text comes from the game's string table, which the engine must not carry; 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" + }, + { + "mitigation": "region:events", + "risk": "low", + "what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", + "why": "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" + }, + { + "mitigation": "guard:player, guard:tree_header", + "risk": "high", + "what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on", + "why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is" + }, + { + "mitigation": "guard:player", + "risk": "high", + "what": "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", + "why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports" + }, + { + "mitigation": "region:rng", + "risk": "high", + "what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)", + "why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check" + }, + { + "mitigation": "region:observed_techs", + "risk": "medium", + "what": "constructs the ObservedTech element it appends to ServerPlayer+0x274", + "why": "`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" + }, + { + "mitigation": "guard:tree_header", + "risk": "low", + "what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region", + "why": "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" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes a completion line to the game log", + "why": "log text is not simulation state" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::WeaponDictionary::Init": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "suspected cause of the sibling section hook's compare crash (docs/M2.md)", + "risk": "high", + "what": "LoadWeapon -> WeaponDef::ParseScript registers each weapon's name with the string table and resolves `requires` against the live TechTree", + "why": "per-file parsing is M3 scope; ours delegates to the game's own LoadWeapon, so a compare run performs the registration a SECOND time and neither the string table nor the tech tree is a declared region" + }, + { + "mitigation": "", + "risk": "low", + "what": "allocates 123 WeaponDef objects (0x278 bytes each) on the game heap", + "why": "the definitions do not exist when the hook is entered, so they cannot be a before-snapshot; the dictionary region compares them by id/name/path" + }, + { + "mitigation": "guard:dict", + "risk": "low", + "what": "the word at dictionary+0x14", + "why": "not modelled; emitted as an opaque pointer, which the default policy ignores -- a change is visible in a trace but never a divergence" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes lines to the game log for a missing manifest", + "why": "log text is not simulation state" + }, + { + "mitigation": "", + "risk": "low", + "what": "std::sort tie order for equal weapon names", + "why": "msvc_sort.h replays MSVC 2010's introsort, but the shipped data has no tied names, so the tie rule is unexercised rather than verified" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Mars::GlobalConsts::LoadFile": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "LoadAll's post-state would have to be hooked to see it", + "risk": "medium", + "what": "erases each consumed key from the caller's std::map", + "why": "the map is a LoadAll temporary; declaring a red-black tree as a region is not possible before the call. First-occurrence-wins is reproduced in game::config::apply instead, so the *effect* is modelled, the container is not" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes three kinds of line to the game log (unrecognised key, applied key, expected-but-not-found)", + "why": "log text is not part of the simulation state" + }, + { + "mitigation": "", + "risk": "low", + "what": "opens the file through the VFS and allocates/releases a refcounted buffer", + "why": "ours performs the same two calls, so allocation behaviour matches by construction rather than by comparison" + }, + { + "mitigation": "", + "risk": "low", + "what": "String slots assign through the engine's own std::string, leaking one heap block per long string in compare mode", + "why": "start-up only; documented in docs/M1.md" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Shim::SelfTest::Fill": { + "coverage": { + "state": "complete", + "unmodelled": [], + "why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + } + }, + "inline_max": 256, + "started": "2026-09-09T13:51:31Z" + } + ], + "totals": { + "calls": 4, + "compared": 0, + "coverage_contradicted": 0, + "coverage_unstated": 0, + "diverged": 0, + "guarded_calls": 3, + "invalid_records": 0, + "undeclared_calls": 0, + "undeclared_writes": 0 + }, + "warnings": [] +} diff --git a/verify/results/compare/cr-replace0.md b/verify/results/compare/cr-replace0.md new file mode 100644 index 0000000..c3f4f31 --- /dev/null +++ b/verify/results/compare/cr-replace0.md @@ -0,0 +1,27 @@ +## tracecmp report: R0.trace.jsonl + +- build: cr-618ccb1-20260909T131556Z started: 2026-09-09T13:51:31Z inline_max: 256 +- calls: 4 compared: 0 diverged: 0 invalid records: 0 warnings: 0 +- coverage: 3 guarded call(s), 0 undeclared write(s) in 0 call(s); 0 hook(s) unstated, 0 contradicted + +| hook | calls | modes | compared | diverged | errors | +|---|---|---|---|---|---| +| Game::TechTree::ProcessResearch | 3 | replace:3 | 0 | 0 | 0 | +| Shim::SelfTest::Fill | 1 | trace:1 | 0 | 0 | 0 | + +### coverage + +| hook | verdict | compared regions | guards | undeclared writes | unmodelled | +|---|---|---|---|---|---| +| Game::TechTree::ProcessResearch | partial | events, node[0], node[105], node[106], node[107], node[108], +271 | player, tree_header | 0 | 8 | +| Shim::SelfTest::Fill | complete | buf | - | not watched | 0 | + +#### Game::TechTree::ProcessResearch — not checked by this run +- (medium) posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it — text comes from the game's string table, which the engine must not carry; 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] +- (low) composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names — 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] +- (high) TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on — with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is [guard:player, guard:tree_header] +- (high) 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 — B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports [guard:player] +- (high) the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths) — RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check [region:rng] +- (medium) constructs the ObservedTech element it appends to ServerPlayer+0x274 — `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] +- (low) the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region — 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] +- (low) writes a completion line to the game log — log text is not simulation state diff --git a/verify/results/compare/cr-replace1.json b/verify/results/compare/cr-replace1.json new file mode 100644 index 0000000..fe04872 --- /dev/null +++ b/verify/results/compare/cr-replace1.json @@ -0,0 +1,1331 @@ +{ + "coverage_contradicted": [], + "coverage_unstated": [], + "format": 1, + "hooks": { + "Game::TechTree::ProcessResearch": { + "calls": 3, + "compared": 0, + "coverage": { + "checked_regions": [ + "events", + "node[0]", + "node[105]", + "node[106]", + "node[107]", + "node[108]", + "node[109]", + "node[10]", + "node[110]", + "node[111]", + "node[112]", + "node[113]", + "node[114]", + "node[115]", + "node[116]", + "node[117]", + "node[118]", + "node[119]", + "node[11]", + "node[120]", + "node[121]", + "node[122]", + "node[123]", + "node[124]", + "node[125]", + "node[126]", + "node[127]", + "node[128]", + "node[129]", + "node[12]", + "node[130]", + "node[131]", + "node[132]", + "node[133]", + "node[134]", + "node[135]", + "node[136]", + "node[137]", + "node[138]", + "node[139]", + "node[13]", + "node[140]", + "node[141]", + "node[142]", + "node[143]", + "node[144]", + "node[145]", + "node[146]", + "node[147]", + "node[148]", + "node[149]", + "node[14]", + "node[151]", + "node[152]", + "node[153]", + "node[154]", + "node[155]", + "node[156]", + "node[157]", + "node[158]", + "node[159]", + "node[15]", + "node[160]", + "node[161]", + "node[162]", + "node[163]", + "node[164]", + "node[165]", + "node[166]", + "node[167]", + "node[168]", + "node[169]", + "node[16]", + "node[170]", + "node[171]", + "node[172]", + "node[173]", + "node[174]", + "node[175]", + "node[177]", + "node[178]", + "node[179]", + "node[17]", + "node[180]", + "node[181]", + "node[182]", + "node[183]", + "node[184]", + "node[185]", + "node[186]", + "node[187]", + "node[188]", + "node[189]", + "node[18]", + "node[190]", + "node[191]", + "node[192]", + "node[193]", + "node[194]", + "node[195]", + "node[196]", + "node[197]", + "node[198]", + "node[199]", + "node[19]", + "node[1]", + "node[200]", + "node[201]", + "node[202]", + "node[203]", + "node[204]", + "node[205]", + "node[206]", + "node[207]", + "node[208]", + "node[209]", + "node[20]", + "node[210]", + "node[211]", + "node[212]", + "node[213]", + "node[214]", + "node[215]", + "node[216]", + "node[217]", + "node[218]", + "node[219]", + "node[21]", + "node[220]", + "node[221]", + "node[222]", + "node[223]", + "node[224]", + "node[225]", + "node[226]", + "node[227]", + "node[228]", + "node[229]", + "node[22]", + "node[230]", + "node[231]", + "node[232]", + "node[233]", + "node[234]", + "node[235]", + "node[236]", + "node[237]", + "node[238]", + "node[239]", + "node[23]", + "node[240]", + "node[241]", + "node[242]", + "node[243]", + "node[244]", + "node[245]", + "node[246]", + "node[247]", + "node[248]", + "node[249]", + "node[24]", + "node[250]", + "node[251]", + "node[252]", + "node[253]", + "node[254]", + "node[255]", + "node[256]", + "node[257]", + "node[258]", + "node[259]", + "node[25]", + "node[260]", + "node[261]", + "node[262]", + "node[263]", + "node[264]", + "node[265]", + "node[266]", + "node[267]", + "node[268]", + "node[269]", + "node[26]", + "node[270]", + "node[271]", + "node[272]", + "node[273]", + "node[274]", + "node[275]", + "node[276]", + "node[277]", + "node[278]", + "node[279]", + "node[27]", + "node[280]", + "node[281]", + "node[282]", + "node[283]", + "node[284]", + "node[285]", + "node[286]", + "node[287]", + "node[288]", + "node[289]", + "node[28]", + "node[290]", + "node[291]", + "node[292]", + "node[29]", + "node[2]", + "node[30]", + "node[31]", + "node[32]", + "node[33]", + "node[34]", + "node[35]", + "node[36]", + "node[37]", + "node[38]", + "node[39]", + "node[3]", + "node[40]", + "node[41]", + "node[42]", + "node[43]", + "node[44]", + "node[45]", + "node[46]", + "node[47]", + "node[48]", + "node[49]", + "node[4]", + "node[50]", + "node[51]", + "node[52]", + "node[53]", + "node[54]", + "node[55]", + "node[56]", + "node[57]", + "node[58]", + "node[5]", + "node[60]", + "node[61]", + "node[62]", + "node[63]", + "node[64]", + "node[65]", + "node[66]", + "node[67]", + "node[68]", + "node[69]", + "node[6]", + "node[70]", + "node[72]", + "node[74]", + "node[77]", + "node[78]", + "node[7]", + "node[80]", + "node[81]", + "node[85]", + "node[86]", + "node[87]", + "node[88]", + "node[89]", + "node[8]", + "node[90]", + "node[91]", + "node[92]", + "node[94]", + "node[98]", + "node[99]", + "node[9]", + "observed_techs", + "overbudget", + "rng" + ], + "guarded_calls": 3, + "guards": [ + "player", + "tree_header" + ], + "spans": { + "replace": [ + "tree_header+0x20:1" + ] + }, + "state": "partial", + "undeclared_calls": 1, + "undeclared_writes": 1, + "unmodelled": [ + { + "mitigation": "region:events", + "risk": "medium", + "what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it", + "why": "text comes from the game's string table, which the engine must not carry; 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" + }, + { + "mitigation": "region:events", + "risk": "low", + "what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", + "why": "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" + }, + { + "mitigation": "guard:player, guard:tree_header", + "risk": "high", + "what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on", + "why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is" + }, + { + "mitigation": "guard:player", + "risk": "high", + "what": "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", + "why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports" + }, + { + "mitigation": "region:rng", + "risk": "high", + "what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)", + "why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check" + }, + { + "mitigation": "region:observed_techs", + "risk": "medium", + "what": "constructs the ObservedTech element it appends to ServerPlayer+0x274", + "why": "`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" + }, + { + "mitigation": "guard:tree_header", + "risk": "low", + "what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region", + "why": "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" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes a completion line to the game log", + "why": "log text is not simulation state" + } + ], + "verdict": "partial", + "why": "" + }, + "diffs": [], + "diverged": 0, + "diverged_call_ids": [], + "errors": 0, + "modes": { + "replace": 3 + } + }, + "Shim::SelfTest::Fill": { + "calls": 1, + "compared": 0, + "coverage": { + "checked_regions": [ + "buf" + ], + "guarded_calls": 0, + "guards": [], + "spans": {}, + "state": "complete", + "undeclared_calls": 0, + "undeclared_writes": 0, + "unmodelled": [], + "verdict": "complete", + "why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region" + }, + "diffs": [], + "diverged": 0, + "diverged_call_ids": [], + "errors": 0, + "modes": { + "trace": 1 + } + } + }, + "inputs": [ + "/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad/out/R1.trace.jsonl" + ], + "invalid": [], + "kind": "report", + "meta": [ + { + "build": "cr-618ccb1-20260909T131556Z", + "exe_sha256": "970b7de729956a53094c7eb98aba4270aee98e2fed5daf0d39e290013c90c841", + "format": 1, + "hooks": { + "Game::EncounterDetect::AssignContacts": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng; arg:detectors/contacts/max_trials bound the expected count", + "risk": "medium", + "what": "the contact-to-detector assignment itself, and the two-pass outer loop", + "why": "this hook exists because the draw here is INLINED and therefore invisible to every call-graph sweep and to the entry-point detours -- it is the one site in ProcessTurn's closure that neither instrument can see. It measures the word cost of the whole call and models nothing" + }, + { + "mitigation": "arg:max_trials is the upper bound only", + "risk": "low", + "what": "the per-trial threshold is 0.25f or 0.0f depending on two tech lookups, and the accept test short-circuits the inner loop", + "why": "so the measured cost is between |contacts| and |contacts| x |detectors| and the exact number depends on tech state this hook does not read" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::EncounterDetect::ProcessTeamRecord": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng; arg:gate/pred_contacts/pred_detectors are a prediction, not a check", + "risk": "medium", + "what": "the whole body of ProcessTeamRecord: the gate call, the two vector builds and the bucket construction", + "why": "this hook measures the word cost of the call and recomputes three integers the original derives from the same record. It models none of the work and asserts nothing about the contact assignment" + }, + { + "mitigation": "arg:fc_byte_vs_dword_disagreements; arg:entry_flags carries the raw values", + "risk": "medium", + "what": "`+0xfc` and `+0xfb` are read as BYTES, following lane I's reading of the classifier functions", + "why": "if either is really a wider field, every count here is wrong in the same direction and no cross-check inside this hook would notice. So the dword at +0xfc is read as well and any row where the two disagree is COUNTED, not silently resolved -- a non-zero disagreement count means the byte reading is unsafe on this workload" + }, + { + "mitigation": "arg:entries against the three class counts", + "risk": "low", + "what": "an entry whose object pointer is unreadable is skipped", + "why": "it is not counted into any of the three classes, so entries != contacts + detectors + neither is the signal that this happened" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::SectionDictionary::SectionDictionary": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "see docs/M2.md; compare mode for this hook is not safe to run", + "risk": "high", + "what": "LoadSection registers each section with the string table and the live TechTree, and may append to the dictionary's own vector", + "why": "M3 scope; ours delegates to the game's LoadSection after the original has already built all 885 definitions, so the second pass registers duplicates -- the leading hypothesis for this hook's compare-mode crash" + }, + { + "mitigation": "", + "risk": "medium", + "what": "post-load validation pass over every definition's @-token against the string table", + "why": "runs after the loop and touches no declared region" + }, + { + "mitigation": "", + "risk": "low", + "what": "allocates 885 SectionDef objects (0x3d8 bytes each) on the game heap", + "why": "they do not exist at hook entry; compared by index/species/id/token" + }, + { + "mitigation": "guard:dict", + "risk": "low", + "what": "the word at dictionary+0x14", + "why": "not modelled; emitted as an ignored pointer" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the before-snapshot of the object is uninitialised heap", + "why": "the hook is on the constructor, so `before` is meaningless and only `after` carries information" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::ComputeBudget": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary; see budget_inputs.h", + "risk": "high", + "what": "slots 1, 2, 3, 4, 7 and 11 are produced by callees this milestone does not model (per-system output, trade, ship-carried population, a second manager, the build-queue spend)", + "why": "they are copied out of the original's own output and back into the same slots, so they match BY CONSTRUCTION and prove nothing" + }, + { + "mitigation": "guard:budget_object does not reach the ships; unverified", + "risk": "high", + "what": "ServerSystem::ComputeOutput repairs damaged ships in orbit", + "why": "replace mode runs the original a second time on a scratch Budget to harvest the six unmodelled slots, so that repair happens TWICE per turn in replace mode and nothing in the trace would show it" + }, + { + "mitigation": "logged as `inputs.live_difficulty` on every record", + "risk": "low", + "what": "the difficulty-mods row still DRIVES `ours` as two fitted constants", + "why": "the old note here said the row was 'not reachable from a ServerPlayer'. That was wrong: the record is at ServerPlayer+0x36c and every consumer calls DifficultyMods_Select(p->+0x36c, p). Lane L5 now snapshots it and emits it as `inputs.live_difficulty` alongside the fitted pair, so a run SHOWS whether the fit is right instead of assuming it. `ours` was deliberately left on the fitted constants so that this run is evidence about them; switching it over is the next lane's one-line change" + }, + { + "mitigation": "the record carries `inputs.live_consts` so the widths are readable, and tests/game_sim/test_economy.cpp pins all three at their boundaries", + "risk": "medium", + "what": "three of the four float widths this routine turns on are unexercised by any reference turn", + "why": "the savings-interest rate is a widened 0.01f and its boundary IS in the corpus (a treasury of 50,000 earns 499, not 500). The research yield factor is a widened 0.85f whose boundary needs a research money that is a multiple of 40,000, and the three research modifiers are summed in SINGLE precision, which needs two of them non-zero -- the corpus has shrm = TRM = 0. So a green compare here verifies one of the three and says nothing about the other two" + }, + { + "mitigation": "", + "risk": "low", + "what": "the research-allocation vector's heap block", + "why": "only the element count is compared; the three words are heap pointers the default policy ignores" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::OnTechResearched": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:player (EventStorage is inline at ServerPlayer+0x29c)", + "risk": "high", + "what": "posts EVENT_RESEARCH_COMPLETE / _UNDERBUDGET / _TEMPERANCE on the owner's EventStorage when !silent", + "why": "the same class of write as B3's defect, and this hook has no replace-mode oracle that could catch it: gotcha 4 in docs/B2.md says a changed save hash on a completion turn is expected and therefore not a finding" + }, + { + "mitigation": "", + "risk": "high", + "what": "writes every owned system's AI flag (CCC_AIVrus / CCC_AISlv), re-evaluates the arcology civilian cap, cures addiction and clears plague across systems AND ships", + "why": "writes through pointers to other objects; compare mode must not touch live state, and no region reaches them" + }, + { + "mitigation": "this is the extra draw B3 observed on a completion", + "risk": "high", + "what": "the pending plague-cure roll (ServerPlayer::RollResearchEvent)", + "why": "it draws one word from the strategic generator unconditionally, and a SECOND word (NextInt) when the roll beats the odds and takes the plague path -- which also posts EVENT_PLAGUE_OUTBREAK, while the rebellion path cancels the research. So a fired roll costs one or two words, not one; running it in compare mode would consume real randomness. The two words it guards are still cleared and the record says whether it would have fired" + }, + { + "mitigation": "", + "risk": "medium", + "what": "TechTree::SetResearched for the Zuul boarding-pod grant", + "why": "it would mutate the live tree, and it recurses" + }, + { + "mitigation": "region:node_bore, declared only when the block already exists", + "risk": "medium", + "what": "allocates or frees the node-bore block at ServerPlayer+0x308", + "why": "ours has no allocator the game's runtime could free, so replace mode calls the game's own updater -- which means replace mode never exercises our node-bore selection at all" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerPlayer::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:player reports Sav, +0xc8 and +0xcc moving", + "risk": "high", + "what": "phases 2, 3 and 6 -- the savings apply Sav = SatAdd(budget[0], net), the three aid/trade records copied out of the budget, and the research refund -- are NOT declared and NOT compared", + "why": "all three are pure functions of ComputeBudget's 22-slot output and of ProcessResearch's overBudget out-parameter, and both live in the original's own stack frame ([ebp-0x90] and [ebp-0x14]). The three ways to reach them are each worse than not having them: call ComputeBudget ourselves (it repairs damaged ships in orbit -- harness-audit #6), read them out of the nested B1/B3 hooks (harness-audit #5, the self-fulfilling compare), or infer them from the observed Sav delta (the same trap). The formulas ARE written and unit-tested in player_turn_inputs.cpp; they are simply not wired into the verdict" + }, + { + "mitigation": "run hook.Game::ServerPlayer::ComputeBudget=compare alongside", + "risk": "high", + "what": "ComputeBudget (0x00863030) itself, including ServerSystem::ComputeOutput, which REPAIRS DAMAGED SHIPS IN ORBIT", + "why": "B1 is the hook that checks the budget; its ship-repair side effect happens inside this call too, once per system per player per turn" + }, + { + "mitigation": "guard:player, and hook.Game::TechTree::ProcessResearch=compare", + "risk": "high", + "what": "TechTree::ProcessResearch and the whole SetResearched / OnTechResearched cascade it can trigger: progress, the completion roll, the 5% decay sweep, the child-unlock cascade, ~90 tech-effect field writes, EVENT_RESEARCH_* and EVENT_TECHS_UNLOCKED", + "why": "declared input boundary. B3/U own it; this hook reports the pre-call research state and lets the player guard say how much moved" + }, + { + "mitigation": "region:rng shows the draws; nothing shows the systems it wrote", + "risk": "high", + "what": "RollResearchAccident (0x00889dc0) draws Mars::RNG::NextInt(100) whenever the research boost is non-zero, can apply progress loss across EVERY system, and posts EVENT_LABACCIDENT_{SMALL,MEDIUM,LARGE}", + "why": "it runs before ProcessResearch and gates it entirely. Its draws move the declared `rng` region, so they are visible -- but as an unexplained RNG divergence, not as their cause" + }, + { + "mitigation": "region:rng + region:roll_flags + guard:player; ours never rolls", + "risk": "high", + "what": "the ResearchRollPending roll: RollResearchEvent draws one NextFloat, and when it BEATS the odds the plague branch draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, while the rebellion branch allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the current research", + "why": "every previous coverage note in this repo says 'exactly one NextFloat'. That is the cost of REACHING the branch; a fired roll costs one or two words. The branch has never been observed firing. `predict_roll` says whether we expected the roll, `region:rng` says what it actually cost, and `roll_flags` says whether ResErrRoll was consumed" + }, + { + "mitigation": "guard:player", + "risk": "medium", + "what": "ServerPlayer::ProcessSpecialProjects (0x00840fe0) and, for an AI player, ConstructionSpend (0x00817f90)", + "why": "1015 bytes of special-project state, plus the log line \"SpecialProject: %s completed investigation phase\"" + }, + { + "mitigation": "guard:player reports the EvNxID bump", + "risk": "medium", + "what": "the EVENT_NO_RESEARCH post at 0x0089168c, which appends to the player's EventStorage (+0x29c) and bumps EvNxID (+0x2b0)", + "why": "B3's exact failure mode: a list append outside every Result region. Its condition is fully known (ResT == 0, nothing completed this turn, and at least one state-2 tech exists) but ours posts nothing" + }, + { + "mitigation": "guard:player sees the header move only", + "risk": "low", + "what": "PruneRaidTargets (0x00863cf0) erases from the heap vector at +0x338", + "why": "the vector header is inside the player guard, but its elements are heap memory no region reaches, and the erase runs each removed element's scalar destructor" + }, + { + "mitigation": "region:bonus_header", + "risk": "low", + "what": "the timed-bonus element regions are captured from the PRE-CALL element addresses, and the sweep may shrink the vector under them", + "why": "erase moves data down inside the same buffer and only lowers _Mylast, so the addresses stay valid and readable. Elements past the new _Mylast are compared as stale bytes; `bonus_header` is the region that carries the count, and ours writes the same stale tail the original leaves" + }, + { + "mitigation": "", + "risk": "medium", + "what": "replace mode is refused for this hook", + "why": "ours models the driver's own writes and none of ComputeBudget, ProcessResearch, ProcessSpecialProjects or the raid-target prune. A replace run would leave a player in a state no code path produces, and there is therefore no oracle layer behind this compare" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::ComputeTotalOutput": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary", + "risk": "high", + "what": "the station count, as for GroupOutput", + "why": "`ours` assumes zero stations, so a system with an imperial population and a station diverges by the station factor" + }, + { + "mitigation": "declared input boundary", + "risk": "high", + "what": "the slave population and its xenotech adjustment", + "why": "the original's slave count is not a plain field: it runs the count through a per-species xenotech factor. `ours` takes the slave term as ZERO, so any system holding slaves diverges. The record logs the raw group-2 population sums so a divergence can be attributed" + }, + { + "mitigation": "declared input boundary", + "risk": "medium", + "what": "the capacity surplus the civilian term adds for the owner's own species", + "why": "the original calls the carrying-capacity helper twice with different out-parameter slots and adds max(0, B - A) to the civilian count; `ours` uses the raw civilian population. The surplus is zero except when the colony is at its cap" + }, + { + "mitigation": "", + "risk": "low", + "what": "the addiction phase", + "why": "`ours` assumes it is below 3, so ADDICTION_OUTPUT_MOD never applies; the record logs the system's addiction table length so the case is visible" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::GroupOutput": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "declared input boundary; the trace makes it recoverable", + "risk": "high", + "what": "the station count that scales the imperial term", + "why": "the original gets it from a helper that walks the system's fleets and their ships through virtual calls and takes the system in EBX, which a hook cannot call portably. `ours` therefore assumes ZERO stations. A divergence on an imperial row is expected to be exactly the station factor, and the record carries `count` and the return, so the factor is MEASURED from the trace rather than fitted" + }, + { + "mitigation": "logged as `tuning` on every record", + "risk": "medium", + "what": "the slave row's output modifier, and every value the data files supply", + "why": "SLAVES_OUTPUT_MOD, the two morale thresholds and their two modifiers, and STATION_BONUS_IMPERIAL_OUTPUT are read out of the live process's globals and logged with every record, so the record says which value drove it" + } + ], + "why": "the imperial and civilian output modifiers, the 1.8 factor and the 500000 divisor are literals inside the executable, so nothing about them is assumed from the data files" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::ServerSystem::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:system", + "risk": "high", + "what": "the addiction sweep raises MoraleEvents, which are constructed and appended to the system's capped morale history", + "why": "the same class of write as B3's defect. sim::ProcessColonyTurn does compute the morale events (ColonyTurnResult), but the hook never emits them: DescribeMoraleEvents is dead code, so they are neither compared nor logged" + }, + { + "mitigation": "guard:system covers the system object only, not the other objects", + "risk": "high", + "what": "every callee: the plague pass, imperial and civilian growth, the resource debit, in-orbit refuel, slaves, rebellion and the build queue", + "why": "declared input boundary -- ProcessTurn is a dispatcher and only the words it writes itself are modelled. The callees raise EVENT_SLAVES_DEAD, EVENT_SYSTEM_REBELLION_CONTINUES, the plague events and SEBuildCompleted, create ships and bump per-player ShipRecords counters" + }, + { + "mitigation": "", + "risk": "medium", + "what": "ApplyInfraBonus / ApplyPopBonus read the owner's home-system id, and the build queue writes the owning ServerPlayer", + "why": "writes through a pointer to another object; no region reaches the player" + }, + { + "mitigation": "", + "risk": "low", + "what": "ProcessRebellion is the pass's only RNG consumer and its draw count is data-dependent", + "why": "the generator IS a declared region, so a moved post-state is visible and names the system whose rebellion fired -- it is reported, not modelled" + }, + { + "mitigation": "", + "risk": "medium", + "what": "replace mode is refused for this hook", + "why": "our side models the dispatcher's own writes and none of the callees, so a replace run would silently skip a colony's whole turn. There is therefore no oracle layer behind the compare for this hook" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyHost::Autosave": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng only", + "risk": "low", + "what": "the whole save write: four path buffers, the ENDTURN pair removal, the backup rotation, the per-player connection detach/reattach, and SaveGame_WriteFile 0x00877070 itself", + "why": "this hook exists to timestamp the generator at the two moments the two save files are written. It is a marker and models nothing" + }, + { + "mitigation": "arg:server_cached / host_plus_0x54 / server_agrees say which pointer was used and whether the +0x54 candidate is the same object", + "risk": "medium", + "what": "the generator is reached through a CACHED StrategyServer pointer, not from this call's own arguments", + "why": "`this` is the global at 0x00b29f98, hardcoded by both call sites, and no argument here names the server. On the first pre-turn autosave after a load no turn driver has run yet, so the cache is empty and that record carries no ledger position -- the FIRST BRACKET OF A SESSION IS INCOMPLETE BY CONSTRUCTION and must not be read as a zero-cost turn" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ApplyEncounterResult": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng measures the subtotal", + "risk": "high", + "what": "the combat resolver 0x007d5af0 (7499 B) is completely unread", + "why": "this hook measures what its subtree spends and models none of it. Nothing about combat determinism can be settled until that function is read; this only puts a number on the hole" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the ~0xea0-byte combat report, the CombatReport list append at S+0x1fc, the ClientEncounterResults push into S+0x2f4, the per-ship turn stamps and the pairwise engagement bits", + "why": "all of it is game state this hook does not declare and does not check" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::MoveFleet": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "guard:fleet sees the fleet's own words; the event and the system do not", + "risk": "high", + "what": "on arrival: dispatches SEFleetArrived and runs one of three arrival handlers by destination kind (enter system / join fleet / stop at point)", + "why": "declared input boundary -- an arriving call is expected to differ in all of it, and none of it is declared, so the compare says nothing about arrivals" + }, + { + "mitigation": "", + "risk": "high", + "what": "on departure: cancels every still-acting ship (with a log line each) and calls ServerSystem::FleetDeparts, which rewrites the system's ownership bits", + "why": "writes through pointers to ships and to the system" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the tanker top-up refuels other ships in the fleet", + "why": "the per-ship range regions would show it, but ours does not model it, so a fleet with a tanker diverges for a known reason" + }, + { + "mitigation": "declared gap: docs/B4.md", + "risk": "medium", + "what": "a node-line waypoint's step comes from the stutter profile", + "why": "NodeLineStep / BuildStutterSegments are written and unit-tested but not wired in; the hook steps every waypoint type as speed x dt, so a node-line leg is knowingly mis-stepped and only its type is recorded" + }, + { + "mitigation": "", + "risk": "medium", + "what": "a missed probabilistic jump scatters the fleet in a random direction", + "why": "the direction is a second draw whose mapping is not modelled; ours leaves the position alone and reports the scatter distance, so the generator region diverges by one word on a miss" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the route revalidation and the waypoint list itself", + "why": "declared input boundary; the waypoint vector is not a region" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::NodeLineDecay": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng only", + "risk": "high", + "what": "the collapse itself: 0x007a92e0 (690 B) and 0x007a4700 (2244 B) destroy or halt fleets and post EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE / _HALTED / _HALTED_VIANODE, and loop 3 posts two more decay-stage events", + "why": "ours advances the generator and writes nothing else. In compare mode that is the intent -- the check is the word count -- but it means a clean verdict here says nothing about which lines actually collapsed" + }, + { + "mitigation": "arg:predict_words vs region:rng is exactly that check", + "risk": "medium", + "what": "the draw-count model is verified by a DIRECT-call sweep of the downstream pair; their subtrees contain unresolved indirect call sites", + "why": "if one of those vtable slots reaches a generator, the measured delta will exceed `predict_words` and this hook will diverge -- which is the correct outcome, and the reason the prediction is recorded as an argument" + }, + { + "mitigation": "", + "risk": "low", + "what": "the expiry formula reproduces a signed idiv on nptf/npdtf without knowing whether nptf can be negative", + "why": "the original never sign-checks the traffic accumulator. The model truncates toward zero the same way; if the field is always non-negative the question never arises, and no save has been observed with a negative one" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::OnAllCombatDone_Tail": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng plus the two nested hooks", + "risk": "high", + "what": "36 phases, of which two can draw and neither is modelled here", + "why": "phase 6 reaches the unread 7499-byte combat resolver 0x007d5af0 (NextInt on the node-cannon path, Twist plus NextInt on the salvage path) and phase 11 draws one word per expired node line. `predict_nodeline_words` covers only the second, and the nested ApplyEncounterResult / NodeLineDecay hooks are what attribute the split" + }, + { + "mitigation": "arg:encounters says how many encounters this call saw; a call with 0 settles it", + "risk": "medium", + "what": "whether this handler runs on a turn with NO combat is what this hook is here to settle, and until it has run it is a hypothesis", + "why": "combat-done-tail.md \u00c2\u00a76 infers it from the determinism note -- the post-turn autosave appears on every End Turn and this handler is its only reachable caller -- not from the instruction stream" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessFleetMovement": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "", + "risk": "high", + "what": "`ours` re-reads the LIVE fleet list after the original has run", + "why": "the gate-traffic total is computed by the original at the very end of the pass, so a pre-call snapshot would diverge for the wrong reason. It breaks the compare invariant that ours never touches live memory, and it makes this hook's verdict partly self-fulfilling: the input to our arithmetic is the original's own post-move state" + }, + { + "mitigation": "", + "risk": "high", + "what": "drives MoveFleet up to five times per fleet", + "why": "every undeclared effect of MoveFleet happens inside this call too; the pass schedule is recorded in the arguments but never compared" + }, + { + "mitigation": "", + "risk": "high", + "what": "writes FPdpos into every fleet and clears flags 0x2 and 0x100 on every fleet", + "why": "no region covers the fleets, only the players' gate-traffic words" + }, + { + "mitigation": "", + "risk": "high", + "what": "OnFleetArrived posts EVENT_FLEET_ARRIVED", + "why": "the same class of write as B3's defect, and there is no replace mode for this hook, so nothing behind the compare could catch it either" + }, + { + "mitigation": "", + "risk": "medium", + "what": "the original accumulates by player->index but writes back by the player's position in the server vector, into a fixed 32-int array with no bounds check", + "why": "a real latent bug in the original that our side reproduces only while index == position; the reference save never separates them" + }, + { + "mitigation": "", + "risk": "medium", + "what": "PassSchedule() is never called by the hook, and FleetSummary::targetFleetId / relation are never filled", + "why": "the header claims ours predicts the call order for a trace to check; that prediction is not actually emitted" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessNodeSpaceTravel": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng", + "risk": "medium", + "what": "2945 bytes of node-space movement, entirely unmodelled and never swept for RNG by any lane", + "why": "it is hooked here only because it runs TWICE a turn -- ProcessTurn phase 7 and tail phase 10 -- so a draw inside it would be double-counted by anyone modelling it once. The record says whether it draws at all" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::StrategyServer::ProcessTurn": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job", + "risk": "high", + "what": "everything the original writes except the strategic generator", + "why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly" + }, + { + "mitigation": "region:rng carries left/block/words, never a call count", + "risk": "low", + "what": "the ledger reports WORDS, not draws", + "why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count" + }, + { + "mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one", + "risk": "low", + "what": "the ledger is deliberately blind to WHICH primitive spent a word", + "why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those" + }, + { + "mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap", + "risk": "medium", + "what": "a generator position the ledger cannot place reads `words: null`", + "why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero" + }, + { + "mitigation": "region:rng measures the total; the per-phase split is not resolved here", + "risk": "high", + "what": "no model of the turn's RNG cost: 32 phases, each of which may draw", + "why": "ProcessResearch's completion roll, RollResearchAccident's NextInt(100), the ResearchRollPending roll (one word, or two on the plague path), and whatever ProcessStations / ProcessSurrenders / ProcessMissions / ProcessSpecialProjects spend -- none of which has ever been measured. `ours` predicts nothing and the record reports the measurement" + }, + { + "mitigation": "shim.log records the MH_CreateHook status for both", + "risk": "low", + "what": "this hook takes the address the fpu module also wants to sample", + "why": "MinHook allows one hook per target. `fpu.sample_turn=off` releases StrategyServer::ProcessTurn so this hook can install; with it on, this hook fails to install and the trace is missing half the ledger" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::TechTree::ProcessResearch": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "region:events", + "risk": "medium", + "what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it", + "why": "text comes from the game's string table, which the engine must not carry; 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" + }, + { + "mitigation": "region:events", + "risk": "low", + "what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names", + "why": "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" + }, + { + "mitigation": "guard:player, guard:tree_header", + "risk": "high", + "what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on", + "why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is" + }, + { + "mitigation": "guard:player", + "risk": "high", + "what": "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", + "why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports" + }, + { + "mitigation": "region:rng", + "risk": "high", + "what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)", + "why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check" + }, + { + "mitigation": "region:observed_techs", + "risk": "medium", + "what": "constructs the ObservedTech element it appends to ServerPlayer+0x274", + "why": "`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" + }, + { + "mitigation": "guard:tree_header", + "risk": "low", + "what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region", + "why": "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" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes a completion line to the game log", + "why": "log text is not simulation state" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Game::WeaponDictionary::Init": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "suspected cause of the sibling section hook's compare crash (docs/M2.md)", + "risk": "high", + "what": "LoadWeapon -> WeaponDef::ParseScript registers each weapon's name with the string table and resolves `requires` against the live TechTree", + "why": "per-file parsing is M3 scope; ours delegates to the game's own LoadWeapon, so a compare run performs the registration a SECOND time and neither the string table nor the tech tree is a declared region" + }, + { + "mitigation": "", + "risk": "low", + "what": "allocates 123 WeaponDef objects (0x278 bytes each) on the game heap", + "why": "the definitions do not exist when the hook is entered, so they cannot be a before-snapshot; the dictionary region compares them by id/name/path" + }, + { + "mitigation": "guard:dict", + "risk": "low", + "what": "the word at dictionary+0x14", + "why": "not modelled; emitted as an opaque pointer, which the default policy ignores -- a change is visible in a trace but never a divergence" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes lines to the game log for a missing manifest", + "why": "log text is not simulation state" + }, + { + "mitigation": "", + "risk": "low", + "what": "std::sort tie order for equal weapon names", + "why": "msvc_sort.h replays MSVC 2010's introsort, but the shipped data has no tied names, so the tie rule is unexercised rather than verified" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Mars::GlobalConsts::LoadFile": { + "coverage": { + "state": "partial", + "unmodelled": [ + { + "mitigation": "LoadAll's post-state would have to be hooked to see it", + "risk": "medium", + "what": "erases each consumed key from the caller's std::map", + "why": "the map is a LoadAll temporary; declaring a red-black tree as a region is not possible before the call. First-occurrence-wins is reproduced in game::config::apply instead, so the *effect* is modelled, the container is not" + }, + { + "mitigation": "", + "risk": "low", + "what": "writes three kinds of line to the game log (unrecognised key, applied key, expected-but-not-found)", + "why": "log text is not part of the simulation state" + }, + { + "mitigation": "", + "risk": "low", + "what": "opens the file through the VFS and allocates/releases a refcounted buffer", + "why": "ours performs the same two calls, so allocation behaviour matches by construction rather than by comparison" + }, + { + "mitigation": "", + "risk": "low", + "what": "String slots assign through the engine's own std::string, leaking one heap block per long string in compare mode", + "why": "start-up only; documented in docs/M1.md" + } + ], + "why": "" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + }, + "Shim::SelfTest::Fill": { + "coverage": { + "state": "complete", + "unmodelled": [], + "why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region" + }, + "ftol": 0, + "ftol_kind": "abs", + "ptr": "ignore" + } + }, + "inline_max": 256, + "started": "2026-09-09T13:42:14Z" + } + ], + "totals": { + "calls": 4, + "compared": 0, + "coverage_contradicted": 0, + "coverage_unstated": 0, + "diverged": 0, + "guarded_calls": 3, + "invalid_records": 0, + "undeclared_calls": 1, + "undeclared_writes": 1 + }, + "warnings": [] +} diff --git a/verify/results/compare/cr-replace1.md b/verify/results/compare/cr-replace1.md new file mode 100644 index 0000000..106ba9a --- /dev/null +++ b/verify/results/compare/cr-replace1.md @@ -0,0 +1,28 @@ +## tracecmp report: R1.trace.jsonl + +- build: cr-618ccb1-20260909T131556Z started: 2026-09-09T13:42:14Z inline_max: 256 +- calls: 4 compared: 0 diverged: 0 invalid records: 0 warnings: 0 +- coverage: 3 guarded call(s), 1 undeclared write(s) in 1 call(s); 0 hook(s) unstated, 0 contradicted + +| hook | calls | modes | compared | diverged | errors | +|---|---|---|---|---|---| +| Game::TechTree::ProcessResearch | 3 | replace:3 | 0 | 0 | 0 | +| Shim::SelfTest::Fill | 1 | trace:1 | 0 | 0 | 0 | + +### coverage + +| hook | verdict | compared regions | guards | undeclared writes | unmodelled | +|---|---|---|---|---|---| +| Game::TechTree::ProcessResearch | partial | events, node[0], node[105], node[106], node[107], node[108], +271 | player, tree_header | 1 in 1 call(s) | 8 | +| Shim::SelfTest::Fill | complete | buf | - | not watched | 0 | + +#### Game::TechTree::ProcessResearch — not checked by this run +- (medium) posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it — text comes from the game's string table, which the engine must not carry; 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] +- (low) composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names — 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] +- (high) TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on — with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is [guard:player, guard:tree_header] +- (high) 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 — B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports [guard:player] +- (high) the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths) — RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check [region:rng] +- (medium) constructs the ObservedTech element it appends to ServerPlayer+0x274 — `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] +- (low) the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region — 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] +- (low) writes a completion line to the game log — log text is not simulation state +- guard hits in replace mode: tree_header+0x20:1 diff --git a/verify/results/saves/certified-pairs.md b/verify/results/saves/certified-pairs.md index f6d452e..8e33ff0 100644 --- a/verify/results/saves/certified-pairs.md +++ b/verify/results/saves/certified-pairs.md @@ -20,6 +20,7 @@ those columns is an observation, not a certification. | `ref-turn2.sav` `ab4ac2d7…` | one End Turn | load | `bb4fd9ac…` | `978041ac…` | 5 + 1 | runs A–E; reproduced by lane H after everything changed | | `ar-turn37-816raiders.sav` `b6f4e05f…` | End Turn, **auto-resolve peacefully**, End Turn | load | `15b99255…` | `7a8b3d5e…` | **4** | lane AR ×3 `hooks=off`, lane BS ×1 instrumented | | `ar-oracle-A-pre.sav` `15b99255…` | one End Turn | **load** | `33e30092…` | `4c356f59…` | **3** | lane BQ ×2 `hooks=off`, lane BS ×1 instrumented | +| `turn3-state.sav` `978041ac…` | one End Turn | **load** | `e00eed0c…` | `79df5047…` | **3** | lane CR ×2 `hooks=off`, ×1 compare-instrumented | **The second and third rows are the same lineage and they do not agree with each other.** Row 2's second turn reached by continuation gives `7a8b3d5e…`; the identical turn reached by *loading* row 2's @@ -29,6 +30,22 @@ boundary, and it is why the route column exists. **Row 3 is the standalone's first trade-raid pair** — one End Turn, no encounter, four raid rolls at one word each. +**Row 4 is the first pair on a turn that completes a research tech** (lane CR, 2026-09-09). +`turn3-state.sav` is the *output* of row 1 — the `(Autosave).sav` that `ref-turn2` + one End Turn +produces — and the turn it starts is the one where `Player[32 "Fane Lao"]` completes tech 144 +`IND_Waldo` and the cascade unlocks three nodes. Row 1's own turn does **not** complete anything +(`unlock-b3-t1.md`: `0 undeclared write(s) in 0 call(s)`), so every research oracle before this row +was taken on a turn where the completion path did not run. Exposure at entry: none of the three +players with an AI client (32, 496, 512) has `ResTNm == ''`; `NumDes` does not move; two ships +complete and join existing fleets; the four factions at 528–576 carry the empty-`ResTNm` signature +inertly (`Status 0`, no client). Two ships completing *and* three fleets changing did **not** expose +this turn — a reminder that 26(c) is a screen and the control is the decision. + +Masks for row 4, measured against the input: **exactly `/Summary/Checksum` and `Player.Status 4 → 0` +on the four live players. No `/CD[1]/NPrvVa` term** — this state's `CD[1]` diplomacy block is +early-game and the leaf does not move, so the documented `--mask resave` rule holds here in its +original form. + ### Masks that must be on the line - `Player.Status` (4 → 0 on load) and the derived `Summary.Checksum`. diff --git a/verify/results/saves/cr/cr-oracle-autosave.sav b/verify/results/saves/cr/cr-oracle-autosave.sav new file mode 100644 index 0000000..e058743 Binary files /dev/null and b/verify/results/saves/cr/cr-oracle-autosave.sav differ diff --git a/verify/results/saves/cr/cr-oracle-endturn.sav b/verify/results/saves/cr/cr-oracle-endturn.sav new file mode 100644 index 0000000..7862d23 Binary files /dev/null and b/verify/results/saves/cr/cr-oracle-endturn.sav differ diff --git a/verify/results/saves/cr/cr-replace0-autosave.sav b/verify/results/saves/cr/cr-replace0-autosave.sav new file mode 100644 index 0000000..488c7be Binary files /dev/null and b/verify/results/saves/cr/cr-replace0-autosave.sav differ diff --git a/verify/results/saves/cr/cr-replace1-autosave.sav b/verify/results/saves/cr/cr-replace1-autosave.sav new file mode 100644 index 0000000..fbefd9d Binary files /dev/null and b/verify/results/saves/cr/cr-replace1-autosave.sav differ diff --git a/verify/results/saves/determinism-hashes.txt b/verify/results/saves/determinism-hashes.txt index ec2e9a5..a0536b6 100644 --- a/verify/results/saves/determinism-hashes.txt +++ b/verify/results/saves/determinism-hashes.txt @@ -37,3 +37,11 @@ b6f4e05ff226eabd1695003b36293553642553cea96ef417364393e2332a7094 verify/results 1c8baa27680809d585ab1728391e82729642d32aaedffd5bf95b616f0a11a436 verify/results/saves/ad-turn27-two-raiders.sav e913ff41188211c02d536da616337544e64133996cd7cc7a6521a8df1960226f verify/results/saves/bp-pinA-turn28.sav 724528ffde9a7514b69ab9e82ed3b8c8ade63d622c0720de30503a73a92df11e verify/results/saves/bp-pinB-turn28.sav + +# Lane CR, 2026-09-09 -- VM145. Input turn3-state.sav (= the (Autosave).sav of certified-pairs +# row 1), one End Turn, LOAD route. Build cr-618ccb1-20260909T131556Z, shimdist-cr. +# C1/C2 hooks=off (two fresh processes), N compare, R0/R1 replace. +e00eed0c03a31d27a81b7470a9dcc9ba08a2ac48c20baeee4f34749164743e3f 67212 (Autosave EndTurn).sav C1 == C2 == N == R0 == R1 +79df50475a7b83afa927d992b9f030dcf45710f4bda0133b8b1fa4800a72e420 67811 (Autosave).sav C1 == C2 == N [THE ORACLE] +6b51db992b158caa5424d71b2dccf72924af7198b4165bfcfc870e0d438fb6d5 67511 (Autosave).sav R0 replace, research.replace_cascade=off -> 27 leaves +8a4309ee4fe0b3177a2820600b5016c7c256d0f51b065df469ecd0b4f2342235 67537 (Autosave).sav R1 replace, research.replace_cascade=on -> 16 leaves diff --git a/verify/results/shim/cr/cr-N.log b/verify/results/shim/cr/cr-N.log new file mode 100644 index 0000000..b040b63 --- /dev/null +++ b/verify/results/shim/cr/cr-N.log @@ -0,0 +1,118 @@ +09:33:54.650 [tid 880] ==== sots-engine shim (binkw32 proxy) build cr-618ccb1-20260909T131556Z ==== +09:33:54.650 [tid 880] exe: C:\SOTS\Sword of the Stars.exe +09:33:54.650 [tid 880] exe base=0x00f40000 (link-time image base 0x00400000, ASLR delta +11796480) pid=20996 shim=71d70000 +09:33:54.650 [tid 880] addresses: Source: sots-re ghidra/addresses.json @ aa8d3fb, generated 2026-09-09 by tools/gen_addresses.py +09:33:54.650 [tid 880] config: hooks=trace +09:33:54.650 [tid 880] config: hook.Game::EncounterDetect::AssignContacts=off +09:33:54.650 [tid 880] config: hook.Game::EncounterDetect::ProcessTeamRecord=off +09:33:54.650 [tid 880] config: hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +09:33:54.650 [tid 880] config: hook.Game::SVSOSwarmQueen::OnTurnBegin=off +09:33:54.650 [tid 880] config: hook.Game::SVSOSwarmQueen::RegisterHives=off +09:33:54.650 [tid 880] config: hook.Game::SVSOSwarmQueen::TickHives=off +09:33:54.650 [tid 880] config: hook.Game::SectionDictionary::SectionDictionary=off +09:33:54.650 [tid 880] config: hook.Game::ServerPlayer::ComputeBudget=off +09:33:54.650 [tid 880] config: hook.Game::ServerPlayer::OnTechResearched=off +09:33:54.650 [tid 880] config: hook.Game::ServerPlayer::ProcessTurn=off +09:33:54.650 [tid 880] config: hook.Game::ServerSystem::ComputeTotalOutput=off +09:33:54.650 [tid 880] config: hook.Game::ServerSystem::GroupOutput=off +09:33:54.650 [tid 880] config: hook.Game::ServerSystem::ProcessTurn=off +09:33:54.650 [tid 880] config: hook.Game::StrategyApp::RunAI=off +09:33:54.650 [tid 880] config: hook.Game::StrategyHost::Autosave=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::ApplyEncounterResult=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::BeginProcessTurn=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::MoveFleet=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::NodeLineDecay=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::OnAllCombatDone_Tail=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::ProcessFleetMovement=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +09:33:54.650 [tid 880] config: hook.Game::StrategyServer::ProcessTurn=off +09:33:54.650 [tid 880] config: hook.Game::WeaponDictionary::Init=off +09:33:54.650 [tid 880] config: hook.Mars::GlobalConsts::LoadFile=off +09:33:54.650 [tid 880] config: hook.Mars::RNG::Seed=off +09:33:54.650 [tid 880] config: hook.Game::TechTree::ProcessResearch=compare +09:33:54.650 [tid 880] config: research.replace_cascade=off +09:33:54.650 [tid 880] config: trace.path=C:\SOTS\shim.trace.jsonl +09:33:54.650 [tid 880] config: trace.inline_max=256 +09:33:54.650 [tid 880] config: trace.flush=always +09:33:54.728 [tid 880] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always) +09:33:54.728 [tid 880] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=013e0e50 +09:33:54.728 [tid 880] hook: MH_Initialize -> MH_OK +09:33:54.728 [tid 880] hook: MH_CreateHook -> MH_OK (trampoline=00a30fe0) +09:33:54.759 [tid 880] hook: MH_EnableHook -> MH_OK +09:33:54.759 [tid 880] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295) +09:33:54.759 [tid 880] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed) +09:33:54.759 [tid 880] dict: dictionaries hook ready (crt new=73e7232b delete=73e70174) +09:33:54.759 [tid 880] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed) +09:33:54.759 [tid 880] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed) +09:33:54.759 [tid 880] research: ProcessResearch hook ready (Cost=010bda00, node=0x34, rng=0x9cc, fpu_cw=0x027f, replace_cascade=off) +09:33:54.759 [tid 880] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010c76c0 MH_CreateHook -> MH_OK (trampoline=00a30fc0) +09:33:54.775 [tid 880] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=compare +09:33:54.775 [tid 880] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f) +09:33:54.775 [tid 880] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::ServerSystem::GroupOutput rva=0x0034b7a0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::ServerSystem::ComputeTotalOutput rva=0x00350480 mode=off (not installed) +09:33:54.775 [tid 880] player_turn: ServerPlayer::ProcessTurn hook armed (ratio helper at 010be950) +09:33:54.775 [tid 880] hook: Game::ServerPlayer::ProcessTurn rva=0x00491340 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyHost::Autosave rva=0x00495210 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::ProcessTurn rva=0x003dc6c0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::OnAllCombatDone_Tail rva=0x003d92a0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::ApplyEncounterResult rva=0x003d8920 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::NodeLineDecay rva=0x003ae010 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::ProcessNodeSpaceTravel rva=0x003a0e20 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::EncounterDetect::AssignContacts rva=0x003aa240 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::EncounterDetect::ProcessTeamRecord rva=0x003ca640 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyServer::BeginProcessTurn rva=0x003d98e0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::SVSOSwarmQueen::OnTurnBegin rva=0x00129930 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::SVSOSwarmQueen::RegisterHives rva=0x00127630 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::SVSOSwarmQueen::TickHives rva=0x00127770 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::SVSOSlaversRefuel::UpdateDifficultyTier rva=0x00115820 mode=off (not installed) +09:33:54.775 [tid 880] hook: Mars::RNG::Seed rva=0x0009fdf0 mode=off (not installed) +09:33:54.775 [tid 880] hook: Game::StrategyApp::RunAI rva=0x004706f0 mode=off (not installed) +09:33:54.806 [tid 880] drawsite: Mars::RNG::NextFloat rva=0x0007d830 -> va=00fbd830 create=MH_OK enable=MH_OK +09:33:54.822 [tid 880] drawsite: Mars::RNG::NextInt rva=0x000271c0 -> va=00f671c0 create=MH_OK enable=MH_OK +09:33:54.853 [tid 880] drawsite: Mars::RNG::Chance rva=0x004e6dd0 -> va=01426dd0 create=MH_OK enable=MH_OK +09:33:54.869 [tid 880] drawsite: Mars::RNG::NextUInt rva=0x000f7670 -> va=01037670 create=MH_OK enable=MH_OK +09:33:54.884 [tid 880] drawsite: Mars::RNG::FloatRange rva=0x0007d8a0 -> va=00fbd8a0 create=MH_OK enable=MH_OK +09:33:54.916 [tid 880] drawsite: Mars::RNG::IntRangeBell rva=0x004e6d80 -> va=01426d80 create=MH_OK enable=MH_OK +09:33:54.931 [tid 880] drawsite: Mars::RNG::GaussianRange rva=0x004e6e30 -> va=01426e30 create=MH_OK enable=MH_OK +09:33:54.931 [tid 880] probe: installing 12 of 12 (probes= in shim.cfg) +09:33:54.947 [tid 880] probe: Game::ServerSpyManager::vslot13 rva=0x004877b0 -> va=013c77b0 create=MH_OK enable=MH_OK +09:33:54.963 [tid 880] probe: Game::ServerSpyManager::vslot14 rva=0x0048db80 -> va=013cdb80 create=MH_OK enable=MH_OK +09:33:54.994 [tid 880] probe: Game::ServerTradeManagerImpl::vslot13 rva=0x0048ef80 -> va=013cef80 create=MH_OK enable=MH_OK +09:33:55.009 [tid 880] probe: Game::ServerTradeManagerImpl::vslot15 rva=0x0042cca0 -> va=0136cca0 create=MH_OK enable=MH_OK +09:33:55.025 [tid 880] probe: Game::SpyManager::Slot13RngCallee rva=0x004408e0 -> va=013808e0 create=MH_OK enable=MH_OK +09:33:55.041 [tid 880] probe: Game::TradeManager::Slot13RngCalleeA rva=0x00420ca0 -> va=01360ca0 create=MH_OK enable=MH_OK +09:33:55.072 [tid 880] probe: Game::TradeManager::Slot13RngCalleeB rva=0x0048b440 -> va=013cb440 create=MH_OK enable=MH_OK +09:33:55.088 [tid 880] probe: Game::ServerTradeManager::CreateRaidEncounter rva=0x004938a0 -> va=013d38a0 create=MH_OK enable=MH_OK +09:33:55.103 [tid 880] probe: Game::ServerTradeManager::GenerateTradeRaidEncounters rva=0x00493290 -> va=013d3290 create=MH_OK enable=MH_OK +09:33:55.134 [tid 880] probe: Game::ServerSpyManager::vslot15 [control] rva=0x00487f30 -> va=013c7f30 create=MH_OK enable=MH_OK +09:33:55.150 [tid 880] probe: Game::ServerTradeManagerImpl::vslot14 [control] rva=0x004590d0 -> va=013990d0 create=MH_OK enable=MH_OK +09:33:55.166 [tid 880] probe: Game::EncounterDetect::Run [control] rva=0x003cb080 -> va=0130b080 create=MH_OK enable=MH_OK +09:33:55.166 [tid 880] watch: disabled (watch=off) +09:33:55.166 [tid 880] aiorders: disabled (aiorders=off) +09:33:55.166 [tid 880] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=off value=0x0000 sample_ticks=on +09:33:55.166 [tid 880] fpu: sample_turn=on (off releases StrategyServer::ProcessTurn for another hook) +09:33:55.166 [tid 880] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=012c3be0 MH_CreateHook -> MH_OK (trampoline=00a30d40) +09:33:55.197 [tid 880] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK +09:33:55.197 [tid 880] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=013198e0 MH_CreateHook -> MH_OK (trampoline=00a30d20) +09:33:55.213 [tid 880] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK +09:33:55.213 [tid 880] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0131c6c0 MH_CreateHook -> MH_OK (trampoline=00a30d00) +09:33:55.228 [tid 880] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK +09:33:55.228 [tid 880] fpu: DemoApp::OnTick rva=0x0049a640 -> va=013da640 MH_CreateHook -> MH_OK (trampoline=00a30ce0) +09:33:55.259 [tid 880] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK +09:33:55.259 [tid 880] selftest: Shim::SelfTest::Fill mode=trace checksum=075ef0c3 records=1 +09:33:55.275 [tid 880] Application::Initialize called (this=00a88128) +09:33:56.166 [tid 880] fpu: TICK BASELINE at OnTick (this=00a88128): cw=0x127f 53bit-double/nearest +09:39:38.180 [tid 880] fpu: sample at StrategyClient::EndTurn (this=1e0711e8): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:39:41.289 [tid 880] fpu: sample at StrategyClient::EndTurn (this=32c5e628): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:39:41.336 [tid 880] fpu: sample at StrategyClient::EndTurn (this=32c58038): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:39:41.383 [tid 880] fpu: sample at StrategyClient::EndTurn (this=32c5d798): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:39:42.305 [tid 880] fpu: sample at StrategyServer::BeginProcessTurn (this=0d2908c8): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:39:42.305 [tid 880] fpu: sample at StrategyServer::ProcessTurn (this=0d2908c8): cw=0x127f 53bit-double/nearest +09:39:42.336 [tid 880] research: mode=compare steps=1 completions=1 overbudget=1166 cascade_possible=1 ok=1 cascade_completions=1 unlocked=3 otch_appends=1 roll_draws=0 failures=0 depth=0 name_unreadable=0 +09:39:42.383 [tid 880] research: mode=compare steps=1 completions=0 overbudget=0 cascade_possible=1 ok=1 cascade_completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0 +09:39:42.398 [tid 880] research: mode=compare steps=1 completions=0 overbudget=0 cascade_possible=1 ok=1 cascade_completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0 diff --git a/verify/results/shim/cr/cr-R0.log b/verify/results/shim/cr/cr-R0.log new file mode 100644 index 0000000..ea09c6d --- /dev/null +++ b/verify/results/shim/cr/cr-R0.log @@ -0,0 +1,118 @@ +09:51:31.009 [tid 19724] ==== sots-engine shim (binkw32 proxy) build cr-618ccb1-20260909T131556Z ==== +09:51:31.009 [tid 19724] exe: C:\SOTS\Sword of the Stars.exe +09:51:31.009 [tid 19724] exe base=0x00f40000 (link-time image base 0x00400000, ASLR delta +11796480) pid=20348 shim=71d70000 +09:51:31.009 [tid 19724] addresses: Source: sots-re ghidra/addresses.json @ aa8d3fb, generated 2026-09-09 by tools/gen_addresses.py +09:51:31.009 [tid 19724] config: hooks=trace +09:51:31.009 [tid 19724] config: hook.Game::EncounterDetect::AssignContacts=off +09:51:31.009 [tid 19724] config: hook.Game::EncounterDetect::ProcessTeamRecord=off +09:51:31.009 [tid 19724] config: hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +09:51:31.009 [tid 19724] config: hook.Game::SVSOSwarmQueen::OnTurnBegin=off +09:51:31.009 [tid 19724] config: hook.Game::SVSOSwarmQueen::RegisterHives=off +09:51:31.009 [tid 19724] config: hook.Game::SVSOSwarmQueen::TickHives=off +09:51:31.009 [tid 19724] config: hook.Game::SectionDictionary::SectionDictionary=off +09:51:31.025 [tid 19724] config: hook.Game::ServerPlayer::ComputeBudget=off +09:51:31.025 [tid 19724] config: hook.Game::ServerPlayer::OnTechResearched=off +09:51:31.025 [tid 19724] config: hook.Game::ServerPlayer::ProcessTurn=off +09:51:31.025 [tid 19724] config: hook.Game::ServerSystem::ComputeTotalOutput=off +09:51:31.025 [tid 19724] config: hook.Game::ServerSystem::GroupOutput=off +09:51:31.025 [tid 19724] config: hook.Game::ServerSystem::ProcessTurn=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyApp::RunAI=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyHost::Autosave=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::ApplyEncounterResult=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::BeginProcessTurn=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::MoveFleet=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::NodeLineDecay=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::OnAllCombatDone_Tail=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::ProcessFleetMovement=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +09:51:31.025 [tid 19724] config: hook.Game::StrategyServer::ProcessTurn=off +09:51:31.025 [tid 19724] config: hook.Game::WeaponDictionary::Init=off +09:51:31.025 [tid 19724] config: hook.Mars::GlobalConsts::LoadFile=off +09:51:31.025 [tid 19724] config: hook.Mars::RNG::Seed=off +09:51:31.025 [tid 19724] config: hook.Game::TechTree::ProcessResearch=replace +09:51:31.025 [tid 19724] config: research.replace_cascade=off +09:51:31.025 [tid 19724] config: trace.path=C:\SOTS\shim.trace.jsonl +09:51:31.025 [tid 19724] config: trace.inline_max=256 +09:51:31.025 [tid 19724] config: trace.flush=always +09:51:31.088 [tid 19724] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always) +09:51:31.088 [tid 19724] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=013e0e50 +09:51:31.088 [tid 19724] hook: MH_Initialize -> MH_OK +09:51:31.088 [tid 19724] hook: MH_CreateHook -> MH_OK (trampoline=017e0fe0) +09:51:31.119 [tid 19724] hook: MH_EnableHook -> MH_OK +09:51:31.119 [tid 19724] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295) +09:51:31.119 [tid 19724] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed) +09:51:31.119 [tid 19724] dict: dictionaries hook ready (crt new=73e7232b delete=73e70174) +09:51:31.119 [tid 19724] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed) +09:51:31.119 [tid 19724] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed) +09:51:31.119 [tid 19724] research: ProcessResearch hook ready (Cost=010bda00, node=0x34, rng=0x9cc, fpu_cw=0x027f, replace_cascade=off) +09:51:31.119 [tid 19724] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010c76c0 MH_CreateHook -> MH_OK (trampoline=017e0fc0) +09:51:31.134 [tid 19724] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=replace +09:51:31.134 [tid 19724] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f) +09:51:31.134 [tid 19724] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::ServerSystem::GroupOutput rva=0x0034b7a0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::ServerSystem::ComputeTotalOutput rva=0x00350480 mode=off (not installed) +09:51:31.134 [tid 19724] player_turn: ServerPlayer::ProcessTurn hook armed (ratio helper at 010be950) +09:51:31.134 [tid 19724] hook: Game::ServerPlayer::ProcessTurn rva=0x00491340 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyHost::Autosave rva=0x00495210 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::ProcessTurn rva=0x003dc6c0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::OnAllCombatDone_Tail rva=0x003d92a0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::ApplyEncounterResult rva=0x003d8920 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::NodeLineDecay rva=0x003ae010 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::ProcessNodeSpaceTravel rva=0x003a0e20 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::EncounterDetect::AssignContacts rva=0x003aa240 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::EncounterDetect::ProcessTeamRecord rva=0x003ca640 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyServer::BeginProcessTurn rva=0x003d98e0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::SVSOSwarmQueen::OnTurnBegin rva=0x00129930 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::SVSOSwarmQueen::RegisterHives rva=0x00127630 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::SVSOSwarmQueen::TickHives rva=0x00127770 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::SVSOSlaversRefuel::UpdateDifficultyTier rva=0x00115820 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Mars::RNG::Seed rva=0x0009fdf0 mode=off (not installed) +09:51:31.134 [tid 19724] hook: Game::StrategyApp::RunAI rva=0x004706f0 mode=off (not installed) +09:51:31.166 [tid 19724] drawsite: Mars::RNG::NextFloat rva=0x0007d830 -> va=00fbd830 create=MH_OK enable=MH_OK +09:51:31.181 [tid 19724] drawsite: Mars::RNG::NextInt rva=0x000271c0 -> va=00f671c0 create=MH_OK enable=MH_OK +09:51:31.213 [tid 19724] drawsite: Mars::RNG::Chance rva=0x004e6dd0 -> va=01426dd0 create=MH_OK enable=MH_OK +09:51:31.228 [tid 19724] drawsite: Mars::RNG::NextUInt rva=0x000f7670 -> va=01037670 create=MH_OK enable=MH_OK +09:51:31.275 [tid 19724] drawsite: Mars::RNG::FloatRange rva=0x0007d8a0 -> va=00fbd8a0 create=MH_OK enable=MH_OK +09:51:31.291 [tid 19724] drawsite: Mars::RNG::IntRangeBell rva=0x004e6d80 -> va=01426d80 create=MH_OK enable=MH_OK +09:51:31.306 [tid 19724] drawsite: Mars::RNG::GaussianRange rva=0x004e6e30 -> va=01426e30 create=MH_OK enable=MH_OK +09:51:31.306 [tid 19724] probe: installing 12 of 12 (probes= in shim.cfg) +09:51:31.338 [tid 19724] probe: Game::ServerSpyManager::vslot13 rva=0x004877b0 -> va=013c77b0 create=MH_OK enable=MH_OK +09:51:31.353 [tid 19724] probe: Game::ServerSpyManager::vslot14 rva=0x0048db80 -> va=013cdb80 create=MH_OK enable=MH_OK +09:51:31.369 [tid 19724] probe: Game::ServerTradeManagerImpl::vslot13 rva=0x0048ef80 -> va=013cef80 create=MH_OK enable=MH_OK +09:51:31.400 [tid 19724] probe: Game::ServerTradeManagerImpl::vslot15 rva=0x0042cca0 -> va=0136cca0 create=MH_OK enable=MH_OK +09:51:31.416 [tid 19724] probe: Game::SpyManager::Slot13RngCallee rva=0x004408e0 -> va=013808e0 create=MH_OK enable=MH_OK +09:51:31.431 [tid 19724] probe: Game::TradeManager::Slot13RngCalleeA rva=0x00420ca0 -> va=01360ca0 create=MH_OK enable=MH_OK +09:51:31.463 [tid 19724] probe: Game::TradeManager::Slot13RngCalleeB rva=0x0048b440 -> va=013cb440 create=MH_OK enable=MH_OK +09:51:31.478 [tid 19724] probe: Game::ServerTradeManager::CreateRaidEncounter rva=0x004938a0 -> va=013d38a0 create=MH_OK enable=MH_OK +09:51:31.494 [tid 19724] probe: Game::ServerTradeManager::GenerateTradeRaidEncounters rva=0x00493290 -> va=013d3290 create=MH_OK enable=MH_OK +09:51:31.525 [tid 19724] probe: Game::ServerSpyManager::vslot15 [control] rva=0x00487f30 -> va=013c7f30 create=MH_OK enable=MH_OK +09:51:31.541 [tid 19724] probe: Game::ServerTradeManagerImpl::vslot14 [control] rva=0x004590d0 -> va=013990d0 create=MH_OK enable=MH_OK +09:51:31.572 [tid 19724] probe: Game::EncounterDetect::Run [control] rva=0x003cb080 -> va=0130b080 create=MH_OK enable=MH_OK +09:51:31.572 [tid 19724] watch: disabled (watch=off) +09:51:31.572 [tid 19724] aiorders: disabled (aiorders=off) +09:51:31.572 [tid 19724] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=off value=0x0000 sample_ticks=on +09:51:31.572 [tid 19724] fpu: sample_turn=on (off releases StrategyServer::ProcessTurn for another hook) +09:51:31.572 [tid 19724] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=012c3be0 MH_CreateHook -> MH_OK (trampoline=017e0d40) +09:51:31.588 [tid 19724] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK +09:51:31.588 [tid 19724] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=013198e0 MH_CreateHook -> MH_OK (trampoline=017e0d20) +09:51:31.619 [tid 19724] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK +09:51:31.619 [tid 19724] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0131c6c0 MH_CreateHook -> MH_OK (trampoline=017e0d00) +09:51:31.634 [tid 19724] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK +09:51:31.634 [tid 19724] fpu: DemoApp::OnTick rva=0x0049a640 -> va=013da640 MH_CreateHook -> MH_OK (trampoline=017e0ce0) +09:51:31.666 [tid 19724] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK +09:51:31.666 [tid 19724] selftest: Shim::SelfTest::Fill mode=trace checksum=075ef0c3 records=1 +09:51:31.681 [tid 19724] Application::Initialize called (this=03938128) +09:51:32.556 [tid 19724] fpu: TICK BASELINE at OnTick (this=03938128): cw=0x127f 53bit-double/nearest +09:57:42.520 [tid 19724] fpu: sample at StrategyClient::EndTurn (this=0dc22d40): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:57:45.614 [tid 19724] fpu: sample at StrategyClient::EndTurn (this=345fdee8): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:57:45.645 [tid 19724] fpu: sample at StrategyClient::EndTurn (this=345fabf0): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:57:45.692 [tid 19724] fpu: sample at StrategyClient::EndTurn (this=345fb338): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:57:46.598 [tid 19724] fpu: sample at StrategyServer::BeginProcessTurn (this=0dc25ab8): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:57:46.598 [tid 19724] fpu: sample at StrategyServer::ProcessTurn (this=0dc25ab8): cw=0x127f 53bit-double/nearest +09:57:46.614 [tid 19724] research: mode=replace steps=1 completions=1 overbudget=1166 cascade_possible=0 ok=0 cascade_completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0 +09:57:46.629 [tid 19724] research: mode=replace steps=1 completions=0 overbudget=0 cascade_possible=0 ok=0 cascade_completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0 +09:57:46.629 [tid 19724] research: mode=replace steps=1 completions=0 overbudget=0 cascade_possible=0 ok=0 cascade_completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0 diff --git a/verify/results/shim/cr/cr-R1.log b/verify/results/shim/cr/cr-R1.log new file mode 100644 index 0000000..f192cf9 --- /dev/null +++ b/verify/results/shim/cr/cr-R1.log @@ -0,0 +1,118 @@ +09:42:14.649 [tid 19172] ==== sots-engine shim (binkw32 proxy) build cr-618ccb1-20260909T131556Z ==== +09:42:14.649 [tid 19172] exe: C:\SOTS\Sword of the Stars.exe +09:42:14.649 [tid 19172] exe base=0x00f40000 (link-time image base 0x00400000, ASLR delta +11796480) pid=20592 shim=71d70000 +09:42:14.649 [tid 19172] addresses: Source: sots-re ghidra/addresses.json @ aa8d3fb, generated 2026-09-09 by tools/gen_addresses.py +09:42:14.649 [tid 19172] config: hooks=trace +09:42:14.649 [tid 19172] config: hook.Game::EncounterDetect::AssignContacts=off +09:42:14.649 [tid 19172] config: hook.Game::EncounterDetect::ProcessTeamRecord=off +09:42:14.649 [tid 19172] config: hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +09:42:14.649 [tid 19172] config: hook.Game::SVSOSwarmQueen::OnTurnBegin=off +09:42:14.649 [tid 19172] config: hook.Game::SVSOSwarmQueen::RegisterHives=off +09:42:14.649 [tid 19172] config: hook.Game::SVSOSwarmQueen::TickHives=off +09:42:14.649 [tid 19172] config: hook.Game::SectionDictionary::SectionDictionary=off +09:42:14.649 [tid 19172] config: hook.Game::ServerPlayer::ComputeBudget=off +09:42:14.649 [tid 19172] config: hook.Game::ServerPlayer::OnTechResearched=off +09:42:14.649 [tid 19172] config: hook.Game::ServerPlayer::ProcessTurn=off +09:42:14.649 [tid 19172] config: hook.Game::ServerSystem::ComputeTotalOutput=off +09:42:14.649 [tid 19172] config: hook.Game::ServerSystem::GroupOutput=off +09:42:14.649 [tid 19172] config: hook.Game::ServerSystem::ProcessTurn=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyApp::RunAI=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyHost::Autosave=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::ApplyEncounterResult=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::BeginProcessTurn=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::MoveFleet=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::NodeLineDecay=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::OnAllCombatDone_Tail=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::ProcessFleetMovement=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +09:42:14.649 [tid 19172] config: hook.Game::StrategyServer::ProcessTurn=off +09:42:14.649 [tid 19172] config: hook.Game::WeaponDictionary::Init=off +09:42:14.649 [tid 19172] config: hook.Mars::GlobalConsts::LoadFile=off +09:42:14.649 [tid 19172] config: hook.Mars::RNG::Seed=off +09:42:14.649 [tid 19172] config: hook.Game::TechTree::ProcessResearch=replace +09:42:14.649 [tid 19172] config: research.replace_cascade=on +09:42:14.649 [tid 19172] config: trace.path=C:\SOTS\shim.trace.jsonl +09:42:14.649 [tid 19172] config: trace.inline_max=256 +09:42:14.649 [tid 19172] config: trace.flush=always +09:42:14.742 [tid 19172] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always) +09:42:14.742 [tid 19172] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=013e0e50 +09:42:14.742 [tid 19172] hook: MH_Initialize -> MH_OK +09:42:14.742 [tid 19172] hook: MH_CreateHook -> MH_OK (trampoline=00cd0fe0) +09:42:14.773 [tid 19172] hook: MH_EnableHook -> MH_OK +09:42:14.773 [tid 19172] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295) +09:42:14.773 [tid 19172] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed) +09:42:14.773 [tid 19172] dict: dictionaries hook ready (crt new=73e7232b delete=73e70174) +09:42:14.773 [tid 19172] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed) +09:42:14.773 [tid 19172] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed) +09:42:14.773 [tid 19172] research: ProcessResearch hook ready (Cost=010bda00, node=0x34, rng=0x9cc, fpu_cw=0x027f, replace_cascade=on) +09:42:14.773 [tid 19172] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010c76c0 MH_CreateHook -> MH_OK (trampoline=00cd0fc0) +09:42:14.805 [tid 19172] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=replace +09:42:14.805 [tid 19172] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f) +09:42:14.805 [tid 19172] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::ServerSystem::GroupOutput rva=0x0034b7a0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::ServerSystem::ComputeTotalOutput rva=0x00350480 mode=off (not installed) +09:42:14.805 [tid 19172] player_turn: ServerPlayer::ProcessTurn hook armed (ratio helper at 010be950) +09:42:14.805 [tid 19172] hook: Game::ServerPlayer::ProcessTurn rva=0x00491340 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyHost::Autosave rva=0x00495210 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::ProcessTurn rva=0x003dc6c0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::OnAllCombatDone_Tail rva=0x003d92a0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::ApplyEncounterResult rva=0x003d8920 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::NodeLineDecay rva=0x003ae010 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::ProcessNodeSpaceTravel rva=0x003a0e20 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::EncounterDetect::AssignContacts rva=0x003aa240 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::EncounterDetect::ProcessTeamRecord rva=0x003ca640 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyServer::BeginProcessTurn rva=0x003d98e0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::SVSOSwarmQueen::OnTurnBegin rva=0x00129930 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::SVSOSwarmQueen::RegisterHives rva=0x00127630 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::SVSOSwarmQueen::TickHives rva=0x00127770 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::SVSOSlaversRefuel::UpdateDifficultyTier rva=0x00115820 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Mars::RNG::Seed rva=0x0009fdf0 mode=off (not installed) +09:42:14.805 [tid 19172] hook: Game::StrategyApp::RunAI rva=0x004706f0 mode=off (not installed) +09:42:14.820 [tid 19172] drawsite: Mars::RNG::NextFloat rva=0x0007d830 -> va=00fbd830 create=MH_OK enable=MH_OK +09:42:14.852 [tid 19172] drawsite: Mars::RNG::NextInt rva=0x000271c0 -> va=00f671c0 create=MH_OK enable=MH_OK +09:42:14.867 [tid 19172] drawsite: Mars::RNG::Chance rva=0x004e6dd0 -> va=01426dd0 create=MH_OK enable=MH_OK +09:42:14.883 [tid 19172] drawsite: Mars::RNG::NextUInt rva=0x000f7670 -> va=01037670 create=MH_OK enable=MH_OK +09:42:14.916 [tid 19172] drawsite: Mars::RNG::FloatRange rva=0x0007d8a0 -> va=00fbd8a0 create=MH_OK enable=MH_OK +09:42:14.945 [tid 19172] drawsite: Mars::RNG::IntRangeBell rva=0x004e6d80 -> va=01426d80 create=MH_OK enable=MH_OK +09:42:14.961 [tid 19172] drawsite: Mars::RNG::GaussianRange rva=0x004e6e30 -> va=01426e30 create=MH_OK enable=MH_OK +09:42:14.961 [tid 19172] probe: installing 12 of 12 (probes= in shim.cfg) +09:42:14.992 [tid 19172] probe: Game::ServerSpyManager::vslot13 rva=0x004877b0 -> va=013c77b0 create=MH_OK enable=MH_OK +09:42:15.008 [tid 19172] probe: Game::ServerSpyManager::vslot14 rva=0x0048db80 -> va=013cdb80 create=MH_OK enable=MH_OK +09:42:15.039 [tid 19172] probe: Game::ServerTradeManagerImpl::vslot13 rva=0x0048ef80 -> va=013cef80 create=MH_OK enable=MH_OK +09:42:15.070 [tid 19172] probe: Game::ServerTradeManagerImpl::vslot15 rva=0x0042cca0 -> va=0136cca0 create=MH_OK enable=MH_OK +09:42:15.086 [tid 19172] probe: Game::SpyManager::Slot13RngCallee rva=0x004408e0 -> va=013808e0 create=MH_OK enable=MH_OK +09:42:15.118 [tid 19172] probe: Game::TradeManager::Slot13RngCalleeA rva=0x00420ca0 -> va=01360ca0 create=MH_OK enable=MH_OK +09:42:15.133 [tid 19172] probe: Game::TradeManager::Slot13RngCalleeB rva=0x0048b440 -> va=013cb440 create=MH_OK enable=MH_OK +09:42:15.148 [tid 19172] probe: Game::ServerTradeManager::CreateRaidEncounter rva=0x004938a0 -> va=013d38a0 create=MH_OK enable=MH_OK +09:42:15.180 [tid 19172] probe: Game::ServerTradeManager::GenerateTradeRaidEncounters rva=0x00493290 -> va=013d3290 create=MH_OK enable=MH_OK +09:42:15.211 [tid 19172] probe: Game::ServerSpyManager::vslot15 [control] rva=0x00487f30 -> va=013c7f30 create=MH_OK enable=MH_OK +09:42:15.226 [tid 19172] probe: Game::ServerTradeManagerImpl::vslot14 [control] rva=0x004590d0 -> va=013990d0 create=MH_OK enable=MH_OK +09:42:15.258 [tid 19172] probe: Game::EncounterDetect::Run [control] rva=0x003cb080 -> va=0130b080 create=MH_OK enable=MH_OK +09:42:15.258 [tid 19172] watch: disabled (watch=off) +09:42:15.258 [tid 19172] aiorders: disabled (aiorders=off) +09:42:15.258 [tid 19172] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=off value=0x0000 sample_ticks=on +09:42:15.258 [tid 19172] fpu: sample_turn=on (off releases StrategyServer::ProcessTurn for another hook) +09:42:15.258 [tid 19172] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=012c3be0 MH_CreateHook -> MH_OK (trampoline=00cd0d40) +09:42:15.273 [tid 19172] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK +09:42:15.273 [tid 19172] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=013198e0 MH_CreateHook -> MH_OK (trampoline=00cd0d20) +09:42:15.305 [tid 19172] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK +09:42:15.305 [tid 19172] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0131c6c0 MH_CreateHook -> MH_OK (trampoline=00cd0d00) +09:42:15.320 [tid 19172] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK +09:42:15.320 [tid 19172] fpu: DemoApp::OnTick rva=0x0049a640 -> va=013da640 MH_CreateHook -> MH_OK (trampoline=00cd0ce0) +09:42:15.351 [tid 19172] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK +09:42:15.351 [tid 19172] selftest: Shim::SelfTest::Fill mode=trace checksum=075ef0c3 records=1 +09:42:15.367 [tid 19172] Application::Initialize called (this=03548128) +09:42:16.336 [tid 19172] fpu: TICK BASELINE at OnTick (this=03548128): cw=0x127f 53bit-double/nearest +09:48:17.041 [tid 19172] fpu: sample at StrategyClient::EndTurn (this=0d6dd1e0): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:48:20.150 [tid 19172] fpu: sample at StrategyClient::EndTurn (this=32e7aed0): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:48:20.181 [tid 19172] fpu: sample at StrategyClient::EndTurn (this=32e7f7a0): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:48:20.228 [tid 19172] fpu: sample at StrategyClient::EndTurn (this=32e7a040): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:48:21.134 [tid 19172] fpu: sample at StrategyServer::BeginProcessTurn (this=0d66ade8): cw=0x127f 53bit-double/nearest [no fpu.force configured] +09:48:21.134 [tid 19172] fpu: sample at StrategyServer::ProcessTurn (this=0d66ade8): cw=0x127f 53bit-double/nearest +09:48:21.150 [tid 19172] research: mode=replace steps=1 completions=1 overbudget=1166 cascade_possible=1 ok=1 cascade_completions=1 unlocked=3 otch_appends=1 roll_draws=0 failures=0 depth=0 name_unreadable=0 +09:48:21.181 [tid 19172] research: mode=replace steps=1 completions=0 overbudget=0 cascade_possible=1 ok=1 cascade_completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0 +09:48:21.197 [tid 19172] research: mode=replace steps=1 completions=0 overbudget=0 cascade_possible=1 ok=1 cascade_completions=0 unlocked=0 otch_appends=0 roll_draws=0 failures=0 depth=0 name_unreadable=0 diff --git a/verify/traces/cr-N.jsonl.gz b/verify/traces/cr-N.jsonl.gz new file mode 100644 index 0000000..6bea771 Binary files /dev/null and b/verify/traces/cr-N.jsonl.gz differ diff --git a/verify/traces/cr-R0.jsonl.gz b/verify/traces/cr-R0.jsonl.gz new file mode 100644 index 0000000..61f7bb5 Binary files /dev/null and b/verify/traces/cr-R0.jsonl.gz differ diff --git a/verify/traces/cr-R1.jsonl.gz b/verify/traces/cr-R1.jsonl.gz new file mode 100644 index 0000000..c262761 Binary files /dev/null and b/verify/traces/cr-R1.jsonl.gz differ