Files

376 lines
16 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 (see the KINDS table below; the payload's
``frame_parse.h`` mirrors the same numbers)
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.
The status body
---------------
A ``KIND_STATUS`` frame is the terminal's report to the host, and it is an
ordinary frame: the same 16-byte header, the same header-only CRC, with
``rows`` and ``stretch`` zero (a status has no geometry). Its body is 16 bytes:
offset size field
------ ---- ------------------------------------------------------
0 4 frames_rx, u32 little-endian (frames fp_parse accepted)
4 2 crc_err, u16 little-endian (frames it rejected: header CRC,
magic, version or length -- since R14 a *body* error is
the LANCE's FCS and can never appear here)
6 2 drops, u16 little-endian (frames that arrived and did not
reach the screen: CSR0 MISS report windows -- the bit is
latched, so two misses in one window count once -- plus
frames too large for the ring, plus datagrams the chip
flagged bad, which carry no bytes at all)
8 2 csr0, u16 little-endian (the chip's status word when the
report was built)
10 2 heap_free_kb, u16 little-endian (the terminal's free heap)
12 1 dict_state, u8: bit 0 ``DICT_READY`` (the 32 KB dictionary
is complete), bit 1 ``DICT_BAD`` (at least one chunk was
refused). Both can be set: a chunk refused after the
dictionary became ready leaves it usable.
13 1 reserved, zero
14 2 decode_worst, u16 little-endian (the worst per-frame decode
time since the last status frame that was actually sent,
in TMS34010 REFCNT ticks -- *not* microseconds; the unit
conversion is still to be measured on the real machine)
The out-of-cadence reports
----------------------------
A status is sent every 64 accepted frames, but two early reports exist so the
host is never left with silence:
* the **first** accepted frame -- it proves the payload is running and carries
``dict_state`` before the first 64 frames have arrived;
* **immediately after a refused dictionary chunk** -- one lost chunk makes
every later chunk fail and no frame is ever accepted, so the cadence would
never fire and a host would report "payload not running" instead of
"dictionary incomplete".
``decode_worst`` is reset only when a status frame has actually been handed to
the chip, so a failed transmit does not lose the measurement.
Both ends put that frame in an Ethernet frame: 6 bytes destination, 6 bytes
source, the ethertype below, then the ``VS`` frame. The terminal does not
filter on ethertype (its LANCE accepts broadcast and its own physical address),
so the value is for the host's benefit: a status frame must not be mistaken for
a probe (0x88B5, sendraw.py) or a command (0x88B6, lance_cmd.c).
"""
import struct
__all__ = [
"HEADER_SIZE", "MAGIC", "VERSION", "CRC_OFFSET",
"KINDS", "KIND_STRIP", "KIND_TILES_FULL", "KIND_TILES_DELTA",
"KIND_DICT_CHUNK", "KIND_STATUS",
"DICT_STATE_READY", "DICT_STATE_BAD",
"ETHERTYPE", "BROADCAST", "TERMINAL_MAC", "MIN_ETHERNET_PAYLOAD",
"STATUS_SIZE",
"crc16", "pack_header", "seal_header", "unpack_header",
"pack_status", "unpack_status", "ethernet_frame",
]
#: 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
#: Every frame kind this protocol defines, in one table. The terminal
#: payload's ``frame_parse.h`` mirrors these **numbers**; neither side may
#: renumber one without the other. The constants below are named views of
#: this table, not a second definition of the wire format.
KINDS = {
"STRIP": 1, # packed 1-bpp source rows (the original stream)
"TILES_FULL": 2, # a full 40x32 screen of dictionary indices
"TILES_DELTA": 3, # only the tiles that changed
"DICT_CHUNK": 4, # a slice of the 32x32 tile dictionary
"STATUS": 5, # the terminal's report, travelling the other way
}
#: A strip of packed 1-bpp source rows.
KIND_STRIP = KINDS["STRIP"]
#: A full-screen frame of dictionary indices.
KIND_TILES_FULL = KINDS["TILES_FULL"]
#: A frame carrying only changed tiles.
KIND_TILES_DELTA = KINDS["TILES_DELTA"]
#: One slice of the dictionary upload (see :mod:`harness.udpstream.tiledict`).
KIND_DICT_CHUNK = KINDS["DICT_CHUNK"]
#: A status report travelling the other way.
KIND_STATUS = KINDS["STATUS"]
#: Ethernet type the stream's frames carry. 0x88B5 is the probe frame
#: sendraw.py sends and 0x88B6 the command frame lance_cmd.c uses; the video
#: stream gets its own so a captured frame cannot be mistaken for either.
ETHERTYPE = 0x88B7
#: Destination that always passes the LANCE's address filter: with LADRF zero
#: the chip accepts its own physical address and broadcast, nothing else.
BROADCAST = b"\xff" * 6
#: The terminal's own physical address, from its Ethernet address ROM
#: (bytes 0..5 of the eight at bit 0x6800000; the last two are the checksum).
TERMINAL_MAC = b"\x08\x00\x2b\x16\x4b\x5a"
#: Bytes of padding an Ethernet frame needs after its 14-byte header.
MIN_ETHERNET_PAYLOAD = 46
#: Size of the status body (see the module docstring).
STATUS_SIZE = 16
#: ``dict_state`` bits: the upload completed / a chunk was refused.
DICT_STATE_READY = 0x01
DICT_STATE_BAD = 0x02
#: 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")
#: The status body: frames_rx u32, four u16, dict_state u8, reserved u8,
#: decode_worst u16.
_STATUS = struct.Struct("<IHHHHBBH")
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,
}
# --- the status body and the Ethernet envelope ---------------------------
def pack_status(frames_rx: int, crc_err: int, drops: int, csr0: int,
heap_free_kb: int, dict_state: int = 0,
decode_worst: int = 0) -> bytes:
"""Build the 16-byte status body the terminal sends (see the docstring).
The field widths are the terminal's: ``frames_rx`` is a 32-bit counter that
must not wrap in any run, the rest are 16-bit quantities whose limits
(65,535 missed packets, a 64 MB heap) are far above anything a VT1200
reaches, so an out-of-range value is a bug here rather than a wrap on the
wire. ``send_status()`` in vidstream.c writes these same offsets.
``dict_state`` is the two-bit set :data:`DICT_STATE_READY` /
:data:`DICT_STATE_BAD`; ``decode_worst`` is in REFCNT ticks, not
microseconds (the terminal-side comment says why).
"""
frames_rx = _checked("frames_rx", frames_rx, 0xFFFFFFFF)
crc_err = _checked("crc_err", crc_err, 0xFFFF)
drops = _checked("drops", drops, 0xFFFF)
csr0 = _checked("csr0", csr0, 0xFFFF)
heap_free_kb = _checked("heap_free_kb", heap_free_kb, 0xFFFF)
dict_state = _checked("dict_state", dict_state, 0xFF)
decode_worst = _checked("decode_worst", decode_worst, 0xFFFF)
return _STATUS.pack(frames_rx, crc_err, drops, csr0, heap_free_kb,
dict_state, 0, decode_worst)
def unpack_status(body: bytes) -> dict:
"""Decode a status body, or raise ValueError if it is not 16 bytes."""
if len(body) != STATUS_SIZE:
raise ValueError("a status body is %d bytes, got %d"
% (STATUS_SIZE, len(body)))
(frames_rx, crc_err, drops, csr0, heap_free_kb, dict_state, _reserved,
decode_worst) = _STATUS.unpack_from(body, 0)
return {
"frames_rx": frames_rx,
"crc_err": crc_err,
"drops": drops,
"csr0": csr0,
"heap_free_kb": heap_free_kb,
"dict_state": dict_state,
"dict_ready": bool(dict_state & DICT_STATE_READY),
"dict_bad": bool(dict_state & DICT_STATE_BAD),
"decode_worst": decode_worst,
}
def ethernet_frame(payload: bytes, src: bytes, dst: bytes = BROADCAST,
ethertype: int = ETHERTYPE, pad: bool = False) -> bytes:
"""Put ``payload`` in an Ethernet frame.
``pad`` is deliberately off by default and is the *caller's* decision:
Ethernet needs 46 bytes after the 14-byte header, but a frame split across
datagrams may only be padded at its very end. Padding a middle chunk would
insert bytes between two chunks of one frame, shifting the body the
receiver reassembles -- so the chunking layer pads its last datagram and
nothing else.
"""
if len(src) != 6 or len(dst) != 6:
raise ValueError("MAC addresses are 6 bytes")
data = bytes(dst) + bytes(src) + struct.pack(">H", ethertype) + bytes(payload)
if pad and len(data) < 14 + MIN_ETHERNET_PAYLOAD:
data += b"\x00" * (14 + MIN_ETHERNET_PAYLOAD - len(data))
return data