REPEAT_DELAY = 0.402, REPEAT_INTERVAL = 0.134, from docs/re/f1-repeat-measured-via-driver-patch.md -- 12 and 4 frames at the run's achieved 29.87 fps guest rate, converted to seconds because this port does not run at the guest's rate and it is the cadence that was measured. The mechanism has been here since 2026-09-02 and inert on purpose. The instruction it was waiting on is now vindicated in the most awkward way: the draft it refused to ship had 0.40 / 0.20, so the guessed delay was nearly right and the guessed interval was off by 50 %. The half that was wrong would have been protected by the half that was right. The delay and the interval are NOT equally well evidenced, and the code says so at the constants. No physical controller exists in the Decoder's container, so the measurement fed Canary's file driver the SDL driver's own 400/100 ms constants: the 402 ms that came back is the constant that went in, and confirms the instrument. The 133 ms interval against a fed-in 100 ms is the new fact -- the game paces repeats to its own frame consumption. One run; the two-run minimum is not met and the finding says so itself. Also: the prediction that this would turn verify-input's "a held stick is ONE step, not six" red was wrong. It stayed green, because steps() never advances a clock and so had never called repeat_due() at all -- the rate was about to ship into a harness with no coverage of the feature, with a green line that would have been read as coverage. So verify-input gains a `repeat` subject: nothing before the delay, the first repeat on the delay, the steady interval, cadence independent of frame rate (the code claims this in a comment, so it is now asserted), and a direction change restarting the delay. Each asserts THESE numbers, not the shape -- a shape-only check would have passed on 0.40 / 0.20. The control removes the premise, a held direction, and every controllable row inverts. The first-repeat row measures from the arming tick, not from t=0: that tick is the frame the press is handled, which is the origin the finding measures its 12 frames from. Measured from zero it read 0.433 vs 0.402 and the tolerance would have had to be widened to hide a units mismatch. Closes #2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
14 KiB
GDScript
306 lines
14 KiB
GDScript
class_name Gamepad
|
||
extends RefCounted
|
||
|
||
## The physical controller: the two buttons Godot does not bind, and the one
|
||
## input that is not an edge.
|
||
##
|
||
## 🔴 BOTH DEFECTS WERE REPORTED BY A HUMAN PLAYING THE PORT (2026-09-01), and
|
||
## neither could have been caught by the `--script` harness, because that harness
|
||
## sends `InputEventAction` — which bypasses the input map and is not an analog
|
||
## axis. The unattended P5 walk passed on every iteration while Ⓐ did nothing at
|
||
## all on a real pad. **A synthetic-input test asserts the code after the input
|
||
## map, never the input map itself.**
|
||
##
|
||
## ## 1. Godot 4.7.2 binds no joypad button to `ui_accept` or `ui_cancel`
|
||
##
|
||
## Measured on this exact build rather than remembered, because the answer has
|
||
## changed between Godot versions and the remembered one was wrong:
|
||
##
|
||
## ```
|
||
## ui_accept key:Enter, key:Kp Enter, key:Space <- no joypad at all
|
||
## ui_cancel key:Escape <- no joypad at all
|
||
## ui_up key:Up, JOYBTN:11, JOYAXIS:1- <- d-pad AND left stick
|
||
## ui_down key:Down, JOYBTN:12, JOYAXIS:1+
|
||
## ui_left key:Left, JOYBTN:13, JOYAXIS:0-
|
||
## ui_right key:Right, JOYBTN:14, JOYAXIS:0+
|
||
## ```
|
||
##
|
||
## That asymmetry is the whole bug report: navigation worked on the pad and Ⓐ/Ⓑ
|
||
## did nothing, which reads like a broken controller and is a complete input map
|
||
## for four actions out of six.
|
||
##
|
||
## The events are **added to** the built-in actions, never redefined. Declaring
|
||
## `ui_accept` in `project.godot` replaces the built-in wholesale, so the
|
||
## keyboard bindings would have to be restated there and would silently rot the
|
||
## next time Godot changes them.
|
||
##
|
||
## ## 2. A stick is not a button
|
||
##
|
||
## `ui_up`/`ui_down` are bound to **axis 1**, so the left stick navigates — which
|
||
## is correct, the real game accepts it too. But an axis emits a fresh
|
||
## `InputEventJoypadMotion` every time the value *changes*, and a real stick held
|
||
## at deflection jitters continuously. Every one of those events reports the
|
||
## action as pressed, so a held stick was one cursor step per jitter: the human's
|
||
## words were "moves the cursor too fast", and on a five-item menu it crosses
|
||
## faster than the eye follows.
|
||
##
|
||
## So the stick is **latched**: it fires once when it leaves the neutral zone and
|
||
## not again until it comes back. That makes it behave exactly like the d-pad,
|
||
## which needs no latch because a button already is an edge.
|
||
##
|
||
## ⚠️ ~~AUTHORED, NOT MEASURED — and deliberately the conservative half.~~
|
||
## 🔴 **THE GAME DOES REPEAT, and this paragraph predicted its own refutation.**
|
||
## It said: *"If the game does repeat, this is a difference a human will notice
|
||
## as 'I have to flick it again'."* On 2026-09-02 a human who has played both
|
||
## reported exactly that — *"holding only moves one item. In game it actually
|
||
## continues to move when holding up/down, just at a medium pace"*.
|
||
##
|
||
## So one-step-per-deflection is no longer the conservative reading; it is a
|
||
## known defect. The mechanism is implemented below and the **rate is not
|
||
## shipped** — see `REPEAT_DELAY` for why an approximate one is worse than none.
|
||
|
||
## ✅ DECODED 2026-09-01, and it replaces an authored value.
|
||
##
|
||
## This was **0.5**, chosen as a *floor* rather than as a value: Godot's action
|
||
## deadzone for the `ui_*` actions is 0.50, so the latch must not arm below it —
|
||
## the action itself would not read as pressed and the step would be swallowed
|
||
## anyway, leaving the latch armed against a press that never happened. That
|
||
## reasoning still holds and 0.61 is comfortably above the floor.
|
||
##
|
||
## The game's own threshold is now measured: it **digitises the left stick to
|
||
## four direction bits at 61 % deflection**, so it never sees a velocity at all
|
||
## (`docs/re/input-button-numbering-is-remapped.md` and the corrected
|
||
## `input-pad-read-path.md`). Between 0.50 and 0.61 Godot reports `ui_down`
|
||
## pressed and the real game reports nothing; at 0.5 this port stepped there.
|
||
##
|
||
## 📌 The mechanism also corroborates the human's fix rather than merely
|
||
## agreeing with it: a control that digitises to bits cannot express a rate, so
|
||
## "one step per deflection" is what the hardware layer *can* produce, not a
|
||
## conservative guess that happened to look right.
|
||
##
|
||
## ⚠️ **The 0.11 gap to `RELEASE` stays AUTHORED.** Nothing measured says the
|
||
## game has hysteresis at all, let alone how wide. Only the arm threshold moved.
|
||
##
|
||
## 🔴 **This changes how the stick feels and a human chose the old number.**
|
||
## Asserted at the device level below and in `tools/port/verify-input`, but a
|
||
## feel-test is the real check: revert this one constant to 0.5 if 0.61 reads as
|
||
## a stick that needs pushing too far.
|
||
const ENTER := 0.61
|
||
|
||
## Release lower than it arms. Without the gap a stick resting near 0.5 chatters
|
||
## across the boundary and re-arms on noise, which is the original bug wearing a
|
||
## smaller number.
|
||
const RELEASE := 0.4
|
||
|
||
## ## 3. A held direction repeats — MECHANISM PRESENT, RATE NOT SHIPPED
|
||
##
|
||
## A human who has played both reported it on 2026-09-02: *"holding only moves
|
||
## one item. In game it actually continues to move when holding up/down, just at
|
||
## a medium pace"*, and separately *"Confirmed D-Pad does repeat when holding
|
||
## too."* So the FACT covers both input devices, which is why `held_direction()`
|
||
## polls the pad and the keyboard and not just the stick.
|
||
##
|
||
## ✅ **THE RATE IS NOW MEASURED, 2026-09-12, and adopted here.**
|
||
## `docs/re/f1-repeat-measured-via-driver-patch.md`, with the per-transition
|
||
## reference data in `docs/re/data/f1-repeat-cursor-transitions.tsv`.
|
||
##
|
||
## ~~THE RATE IS DELIBERATELY UNSET, AND THE REPEAT DOES NOT RUN UNTIL IT IS
|
||
## MEASURED.~~ It ran unset for eleven days and that was the right state; the
|
||
## paragraph is struck through rather than deleted because the reason it gave is
|
||
## the reason these two numbers can be trusted now. An earlier draft had
|
||
## **0.40 / 0.20** with a note saying they were authored — and 0.40 would have
|
||
## looked vindicated today while 0.20 was off by 50 %. That is exactly why an
|
||
## explained guess is worse than none: half of it would have been right.
|
||
##
|
||
## ## Where the two numbers come from, and how far they reach
|
||
##
|
||
## Measured in Canary at an achieved **29.87 fps** guest rate: **12 frames**
|
||
## from the press-triggered step to the first repeat, then **4 frames** per step
|
||
## (13 of 15 gaps; 3 frames for the other 2). Converted to seconds here, not
|
||
## frames, because this port does not run at the guest's rate and it is the
|
||
## *cadence* that was measured — 12 / 29.87 = 0.402, 4 / 29.87 = 0.134.
|
||
##
|
||
## ⚠️ **THE DELAY IS WEAKER EVIDENCE THAN THE INTERVAL, and they should not be
|
||
## trusted equally.** No physical controller exists in that container, so the
|
||
## measurement was taken by patching Canary's `--hid=file` driver to emit
|
||
## Keystroke `REPEAT` at the SDL driver's own 400 ms / 100 ms constants. The
|
||
## 402 ms that came back is, to within the frame quantum, **the constant that
|
||
## was fed in** — it confirms the instrument, not the game. The 133 ms interval
|
||
## is the genuinely new fact: the driver was fed 100 ms and the cursor moved
|
||
## every 133, so the game paces repeats to its own frame consumption rather
|
||
## than to the event stream.
|
||
##
|
||
## So: the interval is what the game does. The delay is what Xenia's SDL driver
|
||
## does, and the game was not observed to disagree with it. If a capture through
|
||
## a real controller ever contradicts 0.402, that is the number to move.
|
||
##
|
||
## 📌 One run. The corpus's own two-run minimum is **not met** — the source page
|
||
## says so itself, and this comment repeats it rather than letting the constant
|
||
## look firmer at the call site than it does at the finding.
|
||
##
|
||
## 📌 **A constant interval is the right SHAPE, and that part IS measured.** The
|
||
## game digitises the left stick to four direction bits at 61 % deflection
|
||
## (`ENTER` above), so it cannot see deflection magnitude at all — a repeat it
|
||
## drives cannot be faster-the-harder-you-push. That excludes the one competing
|
||
## model, so only the two constants are open, and one measurement closes both.
|
||
##
|
||
## ⚠️ ~~**TO ADOPT, TWO THINGS CHANGE, NOT ONE.** … that green row would go red
|
||
## for the right reason and be read as a regression.~~
|
||
##
|
||
## 🔴 **RUN ON ADOPTION DAY: THE ROW STAYED GREEN, AND THAT IS WORSE.**
|
||
## `verify-input`'s `steps()` never advances a clock, so it had never called
|
||
## `repeat_due()` at all — the row it warned about tests the *latch*, which the
|
||
## repeat does not touch. The prediction was reasoned rather than run, and what
|
||
## it hid is the real problem: the rate was about to ship into a harness with
|
||
## **no coverage of this feature whatsoever**, and the green line would have
|
||
## been read as coverage.
|
||
##
|
||
## The fix was not to change that row. It was to add a `repeat` subject that
|
||
## holds a direction through the same latch and ticks `repeat_due()`, asserting
|
||
## **these two numbers** rather than "it repeats eventually" — a shape-only
|
||
## check would have passed on the 0.40 / 0.20 guess this file refused to ship.
|
||
##
|
||
## 📌 The original point survives intact and is worth keeping: a check written
|
||
## against today's behaviour becomes an assertion that the behaviour never
|
||
## changes. It was simply aimed at the wrong row.
|
||
const REPEAT_DELAY := 0.402
|
||
const REPEAT_INTERVAL := 0.134
|
||
|
||
|
||
## Whether a measured repeat rate has been adopted. Until it has, the port keeps
|
||
## its current one-step-per-deflection behaviour, which is KNOWN WRONG but is
|
||
## wrong in a way nobody will mistake for a measurement.
|
||
static func repeat_rate_known() -> bool:
|
||
return REPEAT_DELAY > 0.0 and REPEAT_INTERVAL > 0.0
|
||
|
||
## Only the left stick. The triggers are axes too, and latching them here would
|
||
## silently swallow input the port does not read yet but might.
|
||
const STICK := [JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y]
|
||
|
||
var _latched: Dictionary = {}
|
||
var _repeat_direction := 0
|
||
var _repeat_clock := 0.0
|
||
|
||
|
||
## Add the joypad buttons the built-in map omits. Returns a human-readable line,
|
||
## or "" if nothing needed adding — so a future Godot that ships these bindings
|
||
## makes this quietly stop reporting rather than double-binding.
|
||
static func bind_missing() -> String:
|
||
var added := PackedStringArray()
|
||
for pair in [["ui_accept", JOY_BUTTON_A, "Ⓐ"], ["ui_cancel", JOY_BUTTON_B, "Ⓑ"]]:
|
||
var action: String = pair[0]
|
||
var button: int = pair[1]
|
||
if not InputMap.has_action(action):
|
||
# Not a warning we can act on, but silence here would present as the
|
||
# original bug and send the next person back to the controller.
|
||
push_warning("gamepad: no such action %s -- pad button unbound" % action)
|
||
continue
|
||
if _has_button(action, button):
|
||
continue
|
||
var ev := InputEventJoypadButton.new()
|
||
ev.button_index = button
|
||
InputMap.action_add_event(action, ev)
|
||
added.append("%s -> %s" % [pair[2], action])
|
||
if added.is_empty():
|
||
return ""
|
||
return "pad: bound %s (Godot 4.7.2 binds no joypad button to either)" % \
|
||
", ".join(added)
|
||
|
||
|
||
static func _has_button(action: String, button: int) -> bool:
|
||
for e in InputMap.action_get_events(action):
|
||
if e is InputEventJoypadButton and e.button_index == button:
|
||
return true
|
||
return false
|
||
|
||
|
||
## True if this event should be acted on. Everything that is already an edge —
|
||
## keys, d-pad, mouse — passes straight through; only the analog stick is
|
||
## latched, and only on the two axes the navigation actions are bound to.
|
||
func accepts(event: InputEvent) -> bool:
|
||
if not (event is InputEventJoypadMotion):
|
||
return true
|
||
var axis: int = event.axis
|
||
if not STICK.has(axis):
|
||
return true
|
||
var value: float = event.axis_value
|
||
var direction := 0
|
||
if value >= ENTER:
|
||
direction = 1
|
||
elif value <= -ENTER:
|
||
direction = -1
|
||
|
||
if direction == 0:
|
||
# Neutral enough to re-arm? The gap between RELEASE and ENTER is the
|
||
# hysteresis band: inside it the stick is neither a new press nor
|
||
# released, so the latch is left exactly as it was.
|
||
if absf(value) <= RELEASE:
|
||
_latched[axis] = 0
|
||
return false
|
||
if int(_latched.get(axis, 0)) == direction:
|
||
return false # still held in the same direction: not a new press
|
||
_latched[axis] = direction
|
||
return true
|
||
|
||
|
||
## Which way a direction is being HELD right now, as -1 (up), 0 or +1 (down).
|
||
##
|
||
## 🔴 **Polled at the DEVICE, never through `Input.is_action_pressed`.** `ui_up`
|
||
## and `ui_down` are bound to the stick axis at Godot's 0.50 action deadzone,
|
||
## while this port steps at the game's measured 0.61. Polling the action would
|
||
## repeat throughout the 0.50–0.61 band — the exact band `ENTER` exists to
|
||
## exclude — so the repeat would contradict the threshold on the same stick.
|
||
## That is the input-map lesson again: assert the device, not the layer above it.
|
||
func held_direction() -> int:
|
||
# The stick, from the latch `accepts()` already maintains, so the repeat and
|
||
# the first step read one state and cannot disagree about hysteresis.
|
||
var stick := int(_latched.get(JOY_AXIS_LEFT_Y, 0))
|
||
if stick != 0:
|
||
return stick
|
||
for device in Input.get_connected_joypads():
|
||
if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_UP):
|
||
return -1
|
||
if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_DOWN):
|
||
return 1
|
||
if Input.is_key_pressed(KEY_UP):
|
||
return -1
|
||
if Input.is_key_pressed(KEY_DOWN):
|
||
return 1
|
||
return 0
|
||
|
||
|
||
## One repeat step, or 0. Call once per frame with the frame's delta.
|
||
##
|
||
## The FIRST step is not this function's: it comes from the event edge in
|
||
## `_unhandled_input`, and the clock below starts from that same frame, so a held
|
||
## direction gives one step now and the next only after `REPEAT_DELAY`. A change
|
||
## of direction restarts the delay rather than inheriting the old cadence.
|
||
func repeat_due(delta: float) -> int:
|
||
if not repeat_rate_known():
|
||
return 0
|
||
var direction := held_direction()
|
||
if direction == 0 or direction != _repeat_direction:
|
||
_repeat_direction = direction
|
||
_repeat_clock = 0.0
|
||
return 0
|
||
_repeat_clock += delta
|
||
if _repeat_clock < REPEAT_DELAY:
|
||
return 0
|
||
# Subtract rather than reset, so the cadence cannot drift with the frame rate
|
||
# -- at 140 fps and at 30 fps the same number of steps happen per second.
|
||
_repeat_clock -= REPEAT_INTERVAL
|
||
return direction
|
||
|
||
|
||
## The pads Godot can see, for the startup line. A run where the human believes
|
||
## a controller is connected and Godot disagrees should say so on its own,
|
||
## rather than presenting as unresponsive buttons.
|
||
static func report_devices() -> String:
|
||
var pads := Input.get_connected_joypads()
|
||
if pads.is_empty():
|
||
return "pad: none connected -- keyboard only (Enter/Space = Ⓐ, Escape = Ⓑ)"
|
||
var names := PackedStringArray()
|
||
for j in pads:
|
||
names.append("[%d] %s" % [j, Input.get_joy_name(j)])
|
||
return "pad: " + ", ".join(names)
|