mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
251 lines
8.7 KiB
Python
251 lines
8.7 KiB
Python
#!/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)
|