From 3cc3400a96ec048824138b7d5952539604eba2b6 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 2 Sep 2026 17:24:42 +0200 Subject: [PATCH] agents: the splash does not animate, and three instruments could not see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A human on a GPU at ~140 fps: "the logos just switch, there is no animation at all." Measured from a real boot with --film at 0.05 s, then per-frame change: splash moves 1.30 s of 7.95 s = 16.4 % publisher splash 0.30 s of motion, then 3.20 s FROZEN developer splash 0.35 s + 0.25 s, then 2.40 s FROZEN distinct luma states in 7.95 s 26 A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one picture for 3.20 s. The frame counter says 24.8 fps achieved; both are true -- the port is DRAWING 25 times a second and CHANGING almost never. πŸ”΄ Why every check passed, which matters more than the bug: frozen sweep drives the clock BY HAND -- proves the renderer can draw pose N, never that the poses are drawn in sequence settled compare 0.01 % against the capture -- a screen frozen 84 % of the time matches a settled reference PERFECTLY, that is what frozen means achieved fps counts frames DRAWN -- the same pixels 25x/s scores identically to animating Every one measured throughput or a pose. None measured CHANGE. Same shape as InputEventAction bypassing the input map: the instrument sat below the thing that was broken, so the break could not appear in it. tools/motion-census closes the class. It measures change and nothing else, and its --selftest asserts it separates a fade (97.4 % moving) from a switch (2.6 %) from a frozen film (0.0 %) -- a detector that cannot tell those apart would report the same green line on all three. Both briefs: this is the SOLE focus. The port reproduces before changing anything and gates every fix on a film rather than a still. The decoder maps the whole pipeline end to end -- disc bytes, the game's per-frame update (does it interpolate between keyframes or hold?), what is submitted per frame, and what Canary does to it before a capture records it -- delivered as a SERIES, not a settled value. The port should also record the refutation against itself: H2 reads ANSWERED on the strength of the frozen sweep. The mechanism half stands, the blur is a baked companion texture. The behaviour half does not. --- docs/agents/PLAYTEST-2026-09-02.md | 114 +++++++++++++++++ docs/agents/decoder-loop.md | 53 +++++++- docs/agents/port-loop.md | 49 ++++++- tools/motion-census | 199 +++++++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 docs/agents/PLAYTEST-2026-09-02.md create mode 100755 tools/motion-census diff --git a/docs/agents/PLAYTEST-2026-09-02.md b/docs/agents/PLAYTEST-2026-09-02.md new file mode 100644 index 00000000..98a7ad17 --- /dev/null +++ b/docs/agents/PLAYTEST-2026-09-02.md @@ -0,0 +1,114 @@ +# Play-test, 2026-09-02 β€” **the splash does not animate at all** + +**This is the only thing either agent works on until it is fixed.** + +The human, watching the port on real hardware, on a GPU, at ~140 fps: + +> *"The port does no blur animation at all! I cannot discern if there is any +> animation at all. The logos just switch without the animation."* + +## Measured, not paraphrased + +Filmed from a **real boot** at 0.05 s (`--film`), then per-frame change measured +with [`tools/motion-census`](../../tools/motion-census): + +| | | +|---|---| +| splash moves | **1.30 s of 7.95 s β€” 16.4 %** | +| publisher splash | 0.30 s of motion, then **3.20 s frozen** | +| developer splash | 0.35 s + 0.25 s, then **2.40 s frozen** | +| distinct luma states in 7.95 s | **26** | + +A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one +picture for 3.20 s. **This is a switch with a flicker on either side.** + +The frame counter says 24.8 fps achieved. Both are true: the port is *drawing* +25 times a second and *changing* almost never. + +## πŸ”΄ Why three instruments all said it was correct + +This is the part that matters more than the bug, because it is the fourth time +this shape has cost a milestone. + +| instrument | what it proved | what it could not see | +|---|---|---| +| **frozen sweep** (`--time=`, 3 units a step) | the renderer CAN draw pose *N* | whether the poses are ever drawn **in sequence, while running** | +| **settled comparison** (0.01 % against the capture) | the resting pose is right | a screen frozen 84 % of the time matches a settled reference **perfectly β€” that is what frozen means** | +| **achieved-fps counter** | frames are being drawn | drawing the **same pixels** 25Γ—/s scores identically to animating | + +> **Every one measured throughput or a pose. Not one measured CHANGE.** + +That is why the port could write *"the companion quads are drawn, verified by a +frozen sweep"* and be simultaneously right and useless: the sweep drives the +clock by hand. It is the same defect as `InputEventAction` bypassing the input +map β€” **the instrument sat below the thing that was broken**, so the thing that +was broken could not appear in it. + +`tools/motion-census` exists to close this class. It measures change and nothing +else, and its `--selftest` proves it separates a fade (97.4 % moving) from a +switch (2.6 %) from a frozen film (0.0 %) β€” because a detector that cannot tell +those apart would report the same green line on all three. + +## What both agents do now + +**Nothing else.** Not the clock rate, not the plate, not blend, not audio. This +first. + +### Port + +1. **Reproduce it** with `--film` + `motion-census` before changing anything, and + quote the numbers. If your run does not reproduce 16.4 %, say so β€” the + disagreement is then the finding. +2. **Find why the clock does not advance the poses.** Candidates, unranked and + none established: the keyframe interpolation returns the same pose for a + range of *t*; `rest()`/plateau logic snapping to an endpoint; the group clock + not integrating; interpolation between keyframes not happening at all + (nearest-keyframe rather than lerp); the screen advancing by *keyframe index* + instead of by time. +3. **Every fix is gated by a film**, never by a still. A change that improves a + settled frame and leaves the film at 16 % has not fixed this. +4. `motion-census` goes into `check-all`, so a future regression fails a check + instead of waiting for a human. + +### Decoder β€” **map the whole graphics pipeline, end to end** + +The human's instruction, in their words: *"get the whole graphics pipeline, from +the xex/pe + the disc files to the final screen displayed, and take Xenia Canary +processing into account too."* + +So: one continuous account, each stage with evidence and each labelled by the +instrument that produced it β€” + +``` +disc bytes β†’ RATC/T8aD decode β†’ what the GAME CODE does per frame + β†’ the draw calls it submits β†’ Canary's own processing + β†’ the presented frame +``` + +Specifically, and none of it inferable from a file alone: + +* **The game's per-frame update.** Which code advances a UI group's clock, in + what units, and what it does *between* keyframes β€” does it interpolate, or + hold to the next key? That single question decides whether the port should + lerp at all. It is in the image; find the function. +* **What the game submits per frame during the splash** β€” the draw list frame by + frame, not one settled frame. If the alpha changes, it changes *somewhere* + observable: a vertex colour, a PS constant, a blend factor, a texture swap. + **Name which, with the per-frame series.** +* **What Canary does to it.** Present cadence, any resolve/scaling/gamma between + the guest's draw and the pixels a capture records. A capture is evidence about + *Canary's output*, and the difference between that and the guest's intent has + bitten this corpus before (the `kernel_display_gamma_type` entry). + +⚠️ **Deliver a per-frame SERIES, not a settled value.** Follow +[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md): film it, align by +content, report ordering/counts/durations. The port needs to know what the +alpha *trajectory* is, and a single frame cannot carry one. + +## And this is a refutation the port should record against itself + +`BLOCKED.md` H2 currently reads βœ… **ANSWERED**, on the strength of the frozen +sweep. The mechanism half stands β€” the blur is a baked companion texture, that +is decoded and correct. **The behaviour half does not**: the port draws those +quads and does not animate them, so *"the companions are drawn"* was true and +did not mean what the row used it to mean. diff --git a/docs/agents/decoder-loop.md b/docs/agents/decoder-loop.md index b6781e33..5a0f2cf1 100644 --- a/docs/agents/decoder-loop.md +++ b/docs/agents/decoder-loop.md @@ -1,7 +1,58 @@ You are the **Decoder**. Answer the open questions the Godot menu port is blocked on, one at a time. -## πŸ”΄ CURRENT FOCUS, set by the human 2026-09-01: THE LOGO SPLASHES, EXACTLY +## πŸ”΄πŸ”΄ SOLE FOCUS, set by the human 2026-09-02: THE WHOLE GRAPHICS PIPELINE + +**Read [`PLAYTEST-2026-09-02.md`](PLAYTEST-2026-09-02.md) first. Work nothing +else until this is answered.** + +A human on real hardware: *"the logos just switch, there is no animation."* +Measured from a real boot β€” **the splash moves 1.30 s of 7.95 s (16.4 %)**, the +publisher logo frozen **3.20 s**, and the whole thing takes **26 distinct luma +states**. The port draws the right quads in the right places and never moves +them. + +Your half is not the port's bug. It is that **nobody can say what the game does +between keyframes**, so nobody can say what the port should be doing. + +### The deliverable, in the human's words + +> *"Get the whole graphics pipeline, from the xex/pe + the disc files to the +> final screen displayed. Take Xenia Canary processing into account too."* + +One continuous account, each stage carrying its evidence and its `⟨instrument⟩`: + +``` +disc bytes β†’ RATC/T8aD decode β†’ what the GAME CODE does per frame + β†’ the draw calls it submits β†’ Canary's own processing + β†’ the presented frame +``` + +Three questions that are load-bearing and none answerable from a file alone: + +1. **The per-frame update.** Which function advances a UI group's clock, in what + units, and **what does it do BETWEEN keyframes** β€” interpolate, or hold to the + next key? That single answer decides whether the port should lerp at all. It + is in the image. Find it. +2. **What is submitted per frame during the splash** β€” the draw list frame by + frame, not one settled frame. If alpha changes it changes *somewhere* + observable: a vertex colour, a PS constant, a blend factor, a texture swap. + **Name which, and give the per-frame series.** +3. **What Canary does to it** β€” present cadence, and any resolve, scale or gamma + between the guest's draw and the pixels a capture records. A capture is + evidence about *Canary's output*; the gap between that and the guest's intent + has bitten this corpus before (`kernel_display_gamma_type`). + +⚠️ **Deliver a SERIES, not a settled value.** Follow +[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md): film it, align by +content, report ordering and counts and durations. The port needs the alpha +*trajectory*; a single frame cannot carry one. +[`../../tools/motion-census`](../../tools/motion-census) measures change and +nothing else β€” use it on your own captures too, and note that three of the +port's instruments passed a frozen screen because each measured throughput or a +pose rather than change. + +## Previous focus, 2026-09-01 (still live, but AFTER the above) A human played the port on real hardware and reported that the splashes are **close but not right** β€” the fade/blur is more pronounced in the game β€” and that diff --git a/docs/agents/port-loop.md b/docs/agents/port-loop.md index 5a23a9f7..c0e95400 100644 --- a/docs/agents/port-loop.md +++ b/docs/agents/port-loop.md @@ -1,6 +1,53 @@ You are the **Port**. Build the Godot menu shell, one milestone at a time. -## πŸ”΄ CURRENT FOCUS, set by the human 2026-09-01: THE LOGO SPLASHES, EXACTLY +## πŸ”΄πŸ”΄ SOLE FOCUS, set by the human 2026-09-02: THE SPLASH DOES NOT ANIMATE + +**Read [`../agents/PLAYTEST-2026-09-02.md`](../agents/PLAYTEST-2026-09-02.md) +first. Work nothing else until this is fixed** β€” not the clock rate, not the +plate, not blend, not audio. + +> *"The port does no blur animation at all. The logos just switch."* + +Measured from a real boot, not paraphrased: **the splash moves 1.30 s of 7.95 s +(16.4 %)**, the publisher logo is **frozen for 3.20 s**, the developer logo for +2.40 s, and the whole 7.95 s takes **26 distinct luma states**. A 45-unit +build-in cannot be drawn in 26 states. + +πŸ”΄ **Your three instruments all passed this, and the reason is the point:** + +* the **frozen sweep** drives the clock by hand β€” it proves the renderer can + draw pose *N*, never that the poses are drawn in sequence while running; +* the **settled comparison** scored 0.01 % β€” a screen frozen 84 % of the time + matches a settled reference *perfectly*, because that is what frozen means; +* the **achieved-fps counter** counts frames DRAWN β€” drawing the same pixels + 25Γ—/s scores exactly like animating. + +**Every one measured throughput or a pose. None measured CHANGE.** Same shape as +`InputEventAction` bypassing the input map: the instrument sat below the thing +that was broken. + +**Use [`tools/motion-census`](../../tools/motion-census)** β€” it measures change +and nothing else, and its `--selftest` proves it separates a fade from a switch +from a frozen film. Order of work: + +1. **Reproduce first**, with `--film` + `motion-census`, and quote the numbers. + If you do not get ~16 %, that disagreement is the finding β€” say so. +2. **Find why the poses do not advance.** Unranked, none established: + interpolation returning one pose across a range of *t*; `rest()`/plateau + snapping to an endpoint; the group clock not integrating; nearest-keyframe + instead of lerp; advancing by keyframe *index* rather than by time. +3. **Every fix is gated by a FILM, never a still.** A change that improves a + settled frame and leaves the film at 16 % has not fixed this. +4. Put `motion-census` in `check-all` so the regression fails a check instead of + waiting for a human. + +⚠️ **And record the refutation against yourself.** `BLOCKED.md` H2 reads βœ… +ANSWERED on the strength of the frozen sweep. The *mechanism* half stands β€” the +blur is a baked companion texture, decoded and correct. The *behaviour* half does +not: you draw those quads and do not animate them, so "the companions are drawn" +was true and did not mean what the row used it to mean. + +## Previous focus, 2026-09-01 (still live, but AFTER the above) A human played this port on a real controller for the first time. Read [`../agents/PLAYTEST-2026-09-01.md`](../agents/PLAYTEST-2026-09-01.md) **before diff --git a/tools/motion-census b/tools/motion-census new file mode 100755 index 00000000..af44b742 --- /dev/null +++ b/tools/motion-census @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Does it ANIMATE, or does it switch? β€” compare a film, never two stills. + + tools/motion-census [--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 +except ImportError: + sys.exit("motion-census: needs Pillow (pip install pillow)") + +# 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:]))