From 479d822acaa2ca10d640875ffb7e7969250e2f61 Mon Sep 17 00:00:00 2001 From: SDD Date: Sun, 18 Oct 2026 16:26:37 +0300 Subject: [PATCH] dwt/screenshot: real geometry is 1280x1024 with a 256-byte stride (160B image + 96B mask); --real captures the full screen --- dwt/evidence/real-unit-1280-1to1.png | Bin 0 -> 7704 bytes dwt/notes/REAL_UNIT.md | 23 ++++ dwt/screenshot.py | 154 +++++++++++---------------- 3 files changed, 88 insertions(+), 89 deletions(-) create mode 100644 dwt/evidence/real-unit-1280-1to1.png diff --git a/dwt/evidence/real-unit-1280-1to1.png b/dwt/evidence/real-unit-1280-1to1.png new file mode 100644 index 0000000000000000000000000000000000000000..da1cc2f6c567410bc2ba1f5f27e049707f31ed04 GIT binary patch literal 7704 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 literal 0 HcmV?d00001 diff --git a/dwt/notes/REAL_UNIT.md b/dwt/notes/REAL_UNIT.md index b05d258..8140970 100644 --- a/dwt/notes/REAL_UNIT.md +++ b/dwt/notes/REAL_UNIT.md @@ -132,3 +132,26 @@ bottom — the whole screen. framebuffer; it is **not** 1:1 and the nearest-neighbour version produces the uneven-pixel artefacts. Use it only for an approximate visual match, not as evidence. + +### Final geometry: 1280x1024, 256-byte row stride + +The real unit is **1280x1024** with a **256-byte row stride**. Each row's first +**160 bytes (1280 px)** are the image; the remaining **96 bytes** are a `0x55` +mask. The earlier "1024x2048, even rows" reading was an artifact of treating +that 96-byte gap as an alternate row. + +This is why the 1024-wide reads lost the right quarter: the Icon Box really is +1280 px wide and its resize buttons are at the far right, outside a 1024-px +crop. The mask bytes are what looked like "line doubling". + +Correct 1:1 capture: + +```sh +python3 dwt/screenshot.py --host 100.103.100.103 --real --out screen.png +``` + +`--real` = width 1280, stride 256, 1024 rows, no resampling. +`dwt/evidence/real-unit-1280-1to1.png` is the full 1280x1024 screen, including +the Icon Box resize buttons and the `Ethernet Port Error - 24` box. + +The emulator geometry is unchanged: 1024x1024 with a 128-byte stride. diff --git a/dwt/screenshot.py b/dwt/screenshot.py index 04ae6f7..547d314 100644 --- a/dwt/screenshot.py +++ b/dwt/screenshot.py @@ -4,32 +4,27 @@ 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): +Two geometries exist: - * 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) +* **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 raw 1024x1024 OS canvas + # 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 - # 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 + # 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 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. + python3 dwt/screenshot.py --host HOST --real --rows 0:256 --raw vram.bin """ from __future__ import annotations @@ -42,16 +37,14 @@ import zlib import exploit VRAM_BASE_BITS = 0x04000000 -PITCH_BITS = 1024 -ROW_BYTES = PITCH_BITS // 8 # 128 -WIDTH = 1024 -HEIGHT = 1024 # image lines -MAX_ROWS = 2048 # real unit: image on even rows of 2048 -CHUNK = exploit.MAX_XFER # 1024 bytes = 8 rows -ROWS_PER_CHUNK = CHUNK // ROW_BYTES # 8 +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 -# 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. +# Emulator screen composition (MAME's derived 1280x901 raster). SCREEN_W = 1280 SCREEN_H = 901 OFFSET_X = 192 @@ -69,45 +62,27 @@ def parse_rows(text: str) -> tuple[int, int]: start, end = int(a, 0), int(b, 0) else: start, end = 0, int(text, 0) - if not (0 <= start < end <= MAX_ROWS): + if not (0 <= start < end <= HEIGHT): raise argparse.ArgumentTypeError( - f"rows must satisfy 0 <= start < end <= {MAX_ROWS}") + f"rows must satisfy 0 <= start < end <= {HEIGHT}") return start, end -def decode(data: bytes, width: int = WIDTH) -> bytearray: +def decode(data: bytes, width: int, stride: int) -> bytearray: """LSB-first bits -> 8-bit grayscale (bit 1 = white).""" - rows = len(data) // ROW_BYTES + rows = len(data) // stride out = bytearray(rows * width) for r in range(rows): - src = r * ROW_BYTES + src = r * stride 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 field_select(gray: bytearray, w: int, h: int, parity: str) -> bytearray: - """De-interleave: keep one row parity, at 1:1 (no doubling). - - The real unit's framebuffer is 1024x2048: the 1024 image lines are on even - rows and the odd rows carry a 0x55 mask. The display scans the even rows. - Reading all 2048 rows and keeping the even ones gives the 1024x1024 image - exactly as the hardware shows it. - """ - off = 0 if parity == "even" else 1 - oh = h // 2 - out = bytearray(w * oh) - for y in range(oh): - sy = y * 2 + off - out[y * w:(y + 1) * w] = gray[sy * w:(sy + 1) * w] + 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: - """Nearest-neighbour scale; used for the real unit's 1024->1280 display.""" out = bytearray(dw * dh) for y in range(dh): sy = y * sh // dh @@ -118,24 +93,24 @@ def scale_nearest(gray: bytearray, sw: int, sh: int, return out -def compose_screen(gray: bytearray, rows: int, sw: int, sh: int, +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 + WIDTH) + xe = min(sw, ox + src_w) if xe > xs: canvas[y * sw + xs:y * sw + xe] = \ - gray[r * WIDTH + (xs - ox):r * WIDTH + (xe - ox)] + 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) # filter: none + raw.append(0) raw += gray[y * width:(y + 1) * width] comp = zlib.compress(bytes(raw), 9) @@ -152,9 +127,9 @@ def write_png(path: str, width: int, height: int, gray: bytes) -> None: def grab(rd: exploit.RDebug, row_start: int, row_end: int, - quiet: bool) -> bytes: - start = row_start * ROW_BYTES - end = row_end * ROW_BYTES + stride: int, quiet: bool) -> bytes: + start = row_start * stride + end = row_end * stride data = bytearray() pos = start nchunks = (end - start + CHUNK - 1) // CHUNK @@ -173,9 +148,9 @@ def grab(rd: exploit.RDebug, row_start: int, row_end: int, 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) + 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) @@ -183,23 +158,23 @@ def one(rd: exploit.RDebug, a: argparse.Namespace, tag: str) -> None: if a.repeat > 1: root, dot, ext = out.rpartition(".") out = f"{root}-{tag}.{ext}" if dot else f"{out}-{tag}" - if a.field: - gray = field_select(gray, WIDTH, rows, a.field) - rows = rows // 2 + if a.stretch: dw, dh = a.stretch - scaled = scale_nearest(gray, WIDTH, rows, dw, dh) + scaled = scale_nearest(gray, a.width, rows, dw, dh) write_png(out, dw, dh, scaled) - print(f"[+] {out}: {dw}x{dh} (nearest-neighbour from {WIDTH}x{rows})") + print(f"[+] {out}: {dw}x{dh} (nearest-neighbour from " + f"{a.width}x{rows})") elif a.screen: - canvas = compose_screen(gray, rows, a.screen_width, a.screen_height, - a.offset_x, a.offset_y) + 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 {WIDTH}x{HEIGHT}, rows {a.rows[0]}..{a.rows[1]-1})") + f"(canvas {a.width}x{rows}, 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})") + 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 @@ -220,20 +195,19 @@ def main(argv: list[str]) -> int: 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("--stretch", type=parse_size, metavar="WxH", default=None, - help="nearest-neighbour scale the raw canvas to WxH " - "(the real unit displays 1024x1024 as 1280x1024)") - ap.add_argument("--field", choices=("even", "odd"), default=None, - help="de-interleave: keep one row parity at 1:1; the real " - "unit's 1024 image lines are on even rows of a 2048-row " - "framebuffer, odd rows are a 0x55 mask") + 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="real-unit preset: --rows 0:2048 --field even, 1:1 " - "(add --stretch 1280x1024 only if you want the 5:4 " - "display scaling)") + 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) @@ -246,14 +220,16 @@ def main(argv: list[str]) -> int: ap.add_argument("--quiet", action="store_true") ap.add_argument("--verbose", action="store_true") a = ap.parse_args(argv[1:]) - if a.real: - a.rows = (0, MAX_ROWS) - a.field = "even" + + 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) # cheap reachability check + rd.liveness(7) for i in range(max(1, a.repeat)): if i: time.sleep(a.interval)