# P — wiring the event posts into `ours` Lane E recovered the event API and landed `src/game/events/` as a pure module. Lane R's guarded recapture then made the missing post precise: `TechTree::ProcessResearch` exits 1 with exactly one divergent field, ``` side.events.after.v.next_id [exact] orig={"t":"i32","v":4} ours={"t":"i32","v":3} ``` on a call where the arithmetic reproduces perfectly (`node[144].progress` 2879→5768, `flag` 1→2, the single RNG draw identical). This lane closes that field. It takes lane E's **option (a), count-only**. The game's `EventStorage::PostEvent` is never called and no live byte is written; `ours` posts into its own `sots::events::EventStorage` and writes the resulting counts into the **scratch copy** of the owner's storage header. That is what makes it safe to put near the VM, and it is also the honest limit of what it proves. --- ## 1. What changed | file | change | |---|---| | `src/game/events/research_events.{h,cpp}` | `PostResearchPassEvents` — the events one `ProcessResearch` call raises, as a pure function of the per-node outcomes; `KeylessEventText()` for a caller with no string table | | `src/shim/hooks/event_inputs.{h,cpp}` (new lib `shim_events`) | the live↔model adapter: scan a `Game::EventStorage` before the original runs, seed the model from it, write the counts back into a scratch header | | `src/shim/hooks/research.{h,cpp}` | the wiring, the `observed_techs` region, the new args, the rewritten `Coverage` | | `tests/game_events/test_events.cpp` | 6 new cases on the pass driver (the three call shapes lane R captured, plus dedup and the keyless lookup) | | `tests/shim_events/unit_tests.cpp` (new) | 10 cases on the adapter, driven on a byte buffer laid out with the real offsets | | `include/generated/sots_addresses.h` | regenerated from `sots-re ghidra/addresses.json` (386 → 396 entries) | `tools/clean_room_check.sh` — **OK**. Host `ctest` — **33/33 passed** (was 32/32; `shim_events_unit` is new). Both run as separate commands. The shim TU itself is syntax-checked only (`g++ -std=c++17 -Wall -Wextra -fsyntax-only`, clean): there is no MinGW i686 cross toolchain on this box, so `tools/build-shim.sh` still has to run on CT111 before any deploy. ### New binary facts (via `addresses.json` → `tools/gen_addresses.py`, never hand-edited) Read out of `TechTree::SetResearched` (0x00581e10) by this lane, plus one from lane R's guard: `ServerPlayer_off_ObservedTechs` (0x274) · `TechNode_off_TurnAvailable` (0x20) · `TechNode_off_TurnResearched` (0x24) · `TechNode_off_Order` (0x28) · `TechNode_off_Children` (0x04) · `TechEdge_off_CostRP` (0x1c) · `TechEdge_off_ChildDef` (0x40) · `TechTree_off_OrderCounter` (0x20) · `TechTree_SetResearched_flag_Force` (0x2) · `TechTree_SetResearched_flag_Silent` (0x4) The last one settles a question the count depends on. `SetResearched` invokes the owner callback as `vft+0x10(def, (flags >> 2) & 1)`, so bit 2 of its `flags` argument **is** `OnTechResearched`'s `silent`. `ProcessResearch` calls `SetResearched(def, 2)`, so `silent` is false and the completion event *is* posted. That was previously an inference from an event count; it is now read off the instruction stream. --- ## 2. How the count model works, and why each piece is there ### The scan is taken *before* the original In compare mode `ours` runs **after** the original (`hook.h`, `run`). A scan of the event list taken inside `ours` would see the original's own freshly posted records — and the model would deduplicate against them, post nothing, and agree for exactly the wrong reason. So `ScanEventStorage` runs in `describe_args`, which `hook.h` calls immediately before `regions()` and before the original. ### Ours posts into its own storage, seeded from the scan `SeedFromScan` rebuilds the **turn buckets** (their `EvTurn` values, in order) and `EvNxID`, and leaves the buckets **empty**. Empty because the records' `EvDsc`/`EvMsg` are localized game text, which the engine must not carry. That has one consequence, and it is measured rather than assumed. A post can only be deduplicated against a record already in the same turn bucket, and only a record with the same `EvImg` can ever match. So the scan counts the events in the current turn's bucket that carry one of the six research `EvImg` identifiers and reports it as the argument **`events_dedup_risk`**: * `events_dedup_risk = 0` → nothing in the bucket can collide with anything we post, and the count-only model is **exact** for that call. * anything else → our id count is a lower bound, and the report says so instead of the reader having to guess. On the reference workload it should be 0 on every call (see §4). ### The text stand-in preserves exactly the property dedup needs `KeylessEventText()` resolves every string-table key to the bare format `"%s"`, and the hook passes the node's own index as the substitution. So `EvMsg` is the node index and nothing else. That is not the game's text and must never be shown or serialized — but it preserves the one property the comparator cares about: * two different nodes going over budget in the same turn get different `EvMsg` → both are kept, which is what the original does (their real messages differ by the tech name); * the same node twice collapses to one id, which is also what the original does; * an over-budget and a completion for the same node share `EvMsg` but differ in `EvImg`, which the comparator also tests → both are kept. `tests/game_events` pins all three through this lookup. ### Only compare mode writes `WriteBackCounts` is called **only when `compare` is set**. In replace mode the region pointer is live game memory, and bumping `EvNxID` there without a serialized record behind it would corrupt the very save the oracle hashes — strictly worse than the missing event. Replace mode therefore still posts nothing, and the `Coverage` note says so. --- ## 3. What is verified, what is modelled by analogy, and what is not modelled **Verified from the instruction stream** (lane E's read, plus this lane's read of 0x00581e10): * `EVENT_RESEARCH_OVERBUDGET` fires iff the completion roll failed **and** `!wasDone && nowDone`. `sim::ResearchStepResult::overbudgetEvent` already computes exactly that, in the same branch that sets `node.flag = 2` — the flag lane R confirmed on the game. * One completion event fires on every completion reached from `ProcessResearch`, because `silent = (flags >> 2) & 1` and `ProcessResearch` passes `flags = 2`. * The posting rules themselves: dedup keys, `act == 0 && !obj && !pos → 2`, the `FLT_MAX` default, `EvNxID` starting at 0, the last-match bucket lookup, the prune off-by-one. All of lane E's, all unit-tested. **Modelled by analogy, and count-neutral:** which of `EVENT_RESEARCH_COMPLETE` / `_UNDERBUDGET` is posted. The original splits on `TechTree::GetProgressRatio(tree, def)` against `(double)0.8f`; `ours` splits on `ResearchStepResult::completedEarly`, which is `progress/cost` against the same constant, computed by `ProcessResearch` itself two instructions earlier for the `flag = 0` decision. They are very probably the same number, but this lane did not read `GetProgressRatio` (0x0057e950). **It does not affect `next_id`**: both branches post exactly one event. It would affect a text or `EvImg` comparison, and no region has one. **Not modelled — flagged, not guessed:** * **`EVENT_TECHS_UNLOCKED`.** Its trigger *is* pinned: `SetResearched`'s second sweep sets `state = 2` and stamps `turnAvailable` (only when it reads −1), and the tail loop at 0x00587ff4 collects `state == 2 && turnAvailable == currentTurn`. But evaluating it needs the child-unlock cascade, which `ours` deliberately does not run — B3 declared it out of scope and its writes land on live objects compare mode must not touch. `PostResearchPassEvents` therefore takes the unlock list as an **input** and is handed `nullptr` (*"no list"*), which is deliberately distinct from an empty list (*"computed, and empty"*). **Predicted residual: `next_id` short by exactly 1 on every call that completes a tech.** Guessing the trigger — "post it whenever something completed" — would score on this save and be wrong the first time a completion unlocks nothing. * **`EVENT_TEMPERANCE`.** Posted from the same `OnTechResearched` call, but only when the completed tech is a temperance tech *and* the per-species sweep actually cured at least one addicted system. Neither input is available here, and no save in `verify/` has addiction, so this has never been observed firing. The helper exists and is unit-tested; it is not driven. * **`EVENT_NO_RESEARCH`.** Posted from `ServerPlayer::ProcessTurn` (0x0089168c), not from this hook. Out of scope by call site, not by omission. * **The text.** `EvDsc`/`EvMsg` come from the game's string table. No region can see them, and none pretends to. --- ## 4. What the next VM run should show This is a prediction, not a fishing trip. Run the existing `shim.cfg.recapb3` recipe (`ref-turn2.sav` → Launch → End Turn) unchanged, then the five-End-Turn continuation. ### 4.1 The first End Turn — the headline **`Game::TechTree::ProcessResearch`: 3 calls, 3 compared, 0 divergent, `tracecmp` exit 0.** | call | alloc | `events.next_id` orig | ours before this lane | ours now | |---|---|---|---|---| | **0** | `{tech 144, 2889}`, species 2 | 3 → **4** | 3 ✗ | 3 → **4** ✓ | | 1 | `{90, 0}` | 0 → 0 | 0 ✓ | 0 ✓ | | 2 | `{9, 0}` | 0 → 0 | 0 ✓ | 0 ✓ | Also on call 0, and these are the fields that say the model is right for the right reason: * `turn` = **3**. If it is not 3, nothing below is trustworthy: the whole post lands in the wrong bucket. (`ModCount` at `*(int*)(*(char**)(player+8) + 8)`.) * `events_turn_bucket_exists` = **true** — the turn-3 bucket already holds `EVENT_SHIPS_BUILT`. This is what keeps `events.turns` at 2 and `turns_bytes` at 0x30 on both sides. If it comes back false, our post creates a bucket the original did not and `turns_bytes` diverges by 0x18 — which would mean `turn` is wrong, not that the model is. * `events_next_id_in` = **3**, `events_in_turn_bucket` = **1**, `events_dedup_risk` = **0**. * `events_scan_truncated` must be **absent**. Its presence means a vector header did not parse and every event number in the record is unreliable. * `node[144].progress` 2879 → 5768 and `flag` 1 → 2 on both sides, `rng` identical — unchanged from lane R's capture. If any of those regressed, the events wiring broke something it should not have touched. * `observed_techs.bytes` **unchanged on all three calls**: turn 1 of `ref-turn2` contains no tech completion, so nothing appends. Calls 1 and 2 are other players with empty event lists (`EvNxID = 0`). `WriteBackCounts` must leave their scratch header byte-identical — the "no-op" case is unit-tested. ### 4.2 The five-turn continuation — expected to still diverge, by a known amount Lane R warns that anything past the first End Turn is *a* run, not *the* run (from turn 4 the AI picks a different target). Treat the call ids as indicative and the **shape** as the prediction: | call | what happens | orig `next_id` | ours now | residual | |---|---|---|---|---| | 0 | over budget | 4 | **4** | none | | 3 | completion | 7 | **6** | 1 = `EVENT_TECHS_UNLOCKED` | | 6 | rolled, failed, no cost crossing | 8 | 8 | none | | 9 | completion | 12 | **11** | 1 = `EVENT_TECHS_UNLOCKED` | | 12 | rolled, failed | 14 | 14 | none | | all others | zero-spend | 0 | 0 | none | So `tracecmp` should still exit 1 on the five-turn run, with **exactly two divergent calls instead of three**, and both remaining divergences off by exactly 1 in `next_id`. Any other number is new information: off by 2 means the completion event is not firing either; off by −1 means we posted something the original did not. ### 4.3 What the guard should stop saying `observed_techs` is now a **Result** region, so its bytes are excluded from the `player` guard's undeclared-write scan. Expect the guard's span count on the two completion calls to drop by the `player+0x274/0x278/0x27c` entries (lane R saw 3 spans on call 3 and 1 on call 9) and those offsets to appear in the diff instead. That is the conversion, not a regression. ### 4.4 The measurement to take while you are there On a completion call, `observed_techs.bytes` grows by exactly one element. **That delta is `sizeof(ObservedTech)`,** which nothing in the project has pinned. On disk the element is `{int otnF; int otnL; int odet; string otch; int owith}` (confirmed in `turn3-state.sav` — the `otch` string holds the tech's name, e.g. `WEP_RedLas`), so a by-value element should measure 0x2c or 0x30 and a `vector` would measure 4. Write the answer into `addresses.json`; the entry there currently says explicitly that it is not pinned. --- ## 5. Open * `sizeof(ObservedTech)` and its **append call site**. It is somewhere under the `OnTechResearched` callback (both guards see it, which only bounds it to that subtree); it is not in `OnTechResearched`'s own decompilation, and a constant search for `0x274` finds only unrelated objects, so it is reached through a `lea` displacement in a callee. §4.4 measures the stride without finding the site. * `TechTree::GetProgressRatio` (0x0057e950) — the COMPLETE/UNDERBUDGET split is analogy until it is read. * The unlock cascade. It is now fully decompiled (see the sots-re note), so `EVENT_TECHS_UNLOCKED` is implementable — but it belongs to whichever lane takes `SetResearched`, because modelling it means `ours` starts writing child `state` / `turn_available` / `cost_rp`, which B3 declared out of scope. * `EventStorage::PruneOldTurns` still has never run: the reference saves are at turn ≤ 7 and the window is 50. The model reproduces the off-by-one and the unit tests pin it; nothing has measured it. * Option (b) — delegating to the game's own `PostEvent` so the text and the save hash match — is untouched and remains the only path that can prove `EvDsc`/`EvMsg`. It changes VM behaviour and is deliberately not wired here.