aboutsummaryrefslogtreecommitdiffstats
path: root/tools/kitty_decode.py
diff options
context:
space:
mode:
authorgbc dev <gbc@localhost>2026-09-22 14:55:45 +0200
committergbc dev <gbc@localhost>2026-09-22 14:55:45 +0200
commit44c21908b4ff6a1e41a6b9ba17e3ed78de8ec3db (patch)
tree1278a79c6b16bff4b64a0798c91045d7ae72abff /tools/kitty_decode.py
parentmain: keep signal handlers installed (sigaction, not signal) (diff)
downloadsl0pboy-44c21908b4ff6a1e41a6b9ba17e3ed78de8ec3db.tar.gz
sl0pboy-44c21908b4ff6a1e41a6b9ba17e3ed78de8ec3db.tar.xz
sl0pboy-44c21908b4ff6a1e41a6b9ba17e3ed78de8ec3db.zip
kitty: truecolor graphics output with a layered HUD
Sixel is a 1987 printer format wearing a terminal costume: 256 colors per image, no alpha, no compositing, one flat picture. --kitty [scale] takes the other road on kitty/ghostty/WezTerm/Konsole: - the framebuffer ships as raw RGB888, so no per-frame palette build and no coarser quantization when a CGB game blows past 256 colors - the HUD (title, render rate, frameskip, turbo, live button readout) is a *separate* alpha-blended image on z-index 1, composited over the game by the terminal instead of being burned into the picture; 'h' toggles it, --no-hud starts without it - consecutive identical images are dropped, so a pause menu or text box stops the traffic dead - pixels go over at 1x and the terminal scales them into a cell box, so --kitty 6 costs the same bytes as --kitty 1 (padded to whole cells first, so the zoom stays exact) Two transfers: shm hands over the name of a POSIX shared-memory object (~60 bytes/frame) when we can tell the terminal is local, b64 inlines the bytes for ssh and tmux (which might be attached from another machine). --kitty-shm / --kitty-b64 force either. tmux gets DCS passthrough wrapping. --chrome and --palette work here just like in sixel mode. Nothing about a graphics stream is human-readable, so tools/kitty_decode.py reassembles a capture back into images - chunked payloads, tmux wrapping and all - and can assert a frame is pixel-identical to a --shot: tools/kitty_decode.py cap.bin -o frames --check shot.ppm -> OK: image 1 matches shot.ppm exactly (160x144)
Diffstat (limited to '')
-rwxr-xr-xtools/kitty_decode.py174
1 files changed, 174 insertions, 0 deletions
diff --git a/tools/kitty_decode.py b/tools/kitty_decode.py
new file mode 100755
index 0000000..263d57e
--- /dev/null
+++ b/tools/kitty_decode.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""Decode a captured kitty-graphics stream back into images.
+
+We can't eyeball the emulator's output on a terminal that doesn't speak the
+protocol, so this reads a raw capture of stdout, reassembles every
+transmit-and-place command (including chunked payloads and tmux passthrough
+wrapping) and writes each image out as a PPM/PAM, reporting the control keys.
+
+ ./build/sl0pboy --kitty 3 --kitty-b64 rom.gb > cap.bin
+ tools/kitty_decode.py cap.bin -o /tmp/frames
+
+Use --check FILE.ppm to assert an image matches a --shot capture pixel for pixel.
+"""
+import argparse
+import base64
+import os
+import re
+import sys
+
+APC = re.compile(rb"\x1b_G(.*?)\x1b\\", re.S)
+TMUX = re.compile(rb"\x1bPtmux;(.*?)\x1b\\\Z", re.S)
+
+
+def unwrap_tmux(data: bytes) -> bytes:
+ """Undo tmux passthrough framing: DCS tmux; <ESC-doubled payload> ST."""
+ out = bytearray()
+ i = 0
+ while i < len(data):
+ if data.startswith(b"\x1bPtmux;", i):
+ j = i + 7
+ body = bytearray()
+ while j < len(data):
+ if data[j] == 0x1B:
+ if j + 1 < len(data) and data[j + 1] == 0x1B:
+ body.append(0x1B) # doubled ESC -> literal ESC
+ j += 2
+ continue
+ if j + 1 < len(data) and data[j + 1] == ord("\\"):
+ j += 2 # ST: end of passthrough
+ break
+ body.append(data[j])
+ j += 1
+ out += body
+ i = j
+ else:
+ out.append(data[i])
+ i += 1
+ return bytes(out)
+
+
+def parse_ctrl(ctrl: bytes):
+ keys = {}
+ for part in ctrl.split(b","):
+ if b"=" in part:
+ k, v = part.split(b"=", 1)
+ keys[k.decode()] = v.decode()
+ return keys
+
+
+def images(data: bytes):
+ """Yield (control_keys, payload_bytes) for each complete transmission."""
+ cur = None
+ for m in APC.finditer(data):
+ body = m.group(1)
+ ctrl, _, payload = body.partition(b";")
+ keys = parse_ctrl(ctrl)
+ if cur is None:
+ cur = (keys, bytearray())
+ cur[1].extend(payload)
+ if keys.get("m", "0") == "0":
+ try:
+ yield cur[0], base64.b64decode(bytes(cur[1]))
+ except Exception as e: # truncated capture (killed mid-frame)
+ print(f"skipping undecodable payload: {e}", file=sys.stderr)
+ cur = None
+
+
+def write_image(path, keys, raw):
+ w, h = int(keys["s"]), int(keys["v"])
+ fmt = int(keys.get("f", "24"))
+ bpp = 4 if fmt == 32 else 3
+ want = w * h * bpp
+ if len(raw) != want:
+ raise SystemExit(f"{path}: payload {len(raw)} bytes, expected {want}")
+ if bpp == 3:
+ with open(path, "wb") as f:
+ f.write(b"P6\n%d %d\n255\n" % (w, h))
+ f.write(raw)
+ else: # RGBA -> PAM keeps the alpha
+ with open(path, "wb") as f:
+ f.write(b"P7\nWIDTH %d\nHEIGHT %d\nDEPTH 4\nMAXVAL 255\n"
+ b"TUPLTYPE RGB_ALPHA\nENDHDR\n" % (w, h))
+ f.write(raw)
+ return w, h, bpp
+
+
+def read_ppm(path):
+ with open(path, "rb") as f:
+ blob = f.read()
+ if not blob.startswith(b"P6"):
+ raise SystemExit(f"{path}: not a P6 PPM")
+ fields, pos = [], 2
+ while len(fields) < 3:
+ while pos < len(blob) and blob[pos : pos + 1].isspace():
+ pos += 1
+ if blob[pos : pos + 1] == b"#":
+ while blob[pos] != 0x0A:
+ pos += 1
+ continue
+ start = pos
+ while not blob[pos : pos + 1].isspace():
+ pos += 1
+ fields.append(int(blob[start:pos]))
+ return fields[0], fields[1], blob[pos + 1 :]
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("capture")
+ ap.add_argument("-o", "--outdir", default=None)
+ ap.add_argument("--check", help="PPM the first RGB image must match exactly")
+ ap.add_argument("--quiet", action="store_true")
+ args = ap.parse_args()
+
+ data = unwrap_tmux(open(args.capture, "rb").read())
+ if args.outdir:
+ os.makedirs(args.outdir, exist_ok=True)
+
+ n = 0
+ checked = False
+ for keys, raw in images(data):
+ n += 1
+ if keys.get("a") == "d": # delete/cleanup, no pixels
+ if not args.quiet:
+ print(f"[{n}] delete i={keys.get('i')} d={keys.get('d')}")
+ continue
+ if keys.get("t") == "s": # payload is the shm object name
+ if not args.quiet:
+ print(f"[{n}] shm transfer, object={raw.decode()!r} "
+ f"{keys.get('s')}x{keys.get('v')} i={keys.get('i')}")
+ continue
+ ext = "pam" if keys.get("f") == "32" else "ppm"
+ path = os.path.join(args.outdir or ".", f"img{n:04d}.{ext}")
+ if args.outdir:
+ w, h, bpp = write_image(path, keys, raw)
+ else:
+ w, h = int(keys["s"]), int(keys["v"])
+ bpp = 4 if keys.get("f") == "32" else 3
+ if not args.quiet:
+ print(f"[{n}] {w}x{h} bpp={bpp} i={keys.get('i')} p={keys.get('p')} "
+ f"z={keys.get('z','0')} c={keys.get('c','-')} "
+ f"r={keys.get('r','-')} q={keys.get('q')} "
+ f"C={keys.get('C')} bytes={len(raw)}"
+ + (f" -> {path}" if args.outdir else ""))
+ if args.check and not checked and bpp == 3:
+ cw, ch, pix = read_ppm(args.check)
+ if (cw, ch) != (w, h):
+ raise SystemExit(f"size mismatch: kitty {w}x{h} vs "
+ f"{args.check} {cw}x{ch}")
+ if pix[: cw * ch * 3] != raw:
+ bad = sum(a != b for a, b in zip(pix, raw))
+ raise SystemExit(f"pixel mismatch: {bad} bytes differ")
+ print(f"OK: image {n} matches {args.check} exactly ({w}x{h})")
+ checked = True
+
+ if not n:
+ raise SystemExit("no kitty graphics commands found in capture")
+ if args.check and not checked:
+ raise SystemExit("no RGB image to check")
+ print(f"{n} graphics command(s) decoded")
+
+
+if __name__ == "__main__":
+ main()