Files
Sylpheed/port/scripts/screen_view.gd
Sylpheed port agent dbd75bcc56 port: sweep for their tell -- two descriptions my own code had already refuted
Their sweep cleared my three quoted figures: the splash dwell spans were corrected
today, and the gap and cold-boot intervals are capture-derived, which the
record-layout fix cannot reach.

Their sharpening -- the tell is not that a number changed but that a page hedges a
quantity it should not need to -- made a sweep of my side possible. The marker is
'untimed', since there are 0 untimed keyframes in 866. Two hits, both worse than
hedges: descriptions their own code had already refuted.

Hit A: spin_period_units' doc stated the rule as 'the first timed and the second
untimed', the pre-fix version, while the body directly below carries a block
explaining that this rule silently stopped the ring and implements the span-based
replacement. A function and its documentation disagreeing, with the documentation
stating the refuted version.

Hit B: exit_ramp_units' doc claimed the value is authored in timing.json and that
the disc has no time slot on that keyframe. Both false -- the entry was deleted as
progress and the corrected layout times every pose. And I wrote the correction
myself, immediately below it, two iterations ago without looking up.

Both are fixed code under an unfixed description, with the fix and the falsehood
within twenty lines of each other -- not drift, but editing at the point of failure
without re-reading the frame around it. Their practice of quoting the original
sentence is adopted so the change is visible.

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

778 lines
37 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 a final UNTIMED keyframe -- a shape this export no
## longer contains (866 keyframes across 16 screens, **0** untimed).
##
## 🔴 THE TWO SENTENCES THAT WERE HERE ARE PRE-FIX AND I LEFT THEM WHEN I FIXED
## THE CODE BELOW. They read: *"Authored (`authored/timing.json`): the disc has no
## time slot on that keyframe, so this is the one unknown duration per screen."*
## Both halves are now false — the authored entry was DELETED as progress, and
## the corrected record layout times every pose, so there is no unknown to
## author. The correction lived immediately below while the claim stayed on top.
## Synthetic duration for a group's final UNTIMED keyframe.
##
## 🔴 NEGATIVE MEANS "NOT SUPPLIED", AND THAT IS NOW THE DEFAULT. It used to
## default to **24.0** -- the exact constant HANDOFF ask 2 told this port to
## author and that the port refused, because the file's own ramp is 10 units and
## authoring 24 would run the fade 2.4x too long. The authored entry was deleted
## as progress when the corrected record layout removed the unknown; the default
## quietly put the refuted number back where nobody would look for it.
##
## The branch is kept so an older export still loads, but it no longer INVENTS a
## duration: if a group really does end untimed, the port says so and declines to
## make one up, which is the same choice `black_hold_units` and
## `input_during_transition` make in `authored/timing.json`.
##
## Unreachable on today's export -- 866 keyframes across 16 screens, 0 untimed.
var exit_ramp_units: float = -1.0
var _warned_untimed := false
## 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
## Set when a caller pinned an EXPLICIT instant (`--time=`), which then wins over
## `settle_instant`.
##
## 🔴 Without this, `--time=` was silently ignored on every screen with a settle
## window of 30 units or more, because `pose_at` overwrote the requested `t` with
## `settle_instant` whenever `holding` was true. The flag parsed, the log printed
## the time asked for, and the pose came from somewhere else.
##
## `press_start` is the case that exposed it. Its window is [0, 214] -- the long
## dead stretch BEFORE the plate appears -- so its settle instant is t=107, where
## `ptbtn00` is alpha 0. The plate's only opaque frames are t=236-238. The result
## was that the `PRESS (A)` plate could not be rendered **at any time at all**:
## every instant anyone asked for was answered at t=107, and the screen came back
## empty with `ptbtn00 (transparent at rest)`.
##
## The settle instant is still right for a screen that has ARRIVED and is sitting
## there, which is what it was measured for. It is not right as an answer to a
## question about a different instant.
var frozen := false
var looping_focus: Dictionary = {}
## Pin the looping record's phase instead of taking it from `time_units`.
##
## 🔴 WHY THIS EXISTS. The pulse is CORRECT -- a thing that pulses does not stop
## because the screen has arrived -- but it rides the wall clock, so a captured
## frame lands wherever the grab happened to fall. `verify-screen press_start`
## returned `over3` **5021, 8919, 5021** on three identical runs: a regression
## detector that answers differently each time teaches its reader to ignore it.
##
## The port is not the thing that is wrong here, so the port's behaviour does not
## change: negative means "free-running", which stays the default everywhere. The
## HARNESS pins a phase so the comparison is deterministic.
var loop_phase_units: float = -1.0
## 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
## Pin the LEAF's phase independently of the screen's pose, in units. -1 = off.
##
## Built because a measured value could not be tested. The Decoder's refined fit
## for the `ptloop` sweeps is t=357.7 units, and `verify-capture` passed it as
## `--time=5.9617` -- which poses the WHOLE SCREEN there. The title's own group
## ends at t=269, so that fades everything out and scores 30.97 % against the
## capture. The instant was only ever about the sweeps, whose leaf runs to t=600.
##
## So the fit was untestable: the only way to ask for it also destroyed the rest
## of the frame. This separates the two clocks -- the screen sits at its settled
## pose, the leaf is placed at whatever phase is being tested.
var leaf_time_units: float = -1.0
## 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.
# `frozen` means a caller pinned an EXPLICIT instant and wants THAT instant,
# not the settled pose and not a per-element clamp. Both clamps are skipped.
if holding and not frozen:
# 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"):
if exit_ramp_units < 0.0:
if not _warned_untimed:
_warned_untimed = true
push_error("%s has an untimed final keyframe and no exit_ramp_units was supplied. "
% [screen.get("name", "?")]
+ "Not inventing one: the group ends at its last timed frame. "
+ "This export predates the corrected record layout -- re-export it.")
else:
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. The period is the SPAN between
## the two poses.
##
## 🔴 THIS PARAGRAPH DESCRIBED THE PRE-FIX RULE WHILE THE BODY BELOW IMPLEMENTED
## THE CORRECTED ONE. It read: *"with the first timed and the second untimed. The
## period is the first keyframe's declared `t`."* Under the corrected record
## layout every pose is timed, so `b.has("t")` is always true, that rule returns
## 0, and the ring stops spinning — which is exactly the failure the body's own
## comment records and fixes. A doc comment and its function contradicting each
## other, with the doc stating the refuted version.
##
## 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") and exit_ramp_units >= 0.0:
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 := leaf_time_units if leaf_time_units >= 0.0 else 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(t, 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
var lt: float = time_units if loop_phase_units < 0.0 else loop_phase_units
pose = pose_at(fe, fposmod(lt, 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.
#
# 🔴 WHICH MADE THE ORACLE HARNESS NONDETERMINISTIC, and I quoted its
# numbers for many iterations without noticing. `verify-capture`'s
# `main_menu` row read RMSE 13.30 / 13.27 / 13.25 / 13.26 across
# runs -- the ring's angle at the moment of capture -- while
# `extras`, `title` and both splashes are identical to the digit.
#
# `loop_phase_units` already pins the LOOPING FOCUS RECORD phase for
# the same reason; the spin is a second free-running clock and needs
# the same pin. Negative still means free-running, which is what a
# player gets. Only the harnesses pass it.
var st: float = time_units if loop_phase_units < 0.0 else loop_phase_units
rot = 360.0 * fposmod(st, 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:
# 🔴 THIS LINE USED TO SAY "at rest" WHATEVER INSTANT IT HAD POSED.
#
# On the timeline path the pose is `pose_at(time_units)`, not
# `rest`, and on the screens where those differ the message named a
# pose it had not looked at. `palogo_sqex_eff` on the publisher
# splash is `[0:a0 15:a255 30:a212 45:a0]` -- a flash whose `rest`
# alpha is **212**. The port skips it correctly at the settled
# instant and then reported "transparent at rest" about a resting
# pose that is four-fifths opaque.
#
# ⚠️ That is not cosmetic. The rest-versus-posed-instant confusion is
# exactly what made me score a `--pose=rest` frame against a capture
# and write up a drift that did not exist (DECISIONS.md). A log line
# that erases the distinction is the same error, pre-printed.
skipped.append("%s (transparent %s)" % [id,
"at rest" if pose_mode == Pose.REST else "at t=%.0f" % time_units])
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 record INSTEAD of its base sprite -- measured,
# the focused sprite covers the base at 100.0 % of base-visible pixels.
if focused_id == id and element.has("focus"):
_draw_focus(element)
continue
# 🔴 A LOOPING record draws IN ADDITION to the base, not instead of it.
#
# This used to take the same branch as a focused button, and that is why
# the authored entry for the `PRESS (A)` plate had to be deleted: it
# substituted a dim glow for the plate's own bright sprite and the plate
# became invisible at every instant (max 0 against max 252.5).
#
# The Decoder has since MEASURED the real behaviour -- held at the title
# with no input, the plate oscillates continuously for ~23 cycles with no
# decay and NEVER GOES OFF, bottoming at 714 thresholded green pixels
# against a plate-absent floor of 159. A glow alone cannot do that: its
# record ramps 0 -> 80 -> 0. A steady base plus a pulsing glow can, and
# the two numbers line up with base-only and base-plus-glow.
#
# So the base is drawn first and the record over it. `_draw_focus` runs
# after, with no `continue`.
var loops_focus := looping_focus.has(id) and element.has("focus")
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)
if loops_focus:
_draw_focus(element)
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)