diff --git a/CMakeLists.txt b/CMakeLists.txt index b049ca5..b0d09c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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/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/events) # player event log + research events (lib sots_game_events) # ---- shim trace/compare infrastructure (host-testable; linked into binkw32) ---- add_library(shim_trace STATIC @@ -95,7 +96,7 @@ else() add_executable(addr_smoke tests/addr_smoke.cpp) target_link_libraries(addr_smoke PRIVATE sots_addresses) 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) add_subdirectory(tests/${_t}) endif() diff --git a/docs/E-events.md b/docs/E-events.md new file mode 100644 index 0000000..2ae12b6 --- /dev/null +++ b/docs/E-events.md @@ -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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index eacd3f1..54da662 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // 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). #pragma once #include @@ -655,5 +655,117 @@ constexpr uint32_t g_ptr_STUTTER_SYSTEM_INFLUENCE_RADIUS = 0x006ebc44; 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] 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 _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 _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 diff --git a/src/game/events/CMakeLists.txt b/src/game/events/CMakeLists.txt new file mode 100644 index 0000000..27c124d --- /dev/null +++ b/src/game/events/CMakeLists.txt @@ -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() diff --git a/src/game/events/event_log.cpp b/src/game/events/event_log.cpp new file mode 100644 index 0000000..c099094 --- /dev/null +++ b/src/game/events/event_log.cpp @@ -0,0 +1,89 @@ +#include "game/events/event_log.h" + +#include +#include + +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(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 diff --git a/src/game/events/event_log.h b/src/game/events/event_log.h new file mode 100644 index 0000000..17aa55a --- /dev/null +++ b/src/game/events/event_log.h @@ -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 turns; } +// TurnEvents { int turn; vector 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 +#include +#include +#include + +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 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& turns() const { return turns_; } + std::vector& turns() { return turns_; } + + // Total events across every bucket -- for tests and for the trace describer. + std::size_t total_events() const; + +private: + std::vector turns_; + int nextId_ = 0; // EvNxID starts at 0; Post promotes it to 1 on the first post +}; + +} // namespace sots::events diff --git a/src/game/events/research_events.cpp b/src/game/events/research_events.cpp new file mode 100644 index 0000000..968d175 --- /dev/null +++ b/src/game/events/research_events.cpp @@ -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(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& 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 diff --git a/src/game/events/research_events.h b/src/game/events/research_events.h new file mode 100644 index 0000000..7bb84f1 --- /dev/null +++ b/src/game/events/research_events.h @@ -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 +#include +#include +#include + +#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& 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 diff --git a/src/shim/hooks/research.cpp b/src/shim/hooks/research.cpp index 1e36260..2c7f23f 100644 --- a/src/shim/hooks/research.cpp +++ b/src/shim/hooks/research.cpp @@ -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 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): -// ServerPlayer+0x29c is an inline Game::EventStorage, 0x1c bytes, holding a -// vector at +4 and the next-event id EvNxID at +0x14. Posting an event bumps -// EvNxID and grows the turn's list -- the write B3's compare could not see. -// Source: sots-re/findings/objects/struct-recovery.md, ServerPlayer table row 0x29c. -constexpr std::size_t kPlayerEventsOff = 0x29c; -constexpr std::size_t kEventStorageSize = 0x1c; -constexpr std::size_t kEventsVecOff = 0x04; -constexpr std::size_t kEventsNextIdOff = 0x14; +// The owner's inline Game::EventStorage: a vector at +4 and the next-event id +// EvNxID at +0x14. Posting an event bumps EvNxID and grows the turn's list -- the write B3's +// compare could not see. These four were promoted out of this file and into the generated +// header once lane E read them off the instruction stream (ServerPlayer::GetEventStorage is +// literally `lea eax,[ecx+0x29c]; ret`, and PostEvent's counter update names +0x14). +constexpr std::size_t kPlayerEventsOff = A::ServerPlayer_off_Events; // 0x29c +constexpr std::size_t kEventStorageSize = A::EventStorage_sizeof; // 0x1c +constexpr std::size_t kEventsVecOff = A::EventStorage_off_Events; // 0x04 +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 // at the order counter (+0x20). Source: findings/control-flow/turn-spine.md object table. constexpr std::size_t kPlayerSize = 0x3e0; diff --git a/tests/game_events/CMakeLists.txt b/tests/game_events/CMakeLists.txt new file mode 100644 index 0000000..09cdf7b --- /dev/null +++ b/tests/game_events/CMakeLists.txt @@ -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) diff --git a/tests/game_events/build_and_run.sh b/tests/game_events/build_and_run.sh new file mode 100755 index 0000000..423a8bd --- /dev/null +++ b/tests/game_events/build_and_run.sh @@ -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 diff --git a/tests/game_events/test_events.cpp b/tests/game_events/test_events.cpp new file mode 100644 index 0000000..b39bb19 --- /dev/null +++ b/tests/game_events/test_events.cpp @@ -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 +#include +#include +#include + +#include "check.h" // shared with tests/game_sim + +using namespace sots::events; + +// --------------------------------------------------------------------------------------- +// A stub string table: key -> format. +// --------------------------------------------------------------------------------------- +static std::map& table() { + static std::map 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 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"); +}