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:
@@ -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
|
||||
Reference in New Issue
Block a user