diff --git a/docs/port/held-direction-repeat.md b/docs/port/held-direction-repeat.md index 1f1efdd6..26272a9d 100644 --- a/docs/port/held-direction-repeat.md +++ b/docs/port/held-direction-repeat.md @@ -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 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 * That the repeat feels right. It runs now, and **only a human can answer that**: diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index f15e02a3..6d1860b5 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -549,6 +549,7 @@ func _process(delta: float) -> void: view.queue_redraw() _overlay_process(delta) _menu_repeat(delta) + _input_probe() if _player != null: # `--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 ## first thing this port learned about input is that a second path is where the ## 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: var step := _pad.repeat_due(delta) if step == 0: @@ -1349,6 +1369,45 @@ func _run_script() -> void: var until := Time.get_ticks_msec() + int(secs * 1000.0) while Time.get_ticks_msec() < until: 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": pass elif SCRIPT_ACTIONS.has(token): diff --git a/port/scripts/gamepad.gd b/port/scripts/gamepad.gd index 843be068..f5ec1d79 100644 --- a/port/scripts/gamepad.gd +++ b/port/scripts/gamepad.gd @@ -252,16 +252,37 @@ func accepts(event: InputEvent) -> bool: ## 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 + # 🔴 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): @@ -269,6 +290,29 @@ func held_direction() -> int: 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