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.
202 lines
11 KiB
Markdown
202 lines
11 KiB
Markdown
# F1 — "no auto-repeat" was measuring our own scripted-input driver, not the game
|
||
|
||
**Status:** 🔴 refutes [`menu-navigation-semantics.md`](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`](../../tools/re-capture/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`](../../tools/re-capture/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 ~4–5 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`](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 36–48 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 ~4–5 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 ~4–5 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.
|