port: P7 -- the new-game intro plays, and the two screens it skips are named out loud

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
This commit is contained in:
Sylpheed port agent
2026-08-29 13:12:41 +00:00
parent 59fba56a0f
commit d45da73bba
5 changed files with 255 additions and 1 deletions

View File

@@ -369,9 +369,27 @@ func _video_finished() -> void:
print(" video ended at %.2f s" % _elapsed)
_player.queue_free()
_player = null
# A movie the MENU started (P7) returns to an authored screen; a movie the
# BOOT started advances the sequence. Two different owners, and conflating
# them walked the boot sequencer off the end of its own array.
if not _video_then.is_empty():
var after := _video_then
_video_then = {}
var goto := String(after.get("goto", ""))
print(" -> %s (authored: %s)" % [goto, String(after.get("kind", "authored"))])
_menu_activate({"kind": "enter", "goto": goto, "label": "after the movie"})
# `_menu_activate` only arms the transition; the screen it is leaving has
# already gone, so arrive immediately rather than fading out a movie.
if _pending != null:
_menu_arrive()
return
_advance()
## Where a menu-started movie goes when it ends. Empty for a boot-started one.
var _video_then: Dictionary = {}
func _unhandled_input(event: InputEvent) -> void:
# HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached
# at 57 s against a 193 s baseline.
@@ -433,6 +451,17 @@ func _menu_activate(action: Dictionary, cue: String = "") -> void:
print(" (%s) -> %s" % [action.get("label", ""), action["goto"]])
_pending = action
view.holding = false
"video":
# P7. Announce the gap before opening it. `skipped` names the
# MEASURED screens this export does not carry, and printing them is
# not a nicety: the port is about to show a sequence the game does
# not have, and the only thing that keeps that honest is saying so.
var skipped: Array = action.get("skipped", [])
if not skipped.is_empty():
print(" (%s) -> the real chain is %s, then the movie. Neither screen is in this export."
% [action.get("label", ""), " -> ".join(PackedStringArray(skipped))])
_video_then = action.get("after", {})
_play_video(String(action["video"]), bool(action.get("skippable", false)))
"blocked":
# A real, measured destination that is not in this export. Say which
# -- silence here would read as a dead button.
@@ -634,8 +663,21 @@ func _press(action: String) -> void:
## reached its own hold. Shooting before that would photograph a fade.
func _script_settled(what: String) -> bool:
var deadline := _elapsed + SCRIPT_STEP_TIMEOUT
var playhead := -1.0
while _pending != null or _player != null or not view.holding \
or view.time_units < view.settle_time():
# A movie is not a screen that failed to settle: `S00A` runs 93.9 s and
# would trip a 20 s timeout every time (P7). But "wait as long as it
# takes" would turn a movie stuck at frame 0 into a job that hangs
# forever, which is the worse failure -- it does not fail, it waits.
#
# So the test is LIVENESS, not duration: while the playhead advances the
# deadline moves with it, and a stalled movie still trips the same 20 s.
if _player != null:
var now := _player.get_stream_position()
if now > playhead:
playhead = now
deadline = _elapsed + SCRIPT_STEP_TIMEOUT
if _elapsed > deadline:
# Stop the run. Carrying on would write a whole filmstrip of the
# screen that got stuck and call it a walk through the menus.

View File

@@ -110,6 +110,10 @@ func move(step: int, buttons: Array) -> bool:
## {"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
@@ -127,6 +131,24 @@ func accept(buttons: Array) -> Dictionary:
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}