Files
Dragon-s-Lair-X68k/tools/bench/verify_frame256.py
T
prosolis 29eb78a599 Measure the span: the mode survives, and it is an encoder format
FINDINGS 29 priced a literal-span mode at 4*(50 + 4L*9.08) cycles and labelled
the whole section DERIVED. Session 8 step 0 was to measure it before optimising
over the mode set it implies. Two variants in blit.s, one stream per span length
from prep_spans.py, timed by span.lua, driven by span.sh in ~25 s:

  v5, handed (x, npix) and left to work the copy out:  97.9/span + 10.459/px
  v6, handed an address and a jump displacement:       43.7/span +  9.152/px
  29 assumed                                           50.0/span +  9.080/px

So 29's arithmetic was right about a format nobody had written. The difference
is not tuning: v5 spends ~122 cycles a span computing a destination, dividing
npix into bursts and handling a 0..15 remainder, all of which the encoder knows
at build time. v6's record is {u32 absolute GVRAM address, u16 jump
displacement} into an unrolled chain of 24-pixel copy units -- no loop, no
remainder, no arithmetic -- and it fits 11 span lengths to 0.3%.

Three things that measurement showed and derivation could not:

  - The per-pixel cost is a function of REGISTER PRESSURE. FINDINGS 24's 9.08
    was a fixed blit with 12 registers free; v5 can spare 8 and pays 10.46; v6
    gets 12 back only because the encoder holds the state.
  - Short spans die in the remainder path -- a 12-pixel span costs MORE than a
    16-pixel one -- and the fix is padding, not avoidance.
  - Odd-x alignment is free (259.0 vs 261.8 cycles/span), as a 16-bit bus
    implies but nobody had checked.

Re-priced against the unchanged mode maps, sasi: median 74.4% -> 52.0% (29 said
43.0), misses 37 -> 10/120 (29 said 8), 448.0 KB/s. Break-even moved from runs
of 2 blocks to runs of 4. 29.4 survives: a scene cut needs x >= 0.196 of the
frame as spans and the bus allows x <= 0.373, so it fits at 12fps.

All 23 timing configs are also checked pixel-exact, so none of this was timed
against a decoder that quietly skipped work.

FINDINGS 30. Next: lever B, the cost-aware mode decision.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
2026-08-23 15:45:51 -07:00

63 lines
2.5 KiB
Python

#!/usr/bin/env python3
"""Regression test for the 256x256 CRTC mode (docs/FINDINGS 23).
Checks a native snapshot (default tmp/snap256/x68000/0000.png, override with
argv[1] -- tools/bench/span.lua verifies twelve of them) against
tmp/frame256.bin:
1. native snapshot is 256x512 -- 256 dots, and 512 active scanlines of a
568-line 31.5kHz raster carrying 256 double-scanned graphics rows
2. double-scan pairing is (1,2),(3,4),... -- MAME halves the ABSOLUTE
scanline (x68k_v.cpp get_gfx_pixel) and vbegin=41 is odd, so snapshot
row 0 is a lone half-line and even rows are gfx rows 0..255
3. the 192 active rows are PIXEL-EXACT against the palette pushed through
GGGGGRRRRRBBBBBI with I chosen per entry by minimum squared error
4. the letterbox bars are TRUE black -- needs both a reserved index-0 black
entry AND I=0 on it, since pal6bit(1) = 4, not 0
"""
import struct, sys
import numpy as np
from PIL import Image
snap = sys.argv[1] if len(sys.argv) > 1 else "tmp/snap256/x68000/0000.png"
s = np.asarray(Image.open(snap).convert("RGB")).astype(int)
d = open("tmp/frame256.bin", "rb").read()
W, H = struct.unpack(">HH", d[4:8])
pal = np.frombuffer(d[8:8+768], np.uint8).reshape(256, 3).astype(int)
idx = np.frombuffer(d[8+768:8+768+W*H], np.uint8).reshape(H, W)
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
f = pal >> 3
render = lambda I: p6((f << 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)[idx]
fail = []
if s.shape[:2] != (512, 256):
fail.append(f"1. geometry: expected 512x256, got {s.shape[1]}x{s.shape[0]}")
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] - H) // 2
act = g[yoff:yoff+H]
if not np.array_equal(act, exp):
diff = abs(act - exp)
fail.append(f"3. active area not pixel-exact: maxdiff {diff.max()}, "
f"{diff.any(2).sum()} px differ")
bars = np.concatenate([g[:yoff], g[yoff+H:]])
if bars.max() != 0:
fail.append(f"4. letterbox not true black: max channel {bars.max()}")
for x in fail:
print("FAIL " + x)
if fail:
sys.exit(1)
mse = ((act - pal[idx]) ** 2).mean()
print(f"OK {snap}: 256x512 native, double-scan exact, active {W}x{H} pixel-exact, "
f"letterbox true black")
print(f" palette ceiling vs 24-bit palettised source: "
f"{10*np.log10(255**2/mse):.2f} dB ({(I==0).sum()}/256 entries use I=0)")