sots-engine/docs/P-events-wiring.md
lane P f3c6d551de lane P: post the research events from ours (count-only), declare ObservedTech
TechTree::ProcessResearch's events region now compares a modelled value instead of
reporting a known defect. ours posts the pass's events into its own
sots::events::EventStorage, seeded from a scan of the owner's list taken BEFORE the
original runs, and writes only the counts into the region's scratch copy. The game's
PostEvent is never called and no live byte moves; replace mode still posts nothing,
because a bumped EvNxID with no record behind it would corrupt the oracle's save.

- game/events: PostResearchPassEvents (the decision half, pure) + KeylessEventText
- shim/hooks/event_inputs (new lib shim_events, host-tested): the live<->model adapter,
  carrying game pointers as explicit uint32 so a 64-bit host build cannot alias them
- research hook: the wiring, a new observed_techs region for ServerPlayer+0x274, and
  turn / events_next_id_in / events_dedup_risk in the args so the count model's own
  assumption is measured rather than assumed
- EVENT_TECHS_UNLOCKED is NOT posted: its trigger is pinned but needs SetResearched's
  unlock cascade, which ours does not run. The driver takes the unlock list as an input
  and is handed 'no list', so a missing input cannot look like a modelled negative.
  Predicted residual: next_id short by exactly 1 on a completion call.

ctest 33/33 (shim_events_unit is new), clean_room_check OK. The shim TU is
syntax-checked only: no MinGW cross toolchain on this box.

See docs/P-events-wiring.md for the exact prediction for the next VM run.
2026-09-08 04:16:22 -04:00

241 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<true>`). 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<ObservedTech*>` 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.