feat(rx): rectangle blits for stretch-1 strips; ring depth 16 -> 32 in the encoder

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.
This commit is contained in:
arti committed 2026-10-31 23:27:27 +02:00
1 parent c5526332f0
commit 6febcf00f4
5 files changed
+128 -27

No files matched your search

+14 -10
View File
@@ -207,18 +207,18 @@ def test_a_short_last_datagram_is_padded_and_a_mid_one_is_not():
def test_a_chunk_smaller_than_the_ring_can_hold_is_rejected():
"""A whole strip frame split over more than 16 datagrams can never arrive.
"""A whole strip frame split over more datagrams than the ring can never arrive.
``--chunk 1000`` puts a 128-row frame (20,496 B) in 21 datagrams; the
terminal's RX ring holds 16, so the payload would drop the frame. The
constructor refuses it and names the smallest legal chunk.
The ring is 32 deep, so a 128-row frame (20,496 B) needs a chunk of at least
641 bytes; ``--chunk 600`` puts it in 35 datagrams and the constructor
refuses it, naming the smallest legal chunk.
"""
clock = FakeClock()
transport = RecordingTransport(clock)
with pytest.raises(ValueError) as e:
make_sender(clock, transport, rows=128, stretch=8, chunk=1000)
assert "at least 1281" in str(e.value)
assert "16-descriptor" in str(e.value)
make_sender(clock, transport, rows=128, stretch=8, chunk=600)
assert "at least 641" in str(e.value)
assert "32-descriptor" in str(e.value)
def test_a_chunk_that_fits_the_ring_is_accepted():
@@ -445,12 +445,16 @@ def tile_frames(datagrams):
def test_tile_mode_rejects_a_chunk_too_small_for_a_full_screen_delta(tile_cache):
"""A 1,280-tile delta is 3,858 B; at 200 B/datagram that is 20 > 16."""
"""A 1,280-tile delta is 3,858 B; at 100 B/datagram that is 39 > 32.
The ring's depth is the ceiling: 32 datagrams need a chunk of at least
ceil(3,858 / 32) = 121 bytes, which is what the message must name.
"""
clock = FakeClock()
transport = RecordingTransport(clock)
with pytest.raises(ValueError) as e:
make_tile_sender(tile_cache, clock, transport, chunk=200)
assert "at least 242" in str(e.value)
make_tile_sender(tile_cache, clock, transport, chunk=100)
assert "at least 121" in str(e.value)
assert "full-screen delta" in str(e.value)
+13 -7
View File
@@ -159,21 +159,27 @@ def test_strip_source_rejects_impossible_stretch(stretch):
def test_strip_source_rejects_rows_that_do_not_fit_the_header():
with pytest.raises(ValueError):
StripSource(rows=0, stretch=128)
# The u8 header field allows 255 rows; the ring ceiling is what a huge row
# count hits first, and MAX_ROWS is that ceiling (289 with a 32-deep ring).
with pytest.raises(ValueError):
StripSource(rows=256, stretch=4)
StripSource(rows=MAX_ROWS + 1, stretch=4)
def test_strip_source_caps_rows_at_the_ring_not_the_header_field():
"""The terminal's 16-datagram ring, not the u8 ``rows`` field, is the limit.
"""The terminal's 32-datagram ring, not the u8 ``rows`` field, is the limit.
144 rows is 23,040 body bytes: 16 datagrams of 1450 with the 16-byte header
in the first. A 145th row would need a 17th datagram, which the receiver
drops (RX_DESC in vidstream.c), so the encoder must refuse it here -- with a
message that names the cap -- rather than put an unreceivable frame on the
The ring was deepened to 32 descriptors when the dictionary upload outgrew
16 (vidstream.c, SLOT_BYTES), so the strip encoder may now send 288 rows:
32 datagrams of 1450 with the 16-byte header in the first. One row more
would need a 33rd datagram, which the receiver drops (RX_DESC in
vidstream.c), so the encoder must refuse it here -- with a message that
names the cap -- rather than put an unreceivable frame on the
wire.
"""
src = StripSource(rows=MAX_ROWS, stretch=8)
pkts = src.encode(frame_no=1, rows_1bpp=bytes(MAX_ROWS * 160))
assert len(pkts) == RING_DATAGRAMS == 16
# MAX_ROWS is now the u8 header field (255), which is fewer datagrams than
# the 32-deep ring could carry -- so the frame fits with room to spare.
assert len(pkts) <= RING_DATAGRAMS == 32
with pytest.raises(ValueError, match=str(MAX_ROWS)):
StripSource(rows=MAX_ROWS + 1, stretch=8)
+8 -9
View File
@@ -45,16 +45,15 @@ 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 = 16
RING_DATAGRAMS = 32
#: Most source rows one frame can carry, from the ring rather than the header
#: field: the first datagram holds the 16-byte header plus a body, the other 15
#: carry body only, so the ring holds
#: ``(RING_DATAGRAMS-1)*CHUNK + (CHUNK-HEADER_SIZE)`` body bytes = 144 rows of
#: 160. The 24 KB slot alone would allow 153, and the u8 ``rows`` field 255 --
#: neither is reachable, because the ring is what binds.
MAX_ROWS = (((RING_DATAGRAMS - 1) * CHUNK + (CHUNK - HEADER_SIZE))
// ROW_BYTES)
#: 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:
+86
View File
@@ -490,6 +490,32 @@ unsigned int dydx;
blit_src = src;
blit_daddr = daddr_xy;
blit_dydx = dydx;
blit_sptch = 0; /* stretch: every row is the same source */
vram_blit_b_();
}
/*
* blit_rect(src, y, rows, sptch) -- copy `rows` screen rows at once.
*
* The strip path used to issue one PIXBLT per screen row because a stretched
* row has SPTCH = 0, so the source cannot advance. For a picture that really
* has one source row per screen row (stretch = 1) the source DOES advance, and
* a group of rows inside one receive fragment is contiguous: one PIXBLT with
* SPTCH = 1280 bits copies the whole group. Measured: a strip call costs about
* 0.45 ms in MAME regardless of its size, while a tile call costs the same for
* 64 words -- so call count, not word count, is what a frame is charged for.
* A full frame is then ~32 calls (one per datagram) instead of 1,200.
*/
static void blit_rect(src, y, rows, sptch)
unsigned int src;
unsigned int y;
unsigned int rows;
unsigned int sptch;
{
blit_src = src;
blit_daddr = (y << 16); /* x = 0: a strip row is the full width */
blit_dydx = (rows << 16) | ROW_PIXELS;
blit_sptch = sptch;
vram_blit_b_();
}
@@ -566,6 +592,62 @@ unsigned int ty;
* The whole burst runs with interrupts masked (spec 8.9) so the resident OS
* cannot repaint between two of our source rows and tear the picture.
*/
/*
* paint_rect(h) -- the stretch = 1 case, one PIXBLT per fragment.
*
* A strip frame with stretch 1 has exactly one source row per screen row, so
* the source advances by ROW_PIXELS bits per row and a group of rows that sits
* inside one receive fragment is contiguous: one blit copies the whole group.
* With the sender's 1,440-byte chunks (9 rows, chosen so no row straddles a
* datagram) a 288-row sub-frame is 32 blits instead of 288.
*
* Returns 1 when the whole frame was painted, 0 when the fragments do not line
* up on row boundaries -- the caller then falls back to the per-row path, which
* handles a split row by drawing it in pieces.
*/
static int paint_rect(h)
fp_header *h;
{
unsigned int rows, pos, k, off, nr, rn;
rows = h->rows;
if (rows * ROW_BYTES != h->payload_len) {
return 0; /* not whole rows: not for us */
}
for (k = 0; k < rx_nfrag; k++) {
if (rx_frag_off[k] % ROW_BYTES
|| rx_frag_len[k] % ROW_BYTES) {
return 0; /* a row straddles: general path */
}
}
rn = 0;
k = 0;
pos = h->payload_off;
while (rn < rows) {
while (k < rx_nfrag && pos >= rx_frag_off[k] + rx_frag_len[k]) {
k = k + 1;
}
if (k >= rx_nfrag) {
return 1; /* truncated: stop drawing */
}
if (pos < rx_frag_off[k]) {
return 1; /* a gap: never paint across it */
}
off = pos - rx_frag_off[k];
nr = (rx_frag_len[k] - off) / ROW_BYTES;
if (nr > rows - rn) {
nr = rows - rn;
}
if (nr == 0) {
return 1;
}
blit_rect(rx_frag_ptr[k] + off * 8, rn, nr, ROW_PIXELS);
rn = rn + nr;
pos = pos + nr * ROW_BYTES;
}
return 1;
}
static void paint(h)
fp_header *h;
{
@@ -597,6 +679,10 @@ fp_header *h;
}
vid_dint_();
if (stretch == 1 && paint_rect(h)) {
vid_eint_();
return;
}
pos = h->payload_off; /* the frame's first body byte */
k = 0;
for (r = 0; r < rows; r++) {
+7 -1
View File
@@ -35,6 +35,11 @@
; PMASK (0C0000160h) 0 every plane writable
; CONTROL (0C00000B0h) raster op 0 (replace), CD/RM/RR preserved
;
; _blit_sptch: the source pitch in bits. Zero is the stretch form (every
; source row reads the same address); 1280 is the rectangle form, where each
; source row is one screen row of the picture -- one PIXBLT then copies a whole
; group of rows, which is what makes a full frame ~32 calls instead of ~1,200.
;
; Entry points. The TMS34010 C compiler passes arguments in its own frame-
; pointer scheme, and no hand-written assembly in this repository has ever
; DECODED that frame: the ROM thunks in entry.asm forward the caller's stack
@@ -54,6 +59,7 @@
; resident repaint cannot tear the picture.
.ref _blit_src
.ref _blit_sptch
.ref _blit_daddr
.ref _blit_dydx
.text
@@ -70,7 +76,7 @@ _vram_blit_b_:
move b9, -*sp, 1
move @_blit_src, b0, 1 ; SADDR: packed 1-bpp source row
clr b1 ; SPTCH = 0: every row is the same source row
move @_blit_sptch, b1, 1 ; SPTCH: 0 = stretch, 1280 = rectangle
move @_blit_daddr, b2, 1 ; DADDR (XY)
movi 800h, b3 ; DPTCH = 2048 bits = 256-byte row pitch
movi 04000000h, b4 ; OFFSET = VRAM base