Recovers the game's event-posting API so the engine can post events and the
compare harness can see them. Until now the owner's event list was invisible to
every layer: B3's replace-mode oracle failed by exactly one item across 40,300
(an unposted EVENT_RESEARCH_OVERBUDGET) while its compare read clean, and B2's
clean compare bounds the economy fields only.
New pure module src/game/events:
* EventStorage / TurnEvents / PlayerEvent -- the list is bucketed by TURN, not
flat, which the save-editor struct notes had wrong.
* EventStorage::Post reproducing the original's rules, including the four that
change save bytes: the FLT_MAX (not infinity) default position; action 0 with
no subject and no position storing as 2; per-bucket dedup that compares
message/image/location/position/action but NOT summary; and EvNxID starting
at 0 and being promoted to 1 on the first post.
* PruneOldTurns reproduced with its off-by-one: of a leading run of buckets
older than turn-50 it erases n-1, so one stale bucket always survives. The
survivor is serialized, so correcting it would diverge.
* research_events: the five events the research path raises, their EvImg
identifiers and string-table keys, and the 0.8 completion split evaluated
against (double)0.8f rather than the decimal 0.8.
Localized text is deliberately absent: only the EVENTSUM_/EVENTMSG_ keys are
here and the text resolves through a caller-supplied lookup, as the game does.
The four event offsets the B3 hook carried as local literals now come from the
generated header; they are read off instructions rather than inferred from the
save schema.
tests/game_events: 112 checks including a replay of the event list
turn3-state.sav actually holds. ctest 31/31 -> 32/32.
docs/E-events.md carries the proposed region and Coverage wording for the next
B3 recapture.
7.1 KiB
E — the player event log
Lane E's job was to recover the game's event-posting API so the engine can post events and
the compare harness can see them. This is the engine-side half; the RE half (addresses,
instruction-level evidence, save cross-check) is sots-re/findings/subsystems/events.md.
Why
Two behavioural milestones write into the owner's event list and neither modelled it:
- B3 (
TechTree::ProcessResearch) failed its replace-mode oracle by exactly one item across 40,300 — anEVENT_RESEARCH_OVERBUDGETour code never posted. Compare mode was clean because the list was not a declared region. - B2 (
ServerPlayer::OnTechResearched) has the same gap, and no replace-mode oracle behind it at all: its clean compare bounds the economy fields only, and its oracle pass was deliberately run on a turn with no completion.
The harness audit put the event list at the top of its 23-item table for exactly this reason
(docs/harness-audit.md §1 rows 1 and 2).
What landed
src/game/events/ — a new pure module, sots_game_events:
| file | contents |
|---|---|
event_log.h/.cpp |
PlayerEvent / TurnEvents / EventStorage, and EventStorage::Post reproducing the original's posting rules |
research_events.h/.cpp |
the five events the research path raises: their EvImg identifiers, their string-table keys, the 0.8 completion split, and one posting helper each |
tests/game_events/ — 112 checks, green (ctest 31/31 → 32/32).
The four event offsets the B3 hook had been carrying as local literals
(kPlayerEventsOff, kEventStorageSize, kEventsVecOff, kEventsNextIdOff) now come from
the generated header (A::ServerPlayer_off_Events, A::EventStorage_sizeof,
A::EventStorage_off_Events, A::EventStorage_off_EvNxID). They were "recovered from the
save schema, not from an instruction"; they are now read off instructions and travel through
the sanctioned channel.
The five rules that are easy to get wrong
Each of these changes the bytes a save-hash oracle compares.
- The default position is
FLT_MAX, not infinity. The constructor copies a staticVector3of three0x7f7fffffwords. Writing+inf(0x7f800000) changes the save.findings/subsystems/formula-gaps.mdrecords this as{inf,inf,inf}; it is wrong. action == 0with no subject and no position is stored as2.EVENT_TEMPERANCEis posted with a literal0and hits this rule, so the correct stored value is 2.- Posting is deduplicated per turn bucket, on
action,location, all three position floats,messageandimage— but notsummary. A duplicate returns the existing id and burns neither an id nor a slot. EvNxIDstarts at 0 and is promoted to 1 on the first post, then post-incremented. A player who has never seen an event serializesEvNxID = 0(two of the four players inturn3-state.savdo).- The prune has an off-by-one and it is load-bearing. Buckets older than
turn - 50are erased except the last one of the leading stale run, so one stale bucket always survives and a single leading stale bucket is never removed at all. It is reproduced, not corrected — the survivor is serialized.
Also modelled: the completion event's message goes through a 256-byte _snprintf buffer, so
it truncates; the two events posted from ProcessResearch format into a std::string and do
not.
What is deliberately NOT here
The displayed text. EvDsc and EvMsg are localized strings from the shipped string
table; only their keys (EVENTSUM_* / EVENTMSG_*) are in the engine, and the text is
resolved through a caller-supplied lookup, exactly as the game resolves it through its own
slot table. game::data::StringTable already provides the lookup.
EventStorage is also not wired into any hook yet. Posting from ours changes what
compare and replace mode do on the VM, and that is a measured change lane R has to schedule —
see the next section for the exact shape it should take.
Proposed: what the B3 hook should declare next
The events Result region already exists on Game::TechTree::ProcessResearch
(src/shim/hooks/research.cpp, describe_events), reporting turns_bytes, next_id and
the three vector words. That is enough to make the missing post visible, and after the
golden-trace recapture it should show a divergence on exactly the over-budget call. It is not
enough to make it pass: a passing compare needs ours to produce the same next_id, which
means posting.
Two ways to close it, in increasing order of what they prove:
(a) Count-only. ours calls EventStorage::Post on its own sots::events::EventStorage
and the descriptor compares only the resulting next_id delta against the original's. This
proves the decision (did we post, and how many) without touching game memory. It is cheap,
it is safe in compare mode, and it converts the audit's row 1 from "known defect" to
"checked". It cannot prove the text.
(b) Delegate, as M2 delegates to LoadWeapon. In replace mode, ours calls the game's
own EventStorage::PostEvent (A::EventStorage_PostEvent, __thiscall, ret 0x4c) on the
owner's real storage, building the two by-value std::string arguments the way the callers
do. That is the only path that makes the save hash match, because the game composes the text
from its own string table. The prototype is pinned and written back to Ghidra; the awkward
part is constructing two MSVC std::strings by value from MinGW code, which needs the same
raw-frame trick the other by-value call sites use.
Recommended: (a) first, on the next B3 recapture, because it is a strict improvement and carries no risk to the live game; (b) when a replace-mode oracle for B2 is scheduled, since that is the run that actually needs the text.
The Coverage note the descriptor carries today should then change from
c.unmodelled("posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage ...",
Risk::High, "the message text is composed from the tech name", "region:events");
to, under (a):
c.unmodelled("posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces "
"the decision and the id sequence, so region:events compares next_id, but the "
"composed EvDsc/EvMsg text is not reproduced and no region can see it",
Risk::Medium, "text comes from the game's string table", "region:events");
Risk::High drops to Risk::Medium only once the count is actually compared; it stays
High until then.
Open
- The convenience wrapper at
A::… (0x00886470insots-re, not exported here) also posts, twice. Not read; not on the research path. EventStorage's save-side write function was not located, only the read. The field order is identical in both directions and the save confirms it, so this is a coverage gap rather than a confidence gap.- The 50-turn prune has never been observed running — the reference saves are at turn ≤ 3. The unit tests pin the behaviour; nothing has measured it against the game.
EvCIDis constructed 0 and never written byPostEvent. Some other caller must set it; which one is unknown.