diff --git a/docs/port/held-direction-repeat.md b/docs/port/held-direction-repeat.md index 591097d4..1f1efdd6 100644 --- a/docs/port/held-direction-repeat.md +++ b/docs/port/held-direction-repeat.md @@ -1,7 +1,10 @@ -# F1 β€” the menu repeats on a held direction: mechanism shipped, **rate deliberately not** +# F1 β€” the menu repeats on a held direction: mechanism shipped, **and the rate adopted** -**Status:** βœ… mechanism implemented and wired. πŸ”΄ **inert on purpose** β€” it does -nothing until a measured repeat rate exists. Written 2026-09-02 by the Port. +**Status:** βœ… mechanism implemented and wired, 2026-09-02. βœ… **rate adopted +2026-09-13** from `docs/re/f1-repeat-measured-via-driver-patch.md` β€” +`REPEAT_DELAY = 0.402`, `REPEAT_INTERVAL = 0.134`. It ran inert for eleven days +and that was the right state; this page keeps the inert-era reasoning because it +is why the two numbers can be trusted now. ## What was reported @@ -57,42 +60,87 @@ A repeat that could fire during a movie or mid-transition 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. -## πŸ”΄ And the rate is not shipped +## βœ… The rate, and how far each half of it reaches -An earlier draft of this change had `REPEAT_DELAY = 0.40` and -`REPEAT_INTERVAL = 0.20`, with a paragraph explaining that they were authored. -**They were removed rather than commented out**, on an explicit instruction: +An earlier draft had `REPEAT_DELAY = 0.40` and `REPEAT_INTERVAL = 0.20` with a +paragraph explaining that they were authored. They were removed rather than +commented out, on an explicit instruction: > *"Take the RATE from the Decoder β€” do NOT ship a placeholder interval. An > invented rate here is indistinguishable from a measured one later, and this is > the exact field where that already cost us."* -The instruction is right and the draft was the named failure mode: the -explanation would have merged, the numbers would have felt roughly right, and -nothing downstream could have separated them from a measurement. `REPEAT_DELAY` -is `-1.0`; `repeat_due()` returns 0 while `repeat_rate_known()` is false. +πŸ“Œ **The measurement has now landed, and it vindicates the instruction in the +most awkward possible way: the guessed delay was nearly right and the guessed +interval was off by 50 %.** 0.40 against a measured 0.402; 0.20 against a +measured 0.134. Had both shipped, the half that was wrong would have been +protected by the half that was right. -**One thing about the rate IS measured, and it narrows the question.** The game -digitises the left stick to four direction bits at 61 % deflection, so it cannot -see deflection magnitude at all β€” the repeat it drives *cannot* be -faster-the-harder-you-push. That excludes the one competing model, so only two -constants are open and a single measurement closes both. +The Decoder measured, in Canary at an achieved 29.87 fps guest rate, **12 frames** +from the press-triggered step to the first repeat and **4 frames** per step after +it. Converted to seconds here because this port does not run at the guest's rate +and it is the cadence that was measured: 12 / 29.87 = **0.402**, 4 / 29.87 = +**0.134**. -## ⚠️ Adopting the rate breaks a green check, for the right reason +### ⚠️ The two numbers are not equally well evidenced -`tools/port/verify-input` asserts *"a held stick is ONE step, not six"*. That row -passes today **because the feature is inert**, i.e. it asserts the absence of the -repeat. When a rate is adopted a held stick should produce further steps and that -row will go red. +No physical controller exists in the Decoder's container. The measurement was +taken by patching Canary's `--hid=file` driver to emit Keystroke `REPEAT` at the +**SDL driver's own** 400 ms / 100 ms constants: -It is not wrong and it should not be deleted in a hurry: it was written for the -2026-09-01 jitter defect, so it will *look* like that bug returning. It has to be -re-stated as "one step per deflection **plus** the measured repeat", with the -jitter case still covered inside the delay window. +* the **delay** came back as 402 ms β€” to within the frame quantum, *the constant + that was fed in*. It confirms the instrument, not the game; +* the **interval** came back as 133 ms against a fed-in 100 ms. That gap is the + genuinely new fact: the game paces repeats to its own frame consumption rather + than to the event stream. + +So the interval is what the game does; the delay is what Xenia's SDL driver does, +and the game was not observed to disagree. **One run** β€” the corpus's own two-run +minimum is not met, and the source page says so itself. + +**One thing about the rate was already measured, and it narrowed the question.** +The game digitises the left stick to four direction bits at 61 % deflection, so +it cannot see deflection magnitude at all β€” the repeat it drives *cannot* be +faster-the-harder-you-push. That excluded the one competing model, so only two +constants were ever open and a single measurement closed both. + +## πŸ”΄ Adopting the rate was predicted to break a green check. It did not, and that was worse + +This page and `gamepad.gd` both said that `verify-input`'s row *"a held stick is +ONE step, not six"* asserts the **absence** of the repeat, and would go red on +adoption β€” looking like the 2026-09-01 jitter defect returning. + +**Run on adoption day: the row stayed green.** `steps()` feeds axis values through +the latch and never advances a clock, so it had never called `repeat_due()` at +all. The row tests the *latch*, which the repeat does not touch. The prediction +was reasoned rather than run. + +What it hid is the real problem: the rate was about to ship into a harness with +**no coverage of this feature whatsoever**, and that green line would have been +read as coverage of it. + +The fix was not to change that row. `verify-input` gains a `repeat` subject that +holds a direction through the same latch and ticks `repeat_due()`, asserting +**these two numbers** β€” a shape-only check ("it repeats eventually") would have +passed on the 0.40 / 0.20 guess this port refused to ship. Five rows: 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 asserted), +and a direction change restarting the delay. + +⚠️ **The origin is one frame, and it is not a tolerance.** `repeat_due()`'s first +call only latches the direction; the clock accumulates from the call after it. In +the port that first call is the frame the press is handled β€” the frame that +produced the press-triggered step β€” which is the origin the finding measures its +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. ## What this does not claim -* That the repeat feels right. It cannot β€” it does not run. +* That the repeat feels right. It runs now, and **only a human can answer that**: + 0.134 s is ~7.5 steps a second, and the play-test that opened this asked for + *"slow enough to see which item is selected"*. If it reads as too fast, the + 100 ms constant is Xenia's and not the game's, and no capture in that + container would have revealed it. * Any rate, or any bound on one. "Medium pace" is a direction, not a number, and it is not recorded anywhere as data. * That the d-pad and the stick repeat at the *same* rate. Both repeat; nobody diff --git a/port/scripts/gamepad.gd b/port/scripts/gamepad.gd index 398d41db..843be068 100644 --- a/port/scripts/gamepad.gd +++ b/port/scripts/gamepad.gd @@ -100,17 +100,43 @@ const RELEASE := 0.4 ## too."* So the FACT covers both input devices, which is why `held_direction()` ## polls the pad and the keyboard and not just the stick. ## -## πŸ”΄ **THE RATE IS DELIBERATELY UNSET, AND THE REPEAT DOES NOT RUN UNTIL IT IS -## MEASURED.** The instruction is explicit: *"Take the RATE from the Decoder β€” do -## NOT ship a placeholder interval. An invented rate here is indistinguishable -## from a measured one later, and this is the exact field where that already cost -## us."* +## βœ… **THE RATE IS NOW MEASURED, 2026-09-12, and adopted here.** +## `docs/re/f1-repeat-measured-via-driver-patch.md`, with the per-transition +## reference data in `docs/re/data/f1-repeat-cursor-transitions.tsv`. ## -## An earlier draft of this file had 0.40 / 0.20 with a paragraph explaining that -## they were authored. **That is precisely the failure mode named above** β€” the -## explanation would have been merged, the numbers would have felt roughly right, -## and nothing afterwards could distinguish them from a measurement. They are -## removed rather than commented out. +## ~~THE RATE IS DELIBERATELY UNSET, AND THE REPEAT DOES NOT RUN UNTIL IT IS +## MEASURED.~~ It ran unset for eleven days and that was the right state; the +## paragraph is struck through rather than deleted because the reason it gave is +## the reason these two numbers can be trusted now. An earlier draft had +## **0.40 / 0.20** with a note saying they were authored β€” and 0.40 would have +## looked vindicated today while 0.20 was off by 50 %. That is exactly why an +## explained guess is worse than none: half of it would have been right. +## +## ## Where the two numbers come from, and how far they reach +## +## Measured in Canary at an achieved **29.87 fps** guest rate: **12 frames** +## from the press-triggered step to the first repeat, then **4 frames** per step +## (13 of 15 gaps; 3 frames for the other 2). Converted to seconds here, not +## frames, because this port does not run at the guest's rate and it is the +## *cadence* that was measured β€” 12 / 29.87 = 0.402, 4 / 29.87 = 0.134. +## +## ⚠️ **THE DELAY IS WEAKER EVIDENCE THAN THE INTERVAL, and they should not be +## trusted equally.** No physical controller exists in that container, so the +## measurement was taken by patching Canary's `--hid=file` driver to emit +## Keystroke `REPEAT` at the SDL driver's own 400 ms / 100 ms constants. The +## 402 ms that came back is, to within the frame quantum, **the constant that +## was fed in** β€” it confirms the instrument, not the game. The 133 ms interval +## is the genuinely new fact: the driver was fed 100 ms and the cursor moved +## every 133, so the game paces repeats to its own frame consumption rather +## than to the event stream. +## +## So: the interval is what the game does. The delay is what Xenia's SDL driver +## does, and the game was not observed to disagree with it. If a capture through +## a real controller ever contradicts 0.402, that is the number to move. +## +## πŸ“Œ One run. The corpus's own two-run minimum is **not met** β€” the source page +## says so itself, and this comment repeats it rather than letting the constant +## look firmer at the call site than it does at the finding. ## ## πŸ“Œ **A constant interval is the right SHAPE, and that part IS measured.** The ## game digitises the left stick to four direction bits at 61 % deflection @@ -118,20 +144,27 @@ const RELEASE := 0.4 ## drives cannot be faster-the-harder-you-push. That excludes the one competing ## model, so only the two constants are open, and one measurement closes both. ## -## ⚠️ **TO ADOPT, TWO THINGS CHANGE, NOT ONE.** Set both constants to the -## measured seconds β€” and update `tools/port/verify-input`, whose row *"a held -## stick is ONE step, not six"* currently asserts **the absence of this -## feature**. It passes today because the repeat is inert; the moment a rate is -## adopted a held stick SHOULD produce further steps, and that green row would -## go red for the right reason and be read as a regression. +## ⚠️ ~~**TO ADOPT, TWO THINGS CHANGE, NOT ONE.** … that green row would go red +## for the right reason and be read as a regression.~~ ## -## πŸ“Œ That row is not wrong. A check written against today's behaviour becomes an -## assertion that the behaviour never changes, and this one has the additional -## trap of looking like a bug-fix regression test β€” it was written for the -## jitter defect, and the repeat is not that defect returning. -## `pad-repeat` in `BLOCKED.md` carries the request. -const REPEAT_DELAY := -1.0 -const REPEAT_INTERVAL := -1.0 +## πŸ”΄ **RUN ON ADOPTION DAY: THE ROW STAYED GREEN, AND THAT IS WORSE.** +## `verify-input`'s `steps()` never advances a clock, so it had never called +## `repeat_due()` at all β€” the row it warned about tests the *latch*, which the +## repeat does not touch. The prediction was reasoned rather than run, and what +## it hid is the real problem: the rate was about to ship into a harness with +## **no coverage of this feature whatsoever**, and the green line would have +## been read as coverage. +## +## The fix was not to change that row. It was to add a `repeat` subject that +## holds a direction through the same latch and ticks `repeat_due()`, asserting +## **these two numbers** rather than "it repeats eventually" β€” a shape-only +## check would have passed on the 0.40 / 0.20 guess this file refused to ship. +## +## πŸ“Œ The original point survives intact and is worth keeping: a check written +## against today's behaviour becomes an assertion that the behaviour never +## changes. It was simply aimed at the wrong row. +const REPEAT_DELAY := 0.402 +const REPEAT_INTERVAL := 0.134 ## Whether a measured repeat rate has been adopted. Until it has, the port keeps diff --git a/tools/port/verify-input b/tools/port/verify-input index ef2e5a1e..8a321026 100755 --- a/tools/port/verify-input +++ b/tools/port/verify-input @@ -112,6 +112,53 @@ func steps(values: Array, latched: bool) -> int: 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": @@ -201,6 +248,76 @@ func _init() -> void: 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)