sots-re/findings/subsystems/events.md

401 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

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