From 08ed3dd17edae70b44d2f307edf628666476d4f5 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Wed, 2 Sep 2026 15:38:41 +0000 Subject: [PATCH] port: the splash never animated -- pose_at ASSIGNED the settle instant instead of clamping to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018AHUQvXGyNcKonSEWsgWcX --- port/scripts/boot.gd | 22 +++++++++++++ port/scripts/screen_view.gd | 19 ++++++++++- tools/motion-census | 63 ++++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 42dd2ee6..66dce511 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -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) diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index e3f45e84..5ec2c657 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -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 diff --git a/tools/motion-census b/tools/motion-census index af44b742..cbcfd34a 100755 --- a/tools/motion-census +++ b/tools/motion-census @@ -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