port: land the play-tested work, and only that

Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
This commit is contained in:
MechaCat02
2026-09-04 16:17:14 +02:00
parent ad96fe97b8
commit a23c321831
76 changed files with 32972 additions and 346 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,15 @@ const FORMAT_SCREEN := "sylpheed.screen/3"
const FORMAT_MANIFEST := "sylpheed.manifest/1"
var root: String = ""
## The override tree, or "" when there is none. MODDING rule 4: a mod replaces a
## file by SHADOWING ITS PATH, so `mods/screens/title/main_menu.json` stands in
## for `<root>/screens/title/main_menu.json` and nothing under the derived tree
## is touched. That is what makes re-exporting always safe.
var mods: String = ""
## Relative paths a mod actually replaced this run, in the order they were first
## read. Recorded because MODDING says "did I break it?" is answered by disabling
## a mod -- which only works if a modded run does not look like an unmodded one.
var shadowed: Array[String] = []
var error: String = ""
@@ -27,9 +36,114 @@ static func locate() -> ExportTree:
t.error = "no manifest.json under %s -- run `sylpheed-export` first" % candidate
return t
t.root = candidate
# The override tree. `SYLPHEED_MODS` wins for the same reason
# `SYLPHEED_EXPORT` does; otherwise `data/mods/`, which is the directory
# MODDING.md's own layout diagram names and the one this repository ships.
#
# Absent is normal and silent: an unmodded run is the common case, and a
# warning about a directory nobody created would be noise.
var m := OS.get_environment("SYLPHEED_MODS")
if m == "":
m = ProjectSettings.globalize_path("res://").path_join("../data/mods").simplify_path()
if DirAccess.dir_exists_absolute(m):
t.mods = m
return t
## Where a relative path actually comes from: the mod tree if it has one, else
## the derived tree.
##
## Every read in this class goes through here, so a mod can replace a screen's
## JSON, a sprite, a cue, a music bed or a movie by dropping a file at the same
## relative path. There is deliberately no manifest of what a mod contains and no
## registration step -- the path IS the registration, which is the whole of
## MODDING rule 4.
##
## ⚠️ One tree, not a stack. Several mods layering over each other needs an
## order, and an order needs a rule nobody has asked for yet. Say so rather than
## invent one.
func resolve(rel: String) -> String:
if mods != "":
var over := mods.path_join(rel)
if FileAccess.file_exists(over):
if not shadowed.has(rel):
shadowed.append(rel)
# Announced the moment it happens, not summarised at startup.
# The first version printed a summary in `_ready`, before a
# single asset had been read, so it always said "nothing
# shadowed yet" -- a report that is structurally incapable of
# reporting anything is worse than none, because it looks like
# an answer.
print("mod: %s <- %s" % [rel, over])
return over
return root.path_join(rel)
## Mod files that were never used, listed at the end of a run.
##
## 🔴 A MISTYPED OVERRIDE WAS SILENT. `resolve` announces every shadow as it
## happens -- that half was already right, and its comment records why a startup
## summary was wrong. What nothing reported was the opposite: a file sitting in
## `data/mods/` whose path matches no asset. Measured: `sprites/title/main_menu/`
## is announced, `sprites/title/TYPO_menu/` produces **no output at all**. The
## modder sees the port load, run, and say nothing about the file that did
## nothing.
##
## That is MODDING rule 4's own failure mode -- base-and-overrides is only usable
## if an override that misses says so -- and it is the same shape as the
## checkers that passed on an empty input: **agreeable rather than wrong.** A
## port that cannot tell "your override is in effect" from "your override was
## never looked at" is unusable for the person the asset tree exists for.
##
## ⚠️ Reported at the END of a run, not at startup: resolution is lazy, so before
## the assets are read there is nothing to compare against. A run that quits
## early will list files a longer run would have used, and the wording says so
## rather than calling them errors.
func unused_mods() -> PackedStringArray:
var out: PackedStringArray = []
if mods == "":
return out
var stack: PackedStringArray = [""]
while not stack.is_empty():
var rel := stack[stack.size() - 1]
stack.remove_at(stack.size() - 1)
var dir := DirAccess.open(mods.path_join(rel))
if dir == null:
continue
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
var child := rel.path_join(name) if rel != "" else name
if dir.current_is_dir():
stack.append(child)
elif not shadowed.has(child):
# 🔴 TWO DIFFERENT THINGS, and reporting them as one produced a
# permanent false positive on the mods directory's own README.
# A file whose path exists in `export/` was simply not read this
# run -- a `--menu` run touches one screen. A file whose path
# exists NOWHERE in the export can never shadow anything: that
# is the mistyped override, and it is the only one that is a
# defect. A report with a standing false positive becomes
# scenery, which is the failure this whole report exists to fix.
# ⚠️ And a THIRD category, excluded by extension with the rule
# stated rather than assumed: the export tree contains only
# `png`, `json`, `ogg`, `ogv` and `cmd` files -- checked, no
# `.md` anywhere -- so a `.md` in `data/mods` cannot shadow
# anything BY CONSTRUCTION and is documentation, not a failed
# override. Flagging a class that could never be an override is
# noise, and a report with a permanent false positive is one
# nobody reads. `data/mods/README.md` is the standing case.
var ext := child.get_extension().to_lower()
if ext in ["png", "json", "ogg", "ogv", "cmd"] \
and not FileAccess.file_exists(root.path_join(child)):
out.append(child)
name = dir.get_next()
dir.list_dir_end()
out.sort()
return out
# `authored/` sits beside `export/`, never inside it: it is hand-written and
# committed, and a re-export must not be able to touch it.
func authored(name: String) -> Variant:
@@ -42,7 +156,7 @@ func authored(name: String) -> Variant:
func read_json(rel: String) -> Variant:
var path := root.path_join(rel)
var path := resolve(rel)
var text := FileAccess.get_file_as_string(path)
if text == "":
error = "cannot read %s" % path
@@ -88,11 +202,20 @@ func screen(name: String) -> Dictionary:
func video(name: String) -> Dictionary:
for entry: Dictionary in manifest().get("videos", []):
if entry.get("name") == name:
var path := root.path_join(entry["file"])
var path := resolve(String(entry["file"]))
if not FileAccess.file_exists(path):
error = "manifest lists %s but %s is not there" % [name, path]
return {}
return {"path": path, "command": entry.get("command", "")}
# `duration_s` and `fps` come with it so a run can report what it
# PRESENTED, not just how long it took. Godot's player drops frames
# to hold its schedule and drops most of them on this hardware, and
# elapsed seconds stay plausible while that happens.
return {
"path": path,
"command": entry.get("command", ""),
"duration_s": float(entry.get("duration_s", 0.0)),
"fps": float(entry.get("fps", 0.0)),
}
error = "no video named %s in manifest.json" % name
return {}
@@ -109,7 +232,7 @@ func screen_names() -> PackedStringArray:
# the disc's own texels and several elements are drawn at 200 %, where a
# bilinear filter would invent detail the disc does not have.
func texture(rel: String) -> Texture2D:
var bytes := FileAccess.get_file_as_bytes(root.path_join(rel))
var bytes := FileAccess.get_file_as_bytes(resolve(rel))
if bytes.is_empty():
error = "cannot read sprite %s" % rel
return null
@@ -118,3 +241,16 @@ func texture(rel: String) -> Texture2D:
error = "%s is not a PNG" % rel
return null
return ImageTexture.create_from_image(img)
## One line naming what a mod replaced, or "" when nothing did.
##
## Printed by every run that loads a tree. A modded run that looked identical to
## an unmodded one in the log would make "disable the mod and see" the only
## debugging tool a modder has; this makes it the second one.
func mod_report() -> String:
if mods == "":
return ""
if shadowed.is_empty():
return "mods: %s is present; each file it replaces is logged as it is read" % mods
return "mods: %s -- %d file(s) shadowed: %s" % [mods, shadowed.size(), ", ".join(shadowed)]

272
port/scripts/gamepad.gd Normal file
View File

@@ -0,0 +1,272 @@
class_name Gamepad
extends RefCounted
## The physical controller: the two buttons Godot does not bind, and the one
## input that is not an edge.
##
## 🔴 BOTH DEFECTS WERE REPORTED BY A HUMAN PLAYING THE PORT (2026-09-01), and
## neither could have been caught by the `--script` harness, because that harness
## sends `InputEventAction` — which bypasses the input map and is not an analog
## axis. The unattended P5 walk passed on every iteration while Ⓐ did nothing at
## all on a real pad. **A synthetic-input test asserts the code after the input
## map, never the input map itself.**
##
## ## 1. Godot 4.7.2 binds no joypad button to `ui_accept` or `ui_cancel`
##
## Measured on this exact build rather than remembered, because the answer has
## changed between Godot versions and the remembered one was wrong:
##
## ```
## ui_accept key:Enter, key:Kp Enter, key:Space <- no joypad at all
## ui_cancel key:Escape <- no joypad at all
## ui_up key:Up, JOYBTN:11, JOYAXIS:1- <- d-pad AND left stick
## ui_down key:Down, JOYBTN:12, JOYAXIS:1+
## ui_left key:Left, JOYBTN:13, JOYAXIS:0-
## ui_right key:Right, JOYBTN:14, JOYAXIS:0+
## ```
##
## That asymmetry is the whole bug report: navigation worked on the pad and Ⓐ/Ⓑ
## did nothing, which reads like a broken controller and is a complete input map
## for four actions out of six.
##
## The events are **added to** the built-in actions, never redefined. Declaring
## `ui_accept` in `project.godot` replaces the built-in wholesale, so the
## keyboard bindings would have to be restated there and would silently rot the
## next time Godot changes them.
##
## ## 2. A stick is not a button
##
## `ui_up`/`ui_down` are bound to **axis 1**, so the left stick navigates — which
## is correct, the real game accepts it too. But an axis emits a fresh
## `InputEventJoypadMotion` every time the value *changes*, and a real stick held
## at deflection jitters continuously. Every one of those events reports the
## action as pressed, so a held stick was one cursor step per jitter: the human's
## words were "moves the cursor too fast", and on a five-item menu it crosses
## faster than the eye follows.
##
## So the stick is **latched**: it fires once when it leaves the neutral zone and
## not again until it comes back. That makes it behave exactly like the d-pad,
## which needs no latch because a button already is an edge.
##
## ⚠️ ~~AUTHORED, NOT MEASURED — and deliberately the conservative half.~~
## 🔴 **THE GAME DOES REPEAT, and this paragraph predicted its own refutation.**
## It said: *"If the game does repeat, this is a difference a human will notice
## as 'I have to flick it again'."* On 2026-09-02 a human who has played both
## reported exactly that — *"holding only moves one item. In game it actually
## continues to move when holding up/down, just at a medium pace"*.
##
## So one-step-per-deflection is no longer the conservative reading; it is a
## known defect. The mechanism is implemented below and the **rate is not
## shipped** — see `REPEAT_DELAY` for why an approximate one is worse than none.
## ✅ DECODED 2026-09-01, and it replaces an authored value.
##
## This was **0.5**, chosen as a *floor* rather than as a value: Godot's action
## deadzone for the `ui_*` actions is 0.50, so the latch must not arm below it —
## the action itself would not read as pressed and the step would be swallowed
## anyway, leaving the latch armed against a press that never happened. That
## reasoning still holds and 0.61 is comfortably above the floor.
##
## The game's own threshold is now measured: it **digitises the left stick to
## four direction bits at 61 % deflection**, so it never sees a velocity at all
## (`docs/re/input-button-numbering-is-remapped.md` and the corrected
## `input-pad-read-path.md`). Between 0.50 and 0.61 Godot reports `ui_down`
## pressed and the real game reports nothing; at 0.5 this port stepped there.
##
## 📌 The mechanism also corroborates the human's fix rather than merely
## agreeing with it: a control that digitises to bits cannot express a rate, so
## "one step per deflection" is what the hardware layer *can* produce, not a
## conservative guess that happened to look right.
##
## ⚠️ **The 0.11 gap to `RELEASE` stays AUTHORED.** Nothing measured says the
## game has hysteresis at all, let alone how wide. Only the arm threshold moved.
##
## 🔴 **This changes how the stick feels and a human chose the old number.**
## Asserted at the device level below and in `tools/port/verify-input`, but a
## feel-test is the real check: revert this one constant to 0.5 if 0.61 reads as
## a stick that needs pushing too far.
const ENTER := 0.61
## Release lower than it arms. Without the gap a stick resting near 0.5 chatters
## across the boundary and re-arms on noise, which is the original bug wearing a
## smaller number.
const RELEASE := 0.4
## ## 3. A held direction repeats — MECHANISM PRESENT, RATE NOT SHIPPED
##
## A human who has played both reported it on 2026-09-02: *"holding only moves
## one item. In game it actually continues to move when holding up/down, just at
## a medium pace"*, and separately *"Confirmed D-Pad does repeat when holding
## too."* So the FACT covers both input devices, which is why `held_direction()`
## polls the pad and the keyboard and not just the stick.
##
## 🔴 **THE RATE IS DELIBERATELY UNSET, AND THE REPEAT DOES NOT RUN UNTIL IT IS
## MEASURED.** The instruction is explicit: *"Take the RATE from the Decoder — do
## NOT ship a placeholder interval. An invented rate here is indistinguishable
## from a measured one later, and this is the exact field where that already cost
## us."*
##
## An earlier draft of this file had 0.40 / 0.20 with a paragraph explaining that
## they were authored. **That is precisely the failure mode named above** — the
## explanation would have been merged, the numbers would have felt roughly right,
## and nothing afterwards could distinguish them from a measurement. They are
## removed rather than commented out.
##
## 📌 **A constant interval is the right SHAPE, and that part IS measured.** The
## game digitises the left stick to four direction bits at 61 % deflection
## (`ENTER` above), so it cannot see deflection magnitude at all — a repeat it
## drives cannot be faster-the-harder-you-push. That excludes the one competing
## model, so only the two constants are open, and one measurement closes both.
##
## ⚠️ **TO ADOPT, TWO THINGS CHANGE, NOT ONE.** Set both constants to the
## measured seconds — and update `tools/port/verify-input`, whose row *"a held
## stick is ONE step, not six"* currently asserts **the absence of this
## feature**. It passes today because the repeat is inert; the moment a rate is
## adopted a held stick SHOULD produce further steps, and that green row would
## go red for the right reason and be read as a regression.
##
## 📌 That row is not wrong. A check written against today's behaviour becomes an
## assertion that the behaviour never changes, and this one has the additional
## trap of looking like a bug-fix regression test — it was written for the
## jitter defect, and the repeat is not that defect returning.
## `pad-repeat` in `BLOCKED.md` carries the request.
const REPEAT_DELAY := -1.0
const REPEAT_INTERVAL := -1.0
## Whether a measured repeat rate has been adopted. Until it has, the port keeps
## its current one-step-per-deflection behaviour, which is KNOWN WRONG but is
## wrong in a way nobody will mistake for a measurement.
static func repeat_rate_known() -> bool:
return REPEAT_DELAY > 0.0 and REPEAT_INTERVAL > 0.0
## Only the left stick. The triggers are axes too, and latching them here would
## silently swallow input the port does not read yet but might.
const STICK := [JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y]
var _latched: Dictionary = {}
var _repeat_direction := 0
var _repeat_clock := 0.0
## Add the joypad buttons the built-in map omits. Returns a human-readable line,
## or "" if nothing needed adding — so a future Godot that ships these bindings
## makes this quietly stop reporting rather than double-binding.
static func bind_missing() -> String:
var added := PackedStringArray()
for pair in [["ui_accept", JOY_BUTTON_A, ""], ["ui_cancel", JOY_BUTTON_B, ""]]:
var action: String = pair[0]
var button: int = pair[1]
if not InputMap.has_action(action):
# Not a warning we can act on, but silence here would present as the
# original bug and send the next person back to the controller.
push_warning("gamepad: no such action %s -- pad button unbound" % action)
continue
if _has_button(action, button):
continue
var ev := InputEventJoypadButton.new()
ev.button_index = button
InputMap.action_add_event(action, ev)
added.append("%s -> %s" % [pair[2], action])
if added.is_empty():
return ""
return "pad: bound %s (Godot 4.7.2 binds no joypad button to either)" % \
", ".join(added)
static func _has_button(action: String, button: int) -> bool:
for e in InputMap.action_get_events(action):
if e is InputEventJoypadButton and e.button_index == button:
return true
return false
## True if this event should be acted on. Everything that is already an edge —
## keys, d-pad, mouse — passes straight through; only the analog stick is
## latched, and only on the two axes the navigation actions are bound to.
func accepts(event: InputEvent) -> bool:
if not (event is InputEventJoypadMotion):
return true
var axis: int = event.axis
if not STICK.has(axis):
return true
var value: float = event.axis_value
var direction := 0
if value >= ENTER:
direction = 1
elif value <= -ENTER:
direction = -1
if direction == 0:
# Neutral enough to re-arm? The gap between RELEASE and ENTER is the
# hysteresis band: inside it the stick is neither a new press nor
# released, so the latch is left exactly as it was.
if absf(value) <= RELEASE:
_latched[axis] = 0
return false
if int(_latched.get(axis, 0)) == direction:
return false # still held in the same direction: not a new press
_latched[axis] = direction
return true
## Which way a direction is being HELD right now, as -1 (up), 0 or +1 (down).
##
## 🔴 **Polled at the DEVICE, never through `Input.is_action_pressed`.** `ui_up`
## and `ui_down` are bound to the stick axis at Godot's 0.50 action deadzone,
## while this port steps at the game's measured 0.61. Polling the action would
## repeat throughout the 0.500.61 band — the exact band `ENTER` exists to
## exclude — so the repeat would contradict the threshold on the same stick.
## That is the input-map lesson again: assert the device, not the layer above it.
func held_direction() -> int:
# The stick, from the latch `accepts()` already maintains, so the repeat and
# the first step read one state and cannot disagree about hysteresis.
var stick := int(_latched.get(JOY_AXIS_LEFT_Y, 0))
if stick != 0:
return stick
for device in Input.get_connected_joypads():
if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_UP):
return -1
if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_DOWN):
return 1
if Input.is_key_pressed(KEY_UP):
return -1
if Input.is_key_pressed(KEY_DOWN):
return 1
return 0
## One repeat step, or 0. Call once per frame with the frame's delta.
##
## The FIRST step is not this function's: it comes from the event edge in
## `_unhandled_input`, and the clock below starts from that same frame, so a held
## direction gives one step now and the next only after `REPEAT_DELAY`. A change
## of direction restarts the delay rather than inheriting the old cadence.
func repeat_due(delta: float) -> int:
if not repeat_rate_known():
return 0
var direction := held_direction()
if direction == 0 or direction != _repeat_direction:
_repeat_direction = direction
_repeat_clock = 0.0
return 0
_repeat_clock += delta
if _repeat_clock < REPEAT_DELAY:
return 0
# Subtract rather than reset, so the cadence cannot drift with the frame rate
# -- at 140 fps and at 30 fps the same number of steps happen per second.
_repeat_clock -= REPEAT_INTERVAL
return direction
## The pads Godot can see, for the startup line. A run where the human believes
## a controller is connected and Godot disagrees should say so on its own,
## rather than presenting as unresponsive buttons.
static func report_devices() -> String:
var pads := Input.get_connected_joypads()
if pads.is_empty():
return "pad: none connected -- keyboard only (Enter/Space = Ⓐ, Escape = Ⓑ)"
var names := PackedStringArray()
for j in pads:
names.append("[%d] %s" % [j, Input.get_joy_name(j)])
return "pad: " + ", ".join(names)

View File

@@ -0,0 +1 @@
uid://b34gxuqrvncbp

218
port/scripts/menu_audio.gd Normal file
View File

@@ -0,0 +1,218 @@
# The menu's sound: three cues and one music bed.
#
# EVERYTHING THIS CLASS PLAYS IS AUTHORED OR MEASURED, and the two are not the
# same. `authored/audio.json` carries the distinction and the exporter copies it
# into `manifest.json` alongside each file, so a reader of the export tree sees
# it without having to find this project:
#
# * WHICH WAVE a menu event plays was MEASURED off the running game (HANDOFF
# Q8) -- it is on the disc in no findable form. `Static.slb` has no RIFF, no
# seek chunk and no container.
# * WHICH TRACK the menu plays is CHOSEN. HANDOFF Q10 is a negative: all 32
# banks are named BGM_001..BGM_109 and nothing on the disc says which one a
# menu uses.
# * WHEN a cue fires is authored here, and §"When a cue fires" below says
# exactly which parts of that nobody has watched the game do.
#
# The wall (MISSION §2): this class reads **Ogg Vorbis**. It has never heard of
# XMA, of `sound.pak` or of `Static.slb`, and it must not learn. The exporter
# converts; the runtime plays.
class_name MenuAudio
extends Node
## Cue name -> stream, from `manifest.json`'s `audio` entries of kind `se`.
var cues: Dictionary = {}
## Role -> {stream, loop}, from the entries of kind `bgm`.
var beds: Dictionary = {}
## Movie name -> what that voice export is KNOWN to be missing, from the
## manifest's `incomplete`. Empty for an asset with no known gap.
var _voice_gaps: Dictionary = {}
## What `movie`'s voice export is known to be missing, or "" if nothing is.
func incomplete_for(movie: String) -> String:
return String(_voice_gaps.get(movie, ""))
## Movie name -> stream, from the entries of kind `voice`.
##
## A cutscene's dialogue is NOT in its `.ogv`. On this disc a movie carries music
## and effects only and the voice is a separate continuous XMA stream in
## `sound.pak`, bound by the movie manifest -- so playing a movie means starting
## two streams together, and a port that plays only the video is silently missing
## every line of dialogue. That is what a human play-test heard.
var voices: Dictionary = {}
var error: String = ""
## One player per cue name, so a move and a confirm can overlap rather than
## cutting each other off. Three cues is not worth a pool.
var _players: Dictionary = {}
var _bed: AudioStreamPlayer = null
var _bed_role := ""
## Load every audio entry the manifest declares.
##
## Missing audio is NOT an error and does not stop a run: every milestone before
## P6 exported none, and `--menu` must stay usable against one of those trees.
## A cue that is listed but unreadable IS an error, because that is a broken
## export rather than an old one.
func configure(tree: ExportTree) -> bool:
var manifest := tree.manifest()
if manifest.is_empty():
error = tree.error
return false
for entry: Dictionary in manifest.get("audio", []):
# Through the resolver, so a mod can replace a cue or the music bed by
# dropping a file at the same relative path (MODDING rule 4). Reading
# `tree.root` directly here would have made audio the one asset kind a
# mod could not touch, for no reason a modder could have guessed.
var path := tree.resolve(String(entry.get("file", "")))
var stream := AudioStreamOggVorbis.load_from_file(path)
if stream == null:
error = "manifest lists audio %s but %s is not a readable Ogg Vorbis file" \
% [entry.get("name", "?"), path]
return false
match String(entry.get("kind", "")):
"se":
# A cue ends. Nothing measured says otherwise, and a looping
# cue would be a bug you hear rather than one you read.
stream.loop = false
cues[String(entry["name"])] = stream
"bgm":
# AUTHORED, and audibly imperfect on purpose. HANDOFF Q10: no
# loop-point field has been identified, so `restart` replays
# from sample 0 -- the listener hears the track's own fade-out
# and its trailing silence before the music returns. Trimming to
# the fade would sound better and would INVENT a loop point,
# which is worse: an invented one is indistinguishable from a
# decoded one a month later. See authored/audio.json loop_why.
stream.loop = String(entry.get("loop_mode", "")) == "restart"
beds[String(entry["name"])] = stream
"voice":
# A cutscene's voice-over ends with the cutscene. It is keyed by
# MOVIE NAME, not by a role: the binding came off the disc's own
# movie manifest, so unlike the music bed there is nothing
# authored about which recording belongs to which picture.
stream.loop = false
voices[String(entry["name"])] = stream
# Carried alongside the stream so the runtime can announce a known gap
# at the moment it plays one. Absent means nothing is KNOWN to be
# missing -- never that the asset was checked and is complete.
if entry.has("incomplete"):
_voice_gaps[String(entry["name"])] = String(entry["incomplete"])
_:
push_warning("manifest audio entry %s has kind %s, which this build does not play"
% [entry.get("name", "?"), entry.get("kind", "?")])
return true
## True when this export carries no audio at all -- an export taken before P6.
func silent() -> bool:
return cues.is_empty() and beds.is_empty() and voices.is_empty()
# --- The cutscene voice -------------------------------------------------------
var _voice: AudioStreamPlayer = null
## Start a movie's dialogue, or do nothing when the export carries none.
##
## **No offset, and none is authored.** The voice plays from the video's first
## frame, so the two streams are started together and nothing here compensates
## for anything. If they ever drift, that is a fact about the export, not a
## constant to be tuned in this file.
##
## Returns whether a stream was found, so the caller can SAY that a movie is
## unvoiced rather than leave silence looking like success.
func play_voice(movie: String) -> bool:
if not voices.has(movie):
return false
if _voice == null:
_voice = AudioStreamPlayer.new()
add_child(_voice)
_voice.stream = voices[movie]
_voice.play()
return true
## Stop the dialogue. Called when the movie ends OR is skipped -- a voice that
## outlived a skipped intro would play over the title screen.
func stop_voice() -> void:
if _voice != null:
_voice.stop()
# --- When a cue fires ---------------------------------------------------------
#
# MEASURED (HANDOFF Q5 + Q8): a d-pad press that MOVES the cursor plays the move
# cue, and left/right play nothing at all. `MenuFlow.move()` returns whether the
# cursor actually moved for exactly this reason, so a press at the end of a
# non-wrapping list cannot click.
#
# NOT MEASURED, and authored here: whether Ⓐ or Ⓑ click when nothing is bound to
# them. Nobody has watched the game take a dead press. This class stays silent in
# that case, which is the choice that invents the least -- a sound the game does
# not make is a wrong fact you can hear, whereas a missing one is a gap. Ask the
# RE agent before relying on it either way.
func play(cue: String) -> void:
if not cues.has(cue):
return
if not _players.has(cue):
var p := AudioStreamPlayer.new()
p.stream = cues[cue]
add_child(p)
_players[cue] = p
(_players[cue] as AudioStreamPlayer).play()
## Start the music bed for a role, or do nothing if it is already playing.
##
## Idempotent because the menu re-enters screens constantly -- Ⓑ back to the main
## menu must not restart the music, and a bed that restarts on every navigation
## is the kind of wrong that reads as "the audio works".
func play_bed(role: String) -> void:
if not beds.has(role) or _bed_role == role:
return
if _bed == null:
_bed = AudioStreamPlayer.new()
add_child(_bed)
_bed.stream = beds[role]
_bed_role = role
_bed.play()
## 🔴 DEAD CODE, and that is the finding rather than a tidiness note.
##
## Nothing in the port calls this. The bed therefore starts when the main menu
## goes live and never stops -- through the cutscene, and on to the title after
## it. Nobody chose that; it is what happens when the only way to stop something
## is a function no caller remembers.
##
## It is the mirror of `ScreenView.skipped`, which was written every frame and
## read by nobody. One is a fact recorded and never surfaced, the other a
## capability provided and never used, and both were invisible for the same
## reason: nothing fails when they are missed.
##
## Kept, not deleted. The day a capture says whether the game's menu music ducks
## under a movie, this is the one line that has to change.
func stop_bed() -> void:
if _bed != null:
_bed.stop()
_bed_role = ""
## Is the music bed sounding right now? Used by the boot to ANNOUNCE that it is
## still playing under a movie, rather than to stop it.
func bed_playing() -> bool:
return _bed != null and _bed.playing
## What the audio server is actually doing, for a run's write-up.
##
## `docs/port/AUDIO-VERIFICATION.md`: "recorded under a dummy driver" is a
## weaker claim than "heard", and the difference matters -- so the claim is
## printed by the run that makes it rather than assumed by the person reading it.
static func driver() -> String:
return AudioServer.get_driver_name()

View File

@@ -0,0 +1 @@
uid://badw3pulb0xpt

235
port/scripts/menu_flow.gd Normal file
View File

@@ -0,0 +1,235 @@
# Where the buttons go, and what the d-pad does.
#
# EVERYTHING IN HERE IS AUTHORED OR MEASURED -- none of it is on the disc.
# HANDOFF Q6 closed the "what drives the flow" question with a negative: the
# order is code, not data, in all four places it could have been. So this class
# reads `authored/flow.json` and holds no rule of its own.
#
# The split is deliberate and is the derived/authored contract in miniature:
#
# * the ORDER of the items is DERIVED -- each screen file's `buttons`, which
# the exporter fills from the button-role elements sorted by resting Y;
# * WHERE an item goes, WHICH item opens focused, and WHAT (B) does are
# AUTHORED, because they were measured off the running game or chosen.
#
# A rule that lived in GDScript instead would be invisible to the person whose
# job is to notice that we decided it.
class_name MenuFlow
extends RefCounted
## The authored `screens` map: screen name -> destinations, initial focus, (B).
var screens: Dictionary = {}
## The authored `navigation` block: wrap, left/right, input during a transition.
var navigation: Dictionary = {}
## Where we are and how we got here, oldest first. The last entry is current.
## (B) restores the focus recorded on the entry it pops back to -- HANDOFF Q5
## measured that the game does this, so the stack carries a focus, not just a
## name.
var stack: Array[Dictionary] = []
var error: String = ""
## Nothing happened. Returned rather than `null` so a caller reads one shape.
const NONE := {"kind": "none"}
func configure(flow: Variant) -> bool:
if typeof(flow) != TYPE_DICTIONARY:
error = "authored/flow.json did not parse to an object"
return false
if not flow.has("screens") or not flow.has("navigation"):
error = "authored/flow.json has no `screens`/`navigation` block -- this build needs both"
return false
screens = flow["screens"]
navigation = flow["navigation"]
return true
func known(name: String) -> bool:
return screens.has(name) and typeof(screens[name]) == TYPE_DICTIONARY
func current() -> String:
return String(stack[stack.size() - 1]["screen"]) if not stack.is_empty() else ""
func focus() -> String:
return String(stack[stack.size() - 1]["focus"]) if not stack.is_empty() else ""
## The item a screen opens on.
##
## Authored per screen. Where the authored value names a button this screen does
## not have -- a mistyped id, or an export whose buttons moved -- fall back to
## the first button rather than to nothing, and SAY SO: a menu that opens with
## no focus looks like a rendering bug, and this is the one place that mistake
## would hide.
##
## 🔴 THE FALLBACK IS A REPAIR, NOT A DEFAULT, and since 2026-08-31 that is
## measured rather than fastidious. `DIFFICULTY` -- EASY/NORMAL/HARD/BACK --
## opens on **NORMAL, the second of four**, so "a screen opens on its first item"
## is refuted as a description of this game. On `EXTRAS`, `TUTORIAL` and
## `OPTIONS` the named item and the top item coincide by accident.
##
## So `buttons[0]` here is what to draw when the DATA IS BROKEN, and it warns
## precisely because it is not a claim about the game. If a screen ever reaches
## this line silently, the port will be showing a top-item default for a game
## that does not always have one.
func initial_focus(name: String, buttons: Array) -> String:
if buttons.is_empty():
return ""
var want := String(screens.get(name, {}).get("initial_focus", ""))
if want != "" and buttons.has(want):
return want
if want != "":
push_warning("flow.json opens %s on %s, which is not one of its buttons %s" % [name, want, buttons])
return String(buttons[0])
## Where a screen's cursor was when the player last left it.
##
## MEASURED 2026-08-30: the main menu remembers its cursor across a round trip
## through the title -- (B) out and (A) back returns to the item you left. The
## port used to reset to `initial_focus` on every entry, so a player who moved to
## EXTRAS, pressed (B) and then (A) landed back on NEW GAME.
##
## 🔴 Which screens have this is AUTHORED, not derived: `focus_persists` in
## `authored/flow.json`, true only on `main_menu`. The measurement is of that one
## screen, and widening it would contradict another measurement -- `extras` opens
## on MISSION SELECT as a MEASURED initial focus, which a remembered cursor would
## override. `wrap` generalises because it was measured on two screens; this was
## measured on one.
var remembered: Dictionary = {}
## What a screen opens on: what it was left on, if it is one of the screens that
## remembers, else the authored opening item.
func opening_focus(name: String, buttons: Array) -> String:
var keep := bool(screens.get(name, {}).get("focus_persists", false))
var was := String(remembered.get(name, ""))
if keep and was != "" and buttons.has(was):
return was
return initial_focus(name, buttons)
func enter(name: String, buttons: Array) -> void:
stack.append({"screen": name, "focus": opening_focus(name, buttons)})
## Move the cursor. Returns true when it actually moved, so a caller can fire the
## move cue only on a real move (P6) rather than on every press.
##
## MEASURED, HANDOFF Q5: up/down move one item and WRAP at both ends -- on the
## 5-item main menu and the 3-item EXTRAS both, so it is a menu rule. `wrap` is
## read from `authored/flow.json` rather than written here, because it is a
## measurement and the day it is contradicted the fix is a data edit.
func move(step: int, buttons: Array) -> bool:
if stack.is_empty() or buttons.size() < 2:
return false
var at := buttons.find(focus())
if at < 0:
at = 0
var to := at + step
if bool(navigation.get("wrap", true)):
to = posmod(to, buttons.size())
else:
to = clampi(to, 0, buttons.size() - 1)
if to == at:
return false
set_focus(String(buttons[to]))
return true
## Set the top of the stack's focus AND remember it, in one place.
##
## Two call sites set focus -- a cursor move and (B)'s restore -- and a memory
## updated at only one of them would be right until the player used the other.
func set_focus(id: String) -> void:
if stack.is_empty():
return
stack[stack.size() - 1]["focus"] = id
remembered[current()] = id
## (A). Returns what the authored flow says the focused item opens.
##
## {"kind": "enter", "goto": <screen>, "label": …} -- go there
## {"kind": "blocked", "label": …, "why": …} -- a real destination
## that is not in this
## export
## {"kind": "video", "video": …, "skipped": […], -- the destination is
## "after": {…}} absent but its chain
## ends in a movie we
## DO have (P7)
## {"kind": "none"} -- nothing bound
##
## `blocked` is not an error and is not an unknown. Those five destinations were
## measured off the running game; they live in other archives and this milestone
## does not export them. Saying "blocked" rather than "none" keeps the two apart.
func accept(buttons: Array) -> Dictionary:
if stack.is_empty():
return NONE
var screen: Dictionary = screens.get(current(), {})
# A screen with no buttons -- the title -- can still take (A).
if buttons.is_empty():
return _target(screen.get("on_accept", null), "A")
var button: Dictionary = screen.get("buttons", {}).get(focus(), {})
if button.is_empty():
return NONE
var label := String(button.get("label", focus()))
if button.get("goto", null) == null:
# A destination this export does not carry, but whose CHAIN ends in
# something it does: `NEW GAME` opens `DIFFICULTY`, then `SELECT DATA`,
# and only then the new-game movie. The port has the movie and neither
# screen (P7).
#
# This is returned as its own kind rather than folded into `blocked`,
# because the caller has to announce the skip. A port that quietly
# jumped from `NEW GAME` to the intro would be showing a sequence the
# game does not have, and nothing on screen would say so.
if button.get("then_video", null) != null:
return {
"kind": "video",
"label": label,
"video": String(button["then_video"]),
"skipped": button.get("skipped_chain", []),
"skippable": bool(button.get("skippable", false)),
"after": button.get("after_video", {}),
}
return {"kind": "blocked", "label": label, "why": String(button.get("blocked", ""))}
return {"kind": "enter", "goto": String(button["goto"]), "label": label}
## (B), and EXTRAS' own `BACK` item, which is treated as the same thing --
## nothing measured distinguishes them and inventing a difference would be a
## guess with no evidence behind it.
##
## MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you
## came from. So the target comes from the authored flow, but the focus comes
## from the STACK -- and only when the stack agrees about where we are going. A
## run that started straight on a submenu has no history to restore and enters
## the parent at its authored initial focus instead.
func cancel() -> Dictionary:
if stack.is_empty():
return NONE
var target: Variant = screens.get(current(), {}).get("on_cancel", null)
var out := _target(target, "B")
if out["kind"] != "enter":
return out
if stack.size() >= 2 and String(stack[stack.size() - 2]["screen"]) == out["goto"]:
out["restore_focus"] = String(stack[stack.size() - 2]["focus"])
out["pop"] = true
return out
## Pop back to the parent, keeping the focus it was left on.
func pop() -> void:
if stack.size() >= 2:
stack.pop_back()
static func _target(target: Variant, label: String) -> Dictionary:
if typeof(target) != TYPE_DICTIONARY or target.get("goto", null) == null:
return NONE
return {"kind": "enter", "goto": String(target["goto"]), "label": label}

View File

@@ -0,0 +1 @@
uid://dyqb3b450d21x

View File

@@ -7,7 +7,7 @@
# 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
# `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`.
@@ -37,10 +37,152 @@ var pose_mode: Pose = Pose.TIMELINE
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
## 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.
@@ -50,6 +192,19 @@ 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
@@ -72,6 +227,10 @@ func load_screen(t: ExportTree, name: String) -> bool:
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
@@ -83,6 +242,8 @@ func _load_textures() -> void:
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:
@@ -155,8 +316,30 @@ func pose_at(element: Dictionary, t: float) -> Dictionary:
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))
# `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
@@ -169,9 +352,17 @@ func pose_at(element: Dictionary, t: float) -> Dictionary:
# 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 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]
@@ -228,6 +419,91 @@ static func settle_units(element: Dictionary) -> float:
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
@@ -248,7 +524,7 @@ func exit_time() -> float:
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"):
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
@@ -288,20 +564,62 @@ func _template_instance_ids() -> Dictionary:
## 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):
if tex != null:
draw_texture_rect(tex, rect, false, colour)
else:
draw_rect(rect, colour, true)
_add_quad(ci, tex, rect, colour)
return
var anchor := pos + pivot
draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE)
var local := Rect2(rect.position - anchor, rect.size)
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:
draw_texture_rect(tex, local, false, colour)
RenderingServer.canvas_item_add_texture_rect(ci, rect, tex.get_rid(), false, colour)
else:
draw_rect(local, colour, true)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
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:
@@ -316,8 +634,89 @@ static func _rot_of(pose: Dictionary) -> float:
## 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 == "":
@@ -326,17 +725,59 @@ func _draw_focus(element: Dictionary) -> void:
if tex == null:
skipped.append("%s (focus sprite failed to load)" % fe.get("id", ""))
continue
# The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360
# with position, scale and alpha all constant -- but the PERIOD is not
# established: the ramp's second keyframe is untimed, and what an untimed
# keyframe means inside a leaf (rather than at screen level, where it is
# the exit) is untested. So this holds the resting angle and does not
# invent a spin rate.
# 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_of(pose))
pivot, pos, rot)
drawn.append(fe.get("id", ""))
@@ -347,9 +788,76 @@ func _draw() -> void:
var ghosts := _template_instance_ids()
skipped.clear()
drawn.clear()
for index: int in screen.get("paint_order", []):
# 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
@@ -357,15 +865,56 @@ func _draw() -> void:
else pose_at(element, time_units)
var colour := modulate_of(pose)
if colour.a <= 0.0:
skipped.append("%s (transparent at rest)" % id)
# 🔴 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)
# A focused button draws its own record instead of its base sprite.
# 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"]
@@ -373,9 +922,13 @@ func _draw() -> void:
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.
@@ -385,3 +938,17 @@ func _draw() -> void:
# 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)