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
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Reference DLX1 reader/decoder -- the ground truth the 68000 player is checked against.
This is deliberately a *decoder*, not a re-run of the encoder: it parses the
container byte-for-byte the way `src/player/` must, so that any disagreement
between it and the 68000 is a decoder bug rather than two encoders differing.
`tools/analysis/09_ratectl_drift.py` already gates the encoder against its own
reconstruction; this gates the container against the player.
Everything is big-endian (see the `encode.py` docstring). Block raster order,
2-bit modes packed MSB-first: 00=SKIP 01=V1 10=V4 11=RAW.
V4 sub-block order is (sub_y, sub_x) row-major -- TL, TR, BL, BR -- matching
`vq_hybrid.paint`'s reshape(-1,2,2,2,2).transpose(0,1,3,2,4).
"""
import struct
import numpy as np
MODE_SKIP, MODE_V1, MODE_V4, MODE_RAW = 0, 1, 2, 3
class DLX:
def __init__(self, path):
self.raw = open(path, "rb").read()
b = self.raw
if b[:4] != b"DLX1":
raise ValueError(f"{path}: not a DLX1 container")
(self.W, self.H, self.fps, self.nframes,
self.k1, self.k4) = struct.unpack(">HHHHHH", b[4:16])
off_pal, off_cb1, off_cb4, off_frm = struct.unpack(">IIII", b[16:32])
self.pal = np.frombuffer(b, np.uint8, 256 * 3, off_pal).reshape(256, 3)
self.cb1 = np.frombuffer(b, np.uint8, self.k1 * 16,
off_cb1).reshape(self.k1, 4, 4)
self.cb4 = np.frombuffer(b, np.uint8, self.k4 * 4,
off_cb4).reshape(self.k4, 2, 2)
self.idx_bytes = 1 if max(self.k1, self.k4) <= 256 else 2
self.nbx, self.nby = self.W // 4, self.H // 4
self.nb = self.nbx * self.nby
self.mode_bytes = (self.nb * 2 + 7) // 8
# frame directory: (offset of the mode header, payload length)
self.frames = []
p = off_frm
for _ in range(self.nframes):
(n,) = struct.unpack(">I", b[p:p + 4])
self.frames.append((p + 4, n))
p += 4 + n
if p != len(b):
raise ValueError(f"{path}: {len(b) - p} trailing bytes after "
f"{self.nframes} frames")
def modes(self, f):
o, _ = self.frames[f]
h = np.frombuffer(self.raw, np.uint8, self.mode_bytes, o)
m = np.stack([(h >> 6) & 3, (h >> 4) & 3, (h >> 2) & 3, h & 3], axis=1)
return m.reshape(-1)[:self.nb].copy()
def blocks(self, f):
"""Decoded 4x4 palette-index blocks for the non-SKIP blocks of frame f.
Returns (mode, dict{block index -> (4,4) uint8}). SKIP blocks are
absent by construction -- the player must leave those pixels alone,
and a decoder that materialises them is hiding the very bug this
module exists to catch.
"""
mode = self.modes(f)
o, n = self.frames[f]
p, end = o + self.mode_bytes, o + n
ib, out = self.idx_bytes, {}
b = self.raw
for i, mo in enumerate(mode):
if mo == MODE_SKIP:
continue
if mo == MODE_V1:
v = b[p] if ib == 1 else (b[p] << 8) | b[p + 1]
p += ib
out[i] = self.cb1[v]
elif mo == MODE_V4:
sub = []
for _ in range(4):
v = b[p] if ib == 1 else (b[p] << 8) | b[p + 1]
p += ib
sub.append(self.cb4[v])
blk = np.empty((4, 4), np.uint8)
blk[0:2, 0:2], blk[0:2, 2:4] = sub[0], sub[1]
blk[2:4, 0:2], blk[2:4, 2:4] = sub[2], sub[3]
out[i] = blk
else:
out[i] = np.frombuffer(b, np.uint8, 16, p).reshape(4, 4)
p += 16
if p != end:
raise ValueError(f"frame {f}: payload consumed {p - o} of {n} bytes")
return mode, out
def paint(self, canvas, f):
"""Apply frame f in place to a (H,W) index canvas. SKIP = untouched."""
mode, blks = self.blocks(f)
for i, blk in blks.items():
by, bx = divmod(i, self.nbx)
canvas[by * 4:by * 4 + 4, bx * 4:bx * 4 + 4] = blk
return mode
def decode_all(self):
"""The true reconstruction sequence: what any correct player displays."""
c = np.zeros((self.H, self.W), np.uint8)
out = []
for f in range(self.nframes):
self.paint(c, f)
out.append(c.copy())
return out