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

@@ -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