diff --git a/dwt/evidence/shot-canvas.png b/dwt/evidence/shot-canvas.png new file mode 100644 index 0000000..20b2312 Binary files /dev/null and b/dwt/evidence/shot-canvas.png differ diff --git a/dwt/evidence/shot-screen.png b/dwt/evidence/shot-screen.png new file mode 100644 index 0000000..664b02e Binary files /dev/null and b/dwt/evidence/shot-screen.png differ diff --git a/dwt/screenshot.py b/dwt/screenshot.py new file mode 100644 index 0000000..17b6187 --- /dev/null +++ b/dwt/screenshot.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Network screenshot of a running VT1200, using rdebug `peek` over TCP 771. + +No debugger, no ROM patch, no agent on the terminal: it reads the framebuffer +out of video RAM one `peek` at a time and writes a PNG. + +The memory layout is the one the harness renderer uses +(`render_active_line()` in harness/mame/vt1200.cpp): + + * base bit 0x04000000 (video RAM) + * pitch 1024 bits = 128 bytes per row + * bit order LSB-first within each byte/word + * active 1024 x 1024 pixels; on the emulated screen it appears at + x 192..1215, y 36..900 (MAME-derived blanking; see --screen) + +Examples: + + # the raw 1024x1024 OS canvas + python3 dwt/screenshot.py --host 192.168.99.2 --out shot.png + + # compose the emulator's visible 1280x901 screen, for comparison with MAME dumps + python3 dwt/screenshot.py --host 192.168.99.2 --screen --out screen.png + + # only the top of the canvas, and keep the raw framebuffer too + python3 dwt/screenshot.py --host 192.168.99.2 --rows 0:256 \\ + --out top.png --raw vram.bin + + # a second grab 1 s later (tearing check) + python3 dwt/screenshot.py --host 192.168.99.2 --repeat 2 --interval 1.0 + +The peek limit is 1024 bytes, i.e. exactly 8 rows per request, so a full +1024-row grab is 128 round trips. +""" +from __future__ import annotations + +import argparse +import struct +import sys +import time +import zlib + +import exploit + +VRAM_BASE_BITS = 0x04000000 +PITCH_BITS = 1024 +ROW_BYTES = PITCH_BITS // 8 # 128 +WIDTH = 1024 +HEIGHT = 1024 +CHUNK = exploit.MAX_XFER # 1024 bytes = 8 rows +ROWS_PER_CHUNK = CHUNK // ROW_BYTES # 8 + +# Defaults for the emulated screen; the real unit's blanking may differ, which +# is why the raw canvas is the primary artifact and --screen is opt-in. +SCREEN_W = 1280 +SCREEN_H = 901 +OFFSET_X = 192 +OFFSET_Y = 36 + + +def parse_rows(text: str) -> tuple[int, int]: + if ":" in text: + a, b = text.split(":", 1) + start, end = int(a, 0), int(b, 0) + else: + start, end = 0, int(text, 0) + if not (0 <= start < end <= HEIGHT): + raise argparse.ArgumentTypeError( + f"rows must satisfy 0 <= start < end <= {HEIGHT}") + return start, end + + +def decode(data: bytes, width: int = WIDTH) -> bytearray: + """LSB-first bits -> 8-bit grayscale (bit 1 = white).""" + rows = len(data) // ROW_BYTES + out = bytearray(rows * width) + for r in range(rows): + src = r * ROW_BYTES + dst = r * width + for x in range(width): + v = (data[src + (x >> 3)] >> (x & 7)) & 1 + out[dst + x] = 255 if v else 0 + return out + + +def compose_screen(gray: bytearray, rows: int, sw: int, sh: int, + ox: int, oy: int) -> bytearray: + canvas = bytearray(sw * sh) + for r in range(rows): + y = r + oy + if 0 <= y < sh: + xs = max(0, ox) + xe = min(sw, ox + WIDTH) + if xe > xs: + canvas[y * sw + xs:y * sw + xe] = \ + gray[r * WIDTH + (xs - ox):r * WIDTH + (xe - ox)] + return canvas + + +def write_png(path: str, width: int, height: int, gray: bytes) -> None: + raw = bytearray() + for y in range(height): + raw.append(0) # filter: none + raw += gray[y * width:(y + 1) * width] + comp = zlib.compress(bytes(raw), 9) + + def chunk(tag: bytes, data: bytes) -> bytes: + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + png = b"\x89PNG\r\n\x1a\n" + png += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)) + png += chunk(b"IDAT", comp) + png += chunk(b"IEND", b"") + with open(path, "wb") as f: + f.write(png) + + +def grab(rd: exploit.RDebug, row_start: int, row_end: int, + quiet: bool) -> bytes: + start = row_start * ROW_BYTES + end = row_end * ROW_BYTES + data = bytearray() + pos = start + nchunks = (end - start + CHUNK - 1) // CHUNK + done = 0 + while pos < end: + n = min(CHUNK, end - pos) + data += rd.peek(VRAM_BASE_BITS + pos * 8, n) + pos += n + done += 1 + if not quiet and nchunks > 8 and (done % 16 == 0 or done == nchunks): + print(f"\r[.] {done}/{nchunks} chunks", end="", file=sys.stderr, + flush=True) + if not quiet and nchunks > 8: + print(file=sys.stderr) + return bytes(data) + + +def one(rd: exploit.RDebug, a: argparse.Namespace, tag: str) -> None: + data = grab(rd, a.rows[0], a.rows[1], a.quiet) + rows = len(data) // ROW_BYTES + gray = decode(data) + if a.invert: + gray = bytearray(255 - v for v in gray) + + out = a.out + if a.repeat > 1: + root, dot, ext = out.rpartition(".") + out = f"{root}-{tag}.{ext}" if dot else f"{out}-{tag}" + if a.screen: + canvas = compose_screen(gray, rows, a.screen_width, a.screen_height, + a.offset_x, a.offset_y) + write_png(out, a.screen_width, a.screen_height, canvas) + print(f"[+] {out}: {a.screen_width}x{a.screen_height} " + f"(canvas {WIDTH}x{HEIGHT}, rows {a.rows[0]}..{a.rows[1]-1})") + else: + write_png(out, WIDTH, rows, gray) + print(f"[+] {out}: {WIDTH}x{rows} (rows {a.rows[0]}..{a.rows[1]-1})") + + if a.raw: + rp = a.raw + if a.repeat > 1: + root, dot, ext = rp.rpartition(".") + rp = f"{root}-{tag}.{ext}" if dot else f"{rp}-{tag}" + with open(rp, "wb") as f: + f.write(data) + print(f"[+] {rp}: {len(data)} bytes of VRAM") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--host", default="192.168.99.2") + ap.add_argument("--port", type=int, default=771) + ap.add_argument("--timeout", type=float, default=8.0) + ap.add_argument("--out", default="shot.png") + ap.add_argument("--rows", type=parse_rows, default=(0, HEIGHT), + metavar="A:B", help=f"row range to read (default 0:{HEIGHT})") + ap.add_argument("--screen", action="store_true", + help="compose the visible screen instead of the raw canvas") + ap.add_argument("--screen-width", type=int, default=SCREEN_W) + ap.add_argument("--screen-height", type=int, default=SCREEN_H) + ap.add_argument("--offset-x", type=int, default=OFFSET_X) + ap.add_argument("--offset-y", type=int, default=OFFSET_Y) + ap.add_argument("--invert", action="store_true", help="white <-> black") + ap.add_argument("--raw", metavar="FILE", help="also write the raw VRAM bytes") + ap.add_argument("--repeat", type=int, default=1, help="grab N times") + ap.add_argument("--interval", type=float, default=0.0, + help="seconds between repeats") + ap.add_argument("--quiet", action="store_true") + ap.add_argument("--verbose", action="store_true") + a = ap.parse_args(argv[1:]) + + rd = exploit.RDebug(a.host, a.port, a.timeout, a.verbose) + rd.connect() + try: + rd.liveness(7) # cheap reachability check + for i in range(max(1, a.repeat)): + if i: + time.sleep(a.interval) + one(rd, a, time.strftime("%H%M%S") if a.repeat > 1 else "1") + finally: + rd.close() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv)) + except exploit.ProtocolError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + except OSError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(2)