ScreenView now draws two builds at once, which it never had to before. It is a second ScreenView in the same SubViewport rather than a subordinate screen inside one: each build has its own timeline, its own textures and its own hold, which is the entire content of the finding, and Node2D siblings already paint in tree order. `paint_order` still means what it meant -- an ordering WITHIN a build. The delay is authored in flow.json on the BOOT STEP, not on the `title` screen. What was measured is the boot title; whether the plate is there when the title is reached again -- (B) from the menu, or after the attract movie -- is not, and hanging it on the screen would quietly claim that it is. REFUTATION, and it is the substance of this commit: the RE agent's authoring instruction does not reproduce the RE agent's own measurement, and the gap is 3.97 s. The instruction is "when build 4 has settled, wait 2.13 s, composite build 2". But build 2 has a group and this port plays groups -- ptbtn00 is alpha 0x00 at t=214, still 0x00 at t=236 while it slides 10 px up, and 0xff only at t=238, which is 3.967 s at 60 units/s. So the plate is first VISIBLE at settle+6.10 s, while what was measured -- the glyph counter leaving 154 -- is visibility at settle+2.13 s. Both groups starting together puts it 0.38 s BEFORE settle; build 2 starting at settle puts it at settle+3.97 s; landing on the measurement needs build 2's group to start 2.51 s after build 4's, which is not a landmark of anything. The measurement is untouched -- it is an observation of the running game and I have no standing to doubt it. What is refuted is the step that turns it into an authoring rule. So the port ships the instruction, prints the discrepancy on every boot, and files the row. Same call as the BGM sub-waves: a port that quietly picks the number that looks right destroys the evidence, because a corrected boot looks exactly like a correct one. Also refuted, and it was mine: BLOCKED.md has said since P2 that "no element's alpha reverses direction anywhere in this export, so nothing pulses". ptbtn00 reverses -- 0x00 -> 0xff -> 0x00 -- and it was in the export the whole time. The claim had been checked against the screens P2 happened to be animating. The port still draws no pulse, because no reading of this group yields the measured 2.24 s: the whole group is 4.47 s and from its first keyframe 0.90 s. Gate: `--boot --capture=` writes one frame of the composited end state, instead of the 600-PNG filmstrip that was previously the only boot artifact. `--screen=title --overlay=press_start` raises the same composite in two seconds for anyone who does not want to sit through 137 s of Theora. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
822 lines
34 KiB
GDScript
822 lines
34 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 -- is composited over it 2.13 s after build 4 settles. MEASURED, two
|
|
## boots agreeing to 6 ms; see the step's `why`.
|
|
##
|
|
## 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
|
|
|
|
_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", "")
|
|
_shots = args.get("shots", "")
|
|
if args.has("script"):
|
|
_script = args["script"].split(",", false)
|
|
# 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.
|
|
view.exit_ramp_units = float(timing["exit_ramp_units"])
|
|
viewport.add_child(view)
|
|
|
|
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)
|
|
|
|
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])
|
|
|
|
if args.has("time"):
|
|
_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.
|
|
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.time_units = float(args["time"]) * overlay.units_per_second
|
|
overlay.queue_redraw()
|
|
|
|
# `--boot --capture=` is deferred to the end of the sequence (`_finish_boot`);
|
|
# in every other mode the frame worth having is this one.
|
|
if args.has("capture") and _capture_to == "":
|
|
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
|
|
var _script: PackedStringArray = PackedStringArray()
|
|
var _shots := ""
|
|
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
|
|
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:
|
|
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: the
|
|
# pacing is each group's own timeline (authored/flow.json, `dwell`).
|
|
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.
|
|
var spec: Variant = _sequence[_step].get("overlay", null)
|
|
if typeof(spec) == TYPE_DICTIONARY:
|
|
_overlay_spec = spec
|
|
_overlay_due = _elapsed + float(spec.get("after_settle_seconds", 0.0))
|
|
print(" overlay %s due at %.2f s (+%.2f s after settle)"
|
|
% [spec.get("screen", "?"), _overlay_due, _overlay_due - _elapsed])
|
|
# 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():
|
|
get_tree().quit(0)
|
|
elif not view.holding and view.time_units >= view.exit_time():
|
|
_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
|
|
if not view.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
get_tree().quit(2)
|
|
|
|
|
|
## 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"]])
|
|
|
|
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()
|
|
|
|
|
|
var _skippable := false
|
|
|
|
|
|
func _video_finished() -> void:
|
|
print(" video ended at %.2f s" % _elapsed)
|
|
_player.queue_free()
|
|
_player = null
|
|
_advance()
|
|
|
|
|
|
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
|
|
"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", []))
|
|
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
|
|
if not view.load_screen(view.tree, name):
|
|
push_error(view.tree.error)
|
|
get_tree().quit(2)
|
|
return
|
|
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 += 0.25
|
|
|
|
|
|
# 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 == "wait":
|
|
pass
|
|
elif SCRIPT_ACTIONS.has(token):
|
|
print("script[%d] %s" % [i + 1, token])
|
|
_press(String(SCRIPT_ACTIONS[token]))
|
|
else:
|
|
push_error("--script: no such step %s (have %s, wait)" % [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)])
|
|
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
|
|
while _pending != null or _player != null or not view.holding \
|
|
or view.time_units < view.settle_time():
|
|
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 = {}
|
|
## 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:
|
|
overlay.time_units += delta * overlay.units_per_second
|
|
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", "")))
|
|
|
|
|
|
## 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.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)
|
|
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
|
|
# The two lines below describe a BOOT. `--screen --overlay=` raises the same
|
|
# composite with no delay, 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
|
|
# 🔴 SAY THE NUMBER OUT LOUD. `authored/flow.json` implements the RE agent's
|
|
# instruction literally -- "when build 4 has settled, wait 2.13 s, composite
|
|
# build 2" -- and build 2 then takes its OWN declared 238 units to fade in.
|
|
# So the plate is first VISIBLE at settle + 2.13 + 3.97 s, while the thing
|
|
# that was measured is the plate becoming visible at settle + 2.13 s. The two
|
|
# cannot both be right and the port is not the one to choose. Printed on
|
|
# every boot so the disagreement cannot go quiet.
|
|
print(" ⚠ plate raised at settle+%.2f s but its own group reaches full alpha %.2f s later, \
|
|
so it is first VISIBLE at settle+%.2f s -- the measurement is settle+%.2f s. See docs/port/BLOCKED.md."
|
|
% [float(spec.get("after_settle_seconds", 0.0)), visible_at,
|
|
float(spec.get("after_settle_seconds", 0.0)) + visible_at,
|
|
float(spec.get("after_settle_seconds", 0.0))])
|
|
if not _play and _film == "":
|
|
_overlay_quit_at = _elapsed + visible_at
|
|
print(" boot ends at %.2f s, once the plate has settled" % _overlay_quit_at)
|
|
# `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 := ""
|