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

View File

@@ -46,8 +46,69 @@ from pathlib import Path
try:
from PIL import Image
_BACKEND = "pillow"
except ImportError:
sys.exit("motion-census: needs Pillow (pip install pillow)")
# 🔴 FALLBACK, NOT A SECOND IMPLEMENTATION. The port's container has no
# Pillow and no pip, so the tool could not run at all there -- and a tool the
# port cannot run is a check the port does not have, which is how this class
# of defect survived in the first place.
#
# This shims only the three Pillow calls used below (open+convert, crop,
# resize+getdata, and new+save for the selftest) onto ImageMagick. The census
# arithmetic, the MOVED floor and the GRID are untouched, so the numbers are
# the tool's and not a re-derivation.
#
# `-grayscale Rec601Luma` rather than `-colorspace Gray`: Rec601 is what
# Pillow's `.convert("L")` uses, and IM7's `-colorspace Gray` linearises
# first, which would shift every value. Verified to round-trip a flat
# rgb(100,100,100) to exactly 100 on this build.
#
# ⚠️ The --selftest is what makes this safe to trust: it drives the SAME
# fade / switch / frozen discrimination through whichever backend is active,
# so a shim that distorted the pixels would fail its own control.
import subprocess
_BACKEND = "imagemagick"
class _IMImage:
def __init__(self, path=None, size=None, value=None):
self._path, self._size, self._value = path, size, value
self._crop = None
def convert(self, _mode):
return self
def crop(self, box):
x0, y0, x1, y1 = box
self._crop = (x1 - x0, y1 - y0, x0, y0)
return self
def resize(self, grid):
self._grid = grid
return self
def getdata(self):
cmd = ["convert", self._path]
if self._crop:
cmd += ["-crop", "%dx%d+%d+%d" % self._crop, "+repage"]
cmd += ["-grayscale", "Rec601Luma",
"-resize", "%dx%d!" % self._grid, "-depth", "8", "gray:-"]
out = subprocess.run(cmd, capture_output=True).stdout
return list(out)
def save(self, path):
subprocess.run(["convert", "-size", "%dx%d" % self._size,
"xc:rgb(%d,%d,%d)" % ((self._value,) * 3),
"-grayscale", "Rec601Luma", str(path)], check=True)
class Image: # noqa: F811 - deliberate stand-in, same call surface
@staticmethod
def open(path):
return _IMImage(path=str(path))
@staticmethod
def new(_mode, size, value):
return _IMImage(size=size, value=int(value))
# Below this, two frames are the same picture. Chosen as a floor, not tuned: PNG
# frames of an unchanged scene differ by exactly 0.000, so anything above noise