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
+202
View File
@@ -0,0 +1,202 @@
; DLX1 frame decoder for a stock 68000 @ 10MHz -- direct-to-GVRAM, single path.
;
; WHY ONE PATH. FINDINGS 24.5/25.6 specified two display paths chosen per
; frame (compose-in-RAM-then-blit vs decode-direct-to-GVRAM, crossing at 70% of
; blocks changed). That plan is incoherent: the compose path assembles a FULL
; frame in RAM so its blit can be row-linear, and the pixels it does not decode
; this frame -- the SKIP blocks -- can only come from a RAM copy of the previous
; reconstruction, which the direct path deliberately never writes. Every direct
; frame invalidates the next compose frame's reference. Measured on the Singe
; window: 70 of 120 frames display stale pixels, worst frame 18.8% of the
; screen. See tools/analysis/10_pathmix_drift.py and FINDINGS 28.
;
; Every coherent version of the mix is worse than plain direct on the median,
; so this decoder implements direct only. It also drops the 96KB RAM reference
; frame entirely: the previous frame is already in GVRAM, so SKIP is genuinely
; free -- no read, no write, just a pointer advance.
;
; GEOMETRY (tools/bench/crtc_mode.lua). 256-colour page: one pixel per WORD of
; CPU address space, 1024-byte line stride, picture in rows 32..223. A 4x4
; block is therefore 4 rows of 8 bytes at a 1024-byte stride, so displacements
; 0/1024/2048/3072 all fit a 16-bit offset and one base pointer covers a block.
; A block row is 4 picture rows = 4096 bytes; 64 blocks x 8 bytes = 512.
;
; The HIGH byte of every GVRAM word write is discarded by the hardware (MAME
; 0.277 x68k_crtc.cpp:501, gvram_w case 0x0100), which is what makes the RAW
; path cheap: it never has to clear the odd bytes it builds.
;
; CODEBOOKS are pre-expanded to word-per-pixel form by the loader, so the inner
; loop movems them straight out with no unpacking:
; CB1 entry = 16 words, row-major = 32 bytes (k1=256 -> 8KB)
; CB4 entry = 4 words, row-major (2x2) = 8 bytes (k4=256 -> 2KB)
; Index scaling is therefore a shift, not a multiply: lsl.w #5 and lsl.w #3.
;
; REGISTERS are fully committed -- a0 payload, a1 mode header, a2/a3 codebooks,
; a4 block cursor, a5 block-row end, a6 block-row base, d0-d7 the 32-byte block
; transfer. Nothing survives a block, which is why each block re-reads its mode
; from (a1) rather than holding the packed byte in a register. That re-read is
; paid only by blocks that are NOT all-SKIP: a header byte of zero clears four
; blocks with one tst.b, and SKIP is the median block.
;
; ALIGNMENT. Frame records are [u32 length][768-byte mode header][payload] laid
; end to end, and payload lengths are arbitrary -- so record boundaries land on
; odd addresses, and `move.l (a0)+,d0` on an odd address is an ADDRESS ERROR on
; a 68000, not a slow read. It killed the first run: frame 0 decoded perfectly,
; then the length read for frame 1 at $03220F vectored into the IPL. Being
; big-endian is only half of what "the 68000 reads it with a plain move" needs.
; This decoder rounds each record start up to 4; the container itself should
; carry the padding so a streaming player can DMA records straight into place.
; FINDINGS 28.3.
FLAG = $18000 ; 0 idle / 1 running / $FF done / $EE desync
ITER = $18008 ; outer repeat count, written by Lua
NFR = $1800C ; frames per pass
FPTR = $18010 ; -> first frame record
SCR_N = $18014 ; frames remaining this pass
SCR_END = $18018 ; expected end of the current payload
CB1 = $20000 ; expanded 4x4 codebook
CB4 = $22000 ; expanded 2x2 codebook
DST0 = $C08000 ; GVRAM + 32*1024 (first picture row)
DSTE = $C38000 ; GVRAM + 224*1024 (one past last)
BROW = 4096 ; bytes per block row (4 picture rows)
ROWLEN = 512 ; bytes per block row of blocks (64 * 8)
MODEB = 768 ; packed mode header, 3072 blocks * 2 bits
org $10000
start:
move.l #1,FLAG.l ; timer starts here
outer:
move.l FPTR.l,a0
move.l NFR.l,SCR_N.l
frameloop:
move.l (a0)+,d0 ; u32 payload length, big-endian
lea 0(a0,d0.l),a1
move.l a1,SCR_END.l ; where the payload must end
move.l a0,a1 ; a1 = packed mode header
lea MODEB(a0),a0 ; a0 = payload
bsr decode_frame
cmpa.l SCR_END.l,a0 ; bitstream desync is silent otherwise
bne desync
move.l a0,d0 ; next record starts on a 4-byte boundary
addq.l #3,d0
and.b #$FC,d0
move.l d0,a0
subq.l #1,SCR_N.l
bne frameloop
subq.l #1,ITER.l
bne outer
move.l #$FF,FLAG.l ; timer stops here
hold: bra.s hold
desync: move.l #$EE,FLAG.l
bra.s hold
; ---------------------------------------------------------------- one block
; \1 = right-shift needed to bring this block's 2 mode bits to bits 1-0.
BLOCK macro
move.b (a1),d0
ifne \1
lsr.b #\1,d0
endc
and.w #3,d0
beq .sk\@ ; 00 SKIP -- the median block
subq.w #1,d0
beq .v1\@ ; 01 V1
subq.w #1,d0
bne .rw\@ ; 11 RAW, else 10 V4
; -- V4: four 2x2 codewords, sub-block order TL TR BL BR (vq_hybrid.paint)
moveq #0,d0
move.b (a0)+,d0
lsl.w #3,d0
movem.l (a3,d0.w),d0-d1
move.l d0,(a4)
move.l d1,1024(a4)
moveq #0,d0
move.b (a0)+,d0
lsl.w #3,d0
movem.l (a3,d0.w),d0-d1
move.l d0,4(a4)
move.l d1,1028(a4)
moveq #0,d0
move.b (a0)+,d0
lsl.w #3,d0
movem.l (a3,d0.w),d0-d1
move.l d0,2048(a4)
move.l d1,3072(a4)
moveq #0,d0
move.b (a0)+,d0
lsl.w #3,d0
movem.l (a3,d0.w),d0-d1
move.l d0,2052(a4)
move.l d1,3076(a4)
bra .sk\@
; -- V1: one 4x4 codeword, 32 bytes, straight out of the expanded codebook
.v1\@:
moveq #0,d0
move.b (a0)+,d0
lsl.w #5,d0
movem.l (a2,d0.w),d0-d7 ; EA is resolved before the load
movem.l d0-d1,(a4)
movem.l d2-d3,1024(a4)
movem.l d4-d5,2048(a4)
movem.l d6-d7,3072(a4)
bra .sk\@
; -- RAW: 16 literal palette indices. Two indices are assembled into one long
; via swap, so each pair of pixels costs one write instead of two; the high
; byte of each word is left as zero because the hardware discards it anyway.
.rw\@:
RAWPAIR 0
RAWPAIR 4
RAWPAIR 1024
RAWPAIR 1028
RAWPAIR 2048
RAWPAIR 2052
RAWPAIR 3072
RAWPAIR 3076
.sk\@:
addq.l #8,a4
endm
RAWPAIR macro
moveq #0,d0
move.b (a0)+,d0
swap d0
move.b (a0)+,d0
move.l d0,\1(a4)
endm
; ------------------------------------------------------------- one frame
; in: a0 = payload, a1 = packed mode header
; out: a0 = one past the last payload byte consumed
decode_frame:
lea CB1,a2
lea CB4,a3
lea DST0,a6
rowloop:
move.l a6,a4
lea ROWLEN(a6),a5
byteloop:
tst.b (a1) ; four SKIPs in one test
beq allskip
BLOCK 6
BLOCK 4
BLOCK 2
BLOCK 0
addq.l #1,a1
cmpa.l a5,a4
bne byteloop
bra rowdone
allskip:
addq.l #1,a1
lea 32(a4),a4
cmpa.l a5,a4
bne byteloop
rowdone:
lea BROW(a6),a6
cmpa.l #DSTE,a6
bne rowloop
rts