diff --git a/harness/badapple/README.md b/harness/badapple/README.md new file mode 100644 index 0000000..f0c0cc1 --- /dev/null +++ b/harness/badapple/README.md @@ -0,0 +1,147 @@ +# Bad Apple on a VT1200 + +The demo end to end: a payload is **delivered over the network** into a running +terminal, and the picture is **streamed over the network** to it, which paints +it from a 32 KB tile dictionary plus per-frame deltas. Nothing here uses a +debugger — that path (`harness/mame/vidstream.lua`) exists to *test* the payload +against MAME's debugger API and is not how anything reaches a machine. + +``` +harness/badapple/ +├── README.md this file +├── demo.sh one command: wait for the OS, load, send +├── rdebug_load.py payload image → terminal RAM over rdebug (TCP 771) +├── clip_from_video.py video → raw 1-bpp frames for make_tiles.py +└── tests/ unit tests for the two Python tools +``` + +## How the delivery works + +| step | transport | what it does | +|---|---|---| +| boot | — | `harness/mame/run.sh net` (MAME) or the real unit powering up | +| readiness | ICMP | `demo.sh` waits for the terminal's own stack to answer | +| payload | **rdebug, TCP 771** | `poke` the image in 512-byte chunks, read it back, `jump` to the entry | +| dictionary | Ethernet, ethertype `0x88B7` | 23 datagrams, one `TILES_DICT_CHUNK` each | +| pictures | same | one `TILES_FULL` or `TILES_DELTA` per clip frame | + +`rdebug` is the firmware's own remote-debug service: `peek`/`poke`/`jump`, the +same protocol `dwt/exploit.py` speaks (`dwt/notes/CROSS.md`). On hardware it is +the delivery path; in MAME it works because the LANCE is attached to a host tap. + +Two facts about it are load-bearing and were measured: + +* **A 512-byte poke chunk, not 1024.** The firmware documents a 1024-byte clamp, + but a 1024-byte poke is mis-parsed — it reads the body's first word as the + next opcode and answers `Invalid command 0xA5A5A5A5` for an `0xA5`-filled + chunk. 512 is accepted and reads back byte for byte. +* **Nothing can be probed after the `jump`.** A non-returning payload takes the + remote-debug process with it, so `rdebug_load.py` verifies the image *before* + jumping, and the run is observed afterwards from the machine itself (the + screen, or MAME's RAM/VRAM dumps). One demo per boot. + +## Setup + +```sh +cd /path/to/vt1200 + +# 1. MAME + the driver (skip if you already have vt1200 built) +harness/mame/build.sh + +# 2. the payload (TI SDK under DOSBox) +harness/udpstream/build-stream.sh # -> harness/udpstream/build/vidstream.bin + +# 3. a clip cache (skip to use the committed 30-frame synthetic clip) +python3 harness/badapple/clip_from_video.py bad-apple.mp4 /tmp/badapple --fps 15 +python3 harness/udpstream/make_tiles.py --clip /tmp/badapple --out /tmp/badapple-tiles +``` + +`clip_from_video.py` writes **LSB-first** 1-bpp rows (bit 0 of a byte is the +leftmost pixel), which is the order both the terminal's scanout and the +payload's encoder use. Do not substitute `ffmpeg`'s `format=monob`: it packs +MSB first and mirrors every eight pixels on the display. + +`make_tiles.py` prints what it built — on the real clip (3,292 frames at 15 fps, +1280x960) that is 256/256 dictionary entries, 55 keyframes, mean delta 496 B, +1.63 MB of stream, PSNR 24.4 dB. + +## Run it in MAME + +Terminal 1 — the machine, with the LANCE on the host tap (`harness/mame/cfg-net` +selects `taptun`; the tap is `tap-mess--0` in bridge `vt1200`, +`192.168.99.1/24`): + +```sh +VT1200_VRAM_REAL=1 harness/mame/run.sh net +``` + +`VT1200_VRAM_REAL=1` renders the framebuffer at the **real unit's** geometry +(2048-bit / 256-byte rows, 1280 px). Without it the window uses the driver's +derived 128-byte pitch and the tile picture is sheared — the divergence is +documented in `harness/mame/RENDER_REPORT.md` §8, and it is a property of the +renderer, not of the demo. + +Terminal 2 — the demo: + +```sh +harness/badapple/demo.sh --tiles /tmp/badapple-tiles --frames 3292 --fps 15 +``` + +With no arguments `demo.sh` plays the committed 30-frame synthetic cache: a +smoke test that needs no clip. + +## Run it on a real VT1200 + +The terminal must be reachable over Ethernet and answering rdebug on TCP 771 — +that is all this demo needs, no debug pod and no special build of the firmware. + +```sh +# the unit's address, and the host interface that reaches it +harness/badapple/demo.sh --host --iface \ + --tiles /tmp/badapple-tiles --frames 3292 --fps 15 +``` + +Notes that differ from the emulator: + +* **Pacing is not needed** for the dictionary upload: the payload's receive ring + is 32 descriptors, one more than the 23-datagram upload, so the whole + dictionary lands before the first chunk is copied. On a slower link the wire + paces it for you. +* **`VT1200_VRAM_REAL` does not exist there** — the unit already uses its own + layout; the switch only affects MAME's renderer. +* The image is position-dependent and is linked at bit `0x018C0000` with its + entry at the address in `harness/udpstream/build/vidstream.env`. Both are + read from that file, so a rebuild moves the entry without editing anything. + +## What success looks like + +`demo.sh` prints the load verification and the sender's per-frame CSV. The +sender's verdict is about the *transport*: + +``` +PASS: 2 status frames, last frames_rx=100 (drops=0 crc_err=0) +``` + +The terminal's own counters, read from a MAME RAM dump (byte offsets from +`MARK_PAGE` = bit `0x01810000`), say what it actually did: + +| cell | offset | good value | +|---|---|---| +| `DICT_BYTES` | `0x54` | `32768` (and `DICT_READY` `0x50` = 1) | +| `DICT_BAD` | `0x58` | `0` | +| `RX_OK` | `0x30` | climbs with painted frames | +| `TILES_DROP` | `0x5c` | `0` | +| `RX_DGRAM` | `0x48` | datagrams consumed | + +`harness/mame/tile_check.py --replay` is the pixel verdict for the MAME replay +path; for a live run the screen is the evidence. + +## Failure modes worth knowing + +| symptom | cause | +|---|---| +| `demo.sh: ... did not answer within 180s` | the terminal is not on the tap, or `run.sh net` was started without the LANCE interface selected in its cfg | +| poke reads back a mismatch | the image is being loaded over a stale link, or the payload was built for a different load address | +| `DICT_BYTES` stuck below 32768 | datagrams lost before the payload saw them — check the tap and the sender's interface | +| picture sheared in MAME | the window is rendering at the derived 128-byte pitch; use `VT1200_VRAM_REAL=1` | +| `jump` seems to hang the service | expected: the payload never returns, so rdebug is gone until the machine is rebooted | diff --git a/harness/badapple/__init__.py b/harness/badapple/__init__.py new file mode 100644 index 0000000..ee1d9a2 --- /dev/null +++ b/harness/badapple/__init__.py @@ -0,0 +1,10 @@ +"""The Bad Apple demo: build a clip cache, deliver the payload, play it. + +``harness/badapple/README.md`` is the setup and run guide. The three pieces: + +* ``clip_from_video.py`` a video file -> raw 1-bpp frames in the terminal's + bit order, the input ``make_tiles.py --clip`` wants; +* ``rdebug_load.py`` the payload image into a running terminal over the + firmware's own remote-debug service (TCP 771); +* ``demo.sh`` one command: wait for the OS, load, send the frames. +""" diff --git a/harness/badapple/clip_from_video.py b/harness/badapple/clip_from_video.py new file mode 100755 index 0000000..23d7a9f --- /dev/null +++ b/harness/badapple/clip_from_video.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Turn a video into the raw 1-bpp frames ``make_tiles.py --clip`` takes. + +Each output file is one frame: whole 160-byte rows, whole 32-row tile bands, +top-aligned, **LSB first** -- bit 0 of a byte is the row's leftmost pixel, the +order the terminal's scanout reads (``dwt/screenshot.py`` decodes live VRAM with +the same rule). An MSB-first packer is self-consistent with an MSB-first +checker and mirrors every eight pixels on a display, which is why the order is +spelled out here rather than left to ffmpeg. + +The screen is 1280 px wide and the tile grid is 32 rows tall, so a 4:3 source +scaled to 1280x960 fills 30 of them and the last two rows stay black. Pass +``--height 1024`` to fill the screen and accept 4:3 being stretched. + +Requires ``ffmpeg`` on PATH and numpy. + + clip_from_video.py bad-apple.mp4 frames/ --fps 15 + clip_from_video.py bad-apple.mp4 frames/ --fps 12 --threshold 100 --invert +""" +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys + +ROW_PIXELS = 1280 +ROW_BYTES = ROW_PIXELS // 8 # 160 + + +def pack_row(pixels) -> bytes: + """Pack one row of 0/1 pixels, LSB first (pixel 0 -> bit 0 of byte 0).""" + if not pixels or len(pixels) % 8: + raise ValueError("a row is a positive multiple of 8 pixels, got %d" + % len(pixels)) + out = bytearray(len(pixels) // 8) + for i, value in enumerate(pixels): + if value: + out[i >> 3] |= 1 << (i & 7) + return bytes(out) + + +def frames(video, out_dir, fps=15, width=ROW_PIXELS, height=960, threshold=127, + invert=False): + """Yield ``(frame_no, packed_row_bytes)`` for every video frame.""" + import numpy as np + + vf = ("fps=%s,scale=%d:%d:flags=lanczos,format=gray" + % (fps, width, height)) + if invert: + vf += ",negate" + cmd = ["ffmpeg", "-v", "error", "-i", video, "-vf", vf, + "-f", "rawvideo", "-"] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=10 ** 8) + size = width * height + n = 0 + try: + while True: + buf = proc.stdout.read(size) + if len(buf) < size: + break + gray = np.frombuffer(buf, dtype=np.uint8).reshape(height, width) + yield n, np.packbits(gray > threshold, axis=1, bitorder="little") + n += 1 + finally: + proc.stdout.close() + proc.wait() + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("video", help="source video (anything ffmpeg reads)") + ap.add_argument("out", help="directory for the *.bin frames") + ap.add_argument("--fps", type=float, default=15.0, + help="frames per second to extract (default %(default)s)") + ap.add_argument("--width", type=int, default=ROW_PIXELS, + help="pixels per row (default %(default)s)") + ap.add_argument("--height", type=int, default=960, + help="rows to extract: 960 fills 30 of the 32 tile bands, " + "1024 fills the screen (default %(default)s)") + ap.add_argument("--threshold", type=int, default=127, + help="grey level above which a pixel is ink " + "(default %(default)s)") + ap.add_argument("--invert", action="store_true", + help="treat bright pixels as paper (negate first)") + args = ap.parse_args(argv) + + if shutil.which("ffmpeg") is None: + print("error: ffmpeg is not on PATH", file=sys.stderr) + return 2 + if args.width % 8 or args.width > ROW_PIXELS or args.width < 8: + print("error: --width must be a multiple of 8, at most %d" % ROW_PIXELS, + file=sys.stderr) + return 2 + if args.height % 32 or args.height > 1024: + print("error: --height must be a multiple of 32, at most 1024", + file=sys.stderr) + return 2 + + os.makedirs(args.out, exist_ok=True) + for name in os.listdir(args.out): + if name.endswith(".bin"): + os.remove(os.path.join(args.out, name)) + + written = 0 + for n, packed in frames(args.video, args.out, fps=args.fps, + width=args.width, height=args.height, + threshold=args.threshold, invert=args.invert): + path = os.path.join(args.out, "%05d.bin" % n) + with open(path, "wb") as handle: + handle.write(packed.tobytes()) + written += 1 + if written % 100 == 0: + print(" %d frames" % written, file=sys.stderr) + print("%d frames of %dx%d at %s fps -> %s" + % (written, args.width, args.height, args.fps, args.out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness/badapple/demo.sh b/harness/badapple/demo.sh new file mode 100755 index 0000000..cfd9b95 --- /dev/null +++ b/harness/badapple/demo.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Play the streaming demo on a RUNNING terminal: wait for its OS network stack, +# deliver the payload over rdebug, then send the clip with the host sender. +# +# The terminal itself is started separately -- `harness/mame/run.sh net` for the +# emulator (add VT1200_VRAM_REAL=1 to see the picture at the real unit's +# framebuffer layout), or a real VT1200 on the network. This script only talks +# to it, so one instance serves one demo: the jump hands the payload the +# remote-debug process and it never returns. +# +# demo.sh # committed 30-frame cache +# demo.sh --tiles /tmp/badapple --frames 3292 --fps 15 +# demo.sh --host 192.168.99.2 --iface vt1200 +# demo.sh --no-load # send only, payload already running +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$HERE/../.." && pwd) + +HOST=192.168.99.2 +IFACE=vt1200 +TILES=$ROOT/harness/udpstream/tiles +FRAMES=200 +FPS=15 +LOAD=1 +SEND=1 +WAIT=180 + +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST=$2; shift 2 ;; + --iface) IFACE=$2; shift 2 ;; + --tiles) TILES=$2; shift 2 ;; + --frames) FRAMES=$2; shift 2 ;; + --fps) FPS=$2; shift 2 ;; + --wait) WAIT=$2; shift 2 ;; + --no-load) LOAD=0; shift ;; + --no-send) SEND=0; shift ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "demo.sh: unknown option $1" >&2; exit 2 ;; + esac +done + +# --- readiness: an ICMP reply from the terminal's own stack ----------------- +start=$(date +%s) +while ! ping -c1 -W1 "$HOST" > /dev/null 2>&1; do + if [ $(( $(date +%s) - start )) -ge "$WAIT" ]; then + echo "demo.sh: $HOST did not answer within ${WAIT}s" >&2 + echo " is the terminal booted and on the tap? (harness/mame/run.sh net)" >&2 + exit 1 + fi +done +echo "demo.sh: $HOST answers ($(( $(date +%s) - start ))s)" + +# --- the payload, over the firmware's own remote-debug service -------------- +if [ "$LOAD" = 1 ]; then + python3 "$HERE/rdebug_load.py" --host "$HOST" + # The payload takes the LANCE and the remote-debug process with it, so the + # sender is started once the machine has had time to arm its ring. + sleep 3 +fi + +# --- the clip --------------------------------------------------------------- +if [ "$SEND" = 1 ]; then + echo "demo.sh: sending $FRAMES frame(s) from $TILES at $FPS fps on $IFACE" + sudo -n python3 "$ROOT/harness/udpstream/vidsend.py" --iface "$IFACE" \ + --tiles --tiles-dir "$TILES" --frames "$FRAMES" --fps "$FPS" +fi diff --git a/harness/badapple/rdebug_load.py b/harness/badapple/rdebug_load.py new file mode 100755 index 0000000..918b185 --- /dev/null +++ b/harness/badapple/rdebug_load.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Deliver a payload image into a running VT1200 over rdebug, then jump to it. + +This is the real delivery path, not a debugger shortcut: the image is written +with the firmware's own remote-debug service (``peek``/``poke``/``jump``, TCP +771 -- ``dwt/notes/CROSS.md``), exactly as ``dwt/exploit.py`` does for its +one-shot commands. ``harness/mame/vidstream.lua`` remains the *test* path: it +pokes through MAME's debugger API, which is fine for checking a payload and is +not how anything reaches the hardware. + +Two things the protocol forces, both measured in ``harness/badapple/README.md``: + +* a **512-byte chunk**. The firmware's documented clamp is 1024, but a + 1024-byte poke is mis-parsed -- the chip reads the body's first word as the + next opcode and answers "Invalid command 0xA5A5A5A5" for a 0xA5-filled + chunk. 512 is accepted and read back byte for byte. +* **no probing after the jump**. A non-returning payload takes the remote-debug + process with it, so the load is verified *before* the jump and observed + afterwards from the machine itself (MAME's RAM dumps on the emulator, the + screen on the unit). + +Usage +----- + rdebug_load.py # image + entry from the build + rdebug_load.py --dry-run # what would be written where + rdebug_load.py --no-jump # load and verify only + rdebug_load.py --host 192.168.99.2 --image build/vidstream.bin +""" +from __future__ import annotations + +import argparse +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +DWT = os.path.join(ROOT, "dwt") + +if DWT not in sys.path: + sys.path.insert(0, DWT) + +#: Largest poke the firmware actually accepts. See the module docstring. +CHUNK = 512 + +#: The image's link origin: the .text address `build-stream.sh` links at. Used +#: only when the environment file is absent, so a hand-built image still loads. +DEFAULT_LOAD = 0x018C0000 + + +def read_env(path) -> dict: + """Parse ``vidstream.env`` (``KEY=VALUE`` lines) into ints where possible.""" + out = {} + with open(path) as handle: + for line in handle: + line = line.strip() + if not line or "=" not in line: + continue + key, value = line.split("=", 1) + value = value.strip() + try: + out[key.strip()] = int(value, 0) + except ValueError: + out[key.strip()] = value + return out + + +def chunks(data: bytes, size: int = CHUNK) -> list[bytes]: + """Split ``data`` into transfers the firmware will accept.""" + if size < 1 or size > CHUNK: + raise ValueError( + "a poke chunk must be 1..%d bytes (the firmware mis-parses %d); got %r" + % (CHUNK, CHUNK * 2, size)) + return [data[off:off + size] for off in range(0, len(data), size)] + + +def chunk_addr(base: int, index: int, size: int = CHUNK) -> int: + """Bit address of chunk ``index``: the TMS34010 addresses bits, so a + ``size``-byte transfer advances the pointer by ``size * 8``.""" + return base + index * size * 8 + + +def plan(image: bytes, load: int, size: int = CHUNK): + """The transfers this load will perform, as ``(bit address, bytes)``.""" + return [(chunk_addr(load, n, size), part) + for n, part in enumerate(chunks(image, size))] + + +def load_and_jump(image_path, entry, load, do_jump=True, chunk=CHUNK, + host="192.168.99.2", port=771, verbose=False): + """Poke ``image_path`` at ``load``, verify it, then jump to ``entry``.""" + import exploit # dwt/exploit.py: the protocol the firmware speaks + + data = open(image_path, "rb").read() + transfers = plan(data, load, chunk) + rd = exploit.RDebug(host, port, 5.0, verbose) + rd.connect() + try: + for addr, part in transfers: + rd.poke(addr, part) + print("poked %d bytes in %d chunk(s) at %#x" + % (len(data), len(transfers), load)) + + back = b"".join(rd.peek(addr, len(part)) for addr, part in transfers) + if back != data: + bad = next(i for i in range(min(len(back), len(data))) + if back[i] != data[i]) + print("readback MISMATCH at byte %d: wrote %02x, read %02x" + % (bad, data[bad], back[bad]), file=sys.stderr) + return 1 + print("readback OK: the machine holds the image we sent") + + if do_jump: + rd.jump(entry, [], wait=False) + print("jump sent to %#x (the payload never returns)" % entry) + finally: + rd.close() + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--host", default="192.168.99.2", + help="terminal address (default %(default)s)") + ap.add_argument("--port", type=int, default=771, + help="rdebug TCP port (default %(default)s)") + ap.add_argument("--image", + default=os.path.join(ROOT, "harness", "udpstream", "build", + "vidstream.bin"), + help="payload image (default: the streaming payload)") + ap.add_argument("--env", + default=os.path.join(ROOT, "harness", "udpstream", "build", + "vidstream.env"), + help="build environment carrying ENTRY_PTR") + ap.add_argument("--load", type=lambda s: int(s, 0), default=None, + help="load bit address (default: %#x)" % DEFAULT_LOAD) + ap.add_argument("--entry", type=lambda s: int(s, 0), default=None, + help="entry bit address (default: ENTRY_PTR from --env)") + ap.add_argument("--chunk", type=int, default=CHUNK, + help="poke size in bytes (max %d; default %%(default)s)" + % CHUNK) + ap.add_argument("--no-jump", action="store_true", + help="load and verify, do not start it") + ap.add_argument("--dry-run", action="store_true", + help="print the transfer plan and exit") + args = ap.parse_args(argv) + + try: + env = read_env(args.env) + except OSError: + print("error: no build environment at %s -- build the payload first " + "(harness/udpstream/build-stream.sh)" % args.env, + file=sys.stderr) + return 2 + + load = args.load if args.load is not None else env.get("LOAD_PTR", + DEFAULT_LOAD) + entry = args.entry if args.entry is not None else env.get("ENTRY_PTR") + if entry is None: + print("error: %s has no ENTRY_PTR (pass --entry)" % args.env, + file=sys.stderr) + return 2 + + try: + image = open(args.image, "rb").read() + transfers = plan(image, load, args.chunk) + except (OSError, ValueError) as exc: + print("error: %s" % exc, file=sys.stderr) + return 2 + + if args.dry_run: + print("%s: %d bytes, %d chunk(s) of %d, load %#x, entry %#x" + % (args.image, len(image), len(transfers), args.chunk, + load, entry)) + for addr, part in transfers[:3]: + print(" %#010x %d B" % (addr, len(part))) + if len(transfers) > 3: + print(" ... %d more" % (len(transfers) - 3)) + return 0 + + try: + return load_and_jump(args.image, entry, load, + do_jump=not args.no_jump, chunk=args.chunk, + host=args.host, port=args.port) + except Exception as exc: # noqa: BLE001 - one line, not a traceback + print("error: %s: %s" % (type(exc).__name__, exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness/badapple/tests/test_rdebug_load.py b/harness/badapple/tests/test_rdebug_load.py new file mode 100644 index 0000000..1d0384c --- /dev/null +++ b/harness/badapple/tests/test_rdebug_load.py @@ -0,0 +1,76 @@ +"""Tests for the rdebug loader and the clip builder. + +The pieces that touch a live terminal cannot be tested here; what is tested is +everything that decides *what* gets written where, because those are the parts a +wrong assumption silently corrupts: the chunking arithmetic, the linkage the +entry point is read from, and the 1-bpp packing the terminal actually reads. +""" +import struct + +import pytest + +from harness.badapple import clip_from_video +from harness.badapple import rdebug_load + +# --- chunking ------------------------------------------------------------- + +def test_chunks_are_transfer_sized_and_cover_the_image_once(): + data = bytes(range(200)) * 23 # 4,600 B: not a multiple of 512 + parts = rdebug_load.chunks(data, 512) + assert [len(p) for p in parts] == [512] * 8 + [504] + assert b"".join(parts) == data + + +def test_chunks_refuse_a_size_the_firmware_cannot_take(): + # 1024 is rejected by the firmware: it reads the body's first word as the + # next opcode ("Invalid command 0xA5A5A5A5" on a 0xA5-filled chunk). + with pytest.raises(ValueError): + rdebug_load.chunks(b"x", 1024) + with pytest.raises(ValueError): + rdebug_load.chunks(b"x", 0) + + +def test_addresses_advance_by_the_chunk_length_in_bits(): + base = 0x018C0000 + assert rdebug_load.chunk_addr(base, 0) == base + assert rdebug_load.chunk_addr(base, 1) == base + 512 * 8 + + +# --- linkage -------------------------------------------------------------- + +def test_env_file_gives_the_entry_and_the_pool_size(tmp_path): + env = tmp_path / "vidstream.env" + env.write_text("POOL_BYTES=249856\nSTATIC_PTR=0x01C00080\n" + "ENTRY_PTR=0x018D0270\n") + got = rdebug_load.read_env(env) + assert got["ENTRY_PTR"] == 0x018D0270 + assert got["POOL_BYTES"] == 249856 + + +def test_missing_env_file_says_so_instead_of_guessing(tmp_path, capsys): + rc = rdebug_load.main(["--image", "x.bin", "--env", str(tmp_path / "no.env")]) + assert rc == 2 + err = capsys.readouterr().err + assert "no build environment" in err and "build-stream.sh" in err + + +# --- the clip format ------------------------------------------------------ + +def test_packed_rows_put_pixel_zero_on_bit_zero_of_byte_zero(): + """The terminal reads bit 0 of a byte as the leftmost pixel. + + Same rule the payload's encoder uses (`vidsrc.Rows.pack_row`), and the one + an MSB-first packer gets wrong in a way that only shows on a display. + """ + pixels = [[0] * 16] + pixels[0][0] = 1 # leftmost pixel of the row + pixels[0][15] = 1 # last pixel of the second byte + row = clip_from_video.pack_row(pixels[0]) + assert len(row) == 2 + assert row[0] == 0x01 + assert row[1] == 0x80 + + +def test_pack_row_rejects_a_short_row(): + with pytest.raises(ValueError): + clip_from_video.pack_row([0] * 15)