mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-11-02 12:23:17 +02:00
Two things the 60 fps work needed, and one ceiling it found. Rectangle blit: a strip frame with stretch 1 has one source row per screen row, so the source advances by 1280 bits and a group of rows inside one fragment is contiguous -- one PIXBLT with SPTCH = 1280 copies the group. Measured on the tile path, a blit call costs ~0.45 ms in MAME regardless of size (1,200 tile calls accepted 9 frames where 128 strip calls accepted 73 for the same pixels), so call count, not word count, is what a frame is charged for. paint_rect() takes the stretch-1 frame 9 rows at a time and falls back to the per-row path if a row straddles a datagram. Ring depth: vidsrc still said RING_DATAGRAMS = 16 and capped a strip frame at 144 rows, which stopped being true when the receive ring was deepened to 32 for the dictionary upload. It is 32 now, the encoder may send up to the header's 255 rows, and the three tests that encoded the old ceiling were updated to it. The ceiling this found: a picture-as-strips full frame is 29 datagrams, i.e. 1,740 datagrams/s at 60 fps, while the emulated LANCE delivers ~450/s (1,935 datagrams over 4.3 s, measured). So keyframes cannot be sent as whole strips at 60 fps in MAME whatever the decoder does -- the datagram rate binds first. The tile delta (about one datagram a frame) remains the right transport; what costs is its blit-call count. Verified: modes rx, draw and tiles still PASS (draw and tiles with their pixel checkers); 155 unit tests pass.
132 lines
6.3 KiB
Python
132 lines
6.3 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
|
|
**LSB first** (bit 0 of a byte is the row's leftmost pixel -- the terminal's
|
|
scanout order, see :meth:`Rows.pack_row`). 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 HEADER_SIZE, KIND_STRIP, pack_header
|
|
|
|
__all__ = [
|
|
"CHUNK", "MAX_ROWS", "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
|
|
|
|
#: Datagrams in the terminal's RX ring (``RX_DESC`` in vidstream.c). A frame
|
|
#: that needs more cannot be received at all: the ring runs out of descriptors
|
|
#: and the payload drops it.
|
|
RING_DATAGRAMS = 32
|
|
|
|
#: Most source rows one frame can carry. Two ceilings, and the lower binds: the
|
|
#: ring holds ``(RING_DATAGRAMS-1)*CHUNK + (CHUNK-HEADER_SIZE)`` body bytes
|
|
#: (289 rows of 160 with a 32-deep ring), and the header's ``rows`` field is one
|
|
#: byte (255). So 255 today; the ring was 16 when this was written, when the
|
|
#: ring was the binding one at 144.
|
|
MAX_ROWS = min(255, (((RING_DATAGRAMS - 1) * CHUNK + (CHUNK - HEADER_SIZE))
|
|
// ROW_BYTES))
|
|
|
|
|
|
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 ``i % 8`` of byte ``i // 8`` -- **LSB first**,
|
|
which is how the terminal's scanout reads the framebuffer: ``dwt/
|
|
screenshot.py`` decodes live VRAM with exactly this rule, and MAME's
|
|
renderer does the same (``(vram[bit >> 4] >> (bit & 15)) & 1``).
|
|
|
|
The earlier MSB-first convention here was self-consistent -- the encoder
|
|
and ``tile_check.py`` shared it, so every replay check passed -- but it
|
|
mirrors every eight pixels on a real display. 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.
|
|
# Pixel i must land on bit i % 8 of byte i // 8, so reverse each
|
|
# eight-pixel group before the parse: the first character of a group
|
|
# becomes that byte's MSB, and the last becomes its LSB.
|
|
bits = "".join("1" if value else "0" for value in pixels)
|
|
lsb = "".join(bits[i:i + 8][::-1] for i in range(0, ROW_PIXELS, 8))
|
|
return int(lsb, 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.
|
|
``rows`` is capped by the terminal's ring, not by its one-byte header field:
|
|
:data:`MAX_ROWS` (144) rows already fill all 16 datagrams, and a frame that
|
|
needs a 17th is dropped by the receiver.
|
|
"""
|
|
|
|
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 <= MAX_ROWS:
|
|
raise ValueError("rows out of range 1..%d: %r (the terminal's ring "
|
|
"is %d datagrams of %d bytes, so a frame can carry "
|
|
"at most %d rows of %d bytes; the header's one-byte "
|
|
"field allows 255 but the ring does not)"
|
|
% (MAX_ROWS, rows, RING_DATAGRAMS, CHUNK,
|
|
MAX_ROWS, ROW_BYTES))
|
|
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)]
|