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

14 KiB
Raw Blame History

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.