401 lines
20 KiB
Markdown
401 lines
20 KiB
Markdown
# Player event posting API (lane E, 2026-09-08)
|
||
|
||
Target: recover the API the game uses to post player-visible turn events, so the
|
||
reimplementation can post them and so the compare harness can *see* them.
|
||
|
||
Motivation: `TechTree::ProcessResearch` (P2-B3) failed its replace-mode oracle by exactly
|
||
one item across 40,300 — an unposted `EVENT_RESEARCH_OVERBUDGET`. `ServerPlayer::
|
||
OnTechResearched` (P2-B2) has the same gap. Until events are modelled, both "0 divergence"
|
||
verdicts bound the economy fields only.
|
||
|
||
**Everything below with an address was read out of the instruction stream** (bytes fetched
|
||
with `read-memory` over the ReVa HTTP shim, disassembled locally with capstone) unless the
|
||
line says *inferred*. Layout claims are additionally confirmed against three real saves via
|
||
`verify/save-reader/save_reader.py`.
|
||
|
||
---
|
||
|
||
## 1. The container
|
||
|
||
`ServerPlayer::Events` is an **`EventStorage` embedded at `ServerPlayer+0x29c`**, size `0x1c`.
|
||
|
||
Verified by `ServerPlayer::GetEventStorage` at **0x0080db00**, which is the whole function:
|
||
|
||
```
|
||
0080db00 8d819c020000 lea eax, [ecx + 0x29c]
|
||
0080db06 c3 ret
|
||
```
|
||
|
||
`__thiscall EventStorage* ServerPlayer::GetEventStorage(ServerPlayer* this)` — no stack args,
|
||
plain `ret`. Every research-path post site either calls it (`ProcessResearch`) or inlines the
|
||
`lea ecx,[esi+0x29c]` (`OnTechResearched`, 0x008919ab / 0x0089241d).
|
||
|
||
### `EventStorage` (0x1c bytes) — already in the Ghidra project, now confirmed by code
|
||
|
||
| off | type | name | evidence |
|
||
|---|---|---|---|
|
||
| 0x00 | `void*` | vptr | not touched by the post path |
|
||
| 0x04 | `TurnEvents*` | `Events._Myfirst` | 0x008853aa `mov edx,[esi+8]; sub edx,[esi+4]` |
|
||
| 0x08 | `TurnEvents*` | `Events._Mylast` | same |
|
||
| 0x0c | `TurnEvents*` | `Events._Myend` | vector growth in 0x00885427+ |
|
||
| 0x10 | `allocator` | `Events._Alval` | MSVC `_Vector_val` tail, 4 B |
|
||
| 0x14 | `int` | **`EvNxID`** | 0x008863e3–0x008863f6 |
|
||
| 0x18 | — | padding | struct size 0x1c |
|
||
|
||
`EvNxID` lands at **`ServerPlayer+0x2b0`** — exactly the byte run the harness guard reported
|
||
as `player+0x2b0:4`. That is now explained, not merely observed.
|
||
|
||
The vector element stride is **0x18**: `0x008853b3 mov eax,0x2aaaaaab; imul edx; sar edx,2`
|
||
= signed divide by 24.
|
||
|
||
### `TurnEvents` (0x18 bytes) — new
|
||
|
||
| off | type | name | evidence |
|
||
|---|---|---|---|
|
||
| 0x00 | `void*` | vptr | virtual dtor called at 0x00879f21 (`mov edx,[edi]; mov eax,[edx]; push 0; call eax`) |
|
||
| 0x04 | `int` | **`EvTurn`** | 0x008853d3 `cmp [edx+4], ebx` (ebx = the turn argument) |
|
||
| 0x08 | `PlayerEvent*` | `Events._Myfirst` | `lea ecx,[esi+8]` handed to `vector::push_back` at 0x008863db |
|
||
| 0x0c | `PlayerEvent*` | `Events._Mylast` | 0x008863f9 `mov eax,[esi+0xc]` |
|
||
| 0x10 | `PlayerEvent*` | `Events._Myend` | |
|
||
| 0x14 | `allocator` | `Events._Alval` | |
|
||
|
||
### `PlayerEvent` (0x74 = 116 bytes) — new
|
||
|
||
Stride confirmed two independent ways: the duplicate scanner's divisor at 0x00825d5f
|
||
(`mov eax,0x8d3dcb09; imul ecx; add edx,ecx; sar edx,6` = signed divide by 116) and the
|
||
post function's `mov [eax-0x70], ecx` writing `id` at `element+4` off `_Mylast`.
|
||
|
||
| off | type | on-disk tag | set by |
|
||
|---|---|---|---|
|
||
| 0x00 | `void*` | — | ctor: vftable `0x00a21958` |
|
||
| 0x04 | `int` | `EvEID` | `PostEvent`: `EvNxID++` (or the duplicate's id) |
|
||
| 0x08 | `std::string` (0x1c) | `EvDsc` | `PostEvent` arg 0 (summary / title) |
|
||
| 0x24 | `std::string` (0x1c) | `EvMsg` | `PostEvent` arg 1 (body) |
|
||
| 0x40 | `int` | `EvLoc` | `obj ? obj->[+4] : 0` |
|
||
| 0x44 | `float[3]` | `EvPos` | `obj ? obj->[+0x18..0x20] : (pos ? *pos : ctor default)` |
|
||
| 0x50 | `std::string` (0x1c) | `EvImg` | `PostEvent` arg `img` (`""` if NULL) |
|
||
| 0x6c | `int` | `EvAct` | `PostEvent` arg `act`; forced to **2** if `act==0 && !obj && !pos` |
|
||
| 0x70 | `int` | `EvCID` | ctor 0; never written by `PostEvent` |
|
||
|
||
**Correction to `formula-gaps.md`:** the default `EvPos` is **`FLT_MAX` (0x7f7fffff), not
|
||
infinity**. `PlayerEvent::PlayerEvent` (0x0084ee30) copies the three floats from the global
|
||
`Vector3` at `0x00af0dc8`, whose bytes are `FF FF 7F 7F` × 3. Confirmed in the save:
|
||
`EvPos = 2139095039 (0x7f7fffff)` for `EVENT_RESEARCH_OVERBUDGET`. Writing `+inf`
|
||
(0x7f800000) would produce a different save byte and a different oracle hash.
|
||
|
||
Ctor also sets `EvEID=0`, `EvLoc=0`, `EvAct=0`, `EvCID=0` and the three strings to `""`
|
||
(`0x009e100c`).
|
||
|
||
### Serialization (matches the save exactly)
|
||
|
||
`PlayerEvent::Serialize` = vftable slot 1 at **0x00825970**, `__thiscall`, `ret 4`. Tag
|
||
pointers all come from the table at `0x00a2bd88` (stride 8): `EvEID EvNxID EvTurn Events
|
||
EvPos EvLoc EvMsg EvImg EvDsc EvCID EvAct sasc`. Emission order read off the instruction
|
||
stream: `EvEID, EvDsc, EvMsg, EvImg, EvLoc, EvPos, EvAct, EvCID`.
|
||
|
||
`TurnEvents` write 0x00825bb0 / read 0x00825c40: `EvTurn` then the nested collection `Events`.
|
||
`EventStorage` read 0x00825cc0: `EvNxID` then the nested collection `Events`.
|
||
|
||
So on disk the shape is **nested, not flat**:
|
||
|
||
```
|
||
Events (EventStorage)
|
||
EvNxID : int
|
||
Events : n × TurnEvents
|
||
EvTurn : int
|
||
Events : m × PlayerEvent
|
||
EvEID EvDsc EvMsg EvImg EvLoc EvPos{x,y,z} EvAct EvCID
|
||
```
|
||
|
||
`findings/objects/save-editor-structs.md:271` models this as
|
||
`SimPlayerEventsSaveStruct events (Int32 evNxId; ComplexArray<SimPlayerEvent>)` — a **flat**
|
||
array of events. That is wrong (or at least the R1 C# editor's simplification): the
|
||
`ComplexArray` elements are *turn groups*, each holding its own array. `save_reader.py`
|
||
parses it correctly today because `Events` falls into the generic tree; nothing needs fixing
|
||
in the reader, but the struct note should be corrected.
|
||
|
||
### Ground truth: `verify/results/saves/turn3-state.sav`
|
||
|
||
Player index 1 (`/Sim/Player[1]/Events`, file offset 120372):
|
||
|
||
```
|
||
EvNxID = 4
|
||
EvTurn = 2
|
||
EvEID 1 EvDsc "Ships Constructed At Ke'Dolarra"
|
||
EvMsg "1 ship built in system Ke'Dolarra"
|
||
EvImg "EVENT_SHIPS_BUILT" EvLoc 288 EvPos {-11.9286, 4.71900, 2.31785}
|
||
EvAct 0 EvCID 0
|
||
EvTurn = 3
|
||
EvEID 2 (the same EVENT_SHIPS_BUILT record, next turn)
|
||
EvEID 3 EvDsc "Research Over Budget"
|
||
EvMsg "Research for Waldo Units has gone overbudget."
|
||
EvImg "EVENT_RESEARCH_OVERBUDGET" EvLoc 0
|
||
EvPos {0x7f7fffff, 0x7f7fffff, 0x7f7fffff} EvAct 1 EvCID 0
|
||
```
|
||
|
||
Player index 0 has two `EVENT_NO_RESEARCH` records (turns 2 and 3, ids 1 and 2, `EvNxID` 3).
|
||
Players 2 and 3 have `EvNxID = 0` and an empty list — note **`EvNxID` starts at 0**, and
|
||
`PostEvent` lazily promotes 0 → 1 on the first post (0x008863e3).
|
||
|
||
---
|
||
|
||
## 2. The entry point
|
||
|
||
```c
|
||
// 0x008862b0
|
||
int __thiscall EventStorage::PostEvent(
|
||
EventStorage* this, // ecx
|
||
std::string summary, // [ebp+0x08], BY VALUE, 0x1c bytes -> EvDsc
|
||
std::string message, // [ebp+0x24], BY VALUE, 0x1c bytes -> EvMsg
|
||
void* obj, // [ebp+0x40] may be NULL
|
||
Vector3* pos, // [ebp+0x44] may be NULL
|
||
int turn, // [ebp+0x48]
|
||
const char* img, // [ebp+0x4c] may be NULL -> ""
|
||
int act); // [ebp+0x50]
|
||
// returns the event id; ret 0x4c
|
||
```
|
||
|
||
`ret 0x4c` = 76 = 2 × 0x1c (the two by-value `std::string`s) + 5 × 4. Both string arguments
|
||
are built **in the caller's frame by `sub esp,0x1c`** and a copy-construct, which is the MSVC
|
||
by-value-`std::string` idiom; `PostEvent` frees their buffers itself before returning
|
||
(0x00886415–0x00886446), so the caller must not.
|
||
|
||
Body, in order (all read from the instruction stream):
|
||
|
||
1. `PlayerEvent ev;` — default ctor `0x0084ee30` on a `[ebp-0x84]` temporary.
|
||
2. `ev.EvDsc = summary; ev.EvMsg = message;` (0x0088630d, 0x0088631a).
|
||
3. `ev.EvLoc = obj ? obj->[+4] : 0` (0x00886322).
|
||
4. `ev.EvImg = img ? img : ""` — `""` is `0x009e100c`; length by inline `strlen` (0x00886330).
|
||
5. `ev.EvAct = act`; **if `act == 0 && obj == NULL && pos == NULL` then `ev.EvAct = 2`**
|
||
(0x00886353–0x0088636f). This default is easy to miss and changes the save bytes.
|
||
6. Position: `obj` wins (`obj->[+0x18/+0x1c/+0x20]`), else `pos` (`pos->[0/4/8]`), else the
|
||
ctor's `FLT_MAX` triple (0x0088637a–0x008863a7).
|
||
7. `PruneOldTurns(turn)` — 0x00879eb0.
|
||
8. `TurnEvents* bucket = GetOrCreateTurnBucket(turn)` — 0x00885380.
|
||
9. `PlayerEvent* dup = FindDuplicate(bucket, &ev)` — 0x00825d40. **If non-NULL, return
|
||
`dup->EvEID` and post nothing.**
|
||
10. else `bucket->Events.push_back(ev)`; `if (EvNxID == 0) EvNxID = 1;`
|
||
`id = EvNxID++; back().EvEID = id;` return `id`.
|
||
|
||
### `EventStorage::FindDuplicate` — 0x00825d40, `__thiscall (TurnEvents*, PlayerEvent*)`, `ret 8`
|
||
|
||
Linear scan of the bucket. Two events are the same when **all** of these match:
|
||
`EvAct` (+0x6c), `EvLoc` (+0x40), the three `EvPos` floats (`fucompp`, so bitwise-unequal
|
||
NaNs never match but the `FLT_MAX` sentinels always do), `EvMsg` (+0x24) and `EvImg` (+0x50).
|
||
**`EvDsc` is NOT compared.** A NULL bucket returns 0 immediately.
|
||
|
||
This is why the two identical `EVENT_SHIPS_BUILT` records in `turn3-state.sav` survive as
|
||
separate events: they are in different turn buckets, and dedup is per-bucket.
|
||
|
||
### `EventStorage::GetOrCreateTurnBucket` — 0x00885380, `__thiscall (int turn)`, `ret 4`
|
||
|
||
Scans `Events` for `EvTurn == turn`; **keeps scanning to the end and returns the *last*
|
||
match** (0x008853d0–0x008853de has no early exit). If none, constructs a bucket
|
||
(`0x00884cb0`, vtable `0x00a0f07c`), appends, and sets `_Mylast[-1].EvTurn = turn`
|
||
(0x0088542d, `mov [eax-0x14], ecx`, i.e. `+4` off the new element at `_Mylast-0x18`).
|
||
|
||
### `EventStorage::PruneOldTurns` — 0x00879eb0, `__thiscall (int turn)`
|
||
|
||
Cutoff is `turn - 0x32` (**50 turns**), constant, not from config: `0x00879ec3 add ebx,-0x32`.
|
||
|
||
Precisely as coded — and this is a quirk worth reproducing rather than "fixing": it walks the
|
||
**leading** run of buckets with `EvTurn < cutoff`, leaves `edi` pointing at the **last** one
|
||
of that run, and then shifts from `edi` down to `_Myfirst`. So it erases `n-1` buckets, not
|
||
`n`: **one stale bucket always survives**, and a single leading stale bucket is never
|
||
removed at all (`0x00879ee2 cmp esi,edi; je` returns). It also stops at the first non-stale
|
||
bucket, so a stale bucket after a fresh one is never reached.
|
||
|
||
### Convenience wrapper
|
||
|
||
`0x00886470` (`ret` and prototype not verified by this lane) posts via `PostEvent` twice at
|
||
0x00886802 / 0x00886b22. **161 call sites in 113 functions** reference `PostEvent` directly —
|
||
this is the single event API for the whole simulation, not a research-specific helper.
|
||
|
||
### Turn number
|
||
|
||
Every research-path site computes the turn as `*(int*)(*(char**)(player + 8) + 8)`.
|
||
`ServerPlayer+8` is the `StrategyServer` *second* base; `0x0080e320` is
|
||
`__thiscall void* ServerPlayer::GetServer()` = `[this+8] ? [this+8]-4 : 0`, so the field is
|
||
`StrategyServer(primary base)+0x0c` — 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).
|