events: recover the player event-posting API (lane E)

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.
This commit is contained in:
alex 2026-09-08 02:47:11 -04:00
parent 84d5609af8
commit 0dff4941bd
7 changed files with 999 additions and 8 deletions

View file

@ -69,4 +69,4 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| golden-trace recapture (post-guards) | verify | in-progress | — | 0% | 2026-09-08 | region sets changed, so goldens must be recaptured: ProcessResearch (expect NEW divergences - the event defect becoming visible), ComputeBudget (verdict should stay 0), and first captures for OnTechResearched / ServerSystem::ProcessTurn / MoveFleet |
| B1 replace double-run | verify | backlog | — | 0% | 2026-09-08 | ComputeBudget replace mode runs the original a second time to harvest budget slots; ComputeOutput repairs ships in orbit as a side effect, so this is a real per-turn double effect on objects no region covers. Needs a design fix (harvest without re-running, or declare+revert) |
| ReVa MCP link drop (workaround) | meta | verified | high | 100% | 2026-09-08 | The ReVa MCP client link dropped mid-session while the CT111 server stayed healthy (systemd active, :8080 listening, valid key -> 200). `tools/reva_call.py <tool> '<json>'` calls the same server over plain HTTP (initialize -> notifications/initialized -> tools/call; replies are SSE with a leading `id:` line, initialize is plain JSON). Key is NEVER stored in the repo: $REVA_KEY, else ~/.claude.json, else ssh to the CT properties file. Use this whenever mcp__plugin_ReVa_ReVa__* is unavailable |
| event posting API | subsystem | in-progress | — | 0% | 2026-09-08 | Closes B3's oracle gap (unposted EVENT_RESEARCH_OVERBUDGET, the ONE item wrong across 40,300) and B2's known gap (ours posts no events, so both clean compares bound economy fields only). Needs: the player event list container, the post/append entry point, the event record layout |
| event posting API | subsystem | mapped | high | 90% | 2026-09-08 | RECOVERED (lane E, `findings/subsystems/events.md`). Container: `EventStorage` embedded at `ServerPlayer+0x29c` (0x1c), `EvNxID` at +0x14 = player+0x2b0 — exactly the guard's byte run. Nested `vector<TurnEvents{int EvTurn; vector<PlayerEvent>}>`, record 0x74 B, tags `EvEID EvDsc EvMsg EvImg EvLoc EvPos EvAct EvCID`; layout confirmed field-by-field against turn3-state.sav, which CONTAINS the overbudget record. Entry point `int __thiscall EventStorage::PostEvent(this, string BYVAL, string BYVAL, obj*, Vector3*, turn, const char* img, int act)` 0x008862b0 RET 0x4c — **161 call sites in 110 functions, the whole sim's event API**. B3 defect fully explained: 0x00587b97, in the completion-roll-FAILED branch under `!wasDone && nowDone && owner`. 3 note corrections (EvPos is FLT_MAX not inf; the save array is turn-bucketed not flat; TECHS_UNLOCKED has no parent clause). 56 entries in addresses.json; 11 prototypes + 13 labels + 12 comments + 2 structs written back to Ghidra. Engine: `sots-engine` branch `wip/events` a7348be, `src/game/events` + 112 checks, ctest 32/32. NOT YET WIRED INTO A HOOK — see `docs/E-events.md` for the proposed region/Coverage change |

View file

@ -268,7 +268,7 @@ Int32 cstR, cstE, cstT, maint, shrm, status, elim
Boolean npc, rebAi, reqCL
SimPlayerTeamSaveStruct teamStruct (Int32 alid, al, na, cf)
Int32 hasVac, hasImm, npTrak, hasDisc, hasDiscSp, hasDiscCl, hasEnc, hasEng
SimPlayerEventsSaveStruct events (Int32 evNxId; ComplexArray<SimPlayerEvent>)
SimPlayerEventsSaveStruct events (Int32 evNxId; ComplexArray<SimPlayerEvent>) <-- SEE NOTE E1
NestedInt32 fngNum
Int32 pvSav, pvMA, aibN
Boolean cnTrd, cnRad, hgs, hadvs, harcc, cnVItl
@ -315,6 +315,25 @@ Int32 ndeflay, rdtc, tnc
`...Spi` (complex): `Int32 sPid, sts; Float cst; Int32 mxC; String name; Int32 trns`.
tail `AsMon`: `Int32 rDn, sys, rMn, rMx; Float rMd`; tail `Tech`: `Int32 rDn; Float aOdd, aInc; String tch; Int32 rCst`.
**NOTE E1 — `events` is nested, not flat (corrected 2026-09-08, lane E).** The R1 model
`SimPlayerEventsSaveStruct { Int32 evNxId; ComplexArray<SimPlayerEvent> }` reads the array as
a flat list of events. Its elements are **turn groups**. The real shape, read off the three
serializers (`EventStorage` 0x00825cc0, `TurnEvents` 0x00825bb0/0x00825c40, `PlayerEvent`
0x00825970) and confirmed against `verify/results/saves/turn3-state.sav`:
```
Events (EventStorage, in memory ServerPlayer+0x29c, 0x1c bytes)
EvNxID : int -- starts at 0, not 1
Events : n x TurnEvents (0x18 bytes each)
EvTurn : int
Events : m x PlayerEvent (0x74 bytes each)
EvEID(+0x04) EvDsc(+0x08) EvMsg(+0x24) EvImg(+0x50)
EvLoc(+0x40) EvPos{x,y,z}(+0x44) EvAct(+0x6c) EvCID(+0x70)
```
`save_reader.py` already parses this correctly (it falls into the generic tree), so no reader
change is needed. Full API in `findings/subsystems/events.md`.
---
## 7. Tech tree & ship designs

View file

@ -0,0 +1,401 @@
# 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 110 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 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).

View file

@ -191,9 +191,14 @@ From the `ProcessResearch` trace/compare/replace runs (engine repo `docs/B3.md`;
`SetResearched` itself. Relevant to the B2 lane: at least one strategic effect rolls.
* **`EVENT_RESEARCH_OVERBUDGET` is raised in the same branch that sets `node.flag = 2`**, with
`EvDsc "Research Over Budget"`, `EvMsg "Research for <tech> has gone overbudget."`,
`EvImg "EVENT_RESEARCH_OVERBUDGET"`, `EvAct 1`, `EvPos {inf,inf,inf}`, and it bumps the player's
`EvNxID`. A replace-mode run that sets only the flag differs from the oracle by exactly this one
event and nothing else in 40,300 save items.
`EvImg "EVENT_RESEARCH_OVERBUDGET"`, `EvAct 1`, `EvPos {FLT_MAX,FLT_MAX,FLT_MAX}`, and it bumps
the player's `EvNxID`. A replace-mode run that sets only the flag differs from the oracle by
exactly this one event and nothing else in 40,300 save items.
**Corrected 2026-09-08 (lane E):** `EvPos` was recorded here as `{inf,inf,inf}`. It is
**`FLT_MAX` (0x7f7fffff), not infinity** — the `PlayerEvent` constructor at 0x0084ee30 copies
the `Vector3` global at 0x00af0dc8, whose bytes are `FF FF 7F 7F` x3, and the save carries
2139095039 in all three slots. Writing `+inf` (0x7f800000) would change the save bytes.
Full API: `findings/subsystems/events.md`.
* Tech-tree size in this game: **293 nodes** per player tree.
---

View file

@ -230,8 +230,14 @@ for each {node, points} in alloc: // one entry: the current Re
}
for each node with state==2 && progress!=0: progress = max(0, progress - ftol(Cost(node)*0.05f)) // 0x009e5060 = (double)0.05f
// NB the funded node is state 3, so it is NOT in this sweep (B3 live trace; formula-gaps Q8 corrected)
for each node with state==2 && turnAvailable == currentTurn && parent researched → EVENT_TECHS_UNLOCKED (names list)
for each node with state==2 && turnAvailable == currentTurn → EVENT_TECHS_UNLOCKED (names list)
```
*(Corrected 2026-09-08, lane E: there is **no** "parent researched" clause. The
`mov ecx,[ecx]; mov eax,[eax+ecx*4]` pair at 0x00587d0d–0x00587d19 dereferences `node->def`
and indexes `tree->nodes` by `def->[0]`, which is the tech's **own** id — the same indirection
the entry loop uses at 0x00587738 — so it resolves back to the node itself and the state test
is on that node. The surrounding null checks are defensive. Event details:
`findings/subsystems/events.md`.)*
Net effect: the current tech gains `spend − 5 %·cost`; every other partially researched tech decays 5 % of its cost per turn.
### 2.4 Unlocking (`SetResearched`)

View file

@ -2593,6 +2593,454 @@
"prototype": "float** -- pointer slot; storage 0x00b212d4 (shipped value 0.33). NOTE min == max in the shipped data, so the stutter ramp collapses to a constant 0.33x inside any influence sphere",
"status": "verified",
"source": "handoff/b4-colony-movement.md"
},
{
"name": "ServerPlayer_GetEventStorage",
"addr": "0x0080db00",
"convention": "thiscall",
"prototype": "EventStorage* (ServerPlayer* this) /* whole body: lea eax,[ecx+0x29c]; ret. No stack args, plain RET */",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "ServerPlayer_GetServer",
"addr": "0x0080e320",
"convention": "thiscall",
"prototype": "void* (ServerPlayer* this) /* eax = [this+8] ? [this+8]-4 : 0. StrategyServer primary base; turn = *(int*)(result+0x0c) */",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_PostEvent",
"addr": "0x008862b0",
"convention": "thiscall",
"prototype": "int (EventStorage* this, std::string summary /*BY VALUE 0x1c -> EvDsc*/, std::string message /*BY VALUE 0x1c -> EvMsg*/, void* obj, Vector3* pos, int turn, const char* img, int act) RET 0x4c. Returns the event id (a duplicate's id if one already exists in the turn bucket). Callee frees both by-value string buffers. img==NULL -> \"\". act==0 && obj==NULL && pos==NULL -> stored EvAct becomes 2. EvLoc = obj ? obj[+4] : 0; EvPos = obj ? obj[+0x18..0x20] : pos ? *pos : FLT_MAX triple. 161 call sites in 110 functions: this is the whole simulation's event API",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_GetOrCreateTurnBucket",
"addr": "0x00885380",
"convention": "thiscall",
"prototype": "TurnEvents* (EventStorage* this, int turn) RET 4. Linear scan with NO early exit, so it returns the LAST bucket whose EvTurn == turn; otherwise appends a new bucket (ctor 0x00884cb0, vtable 0x00a0f07c) and sets its EvTurn",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_FindDuplicate",
"addr": "0x00825d40",
"convention": "thiscall",
"prototype": "PlayerEvent* (EventStorage* this, TurnEvents* bucket, PlayerEvent* candidate) RET 8. NULL bucket -> 0. Match requires EvAct, EvLoc, all three EvPos floats (fucompp), EvMsg and EvImg to be equal. EvDsc is NOT compared",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_PruneOldTurns",
"addr": "0x00879eb0",
"convention": "thiscall",
"prototype": "void (EventStorage* this, int turn) RET 4. Cutoff = turn - 0x32 (50), a code constant. Shifts from the LAST bucket of the leading run with EvTurn < cutoff, so it erases n-1 of n leading stale buckets: one stale bucket always survives and a single leading stale bucket is never removed",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_Read",
"addr": "0x00825cc0",
"convention": "thiscall",
"prototype": "void (EventStorage* this, IStreamable* s) RET 4. Order: EvNxID (int, tag 0x00a2bd90), then nested collection \"Events\" (tag 0x00a2bda0, descriptor vtable 0x00a2da8c) over this+4",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "TurnEvents_Write",
"addr": "0x00825bb0",
"convention": "thiscall",
"prototype": "void (TurnEvents* this, IStreamable* s) RET 4. Order: EvTurn (int, tag 0x00a2bd98), then nested collection \"Events\" (descriptor vtable 0x00a2da7c) over this+8",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "TurnEvents_Read",
"addr": "0x00825c40",
"convention": "thiscall",
"prototype": "void (TurnEvents* this, IStreamable* s) RET 4. Same field order as TurnEvents_Write",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_ctor",
"addr": "0x0084ee30",
"convention": "thiscall",
"prototype": "PlayerEvent* (PlayerEvent* this) RET 0. vptr=0x00a21958; EvEID=EvLoc=EvAct=EvCID=0; the three std::strings = \"\" (0x009e100c); EvPos = the Vector3 global at 0x00af0dc8 = {FLT_MAX, FLT_MAX, FLT_MAX} (0x7f7fffff x3, NOT infinity)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_Serialize",
"addr": "0x00825970",
"convention": "thiscall",
"prototype": "void (PlayerEvent* this, IStreamable* s) RET 4. vftable slot 1. Field order on the wire: EvEID(+4) EvDsc(+8) EvMsg(+0x24) EvImg(+0x50) EvLoc(+0x40) EvPos(+0x44) EvAct(+0x6c) EvCID(+0x70)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "ProcessResearch_PostEventOverbudget",
"addr": "0x00587b97",
"convention": "thiscall",
"prototype": "call site: EventStorage::PostEvent for EVENT_RESEARCH_OVERBUDGET. Guard at 0x005879e9: !wasDone && nowDone && owner, inside the completion-roll-FAILED branch (chance < draw). Sets TechNode.flag(+0x2c)=2 at 0x00587ba3. EvAct=1, obj=NULL, pos=NULL",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "ProcessResearch_PostEventTechsUnlocked",
"addr": "0x00587ff4",
"convention": "thiscall",
"prototype": "call site: EventStorage::PostEvent for EVENT_TECHS_UNLOCKED, once after the per-node loop, if any node has state==2 and turnAvailable==currentTurn. EvAct=1, obj=NULL, pos=NULL",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "OnTechResearched_PostEventComplete",
"addr": "0x008919b5",
"convention": "thiscall",
"prototype": "call site: EventStorage::PostEvent for EVENT_RESEARCH_COMPLETE / _UNDERBUDGET. Guarded by !silent ([ebp+0xc]==0). Message is _snprintf'd (0x008c8eb0) into a 0x100-byte buffer, so >255 chars truncate. EvAct=1",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "OnTechResearched_PostEventTemperance",
"addr": "0x00892427",
"convention": "thiscall",
"prototype": "call site: EventStorage::PostEvent for EVENT_TEMPERANCE. Guarded by !silent AND by the 'a system was cured' local at [ebp-0x189]. Pushed act=0 with obj=pos=NULL, so the STORED EvAct is 2",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "ServerPlayer_ProcessTurn_PostEventNoResearch",
"addr": "0x0089168c",
"convention": "thiscall",
"prototype": "call site: EventStorage::PostEvent for EVENT_NO_RESEARCH. Condition at 0x0089162a: ResT(+0x294)==NULL && ListAvailableTechs(0x00584e50, turn, INT_MAX, 1) returned empty && TechTree 0x0057da90 != 0. EvAct=1",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "TechTree_ProcessResearch_LogFormat",
"addr": "0x00a007a8",
"convention": "data",
"prototype": "const char* \"Research completed at %d of %d (%.1f%%). (Odds: %.2f, Roll: %.2f)\\n\" -- log line on the completion-roll-SUCCEEDED branch, 0x00587977",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "ServerPlayer_off_Events",
"offset": "0x29c",
"convention": "offset",
"prototype": "EventStorage ServerPlayer::Events -- embedded, size 0x1c. Confirmed by ServerPlayer_GetEventStorage",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_off_Events",
"offset": "0x04",
"convention": "offset",
"prototype": "std::vector<TurnEvents> _Myfirst (element stride 0x18; _Mylast +0x08, _Myend +0x0c, _Alval +0x10)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_off_EvNxID",
"offset": "0x14",
"convention": "offset",
"prototype": "int EvNxID -- next event id. Starts at 0; PostEvent promotes 0->1 on the first post, then post-increments. ServerPlayer+0x2b0",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_sizeof",
"offset": "0x1c",
"convention": "offset",
"prototype": "sizeof(EventStorage)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "TurnEvents_off_EvTurn",
"offset": "0x04",
"convention": "offset",
"prototype": "int EvTurn (after the vptr at +0)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "TurnEvents_off_Events",
"offset": "0x08",
"convention": "offset",
"prototype": "std::vector<PlayerEvent> _Myfirst (stride 0x74; _Mylast +0x0c, _Myend +0x10, _Alval +0x14)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "TurnEvents_sizeof",
"offset": "0x18",
"convention": "offset",
"prototype": "sizeof(TurnEvents); the outer vector's stride, from the /24 divide at 0x008853b3",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvEID",
"offset": "0x04",
"convention": "offset",
"prototype": "int EvEID (after the vptr at +0)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvDsc",
"offset": "0x08",
"convention": "offset",
"prototype": "std::string EvDsc (summary / title), 0x1c bytes",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvMsg",
"offset": "0x24",
"convention": "offset",
"prototype": "std::string EvMsg (body), 0x1c bytes",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvLoc",
"offset": "0x40",
"convention": "offset",
"prototype": "int EvLoc (object id, or 0)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvPos",
"offset": "0x44",
"convention": "offset",
"prototype": "float[3] EvPos (default FLT_MAX x3)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvImg",
"offset": "0x50",
"convention": "offset",
"prototype": "std::string EvImg (event-type name, e.g. \"EVENT_RESEARCH_OVERBUDGET\"), 0x1c bytes",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvAct",
"offset": "0x6c",
"convention": "offset",
"prototype": "int EvAct",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_off_EvCID",
"offset": "0x70",
"convention": "offset",
"prototype": "int EvCID -- ctor sets 0 and PostEvent never writes it",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "PlayerEvent_sizeof",
"offset": "0x74",
"convention": "offset",
"prototype": "sizeof(PlayerEvent) = 116. Two independent confirmations: the /116 divide at 0x00825d5f and PostEvent's 'mov [_Mylast-0x70], id' writing EvEID at element+4",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "EventStorage_PruneWindowTurns",
"offset": "0x32",
"convention": "offset",
"prototype": "prune cutoff = turn - 50; a code constant at 0x00879ec3, not config",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_vft_PlayerEvent",
"addr": "0x00a21958",
"convention": "data",
"prototype": "void** -- PlayerEvent vftable. slot0 dtor 0x007694d0, slot1 Serialize 0x00825970, slot2 0x00825ab0. RTTI locator 0x00a80a7c",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_vft_TurnEvents",
"addr": "0x00a0f07c",
"convention": "data",
"prototype": "void** -- TurnEvents vftable (bucket ctor 0x00884cb0); slot0 is the virtual dtor the pruner calls",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_EventTagTable",
"addr": "0x00a2bd88",
"convention": "data",
"prototype": "const char[12][8] -- stride 8: EvEID EvNxID EvTurn Events EvPos EvLoc EvMsg EvImg EvDsc EvCID EvAct sasc",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_Vector3_Invalid",
"addr": "0x00af0dc8",
"convention": "data",
"prototype": "float[3] = {0x7f7fffff, 0x7f7fffff, 0x7f7fffff} = FLT_MAX. PlayerEvent's default EvPos. NOT infinity",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_str_EVENT_RESEARCH_OVERBUDGET",
"addr": "0x00a0078c",
"convention": "data",
"prototype": "const char* \"EVENT_RESEARCH_OVERBUDGET\" -- EvImg pushed at 0x00587b24",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_str_EVENT_RESEARCH_COMPLETE",
"addr": "0x00a33368",
"convention": "data",
"prototype": "const char* \"EVENT_RESEARCH_COMPLETE\" -- EvImg for progress/cost >= 0.8",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_str_EVENT_RESEARCH_UNDERBUDGET",
"addr": "0x00a33380",
"convention": "data",
"prototype": "const char* \"EVENT_RESEARCH_UNDERBUDGET\" -- EvImg for progress/cost < 0.8",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_str_EVENT_TEMPERANCE",
"addr": "0x00a33340",
"convention": "data",
"prototype": "const char* \"EVENT_TEMPERANCE\" -- EvImg at 0x008923ae; posted with act=0 so the stored EvAct is 2",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_str_EVENT_TECHS_UNLOCKED",
"addr": "0x00a00774",
"convention": "data",
"prototype": "const char* \"EVENT_TECHS_UNLOCKED\" (length 0x14 pushed at 0x00587eb6)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_str_EVENT_NO_RESEARCH",
"addr": "0x00a3332c",
"convention": "data",
"prototype": "const char* \"EVENT_NO_RESEARCH\" -- EvImg at 0x0089168c",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTSUM_RESEARCH_OVERBUDGET",
"addr": "0x00ae48e4",
"convention": "data",
"prototype": "const char** -> slot 0x00ae48e0; Strings.csv key EVENTSUM_RESEARCH_OVERBUDGET = \"Research Over Budget\"",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTMSG_RESEARCH_OVERBUDGET",
"addr": "0x00ae48ec",
"convention": "data",
"prototype": "const char** -> slot 0x00ae48e8; key EVENTMSG_RESEARCH_OVERBUDGET = \"Research for %s has gone overbudget.\"",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTSUM_UNLOCKEDTECHS",
"addr": "0x00ae48f4",
"convention": "data",
"prototype": "const char** -> slot 0x00ae48f0; key EVENTSUM_UNLOCKEDTECHS = \"New Technologies Available\"",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTMSG_UNLOCKEDTECHS",
"addr": "0x00ae48fc",
"convention": "data",
"prototype": "const char** -> slot 0x00ae48f8; key EVENTMSG_UNLOCKEDTECHS",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTSUM_RESEARCH_COMPLETE",
"addr": "0x00af09ec",
"convention": "data",
"prototype": "const char** -> slot 0x00af09e8; key EVENTSUM_RESEARCH_COMPLETE = \"Research Complete\"",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTMSG_RESEARCH_COMPLETE",
"addr": "0x00af09f4",
"convention": "data",
"prototype": "const char** -> slot 0x00af09f0; key EVENTMSG_RESEARCH_COMPLETE = \"Tech %s has been acquired\"",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTSUM_RESEARCH_UNDERBUDGET",
"addr": "0x00af09fc",
"convention": "data",
"prototype": "const char** -> slot 0x00af09f8; key EVENTSUM_RESEARCH_UNDERBUDGET = \"Research Breakthrough!\"",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTMSG_RESEARCH_UNDERBUDGET",
"addr": "0x00af0a04",
"convention": "data",
"prototype": "const char** -> slot 0x00af0a00; key EVENTMSG_RESEARCH_UNDERBUDGET",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTSUM_ADDICTION_TEMPERENCE",
"addr": "0x00af0a8c",
"convention": "data",
"prototype": "const char** -> slot 0x00af0a88; key EVENTSUM_ADDICTION_TEMPERENCE (shipped misspelling)",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_ptr_EVENTMSG_ADDICTION_TEMPERENCE",
"addr": "0x00af0a94",
"convention": "data",
"prototype": "const char** -> slot 0x00af0a90; key EVENTMSG_ADDICTION_TEMPERENCE",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_dbl_ResearchUnderbudgetThreshold",
"addr": "0x009e20c8",
"convention": "data",
"prototype": "double 0.800000011920929 = (double)0.8f. progress/cost >= this -> EVENT_RESEARCH_COMPLETE, else EVENT_RESEARCH_UNDERBUDGET; the same constant gates TechNode.flag=0 in ProcessResearch",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
},
{
"name": "g_dbl_ResearchRollBias",
"addr": "0x009e1e68",
"convention": "data",
"prototype": "double 0.0 -- the research completion draw is NextFloat()*(1.0-this)+this, so it is a plain NextFloat()",
"status": "verified",
"source": "E own disassembly pass 2026-09-08 (ReVa read-memory + capstone x86-32): EventStorage::PostEvent 0x008862b0, GetOrCreateTurnBucket 0x00885380, FindDuplicate 0x00825d40, PruneOldTurns 0x00879eb0, PlayerEvent ctor 0x0084ee30 / Serialize 0x00825970; layout cross-checked against verify/results/saves/turn3-state.sav. findings/subsystems/events.md"
}
]
}
}

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ ff67ec0, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 84d5609, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -655,5 +655,117 @@ constexpr uint32_t g_ptr_STUTTER_SYSTEM_INFLUENCE_RADIUS = 0x006ebc44;
constexpr uint32_t g_ptr_STUTTER_MIN_SPEED = 0x006ebc48;
// data float** -- pointer slot; storage 0x00b212d4 (shipped value 0.33). NOTE min == max in the shipped data, so the stutter ramp collapses to a constant 0.33x inside any influence sphere [verified]
constexpr uint32_t g_ptr_STUTTER_MAX_SPEED = 0x006ebc4c;
// thiscall EventStorage* (ServerPlayer* this) /* whole body: lea eax,[ecx+0x29c]; ret. No stack args, plain RET */ [verified]
constexpr uint32_t ServerPlayer_GetEventStorage = 0x0040db00;
// thiscall void* (ServerPlayer* this) /* eax = [this+8] ? [this+8]-4 : 0. StrategyServer primary base; turn = *(int*)(result+0x0c) */ [verified]
constexpr uint32_t ServerPlayer_GetServer = 0x0040e320;
// thiscall int (EventStorage* this, std::string summary /*BY VALUE 0x1c -> EvDsc*/, std::string message /*BY VALUE 0x1c -> EvMsg*/, void* obj, Vector3* pos, int turn, const char* img, int act) RET 0x4c. Returns the event id (a duplicate's id if one already exists in the turn bucket). Callee frees both by-value string buffers. img==NULL -> "". act==0 && obj==NULL && pos==NULL -> stored EvAct becomes 2. EvLoc = obj ? obj[+4] : 0; EvPos = obj ? obj[+0x18..0x20] : pos ? *pos : FLT_MAX triple. 161 call sites in 110 functions: this is the whole simulation's event API [verified]
constexpr uint32_t EventStorage_PostEvent = 0x004862b0;
// thiscall TurnEvents* (EventStorage* this, int turn) RET 4. Linear scan with NO early exit, so it returns the LAST bucket whose EvTurn == turn; otherwise appends a new bucket (ctor 0x00884cb0, vtable 0x00a0f07c) and sets its EvTurn [verified]
constexpr uint32_t EventStorage_GetOrCreateTurnBucket = 0x00485380;
// thiscall PlayerEvent* (EventStorage* this, TurnEvents* bucket, PlayerEvent* candidate) RET 8. NULL bucket -> 0. Match requires EvAct, EvLoc, all three EvPos floats (fucompp), EvMsg and EvImg to be equal. EvDsc is NOT compared [verified]
constexpr uint32_t EventStorage_FindDuplicate = 0x00425d40;
// thiscall void (EventStorage* this, int turn) RET 4. Cutoff = turn - 0x32 (50), a code constant. Shifts from the LAST bucket of the leading run with EvTurn < cutoff, so it erases n-1 of n leading stale buckets: one stale bucket always survives and a single leading stale bucket is never removed [verified]
constexpr uint32_t EventStorage_PruneOldTurns = 0x00479eb0;
// thiscall void (EventStorage* this, IStreamable* s) RET 4. Order: EvNxID (int, tag 0x00a2bd90), then nested collection "Events" (tag 0x00a2bda0, descriptor vtable 0x00a2da8c) over this+4 [verified]
constexpr uint32_t EventStorage_Read = 0x00425cc0;
// thiscall void (TurnEvents* this, IStreamable* s) RET 4. Order: EvTurn (int, tag 0x00a2bd98), then nested collection "Events" (descriptor vtable 0x00a2da7c) over this+8 [verified]
constexpr uint32_t TurnEvents_Write = 0x00425bb0;
// thiscall void (TurnEvents* this, IStreamable* s) RET 4. Same field order as TurnEvents_Write [verified]
constexpr uint32_t TurnEvents_Read = 0x00425c40;
// thiscall PlayerEvent* (PlayerEvent* this) RET 0. vptr=0x00a21958; EvEID=EvLoc=EvAct=EvCID=0; the three std::strings = "" (0x009e100c); EvPos = the Vector3 global at 0x00af0dc8 = {FLT_MAX, FLT_MAX, FLT_MAX} (0x7f7fffff x3, NOT infinity) [verified]
constexpr uint32_t PlayerEvent_ctor = 0x0044ee30;
// thiscall void (PlayerEvent* this, IStreamable* s) RET 4. vftable slot 1. Field order on the wire: EvEID(+4) EvDsc(+8) EvMsg(+0x24) EvImg(+0x50) EvLoc(+0x40) EvPos(+0x44) EvAct(+0x6c) EvCID(+0x70) [verified]
constexpr uint32_t PlayerEvent_Serialize = 0x00425970;
// thiscall call site: EventStorage::PostEvent for EVENT_RESEARCH_OVERBUDGET. Guard at 0x005879e9: !wasDone && nowDone && owner, inside the completion-roll-FAILED branch (chance < draw). Sets TechNode.flag(+0x2c)=2 at 0x00587ba3. EvAct=1, obj=NULL, pos=NULL [verified]
constexpr uint32_t ProcessResearch_PostEventOverbudget = 0x00187b97;
// thiscall call site: EventStorage::PostEvent for EVENT_TECHS_UNLOCKED, once after the per-node loop, if any node has state==2 and turnAvailable==currentTurn. EvAct=1, obj=NULL, pos=NULL [verified]
constexpr uint32_t ProcessResearch_PostEventTechsUnlocked = 0x00187ff4;
// thiscall call site: EventStorage::PostEvent for EVENT_RESEARCH_COMPLETE / _UNDERBUDGET. Guarded by !silent ([ebp+0xc]==0). Message is _snprintf'd (0x008c8eb0) into a 0x100-byte buffer, so >255 chars truncate. EvAct=1 [verified]
constexpr uint32_t OnTechResearched_PostEventComplete = 0x004919b5;
// thiscall call site: EventStorage::PostEvent for EVENT_TEMPERANCE. Guarded by !silent AND by the 'a system was cured' local at [ebp-0x189]. Pushed act=0 with obj=pos=NULL, so the STORED EvAct is 2 [verified]
constexpr uint32_t OnTechResearched_PostEventTemperance = 0x00492427;
// thiscall call site: EventStorage::PostEvent for EVENT_NO_RESEARCH. Condition at 0x0089162a: ResT(+0x294)==NULL && ListAvailableTechs(0x00584e50, turn, INT_MAX, 1) returned empty && TechTree 0x0057da90 != 0. EvAct=1 [verified]
constexpr uint32_t ServerPlayer_ProcessTurn_PostEventNoResearch = 0x0049168c;
// data const char* "Research completed at %d of %d (%.1f%%). (Odds: %.2f, Roll: %.2f)\n" -- log line on the completion-roll-SUCCEEDED branch, 0x00587977 [verified]
constexpr uint32_t TechTree_ProcessResearch_LogFormat = 0x006007a8;
// offset EventStorage ServerPlayer::Events -- embedded, size 0x1c. Confirmed by ServerPlayer_GetEventStorage [verified]
constexpr uint32_t ServerPlayer_off_Events = 0x0000029c;
// offset std::vector<TurnEvents> _Myfirst (element stride 0x18; _Mylast +0x08, _Myend +0x0c, _Alval +0x10) [verified]
constexpr uint32_t EventStorage_off_Events = 0x00000004;
// offset int EvNxID -- next event id. Starts at 0; PostEvent promotes 0->1 on the first post, then post-increments. ServerPlayer+0x2b0 [verified]
constexpr uint32_t EventStorage_off_EvNxID = 0x00000014;
// offset sizeof(EventStorage) [verified]
constexpr uint32_t EventStorage_sizeof = 0x0000001c;
// offset int EvTurn (after the vptr at +0) [verified]
constexpr uint32_t TurnEvents_off_EvTurn = 0x00000004;
// offset std::vector<PlayerEvent> _Myfirst (stride 0x74; _Mylast +0x0c, _Myend +0x10, _Alval +0x14) [verified]
constexpr uint32_t TurnEvents_off_Events = 0x00000008;
// offset sizeof(TurnEvents); the outer vector's stride, from the /24 divide at 0x008853b3 [verified]
constexpr uint32_t TurnEvents_sizeof = 0x00000018;
// offset int EvEID (after the vptr at +0) [verified]
constexpr uint32_t PlayerEvent_off_EvEID = 0x00000004;
// offset std::string EvDsc (summary / title), 0x1c bytes [verified]
constexpr uint32_t PlayerEvent_off_EvDsc = 0x00000008;
// offset std::string EvMsg (body), 0x1c bytes [verified]
constexpr uint32_t PlayerEvent_off_EvMsg = 0x00000024;
// offset int EvLoc (object id, or 0) [verified]
constexpr uint32_t PlayerEvent_off_EvLoc = 0x00000040;
// offset float[3] EvPos (default FLT_MAX x3) [verified]
constexpr uint32_t PlayerEvent_off_EvPos = 0x00000044;
// offset std::string EvImg (event-type name, e.g. "EVENT_RESEARCH_OVERBUDGET"), 0x1c bytes [verified]
constexpr uint32_t PlayerEvent_off_EvImg = 0x00000050;
// offset int EvAct [verified]
constexpr uint32_t PlayerEvent_off_EvAct = 0x0000006c;
// offset int EvCID -- ctor sets 0 and PostEvent never writes it [verified]
constexpr uint32_t PlayerEvent_off_EvCID = 0x00000070;
// offset sizeof(PlayerEvent) = 116. Two independent confirmations: the /116 divide at 0x00825d5f and PostEvent's 'mov [_Mylast-0x70], id' writing EvEID at element+4 [verified]
constexpr uint32_t PlayerEvent_sizeof = 0x00000074;
// offset prune cutoff = turn - 50; a code constant at 0x00879ec3, not config [verified]
constexpr uint32_t EventStorage_PruneWindowTurns = 0x00000032;
// data void** -- PlayerEvent vftable. slot0 dtor 0x007694d0, slot1 Serialize 0x00825970, slot2 0x00825ab0. RTTI locator 0x00a80a7c [verified]
constexpr uint32_t g_vft_PlayerEvent = 0x00621958;
// data void** -- TurnEvents vftable (bucket ctor 0x00884cb0); slot0 is the virtual dtor the pruner calls [verified]
constexpr uint32_t g_vft_TurnEvents = 0x0060f07c;
// data const char[12][8] -- stride 8: EvEID EvNxID EvTurn Events EvPos EvLoc EvMsg EvImg EvDsc EvCID EvAct sasc [verified]
constexpr uint32_t g_EventTagTable = 0x0062bd88;
// data float[3] = {0x7f7fffff, 0x7f7fffff, 0x7f7fffff} = FLT_MAX. PlayerEvent's default EvPos. NOT infinity [verified]
constexpr uint32_t g_Vector3_Invalid = 0x006f0dc8;
// data const char* "EVENT_RESEARCH_OVERBUDGET" -- EvImg pushed at 0x00587b24 [verified]
constexpr uint32_t g_str_EVENT_RESEARCH_OVERBUDGET = 0x0060078c;
// data const char* "EVENT_RESEARCH_COMPLETE" -- EvImg for progress/cost >= 0.8 [verified]
constexpr uint32_t g_str_EVENT_RESEARCH_COMPLETE = 0x00633368;
// data const char* "EVENT_RESEARCH_UNDERBUDGET" -- EvImg for progress/cost < 0.8 [verified]
constexpr uint32_t g_str_EVENT_RESEARCH_UNDERBUDGET = 0x00633380;
// data const char* "EVENT_TEMPERANCE" -- EvImg at 0x008923ae; posted with act=0 so the stored EvAct is 2 [verified]
constexpr uint32_t g_str_EVENT_TEMPERANCE = 0x00633340;
// data const char* "EVENT_TECHS_UNLOCKED" (length 0x14 pushed at 0x00587eb6) [verified]
constexpr uint32_t g_str_EVENT_TECHS_UNLOCKED = 0x00600774;
// data const char* "EVENT_NO_RESEARCH" -- EvImg at 0x0089168c [verified]
constexpr uint32_t g_str_EVENT_NO_RESEARCH = 0x0063332c;
// data const char** -> slot 0x00ae48e0; Strings.csv key EVENTSUM_RESEARCH_OVERBUDGET = "Research Over Budget" [verified]
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_OVERBUDGET = 0x006e48e4;
// data const char** -> slot 0x00ae48e8; key EVENTMSG_RESEARCH_OVERBUDGET = "Research for %s has gone overbudget." [verified]
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_OVERBUDGET = 0x006e48ec;
// data const char** -> slot 0x00ae48f0; key EVENTSUM_UNLOCKEDTECHS = "New Technologies Available" [verified]
constexpr uint32_t g_ptr_EVENTSUM_UNLOCKEDTECHS = 0x006e48f4;
// data const char** -> slot 0x00ae48f8; key EVENTMSG_UNLOCKEDTECHS [verified]
constexpr uint32_t g_ptr_EVENTMSG_UNLOCKEDTECHS = 0x006e48fc;
// data const char** -> slot 0x00af09e8; key EVENTSUM_RESEARCH_COMPLETE = "Research Complete" [verified]
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_COMPLETE = 0x006f09ec;
// data const char** -> slot 0x00af09f0; key EVENTMSG_RESEARCH_COMPLETE = "Tech %s has been acquired" [verified]
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_COMPLETE = 0x006f09f4;
// data const char** -> slot 0x00af09f8; key EVENTSUM_RESEARCH_UNDERBUDGET = "Research Breakthrough!" [verified]
constexpr uint32_t g_ptr_EVENTSUM_RESEARCH_UNDERBUDGET = 0x006f09fc;
// data const char** -> slot 0x00af0a00; key EVENTMSG_RESEARCH_UNDERBUDGET [verified]
constexpr uint32_t g_ptr_EVENTMSG_RESEARCH_UNDERBUDGET = 0x006f0a04;
// data const char** -> slot 0x00af0a88; key EVENTSUM_ADDICTION_TEMPERENCE (shipped misspelling) [verified]
constexpr uint32_t g_ptr_EVENTSUM_ADDICTION_TEMPERENCE = 0x006f0a8c;
// data const char** -> slot 0x00af0a90; key EVENTMSG_ADDICTION_TEMPERENCE [verified]
constexpr uint32_t g_ptr_EVENTMSG_ADDICTION_TEMPERENCE = 0x006f0a94;
// data double 0.800000011920929 = (double)0.8f. progress/cost >= this -> EVENT_RESEARCH_COMPLETE, else EVENT_RESEARCH_UNDERBUDGET; the same constant gates TechNode.flag=0 in ProcessResearch [verified]
constexpr uint32_t g_dbl_ResearchUnderbudgetThreshold = 0x005e20c8;
// data double 0.0 -- the research completion draw is NextFloat()*(1.0-this)+this, so it is a plain NextFloat() [verified]
constexpr uint32_t g_dbl_ResearchRollBias = 0x005e1e68;
} // namespace sots::addr