diff --git a/docs/agents/RETRO-2026-08-31-agreed.md b/docs/agents/RETRO-2026-08-31-agreed.md index 2a77f2b7..f4c63aa8 100644 --- a/docs/agents/RETRO-2026-08-31-agreed.md +++ b/docs/agents/RETRO-2026-08-31-agreed.md @@ -97,7 +97,33 @@ reading as progress because each entry is individually rigorous. ## 7. What needs the human -1. **The register re-classification (R1).** It changes the file both agents read to - decide what *not* to try. Two agents agreeing is not the right authority. +1. ✅ **The register re-classification (R1) — DONE 2026-09-01, by the human**, on + `docs/re/REFUTED.md` at the Decoder's tip. All **222** entries now carry an + `⟨instrument⟩`; the file opens with a reading guide naming which instruments + are ours; R1 is now standing text in `PROTOCOL.md`; and + `tools/stale-instrument` is the `--stale` query — run it whenever you improve + a renderer, a reader or the harness, and it lists what that instrument killed. + + **Ten entries moved ❌ → 🟡**, each naming what would settle it: eight + `render-vs-capture`, one `our-reader`, one `harness`. + + Three things the pass turned up that neither self-review had: + + * **The `rest()` question is open, and had been reading as settled in both + directions.** *"rest = last keyframe"* was refuted by the sibling argument; + that refutation was then refuted by correlating our render against + captures. Both legs run through our renderer, so under R1 neither survives + — and which one you believed depended on which entry you found first. + 🔴 **This one is load-bearing for the port**: `rest()` decides the pose + every plateau-less element is drawn at. + * **A withdrawal never reached its sibling.** *"2 391 frames, max glyph 0"* + was withdrawn because a long-lived `x11grab` stream degrades and then + repeats a stale frame. The 1 674-sample negative three lines above it — + same probe, same instrument, comparable duration — was left standing as a + *reinstated measurement*. §1's lesson, inside the register itself. + * **83 of 222 entries — 37 % — record no instrument at all.** Not disputed, + not safe: **unauditable**. `stale-instrument unrecorded` is the backfill + queue, and it is larger than every other group combined. + 2. **P5's gate** — a person clicking through the port. Unchanged, and it is the only thing standing between the milestone and done. diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 6fed9461..d650a87c 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -84,6 +84,52 @@ command rather than by trusting the date: git log -1 --format=%h -- docs/port/HANDOFF.md # newer than 9ca1eb5? re-reconcile ``` +## New asks, 2026-09-01 — from a HUMAN PLAY-TEST on real hardware, port `HEAD` `6b713e8` + input fix + +**The first play-test on a physical controller found four things.** Two were port +defects and are fixed; **two are oracle questions and are recorded here unguessed.** + +⚠️ Read the fixed pair first, because the *reason* they survived so long is a +method finding that applies to the Decoder's harness as much as this one: + +> **`--script` sends `InputEventAction`, which bypasses the input map.** So every +> check this port had asserted the code *below* the map and nothing about the map +> — which turned out to have **no joypad binding for `ui_accept` or `ui_cancel` +> at all** in Godot 4.7.2, while binding the d-pad *and* the left stick to +> `ui_up`/`ui_down`. Ⓐ and Ⓑ were dead on a real pad for the whole of P5 while +> the unattended walk passed every iteration. The same blind spot hid the second +> defect: an `InputEventAction` is not an analog axis, so nothing could observe +> that a held stick fires once per *jitter*. Now asserted by +> `tools/port/verify-input`, with a control. + +| # | ask | why the port cannot answer it | +|---|---|---| +| **H1** | **Does a held direction REPEAT in the menus, and at what rate?** Initial delay and repeat interval, for the d-pad and for the left stick separately if they differ. | Behavioural. The port now does **one step per deflection** — `authored`, and deliberately the conservative half: it cannot run away and it invents no rate. If the game repeats, a human will feel it as *"I have to flick it again"*. | +| **H2** | **The splash blur/fade-in does not match the game.** A human watching both says the game's is **more pronounced** than the port's. | Needs a capture of the publisher/developer splash across its fade, at a known frame cadence. The port's ramp comes from the declared keyframes; whether the game applies a **blur** on top — and whether what we read as a fade is one — is not answerable from the export. | +| **H3** | **The `PRESS Ⓐ` plate arrives LATE.** The port raises it at `t=236`, derived `238 − 118 = 120 units = 2.000 s`. | 🟡 **Half of this may be the port's, and that half must be settled here first.** Three candidate causes: the **unit→seconds constant** (Decoder's), the **clock origin** — do builds 2 and 4 really start together? — and **`rest.t`** (both ours). `REFUTED.md` records that believing `rest.t` already put a plate **3.97 s late** once. Establish which half it is *before* asking, and say how. | + +**On H2, three things the port can say that narrow it**, none of which settle it: + +* The port draws the splash from the declared keyframe alphas only. It applies + **no blur at all**, so "more pronounced in the game" is consistent with a + post-effect the export does not describe, with a different ramp shape, or with + both. +* 🔴 **The `rest()` question is open in both directions** — see + [`REFUTED.md`](../re/REFUTED.md)'s `rest()` pair after the 2026-09-01 R1 + reclassification. The two splashes are the *only* screens that reach the + plateau-less fallback (title, main menu and `EXTRAS` reach it zero times), so + **H2 lands exactly where our resting-pose heuristic is least trustworthy.** + That is not a coincidence worth ignoring. +* The register also has *"the declared keyframe timeline reproduces the captured + splash"* now sitting at 🟡 `⟨our-reader⟩` rather than ❌, because the + record-layout fix re-times a group's final pose and the entry was never + re-derived under it. **H2 may already be half-answered by re-running that.** + +**What would settle H2:** a capture of the developer splash across its build-in +at a known cadence, compared frame-by-frame against the port's ramp — and, if +they differ in shape rather than in extent, a draw capture naming what is +submitted per frame. + ## Still open — these block work 🔴 **This table's rows carry no derivation sha, and that is why they rot.** The standing instruction is to record the HANDOFF commit each row was derived from; every *prose* section added since does, and **every row in this table and the next does not**. On 2026-08-30 an audit found three stale rows here — one of them contradicted by a struck row four lines below it, claiming the boot ends on a plateless title when it had drawn the plate for weeks. The undated rows are exactly the ones that went stale, which is as close to a controlled experiment as this page is going to get. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 0030e527..ea25ca99 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -103,6 +103,16 @@ func _ready() -> void: if args.has(flag) and not _has_display(flag): get_tree().quit(4) return + + # 🔴 BEFORE ANYTHING ELSE, and announced. Godot 4.7.2 binds no joypad button + # to `ui_accept` or `ui_cancel`, so on a real pad Ⓐ and Ⓑ did nothing while + # navigation worked — which reads as a broken controller. `Gamepad` explains + # it. Both lines are printed because "the pad is seen" and "Ⓐ is bound" are + # different facts and the human debugging this needs to separate them. + var _pad_bound := Gamepad.bind_missing() + if _pad_bound != "": + print(_pad_bound) + print(Gamepad.report_devices()) var export_tree := ExportTree.locate() _tree = export_tree if export_tree.root == "": @@ -400,6 +410,8 @@ func _ready() -> void: var _frozen := false +## Holds the left stick's latch state. See `gamepad.gd`. +var _pad := Gamepad.new() var _flow: Variant = null var _menu: MenuFlow = null var _play := false @@ -774,6 +786,13 @@ func _report_unused_mods() -> void: func _unhandled_input(event: InputEvent) -> void: + # The left stick is bound to ui_up/ui_down by Godot's own defaults, and an + # analog axis is not an edge: held at deflection it emits an event per + # jitter, each reporting the action as pressed. `accepts()` latches it to one + # step per deflection. Everything already edge-shaped passes through + # untouched. See `gamepad.gd` -- including what is authored about it. + if not _pad.accepts(event): + return # HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached # at 57 s against a 193 s baseline. if _player != null: diff --git a/port/scripts/gamepad.gd b/port/scripts/gamepad.gd new file mode 100644 index 00000000..b2abbca3 --- /dev/null +++ b/port/scripts/gamepad.gd @@ -0,0 +1,147 @@ +class_name Gamepad +extends RefCounted + +## The physical controller: the two buttons Godot does not bind, and the one +## input that is not an edge. +## +## 🔴 BOTH DEFECTS WERE REPORTED BY A HUMAN PLAYING THE PORT (2026-09-01), and +## neither could have been caught by the `--script` harness, because that harness +## sends `InputEventAction` — which bypasses the input map and is not an analog +## axis. The unattended P5 walk passed on every iteration while Ⓐ did nothing at +## all on a real pad. **A synthetic-input test asserts the code after the input +## map, never the input map itself.** +## +## ## 1. Godot 4.7.2 binds no joypad button to `ui_accept` or `ui_cancel` +## +## Measured on this exact build rather than remembered, because the answer has +## changed between Godot versions and the remembered one was wrong: +## +## ``` +## ui_accept key:Enter, key:Kp Enter, key:Space <- no joypad at all +## ui_cancel key:Escape <- no joypad at all +## ui_up key:Up, JOYBTN:11, JOYAXIS:1- <- d-pad AND left stick +## ui_down key:Down, JOYBTN:12, JOYAXIS:1+ +## ui_left key:Left, JOYBTN:13, JOYAXIS:0- +## ui_right key:Right, JOYBTN:14, JOYAXIS:0+ +## ``` +## +## That asymmetry is the whole bug report: navigation worked on the pad and Ⓐ/Ⓑ +## did nothing, which reads like a broken controller and is a complete input map +## for four actions out of six. +## +## The events are **added to** the built-in actions, never redefined. Declaring +## `ui_accept` in `project.godot` replaces the built-in wholesale, so the +## keyboard bindings would have to be restated there and would silently rot the +## next time Godot changes them. +## +## ## 2. A stick is not a button +## +## `ui_up`/`ui_down` are bound to **axis 1**, so the left stick navigates — which +## is correct, the real game accepts it too. But an axis emits a fresh +## `InputEventJoypadMotion` every time the value *changes*, and a real stick held +## at deflection jitters continuously. Every one of those events reports the +## action as pressed, so a held stick was one cursor step per jitter: the human's +## words were "moves the cursor too fast", and on a five-item menu it crosses +## faster than the eye follows. +## +## So the stick is **latched**: it fires once when it leaves the neutral zone and +## not again until it comes back. That makes it behave exactly like the d-pad, +## which needs no latch because a button already is an edge. +## +## ⚠️ **AUTHORED, NOT MEASURED — and deliberately the conservative half.** +## Whether the real game *repeats* while a direction is held, and how fast, is +## unknown; it is an oracle question. One deflection = one step cannot run away +## and cannot invent a rate. 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. Logged for the Decoder as `pad-repeat`. + +## Godot's action deadzone for the `ui_*` actions is 0.50, so the latch must not +## arm below it — the action itself would not read as pressed and the step would +## be swallowed anyway, leaving the latch armed against a press that never +## happened. +const ENTER := 0.5 + +## Release lower than it arms. Without the gap a stick resting near 0.5 chatters +## across the boundary and re-arms on noise, which is the original bug wearing a +## smaller number. +const RELEASE := 0.4 + +## 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. +const STICK := [JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y] + +var _latched: Dictionary = {} + + +## Add the joypad buttons the built-in map omits. Returns a human-readable line, +## or "" if nothing needed adding — so a future Godot that ships these bindings +## makes this quietly stop reporting rather than double-binding. +static func bind_missing() -> String: + var added := PackedStringArray() + for pair in [["ui_accept", JOY_BUTTON_A, "Ⓐ"], ["ui_cancel", JOY_BUTTON_B, "Ⓑ"]]: + var action: String = pair[0] + var button: int = pair[1] + if not InputMap.has_action(action): + # Not a warning we can act on, but silence here would present as the + # original bug and send the next person back to the controller. + push_warning("gamepad: no such action %s -- pad button unbound" % action) + continue + if _has_button(action, button): + continue + var ev := InputEventJoypadButton.new() + ev.button_index = button + InputMap.action_add_event(action, ev) + added.append("%s -> %s" % [pair[2], action]) + if added.is_empty(): + return "" + return "pad: bound %s (Godot 4.7.2 binds no joypad button to either)" % \ + ", ".join(added) + + +static 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 + + +## True if this event should be acted on. Everything that is already an edge — +## keys, d-pad, mouse — passes straight through; only the analog stick is +## latched, and only on the two axes the navigation actions are bound to. +func accepts(event: InputEvent) -> bool: + if not (event is InputEventJoypadMotion): + return true + var axis: int = event.axis + if not STICK.has(axis): + return true + var value: float = event.axis_value + var direction := 0 + if value >= ENTER: + direction = 1 + elif value <= -ENTER: + direction = -1 + + if direction == 0: + # Neutral enough to re-arm? The gap between RELEASE and ENTER is the + # hysteresis band: inside it the stick is neither a new press nor + # released, so the latch is left exactly as it was. + if absf(value) <= RELEASE: + _latched[axis] = 0 + return false + if int(_latched.get(axis, 0)) == direction: + return false # still held in the same direction: not a new press + _latched[axis] = direction + return true + + +## The pads Godot can see, for the startup line. A run where the human believes +## a controller is connected and Godot disagrees should say so on its own, +## rather than presenting as unresponsive buttons. +static func report_devices() -> String: + var pads := Input.get_connected_joypads() + if pads.is_empty(): + return "pad: none connected -- keyboard only (Enter/Space = Ⓐ, Escape = Ⓑ)" + var names := PackedStringArray() + for j in pads: + names.append("[%d] %s" % [j, Input.get_joy_name(j)]) + return "pad: " + ", ".join(names) diff --git a/port/scripts/gamepad.gd.uid b/port/scripts/gamepad.gd.uid new file mode 100644 index 00000000..f507b940 --- /dev/null +++ b/port/scripts/gamepad.gd.uid @@ -0,0 +1 @@ +uid://b34gxuqrvncbp diff --git a/tools/port/check-all b/tools/port/check-all index 43314c51..a6c9b5ad 100755 --- a/tools/port/check-all +++ b/tools/port/check-all @@ -66,6 +66,15 @@ step transcode-bands must-pass tools/port/verify-transcode-fidelity step bands-harness must-pass tools/port/verify-transcode-fidelity --selftest step capture-controls must-pass tools/port/check-capture-controls step menu-audio must-pass env OUT="$OUT/audio" tools/port/verify-menu-audio +# 🔴 ADDED 2026-09-01 after a human found Ⓐ dead on a real controller while the +# unattended P5 walk passed. `--script` sends `InputEventAction`, which BYPASSES +# the input map, so every check here asserted the code BELOW the map and nothing +# about the map -- which was missing a joypad binding for `ui_accept` and +# `ui_cancel` entirely. The same blind spot hid a second defect: an +# `InputEventAction` is not an analog axis, so nothing could see that a held +# stick fired once per jitter. +step input-map must-pass tools/port/verify-input +step input-control must-pass tools/port/verify-input --control # A stale index is worse than none: it answers "is this already decided?" with a # confident no. That is not hypothetical -- see the entry it was built after. step decisions-index must-pass tools/port/index-decisions --check diff --git a/tools/port/verify-input b/tools/port/verify-input new file mode 100755 index 00000000..afa90e7e --- /dev/null +++ b/tools/port/verify-input @@ -0,0 +1,212 @@ +#!/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 = "") -> 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. + print(" %-44s -- negative; positive control is the stick row (6 -> 1)" + % name) + 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])) + + # 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