Move the loader onto the 68000, and find 5,920 bytes nobody counted

src/player/load.i expands both codebooks to word-per-pixel form and packs the
palette to GGGGGRRRRRBBBBBI out of the RAW container header, byte-exact against
tools/bench/dlxload.py on both CPU cores.  The palette half is gated on words
read back out of the palette registers at $E82000, so "the words reached the
hardware" is part of what passes.  ROADMAP P1 is done; P2's encoder half (a
reserved black entry, 23.4) is not, and is a re-encode rather than an edit.

A scene change costs 18.96 ms of 68000 time, 22.8% of one 12 fps frame; boot
costs 24.70 ms.  The scratch tables describe the CRTC, not the scene, so
pal_tables is a separate entry point built once at boot -- 5.29 ms off every
scene change.

The one that moves something: the scene header is 5,920 B that no rate table in
this tree included, because it belongs to no frame record.  In FINDINGS 51.3's
currency it is divided by the surplus pipe - wire, so it is hypersensitive:
138 ms of extra refill climb at 488 KB/s and 1.099 s at 451.4 KB/s, for the
same bytes.  tools/analysis/22_scene_load.py prices it across explicit rates.

Recorded as open: the two CPU cores agree to <3% on every stage but the table
build, where they differ by 16.4%.  px68k's C68K charges a flat 50 clocks for
MULU/MULS (c68kmacro.h:1869) where the 68000 charges 38+2n, which explains
4,608 of the 8,703 clock gap.  4,095 clocks are unexplained.  Nothing else in
src/player/ multiplies, so no figure in FINDINGS 24-52 is affected.

decode.s and stream.s are untouched; decode.bin is still 1,296 B at the same
MD5.  check.sh gains a stage that gates byte-exactness on both cores and
deliberately does not gate the cycle counts -- MAME's clock is 1/55.46 s and a
wall timing would make the green light host-sensitive.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-24 20:20:40 -07:00
parent ed172c2da2
commit 7179339bd2
13 changed files with 1189 additions and 18 deletions
+245
View File
@@ -0,0 +1,245 @@
; ---------------------------------------------------------------------------
; load.i -- the two LOAD-TIME transforms, on the 68000 itself. ROADMAP P1+P2.
;
; Until now both of these were done host-side, in tools/bench/dlxload.py, and
; the rigs pushed the RESULT into emulated RAM. That was the right call while
; the inner loop was the thing being measured -- charging a once-per-scene cost
; to the per-frame path would have flattered or damned it for no reason -- but
; a player has no host. These are the bytes that replace it.
;
; The reference is tools/bench/dlxload.py and it stays the reference: this code
; is gated BYTE-FOR-BYTE against it (tools/bench/verify_load.py), palette words
; and darkest-entry index included. If the two ever disagree, the symptom in a
; rig would be wrong colours rather than a crash, which is exactly the class of
; bug the split was made to prevent.
;
; WHAT IT READS. The RAW container as it comes off the disc. The DLX header is
; fixed-layout and big-endian (tools/encoder/dlx.py):
; +0 magic 'DLX3' +12 k1 u16 +16 off_pal u32
; +4 W u16 +14 k4 u16 +20 off_cb1 u32
; +6 H u16 +24 off_cb4 u32
; +8 fps u16 +28 off_frm u32
; +10 nframes u16
; The three offsets are container-relative, so every one of them is an add of
; the base the loader was handed. Nothing here parses a frame record.
;
; WHAT IT WRITES. CB1 (8 KB) and CB4 (2 KB) expanded to one WORD per pixel at
; the addresses geom.i names, and 256 packed palette words straight into the
; graphics palette at $E82000. It also reports the darkest entry, which is what
; the letterbox is filled with until the encoder reserves a black one (23.4,
; still open).
;
; WHY WORD-PER-PIXEL. The block loop movems codebook entries straight into
; GVRAM with no unpacking, and the high byte of a GVRAM word write is discarded
; by the hardware, so the high byte is left zero and never has to be cleared.
; It also makes index scaling a shift rather than a multiply (lsl.w #5 / #3).
;
; SCRATCH. Three tables, built here and dead the moment the palette is packed:
; P6TAB 64 B 6-bit level -> the 8-bit value the hardware renders it as
; SQTAB 256 B the square of that, so the darkest-entry search has no muls
; DTAB 512 B err(v, I=0) - err(v, I=1) per 8-bit channel value, signed
; DTAB is what turns P2's per-entry minimum-squared-error choice of the shared
; LSB into three table reads and a sign test. Choosing I per entry rather than
; fixing it is worth 1.96 dB (FINDINGS 23.3), and it is a per-ENTRY decision
; across three channels, so it cannot be folded into a per-channel table alone.
; ---------------------------------------------------------------------------
LFLAG = $18040 ; 0 idle / 1 running / $FF done / $EE bad header
LHDR = $18044 ; -> raw container base
LDARK = $18048 ; <- index of the darkest palette entry
LK1 = $1804C ; <- k1, as the 68000 read it out of the header
LK4 = $18050 ; <- k4
LMODE = $18054 ; bit0 codebooks, bit1 palette entries,
; bit2 the three scratch tables
LITER = $18058 ; repeat count, so a 55 Hz host clock can time it
P6TAB = $19000 ; 64 bytes
SQTAB = $19040 ; 64 longs
DTAB = $19140 ; 256 words
GPAL = $E82000 ; graphics palette, 256 words
; ---------------------------------------------------------------- do_load
; in: a0 = container base, d1 = mode bits: 1 codebooks, 2 palette entries,
; 4 the scratch tables. A player builds the tables ONCE at boot (they
; describe the hardware's colour rendering and nothing about the scene) and
; then loads each scene with 3.
; out: d0 = 0 ok, -1 not a DLX3 container. a0-a4 clobbered, a5 = base.
do_load:
movea.l a0,a5
cmpi.l #$444C5833,(a5) ; 'DLX3'
bne .bad
move.w 12(a5),d0
ext.l d0
move.l d0,LK1.l
move.w 14(a5),d0
ext.l d0
move.l d0,LK4.l
btst #2,d1
beq.s .notab
move.l d1,-(sp)
bsr pal_tables
move.l (sp)+,d1
.notab:
btst #0,d1
beq.s .nocb
moveq #0,d2 ; the count is built as a LONG and the
move.w 12(a5),d2 ; high word must not carry junk into it
lsl.l #4,d2 ; k1 entries x 16 source bytes
movea.l 20(a5),a0
adda.l a5,a0
lea CB1,a1
bsr expand
moveq #0,d2
move.w 14(a5),d2
lsl.l #2,d2 ; k4 entries x 4 source bytes
movea.l 24(a5),a0
adda.l a5,a0
lea CB4,a1
bsr expand
.nocb:
btst #1,d1
beq.s .nopal
bsr pal_pack
.nopal:
moveq #0,d0
rts
.bad: moveq #-1,d0
rts
; ---------------------------------------------------------------- expand
; One source byte -> one destination word, high byte zero.
; in: a0 src, a1 dst, d2 = source byte count. Always a multiple of 4: CB1 is
; k1*16 and CB4 is k4*4, so no remainder case can exist and none is written.
; A junk high word here is not a slow path, it is a WRONG one: `lsr.l #2` walks
; two of its bits down into the low word and the dbra count comes out long.
expand:
lsr.l #2,d2
subq.l #1,d2 ; k<=256, so the count fits a dbra
moveq #0,d0
.e1: move.b (a0)+,d0
move.w d0,(a1)+
move.b (a0)+,d0
move.w d0,(a1)+
move.b (a0)+,d0
move.w d0,(a1)+
move.b (a0)+,d0
move.w d0,(a1)+
dbra d2,.e1
rts
; ---------------------------------------------------------------- pal_tables
; The three scratch tables. SCENE-INDEPENDENT, every one of them: they describe
; how the CRTC renders a 5-bit channel plus the shared LSB, which is a property
; of the machine. A player builds them once at boot and never again, which is
; why they are a separate entry point rather than the head of pal_pack -- see
; FINDINGS 53.3 for what that is worth.
pal_tables:
; -- P6TAB[x] = ((x<<2)|(x>>4)) & $FF, and SQTAB[x] = P6TAB[x]^2
lea P6TAB,a0
lea SQTAB,a1
moveq #0,d1
.p1: move.w d1,d0
lsl.w #2,d0
move.w d1,d2
lsr.w #4,d2
or.w d2,d0
andi.w #$FF,d0
move.b d0,(a0)+
move.w d0,d2
mulu d2,d2
move.l d2,(a1)+
addq.w #1,d1
cmpi.w #64,d1
bne.s .p1
; -- DTAB[v] = (render(v,0)-v)^2 - (render(v,1)-v)^2, signed
lea P6TAB,a0
lea DTAB,a1
moveq #0,d1
.p2: move.w d1,d2
lsr.w #2,d2
andi.w #$3E,d2 ; x0 = (v>>3)<<1
moveq #0,d3
move.b 0(a0,d2.w),d3
sub.w d1,d3
muls d3,d3
moveq #0,d4
move.b 1(a0,d2.w),d4
sub.w d1,d4
muls d4,d4
sub.l d4,d3
move.w d3,(a1)+
addq.w #1,d1
cmpi.w #256,d1
bne.s .p2
rts
; ---------------------------------------------------------------- pal_pack
; 24-bit RGB -> GGGGGRRRRRBBBBBI, the shared LSB chosen per entry by minimum
; squared error, written to the palette registers. Identical arithmetic to
; dlxload.pack_palette, including its tie-breaks: I stays 0 when the two errors
; are equal, and the darkest entry is the FIRST index at the minimum.
; in: a5 = container base, and pal_tables already run.
pal_pack:
movea.l 16(a5),a0
adda.l a5,a0 ; -> 256 x RGB888
lea GPAL,a1
lea DTAB,a2
lea SQTAB,a4 ; P6TAB is not needed here: the rendered
; value is only ever wanted SQUARED
move.l #$7FFFFFFF,d6
clr.l LDARK.l
moveq #0,d7
.p3: moveq #0,d1
move.b (a0)+,d1 ; R
moveq #0,d2
move.b (a0)+,d2 ; G
moveq #0,d3
move.b (a0)+,d3 ; B
move.w d1,d0
add.w d0,d0
move.w 0(a2,d0.w),d4
move.w d2,d0
add.w d0,d0
add.w 0(a2,d0.w),d4
move.w d3,d0
add.w d0,d0
add.w 0(a2,d0.w),d4 ; sum of err0-err1 over the three
moveq #0,d5
tst.w d4
ble.s .p4
moveq #1,d5 ; I=1 only when it is STRICTLY better
.p4: lsr.w #3,d1 ; fR
lsr.w #3,d2 ; fG
lsr.w #3,d3 ; fB
move.w d2,d4
lsl.w #5,d4
or.w d1,d4
lsl.w #6,d4 ; (fG<<11)|(fR<<6)
move.w d3,d0
add.w d0,d0
or.w d0,d4
or.w d5,d4
move.w d4,(a1)+ ; -> the palette register
add.w d1,d1 ; x = (f<<1)|I, per channel
or.w d5,d1
add.w d2,d2
or.w d5,d2
add.w d3,d3
or.w d5,d3
lsl.w #2,d1 ; SQTAB holds longs
move.l 0(a4,d1.w),d0
lsl.w #2,d2
add.l 0(a4,d2.w),d0
lsl.w #2,d3
add.l 0(a4,d3.w),d0 ; squared distance from black
cmp.l d6,d0
bge.s .p5
move.l d0,d6
move.l d7,LDARK.l ; first index at the minimum wins
.p5: addq.w #1,d7
cmpi.w #256,d7
bne .p3
rts
+38
View File
@@ -0,0 +1,38 @@
; Front-end for the load-time transforms (ROADMAP P1+P2), for the rig.
;
; It is to load.i what decode.s is to frame.i: a timing and control wrapper that
; does nothing the shipping player would not do, so that the bytes being
; measured are the bytes that will ship. The player's own boot path will call
; do_load once with the mode bits set to 3; this repeats it LITER times so a
; host clock with 1/55.46 s granularity can time a job that takes milliseconds,
; and splits it by LMODE so the codebook expansion and the palette pack can be
; priced apart. A player calls do_load with mode 7 once at boot -- the three
; scratch tables describe the machine, not the scene -- and with mode 3 at every
; scene change after that.
;
; Repeating is honest here in a way it would not be for a frame: nothing in
; do_load is temporally recursive. Pass n writes exactly what pass n-1 wrote,
; over the top of it, out of the same source bytes.
include "src/player/geom.i"
org $10000
start:
move.l LMODE.l,d1
move.l LITER.l,d3
move.l #1,LFLAG.l ; timer starts here
loop:
movem.l d1/d3,-(sp)
movea.l LHDR.l,a0
bsr do_load
movem.l (sp)+,d1/d3
tst.l d0
bne.s bad
subq.l #1,d3
bne.s loop
move.l #$FF,LFLAG.l ; timer stops here
hold: bra.s hold
bad: move.l #$EE,LFLAG.l
bra.s hold
include "src/player/load.i"