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

203 lines
7.7 KiB
Python

"""Binary frame header and CRC for the VT1200 video stream.
This module is the single definition of the wire format. The host sender
(Python) and the terminal payload (C, ``vidstream.c``) must agree byte for
byte, so the layout is spelled out here and must be mirrored by the payload:
offset size field
------ ---- ------------------------------------------------------
0 2 magic, ASCII 'V','S'
2 1 version, 0x01
3 1 kind (KIND_STRIP = 1, KIND_STATUS = 5)
4 2 frame_no, u16 little-endian
6 2 payload_len, u16 little-endian
8 1 rows
9 1 stretch
10 2 reserved, zero
12 2 crc16, u16 little-endian
14 2 padding to HEADER_SIZE, zero
The CRC is CCITT-FALSE -- polynomial 0x1021, initial value 0xFFFF, no input or
output reflection, no final XOR -- and it covers **header bytes 0..11 only**,
never the payload and never the CRC field itself (ruling R14).
That is a deliberate narrowing of an earlier contract. A payload-wide CRC cost
the terminal ~139 instructions per payload byte; on the 34010 that made the
parser, not the wire or the blitter, the frame-rate ceiling. Nothing is lost by
dropping it: every datagram already carries an Ethernet FCS that the LANCE
validates before the bytes reach a slot, and ``payload_len`` plus the delivered
length catches truncation. What the header CRC still buys is a check that the
geometry a frame was *sent* with is the geometry the terminal acts on.
So a corrupted **payload** byte does not invalidate a frame here -- that is the
chip's job -- while a corrupted **header** byte does.
"""
import struct
__all__ = [
"HEADER_SIZE", "MAGIC", "VERSION", "CRC_OFFSET",
"KIND_STRIP", "KIND_STATUS",
"crc16", "pack_header", "seal_header", "unpack_header",
]
#: Total size of the fixed header every frame starts with.
HEADER_SIZE = 16
#: Magic bytes that open every frame: ASCII 'V','S'.
MAGIC = b"VS"
#: Wire format version. Bump when the layout changes.
VERSION = 0x01
#: The CRC covers header bytes [0, CRC_OFFSET) and nothing else.
CRC_OFFSET = 12
#: A strip of packed 1-bpp source rows.
KIND_STRIP = 1
#: A status report travelling the other way.
KIND_STATUS = 5
#: Everything before the CRC field, little-endian.
_PREFIX = struct.Struct("<2sBBHHBBH")
#: The CRC field and the two padding bytes behind it.
_TAIL = struct.Struct("<HH")
def _make_crc_table():
"""Build the 256-entry CCITT-FALSE table from the plain bit loop.
Keeping the loop here means the polynomial, the init value and the shift
direction are stated exactly once, in the form ``vidstream.c`` repeats.
The table is only how the host gets through a long buffer quickly; since
ruling R14 the frame CRC itself runs over 12 bytes, but ``crc16`` is still
a general function and the tests cross-check it on long inputs.
"""
table = []
for byte in range(256):
crc = byte << 8
for _ in range(8):
if crc & 0x8000:
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
else:
crc = (crc << 1) & 0xFFFF
table.append(crc)
return tuple(table)
_CRC_TABLE = _make_crc_table()
def crc16(data: bytes) -> int:
"""CCITT-FALSE CRC of ``data`` (poly 0x1021, init 0xFFFF, not reflected).
No input or output reflection and no final XOR, so ``crc16(b"123456789")``
is the standard check value 0x29B1 and equals
``binascii.crc_hqx(data, 0xFFFF)`` byte for byte.
"""
crc = 0xFFFF
for byte in data:
crc = ((crc << 8) & 0xFFFF) ^ _CRC_TABLE[((crc >> 8) ^ byte) & 0xFF]
return crc
def _checked(name: str, value: int, limit: int) -> int:
"""Range-check a header field, so a bad value fails here and not on the wire."""
if not isinstance(value, int) or not 0 <= value <= limit:
raise ValueError("%s out of range 0..%d: %r" % (name, limit, value))
return value
def pack_header(kind: int, frame_no: int, payload_len: int, rows: int,
stretch: int, payload: bytes | None = None) -> bytes:
"""Build the 16-byte header for a frame.
The CRC covers header bytes 0..11 only (ruling R14), so the header can be
built before the body exists. ``payload`` is kept as an **advisory**
consistency check: when it is passed it must be exactly ``payload_len``
bytes, which catches a header that describes a body the caller does not
have. Its *contents* are not covered -- the Ethernet FCS is.
"""
kind = _checked("kind", kind, 0xFF)
frame_no = _checked("frame_no", frame_no, 0xFFFF)
payload_len = _checked("payload_len", payload_len, 0xFFFF)
rows = _checked("rows", rows, 0xFF)
stretch = _checked("stretch", stretch, 0xFF)
if payload is not None:
body = bytes(payload)
if len(body) != payload_len:
raise ValueError("payload is %d bytes but payload_len says %d"
% (len(body), payload_len))
prefix = _PREFIX.pack(
MAGIC,
VERSION,
kind,
frame_no,
payload_len,
rows,
stretch,
0, # reserved
)
assert len(prefix) == CRC_OFFSET
crc = crc16(prefix)
return prefix + _TAIL.pack(crc, 0)
def seal_header(header: bytes, payload: bytes | None = None) -> bytes:
"""Return ``header`` with its CRC field recomputed over header bytes 0..11.
``header`` must be exactly :data:`HEADER_SIZE` bytes: a longer buffer is
rejected rather than silently truncated, so a caller cannot pass
``header + payload`` here and believe the payload was sealed.
``payload`` is advisory, exactly as in :func:`pack_header`: when it is
passed it must match the header's ``payload_len``, and its bytes are not
covered by the CRC. Sealing is therefore idempotent and order-independent
-- header first or body first, the bytes are the same.
"""
if len(header) != HEADER_SIZE:
raise ValueError("header must be exactly %d bytes, got %d"
% (HEADER_SIZE, len(header)))
if payload is not None:
want = struct.unpack_from("<H", header, 6)[0]
if len(payload) != want:
raise ValueError("payload is %d bytes but the header says %d"
% (len(payload), want))
crc = crc16(bytes(header[:CRC_OFFSET]))
return bytes(header[:CRC_OFFSET]) + _TAIL.pack(crc, 0)
def unpack_header(buf: bytes, payload: bytes | None = None) -> dict:
"""Parse a frame header and check its CRC.
``crc_ok`` covers header bytes 0..11 only (ruling R14). ``payload`` is
advisory: when it is passed, its length must equal ``payload_len`` (a
truncated or over-long body is a length error, not a CRC error), but the
bytes themselves are never hashed here.
The returned ``crc_ok`` is purely the CRC result -- callers also check
``magic`` and ``version`` (the C receiver checks magic, the version and the
length bound before it bothers with the CRC).
"""
if len(buf) < HEADER_SIZE:
raise ValueError("buffer shorter than a %d-byte header: %d"
% (HEADER_SIZE, len(buf)))
prefix = _PREFIX.unpack_from(buf, 0)
magic, version, kind, frame_no = prefix[:4]
payload_len, rows, stretch, _reserved = prefix[4:]
if payload is not None and len(payload) != payload_len:
raise ValueError("payload is %d bytes but the header says %d"
% (len(payload), payload_len))
stored_crc, _pad = _TAIL.unpack_from(buf, CRC_OFFSET)
return {
"magic": magic,
"version": version,
"kind": kind,
"frame_no": frame_no,
"payload_len": payload_len,
"rows": rows,
"stretch": stretch,
"crc_ok": crc16(bytes(buf[:CRC_OFFSET])) == stored_crc,
}