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.
This commit is contained in:
alex 2026-09-08 02:45:57 -04:00
parent 7584badab3
commit a7348be72c
12 changed files with 1094 additions and 11 deletions

View file

@ -28,6 +28,7 @@ add_subdirectory(src/game/config) # flat KEY/value constants loader (lib sot
add_subdirectory(src/game/data) # typed catalogs on mars/parse+text (lib game_data) add_subdirectory(src/game/data) # typed catalogs on mars/parse+text (lib game_data)
add_subdirectory(src/game/effects) # tech effects (TechId table + apply) (lib game_effects) add_subdirectory(src/game/effects) # tech effects (TechId table + apply) (lib game_effects)
add_subdirectory(src/game/design) # ship-design rules + derived stats (lib game_design) add_subdirectory(src/game/design) # ship-design rules + derived stats (lib game_design)
add_subdirectory(src/game/events) # player event log + research events (lib sots_game_events)
# ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ---- # ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ----
add_library(shim_trace STATIC add_library(shim_trace STATIC
@ -95,7 +96,7 @@ else()
add_executable(addr_smoke tests/addr_smoke.cpp) add_executable(addr_smoke tests/addr_smoke.cpp)
target_link_libraries(addr_smoke PRIVATE sots_addresses) target_link_libraries(addr_smoke PRIVATE sots_addresses)
add_test(NAME addr_smoke COMMAND addr_smoke) add_test(NAME addr_smoke COMMAND addr_smoke)
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects shim_budget shim_techfx shim_colony shim_movement) foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events shim_budget shim_techfx shim_colony shim_movement)
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t}) add_subdirectory(tests/${_t})
endif() endif()

132
docs/E-events.md Normal file
View file

@ -0,0 +1,132 @@
# 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.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ ff67ec0, generated 2026-09-08 by tools/gen_addresses.py // Source: sots-re ghidra/addresses.json @ 84d5609, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once #pragma once
#include <cstdint> #include <cstdint>
@ -655,5 +655,117 @@ constexpr uint32_t g_ptr_STUTTER_SYSTEM_INFLUENCE_RADIUS = 0x006ebc44;
constexpr uint32_t g_ptr_STUTTER_MIN_SPEED = 0x006ebc48; constexpr uint32_t g_ptr_STUTTER_MIN_SPEED = 0x006ebc48;
// data float** -- pointer slot; storage 0x00b212d4 (shipped value 0.33). NOTE min == max in the shipped data, so the stutter ramp collapses to a constant 0.33x inside any influence sphere [verified] // data float** -- pointer slot; storage 0x00b212d4 (shipped value 0.33). NOTE min == max in the shipped data, so the stutter ramp collapses to a constant 0.33x inside any influence sphere [verified]
constexpr uint32_t g_ptr_STUTTER_MAX_SPEED = 0x006ebc4c; constexpr uint32_t g_ptr_STUTTER_MAX_SPEED = 0x006ebc4c;
// thiscall EventStorage* (ServerPlayer* this) /* whole body: lea eax,[ecx+0x29c]; ret. No stack args, plain RET */ [verified]
constexpr uint32_t ServerPlayer_GetEventStorage = 0x0040db00;
// thiscall void* (ServerPlayer* this) /* eax = [this+8] ? [this+8]-4 : 0. StrategyServer primary base; turn = *(int*)(result+0x0c) */ [verified]
constexpr uint32_t ServerPlayer_GetServer = 0x0040e320;
// thiscall int (EventStorage* this, std::string summary /*BY VALUE 0x1c -> EvDsc*/, std::string message /*BY VALUE 0x1c -> EvMsg*/, void* obj, Vector3* pos, int turn, const char* img, int act) RET 0x4c. Returns the event id (a duplicate's id if one already exists in the turn bucket). Callee frees both by-value string buffers. img==NULL -> "". act==0 && obj==NULL && pos==NULL -> stored EvAct becomes 2. EvLoc = obj ? obj[+4] : 0; EvPos = obj ? obj[+0x18..0x20] : pos ? *pos : FLT_MAX triple. 161 call sites in 110 functions: this is the whole simulation's event API [verified]
constexpr uint32_t EventStorage_PostEvent = 0x004862b0;
// thiscall TurnEvents* (EventStorage* this, int turn) RET 4. Linear scan with NO early exit, so it returns the LAST bucket whose EvTurn == turn; otherwise appends a new bucket (ctor 0x00884cb0, vtable 0x00a0f07c) and sets its EvTurn [verified]
constexpr uint32_t EventStorage_GetOrCreateTurnBucket = 0x00485380;
// thiscall PlayerEvent* (EventStorage* this, TurnEvents* bucket, PlayerEvent* candidate) RET 8. NULL bucket -> 0. Match requires EvAct, EvLoc, all three EvPos floats (fucompp), EvMsg and EvImg to be equal. EvDsc is NOT compared [verified]
constexpr uint32_t EventStorage_FindDuplicate = 0x00425d40;
// thiscall void (EventStorage* this, int turn) RET 4. Cutoff = turn - 0x32 (50), a code constant. Shifts from the LAST bucket of the leading run with EvTurn < cutoff, so it erases n-1 of n leading stale buckets: one stale bucket always survives and a single leading stale bucket is never removed [verified]
constexpr uint32_t EventStorage_PruneOldTurns = 0x00479eb0;
// thiscall void (EventStorage* this, IStreamable* s) RET 4. Order: EvNxID (int, tag 0x00a2bd90), then nested collection "Events" (tag 0x00a2bda0, descriptor vtable 0x00a2da8c) over this+4 [verified]
constexpr uint32_t EventStorage_Read = 0x00425cc0;
// thiscall void (TurnEvents* this, IStreamable* s) RET 4. Order: EvTurn (int, tag 0x00a2bd98), then nested collection "Events" (descriptor vtable 0x00a2da7c) over this+8 [verified]
constexpr uint32_t TurnEvents_Write = 0x00425bb0;
// thiscall void (TurnEvents* this, IStreamable* s) RET 4. Same field order as TurnEvents_Write [verified]
constexpr uint32_t TurnEvents_Read = 0x00425c40;
// thiscall PlayerEvent* (PlayerEvent* this) RET 0. vptr=0x00a21958; EvEID=EvLoc=EvAct=EvCID=0; the three std::strings = "" (0x009e100c); EvPos = the Vector3 global at 0x00af0dc8 = {FLT_MAX, FLT_MAX, FLT_MAX} (0x7f7fffff x3, NOT infinity) [verified]
constexpr uint32_t PlayerEvent_ctor = 0x0044ee30;
// thiscall void (PlayerEvent* this, IStreamable* s) RET 4. vftable slot 1. Field order on the wire: EvEID(+4) EvDsc(+8) EvMsg(+0x24) EvImg(+0x50) EvLoc(+0x40) EvPos(+0x44) EvAct(+0x6c) EvCID(+0x70) [verified]
constexpr uint32_t PlayerEvent_Serialize = 0x00425970;
// thiscall call site: EventStorage::PostEvent for EVENT_RESEARCH_OVERBUDGET. Guard at 0x005879e9: !wasDone && nowDone && owner, inside the completion-roll-FAILED branch (chance < draw). Sets TechNode.flag(+0x2c)=2 at 0x00587ba3. EvAct=1, obj=NULL, pos=NULL [verified]
constexpr uint32_t ProcessResearch_PostEventOverbudget = 0x00187b97;
// thiscall call site: EventStorage::PostEvent for EVENT_TECHS_UNLOCKED, once after the per-node loop, if any node has state==2 and turnAvailable==currentTurn. EvAct=1, obj=NULL, pos=NULL [verified]
constexpr uint32_t ProcessResearch_PostEventTechsUnlocked = 0x00187ff4;
// thiscall call site: EventStorage::PostEvent for EVENT_RESEARCH_COMPLETE / _UNDERBUDGET. Guarded by !silent ([ebp+0xc]==0). Message is _snprintf'd (0x008c8eb0) into a 0x100-byte buffer, so >255 chars truncate. EvAct=1 [verified]
constexpr uint32_t OnTechResearched_PostEventComplete = 0x004919b5;
// thiscall call site: EventStorage::PostEvent for EVENT_TEMPERANCE. Guarded by !silent AND by the 'a system was cured' local at [ebp-0x189]. Pushed act=0 with obj=pos=NULL, so the STORED EvAct is 2 [verified]
constexpr uint32_t OnTechResearched_PostEventTemperance = 0x00492427;
// thiscall call site: EventStorage::PostEvent for EVENT_NO_RESEARCH. Condition at 0x0089162a: ResT(+0x294)==NULL && ListAvailableTechs(0x00584e50, turn, INT_MAX, 1) returned empty && TechTree 0x0057da90 != 0. EvAct=1 [verified]
constexpr uint32_t ServerPlayer_ProcessTurn_PostEventNoResearch = 0x0049168c;
// data const char* "Research completed at %d of %d (%.1f%%). (Odds: %.2f, Roll: %.2f)\n" -- log line on the completion-roll-SUCCEEDED branch, 0x00587977 [verified]
constexpr uint32_t TechTree_ProcessResearch_LogFormat = 0x006007a8;
// offset EventStorage ServerPlayer::Events -- embedded, size 0x1c. Confirmed by ServerPlayer_GetEventStorage [verified]
constexpr uint32_t ServerPlayer_off_Events = 0x0000029c;
// offset std::vector<TurnEvents> _Myfirst (element stride 0x18; _Mylast +0x08, _Myend +0x0c, _Alval +0x10) [verified]
constexpr uint32_t EventStorage_off_Events = 0x00000004;
// offset int EvNxID -- next event id. Starts at 0; PostEvent promotes 0->1 on the first post, then post-increments. ServerPlayer+0x2b0 [verified]
constexpr uint32_t EventStorage_off_EvNxID = 0x00000014;
// offset sizeof(EventStorage) [verified]
constexpr uint32_t EventStorage_sizeof = 0x0000001c;
// offset int EvTurn (after the vptr at +0) [verified]
constexpr uint32_t TurnEvents_off_EvTurn = 0x00000004;
// offset std::vector<PlayerEvent> _Myfirst (stride 0x74; _Mylast +0x0c, _Myend +0x10, _Alval +0x14) [verified]
constexpr uint32_t TurnEvents_off_Events = 0x00000008;
// offset sizeof(TurnEvents); the outer vector's stride, from the /24 divide at 0x008853b3 [verified]
constexpr uint32_t TurnEvents_sizeof = 0x00000018;
// offset int EvEID (after the vptr at +0) [verified]
constexpr uint32_t PlayerEvent_off_EvEID = 0x00000004;
// offset std::string EvDsc (summary / title), 0x1c bytes [verified]
constexpr uint32_t PlayerEvent_off_EvDsc = 0x00000008;
// offset std::string EvMsg (body), 0x1c bytes [verified]
constexpr uint32_t PlayerEvent_off_EvMsg = 0x00000024;
// offset int EvLoc (object id, or 0) [verified]
constexpr uint32_t PlayerEvent_off_EvLoc = 0x00000040;
// offset float[3] EvPos (default FLT_MAX x3) [verified]
constexpr uint32_t PlayerEvent_off_EvPos = 0x00000044;
// offset std::string EvImg (event-type name, e.g. "EVENT_RESEARCH_OVERBUDGET"), 0x1c bytes [verified]
constexpr uint32_t PlayerEvent_off_EvImg = 0x00000050;
// offset int EvAct [verified]
constexpr uint32_t PlayerEvent_off_EvAct = 0x0000006c;
// offset int EvCID -- ctor sets 0 and PostEvent never writes it [verified]
constexpr uint32_t PlayerEvent_off_EvCID = 0x00000070;
// offset sizeof(PlayerEvent) = 116. Two independent confirmations: the /116 divide at 0x00825d5f and PostEvent's 'mov [_Mylast-0x70], id' writing EvEID at element+4 [verified]
constexpr uint32_t PlayerEvent_sizeof = 0x00000074;
// offset prune cutoff = turn - 50; a code constant at 0x00879ec3, not config [verified]
constexpr uint32_t EventStorage_PruneWindowTurns = 0x00000032;
// data void** -- PlayerEvent vftable. slot0 dtor 0x007694d0, slot1 Serialize 0x00825970, slot2 0x00825ab0. RTTI locator 0x00a80a7c [verified]
constexpr uint32_t g_vft_PlayerEvent = 0x00621958;
// data void** -- TurnEvents vftable (bucket ctor 0x00884cb0); slot0 is the virtual dtor the pruner calls [verified]
constexpr uint32_t g_vft_TurnEvents = 0x0060f07c;
// data const char[12][8] -- stride 8: EvEID EvNxID EvTurn Events EvPos EvLoc EvMsg EvImg EvDsc EvCID EvAct sasc [verified]
constexpr uint32_t g_EventTagTable = 0x0062bd88;
// data float[3] = {0x7f7fffff, 0x7f7fffff, 0x7f7fffff} = FLT_MAX. PlayerEvent's default EvPos. NOT infinity [verified]
constexpr uint32_t g_Vector3_Invalid = 0x006f0dc8;
// data const char* "EVENT_RESEARCH_OVERBUDGET" -- EvImg pushed at 0x00587b24 [verified]
constexpr uint32_t g_str_EVENT_RESEARCH_OVERBUDGET = 0x0060078c;
// data const char* "EVENT_RESEARCH_COMPLETE" -- EvImg for progress/cost >= 0.8 [verified]
constexpr uint32_t g_str_EVENT_RESEARCH_COMPLETE = 0x00633368;
// data const char* "EVENT_RESEARCH_UNDERBUDGET" -- EvImg for progress/cost < 0.8 [verified]
constexpr uint32_t g_str_EVENT_RESEARCH_UNDERBUDGET = 0x00633380;
// data const char* "EVENT_TEMPERANCE" -- EvImg at 0x008923ae; posted with act=0 so the stored EvAct is 2 [verified]
constexpr uint32_t g_str_EVENT_TEMPERANCE = 0x00633340;
// data const char* "EVENT_TECHS_UNLOCKED" (length 0x14 pushed at 0x00587eb6) [verified]
constexpr uint32_t g_str_EVENT_TECHS_UNLOCKED = 0x00600774;
// data const char* "EVENT_NO_RESEARCH" -- EvImg at 0x0089168c [verified]
constexpr uint32_t g_str_EVENT_NO_RESEARCH = 0x0063332c;
// data const char** -> slot 0x00ae48e0; Strings.csv key EVENTSUM_RESEARCH_OVERBUDGET = "Research Over Budget" [verified]
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_OVERBUDGET = 0x006e48e4;
// data const char** -> slot 0x00ae48e8; key EVENTMSG_RESEARCH_OVERBUDGET = "Research for %s has gone overbudget." [verified]
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_OVERBUDGET = 0x006e48ec;
// data const char** -> slot 0x00ae48f0; key EVENTSUM_UNLOCKEDTECHS = "New Technologies Available" [verified]
constexpr uint32_t g_ptr_EVENTSUM_UNLOCKEDTECHS = 0x006e48f4;
// data const char** -> slot 0x00ae48f8; key EVENTMSG_UNLOCKEDTECHS [verified]
constexpr uint32_t g_ptr_EVENTMSG_UNLOCKEDTECHS = 0x006e48fc;
// data const char** -> slot 0x00af09e8; key EVENTSUM_RESEARCH_COMPLETE = "Research Complete" [verified]
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_COMPLETE = 0x006f09ec;
// data const char** -> slot 0x00af09f0; key EVENTMSG_RESEARCH_COMPLETE = "Tech %s has been acquired" [verified]
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_COMPLETE = 0x006f09f4;
// data const char** -> slot 0x00af09f8; key EVENTSUM_RESEARCH_UNDERBUDGET = "Research Breakthrough!" [verified]
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_UNDERBUDGET = 0x006f09fc;
// data const char** -> slot 0x00af0a00; key EVENTMSG_RESEARCH_UNDERBUDGET [verified]
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_UNDERBUDGET = 0x006f0a04;
// data const char** -> slot 0x00af0a88; key EVENTSUM_ADDICTION_TEMPERENCE (shipped misspelling) [verified]
constexpr uint32_t g_ptr_EVENTSUM_ADDICTION_TEMPERENCE = 0x006f0a8c;
// data const char** -> slot 0x00af0a90; key EVENTMSG_ADDICTION_TEMPERENCE [verified]
constexpr uint32_t g_ptr_EVENTMSG_ADDICTION_TEMPERENCE = 0x006f0a94;
// data double 0.800000011920929 = (double)0.8f. progress/cost >= this -> EVENT_RESEARCH_COMPLETE, else EVENT_RESEARCH_UNDERBUDGET; the same constant gates TechNode.flag=0 in ProcessResearch [verified]
constexpr uint32_t g_dbl_ResearchUnderbudgetThreshold = 0x005e20c8;
// data double 0.0 -- the research completion draw is NextFloat()*(1.0-this)+this, so it is a plain NextFloat() [verified]
constexpr uint32_t g_dbl_ResearchRollBias = 0x005e1e68;
} // namespace sots::addr } // namespace sots::addr

View file

@ -0,0 +1,11 @@
# Player event log: the per-empire turn-event list every simulation subsystem posts into,
# plus the five events the research path raises. Pure; depends on nothing but the standard
# library, so the shim and the host tests can both link it.
add_library(sots_game_events STATIC
event_log.cpp
research_events.cpp)
target_include_directories(sots_game_events PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
target_compile_features(sots_game_events PUBLIC cxx_std_17)
if(NOT MSVC)
target_compile_options(sots_game_events PRIVATE -Wall -Wextra)
endif()

View file

@ -0,0 +1,89 @@
#include "game/events/event_log.h"
#include <cfloat>
#include <utility>
namespace sots::events {
EventPos InvalidEventPos() {
EventPos p;
p.x = FLT_MAX;
p.y = FLT_MAX;
p.z = FLT_MAX;
return p;
}
TurnEvents& EventStorage::GetOrCreateTurnBucket(int turn) {
// No early exit: the last match wins, as the original's loop does.
TurnEvents* found = nullptr;
for (TurnEvents& b : turns_) {
if (b.turn == turn) found = &b;
}
if (found) return *found;
turns_.push_back(TurnEvents{});
turns_.back().turn = turn;
return turns_.back();
}
const PlayerEvent* EventStorage::FindDuplicate(const TurnEvents& bucket,
const PlayerEvent& candidate) const {
for (const PlayerEvent& e : bucket.events) {
if (e.action != candidate.action) continue;
if (e.location != candidate.location) continue;
if (e.pos != candidate.pos) continue;
if (e.message != candidate.message) continue;
if (e.image != candidate.image) continue;
// summary is deliberately not compared.
return &e;
}
return nullptr;
}
void EventStorage::PruneOldTurns(int turn) {
const int cutoff = turn - kPruneWindowTurns;
if (turns_.empty()) return;
// Index of the last bucket in the LEADING run of stale buckets; turns_.size() means the
// run is empty (the original leaves its cursor on _Mylast in that case).
std::size_t lastStale = turns_.size();
for (std::size_t i = 0; i < turns_.size(); ++i) {
if (turns_[i].turn >= cutoff) break;
lastStale = i;
}
if (lastStale == turns_.size()) return; // nothing stale at the front
if (lastStale == 0) return; // a single leading stale bucket is kept
turns_.erase(turns_.begin(), turns_.begin() + static_cast<std::ptrdiff_t>(lastStale));
}
int EventStorage::Post(std::string summary, std::string message, const EventSubject* subject,
const EventPos* pos, int turn, std::string_view image, int action) {
PlayerEvent ev;
ev.summary = std::move(summary);
ev.message = std::move(message);
ev.location = subject ? subject->id : 0;
ev.image.assign(image);
ev.action = (action == 0 && subject == nullptr && pos == nullptr) ? 2 : action;
if (subject) {
ev.pos = subject->pos;
} else if (pos) {
ev.pos = *pos;
} // else: the constructor's FLT_MAX sentinel
PruneOldTurns(turn);
TurnEvents& bucket = GetOrCreateTurnBucket(turn);
if (const PlayerEvent* dup = FindDuplicate(bucket, ev)) return dup->id;
bucket.events.push_back(std::move(ev));
if (nextId_ == 0) nextId_ = 1;
const int id = nextId_++;
bucket.events.back().id = id;
return id;
}
std::size_t EventStorage::total_events() const {
std::size_t n = 0;
for (const TurnEvents& b : turns_) n += b.events.size();
return n;
}
} // namespace sots::events

141
src/game/events/event_log.h Normal file
View file

@ -0,0 +1,141 @@
// Player event log: the per-empire list of turn events the UI shows and the save stores.
//
// This is the whole simulation's notification API -- one posting function reached from 161
// call sites, from research completion to fleet arrival to bankruptcy. It is modelled here
// because two behavioural milestones (the research pass and the tech-completion callback)
// write into it, and until it exists in the engine every "0 divergences" on those two is
// bounded to the economy fields.
//
// Shape, on disk and in memory:
//
// EventStorage { int nextId; vector<TurnEvents> turns; }
// TurnEvents { int turn; vector<PlayerEvent> events; }
// PlayerEvent { int id; string summary, message; int location;
// float pos[3]; string image; int action; int chainId; }
//
// i.e. the list is bucketed **by turn**, not flat. Field names here map to the on-disk tags
// as: id=EvEID, summary=EvDsc, message=EvMsg, location=EvLoc, pos=EvPos, image=EvImg,
// action=EvAct, chainId=EvCID; nextId=EvNxID, turn=EvTurn.
//
// CONFIDENCE: high. Every rule below was read out of the instruction stream and the record
// layout was cross-checked field by field against three real saves.
// See sots-re findings/subsystems/events.md.
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace sots::events {
// The position a `PlayerEvent` carries when it has no place on the map. The original's
// constructor copies a static Vector3 of three FLT_MAX words (0x7f7fffff), **not** infinity;
// writing +inf here would change the save bytes and break the oracle. CONFIDENCE: high
// (byte-confirmed in the constructor and in every position-less event in a real save).
struct EventPos {
float x = 0;
float y = 0;
float z = 0;
friend bool operator==(const EventPos& a, const EventPos& b) {
// The original compares with `fucompp`, i.e. IEEE equality: a NaN coordinate never
// matches itself, and the FLT_MAX sentinels always do.
return a.x == b.x && a.y == b.y && a.z == b.z;
}
friend bool operator!=(const EventPos& a, const EventPos& b) { return !(a == b); }
};
EventPos InvalidEventPos();
// A map object an event is attached to (a system, a fleet). The original reads the id from
// the object's `+4` word and the position from its `+0x18` Vector3; the caller here has
// already resolved both.
struct EventSubject {
int id = 0;
EventPos pos;
};
struct PlayerEvent {
int id = 0; // EvEID -- assigned by Post, never by the caller
std::string summary; // EvDsc -- short title
std::string message; // EvMsg -- body
int location = 0; // EvLoc -- subject id, or 0
EventPos pos; // EvPos -- defaults to InvalidEventPos()
std::string image; // EvImg -- event-type identifier, e.g. "EVENT_SHIPS_BUILT"
int action = 0; // EvAct
int chainId = 0; // EvCID -- constructed 0 and never written by Post
PlayerEvent() : pos(InvalidEventPos()) {}
};
struct TurnEvents {
int turn = 0;
std::vector<PlayerEvent> events;
};
class EventStorage {
public:
// Buckets older than `turn - kPruneWindowTurns` are candidates for pruning. A code
// constant in the original, not a config key. CONFIDENCE: high.
static constexpr int kPruneWindowTurns = 50;
// Post one event and return its id.
//
// The steps, in the original's order:
// 1. build the record from the arguments (defaults from the constructor);
// 2. `location` = subject ? subject->id : 0;
// 3. `image` = the given name (an empty name is stored as "");
// 4. `action` = act, **except** that act == 0 with no subject and no position is
// stored as 2 -- an easy rule to miss and one that changes the save bytes;
// 5. position: the subject's wins, else the explicit one, else the FLT_MAX sentinel;
// 6. PruneOldTurns(turn);
// 7. bucket = GetOrCreateTurnBucket(turn);
// 8. if an equal event is already in that bucket, return **its** id and post nothing;
// 9. append; promote nextId 0 -> 1; id = nextId++; stamp it on the appended record.
//
// CONFIDENCE: high.
int Post(std::string summary, std::string message, const EventSubject* subject,
const EventPos* pos, int turn, std::string_view image, int action);
// Convenience for the common "no place on the map" case (every research event).
int PostGlobal(std::string summary, std::string message, int turn, std::string_view image,
int action) {
return Post(std::move(summary), std::move(message), nullptr, nullptr, turn, image, action);
}
// Returns the **last** bucket whose turn matches, creating one at the back if there is
// none. The original's scan has no early exit, so a duplicate turn bucket (which nothing
// in the posting path creates, but a loaded save could carry) resolves to the later one.
// CONFIDENCE: high.
TurnEvents& GetOrCreateTurnBucket(int turn);
// The duplicate test the original applies before appending: `action`, `location`, all
// three position floats, `message` and `image` must be equal. **`summary` is not
// compared.** Returns nullptr when there is no match. CONFIDENCE: high.
const PlayerEvent* FindDuplicate(const TurnEvents& bucket, const PlayerEvent& candidate) const;
// Drop buckets older than the window -- faithfully, including the off-by-one.
//
// The original walks the *leading* run of buckets with `turn < cutoff`, leaves its cursor
// on the **last** one of that run, and shifts from there. So it erases n-1 of n: one stale
// bucket always survives, a single leading stale bucket is never removed at all, and a
// stale bucket that sits after a fresh one is never reached. This is reproduced rather
// than corrected -- the surviving bucket is serialized, so "fixing" it diverges.
// CONFIDENCE: high.
void PruneOldTurns(int turn);
int nextId() const { return nextId_; }
void set_nextId(int v) { nextId_ = v; }
const std::vector<TurnEvents>& turns() const { return turns_; }
std::vector<TurnEvents>& turns() { return turns_; }
// Total events across every bucket -- for tests and for the trace describer.
std::size_t total_events() const;
private:
std::vector<TurnEvents> turns_;
int nextId_ = 0; // EvNxID starts at 0; Post promotes it to 1 on the first post
};
} // namespace sots::events

View file

@ -0,0 +1,88 @@
#include "game/events/research_events.h"
namespace sots::events {
bool ResearchCompletedOnBudget(float progressRatio) {
// The original widens the float ratio and compares it against the double nearest 0.8f.
return static_cast<double>(progressRatio) >= kResearchOnBudgetRatio;
}
std::string FormatEventText(std::string_view fmt, std::string_view arg, std::size_t cap) {
std::string out;
out.reserve(fmt.size() + arg.size());
bool substituted = false;
for (std::size_t i = 0; i < fmt.size(); ++i) {
if (fmt[i] == '%' && i + 1 < fmt.size()) {
if (fmt[i + 1] == 's' && !substituted) {
out.append(arg.begin(), arg.end());
substituted = true;
++i;
continue;
}
if (fmt[i + 1] == '%') {
out.push_back('%');
++i;
continue;
}
}
out.push_back(fmt[i]);
}
if (cap != 0 && out.size() > cap) out.resize(cap);
return out;
}
namespace {
int PostPair(EventStorage& log, const EventText& text, const EventTextKeys& keys,
std::string_view arg, std::size_t cap, int turn, std::string_view img, int action) {
std::string summary = FormatEventText(text(keys.summaryKey), arg, cap);
std::string message = FormatEventText(text(keys.messageKey), arg, cap);
return log.PostGlobal(std::move(summary), std::move(message), turn, img, action);
}
} // namespace
int PostResearchOverbudget(EventStorage& log, const EventText& text, std::string_view techName,
int turn) {
// Formatted into a std::string by the original, so no length cap.
return PostPair(log, text, kTextResearchOverbudget, techName, 0, turn, kImgResearchOverbudget,
1);
}
int PostResearchCompleted(EventStorage& log, const EventText& text, std::string_view techName,
float progressRatio, int turn) {
const bool onBudget = ResearchCompletedOnBudget(progressRatio);
const EventTextKeys& keys = onBudget ? kTextResearchComplete : kTextResearchUnderbudget;
const std::string_view img = onBudget ? kImgResearchComplete : kImgResearchUnderbudget;
// Only the message goes through the 256-byte buffer; the summary is taken verbatim from
// its slot without formatting.
std::string summary(text(keys.summaryKey));
std::string message = FormatEventText(text(keys.messageKey), techName, kCompletionMessageCap);
return log.PostGlobal(std::move(summary), std::move(message), turn, img, 1);
}
int PostTemperance(EventStorage& log, const EventText& text, int turn) {
// action 0 with no subject and no position -> EventStorage::Post stores 2.
return PostPair(log, text, kTextTemperance, {}, 0, turn, kImgTemperance, 0);
}
int PostTechsUnlocked(EventStorage& log, const EventText& text,
const std::vector<std::string>& techNames, std::string_view separator,
int turn) {
std::string summary(text(kTextTechsUnlocked.summaryKey));
std::string message(text(kTextTechsUnlocked.messageKey));
for (const std::string& name : techNames) {
message.append(separator.begin(), separator.end());
message.append(name);
}
return log.PostGlobal(std::move(summary), std::move(message), turn, kImgTechsUnlocked, 1);
}
int PostNoResearch(EventStorage& log, const EventText& text, int turn) {
std::string summary(text(kTextNoResearch.summaryKey));
std::string message(text(kTextNoResearch.messageKey));
return log.PostGlobal(std::move(summary), std::move(message), turn, kImgNoResearch, 1);
}
} // namespace sots::events

View file

@ -0,0 +1,133 @@
// The five events the strategic research path posts, in the order a turn produces them.
//
// Two functions raise all of them. `TechTree::ProcessResearch` posts EVENT_RESEARCH_OVERBUDGET
// once per node inside its allocation loop, and EVENT_TECHS_UNLOCKED once after it;
// `ServerPlayer::OnTechResearched` (reached through SetResearched, and only when it is not
// running silently) posts EVENT_RESEARCH_COMPLETE or _UNDERBUDGET, and EVENT_TEMPERANCE;
// `ServerPlayer::ProcessTurn` posts EVENT_NO_RESEARCH at the end of the phase.
//
// Two kinds of string are involved and they are deliberately kept apart:
//
// * the **event-type identifier** (`EvImg`) is a stable interface name the UI keys its icon
// on. It is a literal in the executable and is reproduced here.
// * the **displayed text** (`EvDsc`, `EvMsg`) is localized content that lives in the
// shipped string table. Only the *keys* are here; the text is looked up at run time
// through the caller's table, exactly as the game does. Nothing in this header carries
// shipped prose.
//
// CONFIDENCE: high. See sots-re findings/subsystems/events.md.
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
#include "game/events/event_log.h"
namespace sots::events {
// ---------------------------------------------------------------------------------------
// Event-type identifiers (EvImg)
// ---------------------------------------------------------------------------------------
inline constexpr std::string_view kImgResearchOverbudget = "EVENT_RESEARCH_OVERBUDGET";
inline constexpr std::string_view kImgResearchComplete = "EVENT_RESEARCH_COMPLETE";
inline constexpr std::string_view kImgResearchUnderbudget = "EVENT_RESEARCH_UNDERBUDGET";
inline constexpr std::string_view kImgTemperance = "EVENT_TEMPERANCE";
inline constexpr std::string_view kImgTechsUnlocked = "EVENT_TECHS_UNLOCKED";
inline constexpr std::string_view kImgNoResearch = "EVENT_NO_RESEARCH";
// ---------------------------------------------------------------------------------------
// String-table keys for the displayed text
// ---------------------------------------------------------------------------------------
// A summary/message key pair. `summaryKey` yields EvDsc, `messageKey` yields EvMsg; the
// message format takes at most one `%s` substitution (the tech name), and some summaries
// take none.
struct EventTextKeys {
std::string_view summaryKey;
std::string_view messageKey;
};
inline constexpr EventTextKeys kTextResearchOverbudget{"EVENTSUM_RESEARCH_OVERBUDGET",
"EVENTMSG_RESEARCH_OVERBUDGET"};
inline constexpr EventTextKeys kTextResearchComplete{"EVENTSUM_RESEARCH_COMPLETE",
"EVENTMSG_RESEARCH_COMPLETE"};
inline constexpr EventTextKeys kTextResearchUnderbudget{"EVENTSUM_RESEARCH_UNDERBUDGET",
"EVENTMSG_RESEARCH_UNDERBUDGET"};
// Note the shipped misspelling of "temperance" in the key.
inline constexpr EventTextKeys kTextTemperance{"EVENTSUM_ADDICTION_TEMPERENCE",
"EVENTMSG_ADDICTION_TEMPERENCE"};
inline constexpr EventTextKeys kTextTechsUnlocked{"EVENTSUM_UNLOCKEDTECHS",
"EVENTMSG_UNLOCKEDTECHS"};
inline constexpr EventTextKeys kTextNoResearch{"EVENTSUM_NO_RESEARCH", "EVENTMSG_NO_RESEARCH"};
// ---------------------------------------------------------------------------------------
// Rules
// ---------------------------------------------------------------------------------------
// The completion event splits on progress/cost against 0.8 -- stored in the image as the
// double nearest to the float 0.8f, so the comparison is against 0.800000011920929 and not
// against the decimal 0.8. `ratio >= threshold` selects COMPLETE; below it selects
// UNDERBUDGET, which despite its name is the *cheap*, early completion. CONFIDENCE: high.
inline constexpr double kResearchOnBudgetRatio = 0.800000011920929;
bool ResearchCompletedOnBudget(float progressRatio);
// The completion message is built by the original with `_snprintf` into a 256-byte stack
// buffer and then measured with `strlen`. On overflow MSVC's `_snprintf` neither terminates
// the buffer nor bounds the following scan, so the original's behaviour past 255 characters
// is undefined; we cap deliberately and say so rather than pretending it is defined.
// The two events posted from ProcessResearch (over-budget, unlocked techs) format straight
// into a std::string and are NOT capped.
inline constexpr std::size_t kCompletionMessageCap = 255;
// Substitute a single `%s` in `fmt` with `arg`. Only the first `%s` is replaced; `%%` is
// left alone; a format with no `%s` comes back unchanged (which is what the over-budget
// *summary* and both no-research strings do). `cap` of 0 means no limit.
std::string FormatEventText(std::string_view fmt, std::string_view arg, std::size_t cap = 0);
// ---------------------------------------------------------------------------------------
// Posting helpers
// ---------------------------------------------------------------------------------------
// Anything that can turn a string-table key into text. Returning an empty view for an unknown
// key matches the game, whose slots start out pointing at "".
using TextLookup = std::string_view (*)(void* ctx, std::string_view key);
struct EventText {
TextLookup lookup = nullptr;
void* ctx = nullptr;
std::string_view operator()(std::string_view key) const {
return lookup ? lookup(ctx, key) : std::string_view{};
}
};
// `EvAct = 1`, no subject, no position.
int PostResearchOverbudget(EventStorage& log, const EventText& text, std::string_view techName,
int turn);
// Picks COMPLETE or UNDERBUDGET from `progressRatio`; `EvAct = 1`, message capped at 255.
int PostResearchCompleted(EventStorage& log, const EventText& text, std::string_view techName,
float progressRatio, int turn);
// `EvAct` is pushed as **0** with no subject and no position, so the stored action is 2.
// Reproducing that is the whole point of routing this through EventStorage::Post.
int PostTemperance(EventStorage& log, const EventText& text, int turn);
// The message is the unlocked-techs preamble followed by `separator + name` per tech, in the
// order the caller supplies -- note the separator comes BEFORE each name, so the preamble is
// immediately followed by one. The original appends a single "\n" (the one-byte constant it
// passes to std::string::append), which is `kTechsUnlockedSeparator`. `EvAct = 1`.
inline constexpr std::string_view kTechsUnlockedSeparator = "\n";
int PostTechsUnlocked(EventStorage& log, const EventText& text,
const std::vector<std::string>& techNames, std::string_view separator,
int turn);
// `EvAct = 1`; both strings come from the key pair unformatted.
int PostNoResearch(EventStorage& log, const EventText& text, int turn);
} // namespace sots::events

View file

@ -31,15 +31,15 @@ constexpr std::size_t kRngSize = A::RNG_size; // 0x9cc
constexpr std::size_t kTreeHeadSize = A::TechTree_off_Nodes + 0xc; // owner + the node vector constexpr std::size_t kTreeHeadSize = A::TechTree_off_Nodes + 0xc; // owner + the node vector
constexpr std::size_t kMaxNodes = 8192; // loop guard for a garbage vector header constexpr std::size_t kMaxNodes = 8192; // loop guard for a garbage vector header
// Not in the generated header (it is recovered from the save schema, not from an instruction): // The owner's inline Game::EventStorage: a vector<TurnEvents> at +4 and the next-event id
// ServerPlayer+0x29c is an inline Game::EventStorage, 0x1c bytes, holding a // EvNxID at +0x14. Posting an event bumps EvNxID and grows the turn's list -- the write B3's
// vector<TurnEvents> at +4 and the next-event id EvNxID at +0x14. Posting an event bumps // compare could not see. These four were promoted out of this file and into the generated
// EvNxID and grows the turn's list -- the write B3's compare could not see. // header once lane E read them off the instruction stream (ServerPlayer::GetEventStorage is
// Source: sots-re/findings/objects/struct-recovery.md, ServerPlayer table row 0x29c. // literally `lea eax,[ecx+0x29c]; ret`, and PostEvent's counter update names +0x14).
constexpr std::size_t kPlayerEventsOff = 0x29c; constexpr std::size_t kPlayerEventsOff = A::ServerPlayer_off_Events; // 0x29c
constexpr std::size_t kEventStorageSize = 0x1c; constexpr std::size_t kEventStorageSize = A::EventStorage_sizeof; // 0x1c
constexpr std::size_t kEventsVecOff = 0x04; constexpr std::size_t kEventsVecOff = A::EventStorage_off_Events; // 0x04
constexpr std::size_t kEventsNextIdOff = 0x14; constexpr std::size_t kEventsNextIdOff = A::EventStorage_off_EvNxID; // 0x14
// Whole-object guard spans. ServerPlayer is 0x3e0 and the TechTree header we care about ends // Whole-object guard spans. ServerPlayer is 0x3e0 and the TechTree header we care about ends
// at the order counter (+0x20). Source: findings/control-flow/turn-spine.md object table. // at the order counter (+0x20). Source: findings/control-flow/turn-spine.md object table.
constexpr std::size_t kPlayerSize = 0x3e0; constexpr std::size_t kPlayerSize = 0x3e0;

View file

@ -0,0 +1,7 @@
# game/events tests: the posting rules read out of the instruction stream, plus a replay of
# the event list a real save holds. The canonical runner is build_and_run.sh (plain g++).
add_executable(game_events_test test_events.cpp)
target_link_libraries(game_events_test PRIVATE sots_game_events)
target_include_directories(game_events_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/../game_sim ${CMAKE_CURRENT_SOURCE_DIR}/../../src)
target_compile_options(game_events_test PRIVATE -Wall -Wextra -pedantic)
add_test(NAME game_events COMMAND game_events_test)

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Build and run the game/events unit tests with plain g++ (no CMake needed).
# tests/game_events/build_and_run.sh
# BUILD_DIR=/some/dir tests/game_events/build_and_run.sh
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/../.." && pwd)"
build="${BUILD_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/sots-game-events.XXXXXX")}"
mkdir -p "$build"
CXX="${CXX:-g++}"
CXXFLAGS="${CXXFLAGS:--std=c++17 -O1 -g -Wall -Wextra -Werror -pedantic}"
srcs=("$root"/src/game/events/event_log.cpp "$root"/src/game/events/research_events.cpp)
objs=()
for s in "${srcs[@]}"; do
o="$build/$(basename "${s%.cpp}").o"
$CXX $CXXFLAGS -I"$root/src" -c "$s" -o "$o"
objs+=("$o")
done
exe="$build/test_events"
$CXX $CXXFLAGS -I"$root/src" -I"$here" -I"$root/tests/game_sim" "$here/test_events.cpp" "${objs[@]}" -o "$exe"
if "$exe"; then echo "game_events: all tests passed (build dir $build)"; else echo "game_events: FAILURES (build dir $build)"; exit 1; fi

View file

@ -0,0 +1,344 @@
// game/events unit tests.
//
// The cases are the rules read out of the instruction stream, plus one replay of the event
// list a real save actually contains (turn3-state.sav, player 1: two identical ship-built
// events in different turn buckets followed by the research over-budget event, next id 4).
// No shipped prose appears here: the text pipeline is exercised with placeholder formats and
// the real string-table *keys*, which is what the engine carries.
#include "game/events/event_log.h"
#include "game/events/research_events.h"
#include <cfloat>
#include <cmath>
#include <map>
#include <string>
#include "check.h" // shared with tests/game_sim
using namespace sots::events;
// ---------------------------------------------------------------------------------------
// A stub string table: key -> format.
// ---------------------------------------------------------------------------------------
static std::map<std::string, std::string>& table() {
static std::map<std::string, std::string> t;
return t;
}
static std::string_view lookup(void*, std::string_view key) {
auto it = table().find(std::string(key));
return it == table().end() ? std::string_view{} : std::string_view(it->second);
}
static EventText text_source() { return EventText{&lookup, nullptr}; }
// ---------------------------------------------------------------------------------------
static void test_ids_and_next_id() {
EventStorage log;
CHECK_EQ(log.nextId(), 0); // EvNxID starts at 0, not 1
CHECK_EQ(log.PostGlobal("a", "m1", 5, "IMG", 1), 1);
CHECK_EQ(log.nextId(), 2);
CHECK_EQ(log.PostGlobal("a", "m2", 5, "IMG", 1), 2);
CHECK_EQ(log.PostGlobal("a", "m3", 5, "IMG", 1), 3);
CHECK_EQ(log.nextId(), 4);
CHECK_EQ(log.turns().size(), 1u);
CHECK_EQ(log.total_events(), 3u);
CHECK_EQ(log.turns()[0].turn, 5);
CHECK_EQ(log.turns()[0].events[0].id, 1);
CHECK_EQ(log.turns()[0].events[2].id, 3);
}
static void test_default_record() {
EventStorage log;
log.PostGlobal("sum", "msg", 3, "EVENT_X", 7);
const PlayerEvent& e = log.turns()[0].events[0];
CHECK(e.summary == "sum");
CHECK(e.message == "msg");
CHECK(e.image == "EVENT_X");
CHECK_EQ(e.location, 0);
CHECK_EQ(e.action, 7);
CHECK_EQ(e.chainId, 0); // EvCID is constructed 0 and Post never writes it
// The sentinel is FLT_MAX, not infinity -- byte-confirmed in the save.
CHECK(e.pos.x == FLT_MAX && e.pos.y == FLT_MAX && e.pos.z == FLT_MAX);
CHECK(!std::isinf(e.pos.x));
}
static void test_action_zero_becomes_two() {
EventStorage log;
// act 0, no subject, no position -> stored as 2 (this is what EVENT_TEMPERANCE hits).
log.PostGlobal("s", "m", 1, "A", 0);
CHECK_EQ(log.turns()[0].events[0].action, 2);
// act 0 WITH a position -> stays 0. The rule needs all three conditions.
EventPos p{1.f, 2.f, 3.f};
log.Post("s", "m2", nullptr, &p, 1, "A", 0);
CHECK_EQ(log.turns()[0].events[1].action, 0);
// act 0 with a subject -> stays 0.
EventSubject sub{42, EventPos{4.f, 5.f, 6.f}};
log.Post("s", "m3", &sub, nullptr, 1, "A", 0);
CHECK_EQ(log.turns()[0].events[2].action, 0);
// a non-zero act is never rewritten.
log.PostGlobal("s", "m4", 1, "A", 5);
CHECK_EQ(log.turns()[0].events[3].action, 5);
}
static void test_position_precedence() {
EventStorage log;
EventSubject sub{288, EventPos{-11.92860221862793f, 4.719004154205322f, 2.3178513050079346f}};
EventPos other{9.f, 9.f, 9.f};
// The subject wins over an explicit position.
log.Post("s", "m", &sub, &other, 2, "EVENT_SHIPS_BUILT", 0);
const PlayerEvent& e = log.turns()[0].events[0];
CHECK_EQ(e.location, 288);
CHECK(e.pos.x == -11.92860221862793f);
CHECK(e.pos.z == 2.3178513050079346f);
// With no subject, the explicit position is used.
log.Post("s", "m2", nullptr, &other, 2, "X", 1);
CHECK(log.turns()[0].events[1].pos.y == 9.f);
CHECK_EQ(log.turns()[0].events[1].location, 0);
}
static void test_dedup_rules() {
EventStorage log;
CHECK_EQ(log.PostGlobal("summary A", "same message", 4, "IMG", 1), 1);
// Same message/image/action/location/position, DIFFERENT summary -> still a duplicate:
// the original's comparator does not look at EvDsc.
CHECK_EQ(log.PostGlobal("summary B", "same message", 4, "IMG", 1), 1);
CHECK_EQ(log.total_events(), 1u);
CHECK_EQ(log.nextId(), 2); // a duplicate does not burn an id
// Any of the compared fields differing makes it a new event.
CHECK_EQ(log.PostGlobal("summary A", "other message", 4, "IMG", 1), 2);
CHECK_EQ(log.PostGlobal("summary A", "same message", 4, "OTHER", 1), 3);
CHECK_EQ(log.PostGlobal("summary A", "same message", 4, "IMG", 2), 4);
EventSubject sub{7, EventPos{0.f, 0.f, 0.f}};
CHECK_EQ(log.Post("summary A", "same message", &sub, nullptr, 4, "IMG", 1), 5);
CHECK_EQ(log.total_events(), 5u);
}
static void test_dedup_is_per_turn_bucket() {
// The real save carries the same EVENT_SHIPS_BUILT record in the turn-2 and turn-3
// buckets with ids 1 and 2. Dedup must not collapse them.
EventStorage log;
EventSubject sub{288, EventPos{-11.92860221862793f, 4.719004154205322f, 2.3178513050079346f}};
CHECK_EQ(log.Post("Ships Built", "one ship", &sub, nullptr, 2, "EVENT_SHIPS_BUILT", 0), 1);
CHECK_EQ(log.Post("Ships Built", "one ship", &sub, nullptr, 3, "EVENT_SHIPS_BUILT", 0), 2);
CHECK_EQ(log.turns().size(), 2u);
CHECK_EQ(log.total_events(), 2u);
}
static void test_bucket_lookup_takes_the_last_match() {
EventStorage log;
// A loaded save could hold two buckets for the same turn; the original's scan has no
// early exit, so the later one is the one that gets appended to.
log.turns().push_back(TurnEvents{});
log.turns().back().turn = 9;
log.turns().push_back(TurnEvents{});
log.turns().back().turn = 9;
log.PostGlobal("s", "m", 9, "IMG", 1);
CHECK_EQ(log.turns()[0].events.size(), 0u);
CHECK_EQ(log.turns()[1].events.size(), 1u);
}
static void test_prune_window_and_off_by_one() {
const int W = EventStorage::kPruneWindowTurns;
CHECK_EQ(W, 50);
auto make = [](std::initializer_list<int> turns) {
EventStorage s;
for (int t : turns) {
s.turns().push_back(TurnEvents{});
s.turns().back().turn = t;
}
return s;
};
// Nothing stale: untouched.
{
EventStorage s = make({100, 101});
s.PruneOldTurns(120);
CHECK_EQ(s.turns().size(), 2u);
}
// A single leading stale bucket is NEVER removed (the original returns early).
{
EventStorage s = make({10, 100});
s.PruneOldTurns(120); // cutoff 70; bucket 10 is stale
CHECK_EQ(s.turns().size(), 2u);
CHECK_EQ(s.turns()[0].turn, 10);
}
// Two leading stale buckets: exactly one is erased -- the later stale one survives.
{
EventStorage s = make({10, 11, 100});
s.PruneOldTurns(120);
CHECK_EQ(s.turns().size(), 2u);
CHECK_EQ(s.turns()[0].turn, 11);
CHECK_EQ(s.turns()[1].turn, 100);
}
// Three leading stale: two erased, one survives.
{
EventStorage s = make({10, 11, 12, 100});
s.PruneOldTurns(120);
CHECK_EQ(s.turns().size(), 2u);
CHECK_EQ(s.turns()[0].turn, 12);
}
// A stale bucket AFTER a fresh one is never reached.
{
EventStorage s = make({100, 10, 11});
s.PruneOldTurns(120);
CHECK_EQ(s.turns().size(), 3u);
}
// The cutoff is strict: turn == cutoff is kept.
{
EventStorage s = make({70, 71, 100});
s.PruneOldTurns(120); // cutoff exactly 70
CHECK_EQ(s.turns().size(), 3u);
}
// Post prunes before it appends.
{
EventStorage s = make({1, 2, 3});
s.PostGlobal("a", "b", 120, "IMG", 1);
CHECK_EQ(s.turns().size(), 2u); // buckets 1 and 2 gone, 3 survives, 120 added
CHECK_EQ(s.turns()[0].turn, 3);
CHECK_EQ(s.turns()[1].turn, 120);
}
}
static void test_on_budget_threshold() {
// The comparison is against the double nearest 0.8f, so 0.8f itself is "on budget".
CHECK(ResearchCompletedOnBudget(0.8f));
CHECK(ResearchCompletedOnBudget(1.0f));
CHECK(ResearchCompletedOnBudget(std::nextafter(0.8f, 1.0f)));
CHECK(!ResearchCompletedOnBudget(std::nextafter(0.8f, 0.0f)));
CHECK(!ResearchCompletedOnBudget(0.79f));
CHECK(!ResearchCompletedOnBudget(0.0f));
// The decimal 0.8 is BELOW (double)0.8f, so a naive `ratio >= 0.8` would flip this case.
CHECK(kResearchOnBudgetRatio > 0.8);
}
static void test_format_event_text() {
CHECK(FormatEventText("A %s B", "X") == "A X B");
CHECK(FormatEventText("no substitution", "X") == "no substitution");
CHECK(FormatEventText("%s", "Waldo Units") == "Waldo Units");
CHECK(FormatEventText("%s and %s", "X") == "X and %s"); // only the first
CHECK(FormatEventText("100%% sure", "X") == "100% sure");
CHECK(FormatEventText("abcdef", "X", 3) == "abc");
CHECK(FormatEventText("ab%sef", "CD", 0) == "abCDef");
}
static void test_research_event_posts() {
table().clear();
table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "SUM-OB";
table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "MSG-OB %s .";
table()["EVENTSUM_RESEARCH_COMPLETE"] = "SUM-C";
table()["EVENTMSG_RESEARCH_COMPLETE"] = "MSG-C %s";
table()["EVENTSUM_RESEARCH_UNDERBUDGET"] = "SUM-UB";
table()["EVENTMSG_RESEARCH_UNDERBUDGET"] = "MSG-UB %s";
table()["EVENTSUM_ADDICTION_TEMPERENCE"] = "SUM-T";
table()["EVENTMSG_ADDICTION_TEMPERENCE"] = "MSG-T";
table()["EVENTSUM_UNLOCKEDTECHS"] = "SUM-U";
table()["EVENTMSG_UNLOCKEDTECHS"] = "available:";
table()["EVENTSUM_NO_RESEARCH"] = "SUM-N";
table()["EVENTMSG_NO_RESEARCH"] = "MSG-N";
const EventText t = text_source();
EventStorage log;
CHECK_EQ(PostResearchOverbudget(log, t, "Waldo Units", 3), 1);
{
const PlayerEvent& e = log.turns()[0].events.back();
CHECK(e.summary == "SUM-OB");
CHECK(e.message == "MSG-OB Waldo Units .");
CHECK(e.image == kImgResearchOverbudget);
CHECK_EQ(e.action, 1);
CHECK_EQ(e.location, 0);
CHECK(e.pos.x == FLT_MAX);
}
CHECK_EQ(PostResearchCompleted(log, t, "Waldo Units", 1.0f, 3), 2);
CHECK(log.turns()[0].events.back().image == kImgResearchComplete);
CHECK(log.turns()[0].events.back().summary == "SUM-C");
CHECK_EQ(PostResearchCompleted(log, t, "Other Tech", 0.5f, 3), 3);
CHECK(log.turns()[0].events.back().image == kImgResearchUnderbudget);
CHECK(log.turns()[0].events.back().message == "MSG-UB Other Tech");
// Temperance pushes act 0 with no subject and no position -> stored action 2.
CHECK_EQ(PostTemperance(log, t, 3), 4);
CHECK_EQ(log.turns()[0].events.back().action, 2);
CHECK(log.turns()[0].events.back().image == kImgTemperance);
CHECK_EQ(PostTechsUnlocked(log, t, {"A", "B"}, kTechsUnlockedSeparator, 3), 5);
CHECK(log.turns()[0].events.back().message == "available:\nA\nB");
CHECK_EQ(log.turns()[0].events.back().action, 1);
CHECK_EQ(PostNoResearch(log, t, 3), 6);
CHECK(log.turns()[0].events.back().image == kImgNoResearch);
CHECK(log.turns()[0].events.back().summary == "SUM-N");
CHECK_EQ(log.turns()[0].events.back().action, 1);
}
static void test_completion_message_is_capped() {
table().clear();
table()["EVENTSUM_RESEARCH_COMPLETE"] = "S";
table()["EVENTMSG_RESEARCH_COMPLETE"] = std::string("%s");
EventStorage log;
PostResearchCompleted(log, text_source(), std::string(400, 'x'), 1.0f, 1);
CHECK_EQ(log.turns()[0].events.back().message.size(), kCompletionMessageCap);
// The over-budget message goes into a std::string in the original, so it is NOT capped.
table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "S";
table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "%s";
EventStorage log2;
PostResearchOverbudget(log2, text_source(), std::string(400, 'y'), 1);
CHECK_EQ(log2.turns()[0].events.back().message.size(), 400u);
}
// Replay of the list verify/results/saves/turn3-state.sav actually holds for player 1.
static void test_real_save_shape() {
table().clear();
table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "sum";
table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "msg %s";
EventStorage log;
EventSubject kedolarra{288,
EventPos{-11.92860221862793f, 4.719004154205322f, 2.3178513050079346f}};
CHECK_EQ(log.Post("built", "one ship", &kedolarra, nullptr, 2, "EVENT_SHIPS_BUILT", 0), 1);
CHECK_EQ(log.Post("built", "one ship", &kedolarra, nullptr, 3, "EVENT_SHIPS_BUILT", 0), 2);
CHECK_EQ(PostResearchOverbudget(log, text_source(), "Waldo Units", 3), 3);
// EvNxID 4, two buckets (turn 2 with one event, turn 3 with two), ids 1/2/3.
CHECK_EQ(log.nextId(), 4);
CHECK_EQ(log.turns().size(), 2u);
CHECK_EQ(log.turns()[0].turn, 2);
CHECK_EQ(log.turns()[0].events.size(), 1u);
CHECK_EQ(log.turns()[1].turn, 3);
CHECK_EQ(log.turns()[1].events.size(), 2u);
CHECK_EQ(log.turns()[1].events[1].id, 3);
CHECK(log.turns()[1].events[1].image == "EVENT_RESEARCH_OVERBUDGET");
CHECK_EQ(log.turns()[1].events[1].action, 1);
CHECK_EQ(log.turns()[1].events[1].location, 0);
CHECK(log.turns()[1].events[1].pos.x == FLT_MAX);
// The ship-built events carry the system id and its position, and action 0 stays 0
// because a subject is present.
CHECK_EQ(log.turns()[0].events[0].location, 288);
CHECK_EQ(log.turns()[0].events[0].action, 0);
}
int main() {
test_ids_and_next_id();
test_default_record();
test_action_zero_becomes_two();
test_position_precedence();
test_dedup_rules();
test_dedup_is_per_turn_bucket();
test_bucket_lookup_takes_the_last_match();
test_prune_window_and_off_by_one();
test_on_budget_threshold();
test_format_event_text();
test_research_event_posts();
test_completion_message_is_capped();
test_real_save_shape();
return simtest::finish("game_events");
}