aboutsummaryrefslogtreecommitdiffstats
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
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 '')
-rw-r--r--README.md74
-rw-r--r--src/kitty.c445
-rw-r--r--src/kitty.h37
-rw-r--r--src/main.c121
-rw-r--r--src/render.c264
-rw-r--r--src/render.h10
-rwxr-xr-xtools/kitty_decode.py174
7 files changed, 1029 insertions, 96 deletions
diff --git a/README.md b/README.md
index e0e5f80..db9c218 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@ terminal — no window, no GPU. Two ways to draw it:
bottom), squeezing the 160×144 screen into 160×72 cells. Works anywhere with
truecolor.
- **sixel** (`--sixel`): real pixels on terminals that speak it (see below).
+- **kitty graphics** (`--kitty`): real pixels *without* sixel's 256-color
+ palette, plus a translucent HUD layered over the game (see below).
Resize the terminal and `SIGWINCH` triggers a full repaint.
@@ -20,6 +22,7 @@ make
./build/sl0pboy --test 30 rom.gb # run 30s, serial -> stderr (test roms)
./build/sl0pboy --shot 60 out.ppm rom.gb # 60 frames in, dump a PPM
./build/sl0pboy --sixel 3 rom.gb # real pixels via sixel, 3x zoom
+./build/sl0pboy --kitty 3 rom.gb # ...or via the kitty protocol
./build/sl0pboy --sixel 3 --chrome rom.gb # ...in a lil' handheld body
./build/sl0pboy --bios rom.gb # boot through the real BIOS first
./build/sl0pboy --sock rom.gb # control/debug socket (/tmp/sl0pboy.sock)
@@ -115,15 +118,78 @@ DMG, a small set on CGB; quantized coarser only if a frame overflows) and emits
standard sixel bands with per-column RLE. `scale` is 1–6. Sixel frames go out in
full (no diffing) — pair with `--frameskip` on slow terminals.
+## Kitty graphics (`--kitty`)
+
+Sixel is a 1987 printer format wearing a terminal costume: 256 colors per image,
+no alpha, no compositing, one flat picture. On kitty, ghostty, WezTerm, Konsole
+and friends there's a better option:
+
+```sh
+./build/sl0pboy --kitty rom.gb # 2x zoom
+./build/sl0pboy --kitty 4 rom.gb # 4x zoom (1-8)
+./build/sl0pboy --kitty 3 --chrome rom.gb # the handheld body works here too
+```
+
+What it buys over `--sixel`:
+
+- **Truecolor, unquantized.** The framebuffer goes over as raw RGB888. No
+ per-frame palette build, no coarser quantization when a CGB game overflows
+ 256 colors — what the PPU drew is what you see, byte for byte.
+- **A HUD that floats above the game.** Cart title, live render rate, frameskip,
+ turbo, and a per-button readout that lights up as you play — drawn as a
+ *separate* alpha-blended image on z-index 1, so it's composited over the frame
+ by the terminal instead of being burned into the picture. `h` toggles it,
+ `--no-hud` starts without it.
+- **Idle frames cost nothing.** Each image is diffed against the last one and
+ dropped if identical, so a pause menu or a text box stops the traffic dead.
+- **Constant bandwidth at any zoom.** The pixels ship at 1x and the terminal
+ scales them into a cell box, so `--kitty 6` costs the same bytes as
+ `--kitty 1` (the image is padded to a whole number of cells first, so the zoom
+ stays exact instead of stretching by a few percent).
+
+### How the pixels get there
+
+| transfer | escape carries | when |
+| --- | --- | --- |
+| `shm` | the name of a POSIX shared-memory object | terminal is on this machine |
+| `b64` | the pixel bytes, base64, in 4KB chunks | anywhere (ssh, tmux) |
+
+Shared memory means a ~60-byte escape per frame instead of ~90KB, so it's the
+default when we can tell we're talking to a local kitty/ghostty; everything else
+(including anything inside tmux, which might be attached from another machine)
+uses inline base64. Force either with `--kitty-shm` / `--kitty-b64`. In shm mode
+the frame is upscaled here rather than by the terminal, so pixel edges stay
+hard. Objects are named `/sl0pboy-<pid>-<n>`; the terminal unlinks each one
+after reading it and we reclaim any it didn't a few frames later.
+
+Inside **tmux** every command is wrapped in a passthrough DCS (needs
+`set -g allow-passthrough on`). tmux doesn't track images, so it repaints over
+them — harmless here, since we redraw every frame anyway.
+
+`--kitty` and `--sixel` are mutually exclusive; the kitty one wins.
+
+### Poking at the wire format
+
+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`:
+
+```sh
+./build/sl0pboy --shot 1 /tmp/shot.ppm rom.gb
+./build/sl0pboy --kitty 1 --kitty-b64 rom.gb > /tmp/cap.bin # ^C after a bit
+tools/kitty_decode.py /tmp/cap.bin -o /tmp/frames --check /tmp/shot.ppm
+```
+
### Game Boy chrome
-`--chrome` (sixel only) wraps the LCD in a DMG-style **sl0pboy** handheld:
+`--chrome` (sixel/kitty) wraps the LCD in a DMG-style **sl0pboy** handheld:
bezel + accent stripe, power LED, silk-screened branding (`sl0pboy` logo,
`DOT MATRIX WITH STEREO SOUND`, button labels), D-pad, A/B, Start/Select,
speaker grille. Buttons light up while held, so it doubles as an input display:
```sh
./build/sl0pboy --sixel 3 --chrome rom.gb
+./build/sl0pboy --kitty 3 --chrome rom.gb
```
Flat colors → only a handful of extra palette entries. It composites onto a
@@ -143,8 +209,8 @@ ramp by luminance — this even greens out full-color CGB games:
```
Names: `dmg`/`green` (the iconic `#9bbc0f` pea-green ramp) and `pocket`/`gray`.
-Applies to every render path — half-block, sixel, chrome — and only touches the
-LCD, never the shell.
+Applies to every render path — half-block, sixel, kitty, chrome — and only
+touches the LCD, never the shell.
## Speed & frame skipping
@@ -177,6 +243,7 @@ clamps redraws to ~60 Hz). On exit it prints frames emulated vs. actually drawn.
| enter | Start |
| space | Select |
| `f` | toggle fast-forward (turbo) |
+| `h` | toggle the HUD overlay (`--kitty`) |
| `[` / `]` | frame skip down / up |
| `q` / Ctrl-C | Quit |
@@ -357,6 +424,7 @@ Run `./gbctl` bare for the full command list. Driven end-to-end by the
| `src/timer.c` | DIV/TIMA/TMA/TAC with falling-edge accuracy |
| `src/ppu.c` | LCD controller, scanline renderer (BG/window/sprites), CGB|
| `src/render.c` | terminal truecolor output + raw-mode keyboard/FIFO input |
+| `src/kitty.c` | kitty graphics protocol: shm/base64 transfer, tmux wrap |
| `src/control.c` | socket control/debug: memory, CPU, step, breakpoints |
| `src/main.c` | arg parsing, frame loop, timing, screenshot/test modes |
diff --git a/src/kitty.c b/src/kitty.c
new file mode 100644
index 0000000..b7fe89a
--- /dev/null
+++ b/src/kitty.c
@@ -0,0 +1,445 @@
+#include "kitty.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <signal.h>
+#include <dirent.h>
+
+// Image ids are a global namespace shared with every other program drawing in
+// the same terminal, so pick something unlikely rather than 1 and 2.
+#define ID_FRAME 0x51B0B01u
+#define ID_OVERLAY 0x51B0B02u
+// Placement ids: reusing one replaces the previous placement in-place, which is
+// how we get flicker-free frame updates without deleting anything first.
+#define PLACEMENT 1
+// The protocol caps a single escape's payload at 4096 base64 bytes.
+#define CHUNK 4096
+
+static int zoom = 2;
+static int xfer = KITTY_XFER_B64;
+static bool in_tmux = false;
+static bool started = false;
+static int cell_w = 0, cell_h = 0; // 0 = terminal didn't tell us
+
+// ---- output buffer ---------------------------------------------------------
+// A frame is one write(): partial graphics escapes on the wire look like line
+// noise if anything else (a signal handler, a stray printf) interleaves.
+static char *ob;
+static size_t ob_len, ob_cap;
+
+static void ob_need(size_t n) {
+ if (ob_len + n <= ob_cap) return;
+ size_t cap = ob_cap ? ob_cap : 65536;
+ while (cap < ob_len + n) cap *= 2;
+ char *p = realloc(ob, cap);
+ if (!p) return;
+ ob = p;
+ ob_cap = cap;
+}
+static void ob_putn(const char *s, size_t n) {
+ ob_need(n);
+ if (ob_len + n > ob_cap) return;
+ memcpy(ob + ob_len, s, n);
+ ob_len += n;
+}
+static void ob_puts(const char *s) { ob_putn(s, strlen(s)); }
+
+// One APC graphics command: ESC _ G <control> ; <payload> ST.
+// Inside tmux the whole thing is wrapped in a passthrough DCS with every ESC
+// doubled, otherwise tmux eats it (needs `set -g allow-passthrough on`).
+static void apc(const char *ctrl, const char *payload, size_t plen) {
+ ob_puts(in_tmux ? "\x1bPtmux;\x1b\x1b_G" : "\x1b_G");
+ ob_puts(ctrl);
+ if (plen) {
+ ob_puts(";");
+ ob_putn(payload, plen);
+ }
+ ob_puts(in_tmux ? "\x1b\x1b\\\x1b\\" : "\x1b\\");
+}
+
+static void ob_flush(void) {
+ if (!ob_len) return;
+ fwrite(ob, 1, ob_len, stdout);
+ fflush(stdout);
+ ob_len = 0;
+}
+
+// ---- base64 ----------------------------------------------------------------
+static const char b64tab[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+static size_t b64_len(size_t n) { return ((n + 2) / 3) * 4; }
+
+static size_t b64_encode(const u8 *in, size_t n, char *out) {
+ size_t o = 0;
+ size_t i = 0;
+ for (; i + 3 <= n; i += 3) {
+ u32 v = ((u32)in[i] << 16) | ((u32)in[i + 1] << 8) | in[i + 2];
+ out[o++] = b64tab[(v >> 18) & 63];
+ out[o++] = b64tab[(v >> 12) & 63];
+ out[o++] = b64tab[(v >> 6) & 63];
+ out[o++] = b64tab[v & 63];
+ }
+ if (i < n) {
+ u32 v = (u32)in[i] << 16;
+ if (i + 1 < n) v |= (u32)in[i + 1] << 8;
+ out[o++] = b64tab[(v >> 18) & 63];
+ out[o++] = b64tab[(v >> 12) & 63];
+ out[o++] = (i + 1 < n) ? b64tab[(v >> 6) & 63] : '=';
+ out[o++] = '=';
+ }
+ return o;
+}
+
+static char *b64buf;
+static size_t b64cap;
+static char *b64_get(size_t need) {
+ if (need > b64cap) {
+ char *p = realloc(b64buf, need);
+ if (!p) return NULL;
+ b64buf = p;
+ b64cap = need;
+ }
+ return b64buf;
+}
+
+// ---- terminal metrics ------------------------------------------------------
+void kitty_refresh_metrics(void) {
+ struct winsize ws;
+ cell_w = cell_h = 0;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col && ws.ws_row && ws.ws_xpixel &&
+ ws.ws_ypixel) {
+ cell_w = ws.ws_xpixel / ws.ws_col;
+ cell_h = ws.ws_ypixel / ws.ws_row;
+ }
+}
+
+// Does this look like a terminal that can read a shm object we created? It has
+// to be the kitty/ghostty implementation *and* share our machine, so anything
+// multiplexed or remote falls back to inline bytes.
+static bool shm_plausible(void) {
+ if (in_tmux) return false;
+ const char *t = getenv("TERM");
+ const char *tp = getenv("TERM_PROGRAM");
+ if (getenv("KITTY_WINDOW_ID")) return true;
+ if (t && strstr(t, "kitty")) return true;
+ if (t && strstr(t, "ghostty")) return true;
+ if (tp && (!strcmp(tp, "ghostty") || !strcmp(tp, "kitty"))) return true;
+ return false;
+}
+
+static void shm_reclaim_orphans(void); // defined with the shm transfer below
+
+void kitty_init(int scale, int transfer) {
+ if (scale < 1) scale = 1;
+ if (scale > 8) scale = 8;
+ zoom = scale;
+ const char *tm = getenv("TMUX");
+ in_tmux = tm && *tm;
+ if (transfer == KITTY_XFER_SHM) xfer = KITTY_XFER_SHM;
+ else if (transfer == KITTY_XFER_B64) xfer = KITTY_XFER_B64;
+ else xfer = shm_plausible() ? KITTY_XFER_SHM : KITTY_XFER_B64;
+ kitty_refresh_metrics();
+ if (xfer == KITTY_XFER_SHM) shm_reclaim_orphans();
+ started = true;
+}
+
+const char *kitty_transfer_name(void) { return xfer == KITTY_XFER_SHM ? "shm" : "b64"; }
+
+// ---- nearest-neighbour upscale ---------------------------------------------
+// Pixel art wants hard edges. The terminal scales images with linear filtering,
+// so when we control the pixel count (the shm path, where bandwidth is free) we
+// blow the frame up ourselves and keep it crisp.
+static u8 *upbuf;
+static size_t upcap;
+
+static const u8 *upscale(const u8 *px, int w, int h, int bpp, int sc, int *ow, int *oh) {
+ if (sc <= 1) {
+ *ow = w;
+ *oh = h;
+ return px;
+ }
+ size_t need = (size_t)w * h * sc * sc * bpp;
+ if (need > upcap) {
+ u8 *p = realloc(upbuf, need);
+ if (!p) {
+ *ow = w;
+ *oh = h;
+ return px;
+ }
+ upbuf = p;
+ upcap = need;
+ }
+ int W = w * sc;
+ for (int y = 0; y < h; y++) {
+ u8 *drow = upbuf + (size_t)y * sc * W * bpp;
+ const u8 *srow = px + (size_t)y * w * bpp;
+ for (int x = 0; x < w; x++) {
+ const u8 *s = srow + (size_t)x * bpp;
+ u8 *d = drow + (size_t)x * sc * bpp;
+ for (int k = 0; k < sc; k++, d += bpp) memcpy(d, s, (size_t)bpp);
+ }
+ // the remaining sc-1 rows are copies of the one we just built
+ for (int k = 1; k < sc; k++) memcpy(drow + (size_t)k * W * bpp, drow, (size_t)W * bpp);
+ }
+ *ow = W;
+ *oh = h * sc;
+ return upbuf;
+}
+
+// ---- pad to a whole number of cells ----------------------------------------
+// The c=/r= keys size an image in *cells*, and the terminal stretches the
+// pixels to fill that box - so unless the zoomed image happens to be an exact
+// multiple of the cell size, the picture comes out subtly distorted (a 4:3 LCD
+// going 5% tall). Growing the image by a few pixels first makes the cell box
+// land exactly on zoom x, and the padding (black, or transparent for the HUD)
+// is a sliver at the right/bottom edge.
+static u8 *padbuf;
+static size_t padcap;
+
+static const u8 *pad_to_cells(const u8 *px, int w, int h, int bpp, int *ow, int *oh) {
+ int W = w, H = h;
+ while ((W * zoom) % cell_w) W++;
+ while ((H * zoom) % cell_h) H++;
+ *ow = W;
+ *oh = H;
+ if (W == w && H == h) return px;
+ size_t need = (size_t)W * H * bpp;
+ if (need > padcap) {
+ u8 *p = realloc(padbuf, need);
+ if (!p) {
+ *ow = w;
+ *oh = h;
+ return px;
+ }
+ padbuf = p;
+ padcap = need;
+ }
+ memset(padbuf, 0, need);
+ for (int y = 0; y < h; y++)
+ memcpy(padbuf + (size_t)y * W * bpp, px + (size_t)y * w * bpp, (size_t)w * bpp);
+ return padbuf;
+}
+
+// ---- shared-memory transfer ------------------------------------------------
+// The terminal unlinks the object once it has read it, so every frame needs a
+// fresh name. If it *doesn't* (unsupported / it died), stale objects would pile
+// up in /dev/shm, so we also unlink our own names a few frames later - harmless
+// if the terminal got there first.
+#define SHM_KEEP 4
+static char shm_names[SHM_KEEP][64];
+static unsigned shm_seq;
+
+static bool shm_publish(const u8 *px, size_t len, char *name, size_t namesz) {
+ unsigned slot = shm_seq % SHM_KEEP;
+ if (shm_names[slot][0]) { // reclaim the name from SHM_KEEP frames ago
+ shm_unlink(shm_names[slot]);
+ shm_names[slot][0] = 0;
+ }
+ snprintf(name, namesz, "/sl0pboy-%d-%u", (int)getpid(), shm_seq++);
+ int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600);
+ if (fd < 0) return false;
+ bool ok = false;
+ if (ftruncate(fd, (off_t)len) == 0) {
+ void *m = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+ if (m != MAP_FAILED) {
+ memcpy(m, px, len);
+ munmap(m, len);
+ ok = true;
+ }
+ }
+ close(fd);
+ if (!ok) {
+ shm_unlink(name);
+ return false;
+ }
+ snprintf(shm_names[slot], sizeof(shm_names[slot]), "%s", name);
+ return true;
+}
+
+static void shm_reclaim_all(void) {
+ for (int i = 0; i < SHM_KEEP; i++)
+ if (shm_names[i][0]) {
+ shm_unlink(shm_names[i]);
+ shm_names[i][0] = 0;
+ }
+}
+
+// SIGKILL leaves no chance to clean up, so an emulator that died hard can leave
+// its last few objects sitting in /dev/shm forever. Sweep the ones belonging to
+// pids that no longer exist before we start making our own.
+static void shm_reclaim_orphans(void) {
+ DIR *d = opendir("/dev/shm");
+ if (!d) return;
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ int pid = 0, seq = 0;
+ if (sscanf(e->d_name, "sl0pboy-%d-%d", &pid, &seq) != 2 || pid <= 0) continue;
+ if (pid == getpid()) continue;
+ if (kill(pid, 0) == 0 || errno != ESRCH) continue; // still running
+ char path[280];
+ snprintf(path, sizeof(path), "/%s", e->d_name);
+ shm_unlink(path);
+ }
+ closedir(d);
+}
+
+// ---- unchanged-frame suppression -------------------------------------------
+// A paused game, a menu, a text box: most frames are byte-identical to the last
+// one. Re-sending them is pure waste, so keep a copy per image and diff.
+typedef struct {
+ u32 id;
+ u8 *prev;
+ size_t len;
+ bool valid;
+} ImgCache;
+static ImgCache cache[2];
+
+static ImgCache *cache_for(u32 id) {
+ for (int i = 0; i < 2; i++)
+ if (cache[i].id == id) return &cache[i];
+ for (int i = 0; i < 2; i++)
+ if (!cache[i].id) {
+ cache[i].id = id;
+ return &cache[i];
+ }
+ return NULL;
+}
+// True when the pixels differ from last time (and remembers them).
+static bool cache_changed(u32 id, const u8 *px, size_t len, bool force) {
+ ImgCache *c = cache_for(id);
+ if (!c) return true;
+ if (c->len != len) {
+ u8 *p = realloc(c->prev, len);
+ if (!p) return true;
+ c->prev = p;
+ c->len = len;
+ c->valid = false;
+ }
+ if (!force && c->valid && memcmp(c->prev, px, len) == 0) return false;
+ memcpy(c->prev, px, len);
+ c->valid = true;
+ return true;
+}
+static void cache_invalidate(u32 id) {
+ ImgCache *c = cache_for(id);
+ if (c) c->valid = false;
+}
+
+// ---- image transmission ----------------------------------------------------
+// One image, transmitted and placed in a single command (a=T). Sizing is either
+// baked into the pixels (shm: we upscaled them) or delegated to the terminal
+// via c=/r=, which scales the image into a cell box - that keeps the inline
+// byte count at 1x no matter the zoom.
+static void send_image(u32 id, const u8 *px, int w, int h, int bpp, int z, bool prescale) {
+ if (!started) return;
+
+ int sw = w, sh = h;
+ const u8 *data = px;
+ int cols = 0, rows = 0;
+
+ if (xfer == KITTY_XFER_SHM || prescale) {
+ data = upscale(px, w, h, bpp, zoom, &sw, &sh);
+ } else if (zoom > 1) {
+ if (cell_w > 0 && cell_h > 0) {
+ // hand the pixels over at 1x and let the terminal scale them into a
+ // cell box: the byte count stays constant whatever the zoom
+ data = pad_to_cells(px, w, h, bpp, &sw, &sh);
+ cols = sw * zoom / cell_w;
+ rows = sh * zoom / cell_h;
+ } else {
+ // no cell metrics: we can't ask for a cell box, so pay the bytes
+ data = upscale(px, w, h, bpp, zoom, &sw, &sh);
+ }
+ }
+
+ size_t len = (size_t)sw * sh * bpp;
+ char ctrl[192];
+ int n = snprintf(ctrl, sizeof(ctrl), "a=T,q=2,f=%d,s=%d,v=%d,i=%u,p=%d,C=1", bpp == 4 ? 32 : 24,
+ sw, sh, id, PLACEMENT);
+ if (z) n += snprintf(ctrl + n, sizeof(ctrl) - n, ",z=%d", z);
+ if (cols) n += snprintf(ctrl + n, sizeof(ctrl) - n, ",c=%d,r=%d", cols, rows);
+
+ if (xfer == KITTY_XFER_SHM) {
+ char name[64];
+ if (shm_publish(data, len, name, sizeof(name))) {
+ char nb64[128];
+ size_t nn = b64_encode((const u8 *)name, strlen(name), nb64);
+ snprintf(ctrl + n, sizeof(ctrl) - n, ",t=s");
+ apc(ctrl, nb64, nn);
+ return;
+ }
+ // shm went away under us (no /dev/shm?) - inline the bytes instead
+ xfer = KITTY_XFER_B64;
+ cache_invalidate(id);
+ }
+
+ size_t need = b64_len(len);
+ char *enc = b64_get(need);
+ if (!enc) return;
+ size_t elen = b64_encode(data, len, enc);
+
+ // The payload rides in <=4096-byte base64 chunks: the first carries the
+ // control data and m=1 ("more coming"), the rest only need m, and m=0 ends
+ // the transmission.
+ size_t off = 0;
+ bool first = true;
+ while (first || off < elen) {
+ size_t take = elen - off < CHUNK ? elen - off : CHUNK;
+ bool last = (off + take >= elen);
+ if (first) {
+ char full[224];
+ snprintf(full, sizeof(full), "%s,t=d,m=%d", ctrl, last ? 0 : 1);
+ apc(full, enc + off, take);
+ first = false;
+ } else {
+ apc(last ? "m=0" : "m=1", enc + off, take);
+ }
+ off += take;
+ }
+}
+
+void kitty_frame_rgb(const u8 *rgb, int w, int h, bool force) {
+ size_t len = (size_t)w * h * 3;
+ if (!cache_changed(ID_FRAME, rgb, len, force)) return;
+ ob_puts("\x1b[H"); // place at the top-left cell (never wrapped:
+ // tmux has to see this one to track the pane)
+ send_image(ID_FRAME, rgb, w, h, 3, 0, false);
+ ob_flush();
+}
+
+void kitty_overlay_rgba(const u8 *rgba, int w, int h, bool force) {
+ size_t len = (size_t)w * h * 4;
+ if (!cache_changed(ID_OVERLAY, rgba, len, force)) return;
+ ob_puts("\x1b[H");
+ // The HUD is small and only changes when its text does, so it always ships
+ // pre-zoomed: no cell-box rounding to distort a 22px-tall panel.
+ send_image(ID_OVERLAY, rgba, w, h, 4, 1, true); // z=1: above the frame
+ ob_flush();
+}
+
+void kitty_overlay_clear(void) {
+ if (!started) return;
+ char ctrl[64];
+ snprintf(ctrl, sizeof(ctrl), "a=d,d=I,i=%u,q=2", ID_OVERLAY);
+ apc(ctrl, NULL, 0);
+ ob_flush();
+ cache_invalidate(ID_OVERLAY);
+}
+
+void kitty_shutdown(void) {
+ if (!started) return;
+ char ctrl[64];
+ snprintf(ctrl, sizeof(ctrl), "a=d,d=I,i=%u,q=2", ID_FRAME);
+ apc(ctrl, NULL, 0);
+ snprintf(ctrl, sizeof(ctrl), "a=d,d=I,i=%u,q=2", ID_OVERLAY);
+ apc(ctrl, NULL, 0);
+ ob_flush();
+ shm_reclaim_all();
+ started = false;
+}
diff --git a/src/kitty.h b/src/kitty.h
new file mode 100644
index 0000000..c602a17
--- /dev/null
+++ b/src/kitty.h
@@ -0,0 +1,37 @@
+#ifndef GBC_KITTY_H
+#define GBC_KITTY_H
+
+#include "types.h"
+
+// ---- kitty graphics protocol output ---------------------------------------
+// Sixel's poor relations: a 256-color palette per frame, no alpha, no layering,
+// no image ids. The kitty protocol has none of those limits, so this backend
+// ships the framebuffer as raw truecolor pixels (no quantization at all) and
+// can stack a translucent HUD *above* the game with a z-index.
+//
+// Two ways to hand the pixels over:
+// b64 - the bytes ride inline in the escape sequence (works everywhere the
+// protocol does, incl. over ssh and through tmux passthrough)
+// shm - the bytes go into a POSIX shared memory object and only its name is
+// sent; near-zero cost, but the terminal must be on this machine
+enum { KITTY_XFER_AUTO = 0, KITTY_XFER_B64, KITTY_XFER_SHM };
+
+// scale = integer pixel zoom (1-8). Safe to call repeatedly.
+void kitty_init(int scale, int transfer);
+// Drop every image we put on screen (called on exit / when leaving the mode).
+void kitty_shutdown(void);
+// Re-read the terminal cell size (call after SIGWINCH).
+void kitty_refresh_metrics(void);
+// Which transfer method ended up in use ("shm" / "b64"), for the stats line.
+const char *kitty_transfer_name(void);
+
+// Transmit + place an RGB888 frame (w*h*3) at the top-left cell. Byte-identical
+// consecutive frames are dropped unless force is set.
+void kitty_frame_rgb(const u8 *rgb, int w, int h, bool force);
+// Transmit + place an RGBA8888 overlay (w*h*4) above the frame (z-index 1),
+// anchored at the top-left cell. Also skipped when unchanged.
+void kitty_overlay_rgba(const u8 *rgba, int w, int h, bool force);
+// Remove the overlay image (e.g. when the HUD is toggled off).
+void kitty_overlay_clear(void);
+
+#endif
diff --git a/src/main.c b/src/main.c
index 978255c..a810e99 100644
--- a/src/main.c
+++ b/src/main.c
@@ -1,6 +1,7 @@
#include "gb.h"
#include "cpu.h"
#include "render.h"
+#include "kitty.h"
#include "control.h"
#include <stdio.h>
#include <stdlib.h>
@@ -82,43 +83,48 @@ static u8 keys_mask(const char *keys, int frame) {
static void print_usage(FILE *out, const char *prog) {
fprintf(out,
-"usage: %s [options] <rom>\n"
-"\n"
-"Emulate a Game Boy / Game Boy Color ROM. The ROM path is the final\n"
-"argument; all options precede it.\n"
-"\n"
-"display:\n"
-" --sixel [scale] sixel graphics output; scale = pixel zoom 1-6 (def 2)\n"
-" --chrome draw a Game Boy body around the LCD (sixel display)\n"
-" --palette NAME recolor the LCD: dmg|green (pea green), pocket|gray\n"
-" --shrink N shrink the half-block display N x (1-4); capture and\n"
-" recording stay full-res\n"
-" --green alias for --palette dmg\n"
-" --frameskip N draw 1 of every (N+1) frames\n"
-"\n"
-"speed:\n"
-" --fps N emulation speed cap in frames/sec (default 59.73)\n"
-" --uncapped run as fast as possible (alias: --turbo)\n"
-"\n"
-"boot:\n"
-" --bios [path] run a boot ROM first (default bios/gbc_bios.bin)\n"
-"\n"
-"control channels:\n"
-" --fifo [path] button-input named pipe (default /tmp/sl0pboy.fifo)\n"
-" --sock [path] control/debug socket (default /tmp/sl0pboy.sock)\n"
-"\n"
-"control channels (cont.):\n"
-" --serial-sock PATH link/serial port over a unix socket (network bridge);\n"
-" works with a live display, unlike --headless stdio\n"
-"\n"
-"headless & testing:\n"
-" --headless no video; wire serial <-> stdio (OS console)\n"
-" --test [seconds] run N seconds then exit, logging serial (default 30)\n"
-" --shot N [file] run N frames, write a PPM screenshot (default shot.ppm)\n"
-" --keys STR scripted button taps: u d l r a b s(elect) e(start) .\n"
-"\n"
-" -h, --help show this help and exit\n",
- prog);
+ "usage: %s [options] <rom>\n"
+ "\n"
+ "Emulate a Game Boy / Game Boy Color ROM. The ROM path is the final\n"
+ "argument; all options precede it.\n"
+ "\n"
+ "display:\n"
+ " --sixel [scale] sixel graphics output; scale = pixel zoom 1-6 (def 2)\n"
+ " --kitty [scale] kitty graphics output: truecolor pixels + a layered HUD;\n"
+ " scale = pixel zoom 1-8 (def 2). 'h' toggles the HUD\n"
+ " --kitty-shm force shared-memory transfer (fast, same machine only)\n"
+ " --kitty-b64 force inline base64 transfer (works over ssh/tmux)\n"
+ " --no-hud don't draw the kitty HUD overlay\n"
+ " --chrome draw a Game Boy body around the LCD (sixel/kitty)\n"
+ " --palette NAME recolor the LCD: dmg|green (pea green), pocket|gray\n"
+ " --shrink N shrink the half-block display N x (1-4); capture and\n"
+ " recording stay full-res\n"
+ " --green alias for --palette dmg\n"
+ " --frameskip N draw 1 of every (N+1) frames\n"
+ "\n"
+ "speed:\n"
+ " --fps N emulation speed cap in frames/sec (default 59.73)\n"
+ " --uncapped run as fast as possible (alias: --turbo)\n"
+ "\n"
+ "boot:\n"
+ " --bios [path] run a boot ROM first (default bios/gbc_bios.bin)\n"
+ "\n"
+ "control channels:\n"
+ " --fifo [path] button-input named pipe (default /tmp/sl0pboy.fifo)\n"
+ " --sock [path] control/debug socket (default /tmp/sl0pboy.sock)\n"
+ "\n"
+ "control channels (cont.):\n"
+ " --serial-sock PATH link/serial port over a unix socket (network bridge);\n"
+ " works with a live display, unlike --headless stdio\n"
+ "\n"
+ "headless & testing:\n"
+ " --headless no video; wire serial <-> stdio (OS console)\n"
+ " --test [seconds] run N seconds then exit, logging serial (default 30)\n"
+ " --shot N [file] run N frames, write a PPM screenshot (default shot.ppm)\n"
+ " --keys STR scripted button taps: u d l r a b s(elect) e(start) .\n"
+ "\n"
+ " -h, --help show this help and exit\n",
+ prog);
}
int main(int argc, char **argv) {
@@ -149,7 +155,11 @@ int main(int argc, char **argv) {
int frameskip = 0; // draw 1 of every (frameskip+1) frames
bool sixel = false; // use sixel graphics output
int sixel_scale = 2; // integer pixel zoom for sixel
- bool chrome = false; // draw a Game Boy body around the LCD (sixel)
+ bool kitty = false; // use kitty graphics output
+ int kitty_scale = 2; // integer pixel zoom for kitty
+ int kitty_xfer = KITTY_XFER_AUTO; // pixel handover method
+ bool hud = true; // kitty HUD overlay
+ bool chrome = false; // draw a Game Boy body around the LCD
const char *palette = NULL; // LCD recolor: dmg/green, pocket/gray, or off
bool headless = false; // no video; serial <-> stdio (OS debug console)
const char *bios_path = NULL; // boot ROM to run before the cartridge
@@ -189,6 +199,15 @@ int main(int argc, char **argv) {
} else if (!strcmp(a, "--sixel")) {
sixel = true;
if (has_val) sixel_scale = atoi(argv[++i]);
+ } else if (!strcmp(a, "--kitty")) {
+ kitty = true;
+ if (has_val) kitty_scale = atoi(argv[++i]);
+ } else if (!strcmp(a, "--kitty-shm")) {
+ kitty_xfer = KITTY_XFER_SHM;
+ } else if (!strcmp(a, "--kitty-b64") || !strcmp(a, "--kitty-inline")) {
+ kitty_xfer = KITTY_XFER_B64;
+ } else if (!strcmp(a, "--no-hud")) {
+ hud = false;
} else if (!strcmp(a, "--shrink")) {
if (!has_val) { fprintf(stderr, "%s: --shrink needs a value\n", prog); return 1; }
render_set_shrink(atoi(argv[++i]));
@@ -361,16 +380,26 @@ int main(int argc, char **argv) {
"write step break watch continue pause; 'help')\n",
sock_path);
}
+ if (kitty_xfer != KITTY_XFER_AUTO) kitty = true; // implies the mode
+ if (kitty && sixel) {
+ fprintf(stderr, "note: --kitty and --sixel are exclusive; using --kitty\n");
+ sixel = false;
+ }
if (sixel) render_set_sixel(true, sixel_scale);
- if (chrome && !sixel)
- fprintf(stderr, "note: --chrome only affects live display in --sixel mode "
- "(it still applies to --shot / recordings)\n");
- // A sixel stream is fragile: any stray stderr text (link reconnect notices,
- // input debug, cart warnings) lands mid-stream and corrupts the picture.
- // Silence stderr while the live sixel display is up; restored on exit so
+ if (kitty) {
+ render_set_kitty(true, kitty_scale, kitty_xfer);
+ render_set_hud_overlay(hud);
+ }
+ bool gfx = sixel || kitty; // a graphics protocol owns stdout
+ if (chrome && !gfx)
+ fprintf(stderr, "note: --chrome only affects live display in --sixel / "
+ "--kitty mode (it still applies to --shot / recordings)\n");
+ // A graphics stream is fragile: any stray stderr text (link reconnect
+ // notices, input debug, cart warnings) lands mid-stream and corrupts the
+ // picture. Silence stderr while the live display is up; restored on exit so
// the final stats line still prints.
int saved_stderr = -1;
- if (sixel) {
+ if (gfx) {
fflush(stderr);
saved_stderr = dup(STDERR_FILENO);
int devnull = open("/dev/null", O_WRONLY);
@@ -438,7 +467,7 @@ int main(int argc, char **argv) {
}
term_restore();
- if (saved_stderr >= 0) { // un-silence stderr (sixel mode)
+ if (saved_stderr >= 0) { // un-silence stderr (graphics modes)
fflush(stderr);
dup2(saved_stderr, STDERR_FILENO);
close(saved_stderr);
diff --git a/src/render.c b/src/render.c
index 1f232d8..9ea428e 100644
--- a/src/render.c
+++ b/src/render.c
@@ -1,7 +1,9 @@
#include "render.h"
+#include "kitty.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <time.h>
#include <unistd.h>
#include <termios.h>
#include <fcntl.h>
@@ -18,6 +20,7 @@ static bool raw_active = false;
static void on_winch(int s);
void term_restore(void) {
+ kitty_shutdown(); // drop any images we left on screen
if (raw_active) {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
raw_active = false;
@@ -39,7 +42,15 @@ void term_init(void) {
// enter alt screen, hide cursor, clear
printf("\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H");
fflush(stdout);
- signal(SIGWINCH, on_winch);
+ // sigaction, not signal(): under _POSIX_C_SOURCE glibc's signal() resets the
+ // disposition after the first delivery, which would leave us blind to every
+ // resize after the first one.
+ struct sigaction sa;
+ memset(&sa, 0, sizeof sa);
+ sa.sa_handler = on_winch;
+ sigemptyset(&sa.sa_mask);
+ sa.sa_flags = SA_RESTART;
+ sigaction(SIGWINCH, &sa, NULL);
render_force_full();
atexit(term_restore);
}
@@ -66,6 +77,7 @@ static bool hud_turbo = false;
#define LCD_Y0 CH_TOP
static bool sixel_enabled = false;
+static bool kitty_enabled = false;
static bool chrome_enabled = false;
static int sixel_scale = 2;
static char *sixel_buf = NULL; // reusable output buffer
@@ -91,7 +103,25 @@ void render_set_sixel(bool on, int scale) {
need_full = true;
}
-// Enable the drawn Game Boy body/frame around the LCD (sixel mode only).
+// ---- kitty graphics mode ---------------------------------------------------
+static bool hud_overlay = true; // translucent HUD layered over the frame
+static bool hud_shown = false; // an overlay image is currently on screen
+
+void render_set_kitty(bool on, int scale, int transfer) {
+ kitty_enabled = on;
+ if (on) {
+ sixel_enabled = false; // one graphics protocol at a time
+ kitty_init(scale, transfer);
+ }
+ need_full = true;
+}
+
+void render_set_hud_overlay(bool on) {
+ hud_overlay = on;
+ need_full = true;
+}
+
+// Enable the drawn Game Boy body/frame around the LCD (graphics modes only).
void render_set_chrome(bool on) {
chrome_enabled = on;
need_full = true;
@@ -297,50 +327,51 @@ static void fill_rrect(u8 *cv, int x0, int y0, int w, int h, int rad,
}
// ---- tiny 5x7 bitmap font (column-major: 5 bytes, bit0=top row) ----
-// Only the glyphs we actually silk-screen onto the shell are defined; the rest
-// render blank. Indexed by (ch - 32), covering ASCII space .. 'z'.
+// Indexed by (ch - 32), covering ASCII space .. 'z'; undefined glyphs render
+// blank. Beyond the silk-screened shell labels this also has to cope with
+// whatever a cartridge calls itself in the HUD, so the alphabet is complete.
static const u8 font5x7[95][5] = {
- [' '-32] = {0x00,0x00,0x00,0x00,0x00},
- ['('-32] = {0x00,0x1C,0x22,0x41,0x00},
- [')'-32] = {0x00,0x41,0x22,0x1C,0x00},
- ['.'-32] = {0x00,0x40,0x60,0x00,0x00},
- ['0'-32] = {0x3E,0x51,0x49,0x45,0x3E},
- ['1'-32] = {0x00,0x42,0x7F,0x40,0x00},
- ['2'-32] = {0x42,0x61,0x51,0x49,0x46},
- ['3'-32] = {0x21,0x41,0x45,0x4B,0x31},
- ['4'-32] = {0x18,0x14,0x12,0x7F,0x10},
- ['5'-32] = {0x27,0x45,0x45,0x45,0x39},
- ['6'-32] = {0x3C,0x4A,0x49,0x49,0x30},
- ['7'-32] = {0x01,0x71,0x09,0x05,0x03},
- ['8'-32] = {0x36,0x49,0x49,0x49,0x36},
- ['9'-32] = {0x06,0x49,0x49,0x29,0x1E},
- ['A'-32] = {0x7E,0x11,0x11,0x11,0x7E},
- ['B'-32] = {0x7F,0x49,0x49,0x49,0x36},
- ['C'-32] = {0x3E,0x41,0x41,0x41,0x22},
- ['D'-32] = {0x7F,0x41,0x41,0x22,0x1C},
- ['E'-32] = {0x7F,0x49,0x49,0x49,0x41},
- ['F'-32] = {0x7F,0x09,0x09,0x09,0x01},
- ['G'-32] = {0x3E,0x41,0x49,0x49,0x7A},
- ['H'-32] = {0x7F,0x08,0x08,0x08,0x7F},
- ['I'-32] = {0x00,0x41,0x7F,0x41,0x00},
- ['L'-32] = {0x7F,0x40,0x40,0x40,0x40},
- ['M'-32] = {0x7F,0x02,0x0C,0x02,0x7F},
- ['N'-32] = {0x7F,0x04,0x08,0x10,0x7F},
- ['O'-32] = {0x3E,0x41,0x41,0x41,0x3E},
- ['P'-32] = {0x7F,0x09,0x09,0x09,0x06},
- ['R'-32] = {0x7F,0x09,0x19,0x29,0x46},
- ['S'-32] = {0x46,0x49,0x49,0x49,0x31},
- ['T'-32] = {0x01,0x01,0x7F,0x01,0x01},
- ['U'-32] = {0x3F,0x40,0x40,0x40,0x3F},
- ['W'-32] = {0x7F,0x20,0x18,0x20,0x7F},
- ['X'-32] = {0x63,0x14,0x08,0x14,0x63},
- ['Y'-32] = {0x07,0x08,0x70,0x08,0x07},
- ['b'-32] = {0x7F,0x48,0x44,0x44,0x38},
- ['l'-32] = {0x00,0x41,0x7F,0x40,0x00},
- ['o'-32] = {0x38,0x44,0x44,0x44,0x38},
- ['p'-32] = {0x7C,0x14,0x14,0x14,0x08},
- ['s'-32] = {0x48,0x54,0x54,0x54,0x20},
- ['y'-32] = {0x0C,0x50,0x50,0x50,0x3C},
+ [' ' - 32] = {0x00, 0x00, 0x00, 0x00, 0x00}, ['!' - 32] = {0x00, 0x00, 0x5F, 0x00, 0x00},
+ ['#' - 32] = {0x14, 0x7F, 0x14, 0x7F, 0x14}, ['%' - 32] = {0x23, 0x13, 0x08, 0x64, 0x62},
+ ['\'' - 32] = {0x00, 0x03, 0x00, 0x00, 0x00}, ['(' - 32] = {0x00, 0x1C, 0x22, 0x41, 0x00},
+ [')' - 32] = {0x00, 0x41, 0x22, 0x1C, 0x00}, ['*' - 32] = {0x14, 0x08, 0x3E, 0x08, 0x14},
+ ['+' - 32] = {0x08, 0x08, 0x3E, 0x08, 0x08}, [',' - 32] = {0x00, 0x50, 0x30, 0x00, 0x00},
+ ['-' - 32] = {0x08, 0x08, 0x08, 0x08, 0x08}, ['.' - 32] = {0x00, 0x40, 0x60, 0x00, 0x00},
+ ['/' - 32] = {0x20, 0x10, 0x08, 0x04, 0x02}, ['0' - 32] = {0x3E, 0x51, 0x49, 0x45, 0x3E},
+ ['1' - 32] = {0x00, 0x42, 0x7F, 0x40, 0x00}, ['2' - 32] = {0x42, 0x61, 0x51, 0x49, 0x46},
+ ['3' - 32] = {0x21, 0x41, 0x45, 0x4B, 0x31}, ['4' - 32] = {0x18, 0x14, 0x12, 0x7F, 0x10},
+ ['5' - 32] = {0x27, 0x45, 0x45, 0x45, 0x39}, ['6' - 32] = {0x3C, 0x4A, 0x49, 0x49, 0x30},
+ ['7' - 32] = {0x01, 0x71, 0x09, 0x05, 0x03}, ['8' - 32] = {0x36, 0x49, 0x49, 0x49, 0x36},
+ ['9' - 32] = {0x06, 0x49, 0x49, 0x29, 0x1E}, [':' - 32] = {0x00, 0x36, 0x36, 0x00, 0x00},
+ ['<' - 32] = {0x00, 0x08, 0x14, 0x22, 0x41}, ['>' - 32] = {0x41, 0x22, 0x14, 0x08, 0x00},
+ ['?' - 32] = {0x02, 0x01, 0x51, 0x09, 0x06}, ['A' - 32] = {0x7E, 0x11, 0x11, 0x11, 0x7E},
+ ['B' - 32] = {0x7F, 0x49, 0x49, 0x49, 0x36}, ['C' - 32] = {0x3E, 0x41, 0x41, 0x41, 0x22},
+ ['D' - 32] = {0x7F, 0x41, 0x41, 0x22, 0x1C}, ['E' - 32] = {0x7F, 0x49, 0x49, 0x49, 0x41},
+ ['F' - 32] = {0x7F, 0x09, 0x09, 0x09, 0x01}, ['G' - 32] = {0x3E, 0x41, 0x49, 0x49, 0x7A},
+ ['H' - 32] = {0x7F, 0x08, 0x08, 0x08, 0x7F}, ['I' - 32] = {0x00, 0x41, 0x7F, 0x41, 0x00},
+ ['J' - 32] = {0x20, 0x40, 0x41, 0x3F, 0x01}, ['K' - 32] = {0x7F, 0x08, 0x14, 0x22, 0x41},
+ ['L' - 32] = {0x7F, 0x40, 0x40, 0x40, 0x40}, ['M' - 32] = {0x7F, 0x02, 0x0C, 0x02, 0x7F},
+ ['N' - 32] = {0x7F, 0x04, 0x08, 0x10, 0x7F}, ['O' - 32] = {0x3E, 0x41, 0x41, 0x41, 0x3E},
+ ['P' - 32] = {0x7F, 0x09, 0x09, 0x09, 0x06}, ['Q' - 32] = {0x3E, 0x41, 0x51, 0x21, 0x5E},
+ ['R' - 32] = {0x7F, 0x09, 0x19, 0x29, 0x46}, ['S' - 32] = {0x46, 0x49, 0x49, 0x49, 0x31},
+ ['T' - 32] = {0x01, 0x01, 0x7F, 0x01, 0x01}, ['U' - 32] = {0x3F, 0x40, 0x40, 0x40, 0x3F},
+ ['V' - 32] = {0x1F, 0x20, 0x40, 0x20, 0x1F}, ['W' - 32] = {0x7F, 0x20, 0x18, 0x20, 0x7F},
+ ['X' - 32] = {0x63, 0x14, 0x08, 0x14, 0x63}, ['Y' - 32] = {0x07, 0x08, 0x70, 0x08, 0x07},
+ ['Z' - 32] = {0x61, 0x51, 0x49, 0x45, 0x43}, ['[' - 32] = {0x00, 0x7F, 0x41, 0x41, 0x00},
+ [']' - 32] = {0x00, 0x41, 0x41, 0x7F, 0x00}, ['_' - 32] = {0x40, 0x40, 0x40, 0x40, 0x40},
+ ['a' - 32] = {0x20, 0x54, 0x54, 0x54, 0x78}, ['b' - 32] = {0x7F, 0x48, 0x44, 0x44, 0x38},
+ ['c' - 32] = {0x38, 0x44, 0x44, 0x44, 0x20}, ['d' - 32] = {0x38, 0x44, 0x44, 0x48, 0x7F},
+ ['e' - 32] = {0x38, 0x54, 0x54, 0x54, 0x18}, ['f' - 32] = {0x08, 0x7E, 0x09, 0x01, 0x02},
+ ['g' - 32] = {0x0C, 0x52, 0x52, 0x52, 0x3E}, ['h' - 32] = {0x7F, 0x08, 0x04, 0x04, 0x78},
+ ['i' - 32] = {0x00, 0x44, 0x7D, 0x40, 0x00}, ['j' - 32] = {0x20, 0x40, 0x44, 0x3D, 0x00},
+ ['k' - 32] = {0x7F, 0x10, 0x28, 0x44, 0x00}, ['l' - 32] = {0x00, 0x41, 0x7F, 0x40, 0x00},
+ ['m' - 32] = {0x7C, 0x04, 0x18, 0x04, 0x78}, ['n' - 32] = {0x7C, 0x08, 0x04, 0x04, 0x78},
+ ['o' - 32] = {0x38, 0x44, 0x44, 0x44, 0x38}, ['p' - 32] = {0x7C, 0x14, 0x14, 0x14, 0x08},
+ ['q' - 32] = {0x08, 0x14, 0x14, 0x18, 0x7C}, ['r' - 32] = {0x7C, 0x08, 0x04, 0x04, 0x08},
+ ['s' - 32] = {0x48, 0x54, 0x54, 0x54, 0x20}, ['t' - 32] = {0x04, 0x3F, 0x44, 0x40, 0x20},
+ ['u' - 32] = {0x3C, 0x40, 0x40, 0x20, 0x7C}, ['v' - 32] = {0x1C, 0x20, 0x40, 0x20, 0x1C},
+ ['w' - 32] = {0x3C, 0x40, 0x30, 0x40, 0x3C}, ['x' - 32] = {0x44, 0x28, 0x10, 0x28, 0x44},
+ ['y' - 32] = {0x0C, 0x50, 0x50, 0x50, 0x3C}, ['z' - 32] = {0x44, 0x64, 0x54, 0x4C, 0x44},
};
static void draw_char(u8 *cv, int x, int y, char ch, int sc,
@@ -463,6 +494,141 @@ static void render_frame_chrome(GB *gb) {
fflush(stdout);
}
+// ---- kitty graphics renderer -----------------------------------------------
+// Same pixels as the sixel path, minus the compromises: the frame goes over the
+// wire as raw truecolor RGB (no per-frame palette, so CGB games keep every
+// shade), and the HUD rides *above* it as a separate alpha-blended image on
+// z-index 1 instead of being burned into the picture.
+
+// rolling render rate, measured here so the overlay can show it
+static double fps_now = 0.0;
+static void fps_tick(void) {
+ static double t0 = 0;
+ static int n = 0;
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ double t = ts.tv_sec + ts.tv_nsec / 1e9;
+ if (t0 == 0) {
+ t0 = t;
+ return;
+ }
+ if (++n >= 15) {
+ fps_now = n / (t - t0);
+ n = 0;
+ t0 = t;
+ }
+}
+
+#define HUD_MAXW 240
+#define HUD_MAXH 24
+static u8 hud_rgba[HUD_MAXW * HUD_MAXH * 4];
+static int hud_w, hud_h;
+
+static void hud_px(int x, int y, int r, int g, int b, int a) {
+ if ((unsigned)x >= (unsigned)hud_w || (unsigned)y >= (unsigned)hud_h) return;
+ u8 *p = &hud_rgba[(y * hud_w + x) * 4];
+ p[0] = (u8)r;
+ p[1] = (u8)g;
+ p[2] = (u8)b;
+ p[3] = (u8)a;
+}
+static void hud_rrect(int x0, int y0, int w, int h, int rad, int r, int g, int b, int a) {
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++) {
+ int cx = x < rad ? rad : (x >= w - rad ? w - 1 - rad : x);
+ int cy = y < rad ? rad : (y >= h - rad ? h - 1 - rad : y);
+ int dx = x - cx, dy = y - cy;
+ if (dx * dx + dy * dy <= rad * rad) hud_px(x0 + x, y0 + y, r, g, b, a);
+ }
+}
+static void hud_char(int x, int y, char ch, int r, int g, int b) {
+ if (ch < 32 || ch > 126) return;
+ const u8 *gl = font5x7[ch - 32];
+ for (int col = 0; col < 5; col++)
+ for (int row = 0; row < 7; row++)
+ if (gl[col] & (1 << row)) hud_px(x + col, y + row, r, g, b, 255);
+}
+static int hud_text(int x, int y, const char *s, int r, int g, int b) {
+ for (; *s; s++, x += 6) hud_char(x, y, *s, r, g, b);
+ return x;
+}
+
+// Compose the overlay: cart title + render rate on top, frameskip/turbo and a
+// live button readout below. Alpha 0 everywhere outside the panel, so the game
+// shows through around it.
+static void build_hud_overlay(GB *gb) {
+ // Fixed-width fields: the panel then keeps one size for the whole session,
+ // so it neither jitters on screen nor forces a resize on the terminal.
+ char title[13], fps_s[12], l2[16];
+ snprintf(title, sizeof(title), "%-12.12s", gb->cart.title);
+ for (char *c = title; *c; c++)
+ if (*c < 32 || *c > 126) *c = ' ';
+ snprintf(fps_s, sizeof(fps_s), "%3.0f FPS", fps_now < 999 ? fps_now : 999);
+ snprintf(l2, sizeof(l2), "SKIP:%-2d", hud_frameskip);
+
+ // one glyph per button, lit while held ('s' = select, 'S' = start)
+ const char *pips = "UDLRABsS";
+ static const u8 pip_mask[8] = {BTN_UP, BTN_DOWN, BTN_LEFT, BTN_RIGHT,
+ BTN_A, BTN_B, BTN_SELECT, BTN_START};
+ int w1 = (int)(strlen(title) + 1 + strlen(fps_s)) * 6;
+ int w2 = (int)strlen(l2) * 6 + 4 + 8 * 6 + 4 + 5 * 6; // TURBO slot always
+ // reserved
+
+ hud_w = (w1 > w2 ? w1 : w2) + 8;
+ if (hud_w > HUD_MAXW) hud_w = HUD_MAXW;
+ hud_h = 22;
+
+ memset(hud_rgba, 0, (size_t)hud_w * hud_h * 4);
+ hud_rrect(0, 0, hud_w, hud_h, 5, 12, 12, 20, 172); // smoked panel
+ hud_rrect(0, 0, hud_w, 1, 0, 210, 40, 120, 235); // magenta hairline
+
+ hud_text(4, 3, title, 226, 226, 236);
+ hud_text(4 + ((int)strlen(title) + 1) * 6, 3, fps_s, 90, 220, 230);
+ int x = hud_text(4, 12, l2, 150, 150, 165);
+ x += 4;
+ for (int i = 0; i < 8; i++, x += 6) {
+ bool on = (gb->buttons & pip_mask[i]) != 0;
+ if (on) hud_rrect(x - 1, 11, 6, 9, 1, 40, 90, 60, 220);
+ hud_char(x, 12, pips[i], on ? 120 : 74, on ? 255 : 74, on ? 150 : 88);
+ }
+ if (hud_turbo) hud_text(x + 2, 12, "TURBO", 240, 90, 170);
+}
+
+static void render_frame_kitty(GB *gb) {
+ if (resized) {
+ resized = 0;
+ need_full = true;
+ kitty_refresh_metrics();
+ fputs("\x1b[2J", stdout);
+ }
+ fps_tick();
+
+ const u8 *px;
+ int w, h;
+ if (chrome_enabled) {
+ build_chrome_canvas(gb);
+ px = chrome_canvas;
+ w = CHROME_W;
+ h = CHROME_H;
+ } else {
+ u8(*fb)[SCREEN_W][3] = lcd_fb(&gb->ppu);
+ px = &fb[0][0][0];
+ w = SCREEN_W;
+ h = SCREEN_H;
+ }
+ kitty_frame_rgb(px, w, h, need_full);
+
+ if (hud_overlay) {
+ build_hud_overlay(gb);
+ kitty_overlay_rgba(hud_rgba, hud_w, hud_h, need_full);
+ hud_shown = true;
+ } else if (hud_shown) {
+ kitty_overlay_clear();
+ hud_shown = false;
+ }
+ need_full = false;
+}
+
// ---- frame capture (for screenshots / video recording) ---------------------
// Produce the exact frame the user sees - LCD recolor plus the Game Boy chrome
// if enabled - as a contiguous RGB888 buffer, so captures match the display.
@@ -496,6 +662,10 @@ void render_set_shrink(int n) {
}
void render_frame(GB *gb) {
+ if (kitty_enabled) {
+ render_frame_kitty(gb);
+ return;
+ }
if (sixel_enabled) {
if (chrome_enabled) render_frame_chrome(gb);
else render_frame_sixel(gb);
@@ -712,6 +882,10 @@ bool input_poll(GB *gb) {
case '\r': case '\n': tap(BTN_START, HOLD_FRAMES); break;
case ' ': tap(BTN_SELECT, HOLD_FRAMES); break;
case 'f': turbo_toggles++; break; // toggle fast-forward
+ case 'h': // toggle the kitty HUD overlay
+ hud_overlay = !hud_overlay;
+ need_full = true;
+ break;
case '[': frameskip_delta--; break; // fewer skipped frames
case ']': frameskip_delta++; break; // more skipped frames
case 'q': case 3: quit = true; break;
diff --git a/src/render.h b/src/render.h
index fed4ec8..0869684 100644
--- a/src/render.h
+++ b/src/render.h
@@ -11,9 +11,15 @@ void render_set_hud(int frameskip, bool turbo); // update HUD readout
// Switch terminal output to sixel graphics (scale = integer pixel zoom, >=1).
// Pass on=false to use the default Unicode half-block renderer.
void render_set_sixel(bool on, int scale);
+// Switch terminal output to the kitty graphics protocol: truecolor pixels (no
+// palette), plus a translucent HUD layered over the frame. scale = integer
+// pixel zoom (1-8); transfer = KITTY_XFER_* (see kitty.h).
+void render_set_kitty(bool on, int scale, int transfer);
+// Show/hide the kitty HUD overlay (also toggled live with 'h').
+void render_set_hud_overlay(bool on);
void render_set_shrink(int n); // half-block display downscale (1-4, def 1)
-// Draw a Game Boy body/frame around the LCD (sixel output only); buttons on the
-// drawn shell light up while pressed. No effect unless sixel mode is enabled.
+// Draw a Game Boy body/frame around the LCD (sixel/kitty output); buttons on
+// the drawn shell light up while pressed. No effect on the half-block display.
void render_set_chrome(bool on);
// Recolor the LCD output to a 4-shade palette by luminance, for the classic
// monochrome-LCD look. Names: "dmg"/"green" (DMG-01 pea green), "pocket"/"gray"
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()