# 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::string`s 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.