From 0dff4941bd4be7b69d25889539b42ab0024c014d Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 02:47:11 -0400 Subject: [PATCH] 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}> -- 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. --- campaign/board.md | 2 +- findings/objects/save-editor-structs.md | 21 +- findings/subsystems/events.md | 401 ++++++++++++++++ findings/subsystems/formula-gaps.md | 11 +- .../subsystems/strategic-turn-internals.md | 8 +- ghidra/addresses.json | 450 +++++++++++++++++- ghidra/generated/sots_addresses.h | 114 ++++- 7 files changed, 999 insertions(+), 8 deletions(-) create mode 100644 findings/subsystems/events.md diff --git a/campaign/board.md b/campaign/board.md index 8119cd3..72f3f40 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -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 ''` 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}>`, 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 | diff --git a/findings/objects/save-editor-structs.md b/findings/objects/save-editor-structs.md index 2e5a3ab..3f664c4 100644 --- a/findings/objects/save-editor-structs.md +++ b/findings/objects/save-editor-structs.md @@ -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) +SimPlayerEventsSaveStruct events (Int32 evNxId; ComplexArray) <-- 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 }` 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 diff --git a/findings/subsystems/events.md b/findings/subsystems/events.md new file mode 100644 index 0000000..5c36cc5 --- /dev/null +++ b/findings/subsystems/events.md @@ -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)` — 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). diff --git a/findings/subsystems/formula-gaps.md b/findings/subsystems/formula-gaps.md index 1507dbd..14202b1 100644 --- a/findings/subsystems/formula-gaps.md +++ b/findings/subsystems/formula-gaps.md @@ -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 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. --- diff --git a/findings/subsystems/strategic-turn-internals.md b/findings/subsystems/strategic-turn-internals.md index 037e401..c419363 100644 --- a/findings/subsystems/strategic-turn-internals.md +++ b/findings/subsystems/strategic-turn-internals.md @@ -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`) diff --git a/ghidra/addresses.json b/ghidra/addresses.json index 02dad63..4b9e8db 100644 --- a/ghidra/addresses.json +++ b/ghidra/addresses.json @@ -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 _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 _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" } ] -} \ No newline at end of file +} diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index eacd3f1..54da662 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/generated/sots_addresses.h @@ -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 @@ -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 _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 _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