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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user