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.
210 lines
7.3 KiB
GDScript
210 lines
7.3 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 -- --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
|
|
# 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
|
|
|
|
|
|
func _ready() -> void:
|
|
var args := _args()
|
|
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 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)
|
|
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
|
|
|
|
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()
|
|
|
|
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.
|
|
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)])
|
|
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
|