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

492 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```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`.