diff --git a/docs/port/held-direction-repeat.md b/docs/port/held-direction-repeat.md new file mode 100644 index 00000000..591097d4 --- /dev/null +++ b/docs/port/held-direction-repeat.md @@ -0,0 +1,99 @@ +# F1 β€” the menu repeats on a held direction: mechanism shipped, **rate deliberately not** + +**Status:** βœ… mechanism implemented and wired. πŸ”΄ **inert on purpose** β€” it does +nothing until a measured repeat rate exists. Written 2026-09-02 by the Port. + +## What was reported + +Two statements from the human, both about the **real game**, a play-test apart: + +> *"Moving stick up/down and holding only moves one item. In game it actually +> continues to move when holding up/down, just at a medium pace so player does +> not need to move pad middle↔up/down, but also slow enough to see which item is +> selected and move to target."* + +> *"Confirmed D-Pad does repeat when holding too."* + +## The file predicted its own refutation + +`gamepad.gd` carried this, written when the latch was added: + +> *"Whether the real game repeats while a direction is held, and how fast, is +> unknown… If the game does repeat, this is a difference a human will notice as +> 'I have to flick it again', and the fix is a measured repeat interval β€” not a +> guessed one."* + +That is exactly what happened, in the words it predicted. **So +one-step-per-deflection is no longer the conservative reading β€” it is a known +defect**, and keeping it is choosing a wrong behaviour over an approximate one. + +## What was built + +| | | +|---|---| +| `Gamepad.held_direction()` | βˆ’1 / 0 / +1, polled from the **devices** | +| `Gamepad.repeat_due(delta)` | one step or 0, per frame | +| `Boot._menu_repeat(delta)` | calls it under the same guards a real press gets | + +### Why it polls devices and not `Input.is_action_pressed` + +`ui_up`/`ui_down` are bound to the stick axis at **Godot's 0.50 action +deadzone**, while this port steps at the game's measured **0.61** (`ENTER`). +Polling the action would repeat throughout the 0.50–0.61 band β€” the exact band +`ENTER` exists to exclude β€” so the repeat would contradict the threshold on the +same stick, on the same frame. + +That is the input-map lesson from 2026-09-01 arriving in a new place: **assert +the device, not the layer above it.** The stick reads from the latch +`accepts()` already maintains, so the first step and the repeat cannot disagree +about hysteresis; the d-pad reads `JOY_BUTTON_DPAD_UP/DOWN` directly, which the +human's second report makes load-bearing rather than defensive. + +### Why the guards are duplicated rather than shared + +`_menu_repeat` re-applies the same four conditions `_unhandled_input` applies β€” +no movie playing, a menu exists, its stack is non-empty, no transition pending. +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 + +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: + +> *"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. + +**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. + +## ⚠️ Adopting the rate breaks a green check, for the right reason + +`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. + +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. + +## What this does not claim + +* That the repeat feels right. It cannot β€” it does not run. +* 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 + has said they match, and the code currently assumes one rate for both. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 66dce511..f15e02a3 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -548,6 +548,7 @@ func _process(delta: float) -> void: _worst_gap = maxf(_worst_gap, delta) view.queue_redraw() _overlay_process(delta) + _menu_repeat(delta) if _player != null: # `--skip-at=SECONDS` presses (A) at a wall-clock moment DURING a movie, @@ -948,6 +949,28 @@ func _unhandled_input(event: InputEvent) -> void: _menu_activate(_menu.cancel(), "back") +## A held direction repeats. F1 of the 2026-09-02 menu play-test: the real game +## continues to move while up or down is held, on the stick AND on the d-pad. +## +## πŸ”΄ **This currently does nothing, and that is the intended state.** The +## mechanism is here; the RATE is not, because it has not been measured and an +## invented one would be indistinguishable from a measurement later. `Gamepad` +## returns 0 from `repeat_due()` until `REPEAT_DELAY`/`REPEAT_INTERVAL` are set. +## +## The guards are deliberately the SAME conditions `_unhandled_input` applies to +## a real press -- a repeat that could fire during a movie, mid-transition or on +## 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. +func _menu_repeat(delta: float) -> void: + var step := _pad.repeat_due(delta) + if step == 0: + return + if _player != null or _menu == null or _menu.stack.is_empty() or _pending != null: + return + _menu_move(step, view.screen.get("buttons", [])) + + func _menu_move(step: int, buttons: Array) -> void: # MEASURED, HANDOFF Q8 + Q5: the cue fires on a press that MOVES the cursor. # `move()` returns whether it did, so a press that changes nothing cannot diff --git a/port/scripts/gamepad.gd b/port/scripts/gamepad.gd index 1bb3884a..398d41db 100644 --- a/port/scripts/gamepad.gd +++ b/port/scripts/gamepad.gd @@ -56,9 +56,8 @@ extends RefCounted ## continues to move when holding up/down, just at a medium pace"*. ## ## So one-step-per-deflection is no longer the conservative reading; it is a -## known defect, and keeping it would be choosing a wrong behaviour over an -## approximate one. The repeat is implemented below. **Its RATE is authored and -## its FACT is not** β€” see `REPEAT_DELAY`. +## known defect. The mechanism is implemented below and the **rate is not +## shipped** β€” see `REPEAT_DELAY` for why an approximate one is worse than none. ## βœ… DECODED 2026-09-01, and it replaces an authored value. ## @@ -93,29 +92,53 @@ const ENTER := 0.61 ## smaller number. const RELEASE := 0.4 -## ## 3. A held direction repeats +## ## 3. A held direction repeats β€” MECHANISM PRESENT, RATE NOT SHIPPED ## -## πŸ”΄ **THE FACT IS REPORTED, THE RATE IS AUTHORED. Do not read the second as -## carried by the first.** A human who has played both said the game repeats at -## *"a medium pace … slow enough to see which item is selected"* β€” that settles -## THAT it repeats and gives an order of magnitude, nothing more. Nobody has -## measured an interval off the running game, and `pad-repeat` stays open for the -## Decoder. +## A human who has played both reported it on 2026-09-02: *"holding only moves +## one item. In game it actually continues to move when holding up/down, just at +## a medium pace"*, and separately *"Confirmed D-Pad does repeat when holding +## too."* So the FACT covers both input devices, which is why `held_direction()` +## polls the pad and the keyboard and not just the stick. ## -## πŸ“Œ **A constant interval is the right SHAPE, and that part is measured.** The +## πŸ”΄ **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."* +## +## 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. +## +## πŸ“Œ **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 -## (`ENTER` above), so it cannot see a deflection magnitude at all β€” a repeat it -## drives cannot be rate-by-how-far-you-push. That excludes the one alternative -## model, so only the constants are open. +## (`ENTER` above), so it cannot see deflection magnitude at all β€” a repeat it +## 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. ## -## The delay exists so a deliberate single step never repeats by accident: a -## flick to move one item is held for well under 0.4 s. +## ⚠️ **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. ## -## ⚠️ **These two numbers change how the menu feels and only a human can judge -## them** β€” the same standing as `ENTER`'s 0.61. Too fast reads as a cursor that -## runs away; too slow reads as the defect this replaces. -const REPEAT_DELAY := 0.40 -const REPEAT_INTERVAL := 0.20 +## πŸ“Œ 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 + + +## Whether a measured repeat rate has been adopted. Until it has, the port keeps +## its current one-step-per-deflection behaviour, which is KNOWN WRONG but is +## wrong in a way nobody will mistake for a measurement. +static func repeat_rate_known() -> bool: + return REPEAT_DELAY > 0.0 and REPEAT_INTERVAL > 0.0 ## Only the left stick. The triggers are axes too, and latching them here would ## silently swallow input the port does not read yet but might. @@ -220,6 +243,8 @@ func held_direction() -> int: ## direction gives one step now and the next only after `REPEAT_DELAY`. A change ## of direction restarts the delay rather than inheriting the old cadence. func repeat_due(delta: float) -> int: + if not repeat_rate_known(): + return 0 var direction := held_direction() if direction == 0 or direction != _repeat_direction: _repeat_direction = direction