sots-engine/docs/E-events.md
alex a7348be72c game/events: the player event log and the five research events
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.
2026-09-08 02:45:57 -04:00

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 — an EVENT_RESEARCH_OVERBUDGET our 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.

  1. The default position is FLT_MAX, not infinity. The constructor copies a static Vector3 of three 0x7f7fffff words. Writing +inf (0x7f800000) changes the save. findings/subsystems/formula-gaps.md records this as {inf,inf,inf}; it is wrong.
  2. action == 0 with no subject and no position is stored as 2. EVENT_TEMPERANCE is posted with a literal 0 and hits this rule, so the correct stored value is 2.
  3. Posting is deduplicated per turn bucket, on action, location, all three position floats, message and image — but not summary. A duplicate returns the existing id and burns neither an id nor a slot.
  4. EvNxID starts at 0 and is promoted to 1 on the first post, then post-incremented. A player who has never seen an event serializes EvNxID = 0 (two of the four players in turn3-state.sav do).
  5. The prune has an off-by-one and it is load-bearing. Buckets older than turn - 50 are 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:: … (0x00886470 in sots-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.
  • EvCID is constructed 0 and never written by PostEvent. Some other caller must set it; which one is unknown.