From 3d124985502afd5372002faf2dae6a31a0742b33 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:31:03 +0000 Subject: [PATCH] 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. --- port/project.godot | 7 ++ port/scenes/boot.tscn | 6 ++ port/scripts/boot.gd | 97 ++++++++++++++++++++ port/scripts/boot.gd.uid | 1 + port/scripts/export_tree.gd | 95 ++++++++++++++++++++ port/scripts/export_tree.gd.uid | 1 + port/scripts/screen_view.gd | 152 ++++++++++++++++++++++++++++++++ port/scripts/screen_view.gd.uid | 1 + 8 files changed, 360 insertions(+) create mode 100644 port/scenes/boot.tscn create mode 100644 port/scripts/boot.gd create mode 100644 port/scripts/boot.gd.uid create mode 100644 port/scripts/export_tree.gd create mode 100644 port/scripts/export_tree.gd.uid create mode 100644 port/scripts/screen_view.gd create mode 100644 port/scripts/screen_view.gd.uid diff --git a/port/project.godot b/port/project.godot index 6cf99dd..b38d1c8 100644 --- a/port/project.godot +++ b/port/project.godot @@ -18,3 +18,10 @@ window/size/viewport_width=1280 window/size/viewport_height=720 window/stretch/mode="canvas_items" window/stretch/aspect="keep" + +[rendering] + +; The screens carry their own background; anything the export does not paint is +; black, which is what `sylpheed-cli screen render --black` composites over and +; therefore what a capture is comparable against. +environment/defaults/default_clear_color=Color(0, 0, 0, 1) diff --git a/port/scenes/boot.tscn b/port/scenes/boot.tscn new file mode 100644 index 0000000..0978273 --- /dev/null +++ b/port/scenes/boot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/boot.gd" id="1"] + +[node name="Boot" type="Node"] +script = ExtResource("1") diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd new file mode 100644 index 0000000..7762861 --- /dev/null +++ b/port/scripts/boot.gd @@ -0,0 +1,97 @@ +# 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 +# +# 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 + + var name: String = args.get("screen", DEFAULT_SCREEN) + 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", "") + viewport.add_child(view) + + if not view.load_screen(export_tree, name): + push_error(export_tree.error) + get_tree().quit(2) + return + + print("screen %s: %d elements, %d in paint order, design %dx%d" % [ + name, view.screen["elements"].size(), view.screen["paint_order"].size(), + design[0], design[1]]) + + if args.has("capture"): + await _capture(args["capture"]) + get_tree().quit(0) + + +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("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]) + + +# 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] + return out diff --git a/port/scripts/boot.gd.uid b/port/scripts/boot.gd.uid new file mode 100644 index 0000000..16d85b5 --- /dev/null +++ b/port/scripts/boot.gd.uid @@ -0,0 +1 @@ +uid://cskqmpkw2q6k2 diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd new file mode 100644 index 0000000..bc01050 --- /dev/null +++ b/port/scripts/export_tree.gd @@ -0,0 +1,95 @@ +# 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 `/../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) diff --git a/port/scripts/export_tree.gd.uid b/port/scripts/export_tree.gd.uid new file mode 100644 index 0000000..5f7e6d5 --- /dev/null +++ b/port/scripts/export_tree.gd.uid @@ -0,0 +1 @@ +uid://kyd3xrt1lpnj diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd new file mode 100644 index 0000000..34fecc1 --- /dev/null +++ b/port/scripts/screen_view.gd @@ -0,0 +1,152 @@ +# Draws one exported screen at its resting pose. +# +# P1 is static: every element is drawn at `rest`, the pose the screen holds +# once it has finished arriving (docs/FORMAT.md). Keyframe animation is P2 and +# is deliberately not here -- the keyframe time unit is measured rather than +# decoded, and this milestone must not depend on it. +# +# One CanvasItem draws the whole screen in `_draw`, rather than a node per +# element. The export's `paint_order` is already back-to-front, so honouring it +# is a loop; z-indexing sixteen nodes to reproduce the same order would be the +# same information expressed less directly, and would hide a tie behind Godot's +# own sibling rules. +class_name ScreenView +extends Node2D + +## Skip `kind & 0x4` template instances that duplicate a plain element. +## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest. +## The narrow form of the rule matters -- 174 elements on the disc carry the bit +## with no template to duplicate, and a blanket skip would erase them. +const KIND_TEMPLATE_INSTANCE := 0x4 + +var tree: ExportTree = null +var screen: Dictionary = {} +var textures: Dictionary = {} +var skipped: Array[String] = [] +var drawn: Array[String] = [] + +## Which button is highlighted, by element id. P1 leaves it empty: initial focus +## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an +## authored decision that belongs to P5. +var focused_id: String = "" + + +func load_screen(t: ExportTree, name: String) -> bool: + tree = t + screen = t.screen(name) + if screen.is_empty(): + push_error(t.error) + return false + var design: Array = screen.get("design", [1280, 720]) + # The export's coordinates are in this space and the viewport matches it, so + # a mismatch means the export is not what this project was built to draw. + var viewport := Vector2i( + ProjectSettings.get_setting("display/window/size/viewport_width"), + ProjectSettings.get_setting("display/window/size/viewport_height")) + if Vector2i(int(design[0]), int(design[1])) != viewport: + push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport]) + _load_textures() + queue_redraw() + return true + + +func _load_textures() -> void: + textures.clear() + for element: Dictionary in screen.get("elements", []): + for key in ["sprite", "focus_sprite"]: + var rel: String = element.get(key, "") + if rel != "" and not textures.has(rel): + var tex := tree.texture(rel) + if tex == null: + push_warning(tree.error) + else: + textures[rel] = tex + + +# `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on +# purpose, because the disc spells them differently and a silent swap looks like +# an art bug rather than a parse bug. They multiply per channel. +static func modulate_of(pose: Dictionary) -> Color: + var tint := _rgba(pose.get("tint_rgba", "0xffffffff")) + var fade := _argb(pose.get("fade_argb", "0xffffffff")) + return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a) + + +static func _rgba(hex: String) -> Color: + var v := hex.hex_to_int() + return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff) + + +static func _argb(hex: String) -> Color: + var v := hex.hex_to_int() + return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff) + + +## The drawn rectangle of an element at a pose. +## +## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so +## the top-left moves by `-pivot*(s-1)` and the size is the natural size times +## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly. +static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2: + var pos := _vec(pose.get("pos", [0, 0])) + var s := _vec(pose.get("scale", [100, 100])) / 100.0 + return Rect2(pos - pivot * (s - Vector2.ONE), natural * s) + + +static func _vec(a: Array) -> Vector2: + return Vector2(float(a[0]), float(a[1])) + + +# An element is a ghost only when another element on the same screen carries the +# same id *without* the template bit -- the template it is a repeat of. +func _template_instance_ids() -> Dictionary: + var plain := {} + for element: Dictionary in screen.get("elements", []): + if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0: + plain[element.get("id", "")] = true + var ghosts := {} + for element: Dictionary in screen.get("elements", []): + var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int()) + if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")): + ghosts[int(element.get("index", -1))] = true + return ghosts + + +func _draw() -> void: + if screen.is_empty(): + return + var elements: Array = screen.get("elements", []) + var ghosts := _template_instance_ids() + skipped.clear() + drawn.clear() + for index: int in screen.get("paint_order", []): + var element: Dictionary = elements[index] + var id: String = element.get("id", "") + if ghosts.has(index): + skipped.append("%s (template instance)" % id) + continue + var pose: Dictionary = element.get("rest", {}) + var colour := modulate_of(pose) + if colour.a <= 0.0: + skipped.append("%s (transparent at rest)" % id) + continue + var pivot := _vec(element.get("pivot", [0, 0])) + var rel: String = element.get("sprite", "") + if focused_id == id and element.get("focus_sprite", "") != "": + rel = element["focus_sprite"] + if rel != "": + var tex: Texture2D = textures.get(rel) + if tex == null: + skipped.append("%s (sprite failed to load)" % id) + continue + draw_texture_rect(tex, placement(pose, pivot, tex.get_size()), false, colour) + drawn.append(id) + elif element.get("role", "") == "primitive" and element.has("size"): + # A primitive has no texture; the quad is its declared size and its + # colour is the pose's own modulate. + draw_rect(placement(pose, pivot, _vec(element["size"])), colour, true) + drawn.append(id) + else: + # A .t32 element whose sprite the exporter could not produce. Saying + # so is the point -- a silently missing element looks like art. + skipped.append("%s (no sprite in the export)" % id) diff --git a/port/scripts/screen_view.gd.uid b/port/scripts/screen_view.gd.uid new file mode 100644 index 0000000..2844ec7 --- /dev/null +++ b/port/scripts/screen_view.gd.uid @@ -0,0 +1 @@ +uid://cf6hspvq602s3