The 68000 decoder draws pixel-exact frames, and does not fit

src/player/decode.s parses DLX1 and decodes straight into GVRAM. Verified
pixel-exact over a 120-frame sequential run of the worst sustained window on
the disc -- all four block modes, full temporal recursion, so the last frame
is only right if all 120 were. In check.sh.

It costs a mean of 81.7% of a 12fps frame budget, and 31% of frames exceed
100% (42% at scsi). CPU is now the binding constraint. FINDINGS 28.

Three things that were believed and are not true:

- The dual-display-path plan of FINDINGS 24.5/25.6 is incoherent. The compose
  path needs a RAM copy of the previous reconstruction; the direct path's
  selling point is that it keeps none. Mixing them shows stale pixels on 70 of
  120 frames, worst frame 18.8% of the screen. Every coherent repair is dearer
  than not mixing, and 24.5's two figures were both copies with no decode in
  either, so there was never a crossover to find. One path ships, and the 96KB
  reference frame is gone. tools/analysis/10_pathmix_drift.py keeps the
  counterexample runnable; check.sh asserts it still reproduces.

- The four block modes do not cost the same. V1 300, V4 448, RAW 400 cycles
  against the old model's flat 207.8. V4 is 25% of blocks and 50% of the
  cycles, and the mode decision charges it bytes it does not charge cycles for.
  tools/analysis/11_cpu_budget.py reproduces all four frames timed on the
  68000 to within 1 point. Hand-derived timings agree to 0.5% on V1.

- The container is big-endian but not aligned. Variable-length records laid end
  to end put frame 1's length field at an odd address, and move.l (a0)+ there
  is an address error: frame 0 decoded perfectly and then vectored into the
  IPL for 59 emulated seconds looking like a hang. Found by dumping PC, not by
  reading the source.

Also: an all-V1 frame, the cheapest possible full redraw, is 110.5% of budget.
No mode assignment fits a scene cut at 12fps. That one needs a decision, not a
measurement.

Next: charge cycles in the mode decision and bisect against 833,333 per frame,
the way session 6 bisects lam against bytes -- but with no bucket, because a
late frame cannot be banked.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 15:04:38 -07:00
parent 497f88b945
commit e1aa26bb57
11 changed files with 1276 additions and 38 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Is the 68000 decoder's output pixel-exact against the reference decoder?
python3 tools/bench/verify_decode.py <in.dlx> [--snap tmp/snap_decode]
Checks tmp/snap_decode/x68000/0000.png -- the screen after src/player/decode.s
has decoded every frame of the container in sequence -- against
tools/encoder/dlx.py's reconstruction of the final frame.
This is a stronger test than the blit regression it is modelled on. The blit
proved the 68000 could COPY a frame; this proves it can PARSE one. And because
the decoder is temporally recursive -- a SKIP block is a claim that the previous
frame is still in GVRAM -- the last frame of a sequential run is only correct if
every frame before it was, so a single comparison audits all of them.
"""
import argparse, sys
sys.path.insert(0, "tools/encoder")
import numpy as np
from PIL import Image
from dlx import DLX
ap = argparse.ArgumentParser()
ap.add_argument("container")
ap.add_argument("--snap", default="tmp/snap_decode")
a = ap.parse_args()
d = DLX(a.container)
canvas = np.zeros((d.H, d.W), np.uint8)
for f in range(d.nframes):
d.paint(canvas, f)
pal = d.pal.astype(int)
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
fl = pal >> 3
render = lambda I: p6((fl << 1) | I[:, None])
I = (((render(np.ones(256, int)) - pal) ** 2).sum(1)
< ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int)
exp = render(I)[canvas]
s = np.asarray(Image.open(f"{a.snap}/x68000/0000.png").convert("RGB")).astype(int)
fail = []
if s.shape[:2] != (512, 256):
fail.append(f"1. geometry: expected 512x256, got {s.shape[1]}x{s.shape[0]}")
else:
if not all(np.array_equal(s[i], s[i+1]) for i in range(1, s.shape[0]-1, 2)):
fail.append("2. double-scan pairing (1,2),(3,4),... broken")
g = s[0::2]
yoff = (g.shape[0] - d.H) // 2
act = g[yoff:yoff+d.H]
if not np.array_equal(act, exp):
diff = abs(act - exp)
bad = diff.any(2)
by, bx = np.where(bad)
blocks = sorted(set(zip((by//4).tolist(), (bx//4).tolist())))
fail.append(f"3. frame {d.nframes-1} not pixel-exact: {bad.sum()} px in "
f"{len(blocks)} blocks differ, maxdiff {diff.max()}; "
f"first block (by={blocks[0][0]}, bx={blocks[0][1]})")
for x in fail:
print("FAIL " + x)
if fail:
sys.exit(1)
print(f"OK {d.nframes} frames decoded on the 68000, final frame pixel-exact "
f"against tools/encoder/dlx.py")
print(f" {d.W}x{d.H}, {d.nb} blocks/frame, k1={d.k1} k4={d.k4}, "
f"all four block modes exercised")