The Decoder measured that after B from the menu the plate is re-drawn (daf8f47).
The port landed on a BARE title: _menu_arrive drops the overlay -- correct, the
plate goes with the screen it was measured on -- but nothing put it back, since
_overlay_spec is cleared the instant the overlay is raised and only the boot
sequence ever set it. Confirmed by running it: ten title elements, no ptbtn00.
_rearm_overlay_for reads the declaration out of authored/flow.json's boot step
for that screen rather than naming press_start, so the plate returns by the same
path and shared clock as on boot, with NO new constant. Controlled both ways:
cancel raises it, entering EXTRAS (no overlay declared) raises nothing.
Script steps now carry an elapsed timestamp, without which the port's own press
latency could not be compared to a measured one. With it: B at 1.01 s, title at
1.37 s = 0.36 s, against their measured <= 0.4 s -- nothing was fitted to that.
The plate's RETURN time does not agree: 4.33 s here against their 7.3 s to pulse,
of which up to 2 s is pulse-detection phase. Not adjusted -- an authored delay in
this same block was already refuted once. Recorded as an ask.
auto_repeat and title/on_cancel upgraded to MEASURED on their run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
1302 lines
58 KiB
GDScript
1302 lines
58 KiB
GDScript
# Entry point.
|
|
#
|
|
# P1 shows one exported screen, statically, so that its pixels can be diffed
|
|
# against `sylpheed-cli screen render` of the same build. The boot sequence
|
|
# proper (splash -> intro -> title -> menu) is P3 and is not here.
|
|
#
|
|
# godot --path port -- --screen=main_menu
|
|
# godot --path port -- --screen=main_menu --capture=/tmp/godot.png
|
|
# godot --path port -- --screen=main_menu --time=0.5 --capture=/tmp/at-half.png
|
|
# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png
|
|
# godot --path port -- --screen=title --overlay=press_start --time=4
|
|
# godot --path port -- --boot # the whole boot sequence
|
|
# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s
|
|
# godot --path port -- --menu # P5: navigate the menus
|
|
# godot --path port -- --menu=extras # ...starting somewhere else
|
|
# godot --path port -- --boot --play # boot, then hand over to P5
|
|
# godot --path port -- --menu --script=down,down,accept,cancel --shots=/tmp/p5
|
|
# godot --path port -- --menu --script=down,accept --audio=/tmp/p6.wav
|
|
#
|
|
# `--menu` is the P5 mode: the d-pad moves the cursor, (A) opens, (B) goes back.
|
|
# `--script` drives the SAME input path with synthetic events -- it does not call
|
|
# the navigation functions directly, because then the artifact would prove
|
|
# nothing about whether a human's press arrives. `--shots` writes one PNG per
|
|
# scripted step, after the screen it produced has settled.
|
|
#
|
|
# `--audio=` records the MASTER BUS to a WAV for the whole run. Neither container
|
|
# has a sound card, so "does it actually play?" cannot be answered by listening --
|
|
# but it can be answered by measurement, and an `AudioEffectRecord` on Master
|
|
# captures the mixed output from inside a headless run with no device at all.
|
|
# `docs/port/AUDIO-VERIFICATION.md` §2. The run PRINTS the audio driver it used,
|
|
# because "recorded under a dummy driver" is a weaker claim than "heard" and the
|
|
# write-up has to be able to say which one it is making.
|
|
#
|
|
# `--time` is in SECONDS and freezes the timeline there; without it the screen
|
|
# animates in real time from t=0. `--pose=rest` draws the export's declared
|
|
# resting pose instead of the timeline -- what the reference renderer draws, so
|
|
# that a renderer-vs-renderer diff compares like with like.
|
|
#
|
|
# The screen is drawn into a SubViewport sized to the export's own `design`
|
|
# rectangle and shown through a container that scales it to the window. That is
|
|
# the same separation the project settings already make -- design space is
|
|
# fixed, the window is not -- and it makes `--capture` exact: the PNG is the
|
|
# design rectangle itself, never the window, so it is directly comparable with
|
|
# `screen render`'s composite with no cropping or rescaling.
|
|
extends Node
|
|
|
|
const DEFAULT_SCREEN := "main_menu"
|
|
|
|
var view: ScreenView = null
|
|
var viewport: SubViewport = null
|
|
var audio: MenuAudio = null
|
|
|
|
## The second build, drawn OVER `view`. The boot title is the only place in this
|
|
## port where two builds are on screen at once (`authored/flow.json`, the boot's
|
|
## `title` step): build 4 presents alone and the `PRESS Ⓐ BUTTON` plate -- build
|
|
## 2 -- arrives later.
|
|
##
|
|
## **They share one clock, started together, and there is no authored delay.**
|
|
## The plate arrives at its own declared `t = 238`; build 4's visible build-in
|
|
## ends at `t = 118`; the 120-unit difference is 2.000 s, against an oracle that
|
|
## measured 2.138 s and 2.132 s at an emulator presenting 28.1 fps rather than
|
|
## 30. A port running at a true 30 Hz wants the declared 120, not the wall clock.
|
|
##
|
|
## A second ScreenView rather than a second screen inside one, because that is
|
|
## what "two builds at once" actually is: each has its own timeline, its own
|
|
## textures and its own hold, and Node2D siblings already draw in tree order.
|
|
## Teaching ScreenView about a subordinate screen would have been the same
|
|
## information expressed less directly, and would have put an `if overlay` in
|
|
## every method that walks elements.
|
|
var overlay: ScreenView = null
|
|
|
|
|
|
func _ready() -> void:
|
|
var args := _args()
|
|
for flag: String in ["capture", "film", "shots"]:
|
|
if args.has(flag) and not _has_display(flag):
|
|
get_tree().quit(4)
|
|
return
|
|
var export_tree := ExportTree.locate()
|
|
if export_tree.root == "":
|
|
push_error(export_tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
|
|
# Say it before anything is drawn. A modded run that looked identical to an
|
|
# unmodded one in the log would leave a modder with exactly one debugging
|
|
# tool -- delete the mod and try again.
|
|
var mods := export_tree.mod_report()
|
|
if mods != "":
|
|
print(mods)
|
|
|
|
_flow = export_tree.authored("flow.json")
|
|
if _flow == null and (args.has("boot") or args.has("menu")):
|
|
push_error(export_tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
if args.has("boot"):
|
|
for step: Dictionary in _flow["boot"]:
|
|
_sequence.append(step)
|
|
# P6. Audio is loaded even for a static `--screen` run: it costs nothing when
|
|
# the export has none, and a mode that silently cannot play sound is a mode
|
|
# that hides the failure this milestone is about.
|
|
audio = MenuAudio.new()
|
|
add_child(audio)
|
|
if not audio.configure(export_tree):
|
|
push_error(audio.error)
|
|
get_tree().quit(2)
|
|
return
|
|
if audio.silent():
|
|
print("this export carries no audio -- run the exporter against a disc for P6")
|
|
_record_to = args.get("audio", "")
|
|
if _record_to != "":
|
|
_start_recording()
|
|
|
|
# In `--boot` the capture is taken at the END, not in `_ready`: the frame
|
|
# worth having is the composited title, and `_ready` runs 150 s before it.
|
|
if args.has("boot"):
|
|
_capture_to = args.get("capture", "")
|
|
_film = args.get("film", "")
|
|
# `--film-interval=` in seconds. Configurable because the fixed 0.25 s could
|
|
# not resolve the boot's own black hold: the transition's pure-black plateau
|
|
# is MEASURED at 0.17-0.23 s (authored/timing.json, HANDOFF Q7), which is
|
|
# shorter than the cadence that was meant to observe it. `verify-dwell` duly
|
|
# reported two screens as one 93 s span and called it a regression, when the
|
|
# black frame had simply fallen between samples.
|
|
_film_interval = maxf(0.01, float(args.get("film-interval", "0.25")))
|
|
_shots = args.get("shots", "")
|
|
if args.has("script"):
|
|
_script = args["script"].split(",", false)
|
|
_skip_at = float(args.get("skip-at", "0"))
|
|
# `--focus=<element id>` draws a button's focus record in a `--screen` run,
|
|
# which otherwise focuses nothing. A diagnostic: the spinning ring is only
|
|
# drawn on a FOCUSED button, so without this the ring can only be observed in
|
|
# a live `--menu` run, where the film cadence jitters by up to a frame and a
|
|
# 3-degree angular wobble swamps the "identical one period apart" check that
|
|
# verified it at P5.
|
|
_force_focus = args.get("focus", "")
|
|
# P5. `--play` boots first and hands over on the title; `--menu` starts on a
|
|
# screen directly, which is what makes an unattended run cheap -- it does not
|
|
# sit through 137 s of intro to press a d-pad.
|
|
_play = args.has("play") or args.has("menu")
|
|
if _play:
|
|
_menu = MenuFlow.new()
|
|
if not _menu.configure(_flow):
|
|
push_error(_menu.error)
|
|
get_tree().quit(2)
|
|
return
|
|
|
|
var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \
|
|
else args.get("menu", args.get("screen", DEFAULT_SCREEN))
|
|
if name == "1":
|
|
name = DEFAULT_SCREEN # bare `--menu`
|
|
if name == "":
|
|
name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport
|
|
var screen: Dictionary = export_tree.screen(name)
|
|
if screen.is_empty():
|
|
push_error(export_tree.error)
|
|
print("screens in this export: ", ", ".join(export_tree.screen_names()))
|
|
get_tree().quit(2)
|
|
return
|
|
var design: Array = screen.get("design", [1280, 720])
|
|
|
|
var container := SubViewportContainer.new()
|
|
container.stretch = true
|
|
container.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
add_child(container)
|
|
|
|
viewport = SubViewport.new()
|
|
viewport.size = Vector2i(int(design[0]), int(design[1]))
|
|
viewport.transparent_bg = false
|
|
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
container.add_child(viewport)
|
|
|
|
view = ScreenView.new()
|
|
# The export is a 1:1 copy of the disc's texels and elements are drawn at up
|
|
# to 500 %. Nearest is also what the reference renderer does
|
|
# (`ui_layout::blit` maps destination to source by integer division), so a
|
|
# filter difference cannot masquerade as a placement difference in the diff.
|
|
view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
|
view.focused_id = args.get("focus", "")
|
|
if args.get("pose", "") == "rest":
|
|
view.pose_mode = ScreenView.Pose.REST
|
|
|
|
# The keyframe unit is MEASURED, not on the disc, so it is authored and read
|
|
# in exactly one place -- here.
|
|
var timing: Variant = export_tree.authored("timing.json")
|
|
if timing == null:
|
|
push_error(export_tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
view.units_per_second = float(timing["keyframe_units_per_second"])
|
|
# The one unknown duration per screen: the ramp into the final untimed
|
|
# keyframe. Authored, because the disc has no time slot there.
|
|
# `exit_ramp_units` is DELETED from authored/timing.json -- under the corrected
|
|
# record layout every pose is timed, so there is no untimed final keyframe to
|
|
# give a synthetic time to. The default below is now unreachable rather than
|
|
# authored, and both of ScreenView's uses are dead branches kept only so an
|
|
# older export still loads.
|
|
view.exit_ramp_units = float(timing.get("exit_ramp_units", 24.0))
|
|
_black_hold = float(timing.get("black_hold_units", 0.0))
|
|
# Which focus records draw unconditionally and loop. Kept out of ScreenView's
|
|
# own logic on purpose -- see `looping_focus` there for the census that says
|
|
# this cannot be a rule.
|
|
_looping = timing.get("looping_focus_records", {})
|
|
# Called, not merely defined. A validator nobody invokes is the same defect
|
|
# it exists to catch.
|
|
_check_authored_invariants(timing)
|
|
# Which decoded rules apply where. See `authored/rendering.json`.
|
|
var rendering: Variant = export_tree.authored("rendering.json")
|
|
_draw_leaf_for = [] if rendering == null else rendering.get("draw_leaf_for", [])
|
|
_loop_leaf_screens = [] if rendering == null else rendering.get("loop_leaf_on_screens", [])
|
|
# `--no-hold` plays a screen's groups PAST their rest instead of clamping each
|
|
# element at its own `rest.t`. A diagnostic, not a mode: `rest.t` is the last
|
|
# HOLD keyframe before the exit, not the settled state, and the only way to
|
|
# ask "is the port's held pose what the idle game shows" is to be able to
|
|
# render the other answer. Added when the title's 1.8 % disagreement with the
|
|
# oracle could not be attributed without it.
|
|
#
|
|
# It goes HERE and not with the other flags: `view` does not exist until this
|
|
# point, and the first version set it thirty lines too early and silently
|
|
# rendered nothing.
|
|
if args.has("no-hold"):
|
|
view.holding = false
|
|
viewport.add_child(view)
|
|
|
|
view.looping_focus = _looping_for(name)
|
|
view.draw_leaf_for = _draw_leaf_for
|
|
view.loop_leaf = _loop_leaf_screens.has(name)
|
|
view.focused_id = _force_focus
|
|
if not view.load_screen(export_tree, name):
|
|
push_error(export_tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
|
|
# Not booting: `--menu` opens straight onto a screen, so the stack starts here.
|
|
if _menu != null and _sequence.is_empty():
|
|
_menu_enter(name, true)
|
|
|
|
view.structural_skips.clear()
|
|
var settle := view.settle_time()
|
|
print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [
|
|
name, view.screen["elements"].size(), view.screen["paint_order"].size(),
|
|
design[0], design[1], settle, settle / view.units_per_second])
|
|
|
|
# `--leaf-time=<seconds>` places the LEAF alone, leaving the screen settled.
|
|
# See `ScreenView.leaf_time_units`.
|
|
if args.has("leaf-time"):
|
|
view.leaf_time_units = float(args["leaf-time"]) * view.units_per_second
|
|
view.queue_redraw()
|
|
|
|
if args.has("time"):
|
|
_frozen = true
|
|
# An explicit instant beats the settle instant -- see `ScreenView.frozen`.
|
|
view.frozen = true
|
|
view.time_units = float(args["time"]) * view.units_per_second
|
|
view.queue_redraw()
|
|
|
|
if _film != "":
|
|
set_process(true)
|
|
_film_capture()
|
|
|
|
# `--overlay=<screen>` composites a second build immediately, without waiting
|
|
# for a boot. It exists because the only other way to see two builds at once
|
|
# is a 156 s `--boot`, of which 137 s is the intro movie -- which under Xvfb's
|
|
# software Theora decode is several minutes to answer "is the plate on top of
|
|
# the title". This raises the same second `ScreenView` by the same code path,
|
|
# so what it photographs is the real composite and not a mock-up. It applies
|
|
# NO delay: the delay is a measurement and lives in `authored/flow.json`,
|
|
# where the boot reads it.
|
|
# A boot whose FIRST step declares an overlay: `_advance` raises it for every
|
|
# later step, and `_ready` is the one with no `_advance` in front of it.
|
|
# Today only the last step has one, so this is a guard rather than a fix --
|
|
# but a silently missing second build is exactly the failure P3 just spent an
|
|
# iteration on.
|
|
if not _sequence.is_empty() and typeof(_sequence[0].get("overlay", null)) == TYPE_DICTIONARY:
|
|
_overlay_spec = _sequence[0]["overlay"]
|
|
_overlay_due = 0.0
|
|
_overlay_process(0.0)
|
|
|
|
if args.has("overlay") and not args.has("boot"):
|
|
_overlay_spec = {"screen": args["overlay"]}
|
|
_overlay_due = 0.0
|
|
_overlay_process(0.0)
|
|
if overlay != null and args.has("time"):
|
|
overlay.frozen = true
|
|
overlay.time_units = float(args["time"]) * overlay.units_per_second
|
|
overlay.queue_redraw()
|
|
|
|
# `--boot --capture=` is deferred to the end of the sequence (`_finish_boot`),
|
|
# and so is `--script --capture=`.
|
|
#
|
|
# 🔴 The second half of that was missing, and the comment here used to assert
|
|
# the opposite -- "in every other mode the frame worth having is this one".
|
|
# With `--script` it is emphatically not: the capture fired **before the
|
|
# first press**, at t=0.133 s, with 10 of 16 elements still transparent, and
|
|
# then quit. Two runs differing by two `down` presses came out BIT-IDENTICAL,
|
|
# because neither had run its script when it was photographed.
|
|
#
|
|
# That is not a harmless default. It is a well-formed answer to a different
|
|
# question, and it produced a confident wrong finding -- "runtime focus never
|
|
# changes" -- that `--shots` immediately contradicted. A flag combination
|
|
# that silently photographs the wrong instant is worse than one that errors.
|
|
if args.has("capture") and _capture_to == "":
|
|
if not _script.is_empty():
|
|
# Defer to the end of the script, through the SAME member the boot
|
|
# path already uses, rather than adding a second mechanism.
|
|
_capture_to = String(args["capture"])
|
|
else:
|
|
await _capture(args["capture"])
|
|
get_tree().quit(0)
|
|
|
|
|
|
var _frozen := false
|
|
var _flow: Variant = null
|
|
var _menu: MenuFlow = null
|
|
var _play := false
|
|
var _pending: Variant = null
|
|
## `authored/timing.json` `looping_focus_records`, keyed `<screen>/<element>`.
|
|
var _looping: Dictionary = {}
|
|
## `authored/rendering.json` `draw_leaf_for`.
|
|
var _draw_leaf_for: Array = []
|
|
## `authored/rendering.json` `loop_leaf_on_screens`.
|
|
var _loop_leaf_screens: Array = []
|
|
var _script: PackedStringArray = PackedStringArray()
|
|
var _shots := ""
|
|
## `--skip-at=SECONDS`: when to send a synthetic (A) during a movie, or 0.
|
|
var _skip_at := 0.0
|
|
## Units of pure black between one screen leaving and the next arriving.
|
|
var _black_hold := 0.0
|
|
## `--focus=<id>`: draw this element's focus record in a `--screen` run.
|
|
var _force_focus := ""
|
|
var _skip_sent := false
|
|
var _script_started := false
|
|
var _sequence: Array[Dictionary] = []
|
|
var _player: VideoStreamPlayer = null
|
|
var _step := 0
|
|
var _film := ""
|
|
var _film_frame := 0
|
|
var _film_next := 0.0
|
|
## Seconds between `--film` frames. See `--film-interval`.
|
|
var _film_interval := 0.25
|
|
var _elapsed := 0.0
|
|
var _boot_done := false
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if _frozen or view == null:
|
|
return
|
|
view.time_units += delta * view.units_per_second
|
|
_elapsed += delta
|
|
view.queue_redraw()
|
|
_overlay_process(delta)
|
|
|
|
if _player != null:
|
|
# `--skip-at=SECONDS` presses (A) at a wall-clock moment DURING a movie,
|
|
# which `--script` structurally cannot do: `_script_settled` waits while
|
|
# `_player != null`, so a scripted walk only ever starts after the movie
|
|
# has ended. That gap is why "does (A) skip the intro" had been read out
|
|
# of the source rather than measured, and a human play-test then found
|
|
# it not working.
|
|
#
|
|
# It goes through `Input.parse_input_event`, like `_press` -- the wiring
|
|
# between a press and `_unhandled_input` is the thing under test, so a
|
|
# direct call to `_video_finished` would prove nothing.
|
|
if _skip_at > 0.0 and _elapsed >= _skip_at and not _skip_sent:
|
|
_skip_sent = true
|
|
print(" --skip-at: pressing (A) at %.2f s" % _elapsed)
|
|
_press("ui_accept")
|
|
return
|
|
|
|
# A menu transition. This is checked BEFORE the boot sequence and outside
|
|
# its emptiness guard: `--menu` has no sequence at all, and an earlier
|
|
# version returned here, so the screen faded out and nothing ever arrived.
|
|
if _pending != null:
|
|
if view.time_units >= view.exit_time():
|
|
_menu_arrive()
|
|
return
|
|
|
|
if _sequence.is_empty():
|
|
return
|
|
|
|
# A screen holds at `rest` until it has arrived, then plays itself out and
|
|
# the next one begins. Nothing waits on a timer the disc does not carry --
|
|
# and for the two splashes that is now MEASURED to be right, not merely
|
|
# cautious. Their dwells are declared: publisher t=0..255, developer
|
|
# t=0..210, corroborated over 3 cold boots to 1.1 % on the developer. The
|
|
# port emits each declared value plus the 9-unit black hold, exactly.
|
|
#
|
|
# ⚠️ The title is the exception and it is why this loop leaves the LAST screen
|
|
# alone: build 4 declares ~120 presented frames and dwells ~1100, because its
|
|
# exit is caused by something outside its timeline. A splash's exit is caused
|
|
# by nothing, so it plays out. Do not generalise either one to the other --
|
|
# a previous revision of this comment did, in both directions.
|
|
#
|
|
# `_advance` is CAUSED by the next screen arriving, never scheduled off a
|
|
# timer, which is what the draw stream says the game does.
|
|
# `authored/flow.json` `dwell` is applied at the EXIT below, not here.
|
|
#
|
|
# 🔴 I wired it here first and it did nothing, silently -- which is the
|
|
# defect it exists to remove, reproduced while removing it. Holding longer
|
|
# after settle changes nothing, because the screen still leaves when
|
|
# `exit_time() + black_hold` arrives and the extra hold is absorbed. A dwell
|
|
# has to delay the DEPARTURE.
|
|
#
|
|
# It was read NOWHERE for eight milestones. The
|
|
# block's own text says "when a capture times the real boot, the extra hold
|
|
# per screen goes here" -- and a number placed there did nothing at all. Two
|
|
# iterations ago I asked the Decoder for measurements destined for that slot;
|
|
# had they arrived, they would have been filed into a value with no reader
|
|
# and the boot would have been unchanged, silently.
|
|
#
|
|
# It stays EMPTY. Nothing is authored into it, because the splash dwells are
|
|
# declared on the disc and measured to agree. This wires the slot so the day
|
|
# a number belongs there it has an effect, which is the opposite of adopting
|
|
# one now.
|
|
if view.holding and view.time_units >= view.settle_time():
|
|
# The LAST screen in the sequence keeps holding. A screen plays itself
|
|
# out because something is taking its place; nothing is taking the
|
|
# title's place here, and a boot that ends by fading to black is a boot
|
|
# that looks like it crashed. P4 puts the intro video in front of the
|
|
# title, and P5 gives the title somewhere to go.
|
|
if _step + 1 < _sequence.size():
|
|
view.holding = false
|
|
elif not _boot_done:
|
|
_boot_done = true
|
|
print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step].get("screen", _sequence[_step])])
|
|
# The plate is timed from HERE -- the moment the screen reaches its
|
|
# own hold -- and not from the frame it first appeared. That is the
|
|
# finding, not a detail: measured from first-draw the two oracle
|
|
# runs disagree by 0.48 s, because the build-in's own duration is
|
|
# the emulator's frame pacing rather than the game's clock.
|
|
if overlay != null:
|
|
# It was raised with the screen, 120 units ago. Nothing to do
|
|
# here any more -- this hook used to start an authored 2.13 s
|
|
# timer, and the timer was the bug.
|
|
pass
|
|
# P5 takes over here: the boot ends on the title and the title has
|
|
# somewhere to go. Without `--play` the run still stops, because a
|
|
# boot that ends by waiting for a key it will never get is worse
|
|
# than one that exits.
|
|
if _play:
|
|
_menu_enter(String(_sequence[_step].get("screen", "")), true)
|
|
elif _film == "" and _overlay_spec.is_empty() and _overlay_quit_at < 0.0:
|
|
get_tree().quit(0)
|
|
elif not view.holding and view.time_units >= view.exit_time() + _black_hold \
|
|
+ _dwell_for(String(_sequence[_step].get("screen", ""))):
|
|
# 🔴 THE BLACK HOLD, which this port had never implemented. A transition
|
|
# is a fade THROUGH black (HANDOFF Q7), and the pure-black plateau
|
|
# between one screen leaving and the next arriving was measured at
|
|
# 0.17-0.23 s. Filmed at 0.05 s the port fell straight from the
|
|
# publisher's fade-out into the developer logos with NO BLACK FRAME.
|
|
#
|
|
# The menus' transition quad declares black for 12 units and 12/60 =
|
|
# 0.200 s sits in the middle of the measured range -- but the boot
|
|
# splashes carry no such quad (`palogo_eff0` is one static keyframe), so
|
|
# on this path it is authored. See `authored/timing.json`.
|
|
_advance()
|
|
|
|
|
|
func _advance() -> void:
|
|
_drop_overlay()
|
|
_step += 1
|
|
var next: Dictionary = _sequence[_step]
|
|
if next.has("video"):
|
|
_play_video(String(next["video"]), bool(next.get("skippable", false)))
|
|
return
|
|
var name := String(next["screen"])
|
|
print(" -> %s at %.2f s" % [name, _elapsed])
|
|
view.holding = true
|
|
view.time_units = 0.0
|
|
view.looping_focus = _looping_for(name)
|
|
view.draw_leaf_for = _draw_leaf_for
|
|
view.loop_leaf = _loop_leaf_screens.has(name)
|
|
if not view.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
# The second build starts WITH the first, not after it. Raised here rather
|
|
# than at the screen's settle, which is what the authored-delay version did.
|
|
var spec: Variant = next.get("overlay", null)
|
|
if typeof(spec) == TYPE_DICTIONARY:
|
|
_overlay_spec = spec
|
|
_overlay_due = _elapsed
|
|
_overlay_process(0.0)
|
|
|
|
|
|
## Play one transcoded movie, full-bleed over the screen.
|
|
##
|
|
## The port never reads WMV: the exporter transcoded this to Ogg Theora and
|
|
## recorded the exact ffmpeg command in the manifest (MISSION §6), so a modder
|
|
## who dislikes the quality re-runs one line.
|
|
func _play_video(name: String, skippable: bool) -> void:
|
|
var v := view.tree.video(name)
|
|
if v.is_empty():
|
|
push_error(view.tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
print(" -> video %s at %.2f s (%s)" % [name, _elapsed, v["path"]])
|
|
# 🔴 THE MENU BED KEEPS PLAYING UNDER THE MOVIE, AND NOBODY DECIDED THAT.
|
|
#
|
|
# `MenuAudio.stop_bed()` exists and is called from nowhere, so the music
|
|
# started on the main menu runs through the cutscene and on past it. That is
|
|
# an UNMADE DECISION, not a choice: the movie carries its own music and
|
|
# effects, so the port emits two unrelated music tracks at once, measured at
|
|
# r=0.42 for the bed inside the movie's own window (docs/port/DECISIONS.md).
|
|
#
|
|
# It is NOT silenced here, deliberately. PORT-MISSION's rule is to leave an
|
|
# unmeasured detail PLAINLY WRONG rather than plausibly invented, and this is
|
|
# the textbook case: music over a cutscene is wrong in a way any listener
|
|
# catches in one second, whereas stopping it would sound perfectly right and
|
|
# be a guess about the game nobody has watched. The audible version gets
|
|
# fixed; the plausible version ships forever.
|
|
#
|
|
# So it says so instead. Announcing the gap before opening it is what
|
|
# `skipped_chain` already does for NEW GAME.
|
|
if audio.bed_playing():
|
|
print(" 🔴 the menu bed is STILL PLAYING under this movie -- unmeasured,")
|
|
print(" left audible on purpose (BLOCKED.md: does menu music duck?)")
|
|
|
|
var stream := VideoStreamTheora.new()
|
|
stream.file = v["path"]
|
|
_player = VideoStreamPlayer.new()
|
|
_player.stream = stream
|
|
_player.expand = true
|
|
_player.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
# Into the SubViewport, not beside it. Everything this port draws composes in
|
|
# the export's own 1280x720 design space; a player parented to the Boot node
|
|
# renders to the window instead and is invisible to `--capture`, which reads
|
|
# the SubViewport. That is not only a capture artefact -- it would also put
|
|
# the movie outside the space every screen coordinate is expressed in.
|
|
viewport.add_child(_player)
|
|
_skippable = skippable
|
|
# `play()` needs the node in the tree; calling it before that is an error
|
|
# the engine reports and then ignores, which looks like a video that simply
|
|
# never starts.
|
|
await get_tree().process_frame
|
|
_player.finished.connect(_video_finished)
|
|
_player.play()
|
|
# The dialogue is a SECOND stream, started with the picture. `ADV.wmv` and
|
|
# `S00A.wmv` carry music and effects only; the voice is a separate asset the
|
|
# exporter resolves off the movie manifest. Started after `play()` and in the
|
|
# same frame, because the offset between them is zero and adding a wait here
|
|
# would be authoring a sync constant nobody measured.
|
|
if audio.play_voice(name):
|
|
print(" + voice %s" % name)
|
|
# 🔴 SAY WHAT IS MISSING, at the moment it is played.
|
|
#
|
|
# The manifest has known the voice export is incomplete for weeks and the
|
|
# runtime did not repeat it. That asymmetry is the dangerous one for
|
|
# audio: a reader of `manifest.json` gets a paragraph, and a person
|
|
# LISTENING gets clean dialogue with no way to learn a stream is absent.
|
|
# The same principle already governs NEW GAME, which announces the two
|
|
# measured screens it jumps over rather than skipping them silently.
|
|
var gap := audio.incomplete_for(name)
|
|
if gap != "":
|
|
print(" 🔴 KNOWN INCOMPLETE: %s" % gap)
|
|
else:
|
|
# Said out loud: silence is the audio failure that looks like success,
|
|
# and "this cutscene is unvoiced" is a real answer for most of the disc.
|
|
print(" no voice track for %s in this export" % name)
|
|
|
|
|
|
var _skippable := false
|
|
|
|
|
|
func _video_finished() -> void:
|
|
print(" video ended at %.2f s" % _elapsed)
|
|
# Before anything else: a voice that outlived a skipped intro would play on
|
|
# over the title screen, which is the sort of bug that sounds like a feature.
|
|
audio.stop_voice()
|
|
_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.
|
|
if _player != null:
|
|
if _skippable and (event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel")):
|
|
print(" video skipped at %.2f s" % _elapsed)
|
|
_player.stop()
|
|
_video_finished()
|
|
return
|
|
if _menu == null or _menu.stack.is_empty():
|
|
return
|
|
# AUTHORED, not measured: a press during a screen's fade-out is dropped.
|
|
# `authored/flow.json` says why -- nobody has watched what the game does
|
|
# here, and dropping invents less than queueing.
|
|
if _pending != null:
|
|
return
|
|
var buttons: Array = view.screen.get("buttons", [])
|
|
if event.is_action_pressed("ui_up"):
|
|
_menu_move(-1, buttons)
|
|
elif event.is_action_pressed("ui_down"):
|
|
_menu_move(1, buttons)
|
|
elif event.is_action_pressed("ui_left") or event.is_action_pressed("ui_right"):
|
|
# MEASURED, HANDOFF Q5: left/right do nothing. Written out rather than
|
|
# left unhandled so that "the game ignores it" and "we never wired it"
|
|
# are different lines of code.
|
|
pass
|
|
elif event.is_action_pressed("ui_accept"):
|
|
_menu_activate(_menu.accept(buttons), "confirm")
|
|
elif event.is_action_pressed("ui_cancel"):
|
|
_menu_activate(_menu.cancel(), "back")
|
|
|
|
|
|
func _menu_move(step: int, buttons: Array) -> void:
|
|
# MEASURED, HANDOFF Q8 + Q5: the cue fires on a press that MOVES the cursor.
|
|
# `move()` returns whether it did, so a press that changes nothing cannot
|
|
# click -- which also means left/right stay silent by construction rather
|
|
# than by a rule written twice.
|
|
if _menu.move(step, buttons):
|
|
view.focused_id = _menu.focus()
|
|
view.queue_redraw()
|
|
audio.play("move")
|
|
print(" focus -> %s" % view.focused_id)
|
|
|
|
|
|
## Act on what the flow returned. A destination starts the screen playing itself
|
|
## out; the arrival happens in `_process` when the exit ramp is done, so the
|
|
## fade is the transition HANDOFF Q7 measured and not a cut.
|
|
func _menu_activate(action: Dictionary, cue: String = "") -> void:
|
|
# AUTHORED, NOT MEASURED: the cue fires when the press does something, and
|
|
# not when nothing is bound to it. Nobody has watched the game take a dead
|
|
# press. Silence invents the less of the two -- a sound the game does not
|
|
# make is a wrong fact you can hear. `blocked` counts as doing something:
|
|
# that destination WAS measured off the running game and is missing from
|
|
# this export, not from the game. See port/scripts/menu_audio.gd.
|
|
if cue != "" and String(action.get("kind", "none")) != "none":
|
|
audio.play(cue)
|
|
match String(action.get("kind", "none")):
|
|
"enter":
|
|
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.
|
|
print(" (%s) opens a screen this export does not carry: %s"
|
|
% [action.get("label", ""), action.get("why", "")])
|
|
_:
|
|
pass
|
|
|
|
|
|
## Enter a screen with the menu live. `fresh` seeds the stack rather than
|
|
## replacing the top, which is what a boot handover and `--menu` both want.
|
|
func _menu_enter(name: String, fresh: bool) -> void:
|
|
if name == "" or not _menu.known(name):
|
|
push_warning("flow.json describes no screen named %s -- navigation stops here" % name)
|
|
return
|
|
if fresh:
|
|
_menu.enter(name, view.screen.get("buttons", []))
|
|
# `--focus=` wins over the authored initial focus, and it did NOT before.
|
|
#
|
|
# 🔴 The flag parsed, was stored, and was applied to `view.focused_id` at
|
|
# startup -- and then this line overwrote it on every `_menu_enter`. So on
|
|
# the `--menu` path `--focus=` did nothing at all, silently: the run logged
|
|
# `focus ptbtn01` whatever was asked for.
|
|
#
|
|
# That mattered because it made an oracle capture untestable.
|
|
# `live-main-menu-options-focused.png` is the menu with OPTIONS focused --
|
|
# the only capture in the corpus of a MEASURED focus state, where the
|
|
# harness's own `main_menu` row uses an AUTHORED initial focus standing in
|
|
# for a measurement that says initial focus is unstable (HANDOFF Q5). There
|
|
# was no way to ask the port for the state the capture shows.
|
|
#
|
|
# It is pushed into the MENU MODEL, not just the view, so that navigation
|
|
# continues from where it was forced rather than jumping back on the first
|
|
# press.
|
|
if _force_focus != "" and not _menu.stack.is_empty():
|
|
var buttons: Array = view.screen.get("buttons", [])
|
|
if buttons.has(_force_focus):
|
|
_menu.stack[_menu.stack.size() - 1]["focus"] = _force_focus
|
|
view.focused_id = _menu.focus()
|
|
view.queue_redraw()
|
|
# AUTHORED, and the weakest thing in P6: HANDOFF Q10 says nothing on the disc
|
|
# names which track a menu plays, so `authored/audio.json` picks one. It
|
|
# starts when the menu becomes live and CARRIES ACROSS submenus -- `play_bed`
|
|
# is idempotent, because music that restarts every time you press (B) is the
|
|
# kind of wrong that reads as "the audio works".
|
|
audio.play_bed("main_menu")
|
|
print(" menu on %s, focus %s" % [name, _focus_label(view.focused_id)])
|
|
if not _script.is_empty() and not _script_started:
|
|
_script_started = true
|
|
_run_script()
|
|
|
|
|
|
## The moment a screen has finished fading out and the next one takes over.
|
|
func _menu_arrive() -> void:
|
|
# The plate goes with the screen it was measured on. See `_drop_overlay`.
|
|
_drop_overlay()
|
|
var action: Dictionary = _pending
|
|
_pending = null
|
|
var name := String(action["goto"])
|
|
view.holding = true
|
|
view.time_units = 0.0
|
|
view.looping_focus = _looping_for(name)
|
|
view.draw_leaf_for = _draw_leaf_for
|
|
view.loop_leaf = _loop_leaf_screens.has(name)
|
|
if not view.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
# 🔴 THE PLATE COMES BACK IN THE GAME, AND IT DID NOT HERE.
|
|
#
|
|
# `_drop_overlay()` at the top of this function is right -- the plate goes
|
|
# with the screen it was measured on -- but nothing ever put it back, so Ⓑ
|
|
# from the main menu landed on a BARE title. `_overlay_spec` is cleared the
|
|
# moment the overlay is raised, and only the boot sequence ever sets it.
|
|
#
|
|
# MEASURED by the Decoder 2026-08-30 (branch `auto/no-disc-and-menu-captures`,
|
|
# `docs/re/data/nav-autorepeat-and-settled-b.txt`): after Ⓑ from the menu the
|
|
# plate IS re-drawn -- pressed at 351.2 s, its pulse back at 358.5 s.
|
|
#
|
|
# ⚠️ No new constant. The delay is not authored here and must not be: the
|
|
# overlay declaration is read back out of `authored/flow.json`'s boot step
|
|
# for this screen, so the plate re-appears by the SAME path, with the same
|
|
# shared clock, as it does on boot. Whatever the boot does, the return does.
|
|
_rearm_overlay_for(name)
|
|
var buttons: Array = view.screen.get("buttons", [])
|
|
if action.get("pop", false):
|
|
# MEASURED, HANDOFF Q5: (B) restores the focus you came from.
|
|
_menu.pop()
|
|
_menu.stack[_menu.stack.size() - 1]["focus"] = String(action["restore_focus"])
|
|
view.focused_id = _menu.focus()
|
|
view.queue_redraw()
|
|
print(" menu on %s, focus restored to %s" % [name, _focus_label(view.focused_id)])
|
|
else:
|
|
_menu_enter(name, true)
|
|
|
|
|
|
## Whether this process can produce a picture at all.
|
|
##
|
|
## MEASURED here, not assumed: under `--headless` Godot's dummy renderer never
|
|
## emits `RenderingServer.frame_post_draw`, so every `await` on it blocks
|
|
## forever. `godot-headless --path port -- --screen=main_menu --capture=…`
|
|
## therefore hung with NO OUTPUT until it was killed -- the same run with
|
|
## `--quit` prints and exits, which is how the difference was isolated.
|
|
##
|
|
## That is the worst shape a failure can take in an unattended loop: it does not
|
|
## fail, it waits, and a job that waits forever reads as a job still working.
|
|
## So the flags that need a frame refuse at STARTUP and say what to run instead,
|
|
## rather than dying somewhere in the middle of a filmstrip.
|
|
func _has_display(flag: String) -> bool:
|
|
if DisplayServer.get_name() != "headless":
|
|
return true
|
|
push_error(("--%s needs a drawn frame, and --headless never draws one: " +
|
|
"Godot's dummy renderer does not emit frame_post_draw, so this would " +
|
|
"hang rather than fail. Run it under Xvfb instead:\n" +
|
|
" xvfb-run -a godot --path port -- …--%s=…") % [flag, flag])
|
|
return false
|
|
|
|
|
|
## How a focus reads in the log. The title has no focusable item at all -- it is
|
|
## a screen with no `buttons` that still takes (A) -- and an empty string there
|
|
## printed as a line that trailed off, which reads like the value went missing
|
|
## rather than like there is none.
|
|
static func _focus_label(id: String) -> String:
|
|
return id if id != "" else "(none -- this screen has no focusable item)"
|
|
|
|
|
|
func _capture(path: String) -> void:
|
|
# Two frames: the first is the one this callback is still inside of.
|
|
await RenderingServer.frame_post_draw
|
|
await RenderingServer.frame_post_draw
|
|
var img := viewport.get_texture().get_image()
|
|
print("t = %.2f units (%.3f s), pose = %s" % [
|
|
view.time_units, view.time_units / view.units_per_second,
|
|
"rest" if view.pose_mode == ScreenView.Pose.REST else "timeline"])
|
|
print("drew %d: %s" % [view.drawn.size(), ", ".join(view.drawn)])
|
|
if not view.skipped.is_empty():
|
|
print("not drawn %d: %s" % [view.skipped.size(), ", ".join(view.skipped)])
|
|
# The overlay is a second build in the same frame, so it needs its own line.
|
|
# Folding its elements into the list above would make the capture report a
|
|
# screen that does not exist; leaving it out entirely made the first
|
|
# composited capture read as though the plate had not been drawn at all.
|
|
if overlay != null:
|
|
print("overlay %s at t = %.2f units (%.3f s), drew %d: %s" % [
|
|
overlay.screen.get("name", "?"), overlay.time_units,
|
|
overlay.time_units / overlay.units_per_second,
|
|
overlay.drawn.size(), ", ".join(overlay.drawn)])
|
|
var err := img.save_png(path)
|
|
if err != OK:
|
|
push_error("cannot write %s (%d)" % [path, err])
|
|
return
|
|
print("captured %dx%d -> %s" % [img.get_width(), img.get_height(), path])
|
|
|
|
|
|
## A frame every 0.25 s for the whole run, so an unattended boot leaves a
|
|
## filmstrip behind rather than requiring someone to be watching it.
|
|
func _film_capture() -> void:
|
|
while true:
|
|
await RenderingServer.frame_post_draw
|
|
if _elapsed >= _film_next:
|
|
var img := viewport.get_texture().get_image()
|
|
img.save_png("%s_%03d.png" % [_film, _film_frame])
|
|
_film_frame += 1
|
|
_film_next += _film_interval
|
|
|
|
|
|
# Godot passes everything after `--` through untouched; take `--key=value`.
|
|
static func _args() -> Dictionary:
|
|
var out := {}
|
|
for arg in OS.get_cmdline_user_args():
|
|
if arg.begins_with("--") and arg.contains("="):
|
|
var pair := arg.substr(2).split("=", true, 1)
|
|
out[pair[0]] = pair[1]
|
|
elif arg.begins_with("--"):
|
|
out[arg.substr(2)] = "1"
|
|
return out
|
|
|
|
|
|
# ── The scripted walk ───────────────────────────────────────────────────────
|
|
#
|
|
# `--script=down,down,accept,cancel` presses those buttons in order and, with
|
|
# `--shots=`, leaves one PNG per step behind. This is the P5 artifact for an
|
|
# unattended run.
|
|
#
|
|
# It sends synthetic events through `Input.parse_input_event`, so they arrive at
|
|
# `_unhandled_input` exactly as a d-pad's would. Calling the navigation
|
|
# functions directly would have been three lines shorter and would have proved
|
|
# nothing: the thing most likely to be broken is the wiring between a press and
|
|
# the cursor, and that is the part a direct call skips.
|
|
|
|
const SCRIPT_ACTIONS := {
|
|
"up": "ui_up", "down": "ui_down", "left": "ui_left", "right": "ui_right",
|
|
"accept": "ui_accept", "a": "ui_accept", "cancel": "ui_cancel", "b": "ui_cancel",
|
|
}
|
|
|
|
## How long a single step may take before the run is called stuck, in seconds.
|
|
## A screen that never settles would otherwise hang an unattended job forever;
|
|
## the title's own timeline is 4.5 s, so this is generous rather than tuned.
|
|
const SCRIPT_STEP_TIMEOUT := 20.0
|
|
|
|
|
|
func _run_script() -> void:
|
|
if not await _script_settled("start"):
|
|
return
|
|
await _shoot("00_start")
|
|
for i in range(_script.size()):
|
|
var token := _script[i].strip_edges().to_lower()
|
|
if token.begins_with("wait:"):
|
|
# `wait:30` holds for thirty SECONDS of wall clock.
|
|
#
|
|
# Added because the port could not be asked to run for a stated
|
|
# duration at all: a bare `wait` is a no-op that returns as soon as
|
|
# the screen settles, so NOTHING that happens after the settle point
|
|
# was observable from a script. The music bed's loop is the case that
|
|
# exposed it -- an 87.7 s track whose restart nobody has ever
|
|
# watched, on a harness whose longest menu run was under seven
|
|
# seconds.
|
|
#
|
|
# It is wall clock rather than keyframe units on purpose: what it
|
|
# exists to observe are things on the AUDIO clock and the engine's,
|
|
# which are not the disc's and do not scale with `units_per_second`.
|
|
var secs := float(token.substr(5))
|
|
if secs <= 0.0:
|
|
push_error("--script: wait: needs a positive number of seconds, got %s" % token)
|
|
get_tree().quit(2)
|
|
return
|
|
print("script[%d] wait %.1f s" % [i + 1, secs])
|
|
# 🔴 WALL CLOCK, POLLED -- **not** `create_timer`, which was the
|
|
# first implementation and was wrong by 39 %.
|
|
#
|
|
# `create_timer` counts down on the frame delta. In an IDLE scene
|
|
# this container throttles hard and the delta it reports is not the
|
|
# time that passed, so a requested 30 s took **41.7 s** of real
|
|
# time while the port cheerfully reported 30. Measured against
|
|
# `date` either side of the process, with a no-wait control to
|
|
# subtract the 1.21 s of startup.
|
|
#
|
|
# ⚠️ That is idle-specific and NOT a general clock problem: over a
|
|
# whole boot, where things are animating, the port's own clock
|
|
# tracks wall clock to within 4 % (10.43 s wall against 10.82 s
|
|
# reported). The port's ANIMATION timing is fine. It is the waiting
|
|
# that was not.
|
|
#
|
|
# This matters because the only reason to hold a screen is to
|
|
# observe something on a REAL clock -- an audio loop, a timeout --
|
|
# and a timer that silently runs 39 % long would put every such
|
|
# observation at the wrong instant.
|
|
var until := Time.get_ticks_msec() + int(secs * 1000.0)
|
|
while Time.get_ticks_msec() < until:
|
|
await get_tree().process_frame
|
|
elif token == "wait":
|
|
pass
|
|
elif SCRIPT_ACTIONS.has(token):
|
|
# The elapsed clock goes in the line because a press with no timestamp
|
|
# cannot be compared against a MEASURED latency. The Decoder's Ⓑ
|
|
# figures are press-to-effect times; without this the port's own
|
|
# press time had to be guessed from the surrounding lines.
|
|
print("script[%d] %s at %.2f s" % [i + 1, token, _elapsed])
|
|
_press(String(SCRIPT_ACTIONS[token]))
|
|
else:
|
|
push_error("--script: no such step %s (have %s, wait, wait:<seconds>)"
|
|
% [token, ", ".join(SCRIPT_ACTIONS.keys())])
|
|
get_tree().quit(2)
|
|
return
|
|
# `Input.parse_input_event` is flushed with the frame, not on the call.
|
|
# Without these two frames the settle check runs while the press has not
|
|
# been delivered yet, decides nothing is moving, and photographs the
|
|
# screen the press was about to leave.
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
if not await _script_settled(token):
|
|
return
|
|
await _shoot("%02d_%s" % [i + 1, token])
|
|
print("script complete after %.2f s on %s, focus %s"
|
|
% [_elapsed, _menu.current(), _focus_label(view.focused_id)])
|
|
# The frame worth having from a scripted run is the one the script ARRIVED
|
|
# at, not the one it started from. See the note beside the early capture.
|
|
if _capture_to != "":
|
|
await _capture(_capture_to)
|
|
get_tree().quit(0)
|
|
|
|
|
|
func _press(action: String) -> void:
|
|
for down in [true, false]:
|
|
var e := InputEventAction.new()
|
|
e.action = action
|
|
e.pressed = down
|
|
Input.parse_input_event(e)
|
|
|
|
|
|
## Wait until nothing is moving: no transition pending, and the screen has
|
|
## 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.
|
|
push_error("--script: %s never settled within %.0f s -- stopping"
|
|
% [what, SCRIPT_STEP_TIMEOUT])
|
|
get_tree().quit(3)
|
|
return false
|
|
await get_tree().process_frame
|
|
# Only a run that is about to photograph the frame needs to wait for one to
|
|
# be drawn. `--script` on its own is a navigation check and must still work
|
|
# where nothing draws -- see `_has_display`.
|
|
if _shots != "":
|
|
await RenderingServer.frame_post_draw
|
|
await RenderingServer.frame_post_draw
|
|
return true
|
|
|
|
|
|
func _shoot(label: String) -> void:
|
|
if _shots == "":
|
|
return
|
|
var path := "%s_%s.png" % [_shots, label]
|
|
var img := viewport.get_texture().get_image()
|
|
# Write to a temp name and rename on completion: another agent probing a
|
|
# file this is still writing gets a confident wrong number.
|
|
var tmp := path + ".part"
|
|
if img.save_png(tmp) != OK:
|
|
push_error("cannot write %s" % tmp)
|
|
return
|
|
DirAccess.rename_absolute(tmp, path)
|
|
print(" shot %s (%s, focus %s)" % [path, _menu.current(), _focus_label(view.focused_id)])
|
|
|
|
|
|
# ── Recording the master bus ──────────────────────────────────────────────────
|
|
#
|
|
# `docs/port/AUDIO-VERIFICATION.md` §2. This is what closes the loop that file
|
|
# opens: comparing an exported Ogg against the disc proves the ASSET is right and
|
|
# says nothing about whether the engine ever reached it. A WAV captured off the
|
|
# Master bus proves both, and needs no sound card to do it.
|
|
#
|
|
# It is saved in `_exit_tree` rather than beside each `quit()` because there are
|
|
# eight of those and the one that would get missed is an error path -- exactly
|
|
# the run whose audio somebody wants to look at.
|
|
|
|
var _record_to := ""
|
|
var _record: AudioEffectRecord = null
|
|
|
|
|
|
func _start_recording() -> void:
|
|
var bus := AudioServer.get_bus_index("Master")
|
|
_record = AudioEffectRecord.new()
|
|
AudioServer.add_bus_effect(bus, _record)
|
|
_record.set_recording_active(true)
|
|
print("recording the Master bus to %s (audio driver: %s)" % [_record_to, MenuAudio.driver()])
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
if _record == null:
|
|
return
|
|
_record.set_recording_active(false)
|
|
var wav := _record.get_recording()
|
|
_record = null
|
|
if wav == null:
|
|
push_error("--audio: the Master bus recorded nothing at all")
|
|
return
|
|
# Write to a temp name and rename on completion, as everything else in this
|
|
# project does: another agent probing a file still being written gets a
|
|
# confident wrong duration rather than an error.
|
|
#
|
|
# ⚠️ The temp name ends in `.wav`, and that is not cosmetic. `save_to_wav`
|
|
# APPENDS `.wav` when the path does not already end in it, so `p6.wav.part`
|
|
# silently became `p6.wav.part.wav` -- and the rename below then failed to
|
|
# find its source and returned an error nobody read, leaving a run that
|
|
# printed success beside a file that was not there. This is the same bug the
|
|
# exporter's `run_ffmpeg` had in a different dialect: a temp-name convention
|
|
# must preserve the extension, because tools dispatch on it.
|
|
var tmp := _record_to + ".part.wav"
|
|
if wav.save_to_wav(tmp) != OK:
|
|
push_error("--audio: cannot write %s" % tmp)
|
|
return
|
|
var moved := DirAccess.rename_absolute(tmp, _record_to)
|
|
if moved != OK:
|
|
# Say so rather than print the success line below. A rename that fails
|
|
# quietly is worse than one that fails loudly: the caller measures a
|
|
# path that does not exist and reads "no such file" as "no audio".
|
|
push_error("--audio: wrote %s but could not rename it to %s (%d)"
|
|
% [tmp, _record_to, moved])
|
|
return
|
|
print("recorded %.3f s of Master bus -> %s (driver %s)"
|
|
% [float(wav.data.size()) / float(wav.mix_rate * 2 * (2 if wav.stereo else 1)),
|
|
_record_to, MenuAudio.driver()])
|
|
|
|
|
|
# ── The second build ─────────────────────────────────────────────────────────
|
|
|
|
## The overlay the current boot step owes, if it has not been raised yet.
|
|
var _overlay_spec: Dictionary = {}
|
|
## Where a STATIC overlay's own clock starts, and the main view's clock then.
|
|
var _overlay_t0 := 0.0
|
|
var _overlay_view_t0 := 0.0
|
|
## Wall-clock second at which it is raised, measured from the screen's settle.
|
|
var _overlay_due: float = 0.0
|
|
|
|
|
|
func _overlay_process(delta: float) -> void:
|
|
if overlay != null:
|
|
# ONE CLOCK. Not `+= delta * ups` on each independently: they would drift
|
|
# apart by a frame here and there, and the whole content of the finding
|
|
# is that the 120 units between build 4's last ramp and the plate's
|
|
# `a=255` is a fixed interval on a shared timeline.
|
|
# 🔴 A STATIC overlay poses at ITS OWN ARRIVAL, not at the shared clock.
|
|
#
|
|
# `--screen=X --overlay=Y` has no sequence driving it, so `view` sits at
|
|
# its settle instant while this line pushed the *raw elapsed* clock into
|
|
# the overlay -- 9 units at the moment `--capture` fires. For
|
|
# `press_start` that is alpha 0 (transparent until t=214, opaque only at
|
|
# t=236-238), so the one flag whose purpose is "put the plate on the
|
|
# title" drew NOTHING and reported `drew 0`. It read as a title with no
|
|
# plate, which is what anyone would conclude.
|
|
#
|
|
# It cost a measurement: against `live-title-press-a.png` every sweep
|
|
# phase gave a flat ~1.0 % floor, and the residual was a row of
|
|
# glyph-sized blobs on the plate's own position. Posed properly the same
|
|
# comparison is 0.00093 %.
|
|
#
|
|
# In a `--boot` sequence the shared clock is the whole point -- the 120
|
|
# units between build 4's last ramp and the plate's a=255 is a fixed
|
|
# interval on ONE timeline -- so that path is untouched.
|
|
if _sequence.is_empty():
|
|
# 🔴 A static overlay STARTS at its arrival and then ADVANCES. It used
|
|
# to be pinned there on every frame, which was this fix overshooting.
|
|
#
|
|
# Pinning fixed the original defect -- `--screen=X --overlay=Y` pushed
|
|
# the raw elapsed clock in, 9 units at capture, and `press_start` drew
|
|
# nothing -- but it replaced a frozen-too-early overlay with a
|
|
# frozen-at-arrival one. `--screen=X` animates X; freezing Y while
|
|
# animating X is an inconsistency in one command, and the plate pulse
|
|
# is what made it visible: the plate oscillates on the boot path and
|
|
# sat flat here, which reads as a regression and is not one.
|
|
#
|
|
# Offset, not pinned: the overlay begins at its own settle and takes
|
|
# the same delta the main view takes.
|
|
overlay.time_units = _overlay_t0 + (view.time_units - _overlay_view_t0)
|
|
else:
|
|
overlay.time_units = view.time_units
|
|
overlay.queue_redraw()
|
|
if _overlay_quit_at >= 0.0 and _elapsed >= _overlay_quit_at:
|
|
print("boot ends on %s + %s at %.2f s"
|
|
% [view.screen.get("name", "?"), overlay.screen.get("name", "?"), _elapsed])
|
|
_overlay_quit_at = -1.0
|
|
_finish_boot()
|
|
return
|
|
if _overlay_spec.is_empty() or _elapsed < _overlay_due:
|
|
return
|
|
_raise_overlay(String(_overlay_spec.get("screen", "")))
|
|
|
|
|
|
## Re-arm the overlay a screen declares in the authored boot sequence.
|
|
##
|
|
## Used when the MENU arrives at a screen, not just when the boot walks onto it.
|
|
## It looks the declaration up rather than naming `press_start`, so a screen that
|
|
## gains an overlay in `authored/flow.json` gets it on both paths at once and
|
|
## this function needs no edit.
|
|
func _rearm_overlay_for(name: String) -> void:
|
|
if _flow == null or not (_flow as Dictionary).has("boot"):
|
|
return
|
|
for step: Dictionary in _flow["boot"]:
|
|
if String(step.get("screen", "")) != name:
|
|
continue
|
|
var spec: Variant = step.get("overlay", null)
|
|
if typeof(spec) == TYPE_DICTIONARY:
|
|
_overlay_spec = spec
|
|
_overlay_due = _elapsed
|
|
_overlay_process(0.0)
|
|
return
|
|
|
|
|
|
## Composite a second build over the first.
|
|
##
|
|
## It starts at `time_units = 0` and plays its OWN group, so the plate rises and
|
|
## fades in exactly as the disc declares -- alpha 0x00 at t=214, 0xff by t=238 --
|
|
## rather than appearing as a cut. `holding` then parks it at its settle, which
|
|
## for this build is the visible pose.
|
|
##
|
|
## ⚠️ It does NOT pulse, and that is a decision with arithmetic behind it rather
|
|
## than an omission. See `authored/flow.json`, `no_pulse_why`.
|
|
func _raise_overlay(name: String) -> void:
|
|
var spec := _overlay_spec
|
|
_overlay_spec = {}
|
|
if name == "":
|
|
return
|
|
overlay = ScreenView.new()
|
|
overlay.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
|
overlay.units_per_second = view.units_per_second
|
|
overlay.exit_ramp_units = view.exit_ramp_units
|
|
overlay.looping_focus = _looping_for(name)
|
|
overlay.draw_leaf_for = _draw_leaf_for
|
|
overlay.loop_leaf = _loop_leaf_screens.has(name)
|
|
overlay.holding = true
|
|
overlay.time_units = 0.0
|
|
if not overlay.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
overlay.queue_free()
|
|
overlay = null
|
|
return
|
|
# After `view`, so it draws over it: Node2D siblings paint in tree order and
|
|
# the export's own `paint_order` only orders WITHIN a build.
|
|
viewport.add_child(overlay)
|
|
_overlay_t0 = overlay.settle_time()
|
|
_overlay_view_t0 = view.time_units
|
|
print(" overlay %s raised at %.2f s, %d element(s), settles at t=%d"
|
|
% [name, _elapsed, overlay.screen.get("elements", []).size(), int(overlay.settle_time())])
|
|
# A boot with no menu to hand over to has now finished: it was held open for
|
|
# this. Give the plate its own group time to play before leaving, so the
|
|
# artifact shows the composited state rather than the frame it began on.
|
|
var visible_at := overlay.settle_time() / overlay.units_per_second
|
|
# `--screen --overlay=` uses the same code path as a fast check and must not
|
|
# narrate a sequence it is not running: a log line that lies is worse than
|
|
# no log line.
|
|
if _sequence.is_empty():
|
|
return
|
|
print(" plate reaches full alpha at t=%d (%.2f s on the shared clock), \
|
|
120 units after build 4's last build-in ramp at t=118"
|
|
% [int(overlay.settle_time()), visible_at])
|
|
if not _play and _film == "":
|
|
# The LATER of the two, not the overlay's alone. The plate arrives at
|
|
# t=238 and build 4 is still fading up from black until t=261 -- its
|
|
# `pteff00` quad is 7 % opaque at 243 -- so quitting when the plate
|
|
# lands photographs a title that has not finished presenting. The first
|
|
# capture taken this way was visibly darker than the one before it, and
|
|
# nothing in the log said why.
|
|
var ends_at := maxf(view.settle_time(), overlay.settle_time()) / view.units_per_second
|
|
_overlay_quit_at = _elapsed - (view.time_units / view.units_per_second) + ends_at
|
|
print(" boot ends at %.2f s, once both builds have arrived (t=%d)"
|
|
% [_overlay_quit_at, int(maxf(view.settle_time(), overlay.settle_time()))])
|
|
# `spec` is read only for the log; the reasoning lives in flow.json where a
|
|
# reader looking for a decision will find it.
|
|
if spec.has("why"):
|
|
print(" why: %s" % String(spec["why"]).substr(0, 96))
|
|
|
|
|
|
var _overlay_quit_at: float = -1.0
|
|
|
|
|
|
## Take the overlay away with the screen it belongs to.
|
|
##
|
|
## The plate was measured on the BOOT title only. Whether it is there when the
|
|
## title is reached again -- (B) from the main menu, or after the attract movie
|
|
## -- is not measured, so leaving it up would be claiming something nobody has
|
|
## watched. `authored/flow.json` says the same thing in the step's `scope_why`.
|
|
func _drop_overlay() -> void:
|
|
_overlay_spec = {}
|
|
_overlay_quit_at = -1.0
|
|
if overlay != null:
|
|
overlay.queue_free()
|
|
overlay = null
|
|
|
|
|
|
## End a `--boot` run, photographing the composited end state first if asked.
|
|
##
|
|
## `--capture` used to be a `--screen`-only flag, taken in `_ready`. The boot had
|
|
## no artifact of its own except a whole `--film` filmstrip, which is 600+ PNGs
|
|
## to answer one question: is the plate on top of the title at the end. This
|
|
## takes that one frame.
|
|
func _finish_boot() -> void:
|
|
if _capture_to != "":
|
|
await _capture(_capture_to)
|
|
get_tree().quit(0)
|
|
|
|
|
|
var _capture_to := ""
|
|
|
|
|
|
## The authored looping-focus entries that apply to one screen.
|
|
##
|
|
## The table is keyed `<screen>/<element>` so a reader can see at a glance which
|
|
## screen an entry belongs to -- `ptbtn00` exists on more than one build, and an
|
|
## entry that silently applied to all of them would be a rule again.
|
|
func _looping_for(screen_name: String) -> Dictionary:
|
|
var out := {}
|
|
for key: String in _looping.keys():
|
|
if key == "_":
|
|
continue
|
|
var parts := key.split("/", true, 1)
|
|
if parts.size() == 2 and parts[0] == screen_name:
|
|
out[parts[1]] = _looping[key]
|
|
return out
|
|
|
|
|
|
## Extra hold for one screen, in units, from `authored/flow.json` `dwell`.
|
|
##
|
|
## Zero unless a measurement is authored. A value here is an ADDITION to the
|
|
## screen's own declared group, not a replacement for it.
|
|
func _dwell_for(screen_name: String) -> float:
|
|
if _flow == null or not (_flow is Dictionary):
|
|
return 0.0
|
|
var table: Variant = (_flow as Dictionary).get("dwell", {})
|
|
if not (table is Dictionary):
|
|
return 0.0
|
|
var v: Variant = (table as Dictionary).get(screen_name, 0.0)
|
|
return float(v) if (v is float or v is int) else 0.0
|
|
|
|
|
|
## Check the authored values this port CANNOT act on, and fail loudly if one
|
|
## changes.
|
|
##
|
|
## `left_right`, `input_during_transition` and `ramp` are authored with reasons
|
|
## and read by nothing -- the behaviour they describe is hardcoded. That is
|
|
## defensible for a record and dangerous for a switch, and they are written like
|
|
## switches: someone setting `left_right` to "move" would change nothing and get
|
|
## no warning.
|
|
##
|
|
## So rather than invent the missing implementations, the port ASSERTS the value
|
|
## it was built against. Changing one now produces an error naming the file
|
|
## instead of silence, which is the distinction the `why` for `left_right`
|
|
## claims to be making -- "the game ignores it" and "we never wired it" are
|
|
## different lines of code -- and which was not actually being made.
|
|
func _check_authored_invariants(timing: Dictionary) -> void:
|
|
var nav: Variant = (_flow as Dictionary).get("navigation", {}) if _flow is Dictionary else {}
|
|
if nav is Dictionary:
|
|
var lr := String((nav as Dictionary).get("left_right", "nothing"))
|
|
if lr != "nothing":
|
|
push_error("authored/flow.json navigation.left_right is \"%s\"; this port implements only \"nothing\"" % lr)
|
|
var idt := String((nav as Dictionary).get("input_during_transition", "ignored"))
|
|
if idt != "ignored":
|
|
push_error("authored/flow.json navigation.input_during_transition is \"%s\"; this port implements only \"ignored\"" % idt)
|
|
var ramp := String(timing.get("ramp", "linear"))
|
|
if ramp != "linear":
|
|
push_error("authored/timing.json ramp is \"%s\"; ScreenView interpolates linearly and has no other mode" % ramp)
|