Files
Sylpheed/port/scripts/menu_flow.gd
Sylpheed port agent 1defbe0a32 port: P5 -- the menus navigate, and the focus ring is drawn wrong on purpose
P5's gate is "a human clicks through it". The artifact is a scripted walk that
proves the wiring rather than the intent -- up (wraps 01->05), five down, (A)
into EXTRAS, down, (B) back, landing on the main menu with focus RESTORED to
EXTRAS, ten PNGs one per settled step:

  xvfb-run -a godot --path port -- --menu \
    --script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5

--script posts InputEventAction through Input.parse_input_event so the presses
arrive at _unhandled_input exactly as a d-pad's would. Calling MenuFlow directly
would have been shorter and would have proved nothing: the wiring between a
press and the cursor is the part most likely to be broken, and a direct call is
exactly the part that skips it.

Derived vs authored, which P5 is the easiest place to blur:

  * DERIVED -- the ORDER of the items, from each screen file's `buttons`, which
    the exporter already fills from button-role elements sorted by resting Y.
  * AUTHORED -- destinations, initial focus, what (B) does, and left/right being
    a no-op. All measured off the running game (HANDOFF Q4/Q5) or chosen, none
    on the disc, all in authored/flow.json with a why.

Four of five main-menu destinations are `goto: null` with a `blocked` note. That
is a MILESTONE BOUNDARY, not an unknown -- DIFFICULTY, the save list, the lesson
list and OPTIONS were all measured and live in archives this export does not
carry. `blocked` and `none` are kept apart so nobody later "discovers" the gap.

--headless CANNOT DRAW, and the port hung instead of saying so.

Measured, not assumed: under --headless Godot's dummy renderer never emits
RenderingServer.frame_post_draw, so every capture path awaited it forever --
--capture since P1, --film since P3, --shots as of now. With stdout block-
buffered the observable behaviour was SILENCE, FOREVER, which in a loop reads as
a job still working. Isolated by `--quit` (prints, exits 0) vs `--capture` (zero
bytes, killed at 40 s). Now those three flags refuse at STARTUP naming the
xvfb-run line that works, and --script no longer waits for a frame it is not
going to photograph -- so headless walks the menus in 4.5 s as a cheap
regression check needing no X server.

REFUTATION ATTEMPT, against the Decoder's 76653ca point 2 ("the oracle confirms
the game renders the ring's rotation"). Aimed there because PROTOCOL says to aim
at a claim the port is about to build on that rests on an estimator whose own
control the Decoder reported as +/-19.8 deg. IT SURVIVES, more strongly than
claimed.

Both captures draw the SAME sprite (ptbtneff01) 240 px apart, so "is it drawn
rotated" becomes "are these two crops one image at a different angle" -- no crop
offset needed and no reference to our own renderer. 360-bin angular luminance
profile over the annulus, circularly cross-correlated. Two controls first: known
rotations 0/30/90/150/210/270/330 recovered with 0 deg error, and a ring-free
patch of the same capture peaks at 0.369, so the estimator does not manufacture
matches. Then: A vs B 134 deg (corr 0.968), sprite vs A 76 deg, sprite vs B
210 deg -- and 210-76 = 134, which nothing in the method forced.

So 0 deg is NOT A POSE THE GAME SHOWS, and screen_view.gd draws the ring at
0 deg. That is now stated in the code as known-wrong rather than suspected. The
port did NOT start spinning it: the period has two unknowns and both are the
Decoder's -- the second keyframe is untimed, and "groups hold" predicts a stop
at 360 = 0 which contradicts both captures. Two frames of one focused button a
known time apart settle it. Filed in BLOCKED.md and asked over the channel.

BLOCKED.md's staleness check was half a check. It tested whether that page is
stale relative to HANDOFF; it cannot see the other direction, and the other
direction is what happened -- 76653ca lands 27 minutes AFTER HANDOFF was last
written and answers a question HANDOFF still lists as open. Added the missing
half: `git log --oneline 0fd8e69..HEAD -- docs/re/`.

Also recorded, since the two were nearly confused: the ring's annulus centroid
lands within ~0.4 px of its design position under a ZERO crop offset, which
corroborates ORACLE-CAPTURES' "1279x675, top-left aligned" on a feature nobody
chose for the purpose. The earlier "text bands at design y + 23" is an offset
WITHIN the button sprite, not a crop offset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtmUw5N5LJaMW1Njb8Ziey
2026-08-29 11:27:05 +00:00

166 lines
6.4 KiB
GDScript

# Where the buttons go, and what the d-pad does.
#
# EVERYTHING IN HERE IS AUTHORED OR MEASURED -- none of it is on the disc.
# HANDOFF Q6 closed the "what drives the flow" question with a negative: the
# order is code, not data, in all four places it could have been. So this class
# reads `authored/flow.json` and holds no rule of its own.
#
# The split is deliberate and is the derived/authored contract in miniature:
#
# * the ORDER of the items is DERIVED -- each screen file's `buttons`, which
# the exporter fills from the button-role elements sorted by resting Y;
# * WHERE an item goes, WHICH item opens focused, and WHAT (B) does are
# AUTHORED, because they were measured off the running game or chosen.
#
# A rule that lived in GDScript instead would be invisible to the person whose
# job is to notice that we decided it.
class_name MenuFlow
extends RefCounted
## The authored `screens` map: screen name -> destinations, initial focus, (B).
var screens: Dictionary = {}
## The authored `navigation` block: wrap, left/right, input during a transition.
var navigation: Dictionary = {}
## Where we are and how we got here, oldest first. The last entry is current.
## (B) restores the focus recorded on the entry it pops back to -- HANDOFF Q5
## measured that the game does this, so the stack carries a focus, not just a
## name.
var stack: Array[Dictionary] = []
var error: String = ""
## Nothing happened. Returned rather than `null` so a caller reads one shape.
const NONE := {"kind": "none"}
func configure(flow: Variant) -> bool:
if typeof(flow) != TYPE_DICTIONARY:
error = "authored/flow.json did not parse to an object"
return false
if not flow.has("screens") or not flow.has("navigation"):
error = "authored/flow.json has no `screens`/`navigation` block -- this build needs both"
return false
screens = flow["screens"]
navigation = flow["navigation"]
return true
func known(name: String) -> bool:
return screens.has(name) and typeof(screens[name]) == TYPE_DICTIONARY
func current() -> String:
return String(stack[stack.size() - 1]["screen"]) if not stack.is_empty() else ""
func focus() -> String:
return String(stack[stack.size() - 1]["focus"]) if not stack.is_empty() else ""
## The item a screen opens on.
##
## Authored per screen. Where the authored value names a button this screen does
## not have -- a mistyped id, or an export whose buttons moved -- fall back to
## the first button rather than to nothing, and SAY SO: a menu that opens with
## no focus looks like a rendering bug, and this is the one place that mistake
## would hide.
func initial_focus(name: String, buttons: Array) -> String:
if buttons.is_empty():
return ""
var want := String(screens.get(name, {}).get("initial_focus", ""))
if want != "" and buttons.has(want):
return want
if want != "":
push_warning("flow.json opens %s on %s, which is not one of its buttons %s" % [name, want, buttons])
return String(buttons[0])
func enter(name: String, buttons: Array) -> void:
stack.append({"screen": name, "focus": initial_focus(name, buttons)})
## Move the cursor. Returns true when it actually moved, so a caller can fire the
## move cue only on a real move (P6) rather than on every press.
##
## MEASURED, HANDOFF Q5: up/down move one item and WRAP at both ends -- on the
## 5-item main menu and the 3-item EXTRAS both, so it is a menu rule. `wrap` is
## read from `authored/flow.json` rather than written here, because it is a
## measurement and the day it is contradicted the fix is a data edit.
func move(step: int, buttons: Array) -> bool:
if stack.is_empty() or buttons.size() < 2:
return false
var at := buttons.find(focus())
if at < 0:
at = 0
var to := at + step
if bool(navigation.get("wrap", true)):
to = posmod(to, buttons.size())
else:
to = clampi(to, 0, buttons.size() - 1)
if to == at:
return false
stack[stack.size() - 1]["focus"] = String(buttons[to])
return true
## (A). Returns what the authored flow says the focused item opens.
##
## {"kind": "enter", "goto": <screen>, "label": …} -- go there
## {"kind": "blocked", "label": …, "why": …} -- a real destination
## that is not in this
## export
## {"kind": "none"} -- nothing bound
##
## `blocked` is not an error and is not an unknown. Those five destinations were
## measured off the running game; they live in other archives and this milestone
## does not export them. Saying "blocked" rather than "none" keeps the two apart.
func accept(buttons: Array) -> Dictionary:
if stack.is_empty():
return NONE
var screen: Dictionary = screens.get(current(), {})
# A screen with no buttons -- the title -- can still take (A).
if buttons.is_empty():
return _target(screen.get("on_accept", null), "A")
var button: Dictionary = screen.get("buttons", {}).get(focus(), {})
if button.is_empty():
return NONE
var label := String(button.get("label", focus()))
if button.get("goto", null) == null:
return {"kind": "blocked", "label": label, "why": String(button.get("blocked", ""))}
return {"kind": "enter", "goto": String(button["goto"]), "label": label}
## (B), and EXTRAS' own `BACK` item, which is treated as the same thing --
## nothing measured distinguishes them and inventing a difference would be a
## guess with no evidence behind it.
##
## MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you
## came from. So the target comes from the authored flow, but the focus comes
## from the STACK -- and only when the stack agrees about where we are going. A
## run that started straight on a submenu has no history to restore and enters
## the parent at its authored initial focus instead.
func cancel() -> Dictionary:
if stack.is_empty():
return NONE
var target: Variant = screens.get(current(), {}).get("on_cancel", null)
var out := _target(target, "B")
if out["kind"] != "enter":
return out
if stack.size() >= 2 and String(stack[stack.size() - 2]["screen"]) == out["goto"]:
out["restore_focus"] = String(stack[stack.size() - 2]["focus"])
out["pop"] = true
return out
## Pop back to the parent, keeping the focus it was left on.
func pop() -> void:
if stack.size() >= 2:
stack.pop_back()
static func _target(target: Variant, label: String) -> Dictionary:
if typeof(target) != TYPE_DICTIONARY or target.get("goto", null) == null:
return NONE
return {"kind": "enter", "goto": String(target["goto"]), "label": label}