aboutsummaryrefslogtreecommitdiff
path: root/tools/capture.py
blob: f30e9cd79b5de5fac04e61315d7ee597b99d8e27 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#!/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=<file.ppm>     dump the frame HSDBG_CAPTURE_FRAME (default 240) and quit
    HSDBG_WINDOW=<w>x<h>         initial window size
    HSDBG_BREAK=<file>:<line>    plant a breakpoint and launch the target right away
    HSDBG_STEP_AT=<f>[,<f>...]   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())