diff options
| author | gbc dev <gbc@localhost> | 2026-09-22 14:55:45 +0200 |
|---|---|---|
| committer | gbc dev <gbc@localhost> | 2026-09-22 14:55:45 +0200 |
| commit | 44c21908b4ff6a1e41a6b9ba17e3ed78de8ec3db (patch) | |
| tree | 1278a79c6b16bff4b64a0798c91045d7ae72abff | |
| parent | main: keep signal handlers installed (sigaction, not signal) (diff) | |
| download | sl0pboy-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.md | 74 | ||||
| -rw-r--r-- | src/kitty.c | 445 | ||||
| -rw-r--r-- | src/kitty.h | 37 | ||||
| -rw-r--r-- | src/main.c | 121 | ||||
| -rw-r--r-- | src/render.c | 264 | ||||
| -rw-r--r-- | src/render.h | 10 | ||||
| -rwxr-xr-x | tools/kitty_decode.py | 174 |
7 files changed, 1029 insertions, 96 deletions
@@ -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 @@ -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() |
