#!/usr/bin/env python3 """Build the README's stills and clips out of a real emulated run. tools/bench/pace_run.sh ... # or the DLX_SNAP_EVERY=1 run below python3 tools/media/make_readme_media.py [--snap tmp/snap_rec] Everything this writes comes from PNGs MAME wrote while `src/player/stream.s` decoded out of a 256 KB ring on an emulated stock X68000. Nothing is redrawn by the reference decoder and nothing is upscaled with interpolation -- the pixels in `docs/img/` are the pixels that were on the emulated screen. THE MAPPING IS ASSERTED, NOT ASSUMED. `stream.lua` snapshots at the frame tick, BEFORE that tick's frame is decoded, so snapshot n holds frame n-1 -- and that is an off-by-one waiting to put the wrong caption under a picture. This script finds the offset by comparing against `tools/encoder/dlx.py`'s reconstruction and refuses to write anything unless every frame matches exactly at one offset. A README that illustrates a pixel-exact decoder with an approximate picture would be a small lie about the one property the project keeps testing. Writes: docs/img/decoded-frame.png one frame, 3x nearest docs/img/source-vs-decoded.png Blu-ray source | 68000 output, same frame docs/img/player.webm the 120-frame window, side by side, 12 fps docs/img/modes.webm the same window with the block-mode map """ import argparse, os, subprocess, sys, shutil sys.path.insert(0, "tools/encoder") import numpy as np from PIL import Image, ImageDraw from dlx import DLX SNAP_H, SNAP_W = 512, 256 # MAME -snapview native, double-scanned ap = argparse.ArgumentParser() ap.add_argument("container", nargs="?", default="tmp/rc_fr_singe_scsi_span.dlx") ap.add_argument("--snap", default="tmp/snap_rec") ap.add_argument("--src", default="tmp/fr_singe", help="the extracted frames") ap.add_argument("--out", default="docs/img") ap.add_argument("--still", type=int, default=96, help="which frame for the stills") ap.add_argument("--fps", type=float, default=12.0) a = ap.parse_args() if not shutil.which("ffmpeg"): sys.exit("ffmpeg not found -- needed for the webm") os.makedirs(a.out, exist_ok=True) d = DLX(a.container) # --- the reference reconstruction, in the palette the machine actually shows. # Same derivation verify_decode.py uses: the X68000 word is GGGGGRRRRRBBBBBI, so # a channel is 5 bits plus a shared intensity, and I is chosen per entry by # minimum squared error against the RGB888 the encoder emitted (FINDINGS 23.3). pal = d.pal.astype(int) p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF fl = pal >> 3 render = lambda I: p6((fl << 1) | I[:, None]) I = (((render(np.ones(256, int)) - pal) ** 2).sum(1) < ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int) LUT = render(I).astype(np.uint8) canvas = np.zeros((d.H, d.W), np.uint8) ref, modes = [], [] for f in range(d.nframes): d.paint(canvas, f) ref.append(LUT[canvas].copy()) modes.append(d.modes(f).reshape(d.nby, d.nbx).copy()) def picture(png): """The 256x192 picture out of one MAME native snapshot.""" s = np.asarray(Image.open(png).convert("RGB")) if s.shape[:2] != (SNAP_H, SNAP_W): sys.exit(f"{png}: expected {SNAP_W}x{SNAP_H}, got {s.shape[1]}x{s.shape[0]}") g = s[0::2] # undo the double scan y = (g.shape[0] - d.H) // 2 # the picture is centred return g[y:y + d.H] snaps = sorted(f"{a.snap}/x68000/{n}" for n in os.listdir(f"{a.snap}/x68000") if n.endswith(".png")) if not snaps: sys.exit(f"no snapshots in {a.snap}/x68000 -- run stream.lua with " f"DLX_PACE=1 DLX_SNAP_EVERY=1") shots = [picture(p) for p in snaps] # --- match every snapshot to a frame, and classify what does not match ----- # The naive expectation is wrong in a way worth recording: stream.lua fires the # snapshot at the tick, BEFORE frame n is decoded, but MAME renders the screen # at the END of the machine frame -- by which time the 68000 has finished frame # n (it needs 69% of a 12 fps slot). So snapshot n IS frame n. That is a # statement about when a screen bitmap is captured, not about the decoder, and # it is the kind of thing to check rather than reason about. # # A handful of snapshots are TORN: the top of the picture is frame n and the # bottom still holds frame n-1, because the capture landed while the block loop # was partway down the screen. That is not a decoder fault and it is not an # artefact of the rig either -- decode.s writes straight to the displayed page # (FINDINGS 28.1, one display path, no flip), so a real player tears the same # way. They are KEPT, with the tear asserted: every differing pixel must equal # the previous frame, or this is something else and the script stops. frames, torn = {}, [] snap0 = shots[0] for n in range(1, len(shots)): j = n if j >= d.nframes: # pace_run.sh's own end-of-run snapshot continue sh = shots[n] if np.array_equal(sh, ref[j]): frames[j] = sh continue diff = (sh != ref[j]).any(2) if not np.array_equal(sh[diff], ref[j - 1][diff]): sys.exit(f"snapshot {n} is neither frame {j} nor a tear against frame " f"{j-1}:\n {diff.sum():,} pixels differ and they do not come " f"from the previous frame.\nThat is a decoder fault or a " f"changed snapshot geometry, not something to align around.") rows = np.where(diff.any(1))[0] torn.append((j, int(rows.min()), int(rows.max()))) frames[j] = sh exact = len(frames) - len(torn) print(f"{len(snaps)} snapshots -> frames 1..{max(frames)} of {d.nframes}.") print(f" {exact} PIXEL-EXACT against tools/encoder/dlx.py") if torn: print(f" {len(torn)} torn by the capture, each verified to be frame n on " f"top of frame n-1:") for j, r0, r1 in torn: print(f" frame {j}: rows {r0}..{r1} of {d.H} still hold frame {j-1}") print(f" dropped snapshot 0 (frame 0 caught part-drawn, nothing behind it) " f"and the\n end-of-run duplicate.") src = {} for j in frames: p = f"{a.src}/f{j+1:04d}.png" if os.path.exists(p): src[j] = np.asarray(Image.open(p).convert("RGB")) Z = 2 # nearest-neighbour zoom BAR = 22 # caption strip height def up(img, z=Z): return np.repeat(np.repeat(img, z, 0), z, 1) def captioned(img, text, z=Z): w = img.shape[1] * z out = Image.new("RGB", (w, img.shape[0] * z + BAR), (16, 16, 18)) out.paste(Image.fromarray(up(img, z)), (0, BAR)) ImageDraw.Draw(out).text((6, 6), text, fill=(190, 190, 196)) return out def side_by_side(j): left = captioned(src[j], "Blu-ray source, cropped 256x192") right = captioned(frames[j], "68000 output (emulated X68000, 256 colours)") out = Image.new("RGB", (left.width + right.width + 8, left.height), (16, 16, 18)) out.paste(left, (0, 0)); out.paste(right, (left.width + 8, 0)) return out # --- stills ---------------------------------------------------------------- still = a.still if a.still in frames else sorted(frames)[len(frames) // 2] Image.fromarray(up(frames[still], 3)).save(f"{a.out}/decoded-frame.png") print(f" {a.out}/decoded-frame.png frame {still}, 3x nearest") if still in src: side_by_side(still).save(f"{a.out}/source-vs-decoded.png") print(f" {a.out}/source-vs-decoded.png frame {still}") # --- the block-mode map ---------------------------------------------------- # The decoder's whole cost model is per mode, so the map is the picture the # FINDINGS tables are really about. Colours are the four modes, not a heat map. MODE_RGB = np.array([[16, 16, 18], # SKIP -- costs nothing, draws nothing [60, 130, 220], # V1 -- one index for a 4x4 block [235, 175, 60], # V4 -- four indices [225, 70, 70]], # RAW -- sixteen bytes verbatim np.uint8) NAMES = ("SKIP", "V1", "V4", "RAW") def mode_panel(j): m = MODE_RGB[modes[j]] m = np.repeat(np.repeat(m, 4, 0), 4, 1) # a block is 4x4 pixels return m[:d.H, :d.W] def mode_pair(j): left = captioned(frames[j], "68000 output") counts = np.bincount(modes[j].ravel(), minlength=4) lab = " ".join(f"{n} {100*c/counts.sum():.0f}%" for n, c in zip(NAMES, counts)) right = captioned(mode_panel(j), "block modes: " + lab) out = Image.new("RGB", (left.width + right.width + 8, left.height), (16, 16, 18)) out.paste(left, (0, 0)); out.paste(right, (left.width + 8, 0)) return out # --- clips ----------------------------------------------------------------- def webm(name, maker, keys): tmpd = f"tmp/_media_{name}" os.makedirs(tmpd, exist_ok=True) for n, j in enumerate(keys): maker(j).save(f"{tmpd}/{n:04d}.png") outp = f"{a.out}/{name}.webm" # VP9, near-lossless: this is 256x192 pixel art scaled by integers, and a # codec that smooths a 4x4 block boundary would be editorialising about the # one thing the picture is evidence of. cmd = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(a.fps), "-i", f"{tmpd}/%04d.png", "-c:v", "libvpx-vp9", "-crf", "12", "-b:v", "0", "-pix_fmt", "yuv444p", "-row-mt", "1", outp] subprocess.run(cmd, check=True) shutil.rmtree(tmpd) print(f" {outp} {len(keys)} frames @ {a.fps:g} fps, " f"{os.path.getsize(outp)/1024:.0f} KB") keys = sorted(k for k in frames if k in src) webm("player", side_by_side, keys) webm("modes", mode_pair, sorted(frames))