forked from arti/vt1200
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.
203 lines
7.7 KiB
Python
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,
|
|
}
|