Files
Sylpheed/tools/port/verify-input
MechaCat02 c0c649c5dd
All checks were successful
CI / Native — linux (pull_request) Successful in 44m35s
CI / WASM — Web (pull_request) Successful in 33m25s
CI / Formatting (pull_request) Successful in 1m23s
feat(port): adopt the measured held-direction repeat rate (F1)
REPEAT_DELAY = 0.402, REPEAT_INTERVAL = 0.134, from
docs/re/f1-repeat-measured-via-driver-patch.md -- 12 and 4 frames at the
run's achieved 29.87 fps guest rate, converted to seconds because this port
does not run at the guest's rate and it is the cadence that was measured.

The mechanism has been here since 2026-09-02 and inert on purpose. The
instruction it was waiting on is now vindicated in the most awkward way: the
draft it refused to ship had 0.40 / 0.20, so the guessed delay was nearly
right and the guessed interval was off by 50 %. The half that was wrong
would have been protected by the half that was right.

The delay and the interval are NOT equally well evidenced, and the code says
so at the constants. No physical controller exists in the Decoder's
container, so the measurement fed Canary's file driver the SDL driver's own
400/100 ms constants: the 402 ms that came back is the constant that went
in, and confirms the instrument. The 133 ms interval against a fed-in 100 ms
is the new fact -- the game paces repeats to its own frame consumption. One
run; the two-run minimum is not met and the finding says so itself.

Also: the prediction that this would turn verify-input's "a held stick is
ONE step, not six" red was wrong. It stayed green, because steps() never
advances a clock and so had never called repeat_due() at all -- the rate was
about to ship into a harness with no coverage of the feature, with a green
line that would have been read as coverage.

So verify-input gains a `repeat` subject: nothing before the delay, the
first repeat on the delay, the steady interval, cadence independent of frame
rate (the code claims this in a comment, so it is now asserted), and a
direction change restarting the delay. Each asserts THESE numbers, not the
shape -- a shape-only check would have passed on 0.40 / 0.20. The control
removes the premise, a held direction, and every controllable row inverts.

The first-repeat row measures from the arming tick, not from t=0: that tick
is the frame the press is handled, which is the origin the finding measures
its 12 frames from. Measured from zero it read 0.433 vs 0.402 and the
tolerance would have had to be widened to hide a units mismatch.

Closes #2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:34:14 +02:00

354 lines
15 KiB
Bash
Executable File

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