Files
Sylpheed/docs/re/f1-no-repeat-was-the-harness.md
sylph-decoder dfbefff512
Some checks failed
CI / Native — linux (pull_request) Failing after 34m6s
CI / WASM — Web (pull_request) Successful in 31m37s
CI / Formatting (pull_request) Failing after 1m17s
re: F1 -- traced C_PAD_RINGBUF; the queue naming inference was wrong
Continuing the F1 investigation rather than starting a fresh one. Last
iteration left two competing hypotheses open (Keystroke-queue-driven vs
polled-state-driven repeat) and flagged C_PAD_RINGBUF's producer as the
cheapest thing to trace next -- named but not traced.

Traced it this time: C_PAD_DECODER's own constructor (sub_8220B610)
allocates C_PAD_RINGBUF (52-byte control struct, 1024-byte backing buffer,
confirmed against its own Shift-JIS trace strings -- "C_PAD_RINGBUF
initialization" and its allocation-error message). Its update function
(sub_8220B8C0) takes the input-manager singleton as a parameter and reads
the ring at offsets 12, 36, 40, 44 and 48 -- not just the one button word.

Offsets 36-48 are four consecutive fields read together through the same
int-to-double conversion an analog axis would use. XamInputGetKeystrokeEx
has no field for a stick position, so a structure carrying four axis-shaped
fields cannot be a keystroke queue -- it reads as a periodically-refreshed
polled-state snapshot. My own prior reading of the "ring buffer" name as
implying a queue was the wrong inference; refuted by tracing it, recorded
either way per adversarial duty.

This shifts the balance toward the second, previously-uncertain hypothesis:
the file driver's GetState() was always capable of showing real repeat (no
modification needed), and nav_repeat_and_b.py's null result is more likely
a sampling artifact of its ~4-5 fps screen-diff detector than a structural
driver limit. Revises "what would close it" accordingly -- re-run the
existing draw-log position-tracking instrument, gated on the menu properly,
before reaching for a driver change.

Not found: the actual producer that writes into C_PAD_RINGBUF each frame --
narrowed to "reachable from the input-manager singleton fetch in
sub_821A9DC8," not traced to completion. Still no number for issue #1;
this narrows the path to one, further than last iteration but not there.
2026-09-12 11:05:22 +00:00

11 KiB
Raw Blame History

F1 — "no auto-repeat" was measuring our own scripted-input driver, not the game

Status: 🔴 refutes menu-navigation-semantics.md's "⬆⬇ — no auto-repeat none" row. decoded (⟨canary-source⟩) why that measurement could never have shown repeat. 🟡 a specific, testable prediction for what the real number is — not yet measured. Instrument: ⟨canary-source⟩, /canary/src/xenia/hid/*, read directly, no emulator run this iteration.

Issue #1 asks for the held-direction repeat's initial delay and interval, in frames, and states the existence half as already settled by the human's own play-test: "it actually continues to move when holding up/down... at a medium pace." That directly contradicts this corpus's own prior measurement. One of the two is wrong, and it matters which before anyone spends emulator time chasing a number.

The prior measurement, and why it could not have found repeat

nav_repeat_and_b.py drove a held ⬇ through Canary's --hid=file pad-file driver and found exactly one cursor-move spike over a 2.0 s hold, with a clean control (a single 0.12 s tap also gives exactly one spike). The counter is not the problem — the driver is:

/canary/src/xenia/hid/file/file_input_driver.h, GetKeystroke(), in its own words:

Deliberately NO auto-repeat — scripted input wants precisely one event per press, and repeat is what makes menu steps overshoot.

and, two lines above:

Menus do NOT read the pad through GetState. … 360 front-ends poll XamInputGetKeystrokeEx

Mechanically: GetKeystroke() computes changed = buttons_ ^ reported_ and returns X_ERROR_EMPTY whenever nothing has changed since the last call — reported_ is updated to match on every delivered edge, so a held button produces exactly one KEYDOWN, ever, no matter how long the pad file says it's down. This is a deliberate, documented design choice in our own harness, made for other scripts' benefit, not a property of the game. input-pad-read-path.md already established the game reads a keystroke queue via this exact API for menu input (§"The second path: a keystroke queue") — so a driver that cannot repeat a keystroke cannot show a menu repeating, structurally, regardless of hold duration.

Refutation attempt, recorded either way (adversarial duty, this iteration): targets menu-navigation-semantics.md's row "⬆⬇ — no auto-repeat none," believed since 2026-08-30, on the grounds that its instrument cannot deliver a repeated keystroke by design. Partly survives, partly doesn't — see the next section, which complicates this before it gets to be the whole story.

What a real controller would produce, read from the same source tree

Canary's sdl input driver — the one an actual joystick goes through, not the scripted file driver — implements keystroke repeat for real:

// sdl_input_driver.h
#define HID_SDL_REPEAT_DELAY 400
#define HID_SDL_REPEAT_RATE 100

sdl_input_driver.cc's GetKeystroke() uses Clock::QueryGuestUptimeMillis()guest time, not host wall-clock — to arm a Waiting state on the initial KEYDOWN, promote to Repeating after HID_SDL_REPEAT_DELAY (400 ms) elapses, then re-fire a KEYDOWN | KEYSTROKE_REPEAT event every HID_SDL_REPEAT_RATE (100 ms) after that, for as long as the button stays down. This is upstream Xenia machinery (not a project modification, unlike the file driver's comment above), present for every game Canary runs.

If the menu treats each incoming keystroke — including REPEAT-flagged ones — as one navigation step, then the on-screen behaviour a real controller would show is: first step on press, a 400 ms pause, then one step every 100 ms — which reads exactly as "medium pace, slow enough to see" against a "continues to move" description. That is a prediction, not yet a measurement.

A second, competing piece of evidence — and it points the other way

file_input_driver's GetState() is not edge-triggered at all: buttons_ is whatever the pad file's last write said, held continuously until the file changes, with no reported_-style consumption. So if a menu's repeat lives in polled state rather than the keystroke queue, this driver was always capable of showing it — the earlier section's "structurally cannot repeat" applies only to the GetKeystroke() path.

And there is direct, if secondhand, evidence that it does: pad.py's own docstring, written by an earlier session driving this exact driver, warns that its dpad helper defaults to a 0.06 s tap "longer auto-repeats and overshoots." That is a caution against a real observed effect, not a hypothetical — someone drove a longer hold through this same file driver and saw more than one step.

These two facts do not agree, and I am not resolving them here. Either:

  • the keystroke route is right, pad.py's caution is about something else (a different held-input path, or stale advice inherited from before GetKeystroke's no-repeat comment was written) — or
  • the polled-state route is right, and nav_repeat_and_b.py's null result is a sampling artifact: its cursor detector grabs frames at ~45 fps (_open()'s -r 4), a coarse enough rate that if the true interval is well under 200 ms, successive samples could straddle several real moves and something in that pipeline collapsed them to one spike rather than under-counting to a nonzero-but-wrong number — which is not obviously how a threshold-crossing detector fails, and is exactly why this needs checking rather than asserting.

Both routes converge on the same next step below.

2026-09-12 update — traced C_PAD_RINGBUF, and the naming inference was wrong

Last iteration's open question was whether C_PAD_RINGBUF is fed by the keystroke ring or the polled state, and said its name suggested a queue. Traced this time, ⟨image⟩, addresses re-verified against /xenia-rs/sylpheed.db (not run against the raw .pe this pass — see reach below):

  • C_PAD_DECODER's own constructor (sub_8220B610) allocates C_PAD_RINGBUF itself — a 52-byte control struct plus a 1024-byte backing buffer (リングバッファ確保エラー size=%d / C_PAD_RINGBUF 初期化, both read off the disc's own Shift-JIS strings) — and stores the pointer at this+76.
  • C_PAD_DECODER's update (sub_8220B8C0) takes the input-manager singleton (sub_824574C0, already named in structures/title-a-press-fault.md) as its third argument, and reads this+76's ring at offsets 12, 36, 40, 44 and 48 — not just the one button word input-pad-read-path.md's sub_8220B8C0 excerpt showed. Offsets 3648 are four consecutive 32-bit fields, read together, converted through the same int→double stack round-trip a stick axis conversion would use.

That is the tell. XamInputGetKeystrokeEx reports discrete digital button edges — it has no field for an analog stick position. A structure that carries four axis-shaped fields alongside a button word cannot be a keystroke queue; it reads as a periodically-refreshed input snapshot (buttons and both sticks together), which only the polled XINPUT_GAMEPAD path has to offer. The "ring buffer" name is the authors' own choice of implementation (a reusable slot, not a growing queue), not evidence of an event stream — my prior reading of the name was the wrong inference, now corrected by tracing it.

This favours the second hypothesis in the section above: pad.py's "longer holds auto-repeat" caution is more likely the real mechanism, and nav_repeat_and_b.py's null result is more likely a sampling artifact of its coarse ~45 fps screen-diff detector than a structural driver limit. It does not flip the Keystroke/SDL-driver prediction to false outright — a menu could still layer Keystroke-driven navigation on top of a polled-state decoder for other purposes — but the balance of evidence moved.

Not found this pass, and still open: the actual producer that writes into C_PAD_RINGBUF's offsets 12/36-48 each frame. sub_8220B8C0 only reads them; nothing in its own 5 856 bytes stores through the chased this+76 pointer, so some other function holds a reference to the same ring instance. sub_821A9DC8 (one of sub_8220B8C0's three callers) fetches the input-manager singleton immediately before each call, which is the strongest lead for where to look next, not yet followed to its own producer.

What is NOT established, and is the reach

  • The actual producer of C_PAD_RINGBUF's contents, per the update above — narrowed to "somewhere reachable from the input-manager singleton," not found.
  • Whether the menu's consumer of that ring treats a REPEAT-flagged keystroke identically to a fresh KEYDOWN — now less likely to be the relevant question at all, per the update above, but not ruled out.
  • Which --hid driver run-canary uses by default, i.e. which of these two numbers (none, or 400/100) the human's own play-test actually went through.

Any of these could be wrong without changing the one thing this page does establish: the previous "none" result cannot stand as evidence either way, because its instrument was built to prevent Keystroke repeat by design, and now more plausibly was never testing the path that matters.

What would close it

Two options, cheapest first — and the 2026-09-12 update changes which one is cheapest:

  1. Read, not run. Finish tracing C_PAD_RINGBUF's producer from sub_821A9DC8's input-manager fetch forward, to confirm it copies from the polled XINPUT_GAMEPAD fields rather than the keystroke ring. Settles the mechanism with no emulator time. Given the axis-shaped fields already found, this is now confirmation work, not a coin flip.
  2. Measure — and the existing driver may already be enough. If the producer is polled-state as the update above now favours, file_input_driver's GetState() needs no modification: it already holds a button continuously with no edge suppression. The likelier defect is the detector, not the driver — nav_repeat_and_b.py's ~45 fps screen-diff undercounts a fast repeat. Re-run with the draw-log position-tracking instrument f1-menu-repeat-harness-built-not-answered.md already built and validated (per-frame cursor position, not a coarse pixel-diff), gated on the main menu via boot_menu.sh/skip_intro.sh rather than blind sleeps, holding a direction through the plain --hid=file driver as-is. Report frame counts at the achieved present rate, per TEMPORAL-VERIFICATION.md. The opt-in Keystroke-repeat mode this page originally proposed adding to the file driver is now a fallback for if this comes back null again, not the first thing to try.

Not run this iteration — this is the static half, and it is large enough on its own (a reversal of a standing claim) not to stack a build-and-boot unit on top of it unverified.