Files
Sylpheed/port/scripts/export_tree.gd
Sylpheed port agent 3d12498550 port: Godot draws an exported screen at its resting pose
P1. The project reads only `export/` -- the manifest, a screen's JSON and its
PNGs -- and draws every element at `rest`, in the export's own `paint_order`.

Three choices worth the words:

* `ExportTree` addresses screens by manifest NAME, never by path, and checks
  `format` on the manifest and on each screen before drawing. Textures are
  decoded from bytes at runtime rather than Godot-imported: `export/` is
  gitignored and regenerated wholesale, and a `.import` per sprite would be
  derived state next to derived state, invalidated on every re-export.

* One CanvasItem draws the whole screen. `paint_order` is already back-to-front,
  so honouring it is a loop; spreading it across sixteen nodes' z-indices would
  hide the one unresolved thing about that order -- the ties -- behind Godot's
  sibling rules.

* The screen renders into a SubViewport sized to the export's `design` rect.
  Capturing the window instead gave 1280x720 of screen minus a window manager's
  title bar: 1235x695. A gate that rescales that to compare against a 1280x720
  composite is measuring the compositor.

Nearest-neighbour filtering, because the export is a 1:1 copy of the disc's
texels, elements draw at up to 500 %, and it is what `ui_layout::blit` does --
so a filter difference cannot masquerade as a placement difference in the diff.

No keyframe interpolation and no focus state: both depend on constants that are
MEASURED rather than decoded (HANDOFF Q1, Q5), and a pixel-diff gate must not
have one of those inside it. P2 and P5.
2026-08-28 19:31:03 +00:00

96 lines
3.0 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
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)