# 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=` 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 := ""