Files
vt1200/harness/udpstream/tests/test_vidsrc.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

180 lines
7.0 KiB
Python

"""Tests for the strip encoder: 1-bpp row packing and frame chunking.
The plan's three tests come first, verbatim. The extras pin down what the plan
text leaves implicit but the terminal depends on: the frame is split at 1450
bytes with only the last packet short, and a payload whose length disagrees with
``rows`` is rejected here instead of on the wire.
Note the third plan test checks ``crc_ok`` *with the payload*: since ruling R14
the header CRC covers header[0:12] only, so the same result must hold with or
without the body -- the payload argument is an advisory length check, not part
of the CRC.
"""
import pytest
from harness.udpstream.frame import HEADER_SIZE, KIND_STRIP, unpack_header
from harness.udpstream.vidsrc import (
MAX_ROWS, RING_DATAGRAMS, Rows, StripSource)
#: Largest UDP payload the transport promises per packet.
CHUNK = 1450
def test_pack_row_is_lsb_first_and_160_bytes():
px = [0] * 1280
px[0] = 1
px[7] = 1
px[8] = 1
b = Rows.pack_row(px)
assert len(b) == 160
# Pixel 0 -> bit 0, pixel 7 -> bit 7, pixel 8 -> bit 0 of byte 1: the
# terminal's scanout reads bit 0 of a byte as its leftmost pixel
# (dwt/screenshot.py decodes live VRAM the same way). The plan carried an
# MSB-first pair here (0b11000001 / 0b10000000), which is self-consistent
# with an MSB-first checker but mirrors every eight pixels on a display.
assert b[0] == 0b10000001 and b[1] == 0b00000001
def test_strip_source_rejects_stretch_that_does_not_divide_1024():
with pytest.raises(ValueError):
StripSource(rows=100, stretch=7)
def test_strip_frame_round_trips_through_the_header():
src = StripSource(rows=8, stretch=128)
rows = bytes([0xAA]) * (8 * 160)
pkts = src.encode(frame_no=3, rows_1bpp=rows)
head = unpack_header(pkts[0][:16])
assert head["kind"] == 1 and head["rows"] == 8 and head["stretch"] == 128
assert head["payload_len"] == 8 * 160
assert b"".join(pkts)[16:] == rows
assert unpack_header(pkts[0][:16], b"".join(pkts)[16:])["crc_ok"] is True
# --- packing -------------------------------------------------------------
def _decode_like_the_terminal(row: bytes) -> list[int]:
"""Decode a packed row the way the VT1200 scanout does.
The same rule ``dwt/screenshot.py`` uses on live VRAM: bit ``x % 8`` of
byte ``x // 8`` is pixel ``x`` -- LSB first within each byte.
"""
return [(row[x // 8] >> (x % 8)) & 1 for x in range(1280)]
def test_pack_row_decodes_back_to_the_pixels_the_terminal_shows():
"""A row must survive the terminal's own LSB-first scanout decode.
The failure this pins: an MSB-first packer is self-consistent with an
MSB-first checker, so every MAME replay check passes while the screen
mirrors every group of eight pixels. Decoding the packed row with the
hardware's rule is the only reading that can catch it.
"""
px = [1 if i % 3 == 0 else 0 for i in range(1280)]
px[0] = 0 # asymmetric ends, so a mirror cannot pass
px[1279] = 1
assert _decode_like_the_terminal(Rows.pack_row(px)) == px
def test_pack_row_puts_every_pixel_where_the_terminal_reads_it():
"""Pixel i is bit (i % 8) of byte i // 8, i.e. LSB first, and no bit leaks."""
px = [1 if i % 3 == 0 else 0 for i in range(1280)]
b = Rows.pack_row(px)
assert len(b) == 160
for i, v in enumerate(px):
assert (b[i // 8] >> (i % 8)) & 1 == v, i
def test_pack_row_rejects_a_row_that_is_not_1280_pixels():
with pytest.raises(ValueError):
Rows.pack_row([1] * 1279)
with pytest.raises(ValueError):
Rows.pack_row([1] * 1281)
# --- framing -------------------------------------------------------------
def test_encode_splits_the_frame_at_1450_bytes_and_only_the_last_is_short():
"""A full-screen frame is 20,496 B including the header: 15 packets."""
src = StripSource(rows=128, stretch=8)
rows = bytes([0x5A]) * (128 * 160)
pkts = src.encode(frame_no=1, rows_1bpp=rows)
assert all(len(p) <= CHUNK for p in pkts)
assert all(len(p) == CHUNK for p in pkts[:-1])
assert len(pkts) == 15
assert sum(len(p) for p in pkts) == HEADER_SIZE + 128 * 160
assert b"".join(pkts)[HEADER_SIZE:] == rows
def test_a_small_strip_is_a_single_packet():
src = StripSource(rows=8, stretch=128)
rows = bytes([0x11]) * (8 * 160)
pkts = src.encode(frame_no=2, rows_1bpp=rows)
assert len(pkts) == 1
assert len(pkts[0]) == HEADER_SIZE + 8 * 160
assert unpack_header(pkts[0][:HEADER_SIZE], pkts[0][HEADER_SIZE:])["crc_ok"] is True
def test_every_packet_carries_the_same_valid_header():
"""The header travels once, in the first packet, and the body follows it."""
src = StripSource(rows=8, stretch=128)
rows = bytes([0x0F]) * (8 * 160)
pkts = src.encode(frame_no=0x1234, rows_1bpp=rows)
head = unpack_header(pkts[0][:HEADER_SIZE])
assert head["magic"] == b"VS"
assert head["kind"] == KIND_STRIP
assert head["frame_no"] == 0x1234
assert head["payload_len"] == 8 * 160
assert head["rows"] == 8 and head["stretch"] == 128
assert unpack_header(pkts[0][:HEADER_SIZE], b"".join(pkts)[HEADER_SIZE:])["crc_ok"] is True
# The header-only check gives the same answer (ruling R14): the body is
# covered by the Ethernet FCS, not by this CRC.
assert head["crc_ok"] is True
def test_encode_rejects_a_payload_that_is_not_rows_times_160():
src = StripSource(rows=8, stretch=128)
with pytest.raises(ValueError):
src.encode(frame_no=0, rows_1bpp=bytes(8 * 160 - 1))
with pytest.raises(ValueError):
src.encode(frame_no=0, rows_1bpp=bytes(8 * 160 + 1))
def test_encode_rejects_a_frame_number_that_does_not_fit_u16():
src = StripSource(rows=8, stretch=128)
with pytest.raises(ValueError):
src.encode(frame_no=0x10000, rows_1bpp=bytes(8 * 160))
# --- geometry validation -------------------------------------------------
@pytest.mark.parametrize("stretch", [0, 7, 1023, 1025, 2048])
def test_strip_source_rejects_impossible_stretch(stretch):
with pytest.raises(ValueError):
StripSource(rows=8, stretch=stretch)
def test_strip_source_rejects_rows_that_do_not_fit_the_header():
with pytest.raises(ValueError):
StripSource(rows=0, stretch=128)
with pytest.raises(ValueError):
StripSource(rows=256, stretch=4)
def test_strip_source_caps_rows_at_the_ring_not_the_header_field():
"""The terminal's 16-datagram ring, not the u8 ``rows`` field, is the limit.
144 rows is 23,040 body bytes: 16 datagrams of 1450 with the 16-byte header
in the first. A 145th row would need a 17th datagram, which the receiver
drops (RX_DESC in vidstream.c), so the encoder must refuse it here -- with a
message that names the cap -- rather than put an unreceivable frame on the
wire.
"""
src = StripSource(rows=MAX_ROWS, stretch=8)
pkts = src.encode(frame_no=1, rows_1bpp=bytes(MAX_ROWS * 160))
assert len(pkts) == RING_DATAGRAMS == 16
with pytest.raises(ValueError, match=str(MAX_ROWS)):
StripSource(rows=MAX_ROWS + 1, stretch=8)