20 KiB
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):
PlayerEvent ev;— default ctor0x0084ee30on a[ebp-0x84]temporary.ev.EvDsc = summary; ev.EvMsg = message;(0x0088630d, 0x0088631a).ev.EvLoc = obj ? obj->[+4] : 0(0x00886322).ev.EvImg = img ? img : ""—""is0x009e100c; length by inlinestrlen(0x00886330).ev.EvAct = act; ifact == 0 && obj == NULL && pos == NULLthenev.EvAct = 2(0x00886353–0x0088636f). This default is easy to miss and changes the save bytes.- Position:
objwins (obj->[+0x18/+0x1c/+0x20]), elsepos(pos->[0/4/8]), else the ctor'sFLT_MAXtriple (0x0088637a–0x008863a7). PruneOldTurns(turn)— 0x00879eb0.TurnEvents* bucket = GetOrCreateTurnBucket(turn)— 0x00885380.PlayerEvent* dup = FindDuplicate(bucket, &ev)— 0x00825d40. If non-NULL, returndup->EvEIDand post nothing.- else
bucket->Events.push_back(ev);if (EvNxID == 0) EvNxID = 1;id = EvNxID++; back().EvEID = id;returnid.
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 — i.e. ModCount. This matches the B4 finding that
StrategyServer has two bases four bytes apart.
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)
ListAvailableTechs(&out, turn, INT_MAX, 1) ; 0x00584e50
if (out.empty() && TechTree::0x0057da90() != 0)
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³).
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 callsPostEvent. 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
sasctag at0x00a2bde0— 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 (differentEvImg), but two different techs going over budget in the same turn differ only inEvMsg, 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/0x8b9d50direction and the twoTurnEventsdirections 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).