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
261 lines
10 KiB
Python
Executable File
261 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Does it ANIMATE, or does it switch? — compare a film, never two stills.
|
|
|
|
tools/motion-census <dir-of-frames> [--interval 0.05] [--rows] [--roi x,y,w,h]
|
|
tools/motion-census --selftest
|
|
|
|
Point it at a directory of PNGs captured in order during a REAL run — the port's
|
|
`--film`, or a capture harness's frame dump. It reports where the picture moves
|
|
and where it is frozen, plus how many distinct states it ever took.
|
|
|
|
Why this exists
|
|
---------------
|
|
🔴 A human played the port and said *"the logos just switch, there is no
|
|
animation at all."* Every check either agent had said the splash was correct.
|
|
Three instruments agreed, and all three were blind in the same way:
|
|
|
|
* a **frozen sweep** — step the clock by hand, render a still per unit. That
|
|
proves the renderer CAN draw pose N. It never runs the animation.
|
|
* a **settled comparison** — correlate the resting pose against a capture. A
|
|
screen that is frozen 84 % of the time matches a settled reference
|
|
perfectly; that is what being frozen MEANS.
|
|
* an **achieved-fps counter** — frames DRAWN per second. Drawing the same
|
|
pixels 25 times a second scores identically to animating at 25 fps.
|
|
|
|
The common defect: **every one measured throughput or a pose, and none measured
|
|
CHANGE.** So this measures change and nothing else.
|
|
|
|
measured 2026-09-02 on the boot splash: moving 1.30 s of 7.95 s (16.4 %),
|
|
publisher frozen for 3.20 s, developer for 2.40 s, 26 distinct luma values.
|
|
|
|
What the numbers mean
|
|
---------------------
|
|
A fade is ONE LONG RUN of small non-zero deltas. A switch is isolated one-frame
|
|
spikes with flat nothing between them. The `distinct states` count is the blunt
|
|
version of the same question: a 45-unit ramp cannot be drawn in 26 states.
|
|
|
|
⚠️ It cannot tell you the animation is CORRECT — only that something moved. A
|
|
wrong ramp that moves every frame passes here. Pair it with a comparison against
|
|
the oracle; this is the liveness half, which is the half that was missing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from PIL import Image
|
|
_BACKEND = "pillow"
|
|
except ImportError:
|
|
# 🔴 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
|
|
# works and a bigger number would only hide small fades.
|
|
MOVED = 0.05
|
|
|
|
# Downsample before comparing. A fade moves every pixel a little, so it survives
|
|
# scaling; scaling also stops one stray cursor pixel reading as motion.
|
|
GRID = (160, 90)
|
|
|
|
|
|
def load(path: Path, roi=None) -> list[int]:
|
|
im = Image.open(path).convert("L")
|
|
if roi:
|
|
x, y, w, h = roi
|
|
im = im.crop((x, y, x + w, y + h))
|
|
return list(im.resize(GRID).getdata())
|
|
|
|
|
|
def census(frames: list[Path], interval: float, roi=None):
|
|
prev, rows = None, []
|
|
for i, f in enumerate(frames):
|
|
px = load(f, roi)
|
|
lum = sum(px) / len(px)
|
|
delta = 0.0 if prev is None else sum(abs(a - b) for a, b in zip(px, prev)) / len(px)
|
|
rows.append((i * interval, delta, lum))
|
|
prev = px
|
|
return rows
|
|
|
|
|
|
def segments(rows):
|
|
segs, cur, start = [], None, 0.0
|
|
for t, delta, _ in rows[1:]:
|
|
state = "MOVING" if delta > MOVED else "static"
|
|
if state != cur:
|
|
if cur is not None:
|
|
segs.append((cur, start, t))
|
|
cur, start = state, t
|
|
if cur is not None:
|
|
segs.append((cur, start, rows[-1][0]))
|
|
return segs
|
|
|
|
|
|
def report(rows, interval: float, show_rows: bool) -> int:
|
|
total = rows[-1][0]
|
|
segs = segments(rows)
|
|
|
|
if show_rows:
|
|
print(f"{'t(s)':>7} {'Δ prev':>8} {'luma':>7} bar")
|
|
for t, delta, lum in rows:
|
|
print(f"{t:>7.2f} {delta:>8.3f} {lum:>7.2f} {'#' * min(60, int(delta * 6))}")
|
|
print()
|
|
|
|
print(f"{len(rows)} frames @{interval}s = {total:.2f}s\n")
|
|
print(f"{'state':<8} {'from':>7} {'to':>7} {'dur':>7} luma")
|
|
for state, a, b in segs:
|
|
lums = [l for t, _, l in rows if a <= t <= b]
|
|
flag = " <-- FROZEN" if state == "static" and (b - a) > 0.5 else ""
|
|
print(f"{state:<8} {a:>7.2f} {b:>7.2f} {b-a:>7.2f} "
|
|
f"{min(lums):.2f}..{max(lums):.2f}{flag}")
|
|
|
|
moving = sum(b - a for s, a, b in segs if s == "MOVING")
|
|
pct = 100 * moving / total if total else 0.0
|
|
states = len({round(l, 2) for _, _, l in rows})
|
|
print(f"\nmoving {moving:.2f}s of {total:.2f}s = {pct:.1f}%")
|
|
print(f"distinct luma states: {states}")
|
|
longest = max((b - a for s, a, b in segs if s == "static"), default=0.0)
|
|
print(f"longest frozen stretch: {longest:.2f}s")
|
|
print()
|
|
if pct < 50:
|
|
print("🔴 This is a SWITCH, not an animation. Most of the run is one still")
|
|
print(" picture. A frozen sweep and a settled comparison both pass on this.")
|
|
return 1
|
|
print("moves for most of its length — liveness only; correctness is a separate")
|
|
print("question this tool cannot answer.")
|
|
return 0
|
|
|
|
|
|
def selftest() -> int:
|
|
"""Synthetic controls, executed. A detector that cannot tell a fade from a
|
|
switch would report the same green line on both, and its verdict on the real
|
|
film would mean nothing."""
|
|
import tempfile
|
|
|
|
ok = 0
|
|
with tempfile.TemporaryDirectory() as d:
|
|
root = Path(d)
|
|
|
|
def write(name, values):
|
|
sub = root / name
|
|
sub.mkdir()
|
|
for i, v in enumerate(values):
|
|
Image.new("L", (320, 180), int(v)).save(sub / f"f{i:04d}.png")
|
|
return sorted(sub.glob("*.png"))
|
|
|
|
# A fade: every frame differs from the last.
|
|
fade = write("fade", [10 + i * 4 for i in range(40)])
|
|
rows = census(fade, 0.05)
|
|
pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
|
|
good = pct > 90
|
|
print(f" {'a real fade reads as MOVING':<40} {pct:5.1f}% {'✅' if good else '🔴'}")
|
|
ok |= 0 if good else 1
|
|
|
|
# A switch: one value, then another, held. This is the shape the port
|
|
# actually produced, and the case the old instruments could not see.
|
|
sw = write("switch", [10] * 20 + [200] * 20)
|
|
rows = census(sw, 0.05)
|
|
pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
|
|
good = pct < 20
|
|
print(f" {'a switch reads as STATIC':<40} {pct:5.1f}% {'✅' if good else '🔴'}")
|
|
ok |= 0 if good else 1
|
|
|
|
# Fully frozen: the degenerate case must not read as motion.
|
|
fr = write("frozen", [42] * 30)
|
|
rows = census(fr, 0.05)
|
|
pct = 100 * sum(b - a for s, a, b in segments(rows) if s == "MOVING") / rows[-1][0]
|
|
good = pct == 0
|
|
print(f" {'a frozen film reads as 0% motion':<40} {pct:5.1f}% {'✅' if good else '🔴'}")
|
|
ok |= 0 if good else 1
|
|
|
|
print()
|
|
print("the census separates a fade from a switch" if not ok
|
|
else "🔴 the census cannot tell a fade from a switch — it asserts nothing")
|
|
return ok
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if "--selftest" in argv:
|
|
return selftest()
|
|
args = [a for a in argv if not a.startswith("--")]
|
|
if not args:
|
|
sys.exit(__doc__.split("\n\n")[1])
|
|
interval = 0.05
|
|
roi = None
|
|
for i, a in enumerate(argv):
|
|
if a == "--interval" and i + 1 < len(argv):
|
|
interval = float(argv[i + 1])
|
|
if a == "--roi" and i + 1 < len(argv):
|
|
roi = tuple(int(v) for v in argv[i + 1].split(","))
|
|
d = Path(args[0])
|
|
frames = sorted(d.glob("*.png"))
|
|
if len(frames) < 3:
|
|
sys.exit(f"motion-census: {d} holds {len(frames)} PNG(s) — nothing to compare.\n"
|
|
" Exit 2: the harness is broken, not the animation.")
|
|
return report(census(frames, interval, roi), interval, "--rows" in argv)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|