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

132 lines
7.1 KiB
Markdown

# 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.