Files
vt1200/harness/udpstream/tests/test_tilecost.py
T
arti 5af949b338 fix(tiles): pack LSB first, the terminal's scanout order; render the real layout
The tile pipeline packed every row MSB first: pixel i -> bit 7 - i % 8. The
encoder and tile_check.py shared that convention, so every MAME replay check
passed while a display showed each group of eight pixels mirrored.

Evidence, from the real clip's own replay dump (vram-replay-200.bin, frame 200
of Bad Apple, 1,228,800 pixels):

  decoded MSB first   1,215 mismatched  <- what the pipeline produced
  decoded LSB first   3,113 mismatched  <- the mirrored picture, a sawtooth

and both the real unit and MAME decode LSB first: dwt/screenshot.py --real
("LSB-first bits", measured on live VRAM) and vt1200.cpp's
(vram[bit >> 4] >> (bit & 15)) & 1.  A zoomed edge of that dump showed the
comb of triangles; after this change the same edge is a smooth curve.

vidsrc.Rows.pack_row now sets bit i % 8 of byte i // 8.  The test that pins it
was written first and watched fail: it decodes the packed row with the
hardware's rule, which no MSB-first packer can pass.  The two older tests that
asserted MSB first were rewritten -- they were asserting the bug -- and the
synthetic clip now packs through a bit-reversal table, so the committed cache
is regenerated (PSNR 29.47 -> 31.30 dB).  The layout claims in tilecost.py,
vram_tile.asm and make_tiles.load_clip are corrected so the clip format states
the order instead of leaving it to whoever writes the frames.

Separately, MAME's renderer reads 128-byte rows while a payload targeting the
real unit writes 256-byte rows (vram_tile.asm DPTCH=0x800; c-hello/README.md:
"a payload writing at row*2048 bits lands on scanline row").  VT1200_VRAM_REAL=1
now draws the framebuffer at 2048 bits / 1280 px from x=0.  Off by default: the
Level-1 error text is only legible at the derived 128-byte pitch, and that is
what the faithful boot needs.  The branch only touches the bitmap, so the
regression baseline is unmoved (PROG_FRAMES=200 -> PC=3FDCE400, samples=198,
3 distinct PCs), and the default frame-3000 render of the Terminal Manager is
unchanged.

Verified end to end on the 3,292-frame Bad Apple clip: RX_OK=3196, TILES_DROP=0,
3292/3292 frames dumped and matched by tile_check.py, and the dumps decode to
1,379 / 4,125 mismatched pixels of 1,228,800 (quantisation) under the hardware
rule versus 3,101 / 9,793 under MSB.  MAME's own frame dumps with
VT1200_VRAM_REAL=1 show the clip unsheared.

Revert note for the driver: delete the m_render_real branch and its getenv and
vt1200.cpp returns to the measured 128-byte renderer byte for byte.  No second
reader reviewed that citation; it is quoted in the comment for the next one.
2026-10-31 18:06:14 +02:00

186 lines
6.9 KiB
Python

"""Tests for the tile dictionary builder and the frame quantiser.
The plan's two tests come first, verbatim. The extras pin down what the plan
text leaves implicit but the later tasks depend on:
* the tile layout -- 32 rows of four bytes, row-major, LSB first (bit 0 of a
byte is the leftmost pixel, the scanout order) -- so a
dictionary entry is exactly the bytes the terminal's ``PIXBLT B,XY`` reads
from ``dict + index*128``;
* the index frame's size. The plan says ``quantise`` returns 1,280 index bytes
(a 40x32 screen), but its own test feeds a 128-row frame, which is only 160
tiles. A buffer shorter than the screen is therefore the *top* of the screen
and the rows it does not cover quantise the all-zero tile; that convention is
pinned here rather than left to the caller to discover;
* the Hamming tie-break: equal distance resolves to the lowest index;
* validation, so a mis-sized frame is an error here and not a mis-painted
screen later.
"""
import random
import pytest
from harness.udpstream.tilecost import build_dictionary, quantise
#: One source row of the 1280-px screen, as ``vidsrc.Rows.pack_row`` packs it.
ROW_BYTES = 160
#: A 32-px tile is four bytes wide, so a screen row is 40 tiles.
COLS = 40
#: Dictionary entry size: 32 rows of four bytes.
TILE_BYTES = 128
def _frame(seed):
r = random.Random(seed)
return bytes(r.randrange(256) for _ in range(128 * 1280 // 8))
def test_dictionary_is_deterministic_and_indexable():
frames = [_frame(i) for i in range(4)]
a = build_dictionary(frames, k=8, seed=7)
b = build_dictionary(frames, k=8, seed=7)
assert a == b and len(a) == 8 * 128
def test_quantise_returns_1280_indices_within_range():
frames = [_frame(i) for i in range(4)]
d = build_dictionary(frames, k=8, seed=7)
idx = quantise(frames[0], d)
assert len(idx) == 1280 and max(idx) < 8
# --- layout --------------------------------------------------------------
def _screen_of(patterns, seed=0):
"""Build a 1280x1024 frame whose 32x32 tiles are drawn from ``patterns``."""
r = random.Random(seed)
screen = bytearray(ROW_BYTES * 1024)
for ty in range(32):
for tx in range(COLS):
tile = patterns[r.randrange(len(patterns))]
for row in range(32):
off = (ty * 32 + row) * ROW_BYTES + tx * 4
screen[off:off + 4] = tile[row * 4:row * 4 + 4]
return bytes(screen)
def _render(indices, dictionary):
"""Blit an index frame the way the terminal will: one block per index."""
screen = bytearray(ROW_BYTES * 1024)
for ty in range(32):
for tx in range(COLS):
base = indices[ty * COLS + tx] * TILE_BYTES
for row in range(32):
off = (ty * 32 + row) * ROW_BYTES + tx * 4
screen[off:off + 4] = dictionary[base + row * 4:
base + row * 4 + 4]
return bytes(screen)
def test_quantise_picks_the_byte_block_the_blitter_reads():
"""Tile (tx, ty) is rows ty*32..+31, bytes tx*4..tx*4+4 of each row."""
frame = bytearray(ROW_BYTES * 32) # one tile row: 40 tiles
tiles = []
for tx in range(COLS):
tile = bytearray()
for row in range(32):
block = bytes((tx * 7 + row * 3 + c) & 0xFF for c in range(4))
frame[row * ROW_BYTES + tx * 4:row * ROW_BYTES + tx * 4 + 4] = block
tile += block
tiles.append(bytes(tile))
idx = quantise(bytes(frame), b"".join(tiles))
# Every tile has distance 0 to its own dictionary entry, so the first row of
# the index frame names them in order -- and nothing else could.
assert idx[:COLS] == bytes(range(COLS))
def test_rows_below_a_short_frame_quantise_the_zero_tile():
"""A 32-row frame is the top of the screen; everything below is black."""
white = bytes([0xFF]) * TILE_BYTES
black = bytes(TILE_BYTES)
dictionary = white + black # index 0 white, 1 black
frame = bytes([0xFF]) * (ROW_BYTES * 32) # one all-white tile row
idx = quantise(frame, dictionary)
assert len(idx) == 1280
assert idx[:COLS] == bytes([0]) * COLS
assert idx[COLS:] == bytes([1]) * (1280 - COLS)
def test_equal_distance_resolves_to_the_lowest_index():
white = bytes([0xFF]) * TILE_BYTES
black = bytes(TILE_BYTES)
dictionary = white + black + black # entries 1 and 2 are identical
frame = bytes(ROW_BYTES * 32) # all black
idx = quantise(frame, dictionary)
assert set(idx) == {1}
# The same tie with the duplicate *first*: an exact match at index 0 must
# stop the search, not be overtaken by a later exact match.
dictionary = black + white + black
idx = quantise(frame, dictionary)
assert set(idx) == {0}
def test_a_dictionary_of_the_frames_own_tiles_reconstructs_the_frame():
rng = random.Random(3)
patterns = [bytes(rng.randrange(256) for _ in range(TILE_BYTES))
for _ in range(5)]
frames = [_screen_of(patterns, seed=s) for s in range(3)]
d = build_dictionary(frames, k=5, seed=1)
assert len(d) == 5 * TILE_BYTES
for frame in frames:
assert _render(quantise(frame, d), d) == frame
def test_build_dictionary_does_not_depend_on_frame_order():
"""Candidates are named by content, so frame order cannot matter."""
frames = [_frame(i) for i in range(4)]
assert (build_dictionary(frames, k=4, seed=2)
== build_dictionary(list(reversed(frames)), k=4, seed=2))
def test_build_dictionary_of_a_single_repeated_tile_is_that_tile():
"""The centroid is data, not a seeded sample: one distinct tile stays."""
pattern = bytes((i * 37 + 11) & 0xFF for i in range(TILE_BYTES))
frame = _screen_of([pattern], seed=0)
assert build_dictionary([frame], k=1, seed=0) == pattern
# --- validation ----------------------------------------------------------
@pytest.mark.parametrize("k", [0, -1, 257, 1000])
def test_build_dictionary_rejects_k_that_is_not_an_index_byte(k):
with pytest.raises(ValueError):
build_dictionary([_frame(0)], k=k)
def test_build_dictionary_rejects_no_frames():
with pytest.raises(ValueError):
build_dictionary([], k=8)
def test_frames_must_be_whole_160_byte_rows_of_whole_32_row_tiles():
d = bytes(TILE_BYTES)
with pytest.raises(ValueError):
quantise(bytes(ROW_BYTES * 32 + 1), d) # half a row
with pytest.raises(ValueError):
quantise(bytes(ROW_BYTES * 33), d) # half a tile row
with pytest.raises(ValueError):
quantise(bytes(ROW_BYTES * 1056), d) # below the 1024-row screen
with pytest.raises(ValueError):
build_dictionary([bytes(ROW_BYTES * 31)], k=1)
def test_the_dictionary_is_a_whole_number_of_tiles_of_at_most_256():
frame = bytes(ROW_BYTES * 32)
for bad in (b"", bytes(TILE_BYTES + 1), bytes(257 * TILE_BYTES)):
with pytest.raises(ValueError):
quantise(frame, bad)