mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-11-01 12:14:56 +02:00
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:
1 parent
f7490d2e09
commit
eabc3051be
6 files changed
+617
No files matched your search
@@ -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 |
|
||||
@@ -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.
|
||||
"""
|
||||
Executable
+124
@@ -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())
|
||||
Executable
+68
@@ -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
|
||||
Executable
+192
@@ -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)
|
||||
Reference in new issue
Block a user