#!/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. Two geometries exist: * **Emulator (MAME)** — 1024x1024, 128-byte row stride. * **Real VT1200** — 1280x1024, **256-byte row stride**. Each row holds the 1280 image pixels in its first **160 bytes**; the remaining 96 bytes are a 0x55 mask. This is why a plain 1024-wide / 128-stride read loses the right 256 pixels (the Icon Box resize buttons) and shows mask rows as stripes. Examples: # the real unit, 1:1 (1280x1024) -- this is the one to use there python3 dwt/screenshot.py --host 100.103.100.103 --real --out shot.png # the emulator python3 dwt/screenshot.py --host 192.168.99.2 --out shot.png # explicit geometry, if it ever differs python3 dwt/screenshot.py --host HOST --width 1280 --stride 256 # only the top of the canvas, and keep the raw framebuffer too python3 dwt/screenshot.py --host HOST --real --rows 0:256 --raw vram.bin """ from __future__ import annotations import argparse import struct import sys import time import zlib import exploit VRAM_BASE_BITS = 0x04000000 HEIGHT = 1024 # image lines in both geometries WIDTH_DEFAULT = 1024 STRIDE_DEFAULT = 128 REAL_WIDTH = 1280 REAL_STRIDE = 256 CHUNK = exploit.MAX_XFER # 1024 bytes per peek # Emulator screen composition (MAME's derived 1280x901 raster). SCREEN_W = 1280 SCREEN_H = 901 OFFSET_X = 192 OFFSET_Y = 36 def parse_size(text: str) -> tuple[int, int]: w, h = text.lower().split("x", 1) return int(w, 0), int(h, 0) 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, stride: int) -> bytearray: """LSB-first bits -> 8-bit grayscale (bit 1 = white).""" rows = len(data) // stride out = bytearray(rows * width) for r in range(rows): src = r * stride dst = r * width for x in range(width): b = data[src + (x >> 3)] out[dst + x] = 255 if ((b >> (x & 7)) & 1) else 0 return out def scale_nearest(gray: bytearray, sw: int, sh: int, dw: int, dh: int) -> bytearray: out = bytearray(dw * dh) for y in range(dh): sy = y * sh // dh src = sy * sw dst = y * dw for x in range(dw): out[dst + x] = gray[src + x * sw // dw] return out def compose_screen(gray: bytearray, rows: int, src_w: 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 + src_w) if xe > xs: canvas[y * sw + xs:y * sw + xe] = \ gray[r * src_w + (xs - ox):r * src_w + (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) 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, stride: int, quiet: bool) -> bytes: start = row_start * stride end = row_end * stride 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.stride, a.quiet) rows = len(data) // a.stride gray = decode(data, a.width, a.stride) 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.stretch: dw, dh = a.stretch scaled = scale_nearest(gray, a.width, rows, dw, dh) write_png(out, dw, dh, scaled) print(f"[+] {out}: {dw}x{dh} (nearest-neighbour from " f"{a.width}x{rows})") elif a.screen: canvas = compose_screen(gray, rows, a.width, 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 {a.width}x{rows}, rows {a.rows[0]}..{a.rows[1]-1})") else: write_png(out, a.width, rows, gray) print(f"[+] {out}: {a.width}x{rows} " f"(stride {a.stride}, 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"image row range (default 0:{HEIGHT})") ap.add_argument("--width", type=int, default=None, help=f"pixels per row (default {WIDTH_DEFAULT}; real {REAL_WIDTH})") ap.add_argument("--stride", type=int, default=None, help=f"bytes per framebuffer row (default {STRIDE_DEFAULT}; " f"real {REAL_STRIDE})") ap.add_argument("--real", action="store_true", help=f"real-unit preset: --width {REAL_WIDTH} " f"--stride {REAL_STRIDE}, 1280x1024 at 1:1") ap.add_argument("--stretch", type=parse_size, metavar="WxH", default=None, help="nearest-neighbour scale the result to WxH") ap.add_argument("--screen", action="store_true", help="compose MAME's 1280x901 emulator screen") 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:]) if a.width is None: a.width = REAL_WIDTH if a.real else WIDTH_DEFAULT if a.stride is None: a.stride = REAL_STRIDE if a.real else STRIDE_DEFAULT rd = exploit.RDebug(a.host, a.port, a.timeout, a.verbose) rd.connect() try: rd.liveness(7) 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)