port: the splash never animated -- pose_at ASSIGNED the settle instant instead of clamping to it

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
This commit is contained in:
Sylpheed port agent
2026-09-02 15:38:41 +00:00
parent 8aa7050309
commit 08ed3dd17e
3 changed files with 102 additions and 2 deletions

View File

@@ -425,6 +425,28 @@ func _ready() -> void:
# path already uses, rather than adding a second mechanism.
_capture_to = String(args["capture"])
else:
# 🔴 "THE SETTLED POSE BY OMISSION" WAS AN ACCIDENT AND IS NOW REAL.
#
# `_capture` shoots after two frames, so a `--screen=X --capture=`
# run photographed t ~= 2 units -- the very start of the build-in.
# It looked settled only because `pose_at` used to ASSIGN
# `settle_instant` rather than clamp to it, handing back the settled
# pose whatever the clock said. `tools/port/verify-capture` says so
# in its own words: "the 0.01 % agreements on both splashes were
# measured through that accident."
#
# Fixing the assignment (see `ScreenView.pose_at`) removed the
# accident and the tool started photographing a mid-ramp frame, which
# is a change in the HARNESS's shot, not in what the port ships. So
# the clock is advanced to the settle instant explicitly, which is
# what the tool was always asking for.
#
# ⚠️ Only when nothing pinned an instant. `--time` means the caller
# wants THAT instant and `frozen` is already set; overriding it here
# would reintroduce exactly the silent-ignore this replaces.
if not _frozen and view != null and view.settle_instant >= 0.0:
view.time_units = maxf(view.time_units, view.settle_instant)
view.queue_redraw()
await _capture(args["capture"])
get_tree().quit(0)

View File

@@ -322,7 +322,24 @@ func pose_at(element: Dictionary, t: float) -> Dictionary:
# One instant for the whole screen where the disc gives a wide enough
# window; otherwise each element's own hold, which is what this port did
# everywhere until 2026-08-29.
t = settle_instant if settle_instant >= 0.0 else minf(t, settle_units(element))
# 🔴 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