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.
241 lines
14 KiB
Markdown
241 lines
14 KiB
Markdown
# 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.
|