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.
132 lines
7.1 KiB
Markdown
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.
|