diff --git a/harness/mame/NETWORK_REPORT.md b/harness/mame/NETWORK_REPORT.md index 33de128..90e5862 100644 --- a/harness/mame/NETWORK_REPORT.md +++ b/harness/mame/NETWORK_REPORT.md @@ -5369,7 +5369,8 @@ add about a quarter: 4 (FCS) + 8 (preamble) + 12 (IFG) = 24 B per datagram, and The clip format is pinned by `make_tiles.load_clip`: a directory of `*.bin` files, each one raw 1-bpp frame — whole 160-byte rows, whole 32-row tile bands, -top-aligned, MSB first, 1 = ink, at most 1,024 rows. A partial row or band is +top-aligned, **LSB first** (bit 0 of a byte is the row's leftmost pixel, the +scanout order), 1 = ink, at most 1,024 rows. A partial row or band is rejected with the file's name, not silently reshaped. With ffmpeg (not installed on this machine, so this route is documented and **not executed**): @@ -5384,9 +5385,12 @@ python3 harness/udpstream/make_tiles.py --clip /tmp/badapple \ --out harness/udpstream/tiles-badapple ``` -`monob` is MSB-first 1-bpp; if the picture comes out negative, invert it in the -filter chain (`...,format=monob,negate`). To keep a run's length independent of -the source, `vidsend.py --tiles` loops a short cache. +`monob` is MSB-first 1-bpp, which is **the wrong bit order for this clip**: the +clip is LSB-first, so each byte of `monob` output must be bit-reversed or every +eight pixels come out mirrored (the real-clip run that produced the tile cache +used its own packer for exactly this reason). Invert in the filter chain +(`...,format=monob,negate`) only if the picture comes out negative. To keep a +run's length independent of the source, `vidsend.py --tiles` loops a short cache. The clip loader is not an untested path in the code sense: the 30-frame synthetic clip written out as `frame-0000.bin … frame-0029.bin` and fed back diff --git a/harness/mame/README.md b/harness/mame/README.md index 7af24ea..29e3592 100644 --- a/harness/mame/README.md +++ b/harness/mame/README.md @@ -269,7 +269,7 @@ Terminal Manager usable. It is a **value-aware inverse switch**: the default lives in the code, not in a wrapper script setting `VT1200_TABLET`, so `VT1200_MOUSE=1` (or `VT1200_MOUSE=0` for the tablet) always flips it either way. -## Environment knobs (21; down from 33) +## Environment knobs (22; down from 33) Almost all are opt-in and off by default — there is no longer a "working incantation" to remember, because everything the firmware needs to run is now the @@ -283,6 +283,10 @@ the descriptor write-back are read directly), `VT1200_BOARD_LOG`, `VT1200_BOARD_ `VT1200_IRQLOG`, `VT1200_LOG_UNMAPPED`, `VT1200_DPYLOG` (+`_FRAMES`), `VT1200_DUMP_FRAME` (+`_FRAMES`), `VT1200_DUMP_RAM` (+`_FRAMES`), `VT1200_DUMP_VRAM` (+`_FRAMES`), `VT1200_DUMP_DIR`, `VT1200_NO_RENDER`, +`VT1200_VRAM_REAL` (draw the framebuffer at the **real unit's** 2048-bit / +256-byte row pitch, 1280 px wide, instead of the derived 1024-bit / 128-byte +pitch — the view for content written for the hardware, e.g. the tile stream; +off by default, see `RENDER_REPORT.md`), `VT1200_INPUT_LOG` (per-byte keyboard/mouse/tablet TX/RX trace, with emulated time). Behaviour switches: `VT1200_SKIP_LEVEL1`, `VT1200_MOUSE` (put the VSXXX-AA mouse (relative) back on DUART2-B; the VSXXX-AB tablet is the default — diff --git a/harness/mame/RENDER_REPORT.md b/harness/mame/RENDER_REPORT.md index 21698f2..73357a0 100644 --- a/harness/mame/RENDER_REPORT.md +++ b/harness/mame/RENDER_REPORT.md @@ -157,3 +157,29 @@ absent, so the code just blanks the line (as it did before). * The video-RAM dump for a single frame is ~58 MB because the share covers the whole `0x04000000..0x1fffffff` mapping; the framebuffer itself is the first ~128 KiB. + +## 8. The other layout: `VT1200_VRAM_REAL=1` + +Everything above describes the **derived** geometry: 1024-bit (128-byte) rows, +1024 active pixels, drawn at x 192..1215. It is what the firmware's own blitter +uses (`pc=3FDF9A00` advances the destination by `0x40` words per glyph row), and +what makes the Level-1 error text legible. + +The **real unit** stores 1280-pixel rows at a 256-byte pitch — first 160 bytes +the picture, the remaining 96 a `0x55` mask — as `harness/c-hello/README.md` +records from measurement ("a payload writing at `row*2048` bits lands on +scanline `row`"), and as `dwt/screenshot.py --real` (width 1280, stride 256) +decodes from live VRAM. + +A payload can target either. The tile stream targets the real unit +(`vram_tile.asm: DPTCH = 0x800` = 2048 bits), so under the derived renderer it +is sheared by a quarter-row per scanline and wrapped. `VT1200_VRAM_REAL=1` +renders the same VRAM at 2048-bit pitch and 1280 px from x=0 instead. Both modes +read the bits LSB-first, which is the TMS34010 pixel order and the order the +tile pipeline now packs (`vidsrc.Rows.pack_row`); an MSB-first packer is +self-consistent with an MSB-first checker and mirrors every eight pixels on a +display. + +The switch changes only the bitmap, never CPU state, so the regression baseline +(`progress.lua`, `PC=3FDCE400 samples=198`) is unaffected. Default is off: the +real machine runs level 1, and the derived geometry is what that text needs. diff --git a/harness/mame/vidstream.lua b/harness/mame/vidstream.lua index 7cb4d79..dc0f1df 100644 --- a/harness/mame/vidstream.lua +++ b/harness/mame/vidstream.lua @@ -268,8 +268,9 @@ local RMD1_STP = 0x0200 local RMD1_CRC = 0x0800 local RMD1_ERR = 0x4000 --- The frame every mode delivers first: 8 rows of 1280 pixels, 1 bpp MSB-first --- (160 bytes a row), each row repeated 8 times. 1280 B of payload -- the same +-- The frame every mode delivers first: 8 rows of 1280 pixels, 1 bpp LSB-first +-- (160 bytes a row, bit 0 of a byte is the leftmost pixel), each row repeated +-- 8 times. 1280 B of payload -- the same -- geometry vidsrc.py's ROW_BYTES produces, so Task 5 can blit it unchanged. -- 1296 bytes is ONE datagram, which is what mode rx wants. local RX_ROWS = 8 @@ -1818,7 +1819,7 @@ local function tile_byte(entry, row, col) end -- The 32 KB dictionary as a string: 256 entries of 32 rows of four bytes, --- row-major, MSB-first within each byte -- exactly tilecost.py's layout. +-- row-major, LSB-first within each byte -- exactly tilecost.py's layout. local function tile_dictionary() local out = {} for e = 0, 255 do diff --git a/harness/mame/vt1200.cpp b/harness/mame/vt1200.cpp index 617a98c..5c4e99b 100644 --- a/harness/mame/vt1200.cpp +++ b/harness/mame/vt1200.cpp @@ -802,6 +802,16 @@ private: // times -- the instrument for telling "progressing" from "stuck": if two // samples minutes apart are byte-identical, nothing is being drawn. bool m_render = true; + // VT1200_VRAM_REAL=1: render the framebuffer at the REAL unit's geometry + // (2048-bit/256-byte row pitch, 1280 px wide) instead of the derived + // 1024-bit/128-byte pitch MAME's own screen geometry implies. Off by + // default: the Level-1 error text is only legible at 128 bytes (see + // RENDER_REPORT.md), so this is for looking at content written by a payload + // that targets the real machine -- e.g. the tile stream, whose + // vram_tile.asm sets DPTCH=0x800 (2048 bits). The bit order is LSB-first in + // both modes, which is the TMS34010 pixel order and matches + // dwt/screenshot.py's decode of live VRAM. + bool m_render_real = false; long m_dump_frame = -1; std::vector m_dump_frames; // VT1200_DUMP_FRAMES=3000,10000,... std::string m_dump_dir = "."; @@ -912,6 +922,8 @@ void vt1200_state::machine_start() // Video: render by default; optionally dump a frame as PPM. if (getenv("VT1200_NO_RENDER")) m_render = false; + if (getenv("VT1200_VRAM_REAL")) + m_render_real = true; if (const char *f = getenv("VT1200_DUMP_FRAME")) m_dump_frame = strtol(f, nullptr, 0); if (const char *f = getenv("VT1200_DUMP_RAM")) @@ -1477,6 +1489,14 @@ void vt1200_state::log_lance_dma(char kind, u32 addr, u16 value, u16 mask) // // The screen line maps 1:1 to a framebuffer row, offset by the vertical // blanking start (params->veblnk). +// +// VT1200_VRAM_REAL=1 switches to the REAL unit's layout instead: pitch 2048 +// bits (0x800 / 256 bytes), 1280 px wide, drawn from x=0. It exists because a +// payload can write for the real machine and then look sheared here -- the tile +// stream's vram_tile.asm sets DPTCH=0x800 and dwt/screenshot.py --real reads +// stride 256 at 1280 wide. With the switch off nothing about the geometry +// changes: this is a view of the same VRAM at the other documented pitch, not a +// different machine model. void vt1200_state::render_active_line(bitmap_ind16 &bitmap, int scanline, const tms340x0_device::display_params *params) { @@ -1492,11 +1512,26 @@ void vt1200_state::render_active_line(bitmap_ind16 &bitmap, int scanline, return; const u16 *const vram = m_vram.target(); - const int x0 = (int)params->heblnk; - const int x1 = (int)params->hsblnk; - const u32 row_bit = (u32)fby * VMEM_PITCH_BITS; + // Two geometries, one per documented layout (harness/c-hello/README.md, + // "Geometry is not portable between the real unit and MAME"): + // + // derived (default) pitch 1024 bits, 1024 px, x = heblnk + fx + // real (VT1200_VRAM_REAL=1) pitch 2048 bits (0x800, 256 bytes), 1280 px, + // x = fx -- the real raster is 1280 wide, so there is + // no blanking offset to add + // + // The real numbers are the ones a payload targeting the hardware writes: + // vram_tile.asm sets DPTCH=0x800 and dwt/screenshot.py --real reads stride + // 256 / width 1280. Revert note: delete the m_render_real branch and the + // environment read and this file returns to the measured 128-byte renderer + // byte for byte; nothing else depends on it. + const u32 pitch = m_render_real ? 2048u : u32(VMEM_PITCH_BITS); + const int width = m_render_real ? 1280 : VMEM_WIDTH; + const int x0 = m_render_real ? 0 : (int)params->heblnk; + const int x1 = m_render_real ? bitmap.width() : (int)params->hsblnk; + const u32 row_bit = (u32)fby * pitch; - for (int fx = 0; fx < VMEM_WIDTH; fx++) + for (int fx = 0; fx < width; fx++) { const int x = x0 + fx; if (x >= x1 || x >= bitmap.width()) diff --git a/harness/udpstream/make_tiles.py b/harness/udpstream/make_tiles.py index fd9719a..527f0d4 100644 --- a/harness/udpstream/make_tiles.py +++ b/harness/udpstream/make_tiles.py @@ -198,12 +198,24 @@ def _silhouette(rows: list[int], n: int) -> None: rows[y] |= _span(x0 + 205, x0 + 250) & _MASK # the "eye" +#: Bit-reversal table: the synthetic clip is built as pixel ints with pixel 0 +#: at the top bit, but the terminal's scanout reads bit 0 of a byte as the +#: leftmost pixel, so every byte is reversed on the way out (one table lookup, +#: not 1,280 shifts a row). +_BITREV = bytes(int(format(value, "08b")[::-1], 2) for value in range(256)) + + +def _pack_row(row: int) -> bytes: + """Pack one row int as the terminal reads it: LSB first within each byte.""" + return row.to_bytes(ROW_BYTES, "big").translate(_BITREV) + + def _synthetic_frame(n: int) -> bytes: rows = list(_background_rows()) for y in range(SCREEN_ROWS - 160, SCREEN_ROWS): rows[y] = 0 # a black ground band _silhouette(rows, n) - return b"".join(row.to_bytes(ROW_BYTES, "big") for row in rows) + return b"".join(_pack_row(row) for row in rows) def synthetic_clip(frames: int = 30) -> list[bytes]: @@ -244,7 +256,12 @@ def load_clip(directory) -> list[bytes]: """Read a clip directory's ``*.bin`` frames, sorted by name. Each file is one raw packed 1-bpp frame, exactly what - :func:`harness.udpstream.tilecost.quantise` takes. A ``.bin`` that is not + :func:`harness.udpstream.tilecost.quantise` takes. The bit order is the + terminal's scanout order: **LSB first** -- pixel *x* of a row is bit + ``x % 8`` of byte ``x // 8`` (:meth:`harness.udpstream.vidsrc.Rows.pack_row`). + An MSB-first packer is self-consistent with an MSB-first checker and mirrors + every eight pixels on the display, so the order is stated here rather than + left to the producer. A ``.bin`` that is not whole rows and whole tile bands is an error naming the file, not a silent reshape -- the terminal's 1,280-byte frame is only meaningful for a whole grid. The sort is by name, so number the frames zero-padded: ``frame-0007`` diff --git a/harness/udpstream/tests/test_tilecost.py b/harness/udpstream/tests/test_tilecost.py index ae79eca..3511986 100644 --- a/harness/udpstream/tests/test_tilecost.py +++ b/harness/udpstream/tests/test_tilecost.py @@ -3,7 +3,8 @@ The plan's two tests come first, verbatim. The extras pin down what the plan text leaves implicit but the later tasks depend on: -* the tile layout -- 32 rows of four bytes, row-major, MSB first -- so a +* the tile layout -- 32 rows of four bytes, row-major, LSB first (bit 0 of a + byte is the leftmost pixel, the scanout order) -- so a dictionary entry is exactly the bytes the terminal's ``PIXBLT B,XY`` reads from ``dict + index*128``; * the index frame's size. The plan says ``quantise`` returns 1,280 index bytes diff --git a/harness/udpstream/tests/test_vidsrc.py b/harness/udpstream/tests/test_vidsrc.py index 9c7932a..fb38af8 100644 --- a/harness/udpstream/tests/test_vidsrc.py +++ b/harness/udpstream/tests/test_vidsrc.py @@ -20,20 +20,19 @@ from harness.udpstream.vidsrc import ( CHUNK = 1450 -def test_pack_row_is_msb_first_and_160_bytes(): +def test_pack_row_is_lsb_first_and_160_bytes(): px = [0] * 1280 px[0] = 1 px[7] = 1 px[8] = 1 b = Rows.pack_row(px) assert len(b) == 160 - # Pixel 0 -> bit 7, pixel 7 -> bit 0, pixel 8 -> bit 7 of byte 1. - # NOTE: the plan/brief write this as 0b11000001, which is 0xC1 and encodes - # pixels 0, 1 and 7 -- three pixels from a two-pixel segment of the row, so - # no packing rule can produce it from px[0] and px[7]. MSB-first, as the - # same brief's prose states and as the terminal's bit-addressed row needs, - # is 0b10000001. See the task-2 report. - assert b[0] == 0b10000001 and b[1] == 0b10000000 + # Pixel 0 -> bit 0, pixel 7 -> bit 7, pixel 8 -> bit 0 of byte 1: the + # terminal's scanout reads bit 0 of a byte as its leftmost pixel + # (dwt/screenshot.py decodes live VRAM the same way). The plan carried an + # MSB-first pair here (0b11000001 / 0b10000000), which is self-consistent + # with an MSB-first checker but mirrors every eight pixels on a display. + assert b[0] == 0b10000001 and b[1] == 0b00000001 def test_strip_source_rejects_stretch_that_does_not_divide_1024(): @@ -54,13 +53,36 @@ def test_strip_frame_round_trips_through_the_header(): # --- packing ------------------------------------------------------------- +def _decode_like_the_terminal(row: bytes) -> list[int]: + """Decode a packed row the way the VT1200 scanout does. + + The same rule ``dwt/screenshot.py`` uses on live VRAM: bit ``x % 8`` of + byte ``x // 8`` is pixel ``x`` -- LSB first within each byte. + """ + return [(row[x // 8] >> (x % 8)) & 1 for x in range(1280)] + + +def test_pack_row_decodes_back_to_the_pixels_the_terminal_shows(): + """A row must survive the terminal's own LSB-first scanout decode. + + The failure this pins: an MSB-first packer is self-consistent with an + MSB-first checker, so every MAME replay check passes while the screen + mirrors every group of eight pixels. Decoding the packed row with the + hardware's rule is the only reading that can catch it. + """ + px = [1 if i % 3 == 0 else 0 for i in range(1280)] + px[0] = 0 # asymmetric ends, so a mirror cannot pass + px[1279] = 1 + assert _decode_like_the_terminal(Rows.pack_row(px)) == px + + def test_pack_row_puts_every_pixel_where_the_terminal_reads_it(): - """Pixel i is bit (7 - i % 8) of byte i // 8, i.e. MSB first, and no bit leaks.""" + """Pixel i is bit (i % 8) of byte i // 8, i.e. LSB first, and no bit leaks.""" px = [1 if i % 3 == 0 else 0 for i in range(1280)] b = Rows.pack_row(px) assert len(b) == 160 for i, v in enumerate(px): - assert (b[i // 8] >> (7 - i % 8)) & 1 == v, i + assert (b[i // 8] >> (i % 8)) & 1 == v, i def test_pack_row_rejects_a_row_that_is_not_1280_pixels(): diff --git a/harness/udpstream/tilecost.py b/harness/udpstream/tilecost.py index 9549d41..960993e 100644 --- a/harness/udpstream/tilecost.py +++ b/harness/udpstream/tilecost.py @@ -13,7 +13,8 @@ Geometry The screen is 1280x1024, one bit per pixel. A source row is therefore 160 bytes (:data:`harness.udpstream.vidsrc.ROW_BYTES`) and a 32x32 tile is 32 rows of **four** bytes -- 32 px is two 16-bit words -- for 128 bytes: row-major, two -bytes per word, MSB first within each byte, the same bit order +bytes per word, **LSB first** within each byte (bit 0 is the leftmost pixel, +the scanout order), the same bit order :meth:`harness.udpstream.vidsrc.Rows.pack_row` produces. The grid is 40 columns by 32 rows: 1,280 tiles. diff --git a/harness/udpstream/tiles/dictionary.bin b/harness/udpstream/tiles/dictionary.bin index 252b353..d3842ce 100644 Binary files a/harness/udpstream/tiles/dictionary.bin and b/harness/udpstream/tiles/dictionary.bin differ diff --git a/harness/udpstream/tiles/indices.bin b/harness/udpstream/tiles/indices.bin index 5ec1b76..b8a81a8 100644 Binary files a/harness/udpstream/tiles/indices.bin and b/harness/udpstream/tiles/indices.bin differ diff --git a/harness/udpstream/tiles/manifest.json b/harness/udpstream/tiles/manifest.json index d017cbe..7222ffa 100644 --- a/harness/udpstream/tiles/manifest.json +++ b/harness/udpstream/tiles/manifest.json @@ -1,68 +1,68 @@ { "active_row_bytes": 160, "bit_errors": [ - 1308, - 1351, - 1420, - 1366, - 1309, - 1425, - 1442, - 1507, - 1389, - 1574, - 1444, - 1342, - 1446, - 1260, - 1556, - 1459, - 1592, - 1472, - 1369, - 1524, - 1509, - 1581, - 1506, - 1505, - 1514, - 1719, - 1540, - 1595, - 1610, - 1809 + 953, + 778, + 999, + 880, + 868, + 1020, + 801, + 944, + 801, + 955, + 778, + 964, + 1086, + 985, + 823, + 1056, + 895, + 1019, + 882, + 890, + 1041, + 1038, + 1031, + 1043, + 910, + 1011, + 1169, + 1173, + 1195, + 1132 ], "changed_tiles": [ 0, - 12, - 14, - 13, - 7, - 15, - 11, - 11, - 11, - 10, - 11, - 11, - 10, - 12, - 9, - 14, 10, 8, - 13, - 15, - 9, 14, + 9, + 12, + 11, + 12, + 14, + 10, + 12, + 13, + 10, + 13, + 10, 13, 11, + 10, 12, + 13, 12, - 12, - 12, + 10, + 13, + 9, + 13, + 13, 11, - 10 + 11, + 12, + 11 ], "clip": "synthetic_clip", "delta_frames": [ @@ -96,7 +96,7 @@ 28, 29 ], - "delta_payload_bytes": 1057, + "delta_payload_bytes": 1054, "dictionary_bytes": 32768, "entries_used": 256, "frames": 30, @@ -108,71 +108,71 @@ ], "payload_bytes": [ 1280, - 38, - 44, - 41, - 23, - 47, - 35, - 35, - 35, - 32, - 35, - 35, - 32, - 38, - 29, - 44, 32, 26, - 41, - 47, - 29, 44, + 29, + 38, + 35, + 38, + 44, + 32, + 38, + 41, + 32, + 41, + 32, 41, 35, + 32, 38, + 41, 38, - 38, - 38, + 32, + 41, + 29, + 41, + 41, 35, - 32 + 35, + 38, + 35 ], "psnr_db": [ - 30.00902182299432, - 29.868545772656496, - 29.652215819046237, - 29.820592269421663, - 30.005702797369246, - 29.636950619431516, - 29.585446659042702, - 29.393966739730484, - 29.748076805500645, - 29.205051982646346, - 29.579427330540604, - 29.897574104547072, - 29.573416333291682, - 30.171393811701176, - 29.255003336340103, - 29.534546343942285, - 29.155668628860305, - 29.496021162862004, - 29.811064781536906, - 29.345249592840986, - 29.388206865120907, - 29.185780563554715, - 29.396849544229987, - 29.399734263578182, - 29.373840511236263, - 28.82234049600628, - 29.299892054512174, - 29.1474923889448, - 29.106840502558306, - 28.600713594278666 + 31.384170256493537, + 32.265303292979915, + 31.17944438061698, + 31.73027254137512, + 31.789902011111884, + 31.08909754525763, + 32.13877410203443, + 31.425379319896113, + 32.13877410203443, + 31.37506554703934, + 32.265303292979915, + 31.334328923848496, + 30.81680101034852, + 31.240736957900687, + 32.02110091075411, + 30.93846008089887, + 31.656868909717684, + 31.093357422812538, + 31.720413411558606, + 31.681199196427677, + 31.00059196777144, + 31.01312572775241, + 31.042512610041637, + 30.992256178611495, + 31.584685339665867, + 31.127587706966793, + 30.4969541512584, + 30.482119141721512, + 30.401420210035237, + 30.636634994354278 ], "screen_rows": 1024, "seed": 0, - "total_payload_bytes": 2337, + "total_payload_bytes": 2334, "unchanged_frames": [], "version": 1 } diff --git a/harness/udpstream/tiles/stream.bin b/harness/udpstream/tiles/stream.bin index 64f0522..8d1f32d 100644 Binary files a/harness/udpstream/tiles/stream.bin and b/harness/udpstream/tiles/stream.bin differ diff --git a/harness/udpstream/vidsrc.py b/harness/udpstream/vidsrc.py index 82a8019..cb18fff 100644 --- a/harness/udpstream/vidsrc.py +++ b/harness/udpstream/vidsrc.py @@ -1,7 +1,8 @@ """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 MSB -first. The terminal blits row *n* to destination row ``n * stretch`` with the +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 @@ -63,19 +64,26 @@ class Rows: def pack_row(pixels: list[int]) -> bytes: """Return the :data:`ROW_BYTES` packed bytes for ``pixels``. - Pixel *i* becomes bit ``7 - i % 8`` of byte ``i // 8`` -- MSB first, - which is the bit order of the terminal's line buffer. A truthy pixel is - a set bit, so ``True``, ``1`` and any other nonzero value all mean ink. + 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. - # Equivalent to setting bit 7 - i % 8 of byte i // 8 for each pixel i, - # but the join+parse is C-speed per character and measured ~1.5x a - # per-pixel shift loop on this box (65-120 us vs 100-150 us per row). + # 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) - return int(bits, 2).to_bytes(ROW_BYTES, "big") + 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: diff --git a/harness/udpstream/vram_tile.asm b/harness/udpstream/vram_tile.asm index a08daaf..1164931 100644 --- a/harness/udpstream/vram_tile.asm +++ b/harness/udpstream/vram_tile.asm @@ -1,8 +1,9 @@ ; vram_tile.asm -- PIXBLT B,XY: one 32x32 1-bpp dictionary tile to the screen. ; ; Task 4's tile primitive. A dictionary entry is 32 rows of FOUR bytes (32 px -; is two 16-bit words) = 128 bytes = 1024 bits, row-major, MSB first within each -; byte -- tilecost.py's layout. PIXBLT's B source form reads packed 1-bit +; is two 16-bit words) = 128 bytes = 1024 bits, row-major, LSB first within each +; byte (bit 0 is the leftmost pixel -- the scanout order) -- tilecost.py's +; layout. PIXBLT's B source form reads packed 1-bit ; pixels from a linear bit address and writes an XY destination, so one tile is ; ; SADDR = dict_base + index * 1024 (128 bytes a tile: index << 10)