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.
This commit is contained in:
Sylpheed port agent
2026-08-29 08:27:41 +00:00
parent 0d2bf1f241
commit 0a026074d3
2 changed files with 123 additions and 5 deletions

View File

@@ -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

View File

@@ -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: