S00A has been exported since P4; what P7 needed was something to play it and a
defined place to land. Both are here, and the interesting part is the gap.
The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot ->
~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations that are not
GP_TITLE builds, so no screen file exists to go to. The port jumps from NEW GAME
to the one thing in that chain it has -- and the whole design is about not
letting that read as a sequence:
* MenuFlow.accept returns a new kind, `video`, rather than folding this into
`blocked`, because the caller has to announce the skip and a distinct kind is
what forces it to;
* the runtime prints the skipped screens by name on every run;
* flow.json carries `skipped_chain` as DATA, so what is missing lives beside
the decision instead of inside a GDScript string.
After the movie the port returns to the title. Authored, and it has to be: the
game goes into mission 1 and gameplay is out of scope. The ~4.5 s before the
movie is left EMPTY on purpose -- GP_TITLE does carry a loading screen and 4.5 s
is about the right shape for one, which is exactly why that belongs in BLOCKED.md
and not in flow.json.
`--script`'s 20 s per-step timeout would have killed every movie run at step 1.
Raising the constant would have been wrong the other way: a movie stuck at frame
0 would then hang the job, and a job that waits is worse than one that fails. The
test is now LIVENESS -- while get_stream_position() advances the deadline moves
with it, and a stalled movie still trips the same 20 s.
Found while looking: GP_TITLE's four unnamed builds (entries 0, 1, 12, 15) are
LOADING screens -- every element in all four is pgloading_*, and LOADING is one of
the three names the decoder read out of the title part's state function. NOT
renamed here: which member of each pair is which locale is an inference, and a
name stops being questioned once written. Handed over.
One of them is a second casualty of the rest.t problem, and a worse one:
pgloading_eff00.prm rests OPAQUE BLACK at t=38, so anything drawing that screen
at its declared rest paints a black rectangle over all of it. The title's case
only dimmed a frame.
REFUTATION, attempted and SURVIVED: HANDOFF says "exactly the six screen builds
carry the black .prm quad while the six overlays do not". Counting bundles with a
full-screen black primitive gives 8 and 4 -- build_12/15 carry one too. But
theirs runs black -> held -> clear where the transition quad runs black -> clear
-> black, so read strictly as "the quad whose group is the transition" the claim
holds. Recorded anyway: there are two kinds, and the naive census over-counts.
Gate: NEW GAME -> S00A plays 93.75 s against a declared 93.9 -> title, with
98.453 s recorded off the Master bus. What that does NOT show is that S00A's own
audio is in the mix -- bed and movie were not separated in this run, and the
write-up says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
188 lines
7.5 KiB
GDScript
188 lines
7.5 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": "video", "video": …, "skipped": […], -- the destination is
|
|
## "after": {…}} absent but its chain
|
|
## ends in a movie we
|
|
## DO have (P7)
|
|
## {"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:
|
|
# A destination this export does not carry, but whose CHAIN ends in
|
|
# something it does: `NEW GAME` opens `DIFFICULTY`, then `SELECT DATA`,
|
|
# and only then the new-game movie. The port has the movie and neither
|
|
# screen (P7).
|
|
#
|
|
# This is returned as its own kind rather than folded into `blocked`,
|
|
# because the caller has to announce the skip. A port that quietly
|
|
# jumped from `NEW GAME` to the intro would be showing a sequence the
|
|
# game does not have, and nothing on screen would say so.
|
|
if button.get("then_video", null) != null:
|
|
return {
|
|
"kind": "video",
|
|
"label": label,
|
|
"video": String(button["then_video"]),
|
|
"skipped": button.get("skipped_chain", []),
|
|
"skippable": bool(button.get("skippable", false)),
|
|
"after": button.get("after_video", {}),
|
|
}
|
|
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}
|