Files
vt1200/harness/udpstream/vidsrc.py
T
opencode.energia.arti.ee 16148d0d24 fix(stream): header-only frame CRC; draw-mode evidence guards
Ruling R14: the frame CRC now covers header bytes 0..11 only.  A
payload-wide CRC cost the terminal ~139 instructions per payload byte
(the parser, not the wire or the blitter, was the frame-rate ceiling),
while every datagram already carries an Ethernet FCS the LANCE checks
and the length bound catches truncation.  frame.py, fp_parse and the
harness's own frame signer follow the new rule; a corrupted payload
byte is now ACCEPTED and a corrupted header byte rejected, stated
explicitly in both test suites, and the C golden vectors were
regenerated from frame.py.

Measured after: RX_READY_A write -> first RX_OK read is 1 emulated
frame for a 20,480-byte frame (84 before), which is the frame hook's
resolution.

Ruling R15: draw mode deletes its dump files before the run (wrapper
and Lua) so a failed run cannot be judged on the previous screen; a
wait that hits its cap reports "timed out waiting for the frame" and
the checker reports it as a timeout, not as pixel mismatches; the
checker asserts the VRAM canary word is 0xA5C3, so a payload that
stopped arming it fails instead of passing quietly.

rx mode now corrupts a header byte in slot B, because a payload flip is
accepted by design under R14.
2026-10-30 23:59:20 +00:00

103 lines
4.6 KiB
Python

"""Turn 1-bpp source rows into framed strip packets for the VT1200 stream.
A *strip* frame is *N* unique rows of 1280 pixels, each row 160 bytes packed MSB
first. The terminal blits row *n* to destination row ``n * stretch`` with the
blitter's vertical stretch, so each source row is reused ``stretch`` times and
the whole screen is repainted from *N* = 1024 / stretch rows. That is the whole
scheme: 128 rows is a full-screen frame at 20,480 B of payload, 8 rows is a
16th of the screen at 1,280 B.
The wire form is the 16-byte header from :mod:`harness.udpstream.frame` followed
by the packed rows, split into datagrams of at most :data:`CHUNK` bytes. The
header travels **once, in the first packet**, and the body continues immediately
after it -- the terminal reassembles the datagrams in order and then checks the
header against the whole payload, exactly as the C receiver does:
[ header | body ... ][ body ... ][ body ]
The header CRC covers header[0:12] **only** (ruling R14): the body bytes are
covered by each datagram's Ethernet FCS, and a payload-wide CRC is far too slow
on the terminal's CPU. ``pack_header`` is still called with
``payload=rows_1bpp`` as an advisory check that the body is exactly
``payload_len`` bytes -- it does not change the CRC.
Pure standard library; no dependency on any host-side capture stack.
"""
from harness.udpstream.frame import KIND_STRIP, pack_header
__all__ = [
"CHUNK", "ROW_BYTES", "ROW_PIXELS", "SCREEN_ROWS", "Rows", "StripSource",
]
#: Width of the terminal's line buffer, in pixels, and the row it packs to.
ROW_PIXELS = 1280
ROW_BYTES = ROW_PIXELS // 8 # 160
#: Scanlines the blitter paints; ``stretch`` divides this.
SCREEN_ROWS = 1024
#: Largest datagram this transport sends, header included.
CHUNK = 1450
class Rows:
"""Pack one 1-bpp source row into the bytes the blitter reads."""
@staticmethod
def pack_row(pixels: list[int]) -> bytes:
"""Return the :data:`ROW_BYTES` packed bytes for ``pixels``.
Pixel *i* becomes bit ``7 - i % 8`` of byte ``i // 8`` -- MSB first,
which is the bit order of the terminal's line buffer. A truthy pixel is
a set bit, so ``True``, ``1`` and any other nonzero value all mean ink.
"""
if len(pixels) != ROW_PIXELS:
raise ValueError("a row is %d pixels, got %d"
% (ROW_PIXELS, len(pixels)))
# "1000000..." -> the integer those bits denote, big-endian bytes.
# Equivalent to setting bit 7 - i % 8 of byte i // 8 for each pixel i,
# but the join+parse is C-speed per character and measured ~1.5x a
# per-pixel shift loop on this box (65-120 us vs 100-150 us per row).
bits = "".join("1" if value else "0" for value in pixels)
return int(bits, 2).to_bytes(ROW_BYTES, "big")
class StripSource:
"""Frame encoder for one strip geometry.
``rows`` is how many unique source rows a frame carries; ``stretch`` is how
many destination scanlines each of them paints, so it must divide
:data:`SCREEN_ROWS` or the last row would stop short of the screen bottom.
Both must fit their one-byte header fields.
"""
def __init__(self, rows: int, stretch: int):
if not isinstance(stretch, int) or not 1 <= stretch <= SCREEN_ROWS:
raise ValueError("stretch out of range 1..%d: %r"
% (SCREEN_ROWS, stretch))
if SCREEN_ROWS % stretch:
raise ValueError("stretch %d does not divide %d; a row would paint "
"a fractional number of scanlines"
% (stretch, SCREEN_ROWS))
if not isinstance(rows, int) or not 1 <= rows <= 0xFF:
raise ValueError("rows out of range 1..255: %r" % (rows,))
self.rows = rows
self.stretch = stretch
def encode(self, frame_no: int, rows_1bpp: bytes) -> list[bytes]:
"""Return one frame as packets: header plus body, chunked at CHUNK.
``rows_1bpp`` must already be packed (``Rows.pack_row`` per row) and
exactly ``rows * 160`` bytes, or the header would describe a body the
terminal does not receive.
"""
body = bytes(rows_1bpp)
expected = self.rows * ROW_BYTES
if len(body) != expected:
raise ValueError("%d rows are %d bytes, got %d"
% (self.rows, expected, len(body)))
frame = pack_header(KIND_STRIP, frame_no, len(body), self.rows,
self.stretch, payload=body) + body
return [frame[i:i + CHUNK] for i in range(0, len(frame), CHUNK)]