Two things, and both are about a hidden parameter nobody was recording. 1. THE STICK THRESHOLD IS DECODED NOW, and it replaces an authored value. The Decoder measured that the game digitises the left stick to four direction bits at 61 % deflection, so it never sees a velocity. Gamepad.ENTER moves 0.5 -> 0.61. The 0.5 was never a chosen value: it was a FLOOR, because Godot's `ui_*` action deadzone is 0.50 and the latch must not arm below it. Between 0.50 and 0.61 Godot reports a direction the real game does not, and at 0.5 this port stepped there. The mechanism also corroborates the human's latch 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. ⚠️ The 0.11 hysteresis gap stays AUTHORED -- nothing says the game has hysteresis at all. And a human chose 0.5, so this changes feel: revert the one constant if 0.61 reads as needing too much push. 🔴 AND THE CONTROL CAUGHT MY FIRST ATTEMPT AT ASSERTING IT. I added the new device-level row as subject "latch", and `verify-input --control` failed immediately with "a check did not invert -- it is not testing what it claims to test". It was right: removing the latch does not remove the THRESHOLD, the unlatched path tests `>= Gamepad.ENTER` too, so 0.55 counts 0 either way and the row could never invert. It is a NEGATIVE, and its positive control is the 0.70 row on the same shape. Reclassified. That also exposed a smaller thing: ok()'s negative branch HARDCODED "positive control is the stick row", so a second negative would have borrowed someone else's green line. It now takes the control's name, defaulting to the original text so the d-pad row is unchanged. ✅ I never consumed the pad bit table they have just corrected -- checked by grep over port/, authored/ and tools/port/, not remembered. 2. MY verify-screen NUMBERS WERE llvmpipe-SPECIFIC, and the prediction failed. Pre-registered: both renderers blend in encoded 8-bit space, so the diffs should be identical or within 1 level on the GPU. They are not -- every mean rose 3-35 %: title 0.4431 -> 0.5936, main_menu 3.9363 -> 4.1449, extras 6.7422 -> 6.9757, title_jp 2.7715 -> 2.9448, main_menu_jp 0.7885 -> 1.0157, extras_jp 0.6592 -> 0.8906, build_12/15 0.0368 -> 0.0454. But the MAXIMA are unchanged -- 41, 97, 113, 233, 17 identical, 26 -> 27 on one row. That is a rounding population growing, not content moving: two rasterisers round the last bit of a blend differently while the elements that genuinely differ do not move. Survives: the additive diagnosis, because it rests on an ORDERING and the ordering holds (9 elements > 5 > 0); the pgloading_loop5 localisation; the build_00/01 agreement; the derived allowance, same four failing rows. Does not, and is now labelled: the histogram (53 % within 1 level, 16 844 over 40); every absolute mean; and the RMSE-vs-capture pair 3151.96 / 3769.61 -- that ORDERING claim is not re-derived on the GPU and is not claimed until it is. The rule this earns: a renderer comparison carries its RASTERISER as a hidden parameter. Nothing here recorded which one produced a diff, and for months there was only one so it never mattered. Same discipline TEMPORAL-VERIFICATION already demands for capture rate, applied to what rasterises rather than what clocks. Not settled: H1's repeat half; H6's +0x04 exposure; the four red verify-screen rows; whether the port is still nearer the capture than the reference on the GPU. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AHUQvXGyNcKonSEWsgWcX
170 lines
7.5 KiB
GDScript
170 lines
7.5 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`.
|
|
|
|
## ✅ 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
|
|
|
|
## 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)
|