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
166 lines
6.4 KiB
GDScript
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}
|