#!/usr/bin/env python3 """Capture a screenshot of hsdbg without touching the running app. Runs the binary with the dev hooks the app reads from the environment, lets it draw for a number of frames, and converts the framebuffer dump it writes into a png. Useful for eyeballing ui changes from a shell (or an agent) that cannot grab the screen. tools/capture.py out.png [--build build] [--target test/hello] [--break hello.cpp:13] [--step 150,200] [--open palette] [--frame 240] [--window 1600x900] [--cwd dir] Hooks (all read once at startup): HSDBG_CAPTURE= dump the frame HSDBG_CAPTURE_FRAME (default 240) and quit HSDBG_WINDOW=x initial window size HSDBG_BREAK=: plant a breakpoint and launch the target right away HSDBG_STEP_AT=[,...] step over a line on those frames while stopped HSDBG_OPEN=palette|target|preferences[:tab] raise that overlay on the second frame --cwd picks where imgui.ini / hsdbg.ini are read and written (default: a fresh directory beside the output, so your own layout and preferences stay untouched). """ import argparse import os import pathlib import struct import subprocess import sys import zlib def ppm_to_png(src: pathlib.Path, dst: pathlib.Path) -> tuple[int, int]: data = src.read_bytes() magic, size, _depth, raw = data.split(b"\n", 3) assert magic == b"P6", "expected a binary ppm" width, height = map(int, size.split()) stride = width * 3 rows = b"".join(b"\x00" + raw[y * stride:(y + 1) * stride] for y in range(height)) def chunk(kind: bytes, body: bytes) -> bytes: payload = kind + body return struct.pack(">I", len(body)) + payload + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF) png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows, 6)) + chunk(b"IEND", b"")) dst.write_bytes(png) return width, height def main() -> int: root = pathlib.Path(__file__).resolve().parent.parent parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("output", type=pathlib.Path, help="png to write") parser.add_argument("--build", default="build", help="build directory holding the hsdbg binary") parser.add_argument("--target", help="executable to load") parser.add_argument("--break", dest="breakpoint", metavar="FILE:LINE", help="breakpoint to plant, then launch") parser.add_argument("--step", metavar="FRAMES", help="comma-separated frames on which to step over") parser.add_argument("--open", metavar="OVERLAY", help="overlay to raise: palette, target, or preferences[:tab]") parser.add_argument("--frame", type=int, default=240, help="frame to capture (default 240)") parser.add_argument("--window", metavar="WxH", help="window size") parser.add_argument("--cwd", type=pathlib.Path, help="directory to run in (imgui.ini / hsdbg.ini live there)") args = parser.parse_args() binary = root / args.build / "hsdbg" if not binary.exists(): print(f"no binary at {binary}; build first", file=sys.stderr) return 1 output = args.output.resolve() cwd = (args.cwd or output.parent / f".{output.stem}").resolve() cwd.mkdir(parents=True, exist_ok=True) ppm = cwd / "capture.ppm" env = dict(os.environ, HSDBG_CAPTURE=str(ppm), HSDBG_CAPTURE_FRAME=str(args.frame)) if args.breakpoint: env["HSDBG_BREAK"] = args.breakpoint if args.step: env["HSDBG_STEP_AT"] = args.step if args.open: env["HSDBG_OPEN"] = args.open if args.window: env["HSDBG_WINDOW"] = args.window command = [str(binary)] if args.target: command.append(str(pathlib.Path(args.target).resolve())) log = cwd / "capture.log" with log.open("w") as sink: result = subprocess.run(command, cwd=cwd, env=env, stdout=sink, stderr=subprocess.STDOUT) if not ppm.exists(): print(f"no capture written (exit {result.returncode}); see {log}", file=sys.stderr) return 1 width, height = ppm_to_png(ppm, output) ppm.unlink() print(f"{output} ({width}x{height})") return 0 if __name__ == "__main__": sys.exit(main())