Driving menu -> NEW GAME -> DIFFICULTY -> SELECT DATA -> slot 01 with plain flags gets further than any run so far: SELECT DATA is reached with ZERO crashes (the screen is alive — a log_ui_draws probe there records 140 draws over 8 frames), and then choosing a slot lets the game proceed into a cinematic or load, where it crashes at 0x82307128 — the same cache-flush std::map erase. So the crash is intermittent in WHERE it fires, not whether: boot, SELECT DATA, and now after slot selection. There is no safe path through the menus to be found by choosing different options. And it is the blocker for every mission-side experiment — the second capital-ship capture included. Navigation is not the obstacle any more; it is scripted and works. Not settled and said so: how to get past it. --mem_watch=false does not (twice). --eh_dispatch is still untested because no run with it on has reached a throw. The black-screen hang is a separate intermittent failure with no diagnosis. A fix is emulator guest-race work, not RE.
259 lines
13 KiB
Markdown
259 lines
13 KiB
Markdown
# The title-screen crash is an STL `map`/`set` erase on a bad iterator
|
||
|
||
**Status:** ✅ `CONFIRMED` — the guest throws `std::out_of_range` from an STL
|
||
`map`/`set` erase during its cache flush, and an **incomplete on-disc cache**
|
||
triggers it about 100 s into a boot (4 runs, 2 each way). 🟡 `PROBABLE` that this
|
||
is the same defect as the Ready-Room crash — same exception type, same
|
||
subsystem, same TOCTOU shape — but ❌ **the `mem_watch` probe that handoff ranks
|
||
as suspect #1 is eliminated here**: with the cache cold, the throw happens with
|
||
the probe off. ⚠️
|
||
**two readings in the first version of this note are withdrawn** (see the
|
||
correction below): the fault address is not a corrupt pointer, and the access
|
||
violation is not the bug.
|
||
|
||
Found while trying to get past the title screen for a second UI screen's paint
|
||
order ([`canary-scripted-input-traps.md`](canary-scripted-input-traps.md)). It is
|
||
worth a page of its own because it turns a crash that cost a whole mission to
|
||
reproduce into one that costs a `mv` and 100 seconds.
|
||
|
||
## The reproduction
|
||
|
||
Move the game's cache directory aside and boot with `--cache_throw_diag=true`:
|
||
|
||
```bash
|
||
mv ~/.local/share/Xenia/cache/aab216c3 /tmp/ # reversible; the game rebuilds it
|
||
run-canary --mem_watch=true --cache_throw_diag=true \
|
||
--logged_profile_slot_0_xuid=B13EBABEBABEBABE
|
||
grep GUEST-THROW <log> # ~100 s in
|
||
```
|
||
|
||
With the cache complete, the same boot produces no throw at all. The original
|
||
symptom — what this note was opened for — looks like this:
|
||
|
||
```
|
||
Access Violation: read at 0x000000010000000C
|
||
PC: 0x82307128 guest thread 9
|
||
… preceded by HostPathDevice::ResolvePath(\aab216c3\5\c10eae6)
|
||
and RtlRaiseException(702DF7F0(E06D7363), ContextArg)
|
||
"Guest attempted to throw a C++ exception!"
|
||
```
|
||
|
||
Xenia pauses itself and stacks crash dialogs — 991 in one run, 2 437 in another.
|
||
|
||
## What the code is
|
||
|
||
`0x82307128` is inside `sub_823070B0` (`0x823070B0..0x823074D0`, has EH), and the
|
||
function identifies itself: it references the string
|
||
`'invalid map/set<T> iterator'` at `0x82062A8C`, builds it with the string
|
||
helpers at `0x8216E7E8` / `0x8216E5C8`, and throws it through `0x825F23D8`.
|
||
|
||
The node layout in the prologue is MSVC's `std::_Tree_node` exactly:
|
||
|
||
```
|
||
823070C8 lbz r10, 25(r5) ; iterator->_Ptr->_Isnil (offset 25)
|
||
… ; if set -> build the string and THROW
|
||
82307124 bl 0x8244E2A8 ; (iterator helper)
|
||
82307128 lwz r11, 0(r25) ; node->_Left (offset 0) <-- FAULT
|
||
8230713C lwz r27, 8(r25) ; node->_Right (offset 8)
|
||
```
|
||
|
||
`_Left` 0, `_Parent` 4, `_Right` 8, `_Color` 24, `_Isnil` 25 — that is the MSVC
|
||
red-black tree node, so this is a `std::map`/`std::set` **erase** (it validates
|
||
the iterator, then walks the node). The crash is the very first dereference
|
||
after the validation.
|
||
|
||
## Correction: the fault address is not a corrupt pointer, and the AV is not the bug
|
||
|
||
The first version of this note read `0x00000001_0000000C` as "a 32-bit pointer
|
||
with a stale high word" and offered an emulator register bug as one of two
|
||
readings. **Both readings were wrong, and the crash dump itself says so** — it
|
||
prints the registers, and
|
||
|
||
```
|
||
r25 = 000000000000000C
|
||
```
|
||
|
||
is clean. Xenia maps the 4 GiB guest address space at host `0x1_00000000`, so
|
||
"read at `0x1_0000000C`" is the *host* address of guest address `0x0000000C`.
|
||
The guest simply dereferenced the small integer **12**.
|
||
|
||
**And the access violation is a consequence of the throw, not an independent
|
||
fault.** `sub_823070B0` validates the iterator, and on failure builds the string
|
||
and calls the throw at `0x82307118`; a throw does not return — except here.
|
||
`RtlRaiseException_entry` in this build handles `0xE06D7363` by logging
|
||
"Guest attempted to throw a C++ exception!" and **returning** (guest EH is only
|
||
dispatched under `--eh_dispatch`, which its own cvar help records as disproven
|
||
for this crash). So execution falls out of the throw into the code that assumed
|
||
it would never run:
|
||
|
||
```
|
||
82307118 bl 0x825F23D8 ; throw std::out_of_range("invalid map/set<T> iterator")
|
||
8230711C addi r3, r31, 276 ; <- execution RESUMES here, in this build
|
||
82307120 or r25, r5, r5 ; r5 has been clobbered by the throw call: 0x0C
|
||
82307124 bl 0x8244E2A8
|
||
82307128 lwz r11, 0(r25) ; <- AV, on 12
|
||
```
|
||
|
||
That means the 1 895 stacked crash dialogs are noise from one guest throw, and
|
||
**the event to study is the throw**.
|
||
|
||
## The throw, with the guest's own diagnosis
|
||
|
||
Run with `--cache_throw_diag=true` (a cvar this fork already carries) and the
|
||
guest says everything — the full record is committed as
|
||
[`captures/cache-flush-throw-cold-cache.log`](captures/cache-flush-throw-cold-cache.log):
|
||
|
||
```
|
||
GUEST-THROW type=.?AVout_of_range@std@@ object=702DF950 lr=82612B50
|
||
GUEST-THROW guest stack: 825F2444 8230711C 8245A1BC 8245A86C 824AFFC4
|
||
CACHE-DUMP deque_count=38 list_count=37 map_size=24
|
||
CACHE-DUMP deque hashpairs: [0]D4EA4615:E46EE8CA [1]69D8E45C:E534FFEA … [37]AAB216C3:A2C8C185
|
||
CACHE-DUMP flush_sp=702DF9D0 snapshot_keys=1 deque_NOT_in_snapshot=38
|
||
(these are what map::at throws on; in_live_map=1 => added during the flush's
|
||
UNLOCKED window = the TOCTOU race)
|
||
```
|
||
|
||
* The thrown type is **`std::out_of_range`** — the same type the Ready-Room crash
|
||
handoff names, from the same subsystem.
|
||
* The stack is `throw ← sub_823070B0 ← sub_8245A098+0x124 ← sub_8245A5E0+0x28C ←
|
||
sub_824AFF88+0x3C`.
|
||
* The keys are `<container hash>:<entry hash>` pairs, and they are the on-disc
|
||
cache files: `AAB216C3:5C10EAE6` is `\aab216c3\5\c10eae6`, the very path the
|
||
log resolves one line before the crash.
|
||
* **The deque holds a duplicate**: 38 entries, 37 distinct, with
|
||
`AAB216C3:A2C8C185` twice, against a map of 37 keys.
|
||
|
||
The mechanism itself was already worked out by whoever wrote this logger — the
|
||
flush snapshots the map, then walks a deque every entry of which is missing from
|
||
the snapshot but present in the live map, i.e. added inside the flush's unlocked
|
||
window. This note adds no new theory there.
|
||
|
||
## What is new: a 100-second, on-demand trigger
|
||
|
||
The throw is not random. It follows the state of the game's **on-disc cache**
|
||
(`~/.local/share/Xenia/cache/<container>/…`, the `\aab216c3\…` device):
|
||
|
||
| run | cache state | `GUEST-THROW` | crash dumps |
|
||
|---|---|---|---|
|
||
| A | complete (6 files) | **0** | 0 |
|
||
| B | directory moved aside — cold | **1** | 1 895 |
|
||
| C | partially rebuilt (1 file + a `.tmp`) | **1** | 0 |
|
||
| D | the original 6-file cache restored | **0** | 0 |
|
||
| E | cold **and** `--mem_watch=false` | **1** | 2 437 |
|
||
|
||
All four reached the same boot stage (each resolves the same `87719002` /
|
||
`aab216c3` cache entries; the warm runs went *further*, so this is not "warm runs
|
||
stopped early"). Runs B and C throw at the same point in the boot, around 100 s
|
||
in, long before anything a player would call gameplay.
|
||
|
||
Run E is the important one, and it **withdraws a claim this note made yesterday**.
|
||
The earlier version said "with `--mem_watch=false` the crash does not happen at
|
||
all", which named the handoff's suspect #1 as measured. That comparison was
|
||
confounded: every `--mem_watch=false` run so far had also had a *warm* cache.
|
||
Holding the cache cold and turning the probe off, the guest throws anyway — so
|
||
**`mem_watch` is not the trigger for this crash**, and the variable that is, is
|
||
the cache.
|
||
|
||
So a suspect in the handoff's bisection plan no longer costs "one build plus one
|
||
Ready-Room run": move the cache directory aside and boot. Note run C — the throw
|
||
happened with **no** access violation behind it, which is why counting crash
|
||
dialogs is the wrong signal and `grep GUEST-THROW` is the right one.
|
||
|
||
**Stated as not settled:** n = 2 on the warm side, n = 3 on the incomplete side,
|
||
one box, one build; and the elimination of `mem_watch` is for *this* throw, not
|
||
necessarily for the Ready-Room one, which nothing here has re-run. And this says
|
||
nothing about *why* the deque grows during the flush — whether that race is
|
||
reachable on hardware or is an artifact of emulator timing (the handoff's
|
||
position) is exactly the open question, and a cheap trigger is only a tool for
|
||
answering it.
|
||
|
||
## The other crash PC, for completeness
|
||
|
||
The one unreproduced crash after an Ⓐ press was at `0x824578A0`, in
|
||
`sub_82457780` (`0x82457780..0x82457958`, one caller, `sub_82457038`). It is an
|
||
unrolled **4 × 16-bit copy loop**:
|
||
|
||
```
|
||
82457890 lhz r6, 0(r10) 82457898 lhz r4, 4(r10)
|
||
82457894 lhz r5, 2(r10) 8245789C lhz r10, 6(r10)
|
||
824578A0 sth r6, 0(r9) <-- FAULT (a STORE, not a load)
|
||
```
|
||
|
||
so a bad *destination*, in what looks like a small block copy — a different
|
||
failure from the tree erase above, and with one observation it stays at that.
|
||
|
||
## Why this matters beyond the blocker
|
||
|
||
The Ready-Room crash costs a full mission to reproduce, which is why its
|
||
bisection plan in the handoff is written as "one build + one Ready-Room run" per
|
||
suspect. If this title-screen crash is the same defect, each suspect costs **40
|
||
seconds** instead, and suspect #1 (`--mem_watch=false`) is already measured here:
|
||
it removes the crash.
|
||
|
||
## It also fires on the way into a mission — which is what blocks the ship capture
|
||
|
||
**2026-08-19.** Driving the game toward a mission for the second capital-ship
|
||
capture (`tutorial_launch.sh`: boot → title → Ⓐ → main menu → two d-pad steps →
|
||
Ⓐ) reaches two screens nobody had captured, and then dies:
|
||
|
||
```
|
||
main menu → DIFFICULTY (EASY / NORMAL / HARD / BACK) captures/difficulty-screen.png
|
||
→ SELECT DATA (save slots, "Current Storage: Dummy HDD")
|
||
→ CRASH: PC 0x82307128, guest thread 9, 537 stacked dumps
|
||
captures/select-data-crash.png
|
||
```
|
||
|
||
`0x82307128` is the **same** `std::map`/`set` erase as the boot-time throw. So
|
||
the cache-flush defect is not a boot curiosity: it fires again when the game
|
||
enumerates save data on the way into every mission, and it paused the emulator
|
||
for good — dismissing one dialog only reveals the next of 537.
|
||
|
||
Three things this pins:
|
||
|
||
* **The blocker for the second capital-ship capture is this crash, not
|
||
navigation.** Navigation works; the game gets as far as the save-slot screen
|
||
and dies there. `BACKLOG`'s ship item should be read that way.
|
||
* **`mem_watch` stays eliminated**: this run had `--mem_watch=false`.
|
||
* **The save/cache path is the common factor** across both firings — the
|
||
boot-time one followed `HostPathDevice::ResolvePath(\aab216c3\…)`, and this one
|
||
follows the save-slot enumeration.
|
||
|
||
Not settled: whether a warm cache prevents *this* firing the way it prevents the
|
||
boot-time one. The cache was warm here (the 6-file `aab216c3` restored earlier),
|
||
so the answer looks like **no** — but that is one run, and the cold/warm A/B was
|
||
only ever run against the boot-time throw.
|
||
|
||
## Standing blocker: the mission path, measured end to end (2026-08-19)
|
||
|
||
Driving `menu → NEW GAME → DIFFICULTY → SELECT DATA → pick slot 01` with plain
|
||
flags (`--mem_watch=false`, no EH knobs) gets **further than any run so far** and
|
||
still ends the same way:
|
||
|
||
| step | outcome |
|
||
|---|---|
|
||
| main menu → NEW GAME | **DIFFICULTY** |
|
||
| DIFFICULTY → Ⓐ | **SELECT DATA**, and this time with **0 crashes** ([capture](captures/select-data-reached-no-crash.png)) — the screen is alive, a `log_ui_draws` probe there records **140 draws over 8 frames** |
|
||
| slot 01 → Ⓐ | the game proceeds — several changing frames, a cinematic or load — and then **crashes at `0x82307128`**, the same cache-flush `std::map` erase |
|
||
|
||
Two things this settles, and one it does not.
|
||
|
||
**Settled: the crash is intermittent in *where* it fires, not whether.** It has
|
||
now been seen at boot, at `SELECT DATA`, and after the save slot is chosen. The
|
||
same run reached `SELECT DATA` cleanly and died one screen later. So there is no
|
||
"safe path" through the menus to be found by picking different options — the
|
||
flush throws whenever it next runs.
|
||
|
||
**Settled: this is the blocker for every mission-side experiment.** The second
|
||
capital-ship capture, in-flight probes, mission-outcome work: all of them are
|
||
behind this, and navigation is no longer the obstacle — that part is scripted and
|
||
works (`tools/re-capture/newgame_path.sh`, `blackscreen_probe.sh`).
|
||
|
||
**Not settled: how to get past it.** `--mem_watch=false` does not (measured
|
||
twice). `--eh_dispatch` remains untested because no run with it on has reached a
|
||
throw. And the black-screen hang is a *separate* intermittent failure that takes
|
||
some runs out earlier — it is not the crash, and it has no diagnosis yet.
|
||
|
||
The cheap trigger from the top of this note (an incomplete on-disc cache) still
|
||
stands as the fastest way to reproduce the throw for bisection; what is missing
|
||
is a fix, and that is guest-race work in the emulator, not RE.
|