Files
Sylpheed/docs/re/structures/title-a-press-fault.md
sylph-decoder 9e35fc26cc re: the A-press A/B is run -- signed-in profile, no swallow, menu opens
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
2026-08-30 08:05:43 +00:00

299 lines
15 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.
# 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 |
| 11851252 | **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 |
| 125415242 | 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.