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