Files
Sylpheed/port/scripts/boot.gd
MechaCat02 65cefa74c3
Some checks failed
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
CI / WASM — Web (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Native — ubuntu-latest (push) Has been cancelled
monorepo: one repository for the decoders, the port and the corpus
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.

The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.

Canary stays separate: it is a fork tracking upstream.

New structure for the long term:

  docs/game/     how the game is NAVIGATED -- menus, modals, prompts, alerts,
                 and in-game flight. Written so nobody rediscovers it. Mostly
                 open questions on purpose; the in-game tutorials are the
                 resource for the flight half.
  docs/port/MODDING.md
                 modding as a constraint on the exporter TODAY, not a later
                 feature: one logical asset in one file (the disc splits nearly
                 everything, and resolving that is the exporter's job), names a
                 person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
                 re-exporting is always safe, provenance in every file.
  data/base + data/mods
                 generated tree and drop-in overrides, both gitignored
  exchange/      transient inter-agent files, deliberately outside history
  docs/agents/   the team protocol

Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.

Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
2026-08-29 11:34:46 +02:00

274 lines
9.6 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(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