The Decoder decoded the rule I refused to guess: draw the leaf on its own timeline, do NOT multiply the parent's alpha in. Multiplying is refuted rather than unsupported -- at the fitted time the parent has expired, so leaf x parent predicts zero for both quads and the sweeps would be invisible. They are drawn. Implemented: `_draw_leaf` runs the leaf unclamped, like the spinning ring and for the same reason -- 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. AND IT CHANGES NOTHING MEASURABLE. The title is still 1.82% against the oracle: 1.82 at t=261, 1.81 at t=355, 1.79 at t=420. At t=355 my interpolation puts the leaf's top-left at x ~ -324, off-screen left, where the Decoder's model puts the quad's CENTRE at 981. Those cannot both be right, and it is not something to tune away -- it is a disagreement about how the leaf's keyframes become a placed quad, most likely in the pivot and the rotation about it. Handed back with both numbers. So: the exporter no longer drops the data, the composition rule is implemented as decoded, and the port's largest oracle gap is exactly where it was. Fixing the export was necessary and not sufficient. TWO FLAGGED ELEMENTS DELIBERATELY NOT DRAWN, in authored/rendering.json with reasons. title_jp/ptlogo_eff2 (parent 125%, leaf 100%) is the same shape and is the element DECISIONS has recorded since P1 as the largest render disagreement -- but the Decoder said plainly "I have not tested it", and drawing it would extend a decode past the case it was fitted on. pgloading_loop5's leaf is scale (0,0), and scale-0 is one of the three historical failures this corpus names. Neither can be adjudicated here: title_jp has no oracle capture, and verify-screen compares against a renderer that draws no leaves at all, so ANY leaf drawing increases that divergence whether right or wrong. Its max went 155 -> 232 when they were drawn, and that number is not evidence in either direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
540 lines
24 KiB
GDScript
540 lines
24 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.
|
|
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])
|
|
_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:
|
|
t = 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]
|
|
if not a.has("t") or 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(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.
|
|
func _draw_leaf(element: Dictionary) -> void:
|
|
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
|
|
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", ""))
|
|
|
|
|
|
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", ""))):
|
|
_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)
|