Both found by a human playing the port on a real controller. Both were
invisible to every check this port has, for one reason:
`--script` sends InputEventAction, which BYPASSES the input map.
So the harness asserted every line of code AFTER the map and nothing about the
map itself. Measured on this Godot, not remembered -- the remembered answer 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+
Four actions worked on the pad and two did not, which presents as a broken
controller: navigation moved, Ⓐ skipped nothing and opened nothing. Godot
4.7.2 binds no joypad button to ui_accept or ui_cancel.
Gamepad.bind_missing() ADDS the two buttons to the built-in actions rather than
redefining them in project.godot, which would replace the built-ins wholesale
and drop the keyboard bindings silently.
Second defect, same blind spot: an InputEventAction is not an analog axis. The
left stick is bound to axis 1, and an axis is not an edge -- held at deflection
it emits an event per jitter, each reporting the action pressed. That was one
cursor step per jitter ("moves the cursor too fast"). The stick is now latched
to one step per deflection, with hysteresis so a stick resting near the
threshold does not chatter.
AUTHORED, and deliberately the conservative half: whether the game REPEATS a
held direction, and how fast, is an oracle question. One deflection one step
cannot run away and invents no rate. Logged as BLOCKED H1.
tools/port/verify-input asserts the map and the latch, with a control that
removes each check's OWN subject -- its first version inverted all nine
assertions when only two depended on the fixup, and reported seven correct
checks as broken. Three rows say plainly they are not controllable (they assert
Godot's own bindings) and one is a negative carrying a positive control (R4),
rather than faking an inversion for either.
Also logged BLOCKED H2, unguessed: the splash blur/fade-in is more pronounced
in the game than in the port. The port applies no blur at all. Noted there that
the two splashes are the only screens reaching the rest() plateau-less
fallback, which the R1 pass just re-opened in both directions.
148 lines
6.2 KiB
GDScript
148 lines
6.2 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.**
|
|
## Whether the real game *repeats* while a direction is held, and how fast, is
|
|
## unknown; it is an oracle question. One deflection = one step cannot run away
|
|
## and cannot invent a rate. If the game does repeat, this is a difference a
|
|
## human will notice as "I have to flick it again", and the fix is a measured
|
|
## repeat interval — not a guessed one. Logged for the Decoder as `pad-repeat`.
|
|
|
|
## 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.
|
|
const ENTER := 0.5
|
|
|
|
## 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
|
|
|
|
## 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 = {}
|
|
|
|
|
|
## 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
|
|
|
|
|
|
## 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)
|