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.
Closes B3's oracle gap and B2's known gap. Both milestones write into the
owner's event list; nothing modelled it, so their clean compares bounded the
economy fields only.
Container: EventStorage embedded at ServerPlayer+0x29c (0x1c bytes), verified by
ServerPlayer::GetEventStorage 0x0080db00 whose whole body is 'lea eax,[ecx+0x29c];
ret'. EvNxID at +0x14 == player+0x2b0, exactly the byte run the harness guard
reports. The list is a vector<TurnEvents{int EvTurn; vector<PlayerEvent>}> --
bucketed by turn, not flat, which the save-editor struct note had wrong.
Entry point: int __thiscall EventStorage::PostEvent(this, std::string BY VALUE,
std::string BY VALUE, void* obj, Vector3* pos, int turn, const char* img,
int act), 0x008862b0, RET 0x4c. 161 call sites in 110 functions: this is the
whole simulation's event API, not a research helper. Dedup is per turn bucket on
message/image/location/position/action but NOT summary; act 0 with no subject and
no position is stored as 2; EvNxID starts at 0 and is promoted to 1 on first post;
PruneOldTurns drops buckets older than turn-50 with an off-by-one that always
leaves one stale bucket behind.
Record: 0x74 bytes, EvEID(+4) EvDsc(+8) EvMsg(+0x24) EvLoc(+0x40) EvPos(+0x44)
EvImg(+0x50) EvAct(+0x6c) EvCID(+0x70) -- confirmed field by field against
turn3-state.sav, which contains the EVENT_RESEARCH_OVERBUDGET record itself.
B3's defect is fully explained: 0x00587b97, reached only from the
completion-roll-FAILED branch, under !wasDone && nowDone && owner.
Three note corrections: EvPos defaults to FLT_MAX, not infinity (writing +inf
changes the save bytes); the save array is turn-bucketed, not flat;
EVENT_TECHS_UNLOCKED has no 'parent researched' clause.
56 entries added to ghidra/addresses.json (header regenerated); 11 prototypes,
13 labels, 12 comments and 2 structs written back to the Ghidra project.