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.
274 lines
9.6 KiB
GDScript
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
|