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.
14 KiB
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
EvMsgbut differ inEvImg, 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_OVERBUDGETfires iff the completion roll failed and!wasDone && nowDone.sim::ResearchStepResult::overbudgetEventalready computes exactly that, in the same branch that setsnode.flag = 2— the flag lane R confirmed on the game.- One completion event fires on every completion reached from
ProcessResearch, becausesilent = (flags >> 2) & 1andProcessResearchpassesflags = 2. - The posting rules themselves: dedup keys,
act == 0 && !obj && !pos → 2, theFLT_MAXdefault,EvNxIDstarting 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 setsstate = 2and stampsturnAvailable(only when it reads −1), and the tail loop at 0x00587ff4 collectsstate == 2 && turnAvailable == currentTurn. But evaluating it needs the child-unlock cascade, whichoursdeliberately does not run — B3 declared it out of scope and its writes land on live objects compare mode must not touch.PostResearchPassEventstherefore takes the unlock list as an input and is handednullptr("no list"), which is deliberately distinct from an empty list ("computed, and empty"). Predicted residual:next_idshort 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 sameOnTechResearchedcall, 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 inverify/has addiction, so this has never been observed firing. The helper exists and is unit-tested; it is not driven.EVENT_NO_RESEARCH. Posted fromServerPlayer::ProcessTurn(0x0089168c), not from this hook. Out of scope by call site, not by omission.- The text.
EvDsc/EvMsgcome 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. (ModCountat*(int*)(*(char**)(player+8) + 8).)events_turn_bucket_exists= true — the turn-3 bucket already holdsEVENT_SHIPS_BUILT. This is what keepsevents.turnsat 2 andturns_bytesat 0x30 on both sides. If it comes back false, our post creates a bucket the original did not andturns_bytesdiverges by 0x18 — which would meanturnis wrong, not that the model is.events_next_id_in= 3,events_in_turn_bucket= 1,events_dedup_risk= 0.events_scan_truncatedmust be absent. Its presence means a vector header did not parse and every event number in the record is unreliable.node[144].progress2879 → 5768 andflag1 → 2 on both sides,rngidentical — unchanged from lane R's capture. If any of those regressed, the events wiring broke something it should not have touched.observed_techs.bytesunchanged on all three calls: turn 1 ofref-turn2contains 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 theOnTechResearchedcallback (both guards see it, which only bounds it to that subtree); it is not inOnTechResearched's own decompilation, and a constant search for0x274finds only unrelated objects, so it is reached through aleadisplacement 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_UNLOCKEDis implementable — but it belongs to whichever lane takesSetResearched, because modelling it meansoursstarts writing childstate/turn_available/cost_rp, which B3 declared out of scope. EventStorage::PruneOldTurnsstill 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
PostEventso the text and the save hash match — is untouched and remains the only path that can proveEvDsc/EvMsg. It changes VM behaviour and is deliberately not wired here.