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.
This commit is contained in:
@@ -18,3 +18,10 @@ window/size/viewport_width=1280
|
|||||||
window/size/viewport_height=720
|
window/size/viewport_height=720
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="keep"
|
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)
|
||||||
|
|||||||
6
port/scenes/boot.tscn
Normal file
6
port/scenes/boot.tscn
Normal file
@@ -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")
|
||||||
97
port/scripts/boot.gd
Normal file
97
port/scripts/boot.gd
Normal file
@@ -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
|
||||||
1
port/scripts/boot.gd.uid
Normal file
1
port/scripts/boot.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cskqmpkw2q6k2
|
||||||
95
port/scripts/export_tree.gd
Normal file
95
port/scripts/export_tree.gd
Normal file
@@ -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 `<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)
|
||||||
1
port/scripts/export_tree.gd.uid
Normal file
1
port/scripts/export_tree.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://kyd3xrt1lpnj
|
||||||
152
port/scripts/screen_view.gd
Normal file
152
port/scripts/screen_view.gd
Normal file
@@ -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)
|
||||||
1
port/scripts/screen_view.gd.uid
Normal file
1
port/scripts/screen_view.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cf6hspvq602s3
|
||||||
Reference in New Issue
Block a user