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 IS NOW ACTUALLY POLLED. It used to read `_latched` — a # reconstruction of the stick's position from the event history — while the # comment above said "polled at the DEVICE". A human holding a real stick # got exactly one step and no repeat (2026-09-13), and the harness could not # see it, because the harness fed the same events the latch was built from. # # `_latched` only changes when an event ARRIVES. A stick held perfectly # still sends nothing, and any one event that reads below `RELEASE` — a # spring settling, a deadzone-shaped value, a driver that emits a zero on # focus change — clears it with no event afterwards to set it back. The # position was then wrong until the player moved the stick again, which is # indistinguishable from "the repeat does not work". # # `get_joy_axis()` is the position itself, tested against the game's own # 0.61, which is what the paragraph above always meant. for device in Input.get_connected_joypads(): var v := Input.get_joy_axis(device, JOY_AXIS_LEFT_Y) if v >= ENTER: return 1 if v <= -ENTER: return -1 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 # The latch, for events that were INJECTED rather than read off a device: # `Input.parse_input_event()` does not move `get_joy_axis()`, so the script # harness and `verify-input` would otherwise test nothing at all here. var stick := int(_latched.get(JOY_AXIS_LEFT_Y, 0)) if stick != 0: return stick if Input.is_key_pressed(KEY_UP): return -1 if Input.is_key_pressed(KEY_DOWN): return 1 return 0 ## What the devices actually report, for a human to read while holding a stick. ## ## The 2026-09-13 report — "I hold it down, it moves one item and stops" — could ## not be diagnosed from here: every instrument in this repo feeds its own ## events, so all of them agreed with each other and none of them agreed with ## the controller. This prints the raw state so the next such report starts from ## a measurement instead of a guess. func probe_line() -> String: var parts := PackedStringArray() for device in Input.get_connected_joypads(): parts.append("[%d] %s Y=%+.3f X=%+.3f dpad=%s%s" % [ device, Input.get_joy_name(device), Input.get_joy_axis(device, JOY_AXIS_LEFT_Y), Input.get_joy_axis(device, JOY_AXIS_LEFT_X), "U" if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_UP) else "-", "D" if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_DOWN) else "-"]) if parts.is_empty(): parts.append("no joypad") return "%s latched=%d held_direction=%d (ENTER=%.2f RELEASE=%.2f)" % [ " ".join(parts), int(_latched.get(JOY_AXIS_LEFT_Y, 0)), held_direction(), ENTER, RELEASE] ## 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)