# 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(step) _film = args.get("film", "") var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \ else args.get("screen", DEFAULT_SCREEN) 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 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[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() if _sequence.is_empty() or _player != null: 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: 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. This is the only input the port handles # so far; menu navigation is P5. if _player == null or not _skippable: return if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"): print(" video skipped at %.2f s" % _elapsed) _player.stop() _video_finished() 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