# Player event posting API (lane E, 2026-09-08) Target: recover the API the game uses to post player-visible turn events, so the reimplementation can post them and so the compare harness can *see* them. Motivation: `TechTree::ProcessResearch` (P2-B3) failed its replace-mode oracle by exactly one item across 40,300 — an unposted `EVENT_RESEARCH_OVERBUDGET`. `ServerPlayer:: OnTechResearched` (P2-B2) has the same gap. Until events are modelled, both "0 divergence" verdicts bound the economy fields only. **Everything below with an address was read out of the instruction stream** (bytes fetched with `read-memory` over the ReVa HTTP shim, disassembled locally with capstone) unless the line says *inferred*. Layout claims are additionally confirmed against three real saves via `verify/save-reader/save_reader.py`. --- ## 1. The container `ServerPlayer::Events` is an **`EventStorage` embedded at `ServerPlayer+0x29c`**, size `0x1c`. Verified by `ServerPlayer::GetEventStorage` at **0x0080db00**, which is the whole function: ``` 0080db00 8d819c020000 lea eax, [ecx + 0x29c] 0080db06 c3 ret ``` `__thiscall EventStorage* ServerPlayer::GetEventStorage(ServerPlayer* this)` — no stack args, plain `ret`. Every research-path post site either calls it (`ProcessResearch`) or inlines the `lea ecx,[esi+0x29c]` (`OnTechResearched`, 0x008919ab / 0x0089241d). ### `EventStorage` (0x1c bytes) — already in the Ghidra project, now confirmed by code | off | type | name | evidence | |---|---|---|---| | 0x00 | `void*` | vptr | not touched by the post path | | 0x04 | `TurnEvents*` | `Events._Myfirst` | 0x008853aa `mov edx,[esi+8]; sub edx,[esi+4]` | | 0x08 | `TurnEvents*` | `Events._Mylast` | same | | 0x0c | `TurnEvents*` | `Events._Myend` | vector growth in 0x00885427+ | | 0x10 | `allocator` | `Events._Alval` | MSVC `_Vector_val` tail, 4 B | | 0x14 | `int` | **`EvNxID`** | 0x008863e3–0x008863f6 | | 0x18 | — | padding | struct size 0x1c | `EvNxID` lands at **`ServerPlayer+0x2b0`** — exactly the byte run the harness guard reported as `player+0x2b0:4`. That is now explained, not merely observed. The vector element stride is **0x18**: `0x008853b3 mov eax,0x2aaaaaab; imul edx; sar edx,2` = signed divide by 24. ### `TurnEvents` (0x18 bytes) — new | off | type | name | evidence | |---|---|---|---| | 0x00 | `void*` | vptr | virtual dtor called at 0x00879f21 (`mov edx,[edi]; mov eax,[edx]; push 0; call eax`) | | 0x04 | `int` | **`EvTurn`** | 0x008853d3 `cmp [edx+4], ebx` (ebx = the turn argument) | | 0x08 | `PlayerEvent*` | `Events._Myfirst` | `lea ecx,[esi+8]` handed to `vector::push_back` at 0x008863db | | 0x0c | `PlayerEvent*` | `Events._Mylast` | 0x008863f9 `mov eax,[esi+0xc]` | | 0x10 | `PlayerEvent*` | `Events._Myend` | | | 0x14 | `allocator` | `Events._Alval` | | ### `PlayerEvent` (0x74 = 116 bytes) — new Stride confirmed two independent ways: the duplicate scanner's divisor at 0x00825d5f (`mov eax,0x8d3dcb09; imul ecx; add edx,ecx; sar edx,6` = signed divide by 116) and the post function's `mov [eax-0x70], ecx` writing `id` at `element+4` off `_Mylast`. | off | type | on-disk tag | set by | |---|---|---|---| | 0x00 | `void*` | — | ctor: vftable `0x00a21958` | | 0x04 | `int` | `EvEID` | `PostEvent`: `EvNxID++` (or the duplicate's id) | | 0x08 | `std::string` (0x1c) | `EvDsc` | `PostEvent` arg 0 (summary / title) | | 0x24 | `std::string` (0x1c) | `EvMsg` | `PostEvent` arg 1 (body) | | 0x40 | `int` | `EvLoc` | `obj ? obj->[+4] : 0` | | 0x44 | `float[3]` | `EvPos` | `obj ? obj->[+0x18..0x20] : (pos ? *pos : ctor default)` | | 0x50 | `std::string` (0x1c) | `EvImg` | `PostEvent` arg `img` (`""` if NULL) | | 0x6c | `int` | `EvAct` | `PostEvent` arg `act`; forced to **2** if `act==0 && !obj && !pos` | | 0x70 | `int` | `EvCID` | ctor 0; never written by `PostEvent` | **Correction to `formula-gaps.md`:** the default `EvPos` is **`FLT_MAX` (0x7f7fffff), not infinity**. `PlayerEvent::PlayerEvent` (0x0084ee30) copies the three floats from the global `Vector3` at `0x00af0dc8`, whose bytes are `FF FF 7F 7F` × 3. Confirmed in the save: `EvPos = 2139095039 (0x7f7fffff)` for `EVENT_RESEARCH_OVERBUDGET`. Writing `+inf` (0x7f800000) would produce a different save byte and a different oracle hash. Ctor also sets `EvEID=0`, `EvLoc=0`, `EvAct=0`, `EvCID=0` and the three strings to `""` (`0x009e100c`). ### Serialization (matches the save exactly) `PlayerEvent::Serialize` = vftable slot 1 at **0x00825970**, `__thiscall`, `ret 4`. Tag pointers all come from the table at `0x00a2bd88` (stride 8): `EvEID EvNxID EvTurn Events EvPos EvLoc EvMsg EvImg EvDsc EvCID EvAct sasc`. Emission order read off the instruction stream: `EvEID, EvDsc, EvMsg, EvImg, EvLoc, EvPos, EvAct, EvCID`. `TurnEvents` write 0x00825bb0 / read 0x00825c40: `EvTurn` then the nested collection `Events`. `EventStorage` read 0x00825cc0: `EvNxID` then the nested collection `Events`. So on disk the shape is **nested, not flat**: ``` Events (EventStorage) EvNxID : int Events : n × TurnEvents EvTurn : int Events : m × PlayerEvent EvEID EvDsc EvMsg EvImg EvLoc EvPos{x,y,z} EvAct EvCID ``` `findings/objects/save-editor-structs.md:271` models this as `SimPlayerEventsSaveStruct events (Int32 evNxId; ComplexArray)` — a **flat** array of events. That is wrong (or at least the R1 C# editor's simplification): the `ComplexArray` elements are *turn groups*, each holding its own array. `save_reader.py` parses it correctly today because `Events` falls into the generic tree; nothing needs fixing in the reader, but the struct note should be corrected. ### Ground truth: `verify/results/saves/turn3-state.sav` Player index 1 (`/Sim/Player[1]/Events`, file offset 120372): ``` EvNxID = 4 EvTurn = 2 EvEID 1 EvDsc "Ships Constructed At Ke'Dolarra" EvMsg "1 ship built in system Ke'Dolarra" EvImg "EVENT_SHIPS_BUILT" EvLoc 288 EvPos {-11.9286, 4.71900, 2.31785} EvAct 0 EvCID 0 EvTurn = 3 EvEID 2 (the same EVENT_SHIPS_BUILT record, next turn) EvEID 3 EvDsc "Research Over Budget" EvMsg "Research for Waldo Units has gone overbudget." EvImg "EVENT_RESEARCH_OVERBUDGET" EvLoc 0 EvPos {0x7f7fffff, 0x7f7fffff, 0x7f7fffff} EvAct 1 EvCID 0 ``` Player index 0 has two `EVENT_NO_RESEARCH` records (turns 2 and 3, ids 1 and 2, `EvNxID` 3). Players 2 and 3 have `EvNxID = 0` and an empty list — note **`EvNxID` starts at 0**, and `PostEvent` lazily promotes 0 → 1 on the first post (0x008863e3). --- ## 2. The entry point ```c // 0x008862b0 int __thiscall EventStorage::PostEvent( EventStorage* this, // ecx std::string summary, // [ebp+0x08], BY VALUE, 0x1c bytes -> EvDsc std::string message, // [ebp+0x24], BY VALUE, 0x1c bytes -> EvMsg void* obj, // [ebp+0x40] may be NULL Vector3* pos, // [ebp+0x44] may be NULL int turn, // [ebp+0x48] const char* img, // [ebp+0x4c] may be NULL -> "" int act); // [ebp+0x50] // returns the event id; ret 0x4c ``` `ret 0x4c` = 76 = 2 × 0x1c (the two by-value `std::string`s) + 5 × 4. Both string arguments are built **in the caller's frame by `sub esp,0x1c`** and a copy-construct, which is the MSVC by-value-`std::string` idiom; `PostEvent` frees their buffers itself before returning (0x00886415–0x00886446), so the caller must not. Body, in order (all read from the instruction stream): 1. `PlayerEvent ev;` — default ctor `0x0084ee30` on a `[ebp-0x84]` temporary. 2. `ev.EvDsc = summary; ev.EvMsg = message;` (0x0088630d, 0x0088631a). 3. `ev.EvLoc = obj ? obj->[+4] : 0` (0x00886322). 4. `ev.EvImg = img ? img : ""` — `""` is `0x009e100c`; length by inline `strlen` (0x00886330). 5. `ev.EvAct = act`; **if `act == 0 && obj == NULL && pos == NULL` then `ev.EvAct = 2`** (0x00886353–0x0088636f). This default is easy to miss and changes the save bytes. 6. Position: `obj` wins (`obj->[+0x18/+0x1c/+0x20]`), else `pos` (`pos->[0/4/8]`), else the ctor's `FLT_MAX` triple (0x0088637a–0x008863a7). 7. `PruneOldTurns(turn)` — 0x00879eb0. 8. `TurnEvents* bucket = GetOrCreateTurnBucket(turn)` — 0x00885380. 9. `PlayerEvent* dup = FindDuplicate(bucket, &ev)` — 0x00825d40. **If non-NULL, return `dup->EvEID` and post nothing.** 10. else `bucket->Events.push_back(ev)`; `if (EvNxID == 0) EvNxID = 1;` `id = EvNxID++; back().EvEID = id;` return `id`. ### `EventStorage::FindDuplicate` — 0x00825d40, `__thiscall (TurnEvents*, PlayerEvent*)`, `ret 8` Linear scan of the bucket. Two events are the same when **all** of these match: `EvAct` (+0x6c), `EvLoc` (+0x40), the three `EvPos` floats (`fucompp`, so bitwise-unequal NaNs never match but the `FLT_MAX` sentinels always do), `EvMsg` (+0x24) and `EvImg` (+0x50). **`EvDsc` is NOT compared.** A NULL bucket returns 0 immediately. This is why the two identical `EVENT_SHIPS_BUILT` records in `turn3-state.sav` survive as separate events: they are in different turn buckets, and dedup is per-bucket. ### `EventStorage::GetOrCreateTurnBucket` — 0x00885380, `__thiscall (int turn)`, `ret 4` Scans `Events` for `EvTurn == turn`; **keeps scanning to the end and returns the *last* match** (0x008853d0–0x008853de has no early exit). If none, constructs a bucket (`0x00884cb0`, vtable `0x00a0f07c`), appends, and sets `_Mylast[-1].EvTurn = turn` (0x0088542d, `mov [eax-0x14], ecx`, i.e. `+4` off the new element at `_Mylast-0x18`). ### `EventStorage::PruneOldTurns` — 0x00879eb0, `__thiscall (int turn)` Cutoff is `turn - 0x32` (**50 turns**), constant, not from config: `0x00879ec3 add ebx,-0x32`. Precisely as coded — and this is a quirk worth reproducing rather than "fixing": it walks the **leading** run of buckets with `EvTurn < cutoff`, leaves `edi` pointing at the **last** one of that run, and then shifts from `edi` down to `_Myfirst`. So it erases `n-1` buckets, not `n`: **one stale bucket always survives**, and a single leading stale bucket is never removed at all (`0x00879ee2 cmp esi,edi; je` returns). It also stops at the first non-stale bucket, so a stale bucket after a fresh one is never reached. ### Convenience wrapper `0x00886470` (`ret` and prototype not verified by this lane) posts via `PostEvent` twice at 0x00886802 / 0x00886b22. **161 call sites in 113 functions** reference `PostEvent` directly — this is the single event API for the whole simulation, not a research-specific helper. ### Turn number Every research-path site computes the turn as `*(int*)(*(char**)(player + 8) + 8)`. `ServerPlayer+8` is the `StrategyServer` *second* base; `0x0080e320` is `__thiscall void* ServerPlayer::GetServer()` = `[this+8] ? [this+8]-4 : 0`, so the field is `StrategyServer(primary base)+0x0c`. This matches the B4 finding that `StrategyServer` has two bases four bytes apart. **CORRECTION (lane EV 2026-09-08): that field is the FRAME (the turn counter), not `ModCount`.** The two are different words and the saves separate them cleanly: `turn2-state.sav` has `Summary/Turn = 2`, `Sim/Frame = 2` and `Sim/ModCount = 12`, and every event the turn posted landed in bucket `EvTurn = 2`. If the argument were `ModCount` the buckets would be 12 and 24. The value is the **post-increment** turn — a turn run from a save at turn *N* posts into bucket *N+1* — because the frame is bumped in `BeginProcessTurn`, before either turn driver runs. --- ## 3. Which events the research path posts Order within `ServerPlayer::ProcessTurn` (0x00891340): | # | site | event | condition | |---|---|---|---| | 1 | `TechTree::ProcessResearch` 0x00587b97 | `EVENT_RESEARCH_OVERBUDGET` | per node, see below | | 2 | → `SetResearched` → `OnTechResearched` 0x008919b5 | `EVENT_RESEARCH_COMPLETE` / `_UNDERBUDGET` | see below | | 3 | → `OnTechResearched` 0x00892427 | `EVENT_TEMPERANCE` | temperance tech cured ≥1 addicted system | | 4 | `TechTree::ProcessResearch` 0x00587ff4 | `EVENT_TECHS_UNLOCKED` | tail loop, ≥1 newly available node | | 5 | `ProcessTurn` 0x0089168c | `EVENT_NO_RESEARCH` | no target, no affordable tech, tree not exhausted | `ProcessResearch`'s two posts bracket the per-node loop: OVERBUDGET fires **inside** the loop (so once per over-budget node), TECHS_UNLOCKED **once** after it. ### 3.1 `EVENT_RESEARCH_OVERBUDGET` — the B3 defect, fully explained Posted at **0x00587b97**, in `TechTree::ProcessResearch` (0x005876c0). Per-node arithmetic recovered from 0x00587732–0x00587907 (naming `cost = TechTree::GetNodeCost(node)` = 0x0057da00, `pts = node->points` at `node+0x1c`): ``` minPts = max(cost * 50 / 100, 0) ; 0x51eb851f/sar 5 = /100 maxPts = max(minPts, cost * 150 / 100) wasDone = (pts >= cost) ; [ebp-0xcd], setge @0x005877e1 granted = min(requested, maxPts - pts) *overbudgetOut += requested - granted ; the out-param accumulates unspent points pts += granted nowDone = (pts >= cost) ; [ebp-0xce], setge @0x00587828 if (pts >= maxPts) chance = 1.0f, draw = 0.0f ; always completes else if (granted == 0) chance = 0.0f, draw = 1.0f ; never completes, no draw else chance = (float)(pts - minPts) / maxPts ; NOTE: / maxPts, not /(max-min) draw = rng.NextFloat()*(1.0-0.0) + 0.0 ; [0x009e1e68] == 0.0 if (owner && owner->Species == 5) ; Zuul draw = max(draw, rng.NextFloat()*(1.0-0.0)+0.0) if (chance < draw) { ; roll FAILED -> tech not completed this turn if (!wasDone && nowDone && owner) { PostEvent(EVENT_RESEARCH_OVERBUDGET); node->flag(+0x2c) = 2; } } else { ; roll succeeded log("Research completed at %d of %d (%.1f%%). (Odds: %.2f, Roll: %.2f)\n", ...) if (pts/cost < 0.8) node->flag(+0x2c) = 0; SetResearched(node->def, 2); ; 0x00581e10 -> OnTechResearched } ``` So **over budget means: the node has accumulated at least its full cost, but the completion roll failed, and this is the first turn that has been true.** `!wasDone` is what makes it fire exactly once per node. The record it posts: | field | value | evidence | |---|---|---| | `EvDsc` | `"Research Over Budget"` | key `EVENTSUM_RESEARCH_OVERBUDGET`, slot `0x00ae48e0`, thunk key at `0x00a00cac` | | `EvMsg` | `"Research for %s has gone overbudget."` % `node->def->name` | key `EVENTMSG_RESEARCH_OVERBUDGET`, slot `0x00ae48e8`, key at `0x00a00ccc` | | `EvImg` | `"EVENT_RESEARCH_OVERBUDGET"` | literal `0x00a0078c`, pushed at 0x00587b24 | | `EvLoc` | `0` | `obj = NULL` (0x00587b2c) | | `EvPos` | `{FLT_MAX, FLT_MAX, FLT_MAX}` | `pos = NULL` → ctor default | | `EvAct` | `1` | literal, 0x00587b22 | | `EvCID` | `0` | ctor | Both strings go through `0x008c97f0` (`__cdecl` format-into-`std::string`, 8 stack args: `out, fmt, a1..a6`) with the tech name as the single substitution; the summary format contains no `%s`, so it comes out literal. The tech name is `def+0x40` (a `std::string`; `_Myres` at `+0x54` selects heap vs. inline buffer, 0x00587a1d). **Save cross-check:** `turn3-state.sav`, player 1, turn-3 bucket, `EvEID 3` is exactly this record, with `EvMsg "Research for Waldo Units has gone overbudget."` — the format string, the `%s` substitution, `EvAct 1`, `EvLoc 0` and the `FLT_MAX` position all match byte for byte. ### 3.2 `EVENT_RESEARCH_COMPLETE` / `EVENT_RESEARCH_UNDERBUDGET` Posted at **0x008919b5** in `ServerPlayer::OnTechResearched` (0x00891790), guarded by `if (!silent)` (0x00891804 `cmp byte [ebp+0xc], 0; jne`). ``` ratio = TechTree::GetProgressRatio(tree, def) ; 0x0057e950, float if (ratio >= 0.8f) img = "EVENT_RESEARCH_COMPLETE" (0x00a33368) sum = EVENTSUM_RESEARCH_COMPLETE (slot 0x00af09e8) msg = EVENTMSG_RESEARCH_COMPLETE (slot 0x00af09f0) else img = "EVENT_RESEARCH_UNDERBUDGET" (0x00a33380) sum = EVENTSUM_RESEARCH_UNDERBUDGET(slot 0x00af09f8) msg = EVENTMSG_RESEARCH_UNDERBUDGET(slot 0x00af0a00) PostEvent(sum, snprintf(msg, 0x100, def->name), NULL, NULL, turn, img, 1) ``` The 0.8 constant is the `double` at `0x009e20c8` = `0.800000011920929`, i.e. `(double)0.8f` — the comparison is `fcomp` of the `float` ratio against that double. Text: * `EVENTSUM_RESEARCH_COMPLETE` = `"Research Complete"`, `EVENTMSG_RESEARCH_COMPLETE` = `"Tech %s has been acquired"` * `EVENTSUM_RESEARCH_UNDERBUDGET` = `"Research Breakthrough!"`, `EVENTMSG_RESEARCH_UNDERBUDGET` = `"Your scientists made a breakthrough with %s. Research has completed ahead of schedule!"` `EvAct = 1`, `obj = pos = NULL`, so `EvLoc = 0` and `EvPos = FLT_MAX³`. Message buffer is a 0x100-byte stack buffer formatted with `0x008c8eb0` (`_snprintf`-shaped, 9 args), then assigned into a `std::string`, so **a message longer than 255 chars is truncated** — a real behaviour to reproduce. Note the naming is counter-intuitive: `UNDERBUDGET` is the *cheap* completion (ratio < 0.8). ### 3.3 `EVENT_TEMPERANCE` Posted at **0x00892427**, same function, after the per-species temperance sweep (0x00892280–0x008922b9: for each species with flag bit 5, cure every owned addicted system via 0x00745e40 / 0x00743800). Guarded by `!silent` **and** by a local "something was cured" flag (`[ebp-0x189]`, set at 0x008922a6). Strings `EVENTSUM_ADDICTION_TEMPERENCE` / `EVENTMSG_ADDICTION_TEMPERENCE` (slots `0x00af0a88` / `0x00af0a90`, note the shipped misspelling "TEMPERENCE"). `EvImg = "EVENT_TEMPERANCE"` (0x00a33340), `obj = pos = NULL`, and **`EvAct = 0`** — which means rule 5 of `PostEvent` fires and the stored `EvAct` becomes **2**, not 0. Any reimplementation that stores the literal 0 will differ from the oracle here. ### 3.4 `EVENT_TECHS_UNLOCKED` Posted at **0x00587ff4**, at the tail of `ProcessResearch` (loop at 0x00587cc3). Collects every node `n` where `n != NULL`, `n->def != NULL`, `tree->nodes[n->def->index] != NULL`, that node's **`state` (`+0x14`) `== 2` (available)**, and **`n->turnAvailable` (`+0x20`) `== currentTurn`** (0x00587cfc–0x00587d42). If the collected vector is non-empty it posts once. *Correction to `strategic-turn-internals.md:233`*, which reads the condition as "state==2 && turnAvailable == currentTurn **&& parent researched**". There is no parent test: the `mov ecx,[ecx]; mov eax,[eax+ecx*4]` pair at 0x00587d0d–0x00587d19 dereferences `n->def` and then indexes `tree->nodes` by `def->[0]`, which is the tech's **own** index (the same indirection the entry loop uses at 0x00587738–0x00587740). It resolves back to `n` itself; the two null checks around it are defensive. The state test is therefore on `n`, not on a parent. `EvDsc` = `EVENTSUM_UNLOCKEDTECHS` (slot `0x00ae48f0`) = `"New Technologies Available"`. `EvMsg` = `EVENTMSG_UNLOCKEDTECHS` (slot `0x00ae48f8`) = `"The following technologies are now available for research:"` followed by, per node, the separator string at `0x009e4588` and the node's `def->name` (0x00587f46–0x00587f82). `EvImg` = literal `"EVENT_TECHS_UNLOCKED"` (`0x00a00774`, length pushed as 0x14). `EvAct = 1`, `obj = pos = NULL`. ### 3.5 `EVENT_NO_RESEARCH` Posted at **0x0089168c** in `ServerPlayer::ProcessTurn`. Condition (0x0089162a–0x0089167e): ``` if (player->ResT (+0x294) == NULL) TechTree::CollectResearchedTechs(&out, turn, INT_MAX, sort=1) ; 0x00584e50 if (out.empty() && TechTree::FindFirstAvailableTech() != NULL) ; 0x0057da90 PostEvent(EVENTSUM_NO_RESEARCH, EVENTMSG_NO_RESEARCH, NULL, NULL, turn, "EVENT_NO_RESEARCH" (0x00a3332c), 1) ``` Both strings are `"No Research Project Assigned."` — matches `turn3-state.sav` player 0 exactly (`EvAct 1`, `EvLoc 0`, `EvPos` FLT_MAX³). **CORRECTION (lane EV 2026-09-08) to this section as first written.** 0x00584e50 was named here as a `ListAvailableTechs` and the middle test read as "no affordable tech". It is `TechTree::CollectResearchedTechs` (lane T read the whole body; the call site's argument order was re-read here byte for byte from 0x00891600), and the test is about what was **researched**, not about what is available: 1. `ResT == NULL` — the player holds no research target; 2. **no tech has `state == 4` (researched) with `turnResearched (+0x24) >= turn`**, i.e. nothing finished on this turn or later. The turn is passed as the collector's `minTurn` and the range runs to `INT_MAX`; 3. at least one node is in `state == 2` (available) — that is the availability half, and it is what excludes the four monster factions, whose entire tree is state 4. Test 2 is not decoration and the corpus exercises it: in `zuul-turn23-fleet23.sav` the human posts `EVENT_RESEARCH_COMPLETE` on turn 22 and **no** no-research event that turn, then `EVENT_NO_RESEARCH` again on turn 23. `sots-engine`'s P11 implements all three tests; before this lane it implemented tests 1 and 3 only, and over-fired. Test 1 is the one a save cannot answer. `ResTNm` on the wire is the target the file was **written** with; on the reference pair all four real players start the turn with none and three of them acquire one *during* it, which is AI research selection. Across all eleven corpus saves no AI player ever posts `EVENT_NO_RESEARCH` (~90 player-turns) while the human posts it on every turn it lacks a target — stated as a hypothesis, with the operator's `--ai-player` roster standing in for the missing input. Also in this window (0x008915ec–0x00891624): `RollResearchEvent` (0x0088df20) is called when `ResT != NULL && ResErrRoll(+0x3b4) != 0 && (const at 0x00a2c788) < progressRatio`, then `ResErrRoll` is cleared — the same draw the B2/B3 lanes measured. `RollResearchAccident` (0x00889dc0) posts the `EVENT_LABACCIDENT_*` family; not traced here. --- ## 4. What is *not* verified * `0x00886470` — the wrapper that also calls `PostEvent`. Its prototype and its two call paths were not read; I only established that it exists and is not on the research path. * `TurnEvents`' vptr/vtable contents beyond slot 0 (the virtual dtor used by the pruner). * The `sasc` tag at `0x00a2bde0` — adjacent to the event tag block, owner unknown. * Whether `PostEvent`'s dedup can ever collapse two research events in practice. It cannot for OVERBUDGET vs COMPLETE (different `EvImg`), but two *different techs* going over budget in the same turn differ only in `EvMsg`, which **is** compared — so both are kept. Verified by reading the comparator, not by observing it. * `EventStorage::Write` (the save-side counterpart of 0x00825cc0) was not located; only the read/`0x8b9d50` direction and the two `TurnEvents` directions were disassembled. The field order is identical in both directions and is confirmed by the save, so this is a gap in coverage, not in confidence about the layout. * The 50-turn prune has never been observed running (our saves are at turn ≤ 3). --- ## 5. What a turn actually posts (lane EV 2026-09-08, measured over all 11 saves) Read out of the corpus with `verify/state-checksum/state_checksum.py`, not derived. Every `EvImg` value, bucket and id below is a file fact. **Only two players in the whole corpus ever hold an event.** `PlyrIdx 0` (the human) and `PlyrIdx 1` (the one AI empire that owns colonies). `PlyrIdx 2..3` are the dormant shadow empires — `Sav = 0`, no colonies — and although they *do* pick research targets every turn, their `EvNxID` is 0 on every save. `PlyrIdx 4..7` are the monster factions, whose whole tech tree is state 4 and whose `EvNxID` is likewise 0 throughout. So "who posts" is not a property of the event API; it is a property of who does anything. ### The reference pair | pair | player | bucket | id | image | EvAct | EvLoc | |---|---|---|---|---|---|---| | turn1 -> turn2 | 0 | `EvTurn=2` | 1 | `EVENT_NO_RESEARCH` | 1 | 0 | | turn1 -> turn2 | 1 | `EvTurn=2` | 1 | `EVENT_SHIPS_BUILT` | 0 | 288 | | turn2 -> turn3 | 0 | `EvTurn=3` | 2 | `EVENT_NO_RESEARCH` | 1 | 0 | | turn2 -> turn3 | 1 | `EvTurn=3` | 2 | `EVENT_SHIPS_BUILT` | 0 | 288 | | turn2 -> turn3 | 1 | `EvTurn=3` | 3 | `EVENT_RESEARCH_OVERBUDGET` | 1 | 0 | **Order within a player is readable off the ids**: on turn 3 player 1's construction event is id 2 and its research event id 3, so **the build pass posts before the research pass**. Ids are per player (`EvNxID` is on the player's own storage), so no cross-player order is observable from a save and none should be assumed. `EVENT_SHIPS_BUILT` carries `EvAct = 0` **as stored**, because it has a subject (`EvLoc` 288, a real position) — the `act == 0 && !obj && !pos -> 2` rule does not fire. The only stored `EvAct = 2` in the corpus is `EVENT_LABACCIDENT_SMALL`. ### Every image the corpus contains `EVENT_NO_RESEARCH`, `EVENT_SHIPS_BUILT`, `EVENT_RESEARCH_COMPLETE`, `EVENT_TECHS_UNLOCKED`, `EVENT_RESEARCH_OVERBUDGET`, `EVENT_FLEET_ARRIVED`, `EVENT_FLEET_EXPLORED`, `EVENT_FLEET_MULTIPOINT_NONODE`, `EVENT_LABACCIDENT_SMALL`, `EVENT_COLONY_NEWSETTLERS`, `EVENT_ENEMY_INCOMING_Human`. The last is worth noting: the **event type is the `EvImg` string and it is composed at run time** — `_Human` is a species suffix, so the type space is not a fixed enumeration and never was. ### Text keys used, from `Locale/EN/Strings.csv` `EVENTSUM_SHIPS_BUILT` = `Ships Constructed At %s`; `EVENTMSG_SHIPS_BUILT_1SHIP` = `1 ship built in system %s`; `EVENTMSG_SHIPS_BUILT_NSHIPS` = `%s ships built in system %s`. So the construction event picks between **two** message keys on the ship count, and the count is substituted as a `%s`. Both `EVENTSUM_NO_RESEARCH` and `EVENTMSG_NO_RESEARCH` are the same string and take no parameter. ### Wired into `sots-engine` `src/app/event_phase.{h,cpp}` bridges the save's event subtree to `game::events` and P11 posts through it. On the reference pair, with the data root and the AI roster supplied and `--commit-blocked=P11`: **4 leaves closed on turn1->turn2 and 3 on turn2->turn3, 0 regressed**, and the posted record agrees with the oracle on every one of its eight fields. Engine-side write-up: `sots-engine docs/EV-events.md`.