diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 9b55ca8e..3ecd12b1 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -2529,7 +2529,69 @@ whatever it returns — the same reason a truncated log and a t=0 render both lo fine from inside. Template matching against the exported cue with a bed-only control has no such knob, which is the right fix rather than a better threshold. -## 2026-08-29 — the Ⓐ blocker, diagnosed +## 2026-08-30 — the Ⓐ blocker is SOLVED, and it was the emulator, not the game + +**This supersedes the section below, which stands as the record of the wrong +diagnosis it corrects.** Two things in it were wrong and one of them would have +sent the next probe to the wrong place. + +**What actually happens.** Xenia's `XamInputGetKeystrokeEx` returns +`X_ERROR_SUCCESS` with a *zeroed* keystroke on every call, for as long as any XAM +dialog is up (`xam_input.cc:197` — upstream Canary, not one of our patches). The +game's keystroke pump is an unbounded `while (GetKeystrokeEx(...) == SUCCESS) +queue.push_back(ks);`. A XAM dialog went up right after the third Ⓐ was delivered, +the pump queued **8 388 608** empty keystrokes, its vector reached 64 MB, it asked +for 128 MB, the allocation failed, the failure path left a stale stack pointer in +`r3` **unchecked**, and the copy walked off the top of the guest thread stack. + +**The number is the argument.** The Canary log's own counter says **8 388 601** +swallowed calls at the last report before the crash; the crash dump's `r29` says +the vector held **8 388 608** records. Two independent instruments, seven apart, +inside the 600-call reporting granularity. No further boot was needed — the +326 MB log from the failing run was still on disk. + +🔴 **The retraction that matters to anyone reading a Canary crash.** This page said +`r9` was "a wild pointer above 4 GB, never a guest address". It is a *host* address: +Xenia prints `si_addr`, and the guest is mapped at `0x100000000`. `0x1701D0000 − +0x100000000 = 0x701D0000`, which is exactly `r9` in the register dump — an ordinary +guest heap address on an uncommitted page. **Subtract `0x100000000` from every +`Access Violation … at 0x1________` before reading it.** + +**And it explains the thing that had no explanation:** why Q4 and Q5 pressed Ⓐ +successfully and four later runs did not. Nothing about the game differs. Whether a +XAM dialog happens to be up is *emulator* state — so "reproduced 4/4" and "it worked +before" were both true all along. + +**For you, concretely:** nothing you ship changes. No disc fact moved, no screen, +no timing. What changes is that the questions parked behind this — the BGM loop +point (Q10) and whether menu music ducks under a cutscene — are no longer parked +behind an unexplained crash. 🟡 They are not unblocked yet either: the route out is +one of three (dismiss the dialog, `--headless`, or patch the swallow to return +`X_ERROR_EMPTY`), none of them tried, and *which* dialog raises the flag is still +unknown. Do not plan around a date. + +Detail, with the disassembly and the log extract: +[`docs/re/structures/title-a-press-fault.md`](../re/structures/title-a-press-fault.md) +· [log extract](../re/data/a-press-fault-log-extract.txt) + +## ✅ 2026-08-30 — your ask #3: the two `press-a` captures are DIFFERENT FRAMES, so 0.301 % is not a floor + +You asked whether `live-title-press-a.png` and +`live-attract-title-press-a-band.png` came from different configurations, because +if they did not, the 0.301 % between them would be a floor under every full-frame +comparison in the corpus. **They are different frames of a moving screen, and your +own preferred reading is the right one.** + +Sliding the 1279×120 band down every row of the 1279×675 capture gives a sharp, +unambiguous minimum at **y = 520** (mean |Δ| 4.016, against 7.87/7.89 at ±1 row) — +the sharpness is the instrument's control. At that alignment they disagree on +**40.84 %** of pixels, max Δ **169**. A crop of the same frame would be zero. + +⚠️ Reach: this shows the two *captures* differ, not that the two *configurations* +agree — a moving background makes that unanswerable from these two images. +[`ui-title-build-map.md`](../re/ui-title-build-map.md) + +## 2026-08-29 — the Ⓐ blocker, diagnosed (superseded above; the wrong diagnosis, kept) Not something you need, but it bounds what I can still answer, so it is worth having in one place. diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 678b3daa..088b9ba0 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -179,4 +179,4 @@ files, which is how the same ground got covered twice. | [`structures/boot-splash-dwells-are-declared.md`](structures/boot-splash-dwells-are-declared.md) | How long each boot splash is shown | ✅ **decoded**: the dwells are the bundles' own declared timelines — publisher **t=0…255 = 4.250 s**, developer **t=0…210 = 3.500 s** at 60 units/s. The corpus's independent screenshot timing over 3 cold boots gives 4.30/4.60/4.37 and **3.51/3.50/3.37** — the developer agreeing to **1.1 %**, two of its three runs to 0.3 %. 🔴 **Wall clock is the wrong unit to author**: a fresh no-input boot measured the same two dwells at **5.10–5.61 s** and 3.83–4.30 s, 15–20 % longer than both the declared values and the corpus's runs, on the same disc — so a seconds figure is one run's emulator pacing. Boundaries from the draw stream: publisher wordmark frames 6–119, **3 frames with no sprite drawn**, developer glows 123, wordmarks 140–209, intro video 216. 🔴 **The frame→wall-clock instrument resolves to one BUFFER FLUSH, not one frame** — 69 of 125 samples showed no advance and the rest jumped 7–15 frames, making the apparent rate swing 0.0164–0.0316 s/frame; frames 119 and 123 fall in one burst, so the inter-splash gap is **not separable** by it. Quoted as brackets; sub-flush estimates withdrawn before reporting. ⚠️ `palogo_anima` never appears — almost certainly the 8-vertex cap (7 elements batched, 2 logged), the same trap as the `eff3` false negative, so it is named not reported. ❔ the publisher's 4.1 % error vs the developer's 1.1 % is unexplained | | [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(colour census + self-refutation)* | What colour a keyless element is, and which forced verdicts the argument actually supports | ✅ **decoded, disc-wide**: every full-screen `*eff00*` **primitive** is **pure black** at its various alphas (`ff000000`, `7f000000`, `40000000`, `b2000000`, `cc000000`, `d4000000`, `00000000`) — exactly an alpha-over dim or fade, and an *additive* black quad would be a no-op nobody would author. The **only** non-black primitive on the disc is `pbafc.prm`, RGB `00e8e0` cyan at alphas to `ff`, and it is **844×600, not full-screen**, so outside the backdrop rule's geometry guard — ❔ it is now the sole additive candidate. 🔴 **Self-refutation: of the 80 forced-first instances only 42 are `.prm`; 38 are `.tbm` carrying fade `ffffffff`.** A *solid* white quad painted first would make the screen white and no screen is white, so a `.tbm` is a white **modulation on a texture** — and element alpha does not establish its coverage. That is the `.t32` error one extension further out: I had fixed the symptom (`el.sprite.is_some()`) not the cause, **an element's alpha is not its texture's opacity, and only an untextured primitive makes the two the same fact**. So 42 verdicts stay **decoded**, 38 drop to 🟡 (still almost certainly right — all named `*base*`, full-screen, and `pfbase.tbm`'s first position is *measured* — but on a name-and-role argument this page elsewhere calls the weaker kind). ⚠️ Code deliberately unchanged: restricting to `.prm` would send eleven screens' backgrounds back to last, the blank-screen bug the rule fixed. Split pinned by a test | | [`structures/ui-prm-blend-mode.md`](structures/ui-prm-blend-mode.md) | Whether a primitive blends additively or alpha-over | ❔ **undecodable, with reach** — but the consequence is closed. Looked in **the bundle** (no field: the declaration words are constant and a primitive has no RATC child at all), **the colour census** (every full-screen `*eff00*` primitive is **pure black**; the only non-black primitive on the disc is `pbafc.prm`, cyan `00e8e0`), **the occlusion constraint** (inapplicable — `pbafc.prm` strobes 255/124 every 2 units, travels, and is scaled **2 %×3 %**, so it draws ~**17×18 px**, not its declared 844×600), and **the oracle** (`GP_READY_ROOM` is a recorded no-go and gameplay needs the Ⓐ that faults the guest). ✅ **Why it stopped mattering:** for a *black* quad the hypotheses differ only in whether it hides what is beneath — drawn **first** it is correct under **both**, drawn **last** only under additive. So `forced_backdrop`'s verdict is robust to the open question, and the port's original "layerless sorts last" was wrong under alpha-over and merely pointless under additive. ⚠️ This is not evidence *for* alpha-over. 🔴 The investigation found `forced_backdrop` judged coverage from the **pivot alone**, ignoring scale; checked first, **all 80 forced instances are at 100 %**, so no verdict moved and the added guard is defensive | -| [`structures/title-a-press-fault.md`](structures/title-a-press-fault.md) | Why a single Ⓐ on the title faults the guest — the blocker on all menu-side dynamic RE | ✅ **measured**, 4 runs: `==== CRASH DUMP ====` with **`PC: 0x824578A0`**, *Access Violation: write at `0x00000001701D0000`*, repeating **32 356×** and writing **326 MB** in ~10 s. Four Ⓐ runs faulted; four no-input runs in the same sessions completed. 🔴 **Refuted, my own hypothesis**: it is NOT an unimplemented instruction — `--break_on_unimplemented_instructions=false` faults identically and **no `Unimplemented instr` line is ever logged**, and since that path emits its `XELOGE` *before* the guarded `DebugBreak()`, its absence rules the mechanism out. The dump comes from `Emulator::ExceptionCallback`, i.e. a genuine guest exception. ✅ **Instruction read from the image** (primary): `b0c90000` = **`sth r6, 0(r9)`**, first of four halfword stores at 0/2/4/6 through `r9` inside a `bne-` loop filling 8-byte records — so **`r9` is a wild pointer**, and `0x1701D0000` is above 4 GB, outside the guest's 32-bit space entirely. DB agrees: `sub_82457780`, +0x120. 🔴 **A THIRD failure mode**: not the cache-flush crash (`0x82307128`) and not the loader stall (which logs **zero** crash dumps) — and unlike the latter it reproduced 4/4, so "retry whole boots" does not obviously apply. ⚠️ Does **not** explain the corpus's earlier successes (Q4/Q5 pressed Ⓐ fine); what differs is unfound. ✅ `frame_clock.sh`'s 300 MB guard killed the run as designed | +| [`structures/title-a-press-fault.md`](structures/title-a-press-fault.md) | Why a single Ⓐ on the title faults the guest — the blocker on all menu-side dynamic RE | ✅ **SOLVED 2026-08-30, and it is the emulator, not the game.** Xenia returns `X_ERROR_SUCCESS` with a *zeroed* keystroke on every `XamInputGetKeystrokeEx` while a XAM dialog is up (`xam_input.cc:197`, upstream); the game's pump is an **unbounded** `while (GetKeystrokeEx()==SUCCESS) queue.push_back()`, so it 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 top of the guest thread stack. ✅ **The number is the argument**: the Canary counter reports **8 388 601** swallowed calls at the last report before the crash, the dump's `r29` says the vector held **8 388 608** — two independent instruments, 7 apart, inside the 600-call reporting granularity. No new boot: the failing run's 326 MB log was still on disk. 🔴 **RETRACTED — "`r9` is a wild pointer above 4 GB"**. Xenia prints `si_addr`, a *host* address, and the guest is mapped at `0x100000000`: `0x1701D0000 − 0x100000000 = 0x701D0000`, which **is** `r9` in the dump — an ordinary guest heap address on an uncommitted page. Subtract `0x100000000` from every `Access Violation … at 0x1________` before reading it. ✅ **Decoded code path**, image-checked with **0 mismatches** over 586 instructions: `sub_824574C0` the input-manager singleton at `0x828F3888`, `sub_82457038` the keystroke pump, `sub_82457780` its `vector` insert-with-grow. ✅ **It explains the earlier successes**: whether a XAM dialog is up is *emulator* state, so "reproduced 4/4" and "Q4/Q5 pressed Ⓐ fine" were both always true. 🟡 **Which** dialog is still open — `XamShowDeviceSelectorUI` is ruled out (`storage_selection_dialog = false` takes the headless path), `XamShowSigninUI` / `XamShowMessageBoxUIEx` are not; the settling experiment is one log line per `is_xam_dialog_present_.store(true)` site, not another blind boot. 🟡 Three untried routes out: dismiss the dialog, `--headless`, or return `X_ERROR_EMPTY` from the swallow. ✅ `frame_clock.sh`'s 300 MB guard killed the run as designed — keep it | diff --git a/docs/re/data/a-press-fault-log-extract.txt b/docs/re/data/a-press-fault-log-extract.txt new file mode 100644 index 00000000..8bc0eae0 --- /dev/null +++ b/docs/re/data/a-press-fault-log-extract.txt @@ -0,0 +1,138 @@ +# Extract from the Ⓐ-press fault run's xenia.log (2026-08-29) +# +# Source: /sylph-home/re/canary-build/bin/Linux/Release/xenia.log, 326 921 343 bytes, +# mtime 2026-08-29 22:15. 32 356 '==== CRASH DUMP ====' blocks. This file is the +# part that carries the diagnosis; the whole log is not committed (326 MB). +# +# ---- 1. the three real Ⓐ keystrokes, then the swallow begins (log lines 1180-1262) + +i> 0100000C [UI-CAP] writing xenia_re_ui_draws_01.log (from frame 0) +!> 0001278F MEM-WATCH rss=590MB (peak 590MB) vsz=19185MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=590MB (peak 590MB) vsz=19189MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40 +i> F8000008 [file-pad] #3 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0 +i> F8000008 [file-pad] keystroke vk=5800 down +i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003) +i> F8000008 [file-pad] #4 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0 +i> F8000008 [file-pad] keystroke vk=5800 up +i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003) +!> 0001278F MEM-WATCH rss=594MB (peak 594MB) vsz=19189MB malloc_inuse=314MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=595MB (peak 595MB) vsz=19189MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40 +i> F8000008 XThreadF80000B8 (1A) Stack: 70880000-70900000 +K> F80000B8 XThread::Execute thid 26 (handle=F80000B8, 'XThread76FFE6C0 (F80000B8)', native=76FFE6C0) +F> F80000B8 HostPathDevice::ResolvePath(\aab216c3\5\c10eae6) +F> F80000B8 HostPathDevice::ResolvePath(\aab216c3\5) +F> F8000084 DiscImageDevice::ResolvePath(\dat) +F> F8000008 DiscImageDevice::ResolvePath(\dat\movie) +F> F8000008 DiscImageDevice::ResolvePath(\dat\movie) +i> F8000008 XThreadF8000154 (1B) Stack: 70880000-70890000 +i> F8000008 XThreadF8000158 (1C) Stack: 708B0000-708C0000 +i> F8000008 XThreadF800015C (1D) Stack: 708E0000-708F0000 +i> F8000008 XThreadF8000160 (1E) Stack: 70910000-70920000 +K> F8000158 XThread::Execute thid 28 (handle=F8000158, 'XThread6FFFF6C0 (F8000158)', native=6FFFF6C0) +K> F8000154 XThread::Execute thid 27 (handle=F8000154, 'XThread75FFD6C0 (F8000154)', native=75FFD6C0) +K> F800015C XThread::Execute thid 29 (handle=F800015C, 'XThread6EFFE6C0 (F800015C)', native=6EFFE6C0) +!> 0001278F MEM-WATCH rss=660MB (peak 660MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=661MB (peak 661MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=661MB (peak 661MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40 +i> F8000008 [file-pad] #5 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0 +i> F8000008 [file-pad] keystroke vk=5800 down +i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003) +i> F8000008 [file-pad] #6 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0 +i> F8000008 [file-pad] keystroke vk=5800 up +i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003) +w> F8000008 XThread::Resume: host resume was refused for thread F8000154 +w> F8000008 XThread::Resume: host resume was refused for thread F8000158 +w> F8000008 XThread::Resume: host resume was refused for thread F800015C +K> F8000160 XThread::Execute thid 30 (handle=F8000160, 'XThread6DFFD6C0 (F8000160)', native=6DFFD6C0) +w> F8000008 XThread::Resume: host resume was refused for thread F8000154 +w> F8000008 XThread::Resume: host resume was refused for thread F8000158 +w> F8000008 XThread::Resume: host resume was refused for thread F800015C +w> F8000008 XThread::Resume: host resume was refused for thread F8000160 +w> F8000008 XThread::Resume: host resume was refused for thread F8000160 +w> F8000008 XThread::Resume: host resume was refused for thread F8000160 +w> F8000008 XThread::Resume: host resume was refused for thread F8000160 +w> F8000008 XThread::Resume: host resume was refused for thread F8000160 +w> F8000008 XThread::Resume: host resume was refused for thread F8000160 +!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1E4B0E00 heap_base=00000000 page=124080 owning_region_start=1C220000 region_page_count=14976 state=03 +!> F8000008 PhysicalHeap::Release failed due to parent heap failure +!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1E7A8F00 heap_base=00000000 page=124840 owning_region_start=1C220000 region_page_count=14976 state=03 +!> F8000008 PhysicalHeap::Release failed due to parent heap failure +F> F8000084 DiscImageDevice::ResolvePath(\dat) +!> 0001278F MEM-WATCH rss=830MB (peak 830MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=832MB (peak 832MB) vsz=19501MB malloc_inuse=348MB mmap=90MB cache_deque=40 cache_list=40 +!> 0001278F MEM-WATCH rss=832MB (peak 832MB) vsz=19501MB malloc_inuse=348MB mmap=90MB cache_deque=40 cache_list=40 +i> F8000008 [file-pad] #7 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0 +i> F8000008 [file-pad] keystroke vk=5800 down +i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003) +i> F8000008 XThreadF80000D4 (1F) Stack: 70880000-70900000 +K> F80000D4 XThread::Execute thid 31 (handle=F80000D4, 'XThread9BFFF6C0 (F80000D4)', native=9BFFF6C0) +F> F80000D4 HostPathDevice::ResolvePath(\aab216c3\a\c7e701e) +F> F80000D4 HostPathDevice::ResolvePath(\aab216c3\a) +F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\e\b80b1a0) +F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\e) +F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\c\6dea48b) +F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\c) +i> F8000008 [file-pad] #8 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0 +i> F8000008 [file-pad] keystroke vk=5800 up +i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003) +!> 0001278F MEM-WATCH rss=833MB (peak 833MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=41 +w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1 so far) +!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1DA98C80 heap_base=00000000 page=121496 owning_region_start=1C220000 region_page_count=14976 state=03 +!> F8000008 PhysicalHeap::Release failed due to parent heap failure +w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 601 so far) +w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1201 so far) +w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1801 so far) +w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 2401 so far) +w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 3001 so far) +w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 3601 so far) + +# ---- 2. the last swallow report before the first crash dump (log line <15243) +15236:w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 8388001 so far) +15237:w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 8388601 so far) +# total 'swallowed' report lines before the first crash dump: +13982 +# they are emitted every 600th call, so swallowed calls ~= 600 x that count + +# ---- 3. the first crash dump's GPRs (log line 15243+) +!> F8000008 ==== CRASH DUMP ==== +Thread ID (Host: 0xEEFFE6C0 / Guest: 0x00000006) +Thread Handle: 0xF8000008 +PC: 0x824578A0 +Access Violation: write at 0x00000001701D0000 +Registers: + r0 = 0000000000000000 + r1 = 00000000701CF7B0 + r2 = 0000000020000000 + r3 = 00000000701CF5F0 + r4 = 0000000000000000 + r5 = 0000000000000000 + r6 = 0000000000000000 + r7 = 00000000A3AC0000 + r8 = 00000000701D0008 + r9 = 00000000701D0000 + r10 = 0000000000000000 + r11 = 00000000A3AC0A18 + r12 = 0000000082457864 + r13 = 000000003001E000 + r14 = 0000000000000000 + r15 = 0000000000000000 + r16 = 0000000000000000 + r17 = 0000000000000000 + r18 = 00000000BCE24BFC + r19 = 0000000000000001 + r20 = 0000000000000000 + r21 = FFFFFFFFFFFFFFFF + r22 = 00000000BC65D540 + r23 = FFFFFFFF828F3844 + r24 = 000000000000052F + r25 = FFFFFFFF828E0000 + r26 = 0000000000800001 + r27 = 0000000001000000 + r28 = 00000000701CF898 + r29 = 0000000000800000 + r30 = FFFFFFFF828F38CC + r31 = 00000000A7AC0000 diff --git a/docs/re/structures/title-a-press-fault.md b/docs/re/structures/title-a-press-fault.md index 21684a74..73067e25 100644 --- a/docs/re/structures/title-a-press-fault.md +++ b/docs/re/structures/title-a-press-fault.md @@ -1,91 +1,205 @@ -# Pressing Ⓐ on the title faults the guest — diagnosed, and it is a third failure +# Pressing Ⓐ on the title faults the guest — SOLVED, and it is the emulator swallowing input -**Classification: measured.** Xenia Canary, 2026-08-29, four runs. This is the -blocker that gates every menu-side dynamic question in this container. +**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. -## What happens +## The one-line answer -A single Ⓐ press on the title screen produces a Xenia **`==== CRASH DUMP ====`**: +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 ``` -PC: 0x824578A0 -Access Violation: write at 0x00000001701D0000 + r9 = 00000000701D0000 Access Violation: write at 0x00000001701D0000 ``` -It repeats **32 356 times** in one run, writing **326 MB** of register dump to -stdout in roughly ten seconds. Four runs that pressed Ⓐ on the title all faulted; -four runs in the same sessions that pressed nothing all completed normally. +`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. -## 🔴 Refuted: my own hypothesis, that it was an unimplemented instruction +⚠️ **Generalise this.** Every `Access Violation: … at 0x1________` in a Canary log +from this container is a guest address plus `0x100000000`. Subtract before reading. -The config dump carries `break_on_unimplemented_instructions = true`, and Xenia's -own message for that path reads *"report the game to Xenia developers; to skip, -disable break_on_unimplemented_instructions"* — so the flag looked like the fix. +## The code path, read out of the image (0 mismatches against `sylpheed.db`) -**It is not.** Booting with `--break_on_unimplemented_instructions=false` faulted -identically, and **no `Unimplemented instr` line is ever logged**, on stdout or -stderr, in any run. That path emits its `XELOGE` *before* the guarded -`DebugBreak()`, so its absence rules the mechanism out rather than leaving it open. +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. -The dump comes from `Emulator::ExceptionCallback` (`emulator.cc:1468`), which fires -on a **genuine guest exception** — an access violation or illegal instruction -inside guest code — not on a translation failure. - -## The faulting instruction, read from the image - -`sylpheed.db` puts the PC inside `sub_82457780` (0x82457780…0x82457958, non-leaf, -frame 144), +0x120 in. The image is primary and gives the instruction itself: - -| address | word | instruction | +| | what it is | how that is known | |---|---|---| -| **0x824578A0** | `b0c90000` | **`sth r6, 0(r9)`** ← faults | -| 0x824578A4 | `b0a90002` | `sth r5, 2(r9)` | -| 0x824578A8 | `b0890004` | `sth r4, 4(r9)` | -| 0x824578AC | `b1490006` | `sth r10, 6(r9)` | -| 0x824578B0 | `409affcc` | `bne-` — loops back | +| `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 | -Four consecutive halfword stores at offsets 0/2/4/6 through **`r9`**, inside a -loop: the code is filling an array of 8-byte records with four `u16` fields each. +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. -**`r9` is a wild pointer.** The faulting address `0x1701D0000` is above 4 GB and so -outside the guest's 32-bit address space entirely — not a null dereference and not -a small overrun, but a base that was never a guest address. +The pump, in C: -## It is a *third* failure mode, not either known one +```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 +} +``` -| | PC | crash dumps | this | +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. + +## 🟡 What is still open: *which* dialog + +`is_xam_dialog_present_` is a single bool with no logging on the setting side, so the +log says a XAM dialog exists and not which one. The static reach: + +* `XamShowDeviceSelectorUI` — **ruled out**. The run's own config dump carries + `storage_selection_dialog = false`, and `xam_ui.cc:550` takes the headless path in + that case, which never sets the flag. +* `XamShowSigninUI` and `XamShowMessageBoxUIEx` — both set it, both are imported, and + both reach the guest. `sub_821D03A0` calls the game's `XamShowSigninUI` **and** + `XamShowDeviceSelectorUI` wrappers, which is the shape of a "sign in, then pick + storage" flow — exactly what a title screen's Ⓐ would start. +* `XamShowKeyboardUI`, `XamShowDirtyDiscErrorUI` — imported; not excluded, but neither + fits the moment. + +**The experiment that settles it** is one line of Canary, not another blind boot: log +the function name at each `is_xam_dialog_present_.store(true)` site in `xam_ui.cc`. +Recorded rather than done, because it is an emulator change and this iteration's +budget went to the diagnosis. + +## 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. Three routes now exist where before there +were none, in cost order: + +1. **Dismiss the dialog.** It is an ImGui window on the emulator surface; the run had + a display. If it can be clicked or key-dismissed, the flag clears and the pump + drains normally. Cheapest, and testable in one boot. +2. **`--headless`.** Both `xeXamShowSigninUI` and `XamShowMessageBoxUIEx` take a + dispatch-headless path that never sets the flag. ⚠️ It also removes the window the + capture harness grabs, so this trades the fault for a different blocker. +3. **Patch the swallow.** Returning `X_ERROR_EMPTY` instead of `X_ERROR_SUCCESS` at + `xam_input.cc:217` terminates the pump immediately and is closer to hardware + (a real Xbox does not hand a game an infinite run of empty keystrokes). This is + an emulator change and needs to 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 PC | -| loader stall ([`canary-scripted-input-traps.md`](../canary-scripted-input-traps.md)) | — | **zero** | ❌ this has 32 356 | -| **this** | **`0x824578A0`** | 32 356 | — | +| 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** | -⚠️ So the advice in the input-traps page — *"retry whole boots"*, because that -failure is not deterministic — does not obviously apply: this one reproduced on -**4 of 4** attempts. +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. -⚠️ **And it does not explain the corpus's earlier successes.** Q4 and Q5 measured -all five main-menu buttons, and the focus ring was timed on the menu, so Ⓐ worked -then. What differs between those runs and these has not been found. +## 🔴 Also refuted: the earlier "unimplemented instruction" hypothesis -## The disk hazard, quantified - -A guest fault writes an **unbounded** register dump: 32 register lines, 32 float -lines and 128 vector lines per fault, at ~30 MB/s, on a filesystem at 91 %. - -✅ `tools/re-capture/frame_clock.sh`'s size guard killed this run at its 300 MB cap -and worked exactly as designed — the session log's `EMULATOR GONE at 56s` is the -guard, not the crash. **Any scripted run that presses a button needs it.** - -## What this blocks - -The main-menu sweeps; whether a `.tbm` draws pixels (needs `GP_SAVE_LOAD`, -`GP_BUNK` or `GP_DEBRIEFING_PILOTLOG`); `pbafc.prm`'s blend (needs -`GP_READY_ROOM`); and any other menu-side question. - -## Where the next probe goes - -At **`sub_82457780`** and what sets `r9`. The function is non-leaf with a 144-byte -frame; the loop writes 8-byte records. Whether `r9` comes from an allocation that -failed, a table the guest expects the loader to have filled, or a pointer read back -from a structure, is the question — and the corpus's note that the *loader thread* -does no file I/O in a failed run is suggestive but is a different failure. +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. diff --git a/docs/re/ui-title-build-map.md b/docs/re/ui-title-build-map.md index bd873f57..620e8818 100644 --- a/docs/re/ui-title-build-map.md +++ b/docs/re/ui-title-build-map.md @@ -273,6 +273,36 @@ This is consistent with, and adds nothing to, the draw-quad comparison in [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md): the plate is not the tell that distinguishes the boot title from the attract title. +### ✅ Refutation attempt (2026-08-30) — the two `press-a` captures are DIFFERENT FRAMES + +The port asked whether `live-title-press-a.png` and +`live-attract-title-press-a-band.png` were captured the same way, because it +measures **0.301 %** between them and — if they were the same frame — that number +would be a **floor under every full-frame comparison in this corpus**. That is the +expensive reading, so it is the one worth attacking. + +**The attempt to confirm it failed; they are two different moments.** The band is +1279×120 and the full capture 1279×675, so the band was slid down every row of the +full frame and scored by mean |Δ|. The alignment is unambiguous — a sharp minimum, +which is the instrument's own control: + +| y offset | mean abs Δ | +|---|---| +| 519 | 7.887 | +| **520** | **4.016** | +| 521 | 7.872 | + +At that best alignment the two disagree on **40.84 %** of the band's pixels +(32.56 % by more than 1), mean |Δ| **4.02**, max **169**. A crop of the same frame +would be zero. So the band is a different instant of a moving screen — the movie +still running behind the plate — and the port's own preferred reading is right. + +**So 0.301 % is not an instrument floor**, and no full-frame figure in this corpus +needs to be discounted by it. ⚠️ Reach: this says the two *captures* differ; it +says nothing about whether the two *configurations* differ, because a moving +background makes that unanswerable from these two images. A configuration +comparison needs two captures of a static screen taken deliberately. + **`EXTRAS` is the only main-menu destination inside `GP_TITLE`.** Ⓐ on `EXTRAS` opens build 6 — measured. The other four destinations leave the archive: Ⓐ on `LOAD GAME` opened a `LOAD GAME` slot list, and Ⓐ on `MISSION SELECT` inside