This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/canary-scripted-input-traps.md
Sylpheed RE agent d0e3dc6f33 docs/re: three more runs, and eh_dispatch is still untested — with the proof why
Drove NEW GAME deliberately (the menu's first item, no d-pad, which is what the
run that first hit the crash actually did). Three runs with --eh_dispatch on: no
crash, no throw, black screen before the save-slot screen. It is tempting to read
that as the flag working.

The run with --cache_throw_diag as well disproves it. That cvar logs the throw
BEFORE any dispatch is attempted, so a throw would appear whatever dispatch then
did — and there were zero. No guest exception happened, so neither flag ran any
code, so neither can explain the difference. What took those runs out is the
intermittent content-load hang, before the crash path was reached.

eh_dispatch therefore stays untested, and the entry says so with the reasoning
rather than banking a false pass.

Confirmed on the way: the screen after NEW GAME is DIFFICULTY — its whole-image
signature matches the earlier capture exactly — so the menu path is understood
even though the runs die after it.
2026-08-19 04:43:25 +00:00

384 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.
# Getting past the title screen in the container — three traps and one blocker
**Status:**`CONFIRMED` for the three traps (each reproduced, and two of them
fixed in the toolkit). 🔴 **blocked** on reaching the main menu: the guest reads
Ⓐ at the title, and the step *behind* Ⓐ either crashes it or stalls it.
Written down because each of these presents as "the pad is dead" or "the game
ignores input", and each has a completely different cause.
## 1. F10 opens the emulator's menu bar, and that swallows every guest keystroke
F10 is the RE hotkey (ship capture, and now `log_ui_draws`). It is *also* the
toolkit key that opens the application menu bar, and it does both. With that menu
open, `XamState::IsUIActive()` is true, so `XamInputGetKeystrokeEx` returns
`X_ERROR_SUCCESS` with a **zeroed** keystroke before the driver is ever asked
(`xam_input.cc`). The guest polls, gets an empty event, and does nothing —
forever, and with no error anywhere.
This cost most of an iteration. Symptom: a run presses F10 to arm a capture,
then every later Ⓐ does nothing; the title sits there while the pad log happily
prints `[file-pad] keystroke vk=5800 down`. Screenshot
[`captures/title-with-emulator-menu-open.png`](captures/title-with-emulator-menu-open.png)
shows the giveaway — `Open… / Open Recent / Install Content… / Exit` over the
title art. It is easy to miss because the menu is at the far left of the window
and the game is still animating behind it.
**Fix:** after any F10, click once on the game surface to dismiss the menu.
`Escape` does *not* close it. `ui_draw_capture.sh` now does this.
## 2. The pad is fine — the driver was never the problem
Worth stating because the file-pad's own history (`--hid=file`, `GetKeystroke`
implemented specifically so "PRESS Ⓐ BUTTON" would work) makes it the natural
suspect. It delivers: the log shows `vk=5800 down`/`up` per tap, and Ⓐ during a
movie skips the movie, every time. When the title ignores Ⓐ, the pad is not why.
## 3. Ⓐ at the title needs a signed-in profile — and this container cannot type one in
With no profile, Ⓐ **is** handled: the guest calls `XamShowSigninUI` and Xenia
pops its Sign In dialog ([`captures/title-signin-dialog.png`](captures/title-signin-dialog.png)),
`ProfileManager: Found 0 Profiles`. That dialog is the proof the guest reads the
button.
The dialog cannot be completed from here: "Create Profile" opens an **ImGui text
field** for a gamertag, and synthetic X key events do not reach it. Tried, all
with the window focused (`xdotool getwindowfocus` = the game window):
`xdotool type --window`, `xdotool type` via XTEST, single `xdotool key` presses,
clicking the field first. The field stays empty; `Create` with an empty gamertag
is refused. Mouse clicks *do* work (they opened the dialog and the sub-panel), so
it is text entry specifically.
Xenia's own **Profile ▸ Show Profile Menu** panel does have a working `Create
Profile` button, and mouse clicks reach it — but the gamertag it then asks for is
the same ImGui field, so the UI route dead-ends in the same place.
**Fix, landed in Canary** (`auto/re-ui-draw-order`): a `--create_profile_if_none=<gamertag>`
cvar that calls `ProfileManager::CreateProfile(tag, autologin, default_xuid)` at
startup when no profile exists. `default_xuid` is fixed at `B13EBABEBABEBABE`, so
a script can then pass `--logged_profile_slot_0_xuid=B13EBABEBABEBABE` and be
deterministic. Verified: `ProfileManager: RE bootstrap profile 'SylphRE' ->
created`, then on the next boot `Found 1 Profiles` / `Loaded SylphRE (GUID:
B13EBABEBABEBABE) to slot 0`, and the Profiles Menu panel shows the user
([`captures/xenia-profile-panel-signed-in.png`](captures/xenia-profile-panel-signed-in.png)).
The profile persists on disk, so later runs need only
`--logged_profile_slot_0_xuid=B13EBABEBABEBABE`.
## Withdrawn: Ⓐ is not blocked — it works, intermittently
**The main menu has been reached**
([`captures/main-menu-reached.png`](captures/main-menu-reached.png) — NEW GAME /
LOAD GAME / TUTORIAL / OPTIONS / EXTRAS). So the previous version of this entry,
which called the title's Ⓐ a hard blocker, is **withdrawn**. What is true is
narrower and more annoying: **Ⓐ advances the title only sometimes** — one success
in roughly four attempts, with the press verifiably delivered every time
(`[file-pad] keystroke vk=5800 down/up` in the log) and no Xenia UI active.
The successful press landed on the **first** title after boot, before any attract
cycle; the failures were on a title that had come round again. That is a
hypothesis, not a rule — a later attempt on a first title also failed (the screen
flickered to something else for one sample and came back), so timing is involved
and the window has not been pinned down.
## What was eliminated on the way, so nobody re-walks it
* **`IsUIActive()` is now observable.** `XamInputGetKeystrokeEx` returns
`X_ERROR_SUCCESS` with a zeroed keystroke *before consulting any driver* when a
Xenia dialog is up — an invisible input blackout. Canary now logs
`[RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (dialogs=N …)` when
that happens. On the runs where Ⓐ failed, **it never fires**, so the blackout
is not the cause there. (It *is* the cause after F10 — trap 1 above.)
* **The driver filter is fine.** The game polls with `flags = 3`, and
`InputSystem::FilterDrivers` keeps a driver when `flags & InputType` is
non-zero; the file pad reports `Controller = 1`, so it is selected.
* **The game does not query sign-in on the press.** Tracing every `Xam*` call
around a press shows only `XamInputGetCapabilities`, `XamInputGetKeystrokeEx`
and `XamInputGetState` — no content, user or signin call at all.
### Two traps in the measuring rig itself, which cost more than the bug
* **A FIFO trace consumer that exits stalls the emulator.** `--phase_a_event_log_path`
writes 1.5 GB a minute, so it was pointed at a named pipe with a `grep` on the
other end. When that reader goes away, the emulator blocks on the write — and
the symptom is *exactly* "the pad is dead": the guest stops polling, the driver
never refreshes, nothing is logged. Two runs' worth of "the driver never
reacts" was this, not the game.
* **The trace's `return_value` is a placeholder.** `phase_a_bridge::EmitReturn`
is called as `EmitReturn(name, 0)` — literally zero — so every `kernel.return`
event reads `"status": "0x00000000"`. "Every keystroke call returns SUCCESS"
looked like hard evidence for the `IsUIActive` theory and means nothing. The
theory had to be tested with a real log line instead.
## The scripted reproduction was tried, and it failed
"Tap Ⓐ once per second and see whether the success rate goes to 1" was the next
step this entry named. It was run (`tools/re-capture/menu_draw_capture.sh`), and
the answer is **no**:
| run | what was pressed | result |
|---|---|---|
| E | **one** Ⓐ on the first title after boot | **main menu** |
| F | **40** × Ⓐ at 1/s on the title, then one each of START, B, BACK, X, Y | title, unchanged |
| G | Ⓐ every 4 s through the whole boot (88 presses) | permanent **black screen**, no crash, never recovers |
In run F every press was delivered — the driver logs `keystroke vk=5800 down/up`
for each — and the new `[RE-INPUT]` line never fired, so nothing was swallowed.
The guest received them and ignored them. **No button does anything** on that
title: START, B, BACK, X and Y are as inert as Ⓐ
([`captures/title-unresponsive-attract.png`](captures/title-unresponsive-attract.png)).
Run G is worth its own warning: hammering Ⓐ through the boot is not merely
useless, it **breaks the run** — 88 presses left a black screen that never came
back (no crash, no throw, the emulator healthy). The script no longer taps
during boot; the intro takes ~3.5 minutes and gets to the title by itself.
**So the difference is not the number of presses or the button — it is which
title you are looking at**, and that is now measured rather than guessed:
* the title that **ends the boot sequence** accepts a single Ⓐ and opens the main
menu — **2 of 2**, in independent runs, one of which never pressed F10;
* the title the **attract loop returns to** accepts nothing — Ⓐ, START, B, BACK,
X, Y, dozens of presses, every one delivered, none swallowed.
**The recipe, then:** launch, wait for the first `title` classification, tap Ⓐ
once. Do not tap during the boot, and do not wait for a second title.
The tell is *not* visible in what the game draws — a `log_ui_draws` capture in
each state gives 13 quads at identical rects, `ptbtn00` (the `PRESS Ⓐ BUTTON`
plate) included in both. See
[`ui-title-paint-order-capture.md`](ui-title-paint-order-capture.md).
## Still open
**Why** the attract title stops accepting input, given that it draws exactly the
same thing. Nothing in the composition, the input path or the sign-in state
differs; the difference is in the guest's own state machine, and reading it means
reading that code.
Not open any more, and worth stating because it was the thing blocking the RE:
reaching the main menu is now routine (first title, one Ⓐ), and the second
screen's paint order has been captured with it.
## 2026-08-19 — the "first title accepts Ⓐ" rule is weaker than stated, and there is a confound
The entry above says the boot title accepts a single Ⓐ, 2 of 2. Continuing to
use that recipe, it failed four times running. The full record, every run with
the press verifiably delivered:
| run | `--log_ui_draws` | title appeared at | Ⓐ |
|---|---|---|---|
| crash-dir | off | ~83 s | **menu** |
| boottitle | off | 147 s | **menu** |
| A/B "off" | off | — | **menu** |
| menucap 1 | on | 292 s | no (40 taps) |
| menucap 2 | on | 177 s | no |
| optcap 1 | on | 241 s | no |
| optcap 2 | on | 219 s | no |
| A/B "on" 1 | on | never (black screen) | — |
| A/B "on" 2 | on | 186 s | no |
**3 of 3 without the flag, 0 of 5 with it.** That is a strong correlation, and it
points at *my own instrumentation cvar* — which would be a serious finding, since
`--log_ui_draws` is what captures the paint order in the first place.
**It is also completely confounded, and the table shows it.** Every success had
its title inside 147 s; every failure had it at 177 s or later. Flag and
boot-time are perfectly correlated across these nine runs, so this evidence
cannot separate "the capture cvar breaks input" from "a title that arrives late
is already in the attract state and does not accept input". Both stories fit all
nine rows.
**A mechanism is hard to see for the flag**: `log_ui_draws` is read only when F10
arms a capture; the per-draw hook is one relaxed atomic load, and the swap hook
another. Nothing there should reach the guest. That argues for the boot-time
reading — but "I cannot see how" is not evidence, and the correlation is what was
measured.
**What would separate them**, cheaply and next: run the flag ON several times and
record (boot time → outcome). A single ON run whose title arrives inside 147 s
and which then accepts Ⓐ exonerates the flag outright.
### A defect I introduced, recorded rather than left to bite
Widening the menu signature (previous commit) made `screen_id.py` classify some
**boot/attract frames** as `menu` — twice in one run, at t=54 s and t=146 s, long
before any menu exists. The menu and a dark-blue movie frame are not separable
from a single grab. Scripts that navigate on this must require the classification
to hold across two grabs a second apart; the classifier itself cannot fix it.
### The confound was broken, and it did not go the way the table suggested
An **interleaved** series (same script, arms alternating, so cache warming cannot
masquerade as either variable) settles the boot-time question and leaves the
other one open:
| arm | title at | Ⓐ |
|---|---|---|
| `--log_ui_draws` on | 252 s | stuck |
| off | **268 s** | **menu** |
| on | 254 s | stuck |
| off | 232 s | stuck |
**Boot time is refuted.** The *latest* title of every run recorded — 268 s —
accepted Ⓐ, and a 232 s title refused. Whatever gates the button, it is not "the
title arrived late".
**The flag still correlates and still has no mechanism.** Over all twelve runs it
is 0 of 7 with the flag and 4 of 5 without. Against that:
* `log_ui_draws` is read in exactly one place — when F10 arms a capture — and
**F10 was never pressed** in any of those runs;
* the per-draw hook is one relaxed atomic load, the swap hook another;
* the two arms' startup config dumps are **byte-identical** (246 settings, zero
differences).
A correlation with no mechanism, and an arm that failed once, is not a finding.
It is a variable to remove, which is what was done: **F10 now arms the UI capture
unconditionally**, like the ship capture beside it, so no run needs the flag at
launch. Verified by artifact — F10 with no capture flags writes the log.
### A third failure mode, recorded because it is now frequent
Three boots have ended on a **permanent black screen**: one after 88 scripted Ⓐ
presses, and **two with no input at all**. No crash, no guest throw, the emulator
healthy and still resolving cache paths. It is not caused by the pad, and it is
not the cache-flush crash. Roughly one boot in five in this session.
## Still open, restated honestly
What gates Ⓐ at the title. Four candidate explanations have now been eliminated
or refuted — Xenia UI swallowing the keystroke, the driver filter, a sign-in or
content call on the press, and the age of the title — and the fifth (the capture
flag) has a strong correlation and no possible mechanism. The next honest step is
not another guess from outside: it is the guest's own state machine, and that
means reading the title code in `sylpheed.db`.
### 2026-08-19 (later) — the flag hypothesis loses its own test
With the capture cvar **removed from the launch line entirely** (F10 arms
unconditionally now), a fresh navigation run reached the boot title and Ⓐ **did
nothing**. So the flag is not necessary for the failure, and the correlation —
0 of 7 with it, 4 of 6 without it — does not survive as an explanation.
What is left is the plain reading, and it is worth stating as the conclusion
rather than as a stepping stone: **Ⓐ at the title succeeds about half the time,
and nothing measurable from outside predicts which.** Eliminated so far: Xenia UI
swallowing the keystroke (diagnostic never fires), the driver filter (flags 3 vs
`Controller = 1`), a sign-in or content call on the press (none is made), the age
of the title (the latest title of all accepted; an earlier one refused), and now
the capture cvar.
## The input path, mapped statically
Since the outside is exhausted, the guest side was traced in `sylpheed.db`:
```
entry_point 824AB748
└ sub_8216EA68 (968 B) main loop
└ sub_822F1AA8 (996 B) per-frame input update
└ sub_82457038 (628 B) THE PAD POLL
└ sub_824AA870 → XamInputGetKeystrokeEx (flags = 3)
```
`XamInputGetKeystrokeEx` has exactly **one** caller in the whole title
(`sub_824AA870`, a wrapper that maps user index `0xFF` to 0 and ORs in
`0x4000_0000` = ANY_USER), and that has exactly one caller, the poller. The
poller's shape is readable and matches the runtime trace:
* `+0` flags word — bit 31 "disabled", bit 30 "copy current→previous", bit 3
"also poll device state";
* a **keystroke queue** at `+68` with a count at `+72`: it drains
`GetKeystrokeEx` with `flags = 3` into 8-byte slots (the same `flags = 3` the
phase-A trace recorded, so this is the code the trace was watching);
* the current pad state at `+36`, the previous at `+52`, compared field by field
at the end (buttons `+40/+56`, triggers `+42/+43`, sticks `+44…+50`) to return
"changed" (3) or "unchanged" (0), and 2 if the device vanished.
**Nothing in that path is state-gated** — it polls unconditionally and reports
changes. Whatever ignores Ⓐ at an attract title is a *consumer* further up, and
finding it means identifying the object the poller fills (passed in as `r3` from
the per-frame update) and then its readers. That is a real RE job, not a
one-iteration probe, and it is where this thread now stands.
### A confound in all of the above: the box was not always idle
Worth recording against every timing number in this entry. The container runs
the emulator, the Canary build, and Reborn's disc test suite on the same 12
cores, and Xenia alone uses about **5.5**. Several of the Ⓐ experiments — and
most of the boot-duration measurements — were taken while a `cargo test` or a
`build-canary` was running. One boot then failed to reach the title at all
within 400 s, where an unloaded boot reaches it in 130290 s.
That does not explain the Ⓐ result on its own: the interleaved A/B alternated
arms under whatever load was present, and the latest title of all (268 s)
*accepted* while a 232 s title refused. But a starved guest missing an input
window is a live hypothesis that the data collected so far cannot exclude, and
the honest next measurement is the one now being taken — the same navigation on
an **idle** box, with nothing else running.
## The black screen has a trigger: loading content (2026-08-19)
Running the same navigation with `--eh_dispatch=true` (to test whether
dispatching the guest's C++ exception to its catch handler survives the
`SELECT DATA` crash) produced **zero crashes and zero throws** — and that is not
the good news it looks like, because the crash path was never reached.
What happened instead, with screenshots at every step:
```
main menu → TUTORIAL → the LESSON LIST (BASIC CONTROLS / HEADS-UP DISPLAY /
RADAR / SUPPLY AND SPECIAL MOVES / RADIO ORDERS / ADVANCED
CONTROLS / BACK) captures/tutorial-lesson-list.png
→ Ⓐ on BASIC CONTROLS
→ BLACK SCREEN, permanently, for the remaining 160 s
```
No crash dump, no `GUEST-THROW`, the emulator healthy and the guest still
running. So **`--eh_dispatch` is untested** — the experiment it was set up for
did not occur.
**What it did establish**: the black screen this session has hit four times is
not random. It follows a **content load** — here, selecting a tutorial lesson.
Earlier black screens fit that too: one came after 88 scripted Ⓐ presses through
the boot (which would have selected *something*), and one after a boot that was
never touched but had reached the attract loop. The two failure modes on the way
to a mission are therefore distinct:
* the **`SELECT DATA` crash** — the cache-flush `std::map` erase, when the path
goes through save-slot enumeration;
* the **black-screen hang** — no crash, no throw, on loading content.
Neither has been beaten, and this run tested neither: it took a different branch
through the menus than the run before it (TUTORIAL leads to a lesson list, and
the DIFFICULTY screen captured earlier came from a different selection). The
`--eh_dispatch` test needs a run that actually reaches `SELECT DATA`.
### `--eh_dispatch` is still untested, and here is why that is a logical claim, not an excuse
Three more runs, driving **NEW GAME** deliberately (the menu's first item, no
d-pad — which is what the run that first hit the crash actually did, though it
believed it had chosen TUTORIAL):
| flags | reached | crash dumps | `GUEST-THROW` |
|---|---|---|---|
| *(none)* — earlier run | menu → DIFFICULTY → **SELECT DATA** | **537** | yes |
| `--eh_dispatch=true` ×2 | menu → black | 0 | 0 |
| `--eh_dispatch=true --cache_throw_diag=true` | menu → **DIFFICULTY** → black | 0 | **0** |
The third row is the informative one. `cache_throw_diag` runs `LogGuestThrow`
**before** any dispatch is attempted, so a throw would have been logged whatever
the dispatch then did — and there were **none**. So no guest exception occurred
in that run at all, and neither flag can have changed anything: both are inert
until a throw happens.
**Therefore the difference between the rows is not the flags.** It is the
intermittent content-load hang, which took the run out before it reached the
save-slot screen. The flag remains untested for the reason the runs record, and
the temptation to read "0 crashes with `--eh_dispatch`" as a fix is exactly what
the log disproves.
Also confirmed on the way: the screen after NEW GAME is **DIFFICULTY** — its
whole-image signature `(9.7, 19.5, 53.9)` matches the earlier capture of that
screen exactly — so the menu path is understood even though the run dies after
it.