sots-re/findings/subsystems/events.md
alex c224ff2214 lane EV: what a turn actually posts, and two corrections to events.md
Measured over all eleven saves with state_checksum, not derived:

  * the reference pair posts TWO events (turn1->turn2) and THREE (turn2->turn3),
    and only two players in the whole corpus ever hold an event -- the human and
    the one AI empire that owns colonies. The dormant shadow empires pick research
    targets every turn and still post nothing.
  * order within a player is readable off the ids: the build pass posts before the
    research pass. Ids are per player, so no cross-player order is observable.
  * the event type is the EvImg string and it is composed at run time --
    EVENT_ENEMY_INCOMING_Human carries a species suffix, so the type space is not
    an enumeration.

Two corrections in place (rule 11):

  * the turn PostEvent is handed is the FRAME, not ModCount. turn2-state.sav has
    Frame 2 and ModCount 12, and every event it carries is in bucket EvTurn=2. It
    is the post-increment turn: a turn run from a save at turn N posts into N+1.
  * the EVENT_NO_RESEARCH gate: 0x00584e50 is TechTree::CollectResearchedTechs,
    not a ListAvailableTechs, and the middle test is "nothing was researched on
    this turn or later" -- not "no affordable tech". zuul-turn23 exercises it: the
    human posts RESEARCH_COMPLETE on turn 22 with no no-research event that turn,
    then NO_RESEARCH again on turn 23.

ghidra/addresses.d/lane-ev.json records the gate at 0x0089162a with the argument
order re-read from the instruction stream. Validated with tools/gen_addresses.py to
a scratch path (1121 entries, no duplicate); the shared header is NOT regenerated.

tools/standalone_report.py gains --engine-arg (repeatable), so a lane can feed the
standalone the operator inputs a save does not carry -- the data root, the AI
roster, which blocked phases may commit -- instead of hard-coding them. Every run
records what it was given, in the report header and in status.json.
2026-09-08 15:40:07 -04:00

26 KiB
Raw Permalink Blame History

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<SimPlayerEvent>) — 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

// 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::strings) + 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.