#!/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") 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) 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