Files
Sylpheed/port/scripts/screen_view.gd
Sylpheed port agent e6fc2977ff port: make ScreenView say what it could not draw; refute 'first-declared paints first'
skipped[] has been tracked and read by nobody since P1, under a comment saying a
silently missing element looks like art. _note_structural prints from inside
ScreenView rather than returning a value for a caller -- routing it through a
caller is exactly what did not happen. Structural skips only; transparent-at-
rest is ordinary animation. Zero found today: a guard, not a fix.

The first version of that scan was a FALSE PASS: screen_view.gd did not parse
(a line inserted at three tabs inside a four-tab block -- the substring assert
matched a shallower indent), so grep counted zero from a dead script. The scan
now counts the summary line as a positive control.

Refutes 'the first-declared element paints first', which would have made the
forced-backdrop rule redundant since all six forced elements are index 0. False
on 8 of 16 screens -- decisively on main_menu, where index 0 is pteff00, painted
LAST, and pteff00 is a measured control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
2026-08-29 22:49:16 +00:00

647 lines
29 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
## Duration of the ramp into the final, untimed keyframe -- the screen playing
## itself out. Authored (`authored/timing.json`): the disc has no time slot on
## that keyframe, so this is the one unknown duration per screen.
var exit_ramp_units: float = 24.0
## Focus records this screen draws unconditionally, and the period each loops on.
##
## `{ <parent element id>: { "record_element": String, "period_units": float } }`,
## from `authored/timing.json` `looping_focus_records`, keyed there by
## `<screen>/<element>` and narrowed to this screen by `load_screen`.
##
## ⚠️ A LOOKUP, NOT A RULE, and the census is why. The spinning ring is a rule
## (`spin_period_units`) because 16 of 212 elements match its shape and all 16
## are focus rings. The analogous rule for a pulse -- keyframes varying only in
## alpha, first alpha equal to last -- matches **82 of 212**, including
## `ptcopyright`, `palogo_sqex`, `ptmsg` and every `_eff` fade. It would make the
## copyright notice pulse. Narrowed to focus records it matches exactly one
## distinct element, and a rule justified by n=1 is a special case wearing a
## rule's clothes.
## The one instant a settled screen is posed at, in keyframe units, or -1.
##
## 🔴 Replaces per-element `rest()` while `holding`, where the export gives a
## wide enough window. `rest()` returns each element's last HOLD keyframe chosen
## independently of every other element -- right for anything that ends the
## screen settled, and exactly wrong for a **transient**. The title's
## `ptlogo_back2eff1` is a two-frame flash (0 until t52, 255 at t54-56, 0 by
## t58), so its last hold IS the flash peak and `rest()` leaves it burning. There
## are five of them, and `rest()` draws all five at once.
##
## ⚠️ **Only where the window is wide.** Across this export the widths split with
## nothing in between: `press_start` 214, `publisher_logo` 190,
## `developer_logos` 145, `title` 76 -- then `main_menu` 12, `extras` 12, the
## loading screens 8 and 4. A 12-unit "settle" on a menu that builds in until
## t=70 is a gap between staggered ramps, not a settled pose. The bar is 30
## units: the Decoder's disc-wide census puts the knee there (30 % of bundles
## have a window >= 30, 42 % have one under 10), and this export's own screens
## sit 4x either side of it with nothing between 12 and 46.
var settle_instant: float = -1.0
const SETTLE_WINDOW_MIN := 30.0
var looping_focus: Dictionary = {}
## Element ids whose nested `.rat` leaf the runtime actually draws.
##
## The exporter flags `leaf_carries_geometry` on 15 elements -- a census fact.
## This is narrower on purpose: it is the subset the DECODE covers, and it comes
## from `authored/rendering.json` with its reasons. Two elements are flagged and
## deliberately not drawn (`title_jp/ptlogo_eff2`, `pgloading_loop5`), because
## drawing them would extend a decode past the case it was fitted on and neither
## can be adjudicated here -- `title_jp` has no oracle capture, and the
## consistency harness compares against a renderer that draws no leaves at all.
var draw_leaf_for: Array = []
## Whether this screen replays a leaf's group. See `authored/rendering.json`.
var loop_leaf := false
## While true the screen holds at `rest` and never plays its exit. The
## sequencer clears it to send the screen away.
var holding: bool = true
var tree: ExportTree = null
var screen: Dictionary = {}
var textures: Dictionary = {}
var skipped: Array[String] = []
## Structural skips accumulated over the life of the CURRENT screen, deduplicated.
##
## 🔴 `skipped` itself is per-frame and was read by NOBODY. Its own comment says
## "a silently missing element looks like art" -- and for eight milestones
## nothing printed it, so the port could drop an element every frame and say so
## to no one. That is the same shape as the black hold, which was implemented,
## called, and emitted nothing until somebody filmed it.
##
## Only STRUCTURAL skips accumulate here. "(transparent at rest)" is ordinary
## animation -- every element is transparent at some instant -- and reporting it
## would bury the three that mean something under the one that never does.
var structural_skips: 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])
var w: Array = screen.get("settle_window", [])
settle_instant = -1.0
if w.size() == 3 and float(w[1]) - float(w[0]) >= SETTLE_WINDOW_MIN:
settle_instant = float(w[2])
_load_textures()
queue_redraw()
return true
func _load_textures() -> void:
textures.clear()
for element: Dictionary in screen.get("elements", []):
var paths: Array = [element.get("sprite", ""), element.get("focus_sprite", "")]
# The focus record's own elements carry their own sprites -- the ring is
# only reachable this way.
for fe: Dictionary in element.get("leaf", {}).get("elements", []):
paths.append(fe.get("sprite", ""))
for fe: Dictionary in element.get("focus", {}).get("elements", []):
paths.append(fe.get("sprite", ""))
for rel: String in paths:
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", {})
# While holding, stop at the hold: past it the group is ramping out, and a
# screen that has arrived and is sitting there is not leaving.
if holding:
# One instant for the whole screen where the disc gives a wide enough
# window; otherwise each element's own hold, which is what this port did
# everywhere until 2026-08-29.
t = settle_instant if settle_instant >= 0.0 else minf(t, settle_units(element))
# The exit. The final keyframe carries no `t` -- the disc has no slot for one
# -- so it is given a synthetic time `exit_ramp_units` after the last timed
# frame and then interpolated like any other. That keeps one code path: the
# difference between arriving and leaving is only how far `t` is allowed to
# run, not a second kind of animation.
#
# The whole group plays out, not just the fade quad: on the main menu
# pteff00 ramps to opaque black while the labels ramp to transparent and
# ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen
# was measured and refuted -- see authored/timing.json.
var last_frame: Dictionary = frames[frames.size() - 1]
if not last_frame.has("t"):
var exit_frame := last_frame.duplicate()
exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units
timed.append(exit_frame)
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),
"rotation_deg": _ilerp(a.get("rotation_deg", 0), b.get("rotation_deg", 0), 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
## How long one turn takes, in keyframe units, for an element that spins — or 0.
##
## The rule is STRUCTURAL and narrow: exactly two keyframes, differing in
## **nothing but** `rotation_deg`, by a full 360, with the first timed and the
## second untimed. The period is the first keyframe's declared `t`.
##
## Its disc-wide check, over this export: **16 of 212 elements match, and all 16
## are focus rings** — `ptbtneff01` on the five main-menu buttons and
## `ptbtneff02` on the three `EXTRAS` buttons, in both locales, every one of them
## declaring `t = 120`. Zero false positives. That matters because the rule is
## applied on the strength of a measurement taken on **one** button of one
## screen; a rule that also caught something else would be extrapolating from
## that measurement to elements nobody watched.
##
## ⚠️ It is a rule about SHAPE, not a decoded field. Nothing on the disc says
## "this loops". What the disc says is 0° → 360° over `t`; what the RE agent
## measured is that the turn repeats rather than stopping. Those are two
## different sources and the day a loop flag is decoded, this goes.
static func spin_period_units(element: Dictionary) -> float:
var frames: Array = element.get("keyframes", [])
if frames.size() != 2:
return 0.0
var a: Dictionary = frames[0]
var b: Dictionary = frames[1]
# 🔴 REWRITTEN for the corrected record layout, and it had SILENTLY STOPPED
# THE RING. The old rule required "the first timed and the second untimed",
# which was true when a group's data stopped short of its final time slot.
# Under the corrected layout every pose is timed -- the ring now reads
# `t=0 rot=0` then `t=120 rot=360` -- so `b.has("t")` was true, the rule
# returned 0, and the focus ring stopped spinning. Nothing reported it: a
# period of 0 is a legal "this element does not spin".
#
# `docs/port/BLOCKED.md` had listed `spin_period_units` among the five things
# the layout change touches. I checked `pose_at` and `exit_ramp_units` and
# did not work the list.
#
# The period is now the SPAN between the two poses rather than the first
# one's declared time. On the ring that is 120 - 0 = 120 units, the same
# number the old rule produced -- which is a small piece of evidence that the
# corrected layout is self-consistent rather than merely different.
if not a.has("t") or not b.has("t"):
return 0.0
for key in ["pos", "scale", "tint_rgba", "fade_argb"]:
if a.get(key) != b.get(key):
return 0.0
if absf(float(b.get("rotation_deg", 0)) - float(a.get("rotation_deg", 0))) != 360.0:
return 0.0
var t := float(b["t"]) - float(a["t"])
return t if t > 0.0 else 0.0
## 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
## The moment the screen has finished playing itself out, in keyframe units --
## the last element's final timed keyframe plus the authored exit ramp.
func exit_time() -> float:
var last := 0.0
for element: Dictionary in screen.get("elements", []):
var frames: Array = element.get("keyframes", [])
if frames.is_empty():
continue
var timed_end := 0.0
for k: Dictionary in frames:
if k.has("t"):
timed_end = maxf(timed_end, float(k["t"]))
if not frames[frames.size() - 1].has("t"):
timed_end += exit_ramp_units
last = maxf(last, timed_end)
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
## Draw one textured or solid quad, rotated about its pivot.
##
## The rotation anchor in design space is `pos + pivot`: `pos` is the top-left
## at 1:1, so the pivot point sits `pivot` in from it, and scaling about that
## point is exactly the `pos - pivot*(s-1)` rule the placement already uses.
##
## THE GAME DRAWS ROTATION. Confirmed twice by the RE agent, on different
## screens and different elements -- the title's `ptloop` sweeps declare +30/-45
## and a GPU capture submits them at +30.26/-45.28, and the main menu's focus
## ring ramps 0 -> 360 with everything else held constant, caught mid-spin in a
## capture. The comparison renderer does not draw it yet, so expect a title
## divergence that means "sylpheed-cli is behind", not "the port is broken".
##
## 🟡 The SIGN is an assumption: the decoder documents `+12` as
## clockwise-positive and Godot's 2D rotation is clockwise-positive in a y-down
## space, so this passes the value straight through. Not yet checked against a
## capture at a known angle.
func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2,
pos: Vector2, rotation_deg: float) -> void:
if is_zero_approx(rotation_deg):
if tex != null:
draw_texture_rect(tex, rect, false, colour)
else:
draw_rect(rect, colour, true)
return
var anchor := pos + pivot
draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE)
var local := Rect2(rect.position - anchor, rect.size)
if tex != null:
draw_texture_rect(tex, local, false, colour)
else:
draw_rect(local, colour, true)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
static func _rot_of(pose: Dictionary) -> float:
return float(pose.get("rotation_deg", 0))
## Draw a focus record's own elements -- the spinning ring and the bright label.
##
## The focused state is NOT a sprite swap. `ptbtn0Nf.rat` declares two elements,
## and the parent bundle declares NO element for the record at all, so the leaf
## is the only source of placement for both and there is nothing to inherit.
## The label is 13 px larger per axis than the base and sits at (-7,-7), which
## keeps the two concentric; drawing it at the base position pushes it 7 px
## down-right and off-centre.
## Draw an element's nested `.rat` leaf INSTEAD of the element itself.
##
## Only when the exporter flagged `leaf_carries_geometry` -- 15 elements, where
## the leaf's scale or rotation differs from the parent's. Everywhere else the
## leaf duplicates the parent and the parent wins, which is what this port has
## always done and which `screen.rs` documents for base records.
##
## ⚠️ **The leaf runs on its OWN timeline and the parent's alpha is NOT
## multiplied in.** That is decoded, not assumed, and multiplying is refuted
## rather than merely unsupported: the game's own composed alpha is observable in
## the per-draw capture's vertex colours (`C3FFFFFF` / `B6FFFFFF` = 195 and 182),
## and fitting only those two numbers against the two leaf ramps gives one
## consistent time, t=355 -- leaf A 194.8 against 195, leaf B 182.2 against 182.
## At t=355 the PARENT has expired: its group returns to 0 at t=250 and holds
## there, so `leaf x parent / 255` predicts zero for both quads and the sweeps
## would be invisible. They are drawn.
##
## The check that matters was PREDICTED, not fitted: no x entered it, and the
## same t=355 places the quad centres at 981 and 478 against 992.0 and 467.2
## measured off the capture -- ~11 px on quads travelling 1 560 and 1 950 px.
##
## ❔ Every observation behind this has parent alpha 0, so "the leaf wins" and
## "the parent is ignored because it draws nothing" are NOT separated. A capture
## during t=100...238 would separate them.
## Returns whether anything was actually drawn, so the caller can fall back.
func _draw_leaf(element: Dictionary) -> bool:
var any_drawn := false
for fe: Dictionary in element.get("leaf", {}).get("elements", []):
var rel: String = fe.get("sprite", "")
if rel == "":
continue
var tex: Texture2D = textures.get(rel)
if tex == null:
skipped.append("%s (leaf sprite failed to load)" % fe.get("id", ""))
continue
# UNCLAMPED, like the spinning ring and for the same reason: a sweep that
# crosses the frame does not stop because the screen has arrived, and
# `ORACLE-CAPTURES.md` says these two "move continuously". Held at its own
# `rest.t` the leaf sits at x=1521 -- entirely off the right edge -- so
# `holding` would delete the sweeps rather than settle them.
# A leaf replays its own group where the oracle has measured that it does
# -- `authored/rendering.json` `loop_leaf_on_screens`. The period is the
# leaf's own last keyframe time, which IS its declared length: these
# records carry zero slack, which is also why the loop-length field
# cannot tell "loops at 600" from "runs once for 600 and stops".
var was := holding
holding = false
var t := time_units
if loop_leaf:
var span := 0.0
for k: Dictionary in fe.get("keyframes", []):
if k.has("t"):
span = maxf(span, float(k["t"]))
if span > 0.0:
t = fposmod(time_units, span)
var pose := pose_at(fe, t)
holding = was
# 🔴 A SCALE-0 LEAF MUST NOT CLAIM THE DRAW. The Decoder hit this in its own
# renderer: its leaf branch marked the element drawn unconditionally, but
# the blit returns early on zero scale, so a scale-0 leaf suppressed its
# parent and BLANKED the element -- live on all four loading screens via
# `pgloading_loop5`, whose leaf is scale (0, 0).
#
# ⚠️ This port did not have the bug only because `authored/rendering.json`
# happens not to list `pgloading_loop5`. That is an accident of a gate
# written for a different reason, not a defence, so the guard is here: a
# leaf that would draw nothing reports so, and `_draw` falls back to the
# parent rather than losing the element.
var scale: Array = pose.get("scale", [100, 100])
if int(scale[0]) == 0 or int(scale[1]) == 0:
skipped.append("%s (leaf scale 0 -- parent drawn instead)" % fe.get("id", ""))
continue
var pivot := _vec(fe.get("pivot", [0, 0]))
_draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose),
pivot, _vec(pose.get("pos", [0, 0])), _rot_of(pose))
drawn.append(fe.get("id", ""))
any_drawn = true
return any_drawn
func _draw_focus(element: Dictionary) -> void:
var focus: Dictionary = element.get("focus", {})
var parent_id := String(element.get("id", ""))
for fe: Dictionary in focus.get("elements", []):
var rel: String = fe.get("sprite", "")
if rel == "":
continue
var tex: Texture2D = textures.get(rel)
if tex == null:
skipped.append("%s (focus sprite failed to load)" % fe.get("id", ""))
continue
# The ring spins, and until 2026-08-29 this drew it at 0 -- a pose the
# running game never shows -- because the PERIOD was the missing piece
# and a spin rate would have been invented.
#
# It is no longer invented. `docs/re/focus-ring-spin-measured.md`
# measures a continuous spin, period 2.177 s wall-clock, from eight
# evenly spaced autocorrelation peaks over nine revolutions, with NO
# angle estimated anywhere -- both angle estimators failed their own
# controls and were not used. It reconciles with the declared `t = 120`
# without a new constant: 120 units is 60 rendered frames, 2.00 s at a
# true 30 Hz and 2.08-2.17 s at the 27.6-28.8 fps that emulator runs.
#
# So the period comes off the DISC -- the element's own declared `t` --
# and what the RE agent supplied is that one turn takes exactly that
# long and repeats. See `spin_period_units` for the rule and its check.
var pose: Dictionary = fe.get("rest", {})
# An authored loop plays the record's OWN group on repeat instead of
# holding it at rest. `pose_at` already synthesises the final untimed
# keyframe at `exit_ramp_units`, so a loop is a modulo and nothing else --
# no new machinery and no new constant. `holding` is bypassed for the
# same reason the ring bypasses it: a thing that pulses does not stop
# because the screen has arrived.
var loop: Dictionary = looping_focus.get(parent_id, {})
if float(loop.get("period_units", 0.0)) > 0.0 \
and String(loop.get("record_element", "")) == String(fe.get("id", "")):
var was := holding
holding = false
pose = pose_at(fe, fposmod(time_units, float(loop["period_units"])))
holding = was
var pivot := _vec(fe.get("pivot", [0, 0]))
var pos := _vec(pose.get("pos", [0, 0]))
var period := spin_period_units(fe)
var rot := _rot_of(pose)
if period > 0.0:
# `time_units` raw, NOT the pose clamped by `holding`: a spinning
# ring is the one thing on the settled main menu that keeps moving,
# and the whole point of the finding is that it does not stop.
rot = 360.0 * fposmod(time_units, period) / period
_draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose),
pivot, pos, rot)
drawn.append(fe.get("id", ""))
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 pos := _vec(pose.get("pos", [0, 0]))
var rot := _rot_of(pose)
# An element whose LEAF carries the geometry draws the leaf instead of
# itself: the parent is a container whose own record has identity scale
# and rotation. See `_draw_leaf`.
if element.get("leaf_carries_geometry", false) \
and draw_leaf_for.has(String(element.get("id", ""))) \
and _draw_leaf(element):
continue
# A focused button draws its own record instead of its base sprite -- and
# so does an element the authored table says always shows it, which is
# how the PRESS (A) plate gets its glow on a screen that has no buttons
# and focuses nothing.
if (focused_id == id or looping_focus.has(id)) and element.has("focus"):
_draw_focus(element)
continue
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)
_note_structural("%s (sprite failed to load)" % id)
continue
_draw_quad(tex, placement(pose, pivot, tex.get_size()), colour, pivot, pos, rot)
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_quad(null, placement(pose, pivot, _vec(element["size"])), colour, pivot, pos, rot)
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)
_note_structural("%s (no sprite in the export)" % id)
## Record a skip that is NOT ordinary animation, and SAY SO, once per screen.
##
## It prints from here rather than returning a value for a caller to report,
## because "the caller will report it" is precisely what did not happen: the
## per-frame `skipped` list has been correct and unread since P1. A fact that
## needs somebody else to remember to look at it is a fact that goes unnoticed.
func _note_structural(what: String) -> void:
if not structural_skips.has(what):
structural_skips.append(what)
push_warning("element not drawn: %s" % what)
print(" 🔴 element NOT DRAWN: %s" % what)