feat(badapple): the demo as a tool -- rdebug delivery, clip builder, one command

A new folder so the demo can be reproduced instead of re-derived: the delivery
and the clip packing were living in throwaway scripts under /tmp, and the only
written route to a real clip was an ffmpeg recipe that packs MSB first -- the
order that mirrors every eight pixels on a display.

  harness/badapple/rdebug_load.py    image -> terminal RAM over the firmware's
                                     own remote-debug service (TCP 771), then
                                     jump to the entry from vidstream.env
  harness/badapple/clip_from_video.py video -> LSB-first 1-bpp frames for
                                     make_tiles.py --clip
  harness/badapple/demo.sh           ICMP gate, load, send; one command
  harness/badapple/README.md         setup, run in MAME, run on real hardware,
                                     what success looks like, failure modes

The loader records the two protocol facts that cost time to find: a 512-byte
poke chunk (1024 is mis-parsed as a following opcode: "Invalid command
0xA5A5A5A5"), and that nothing can be probed after the jump, so the image is
read back BEFORE jumping and the run is observed from the machine afterwards.

Tests cover what a wrong assumption would silently corrupt -- chunk arithmetic,
the linkage the entry point is read from, and pixel 0 landing on bit 0 -- and
the tool was exercised end to end against MAME: demo.sh loaded the payload and
the terminal reported DICT_BYTES=32768, DICT_BAD=0, RX_OK=28, TILES_DROP=0
while the sender saw frames_rx=129, drops=0.

153 tests pass (7 new).
This commit is contained in:
arti committed 2026-10-31 20:20:35 +02:00
1 parent f7490d2e09
commit eabc3051be
6 files changed
+617

No files matched your search

+147
View File
@@ -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-<uid>-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 <unit-ip> --iface <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 |
+10
View File
@@ -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.
"""
+124
View File
@@ -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())
+68
View File
@@ -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
+192
View File
@@ -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())
@@ -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)