port: delete exit_ramp_units, invert the format's own rule, and guard a scale-0 leaf

FOUR THINGS, and the first is what MISSION section 3 calls the measure of
progress.

DELETED `exit_ramp_units` AND `exit_ramp_seconds`. They were authored because the
disc had no time slot on a group's final keyframe, so the ramp into it was the
one unknown duration per screen. Under the corrected record layout that keyframe
does not exist -- a group is an 8-byte header then frames x {u32 time; 36-byte
pose} and every pose is timed. VERIFIED DEAD BEFORE DELETING: setting it to 9999
(166 s) moved the boot's transitions by 0.04 s, which is wall-clock jitter, and
both uses in ScreenView are gated on a condition that no longer fires on any of
the export's 866 keyframes.

INVERTED THE FORMAT'S OWN RULE. `check.rs` enforced "the final keyframe has no
`t`; the disc has no time slot there" and FORMAT.md stated it. Both are now
backwards, and the validator fired 150 times on a re-export. I had not run
`check` between pinning the tag and measuring against the oracle -- the pixel
harness was green while the format validator was failing on every screen with a
multi-keyframe group. A correctness harness does not replace a format one; they
fail at different layers.

GUARDED A SCALE-0 LEAF, which the Decoder hit in its own renderer: its leaf
branch marked the element drawn unconditionally while the blit returned early on
zero scale, so a scale-0 leaf suppressed its parent and blanked the element --
live on all four loading screens. 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 another reason, not a defence, so `_draw_leaf` now reports
whether it drew and `_draw` falls back to the parent.

ISOLATED THE PACING QUESTION rather than leaving it as a suspected regression.
Legacy association: publisher 4.70 agrees, developer 3.92 DIFFERS. Corrected:
publisher 4.26 DIFFERS, developer 3.62 agrees. Both misses are ~0.03 s outside a
composite bound. The association traded which screen is marginally out; it did
not regress the pacing.

Bumped the pin c -> d for the parser and audio changes. Its headline renderer
change does not reach this port: sylpheed-cli builds from the workspace crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-29 19:19:46 +00:00
parent 16ab24480d
commit ace071dd7b
8 changed files with 189 additions and 102 deletions

View File

@@ -117,6 +117,13 @@ func _ready() -> void:
if args.has("boot"):
_capture_to = args.get("capture", "")
_film = args.get("film", "")
# `--film-interval=` in seconds. Configurable because the fixed 0.25 s could
# not resolve the boot's own black hold: the transition's pure-black plateau
# is MEASURED at 0.17-0.23 s (authored/timing.json, HANDOFF Q7), which is
# shorter than the cadence that was meant to observe it. `verify-dwell` duly
# reported two screens as one 93 s span and called it a regression, when the
# black frame had simply fallen between samples.
_film_interval = maxf(0.01, float(args.get("film-interval", "0.25")))
_shots = args.get("shots", "")
if args.has("script"):
_script = args["script"].split(",", false)
@@ -177,7 +184,12 @@ func _ready() -> void:
view.units_per_second = float(timing["keyframe_units_per_second"])
# The one unknown duration per screen: the ramp into the final untimed
# keyframe. Authored, because the disc has no time slot there.
view.exit_ramp_units = float(timing["exit_ramp_units"])
# `exit_ramp_units` is DELETED from authored/timing.json -- under the corrected
# record layout every pose is timed, so there is no untimed final keyframe to
# give a synthetic time to. The default below is now unreachable rather than
# authored, and both of ScreenView's uses are dead branches kept only so an
# older export still loads.
view.exit_ramp_units = float(timing.get("exit_ramp_units", 24.0))
# Which focus records draw unconditionally and loop. Kept out of ScreenView's
# own logic on purpose -- see `looping_focus` there for the census that says
# this cannot be a rule.
@@ -278,6 +290,8 @@ var _step := 0
var _film := ""
var _film_frame := 0
var _film_next := 0.0
## Seconds between `--film` frames. See `--film-interval`.
var _film_interval := 0.25
var _elapsed := 0.0
var _boot_done := false
@@ -652,7 +666,7 @@ func _film_capture() -> void:
var img := viewport.get_texture().get_image()
img.save_png("%s_%03d.png" % [_film, _film_frame])
_film_frame += 1
_film_next += 0.25
_film_next += _film_interval
# Godot passes everything after `--` through untouched; take `--key=value`.

View File

@@ -404,7 +404,9 @@ static func _rot_of(pose: Dictionary) -> float:
## ❔ Every observation behind this has parent alpha 0, so "the leaf wins" and
## "the parent is ignored because it draws nothing" are NOT separated. A capture
## during t=100...238 would separate them.
func _draw_leaf(element: Dictionary) -> void:
## 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 == "":
@@ -422,10 +424,27 @@ func _draw_leaf(element: Dictionary) -> void:
holding = false
var pose := pose_at(fe, time_units)
holding = was
# 🔴 A SCALE-0 LEAF MUST NOT CLAIM THE DRAW. The Decoder hit this in its own
# renderer: its leaf branch marked the element drawn unconditionally, but
# the blit returns early on zero scale, so a scale-0 leaf suppressed its
# parent and BLANKED the element -- live on all four loading screens via
# `pgloading_loop5`, whose leaf is scale (0, 0).
#
# ⚠️ This port did not have the bug only because `authored/rendering.json`
# happens not to list `pgloading_loop5`. That is an accident of a gate
# written for a different reason, not a defence, so the guard is here: a
# leaf that would draw nothing reports so, and `_draw` falls back to the
# parent rather than losing the element.
var scale: Array = pose.get("scale", [100, 100])
if int(scale[0]) == 0 or int(scale[1]) == 0:
skipped.append("%s (leaf scale 0 -- parent drawn instead)" % fe.get("id", ""))
continue
var pivot := _vec(fe.get("pivot", [0, 0]))
_draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose),
pivot, _vec(pose.get("pos", [0, 0])), _rot_of(pose))
drawn.append(fe.get("id", ""))
any_drawn = true
return any_drawn
func _draw_focus(element: Dictionary) -> void:
@@ -508,8 +527,8 @@ func _draw() -> void:
# itself: the parent is a container whose own record has identity scale
# and rotation. See `_draw_leaf`.
if element.get("leaf_carries_geometry", false) \
and draw_leaf_for.has(String(element.get("id", ""))):
_draw_leaf(element)
and draw_leaf_for.has(String(element.get("id", ""))) \
and _draw_leaf(element):
continue
# A focused button draws its own record instead of its base sprite -- and
# so does an element the authored table says always shows it, which is