The Decoder's finding, applied. `rest()` returns each element's last hold keyframe chosen independently of every other element: right for anything that ends the screen settled, 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() left it burning -- five of them, drawn at once. The settled instant is the longest interval containing no keyframe time, over a bundle's TOP-LEVEL elements. Reproduced here before adopting: title [160, 236], midpoint 198, the Decoder's number to the unit. The top-level restriction is what makes it match -- including the ptloop leaves gives [269, 540]. AGAINST THE ORACLE: title 20.92 RMSE 1.81% -> 14.61 RMSE 0.26% publisher_logo 9.05 0.75% -> 2.17 0.01% developer_logos 8.86 0.33% -> 3.05 0.01% main_menu 0.08% -> 0.08% window too narrow extras 0.19% -> 0.19% window too narrow Seven times fewer differing pixels on the title, seventy-five times fewer on the publisher splash, whose differing region is now a 13x18 box. The largest correctness gain this port has had, and none of it is mine -- it is a decode computed from the keyframe table with no reference to any capture. APPLIED ONLY WHERE THE WINDOW IS WIDE, and the bar is not invented. This export's widths split with nothing in between: 214, 190, 145, 76, then 12, 12, 8, 4. The bar is 30 units -- the Decoder's disc-wide census puts the knee there (30% of bundles >= 30, 42% under 10, the latter mostly loop* fragments meant to be in motion) and this export's screens sit 4x either side with nothing between 12 and 46. Two independent populations agreeing on where to cut. Checked unbroken: boot pacing unmoved, scripted walk runs end to end with focus restored. Also recorded: my "34 focus-record elements, only 2 varying" is right for GP_TITLE and reads as a fact about the format -- disc-wide it is 210 varying, 202 with rest() at the peak, concentrated in the paks a wider port reaches next. And their sharper framing, which I have adopted: a pulsing element has no resting pose at all, so rest() is MALFORMED rather than mis-answered on one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
603 lines
27 KiB
GDScript
603 lines
27 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 = []
|
|
|
|
## 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] = []
|
|
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.
|
|
var was := holding
|
|
holding = false
|
|
var pose := pose_at(fe, time_units)
|
|
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)
|
|
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)
|