The debt from two iterations ago. Two boots, same binary and ISO, one A tap each, fired only after the plate's pulse had been seen for 12 consecutive samples. ARGV recorded per leg, because the config dump provably cannot say. leg A no profile flag 3811 swallow lines and climbing leg B --logged_profile_... 0 swallow lines, final glyph 327 = MAIN MENU 327 is the documented main-menu glyph count, reproduced by this instrument's own control, so leg B's press opened the menu. Capture committed. Leg A demonstrates the SWALLOW, not the crash: I stopped it at ~2.3 M swallowed calls because kernel tracing at log_level=3 was eating the 300 MB budget the crash dumps need. The fault itself remains measured once, historically. One run per leg. A void pair came first and is recorded, because it is why the detector is what it is. The first version fired on a single frame over a glyph threshold and hit the INTRO MOVIE -- green flashes of 1298..5433 lasting under a second -- about 6 s before the title, in both legs. The presses were real (each skipped the rest of the movie, which is Q9's behaviour) but the pair tested nothing. The fixed detector requires 12 consecutive in-band samples, and was replayed against the void runs' own series as its control: it declines the movie flash at 84.8/85.5 s and fires at 93.9/94.7 s inside the sustained pulse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
299 lines
15 KiB
Markdown
299 lines
15 KiB
Markdown
# Pressing Ⓐ on the title faults the guest — SOLVED, and it is the emulator swallowing input
|
||
|
||
**Classification: measured** (the mechanism, from the run's own retained log) on a
|
||
**decoded** code path (the three functions, read out of the image). Xenia Canary,
|
||
2026-08-29. This is the blocker that gated every menu-side dynamic question in this
|
||
container, and it is not a mystery any more.
|
||
|
||
## The one-line answer
|
||
|
||
Xenia's `XamInputGetKeystrokeEx` returns **`X_ERROR_SUCCESS` with a zeroed
|
||
keystroke, on every call, for as long as a XAM dialog is up**. The game's
|
||
keystroke pump is `while (GetKeystrokeEx(...) == SUCCESS) queue.push_back(ks);`
|
||
with **no bound**. Something raised a XAM dialog immediately after the third Ⓐ was
|
||
delivered, and the pump then queued **8 388 608** empty keystrokes, grew its vector
|
||
to 64 MB, asked for 128 MB, got a failed allocation back **unchecked**, and copied
|
||
off the end of the guest thread stack.
|
||
|
||
So the fault is a *symptom two levels down* from an emulator-side input blackout.
|
||
Nothing is wrong with the disc, the title screen, or the Ⓐ button.
|
||
|
||
## 🔴 Retraction — "`r9` is a wild pointer, above 4 GB, never a guest address"
|
||
|
||
That is this page's own claim, written 2026-08-29 at `72e45a7`, and it is **wrong**.
|
||
|
||
`Access Violation: write at 0x00000001701D0000` prints `ex->fault_address()`, which
|
||
`exception_handler_posix.cc:154` fills from **`signal_info->si_addr`** — a *host*
|
||
address. Xenia maps the guest at `mapping_base_`, chosen in `memory.cc:193` as the
|
||
first `1ull << n` from n=32 that maps, i.e. **`0x100000000`**.
|
||
|
||
The register file proves the translation rather than assuming it: the faulting
|
||
instruction is `sth r6, 0(r9)` and the dump shows
|
||
|
||
```
|
||
r9 = 00000000701D0000 Access Violation: write at 0x00000001701D0000
|
||
```
|
||
|
||
`0x1701D0000 − 0x100000000 = 0x701D0000 = r9`. So `r9` **is** a guest address, in
|
||
the `v40000000` heap (`0x40000000 … 0x7EFFFFFF`), and the page is simply not
|
||
committed. The distinction matters: "garbage pointer" pointed the next probe at
|
||
memory corruption; the truth points it at an allocation that failed.
|
||
|
||
⚠️ **Generalise this.** Every `Access Violation: … at 0x1________` in a Canary log
|
||
from this container is a guest address plus `0x100000000`. Subtract before reading.
|
||
|
||
## The code path, read out of the image (0 mismatches against `sylpheed.db`)
|
||
|
||
All three functions were disassembled from `/image/sylpheed.pe` and cross-checked
|
||
word-for-word against the database: **466 + 120 instructions, zero disagreements**
|
||
across `sub_82457038`, `sub_82457780` and their callees.
|
||
|
||
| | what it is | how that is known |
|
||
|---|---|---|
|
||
| `sub_824574C0` | lazy singleton getter for the **input manager** at guest `0x828F3888`, guarded by a bit-0 "constructed" flag at `0x828F3A70` | `lis r11,0x828F; addi r30,r11,14472` = `0x828F3888`; classic guard-variable shape |
|
||
| `sub_82457038` | the **keystroke pump**: drains `XamInputGetKeystrokeEx` into a vector at `this+68` = `0x828F38CC` | calls `sub_824AA870`, which is `b 0x8284DBDC` = the **`XamInputGetKeystrokeEx`** import thunk (`imports`, ordinal 408) |
|
||
| `sub_82457780` | that vector's **insert-with-grow** | `{ptr@+0, size@+4, capacity@+8}`; doubles capacity, clamps at `0x1FFFFFFF`, `slwi r3,r27,3` for the byte count |
|
||
|
||
The element is **8 bytes copied as four halfwords** at offsets 0/2/4/6 — which is
|
||
exactly `X_INPUT_KEYSTROKE` `{u16 VirtualKey; u16 Unicode; u16 Flags; u8 UserIndex;
|
||
u8 HidCode}`. That is what makes the vector identifiable as a keystroke queue and
|
||
not some other 8-byte record.
|
||
|
||
The pump, in C:
|
||
|
||
```c
|
||
// sub_82457038, 0x82457174 … 0x824571C8
|
||
while (XamInputGetKeystrokeEx(&user, 3, &ks) == X_ERROR_SUCCESS) {
|
||
if (v->size < v->capacity) v->data[v->size++] = ks; // 0x8245718C
|
||
else insert_slow(v, end, &ks); // 0x824571B0 → sub_82457780
|
||
}
|
||
```
|
||
|
||
There is no iteration cap and no check on the allocator's return.
|
||
|
||
## The emulator half — `xam_input.cc:197`
|
||
|
||
```cpp
|
||
if (kernel_state()->xam_state()->IsUIActive()) {
|
||
...
|
||
return X_ERROR_SUCCESS; // keystroke was zeroed above
|
||
}
|
||
```
|
||
|
||
`IsUIActive()` is `is_xam_dialog_present_`, set to true by every non-headless
|
||
`XamShow*UI` path in `xam_ui.cc` and cleared only by a dialog's close handler. While
|
||
it is set, the guest's `== SUCCESS` loop can never terminate.
|
||
|
||
⚠️ This is **upstream Canary behaviour**, not one of this container's RE patches.
|
||
The RE patch is only the `[RE-INPUT]` logging around it — and that logging is what
|
||
made the diagnosis possible, so it earned its keep.
|
||
|
||
## The number that closes it
|
||
|
||
The instrumentation reports one line per 600 swallowed calls. Immediately before the
|
||
first crash dump:
|
||
|
||
```
|
||
[RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 8388601 so far)
|
||
```
|
||
|
||
and the crash dump's own registers say how many records the vector held:
|
||
|
||
```
|
||
r29 = 0000000000800000 = 8 388 608 elements to copy
|
||
r26 = 0000000000800001 = new size
|
||
r27 = 0000000001000000 = new capacity (doubled)
|
||
r30 = FFFFFFFF828F38CC = the vector object — the pump's queue
|
||
r31 = 00000000A7AC0000 r7 = 00000000A3AC0000 → 0x04000000 = 64 MB of live data
|
||
```
|
||
|
||
**8 388 601 swallowed calls against 8 388 608 queued records — a gap of 7, inside
|
||
the 600-call reporting granularity.** One push per swallowed poll. The two numbers
|
||
are independent instruments (a Canary log counter and a guest register file) and
|
||
they agree; that is the whole argument, and it needs no further run.
|
||
|
||
## Why `r3` looked like a stack pointer
|
||
|
||
`slwi r3, r27, 3` = `0x8000000` = **128 MB** requested from `sub_824F7240`
|
||
(`b 0x82150000`, the game's `heap_alloc(*0x828E2B14, size, &out)` wrapper). It came
|
||
back as `0x701CF5F0` — **below** the pump thread's own `r1 = 0x701CF7B0`, i.e. a
|
||
pointer into a stack frame that had already been popped. The copy then walked
|
||
`+0xA18` and hit the top of the thread's 64 KB stack at `0x701D0000`.
|
||
|
||
So: the allocation failed, the failure path left a stale `&local` in `r3`, and the
|
||
caller never checked. A 128 MB request on top of a live 64 MB one, in a guest with
|
||
512 MB total, is not a surprising failure.
|
||
|
||
## The timeline, from the log
|
||
|
||
| log line | event |
|
||
|---|---|
|
||
| 1149 | first `XamInputGetKeystrokeEx` reaches a driver |
|
||
| 1185–1252 | **three** Ⓐ press/release pairs delivered — `vk=5800`, flags `0001` down / `0002` up |
|
||
| 1253 | the third Ⓐ **up** is handed to the guest |
|
||
| **1254** | `swallowed by IsUIActive (ui_active=true, 1 so far)` — the blackout starts |
|
||
| 1254–15242 | 13 982 swallow reports = ~8.39 M swallowed calls |
|
||
| 15243 | first `==== CRASH DUMP ====`, `PC 0x824578A0` |
|
||
|
||
Evidence: [`../data/a-press-fault-log-extract.txt`](../data/a-press-fault-log-extract.txt).
|
||
|
||
⚠️ Note the feedback loop that produces **32 356** dumps rather than one: a guest
|
||
crash makes Xenia call `ImGuiDialog::ShowMessageBox` (`emulator.cc:1487`), which is
|
||
itself a UI — so the swallow can only get worse after the first fault.
|
||
|
||
## ✅ ANSWERED — it is the **sign-in** dialog, and the run had no profile signed in
|
||
|
||
Not a new measurement: **the corpus already had this**, and this page failed to
|
||
connect to it. [`canary-scripted-input-traps.md`](../canary-scripted-input-traps.md)
|
||
§3 says it outright — *"With no profile, Ⓐ **is** handled: the guest calls
|
||
`XamShowSigninUI` and Xenia pops its Sign In dialog"* — with a committed capture,
|
||
[`title-signin-dialog.png`](../captures/title-signin-dialog.png). And
|
||
`tools/re-capture/boot_menu.sh`'s own header has carried the mechanism, **including
|
||
the 8.4 million figure**, since before this page was written.
|
||
|
||
### 🔴 RETRACTED — "the run's config dump says the profile was not signed in"
|
||
|
||
This section read the faulting run's `[Profiles]` block —
|
||
`logged_profile_slot_0_xuid = ""` — as evidence that nobody was signed in.
|
||
**That inference is wrong, and I refuted it with a direct test.**
|
||
|
||
Xenia prints its config dump **before applying command-line overrides**. In a run
|
||
launched with `--apu=sdl --hid=file --mute=true --log_mask=13`, the dump says:
|
||
|
||
| dumped | actually passed |
|
||
|---|---|
|
||
| `apu = "any"` | `--apu=sdl` |
|
||
| `hid = "any"` | `--hid=file` |
|
||
| `mute = false` | `--mute=true` |
|
||
| `log_mask = 0` | `--log_mask=13` |
|
||
|
||
Four for four. **The dump is the config *file*, not the run.** So
|
||
`logged_profile_slot_0_xuid = ""` says only that the file is empty; the faulting
|
||
run may well have had the flag on its command line, and this page cannot tell.
|
||
|
||
⚠️ **Anything in this corpus that cites a Canary config dump as evidence of what a
|
||
run did is making the same mistake.** The dump is a statement about
|
||
`xenia-canary.config.toml`. To know a run's settings, record its **argv**.
|
||
|
||
What survives untouched: the *mechanism* (swallow → unbounded pump → failed
|
||
allocation → fault), which rests on the `[RE-INPUT]` log counter and the crash
|
||
dump's register file, neither of which is a config dump; and
|
||
[`canary-scripted-input-traps.md`](../canary-scripted-input-traps.md) §3's measured
|
||
claim that a profile-less Ⓐ pops the sign-in dialog, which is somebody else's
|
||
observation with a capture behind it.
|
||
|
||
What does **not** survive: this page's claim to know the faulting run's profile
|
||
state. It does not.
|
||
|
||
### And the call site is now located in the image, not only observed
|
||
|
||
`sub_821D03A0` is the state machine that raises it, verified byte-for-byte
|
||
(**85 instructions, 0 mismatches**):
|
||
|
||
| state at `[[r31+8]+4]` | branch | call |
|
||
|---|---|---|
|
||
| **0** | `0x821D04CC` | `li r4,1; li r3,1; bl 0x824A9068` → thunk `0x8284DA8C` = **`XamShowSigninUI(1, 1)`** |
|
||
| **3** | `0x821D04A4` | `bl 0x824A9080` → `0x8284DABC` = `XamShowDeviceSelectorUI` |
|
||
| other | — | nothing |
|
||
|
||
So the two candidates this page listed are both real branches of one function, and
|
||
the run took the **state-0** one. The device selector was already ruled out by
|
||
`storage_selection_dialog = false`; this identifies the other by address rather
|
||
than by elimination.
|
||
|
||
Canary's `xeXamShowSigninUI` then sets the flag and dispatches `ui::SigninUI`
|
||
**asynchronously with a no-op close handler** — so in an unattended run nothing
|
||
ever dismisses it, and the swallow is permanent.
|
||
|
||
### The correlation runs through the tooling, not just this one log
|
||
|
||
| launcher | passes `--logged_profile_slot_0_xuid` | Ⓐ outcome |
|
||
|---|---|---|
|
||
| `boot_menu.sh` | **yes** | Q4/Q5 pressed all five menu buttons |
|
||
| `frame_clock.sh` (this run) | **no** | faulted, 4/4 |
|
||
|
||
### 🔴 The process failure, which is the part worth keeping
|
||
|
||
This page said *"it does not explain how Q4/Q5 pressed Ⓐ successfully; what differs
|
||
is unfound."* **It was found, twice, and written down in two places this page did
|
||
not read** — a sibling `docs/re/` page and a tool header. The corpus knew the
|
||
swallow and knew the profile requirement; nobody had joined either to the crash.
|
||
|
||
What this session actually adds is the **join**: that the known input blackout is
|
||
what drives an unbounded guest queue into a failed 128 MB allocation, with the
|
||
counter and the register file agreeing to 7. Recorded in
|
||
[`METHOD.md`](../METHOD.md).
|
||
|
||
## What this unblocks, and how
|
||
|
||
The blocked list — main-menu sweeps, whether a `.tbm` draws pixels, `pbafc.prm`'s
|
||
blend — needs a screen behind an Ⓐ press.
|
||
|
||
✅ **There is no blocker. Boot with `tools/re-capture/boot_menu.sh`**, which signs
|
||
the existing profile in, and the state-0 branch never fires. That is the launcher
|
||
Q4 and Q5 used, and it has been in the tree the whole time.
|
||
|
||
### ✅ The A/B has now been run (2026-08-30), and it confirms the mechanism
|
||
|
||
Two boots, same binary, same ISO, one Ⓐ tap each, fired only after the plate's pulse
|
||
had been seen for 12 consecutive samples. **argv recorded per leg**, because the
|
||
config dump provably cannot say (see the retraction above).
|
||
|
||
| leg | profile flag | swallow lines | crash dumps | final glyph | outcome |
|
||
|---|---|---|---|---|---|
|
||
| **A** | *none* | **3 811** and climbing | 0 (I stopped it) | — | swallow storm |
|
||
| **B** | `--logged_profile_slot_0_xuid=B13EBABEBABEBABE` | **0** | 0 | **327** | **main menu** |
|
||
|
||
**327 is the documented main-menu glyph count** (`live-main-menu.png`), reproduced
|
||
by this instrument's own control. So leg B's Ⓐ opened the menu:
|
||
[capture](../captures/title-builds/live-ab-signedin-menu-after-press.png).
|
||
Series for both legs: [`../data/a-press-ab-legs.txt`](../data/a-press-ab-legs.txt).
|
||
|
||
⚠️ **Leg A demonstrates the SWALLOW, not the crash.** I stopped it at 3 811 report
|
||
lines — ~2.3 M swallowed calls — because kernel tracing at `log_level=3` was eating
|
||
the 300 MB size budget the crash dumps need. The crash arrives at ~13 982 report
|
||
lines, and that part remains as it was: measured once, historically. **One run per
|
||
leg.**
|
||
|
||
🔴 **A void pair came first, and it is the reason the detector is what it is.** The
|
||
first attempt used a single frame over a glyph threshold and fired on the **intro
|
||
movie** — which throws green flashes of 1 298…5 433 lasting under a second — about
|
||
6 s before the title. Both legs pressed into the movie, both showed zero swallow,
|
||
and the pair meant nothing. (The presses were real: each skipped the rest of the
|
||
movie, which is Q9's behaviour.) The detector now requires 12 consecutive samples
|
||
inside a band the movie overshoots, and that rule was **replayed against the void
|
||
runs' own series as its control** — it declines the movie flash at 84.8 / 85.5 s and
|
||
fires at 93.9 / 94.7 s, inside the sustained pulse.
|
||
|
||
If it ever needs a belt-and-braces second route, patching `xam_input.cc:217` to
|
||
return `X_ERROR_EMPTY` instead of `X_ERROR_SUCCESS` terminates the pump immediately
|
||
and is closer to hardware — a real Xbox does not hand a game an infinite run of
|
||
empty keystrokes. That is an emulator change and must be recorded as one wherever
|
||
it is used.
|
||
|
||
⚠️ **Whichever route is taken, keep `tools/re-capture/frame_clock.sh`'s size guard.**
|
||
It killed this run at its 300 MB cap and worked exactly as designed; without it the
|
||
next fault fills a filesystem that was already at 91 %.
|
||
|
||
## It was never the same failure as the other two
|
||
|
||
| | PC | crash dumps | cause |
|
||
|---|---|---|---|
|
||
| cache-flush crash ([`../title-crash-stl-tree.md`](../title-crash-stl-tree.md)) | `0x82307128` | yes | different |
|
||
| loader stall ([`../canary-scripted-input-traps.md`](../canary-scripted-input-traps.md)) | — | **zero** | different |
|
||
| **this** | `0x824578A0` | 32 356 | **emulator input blackout → unbounded guest queue** |
|
||
|
||
And it explains the thing the old page could not: **why Q4 and Q5 pressed Ⓐ
|
||
successfully and these runs did not.** Nothing about the game differs. What differs
|
||
is whether a XAM dialog happened to be up, which is emulator state, not guest state
|
||
— so "it reproduced 4/4" and "it worked before" are both true and always were.
|
||
|
||
## 🔴 Also refuted: the earlier "unimplemented instruction" hypothesis
|
||
|
||
Kept from the previous version of this page because the negative still stands.
|
||
`break_on_unimplemented_instructions = true` looked like a one-flag fix; booting with
|
||
it false faults identically, and **no `Unimplemented instr` line is ever logged**.
|
||
That path emits its `XELOGE` *before* the guarded break, so its absence rules the
|
||
mechanism out. The dump comes from `Emulator::ExceptionCallback`, which fires on a
|
||
genuine guest exception.
|