fix(port): held_direction() polls the stick instead of reconstructing it

Reported by a human on a real controller: hold the left stick down, the
cursor moves one item and stops. The repeat never runs.

`held_direction()`'s own comment says "Polled at the DEVICE, never through
Input.is_action_pressed". It was not. The d-pad and keyboard branches polled;
the STICK branch read `_latched`, which is a reconstruction of the stick's
position from the event history.

That reconstruction is only as good as the last event seen. A stick held
still sends nothing, and one event reading below RELEASE -- a spring
settling, a deadzone-shaped value, a driver emitting a zero on focus change
-- clears it with no event afterwards to set it back. The port then believes
the stick is centred while the player is holding it, which is precisely the
symptom reported.

Now polls `Input.get_joy_axis()` against the game's own 0.61, which is what
the comment always meant. The latch stays as a fallback for INJECTED events,
so the script harness and verify-input keep testing something.

🔴 Every instrument here missed this because every instrument SUPPLIES the
input it measures: verify-input ticks repeat_due() directly, --script sends
InputEventAction which bypasses the input map, and the new --script=hold:
injects its own axis event. All three agreed with each other and none read a
device. Same shape as the 2026-09-01 report that opened gamepad.gd, one
level deeper, with the lesson already written at the top of that file.

So this adds the two things that would have caught it:

  --script=hold:down:2.0   hold one real axis deflection and log every move
  --input-probe            print what the devices report, on change

⚠️ The fix itself is NOT verified. It matches the symptom exactly and was
found by reading, but only a human holding a stick can confirm it, and the
probe exists so the answer is measured either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-13 14:27:09 +02:00
parent a88e273c40
commit b6e206f1bc
3 changed files with 157 additions and 5 deletions

View File

@@ -134,6 +134,55 @@ produced the press-triggered step — which is the origin the finding measures i
12 frames from. Measured from `t = 0` the harness read 0.433 against 0.402 and 12 frames from. Measured from `t = 0` the harness read 0.433 against 0.402 and
the tolerance would have had to be widened to hide a units mismatch. the tolerance would have had to be widened to hide a units mismatch.
## 🔴 It did not work on a real controller, and every instrument here said it did
**Reported 2026-09-13, by a human holding a real stick: one step, then nothing.**
`held_direction()`'s own comment said *"Polled at the DEVICE, never through
`Input.is_action_pressed`"*. It was not. For the stick it read `_latched` — a
reconstruction of the stick's position from the **event history** — and only the
d-pad and keyboard branches actually polled anything.
`_latched` changes only when an event arrives. A stick held perfectly still
sends nothing, so the reconstruction is only as good as the last event seen, and
any single event reading below `RELEASE` — a spring settling, a deadzone-shaped
value, a driver emitting a zero on focus change — clears it with nothing
afterwards to set it back. From then on the port believes the stick is centred
while the player is holding it. That is exactly "it moves one item and stops".
### Why nothing here caught it
Every instrument in this repo **supplies the input it then measures**:
| instrument | what it feeds |
|---|---|
| `verify-input`'s `repeat` rows | calls `repeat_due()` directly, after setting the latch through `accepts()` |
| `--script=up,down,accept` | `InputEventAction` — bypasses the input map entirely |
| `--script=hold:down:2.0` | injects one `InputEventJoypadMotion` |
All three agreed with each other and none of them agreed with the controller,
because none of them read a device. This is the same shape as the 2026-09-01
report that opened `gamepad.gd`: *a synthetic-input test asserts the code after
the input map, never the input map itself* — one level deeper, and it caught us
again with the lesson already written down.
### What changed
`held_direction()` now polls `Input.get_joy_axis()` against the game's own 0.61,
which is what its comment always meant. The latch survives as a **fallback for
injected events**, because `Input.parse_input_event()` does move `get_joy_axis()`
but the harness must keep working if that ever changes.
And `--input-probe` prints what the devices report, on change:
```
probe: [0] Generic X-Box pad Y=+1.000 X=+0.000 dpad=-- latched=1 held_direction=1 (ENTER=0.61 RELEASE=0.40)
```
⚠️ **This fix is not verified.** It is a defect that matches the symptom exactly,
found by reading, and the only instrument that can confirm it is a human holding
a stick. The probe exists so that the answer is a measurement either way.
## What this does not claim ## What this does not claim
* That the repeat feels right. It runs now, and **only a human can answer that**: * That the repeat feels right. It runs now, and **only a human can answer that**:

View File

@@ -549,6 +549,7 @@ func _process(delta: float) -> void:
view.queue_redraw() view.queue_redraw()
_overlay_process(delta) _overlay_process(delta)
_menu_repeat(delta) _menu_repeat(delta)
_input_probe()
if _player != null: if _player != null:
# `--skip-at=SECONDS` presses (A) at a wall-clock moment DURING a movie, # `--skip-at=SECONDS` presses (A) at a wall-clock moment DURING a movie,
@@ -962,6 +963,25 @@ func _unhandled_input(event: InputEvent) -> void:
## a screen with no menu would be a second, subtly different input path, and the ## a screen with no menu would be a second, subtly different input path, and the
## first thing this port learned about input is that a second path is where the ## first thing this port learned about input is that a second path is where the
## defect hides. ## defect hides.
## `--input-probe` prints what the DEVICES report, whenever it changes.
##
## 🔴 WHY IT EXISTS. On 2026-09-13 a human held a real stick and the cursor moved
## once. Every instrument here disagreed with them and agreed with each other —
## `verify-input` ticks `repeat_due()` directly, `--script=hold:` injects its own
## axis event — because all of them SUPPLY the input they then measure. None
## could see a controller. This is the one line that reads the device and says
## what it says, so the next report of this shape starts from a measurement.
var _probe_last := ""
func _input_probe() -> void:
if not _args().has("input-probe"):
return
var line := _pad.probe_line()
if line != _probe_last:
_probe_last = line
print("probe: %s" % line)
func _menu_repeat(delta: float) -> void: func _menu_repeat(delta: float) -> void:
var step := _pad.repeat_due(delta) var step := _pad.repeat_due(delta)
if step == 0: if step == 0:
@@ -1349,6 +1369,45 @@ func _run_script() -> void:
var until := Time.get_ticks_msec() + int(secs * 1000.0) var until := Time.get_ticks_msec() + int(secs * 1000.0)
while Time.get_ticks_msec() < until: while Time.get_ticks_msec() < until:
await get_tree().process_frame await get_tree().process_frame
elif token.begins_with("hold:"):
# 🔴 THE ONE THING `--script` STRUCTURALLY COULD NOT DO, AND THE ONE
# THING F1 IS ABOUT. Every other step sends an `InputEventAction`,
# which BYPASSES the input map and is not an analog axis — the
# lesson `gamepad.gd` opens with. A held direction is exactly an
# analog axis that is not re-sent, so nothing in this harness could
# ever observe the repeat, and the first report that it does not
# work came from a human with a real controller.
#
# This injects ONE real `InputEventJoypadMotion` at full deflection
# and then sends NOTHING for the duration — which is what a stick
# held still actually looks like — logging every focus change.
var bits := token.split(":")
var secs2 := float(bits[2]) if bits.size() > 2 else 1.0
var dir := -1.0 if bits[1] == "up" else 1.0
print("script[%d] hold %s for %.2f s at %.2f s"
% [i + 1, bits[1], secs2, _elapsed])
var m := InputEventJoypadMotion.new()
m.axis = JOY_AXIS_LEFT_Y
m.axis_value = dir
Input.parse_input_event(m)
var seen := _focus_label(view.focused_id)
var moves := 0
var t0 := _elapsed
while _elapsed - t0 < secs2:
await get_tree().process_frame
var now := _focus_label(view.focused_id)
if now != seen:
moves += 1
print(" hold move %d -> %s at %+.3f s"
% [moves, now, _elapsed - t0])
seen = now
# Release, so the latch re-arms for whatever follows.
var rel := InputEventJoypadMotion.new()
rel.axis = JOY_AXIS_LEFT_Y
rel.axis_value = 0.0
Input.parse_input_event(rel)
print(" hold produced %d move(s) in %.2f s (held_direction=%d at end)"
% [moves, secs2, _pad.held_direction()])
elif token == "wait": elif token == "wait":
pass pass
elif SCRIPT_ACTIONS.has(token): elif SCRIPT_ACTIONS.has(token):

View File

@@ -252,16 +252,37 @@ func accepts(event: InputEvent) -> bool:
## exclude — so the repeat would contradict the threshold on the same stick. ## 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. ## That is the input-map lesson again: assert the device, not the layer above it.
func held_direction() -> int: func held_direction() -> int:
# The stick, from the latch `accepts()` already maintains, so the repeat and # 🔴 THE STICK IS NOW ACTUALLY POLLED. It used to read `_latched` — a
# the first step read one state and cannot disagree about hysteresis. # reconstruction of the stick's position from the event history — while the
var stick := int(_latched.get(JOY_AXIS_LEFT_Y, 0)) # comment above said "polled at the DEVICE". A human holding a real stick
if stick != 0: # got exactly one step and no repeat (2026-09-13), and the harness could not
return stick # 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(): 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): if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_UP):
return -1 return -1
if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_DOWN): if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_DOWN):
return 1 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): if Input.is_key_pressed(KEY_UP):
return -1 return -1
if Input.is_key_pressed(KEY_DOWN): if Input.is_key_pressed(KEY_DOWN):
@@ -269,6 +290,29 @@ func held_direction() -> int:
return 0 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. ## 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 ## The FIRST step is not this function's: it comes from the event edge in