From 0a026074d3ed4953afd621da532059a0ed6932f7 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:27:41 +0000 Subject: [PATCH] port: play the exit ramp, and run the boot sequence unattended P3. The exit is the group playing ITSELF out, not a black rect over a frozen screen -- and that was settled by a test that discriminates rather than by plausibility. Under the black-rect model every region is scaled by the same 1-alpha, so the button/background brightness RATIO would hold constant through the fade; measured, it falls 6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935. Implemented by giving the final untimed keyframe a SYNTHETIC time, exit_ramp_units after the last timed one, then interpolating it like any other frame. One code path: arriving and leaving differ only in how far `t` is allowed to run, not in kind. `holding` is what the sequencer clears to send a screen away. The sequencer waits on nothing the disc does not carry. A screen holds until its own group has arrived, then plays out; `dwell` in flow.json is deliberately empty because each screen's dwell IS its keyframe group (publisher wordmark 3.92 s, developer logos 3.17 s, both read from the disc). Any extra hold would be a number nobody measured. The last screen keeps holding -- nothing is taking the title's place, and a boot that ends by fading to black looks like a boot that crashed. flow.json reproduces an OBSERVATION and says so in its header: Q6 closed with a negative, the order is on the disc nowhere, a transition is a call with a name argument chosen by code. The intro video's place in the real boot is named as a gap rather than the order being quietly rewritten to hide it. --- port/scripts/boot.gd | 77 ++++++++++++++++++++++++++++++++++++- port/scripts/screen_view.gd | 51 ++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 1ac135f..61c3b19 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -8,6 +8,8 @@ # 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 -- --boot # the whole boot sequence +# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s # # `--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 @@ -36,7 +38,17 @@ func _ready() -> void: get_tree().quit(2) return - var name: String = args.get("screen", DEFAULT_SCREEN) + _flow = export_tree.authored("flow.json") + if args.has("boot"): + if _flow == null: + push_error(export_tree.error) + get_tree().quit(2) + return + for step: Dictionary in _flow["boot"]: + _sequence.append(String(step["screen"])) + _film = args.get("film", "") + + var name: String = _sequence[0] if not _sequence.is_empty() else args.get("screen", DEFAULT_SCREEN) var screen: Dictionary = export_tree.screen(name) if screen.is_empty(): push_error(export_tree.error) @@ -74,6 +86,9 @@ func _ready() -> void: 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): @@ -91,20 +106,66 @@ func _ready() -> void: view.time_units = float(args["time"]) * view.units_per_second view.queue_redraw() + if _film != "": + set_process(true) + _film_capture() + if args.has("capture"): await _capture(args["capture"]) get_tree().quit(0) var _frozen := false +var _flow: Variant = null +var _sequence: Array[String] = [] +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() + 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]]) + if _film == "": + get_tree().quit(0) + elif not view.holding and view.time_units >= view.exit_time(): + _advance() + + +func _advance() -> void: + _step += 1 + var next := _sequence[_step] + print(" -> %s at %.2f s" % [next, _elapsed]) + view.holding = true + view.time_units = 0.0 + if not view.load_screen(view.tree, next): + push_error(view.tree.error) + get_tree().quit(2) + func _capture(path: String) -> void: # Two frames: the first is the one this callback is still inside of. @@ -124,6 +185,18 @@ func _capture(path: String) -> void: 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 := {} @@ -131,4 +204,6 @@ static func _args() -> Dictionary: 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 diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 9f6837f..08cb8f6 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -37,6 +37,15 @@ var pose_mode: Pose = Pose.TIMELINE var time_units: float = 0.0 var units_per_second: float = 60.0 +## Duration of the ramp into the final, untimed keyframe -- the screen playing +## itself out. Authored (`authored/timing.json`): the disc has no time slot on +## that keyframe, so this is the one unknown duration per screen. +var exit_ramp_units: float = 24.0 + +## While true the screen holds at `rest` and never plays its exit. The +## sequencer clears it to send the screen away. +var holding: bool = true + var tree: ExportTree = null var screen: Dictionary = {} var textures: Dictionary = {} @@ -140,10 +149,26 @@ func pose_at(element: Dictionary, t: float) -> Dictionary: if timed.is_empty(): # No timed frame at all: the group is a single static pose. return frames[0] if not frames.is_empty() else element.get("rest", {}) - # Stop at the hold. Past it the group is ramping out, which is the screen - # transition and belongs to whatever is driving the transition -- not to a - # screen that has arrived and is sitting there. - t = minf(t, settle_units(element)) + # While holding, stop at the hold: past it the group is ramping out, and a + # screen that has arrived and is sitting there is not leaving. + if holding: + t = minf(t, settle_units(element)) + # The exit. The final keyframe carries no `t` -- the disc has no slot for one + # -- so it is given a synthetic time `exit_ramp_units` after the last timed + # frame and then interpolated like any other. That keeps one code path: the + # difference between arriving and leaving is only how far `t` is allowed to + # run, not a second kind of animation. + # + # The whole group plays out, not just the fade quad: on the main menu + # pteff00 ramps to opaque black while the labels ramp to transparent and + # ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen + # was measured and refuted -- see authored/timing.json. + var last_frame: Dictionary = frames[frames.size() - 1] + if not last_frame.has("t"): + var exit_frame := last_frame.duplicate() + exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units + timed.append(exit_frame) + if t <= float(timed[0]["t"]): return timed[0] for i in range(timed.size() - 1): @@ -206,6 +231,24 @@ func settle_time() -> float: return last +## The moment the screen has finished playing itself out, in keyframe units -- +## the last element's final timed keyframe plus the authored exit ramp. +func exit_time() -> float: + var last := 0.0 + for element: Dictionary in screen.get("elements", []): + var frames: Array = element.get("keyframes", []) + if frames.is_empty(): + continue + var timed_end := 0.0 + for k: Dictionary in frames: + if k.has("t"): + timed_end = maxf(timed_end, float(k["t"])) + if not frames[frames.size() - 1].has("t"): + timed_end += exit_ramp_units + last = maxf(last, timed_end) + return last + + # An element is a ghost only when another element on the same screen carries the # same id *without* the template bit -- the template it is a repeat of. func _template_instance_ids() -> Dictionary: