This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Sylpheed-Godot/port/scripts/export_tree.gd
Sylpheed port agent 0db45b4c66 port: play the keyframe timeline, with the time unit authored in one place
P2. A keyframe is the start of a linear ramp toward the next; `ScreenView` walks
them at `time_units` and `boot.gd` advances that in real time, or freezes it with
`--time=<seconds>`.

`authored/timing.json` holds the ONE constant this needs. HANDOFF Q1 is answered
-- linear, 2 units per rendered frame, 1 unit = 1/60 s -- but that conversion was
MEASURED off the running game, not read from a file, so it is authored rather
than exported and it says so at length. Expressed as units-per-second, because
60 is exact and 0.01666... is a decimal a reader has to recognise.

The timeline stops at the last TIMED keyframe and never plays the exit. Every
group's final keyframe carries no `t` -- across this export it is a fade-out for
116 of 134 elements, a scale-and-slide exit for 12, and identical for 6 -- so
playing into it would mean inventing how long the ramp takes. That duration is
the screen transition, it is measured at ~0.4 s, and it is P3's to author with
its own evidence. `exit_ramp_seconds` is therefore null on purpose, not missing.

`--pose=rest` keeps the P1 behaviour available: since the port's default is now
the timeline and the two DISAGREE, renderer-vs-renderer diffing has to be able to
ask for the same assumption the reference renderer makes.

The interpolation is checked by where it lands: on 8 of the 12 screens the
settled timeline is byte-identical to the rest render.
2026-08-28 19:51:57 +00:00

107 lines
3.4 KiB
GDScript

# Locating and reading the open export tree.
#
# The Godot project NEVER reads a disc format (docs/MISSION.md §2). Everything
# it draws comes from `export/`, which is derived, gitignored and regenerated
# wholesale by `crates/sylpheed-export`. This class is the only place that knows
# where that tree is on disk.
class_name ExportTree
extends RefCounted
const FORMAT_SCREEN := "sylpheed.screen/2"
const FORMAT_MANIFEST := "sylpheed.manifest/1"
var root: String = ""
var error: String = ""
# `SYLPHEED_EXPORT` wins, so a modder can point the game at their own tree
# without touching the project. Otherwise `<project>/../export`, which is the
# layout this repository has.
static func locate() -> ExportTree:
var t := ExportTree.new()
var env := OS.get_environment("SYLPHEED_EXPORT")
var candidate := env
if candidate == "":
candidate = ProjectSettings.globalize_path("res://").path_join("../export").simplify_path()
if not FileAccess.file_exists(candidate.path_join("manifest.json")):
t.error = "no manifest.json under %s -- run `sylpheed-export` first" % candidate
return t
t.root = candidate
return t
# `authored/` sits beside `export/`, never inside it: it is hand-written and
# committed, and a re-export must not be able to touch it.
func authored(name: String) -> Variant:
var path := root.path_join("../authored").simplify_path().path_join(name)
var text := FileAccess.get_file_as_string(path)
if text == "":
error = "cannot read %s" % path
return null
return JSON.parse_string(text)
func read_json(rel: String) -> Variant:
var path := root.path_join(rel)
var text := FileAccess.get_file_as_string(path)
if text == "":
error = "cannot read %s" % path
return null
var parsed: Variant = JSON.parse_string(text)
if parsed == null:
error = "%s is not JSON" % path
return null
return parsed
func manifest() -> Dictionary:
var m: Variant = read_json("manifest.json")
if m == null:
return {}
if m.get("format") != FORMAT_MANIFEST:
error = "manifest.json is %s, this build reads %s" % [m.get("format"), FORMAT_MANIFEST]
return {}
return m
# Screens are addressed by their manifest name, not by a path, so the caller
# never has to know the archive's subdirectory.
func screen(name: String) -> Dictionary:
var m := manifest()
if m.is_empty():
return {}
for entry: Dictionary in m.get("screens", []):
if entry.get("name") == name:
var s: Variant = read_json(entry["file"])
if s == null:
return {}
if s.get("format") != FORMAT_SCREEN:
error = "%s is %s, this build reads %s" % [name, s.get("format"), FORMAT_SCREEN]
return {}
return s
error = "no screen named %s in manifest.json" % name
return {}
func screen_names() -> PackedStringArray:
var names := PackedStringArray()
for entry: Dictionary in manifest().get("screens", []):
names.append(entry["name"])
return names
# Textures live outside res://, so they are read as bytes and decoded at
# runtime rather than imported. Nearest-neighbour: the export is a 1:1 copy of
# the disc's own texels and several elements are drawn at 200 %, where a
# bilinear filter would invent detail the disc does not have.
func texture(rel: String) -> Texture2D:
var bytes := FileAccess.get_file_as_bytes(root.path_join(rel))
if bytes.is_empty():
error = "cannot read sprite %s" % rel
return null
var img := Image.new()
if img.load_png_from_buffer(bytes) != OK:
error = "%s is not a PNG" % rel
return null
return ImageTexture.create_from_image(img)