The 2026-09-02 play-test: "the logos just switch, I cannot discern any animation
at all." Reproduced, diagnosed, fixed, and gated by a film.
REPRODUCED FIRST, as instructed. tools/motion-census needed Pillow, which this
container has no pip for, so it got an ImageMagick fallback that shims only the
four Pillow calls it uses -- the census arithmetic, the MOVED floor and the GRID
are untouched. Its --selftest passes on that backend with the human's own
numbers: fade 97.4 %, switch 2.6 %, frozen 0.0 %. A shim that distorted pixels
would fail its own control.
publisher 0.30 s moving then 3.30 s FROZEN (human: 0.30 then 3.20)
developer 0.40 + 0.25 split then 2.45 FROZEN (human: 0.35 + 0.25 then 2.40)
Matched to within a frame.
THE CLOCK WAS NEVER THE PROBLEM. view_units advances 2.8-3.0 per frame, smooth,
~60 units/s, no stalls -- the play-test's candidate list can drop "the group
clock not integrating" and "advancing by keyframe index".
THE POSE WAS. Measuring the sharp logo's own rect frame by frame: 0.40549 flat
from unit 7.9 through 28.2 -- the same value it holds at 45 and beyond. It was
already FULL before its declared ramp (15 -> 30) began.
Cause, in ScreenView.pose_at:
t = settle_instant if settle_instant >= 0.0 else minf(t, settle_units(element))
The comment above it says "stop at the hold". The else-branch clamps. This half
ASSIGNS, so from a screen's first frame every element was posed at the settled
instant and no build-in was ever drawn. The asymmetry is the whole defect, and
`--time` sets `frozen`, which skips the clamp -- which is exactly why my frozen
sweep "proved" the companions were drawn and proved nothing about running.
Fix: `minf(t, settle_instant)`. One operator.
⚠️ AND THE HOLD IS NOT THE BUG. The Decoder measured the game holding one picture
for 3.34 s on this screen -- LONGER than the port's 3.30 -- because palogo_sqex
declares 205 of its 255 units as a flat plateau. The play-test's "a fade does not
hold one picture for 3.20 s" would have sent me to delete the one correct part.
Clamping keeps the plateau exactly.
GATED BY A FILM, not a still:
before after game (Decoder)
publisher build-in 0.30 s 0.60 s
developer build-in 0.40+0.25 0.95 s continuous
splash moving 12.0 % 24.8 % 21.2 % / 27.8 %
distinct luma states 120 152
longest frozen 3.30 s 3.30 s 3.34 s
🔴 AND IT LOOKED LIKE A 10x REGRESSION AGAINST THE ORACLE, WHICH IT WAS NOT.
verify-capture went publisher 2.17 -> 22.58, title 14.11 -> 67.07. Cause: it
shoots two frames after load and got the settled pose ONLY because pose_at
assigned it. Its own comment says so -- "the 0.01 % agreements on both splashes
were measured through that accident."
So the `--screen --capture` path now advances the clock to the settle instant
explicitly before shooting, which is what the tool was always asking for. Guarded
on `not _frozen`: `--time` means the caller wants THAT instant, and overriding it
would reintroduce the silent-ignore this replaces.
Every oracle row is back to its pre-fix value to the digit: publisher 2.17
(0.01 %), developer 3.05 (0.01 %), title 14.11, main_menu 13.02, extras 13.10,
title_plate 13.04. The port animates AND still matches the settled captures.
Not settled: motion-census is not yet wired into check-all -- next, and
deliberately not rushed at the end of a long iteration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AHUQvXGyNcKonSEWsgWcX
955 lines
46 KiB
GDScript
955 lines
46 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/port/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 = []
|
|
|
|
## Elements the game draws ADDITIVELY, by screen -- `authored/rendering.json`
|
|
## The canvas items backing the paint-order runs. See `_band`.
|
|
var _bands: Array[RID] = []
|
|
## The canvas item the next `_draw_quad` paints into. It is a member rather than
|
|
## a parameter because every draw funnels through `_draw_quad` from three call
|
|
## sites, and threading a RID through `_draw_leaf` and `_draw_focus` would change
|
|
## their signatures to carry a value neither of them chooses.
|
|
var _target: RID = RID()
|
|
## 🔴 THIS USED TO BE `CanvasItemMaterial.new()` AND NOTHING ELSE, so the blend
|
|
## mode was its default, MIX. Every band was created, ordered and assigned
|
|
## correctly and the screen composited exactly as before: `ptframe1` moved from
|
|
## 22.72 to 22.69. That is the failure mode this port keeps meeting -- the change
|
|
## ran, produced a number, and the number was WRONG BY BEING RIGHT-LOOKING. It was
|
|
## caught only because the measurement predicted a large move and a 0.03 move is
|
|
## not one.
|
|
var _additive_material := _make_additive()
|
|
|
|
|
|
static func _make_additive() -> CanvasItemMaterial:
|
|
var m := CanvasItemMaterial.new()
|
|
m.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
|
|
return m
|
|
|
|
## 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.
|
|
# 🔴 THIS WAS AN ASSIGNMENT AND THE COMMENT ABOVE SAYS "STOP AT". It read
|
|
# `t = settle_instant ...`, so from the screen's FIRST FRAME every element
|
|
# was posed at the settled instant and the build-in was never drawn. The
|
|
# else-branch beside it always clamped; only this half did not, and the
|
|
# asymmetry is the whole defect.
|
|
#
|
|
# A human on a 140 fps GPU: "the logos just switch, I cannot discern any
|
|
# animation at all." Filmed and measured with `tools/motion-census`: the
|
|
# sharp logo's region sat at 0.40549 from unit 7.9 through 28.2 -- the
|
|
# same value it holds at 45 and beyond -- while its declared ramp is
|
|
# 15 -> 30. It was already full before its ramp began.
|
|
#
|
|
# ⚠️ THE HOLD IS NOT THE BUG AND MUST SURVIVE THIS. The Decoder measured
|
|
# the game holding one picture for 3.34 s on this screen -- LONGER than
|
|
# the port's 3.30 -- because `palogo_sqex` declares 205 of its 255 units
|
|
# as a flat plateau. The deficit was only ever in the ramps. Clamping
|
|
# rather than assigning keeps the plateau exactly and restores the ramp.
|
|
t = minf(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.
|
|
## The cycle length of a looping focus record, **derived in preference to authored**.
|
|
##
|
|
## The record header's `+0x08` says where the cycle restarts, and it is not the
|
|
## last keyframe's time: the plate's glow ramps 0→80→0 over 105 units inside a
|
|
## 120-unit cycle and rests dark for 15. The exporter now carries it as
|
|
## `focus.loop_length_units`, so the period comes off the DISC.
|
|
##
|
|
## `authored/timing.json` had 120 already, from a wall-clock measurement of the
|
|
## running game (≈2.37 s). **The two agree**, which is why this is a provenance
|
|
## change and not a pixel change — an emulator stopwatch and a field on the disc,
|
|
## sharing no instrument, landing on the same number. The authored value stays as
|
|
## the fallback and as that second witness.
|
|
##
|
|
## A DISAGREEMENT IS ANNOUNCED, never silently resolved. Preferring one number
|
|
## without saying so is how a measurement and a declaration drift apart for
|
|
## milestones without anybody learning that they had.
|
|
func _loop_period(focus: Dictionary, loop: Dictionary) -> float:
|
|
var authored := float(loop.get("period_units", 0.0))
|
|
var derived := float(focus.get("loop_length_units", 0.0))
|
|
if derived <= 0.0:
|
|
return authored
|
|
if authored > 0.0 and absf(derived - authored) > 0.5:
|
|
push_warning("focus record %s: the disc declares a %.0f-unit cycle, `authored/timing.json` says %.0f -- using the disc. One of them is wrong and this message is the only thing that will say so." % [String(focus.get("record", "?")), derived, authored])
|
|
return derived
|
|
|
|
|
|
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:
|
|
var ci := _target if _target.is_valid() else get_canvas_item()
|
|
if is_zero_approx(rotation_deg):
|
|
_add_quad(ci, tex, rect, colour)
|
|
return
|
|
var anchor := pos + pivot
|
|
RenderingServer.canvas_item_add_set_transform(ci,
|
|
Transform2D(deg_to_rad(rotation_deg), anchor))
|
|
_add_quad(ci, tex, Rect2(rect.position - anchor, rect.size), colour)
|
|
RenderingServer.canvas_item_add_set_transform(ci, Transform2D())
|
|
|
|
|
|
func _add_quad(ci: RID, tex: Texture2D, rect: Rect2, colour: Color) -> void:
|
|
if tex != null:
|
|
RenderingServer.canvas_item_add_texture_rect(ci, rect, tex.get_rid(), false, colour)
|
|
else:
|
|
RenderingServer.canvas_item_add_rect(ci, rect, colour)
|
|
|
|
|
|
## 🔴 WHY THE DRAWING GOES THROUGH `RenderingServer` AND NOT `draw_texture_rect`.
|
|
##
|
|
## Godot sets the blend mode on a CANVAS ITEM, not on a draw call, so an additive
|
|
## element cannot simply be drawn differently inside one `_draw()`. The measured
|
|
## fact is per element (`authored/rendering.json` `additive_elements`), so the
|
|
## screen is split into RUNS of consecutive paint-order entries sharing a blend
|
|
## mode and each run gets its own canvas item, ordered by `canvas_item_set_draw_index`.
|
|
##
|
|
## ⚠️ The obvious implementation -- child `Node2D`s with a `CanvasItemMaterial`
|
|
## each -- LOSES A FRAME. `boot.gd` calls `view.queue_redraw()` from nine places
|
|
## and none of them reaches a child node, so the bands would paint the previous
|
|
## pose. A capture taken with `--script=wait` would have shown that as a plausible
|
|
## wrong answer rather than as an error. These items are filled synchronously
|
|
## inside `_draw()` instead, so there is no second node to keep in step.
|
|
## 🔴 CANVAS ITEMS MADE THROUGH `RenderingServer` ARE NOT OWNED BY THE NODE, and
|
|
## the first version of this file did not free them: Godot printed
|
|
## `5 RIDs of type "CanvasItem" were leaked` on every exit -- exactly the number of
|
|
## paint-order runs on the main menu. A node-owned child would have been collected
|
|
## for me; the reason for using the server directly is in `_band`, and this is its
|
|
## price. `_exit_tree` rather than `NOTIFICATION_PREDELETE` because the items are
|
|
## parented to this node's canvas item, which goes when the node leaves the tree.
|
|
func _exit_tree() -> void:
|
|
for ci: RID in _bands:
|
|
RenderingServer.free_rid(ci)
|
|
_bands.clear()
|
|
|
|
|
|
func _band(i: int, additive: bool) -> RID:
|
|
while _bands.size() <= i:
|
|
var ci := RenderingServer.canvas_item_create()
|
|
RenderingServer.canvas_item_set_parent(ci, get_canvas_item())
|
|
_bands.append(ci)
|
|
var item: RID = _bands[i]
|
|
RenderingServer.canvas_item_clear(item)
|
|
RenderingServer.canvas_item_set_draw_index(item, i)
|
|
RenderingServer.canvas_item_set_material(item,
|
|
_additive_material.get_rid() if additive else RID())
|
|
return item
|
|
|
|
|
|
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, _loop_period(focus, loop)))
|
|
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()
|
|
# The runs are computed from the paint order every frame rather than cached,
|
|
# because the additive elements happen to be CONSECUTIVE on both screens that
|
|
# have a measurement and that is an accident of those two screens. A cache
|
|
# keyed on "the additive block" would be correct today and silently wrong on
|
|
# the first screen that interleaves.
|
|
# 🔴 THE AUTHORED MAP IS GONE. `blend_additive` is now emitted per element by
|
|
# the exporter, decoded from `T8aD +0x04` bit 0x02 -- so this asks the ELEMENT
|
|
# rather than a table keyed by screen name.
|
|
#
|
|
# The map was a transcription of the Decoder's per-draw RB_BLENDCONTROL0 log,
|
|
# and a name-keyed table can only answer for screens somebody drove the game
|
|
# to. Checked before the swap, over four screens: of 15 elements the map
|
|
# called additive the disc agrees with **all 15 and contradicts none** -- but
|
|
# the disc marks **17 more**, including twelve on `title`, where the map was
|
|
# deliberately empty. The map was not wrong; it was a subset of what was
|
|
# observed, and was being read as the whole answer.
|
|
var order: Array = screen.get("paint_order", [])
|
|
# 🔴 BANDS ARE PER DRAW OP, NOT PER ELEMENT, and the plate is why. `ptbtn00` is
|
|
# drawn alpha-over and its own focus record `ptbtn00f` ADDITIVE -- same screen,
|
|
# same element, adjacent draws, measured off the GPU. One band per paint-order
|
|
# entry cannot express that, and the first version of this file could not draw
|
|
# the PRESS (A) plate's pulse at all: both halves went through the base's band.
|
|
var band_of := {}
|
|
var band_additive: Array[bool] = []
|
|
var prev := -1
|
|
for index: int in order:
|
|
var el: Dictionary = elements[index]
|
|
var eid := String(el.get("id", ""))
|
|
var parts: Array = [[index, "base"], [index, "focus"]] if el.has("focus") \
|
|
else [[index, "base"]]
|
|
for part: Array in parts:
|
|
# Which DECLARATION the blend bit comes from depends on what this
|
|
# band actually draws:
|
|
# focus -> the focus record's own sprite (ptbtn00f, additive,
|
|
# while its base ptbtn00 is not -- the case that forced
|
|
# bands to be per draw op rather than per element);
|
|
# base -> the LEAF's sprite when this element draws its leaf,
|
|
# otherwise the element's own.
|
|
# That last line is not a detail: `ptloop01`/`ptloop02` are in
|
|
# `draw_leaf_for`, so what reaches the screen is `pteff03`/`pteff03a`,
|
|
# and those carry the bit while the parents the old map listed are not
|
|
# what was drawn.
|
|
var src: Dictionary = el
|
|
if part[1] == "focus":
|
|
var fes: Array = el.get("focus", {}).get("elements", [])
|
|
if not fes.is_empty():
|
|
src = fes[0]
|
|
elif draw_leaf_for.has(eid):
|
|
var les: Array = el.get("leaf", {}).get("elements", [])
|
|
if not les.is_empty():
|
|
src = les[0]
|
|
# Absent means the sprite resolves to no T8aD header -- a `.prm`
|
|
# primitive has no header and so no blend bit. Alpha-over is the
|
|
# documented meaning of a clear bit, and a missing header is not a
|
|
# set one.
|
|
var add_it: bool = bool(src.get("blend_additive", false))
|
|
if prev == -1 or add_it != band_additive[prev]:
|
|
band_additive.append(add_it)
|
|
prev += 1
|
|
band_of[[index, part[1]]] = prev
|
|
for i in band_additive.size():
|
|
_band(i, band_additive[i])
|
|
# Runs left over from a screen with more of them would still hold last
|
|
# frame's commands and paint over this one.
|
|
for i in range(band_additive.size(), _bands.size()):
|
|
RenderingServer.canvas_item_clear(_bands[i])
|
|
for index: int in order:
|
|
var element: Dictionary = elements[index]
|
|
var id: String = element.get("id", "")
|
|
_target = _bands[band_of[[index, "base"]]]
|
|
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"):
|
|
_target = _bands[band_of[[index, "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:
|
|
_target = _bands[band_of[[index, "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)
|