Files
Sylpheed/port/scripts/screen_view.gd
Sylpheed port agent 22d7c88cf5 port: settle at the hold, not at the last timed keyframe
P2 shipped the wrong rule and the title is the counter-example. I had reasoned
that the exit is the final untimed keyframe, so anything timed was still the
entry. It is not: `pteff02` on the title holds at t=46 with the 25 % dim quad at
alpha 0x40 and then ramps to 0x00 by t=236. The exit can be a long run of TIMED
keyframes, and running to the end drops the dim and leaves the whole screen
~13/255 too bright -- exactly the luminance excess P2 recorded (oracle 64.1,
rest 62.8, timeline 80.0) and filed as an unexplained "glow slab".

A group is pre-roll -> ramp in -> hold -> ramp out -> post-roll, and a screen
that has arrived sits on the hold. `settle_units()` is now `rest.t`, the
decoders' own identification of that hold.

The check is that a disagreement vanishes: on ALL TWELVE screens the settled
timeline is now byte-identical to the `--pose=rest` render, where before this
four differed by up to 247/255. The timeline's endpoint should BE `rest` -- the
animation is what it adds, not a different destination -- so this is the
property to want, and it holds with no special case.

Credit where due: this came out of the RE agent measuring the title's dim quad
against a plate-free capture, in pursuit of a different question.
2026-08-28 21:44:08 +00:00

263 lines
10 KiB
GDScript

# Draws one exported screen, either at a moment on its timeline or at the
# `rest` pose the export declares.
#
# TIMELINE is the real behaviour and the default. A keyframe is the start of a
# LINEAR ramp toward the next, and the unit of `t` comes from
# `authored/timing.json` -- it is measured, not on the disc, which is why it is
# authored and applied in exactly one place.
#
# REST reproduces what the export's `rest` field says, which is what
# `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can
# hold both renderers to the same assumption. The two modes DISAGREE on six
# elements in this export, and the running game sides with the timeline -- see
# `docs/DECISIONS.md`.
#
# 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
enum Pose { TIMELINE, REST }
## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws
## the export's declared `rest` and is there for renderer-vs-renderer diffing.
var pose_mode: Pose = Pose.TIMELINE
## Position on the timeline, in the disc's own keyframe units. `t` is left raw
## everywhere; seconds appear only where `units_per_second` is applied.
var time_units: float = 0.0
var units_per_second: float = 60.0
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]))
## The pose of one element at `time_units`.
##
## A group is `pre-roll -> ramp in -> HOLD -> ramp out -> post-roll`, and a
## screen that has arrived sits on the **hold**. So the timeline plays in and
## stops at `rest`, which is the decoders' identification of that hold and
## carries its own `t`.
##
## It is emphatically NOT "play to the last timed keyframe". The exit is not
## only the final untimed frame -- it can be a long run of TIMED ones. The
## title's `pteff02` holds at `t=46` with the 25 % dim quad at alpha 0x40 and
## then ramps to 0x00 by `t=236`; running to the end drops the dim and makes the
## whole screen ~13/255 too bright. That was measured against a plate-free
## capture of the running title, and it is what corrected this rule.
##
## Before the first keyframe the element holds its first pose -- the pre-roll a
## staggered menu needs, with the five buttons starting at t=28,30,32,34,36.
func pose_at(element: Dictionary, t: float) -> Dictionary:
var frames: Array = element.get("keyframes", [])
var timed: Array = []
for k: Dictionary in frames:
if k.has("t"):
timed.append(k)
if timed.is_empty():
# No timed frame at all: the group is a single static pose.
return frames[0] if not frames.is_empty() else element.get("rest", {})
# Stop at the hold. Past it the group is ramping out, which is the screen
# transition and belongs to whatever is driving the transition -- not to a
# screen that has arrived and is sitting there.
t = minf(t, settle_units(element))
if t <= float(timed[0]["t"]):
return timed[0]
for i in range(timed.size() - 1):
var a: Dictionary = timed[i]
var b: Dictionary = timed[i + 1]
var t0 := float(a["t"])
var t1 := float(b["t"])
if t < t1:
# A keyframe is the start of a ramp toward the next, and the ramp is
# linear -- measured, `authored/timing.json`.
return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0))
return timed[timed.size() - 1]
# Channels are integers on the disc. The running game's own fade lands on
# `round(255*k/15)`, so rounding -- not truncation -- is what was measured.
static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary:
return {
"pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)],
"scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)],
"tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f),
"fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f),
}
static func _ilerp(a: float, b: float, f: float) -> int:
return int(round(a + (b - a) * f))
# Byte-wise, so it works for both orders without knowing which one it has.
static func _hex_lerp(a: String, b: String, f: float) -> String:
var x := a.hex_to_int()
var y := b.hex_to_int()
var out := 0
for shift in [24, 16, 8, 0]:
out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift
return "0x%08x" % out
## Where one element stops, in keyframe units: its hold.
##
## `rest.t` when the export gives one. An element whose `rest` carries no time is
## a single static pose, and there the last timed keyframe is the same answer.
static func settle_units(element: Dictionary) -> float:
var rest: Dictionary = element.get("rest", {})
if rest.has("t"):
return float(rest["t"])
var last := 0.0
for k: Dictionary in element.get("keyframes", []):
if k.has("t"):
last = maxf(last, float(k["t"]))
return last
## The moment the whole screen has arrived: the last element to reach its hold.
func settle_time() -> float:
var last := 0.0
for element: Dictionary in screen.get("elements", []):
last = maxf(last, settle_units(element))
return last
# 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", {}) if pose_mode == Pose.REST \
else pose_at(element, time_units)
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)