#!/usr/bin/env bash # The input map, and the stick latch -- asserted against Godot, not reasoned about. # # tools/port/verify-input # tools/port/verify-input --control # each check fails when its subject is removed # # πŸ”΄ WHY THIS EXISTS. A human played the port on a real controller and β’Ά did # nothing. Skipping the intro did nothing; opening a submenu did nothing. The # unattended P5 walk had passed on every iteration while this was true, and the # reason is exact: # # `--script` sends `InputEventAction`, which BYPASSES the input map. # # So the harness asserted every line of code *after* the input map and nothing # about the map itself -- and the map was missing half the actions. Godot 4.7.2 # binds NO joypad button to `ui_accept` or `ui_cancel`, while it binds the d-pad # AND the left stick to `ui_up`/`ui_down`. Four actions worked on the pad, two # did not, which reads as a broken controller. # # The second defect had the same blind spot: `InputEventAction` is not an analog # axis, so the harness could not have seen that a held stick fires once per # jitter. The human's words were "moves the cursor too fast". # # ⚠️ THE GENERAL LESSON, worth more than either fix: **a synthetic-input test # cannot assert the input map.** Anything injected below the map is evidence # about the code above it only. # # ## The control, and what it can and cannot cover # # πŸ”΄ The first version of `--control` inverted ALL NINE assertions and demanded # every one fail with the fixup skipped. Seven of them do not depend on the # fixup, so it reported them as broken -- a control that fails a correct check # is the same defect as one that passes a dead check, and this file would have # shipped claiming its checks were untrustworthy. Each check now names its # SUBJECT, and the control removes exactly that subject: # # bind -- skip `Gamepad.bind_missing()`; the check must fail # latch -- run the same events through no latch at all; the count must differ # godot -- NOT CONTROLLABLE HERE, and said so rather than faked. These assert # what Godot itself binds. There is nothing of ours to remove; they # exist to make a future Godot dropping the d-pad a failing check # instead of a bug report. set -euo pipefail cd "${PROJECT_DIR:-$(git rev-parse --show-toplevel)}" GODOT="${GODOT:-godot}" mode="assert" [ "${1:-}" = "--control" ] && mode="control" probe="port/.verify-input-probe.gd" trap 'rm -f "$probe" "${probe}.uid"' EXIT INT TERM cat > "$probe" <<'GD' extends SceneTree var mode := OS.get_environment("VERIFY_INPUT_MODE") var fail := 0 var ran := 0 ## `subject` is what the check depends on, and decides whether the control ## removes it. A check whose subject cannot be removed is skipped there and ## counted, not silently dropped -- a control that quietly tests four of nine ## things reports the same green line as one that tests all nine. func ok(name: String, subject: String, cond: bool, detail: String = "", control_row: String = "the stick row (6 -> 1)") -> void: if mode == "control" and subject == "godot": print(" %-44s -- not controllable (Godot's own binding)" % name) return if mode == "control" and subject == "negative": # πŸ”΄ R4: a NEGATIVE carries a positive control, it does not carry an # inversion. "The latch must not touch buttons" cannot be controlled by # removing the latch -- with no latch, buttons pass, which is the same # answer. What shows the method has power is that the SAME counter, on # the same code path, reduces 6 stick events to 1. That row is the # positive control for this one, and naming it is the honest move; # inverting it would have been a green line that meant nothing. # πŸ”΄ The control row was HARDCODED here and a second negative arrived. # A negative that names someone else's control is not controlled; it is # borrowing a green line. `control_row` now defaults to the original # text so that row is unchanged, and any new negative must say what # actually backs it. print(" %-44s -- negative; positive control is %s" % [name, control_row]) return ran += 1 var want: bool = cond if mode != "control" else not cond print(" %-44s %s%s" % [name, "ok" if want else "πŸ”΄ FAILED", (" " + detail) if detail != "" else ""]) if not want: fail = 1 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 ## Feed a run of axis values through a latch (or through none) and count the ## presses it would produce. func steps(values: Array, latched: bool) -> int: var pad := Gamepad.new() var n := 0 for v: float in values: var e := InputEventJoypadMotion.new() e.axis = JOY_AXIS_LEFT_Y e.axis_value = v # No latch = what the port did before: every event above the action # deadzone is a press. That is the bug, reproduced, as the control. if pad.accepts(e) if latched else absf(v) >= Gamepad.ENTER: n += 1 return n ## The latch as the port actually uses it -- and REMOVED under `--control`, so ## the rows that depend on it invert. func nav(values: Array) -> int: return steps(values, mode != "control") ## Hold a direction through the REAL latch, then tick `repeat_due()` and report ## the time of every repeat it produces. ## ## The press edge is fed through `accepts()` rather than poked into the latch, ## so this exercises the same path the port does -- `held_direction()` reads ## that latch first. Under `--control` the hold is simply not made: the rate is ## a `const` and cannot be removed at runtime, so what the control removes is ## the PREMISE (a direction being held), and every count must go to zero. func hold_and_tick(seconds: float, delta: float) -> Array[float]: var pad := Gamepad.new() if mode != "control": pad.accepts(deflect(0.92)) var t := 0.0 var out: Array[float] = [] while t < seconds: t += delta if pad.repeat_due(delta) != 0: out.append(t) return out ## Hold one way past the delay, reverse, and report how long until the first ## repeat in the NEW direction. Inheriting the old cadence would show up here as ## a time far below `REPEAT_DELAY`. func reversal_delay(delta: float) -> float: var pad := Gamepad.new() if mode != "control": pad.accepts(deflect(0.92)) var t := 0.0 while t < 1.0: t += delta pad.repeat_due(delta) pad.accepts(deflect(0.0)) if mode != "control": pad.accepts(deflect(-0.92)) t = 0.0 while t < 2.0: t += delta if pad.repeat_due(delta) != 0: return t return -1.0 func deflect(v: float) -> InputEventJoypadMotion: var e := InputEventJoypadMotion.new() e.axis = JOY_AXIS_LEFT_Y e.axis_value = v return e func _init() -> void: # The control removes the repair. Everything else runs with it applied. if mode != "control": Gamepad.bind_missing() # ── 1. subject `bind` -- the two actions Godot leaves unbound ───────────── ok("β’Ά reaches ui_accept", "bind", has_button("ui_accept", JOY_BUTTON_A), "JOY_BUTTON_A") ok("β’· reaches ui_cancel", "bind", has_button("ui_cancel", JOY_BUTTON_B), "JOY_BUTTON_B") # ── 2. subject `godot` -- what the engine binds, and must keep binding ──── # # The keyboard events must SURVIVE the fixup: declaring `ui_accept` in # project.godot would have replaced the built-in wholesale and dropped them # silently. Adding to the action must not. var keys := 0 for e in InputMap.action_get_events("ui_accept"): if e is InputEventKey: keys += 1 ok("ui_accept keeps its keyboard events", "godot", keys >= 2, "%d key event(s)" % keys) ok("d-pad reaches ui_down", "godot", has_button("ui_down", JOY_BUTTON_DPAD_DOWN)) var axis := false for e in InputMap.action_get_events("ui_down"): if e is InputEventJoypadMotion and e.axis == JOY_AXIS_LEFT_Y: axis = true ok("left stick reaches ui_down", "godot", axis, "axis %d" % JOY_AXIS_LEFT_Y) # ── 3. subject `latch` -- one step per deflection, not one per jitter ───── # # A push to full deflection followed by jitter that never returns to # neutral: what a real stick emits, and what produced "moves the cursor too # fast". The control runs the identical values with no latch and must count # every one of them, which is what makes this a discriminator rather than a # number that happens to be 1. var held := [0.92, 0.95, 0.91, 0.99, 0.93, 0.97] # `nav()` is the latch under control: in `--control` the latch is REMOVED, # which is what makes these rows invert. Reading `steps(..., true)` in both # modes was the earlier defect -- the control ran the repaired code and then # demanded it fail. ok("a held stick is ONE step, not six", "latch", nav(held) == 1, "latched %d, unlatched %d" % [steps(held, true), steps(held, false)]) # Release, then push again: that IS a second press, or the stick becomes # single-use. ok("release then push is a second step", "latch", nav([0.92, 0.95, 0.10, 0.88]) == 2, "%d step(s)" % nav([0.92, 0.95, 0.10, 0.88])) # Hysteresis: drifting back only as far as the release threshold must not # re-arm, or a stick resting near the boundary chatters -- the original bug # with a smaller number. ok("boundary drift does not re-arm", "latch", nav([0.9, 0.45, 0.9, 0.45, 0.9]) == 1, "%d step(s)" % nav([0.9, 0.45, 0.9, 0.45, 0.9])) # βœ… THE GAME'''S OWN THRESHOLD, ASSERTED AT THE DEVICE LEVEL. The game # digitises the stick to four direction bits at 61 % deflection, so a # deflection between Godot'''s 0.50 action deadzone and that 0.61 is a # direction the real game never sees. At the old ENTER = 0.5 this port # stepped there. Negative first, then the positive control on the SAME run # shape -- a negative alone would also pass if the latch were simply broken. # πŸ”΄ THIS ROW WAS "latch" AND THE CONTROL CAUGHT IT IMMEDIATELY. Removing # the latch does not remove the THRESHOLD -- the unlatched path also tests # `>= Gamepad.ENTER`, so 0.55 counts 0 either way and the row could never # invert. The harness said so in one run: "a check did not invert -- it is # not testing what it claims to test". It is a negative, and its positive # control is the row below it: the same shape at 0.70 does step. ok("0.55 is below the game 61 % threshold, must not step", "negative", nav([0.55, 0.55, 0.55]) == 0, "%d step(s)" % nav([0.55, 0.55, 0.55]), "the 0.70 row on the same shape") ok("...and its control: 0.70 on the same shape DOES step", "latch", nav([0.70, 0.70, 0.70]) == 1, "%d step(s)" % nav([0.70, 0.70, 0.70])) # A button already IS an edge; latching it would swallow the second of two # quick taps. var pad := Gamepad.new() var passed := 0 for i in 3: var b := InputEventJoypadButton.new() b.button_index = JOY_BUTTON_DPAD_DOWN b.pressed = true if pad.accepts(b): passed += 1 ok("d-pad presses are not latched", "negative", passed == 3, "%d of 3" % passed) # ── 4. subject `repeat` -- a held direction repeats at the MEASURED rate ── # # πŸ”΄ THIS SECTION EXISTS BECAUSE A PREDICTION IN `gamepad.gd` WAS WRONG. # That file said adopting the rate would turn "a held stick is ONE step, not # six" red, and warned that the row would read as a regression. Measured on # adoption day: it stays green, because `steps()` never advances a clock and # so has never called `repeat_due()` at all. The warning was reasoned, not # run -- and the real consequence is worse than the one predicted. The rate # shipped into a harness with **no coverage of the feature whatsoever**. # # The rows below are that coverage. They assert the two numbers from # `docs/re/f1-repeat-measured-via-driver-patch.md`, not the shape alone: a # test that only checked "it repeats eventually" would pass on any constant # and would have passed on the 0.40 / 0.20 guess this port deliberately # refused to ship. var FRAME := 1.0 / 60.0 # Hold the stick by pushing it through the same latch the port uses, then # tick. `held_direction()` reads the latch, so this is the real path. var timeline := hold_and_tick(2.0, FRAME) # ⚠️ MEASURED FROM THE ARMING TICK, NOT FROM t=0, and the difference is a # whole frame. `repeat_due()`'s first call only latches the direction and # returns 0; the clock accumulates from the call after it. In the port that # first call happens on the frame the press is handled -- the frame that # produced the press-triggered step -- and the finding measures its 12 # frames "from the press-triggered step to the first repeat". So the arming # tick is the press step, and subtracting it is what puts the harness and # the finding on the same origin. Without this the row read 0.433 vs 0.402 # and the tolerance would have had to be widened to hide a units mismatch. var first: float = (timeline[0] - FRAME) if not timeline.is_empty() else -1.0 ok("nothing repeats before the measured delay", "negative", first >= Gamepad.REPEAT_DELAY, "first repeat %.3fs after the press step, delay is %.3f" % [first, Gamepad.REPEAT_DELAY], "the steady-interval row below") ok("first repeat lands on the measured delay", "repeat", first >= 0.0 and absf(first - Gamepad.REPEAT_DELAY) <= FRAME, "%.3fs vs %.3f (Β±one frame)" % [first, Gamepad.REPEAT_DELAY]) var gaps: Array[float] = [] for i in range(1, timeline.size()): gaps.append(timeline[i] - timeline[i - 1]) var mean := 0.0 for g in gaps: mean += g mean = mean / gaps.size() if not gaps.is_empty() else -1.0 ok("steady interval is the measured 0.134s", "repeat", not gaps.is_empty() and absf(mean - Gamepad.REPEAT_INTERVAL) <= FRAME, "mean %.3fs over %d gap(s) vs %.3f" % [mean, gaps.size(), Gamepad.REPEAT_INTERVAL]) # `repeat_due()` subtracts the interval rather than resetting the clock, # with the stated reason "at 140 fps and at 30 fps the same number of steps # happen per second". That is a claim about the code, so it is asserted # rather than believed. var at30 := hold_and_tick(2.0, 1.0 / 30.0).size() var at240 := hold_and_tick(2.0, 1.0 / 240.0).size() # `at30 > 0` matters: with nothing held both counts are 0 and "they agree" # would be a green line for a mechanism that never ran -- the control caught # exactly that, so the count is asserted as well as the agreement. ok("the cadence does not drift with frame rate", "repeat", at30 > 0 and absf(at30 - at240) <= 1, "%d steps at 30fps, %d at 240fps" % [at30, at240]) # A direction change must restart the delay, not inherit the old cadence -- # otherwise flicking the other way mid-repeat steps instantly. ok("a direction change restarts the delay", "repeat", reversal_delay(FRAME) >= Gamepad.REPEAT_DELAY, "%.3fs after the reversal" % reversal_delay(FRAME)) if ran == 0: print("πŸ”΄ no check ran -- the harness asserted nothing") quit(2) quit(fail) GD out=$(VERIFY_INPUT_MODE="$mode" "$GODOT" --headless --path port \ --script "res://$(basename "$probe")" 2>&1 \ | grep -v "^Godot Engine\|^$" || true) rc=0 printf '%s' "$out" | grep -q "πŸ”΄" && rc=1 if [ "$mode" = "control" ]; then echo "control -- each check must fail when ITS OWN subject is removed:" printf '%s\n' "$out" echo if [ $rc -eq 0 ]; then echo "every controllable check fails without its subject -- the control holds" exit 0 fi echo "πŸ”΄ a check did not invert -- it is not testing what it claims to test" exit 1 fi echo "input map and stick latch:" printf '%s\n' "$out" echo if [ $rc -eq 0 ]; then echo "β’Ά and β’· reach the game, and a held stick is one step" exit 0 fi echo "πŸ”΄ the input map is not what the port needs" exit 1