diff options
| author | gbc dev <gbc@localhost> | 2026-07-14 23:26:49 +0200 |
|---|---|---|
| committer | gbc dev <gbc@localhost> | 2026-07-14 23:26:49 +0200 |
| commit | c879adc43b91a53eb7e76f232a39abb56d8144c1 (patch) | |
| tree | 3bd8cd77fed46ceb62504d2fb067f349bad2cf0e | |
| parent | speed control: frame skipping to decouple terminal draw from emulation (diff) | |
| download | sl0pboy-c879adc43b91a53eb7e76f232a39abb56d8144c1.tar.gz sl0pboy-c879adc43b91a53eb7e76f232a39abb56d8144c1.tar.xz sl0pboy-c879adc43b91a53eb7e76f232a39abb56d8144c1.zip | |
control: socket-based debug channel + gbctl CLI driver
Replace the input-only FIFO's limitations with a Unix-domain socket control
channel (--sock) that is a superset of the button vocabulary plus emulator
introspection: read/write bus memory, get/set CPU context, single-step, PC
breakpoints, value-change watchpoints, run/pause. Being a socket it replies to
each command and broadcasts async 'event stop ...' lines; a stored last-stop
record (stopinfo) lets per-request clients recover a missed event.
Add gbctl: a stdlib-python CLI that spawns a tmux pane running the emulator on
a ROM, auto-resolves the socket, and drives it with terse verbs (cpu/read/write/
reg/step/break/watch/continue/pause/press/hold/screen/stop). The FIFO stays as
legacy input-only.
| -rw-r--r-- | README.md | 113 | ||||
| -rwxr-xr-x | gbctl | 362 | ||||
| -rw-r--r-- | src/control.c | 591 | ||||
| -rw-r--r-- | src/control.h | 36 | ||||
| -rw-r--r-- | src/main.c | 29 | ||||
| -rw-r--r-- | src/render.c | 135 | ||||
| -rw-r--r-- | src/render.h | 8 |
7 files changed, 1271 insertions, 3 deletions
@@ -20,11 +20,31 @@ make ./build/gbc path/to/rom.gb # interactive ./build/gbc rom.gb --test 30 # headless: run 30s, log serial (test roms) ./build/gbc rom.gb --shot 60 out.ppm # run 60 frames, dump a PPM screenshot +./build/gbc rom.gb --sixel 3 # sixel graphics output at 3x zoom +./build/gbc rom.gb --sock # open a control/debug socket (/tmp/gbc.sock) ``` Requires a truecolor-capable terminal (`COLORTERM=truecolor`) at least 160 columns wide. +### Sixel output + +On terminals that support **sixel** graphics (xterm `-ti vt340`, foot, WezTerm, +mlterm, Contour, recent iTerm2, etc.) pass `--sixel [scale]` to render true +pixels instead of half-blocks: + +```sh +./build/gbc rom.gb --sixel # default 2x zoom (320×288 pixels) +./build/gbc rom.gb --sixel 4 # 4x zoom +``` + +Each frame builds a per-frame palette from the framebuffer (≤256 entries — 4 +shades on DMG, a small set on CGB; colors are quantized more coarsely only if a +frame ever overflows), then emits standard sixel bands with per-column +run-length encoding. `scale` is an integer pixel zoom (1–6). Sixel frames are +sent in full (no inter-frame diffing), so combine with `--frameskip` on slower +terminals. + ### Speed & frame skipping The emulator always advances every Game Boy frame at the correct rate, so @@ -95,6 +115,96 @@ Commands (one line, space/comma separated tokens, case-insensitive): Set `GBC_INPUT_DEBUG=1` to trace resulting joypad state to stderr. +The FIFO is one-way (input only) and can't reply. For introspection/debugging +use the socket control channel below, which is a strict superset. + +### Socket control & debug channel + +Start with `--sock [path]` (default `/tmp/gbc.sock`) to open a Unix-domain +stream socket. It accepts everything the FIFO does **plus** emulator +introspection commands, and — being a socket — it *replies* to each command and +pushes asynchronous events when execution stops. Connect any line-oriented +client: + +```sh +./build/gbc rom.gb --sock /tmp/gbc.sock & +socat - UNIX-CONNECT:/tmp/gbc.sock # interactive +# or: nc -U /tmp/gbc.sock +``` + +The protocol is line based (one command per line). Replies start with `ok` or +`err`; asynchronous notifications start with `event`. Numbers accept `0x` hex +or decimal; **memory bytes are hex** (so `read` output feeds straight back into +`write`). + +| Command | Effect | +|-----------------------------|----------------------------------------------------| +| `ping` | `pong` liveness check | +| `help` | one-line command summary | +| `state` | `ok state running\|paused` | +| `cpu` (`regs`) | dump CPU context (af/bc/de/hl/sp/pc/ime/halted/cycles) | +| `reg <name> <val>` | set a register/flag (a..l, af..hl, sp, pc, ime) | +| `read <addr> [len]` | read `len` bytes (bus read), returned as hex | +| `write <addr> <bytes…>` | write bytes: `de ad be ef` or a `deadbeef` string | +| `step [n]` | single-step n instructions, replies with new context | +| `continue` (`c`) | resume free-running emulation | +| `pause` | halt CPU advancement at the next instruction | +| `break <addr>` | add a PC breakpoint (no arg lists them) | +| `delete <0xaddr\|#idx\|all>` | remove a breakpoint | +| `watch <addr>` | value-change watchpoint on a byte (no arg lists) | +| `unwatch <0xaddr\|#idx\|all>`| remove a watchpoint | +| `quit` | shut the emulator down | +| *(any button tokens)* | same vocabulary as the FIFO (tap/hold/release) | + +When free-running (`continue`) hits a breakpoint or watchpoint, every connected +client receives an async line and the machine pauses: + +``` +event stop breakpoint af=0x0840 bc=0x0800 … pc=0x0048 … cycles=72654560 +event stop watch 0xFF44 af=0x90C0 … pc=0x4812 … +``` + +There is also a `stopinfo` command (alias `why`/`laststop`) that reports the +last reason execution halted, so a client that wasn't connected when an async +`event` fired can still recover it (`ok stop breakpoint 0x0150 …`). + +`step` runs synchronously and replies immediately with the stop reason and the +resulting context (`ok stop step …` / `ok stop breakpoint …` / `ok stop watch …`). +Example session: + +``` +> pause +ok paused af=0x9040 … pc=0x021D … +> break 0x0150 +ok break #0 0x0150 +> read 0xFF44 1 +ok read 0xFF44 1 90 +> write 0xC000 de ad be ef +ok write 0xC000 4 +> reg pc 0x0150 +ok reg pc=0x0150 +> continue +ok running +event stop breakpoint … pc=0x0150 … +``` + +#### `gbctl` — CLI driver (agent/tmux friendly) + +`./gbctl` wraps the socket in a terse CLI that also spawns/stops a tmux pane +running the emulator, so an LLM agent (or you) can drive it by tool-calling one +command at a time. Socket auto-resolves to the single live instance. + +```sh +./gbctl spawn roms/game.gb --uncapped # tmux pane + emulator --sock; waits ready +./gbctl cpu ; ./gbctl read 0xC000 16 ; ./gbctl write 0xC000 deadbeef +./gbctl break 0x0150 ; ./gbctl continue 5 # blocks up to 5s for the stop event +./gbctl press a b ; ./gbctl hold left ; ./gbctl release +./gbctl screen ; ./gbctl stop # view the pane / tear down +``` + +Run `./gbctl` with no args for the full command list. This is driven end-to-end +by the `gbc-driver` skill. + ## Architecture | File | Responsibility | @@ -105,7 +215,8 @@ Set `GBC_INPUT_DEBUG=1` to trace resulting joypad state to stderr. | `cpu.c` | Sharp SM83 core (full opcode set, cycle-accurate accesses)| | `timer.c` | DIV/TIMA/TMA/TAC with falling-edge accuracy | | `ppu.c` | LCD controller, scanline renderer (BG/window/sprites), CGB| -| `render.c` | terminal truecolor output + raw-mode keyboard input | +| `render.c` | terminal truecolor output + raw-mode keyboard/FIFO input | +| `control.c` | socket control/debug channel: memory, CPU, step, breakpoints| | `main.c` | argument parsing, frame loop, timing, screenshot/test modes| ## Emulation status @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""gbctl — drive the gbc emulator's socket control/debug channel from the CLI. + +Model mirrors gdb-driver: spawn a tmux pane running the emulator with a control +socket, then drive it with terse verbs, then stop. Socket auto-resolves to the +single live instance (else pass --sock / set GBCTL_SOCK). + + gbctl spawn <rom> [emu-args...] # tmux pane + emulator --sock; waits ready + gbctl cpu | state | stopinfo # introspection (one-line replies) + gbctl read 0xC000 16 # hex dump + gbctl write 0xC000 de ad be ef # hex bytes (round-trips with read) + gbctl reg pc 0x150 # set a register/flag + gbctl step [n] # single-step (synchronous) + gbctl break 0x150 | break | delete all + gbctl watch 0xFF44 | unwatch all + gbctl continue [secs] # resume; if secs given, block for a stop event + gbctl pause + gbctl press a b | gbctl hold left | gbctl release # inject input + gbctl send <raw protocol line...> # escape hatch: send any line verbatim + gbctl monitor [secs] # stream async events (default until Ctrl-C) + gbctl screen [color] # tmux capture of the emulator pane + gbctl list [--prune] # inventory of live instances + gbctl stop # quit emulator + kill pane + rm socket +""" +import json, os, re, socket, subprocess, sys, time, glob + +HERE = os.path.dirname(os.path.abspath(__file__)) +BIN = os.path.join(HERE, "build", "gbc") +REGDIR = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "gbctl") + +# --------------------------------------------------------------------------- +# registry +# --------------------------------------------------------------------------- +def _ensure_regdir(): + os.makedirs(REGDIR, exist_ok=True) + +def _reg_path(inst_id): + return os.path.join(REGDIR, inst_id + ".json") + +def _load_all(): + out = [] + for p in glob.glob(os.path.join(REGDIR, "*.json")): + try: + with open(p) as f: + d = json.load(f) + d["_path"] = p + out.append(d) + except Exception: + pass + return out + +def _pane_alive(pane): + if not pane: + return False + try: + r = subprocess.run(["tmux", "list-panes", "-a", "-F", "#{pane_id}"], + capture_output=True, text=True) + return pane in r.stdout.split() + except Exception: + return False + +def _live_instances(prune=False): + live = [] + for d in _load_all(): + ok = os.path.exists(d.get("sock", "")) and _pane_alive(d.get("pane")) + if ok: + live.append(d) + elif prune: + try: + os.unlink(d["_path"]) + if d.get("sock") and os.path.exists(d["sock"]): + os.unlink(d["sock"]) + except Exception: + pass + return live + +# --------------------------------------------------------------------------- +# socket resolution + I/O +# --------------------------------------------------------------------------- +def _resolve_sock(argv): + # explicit --sock anywhere + if "--sock" in argv: + i = argv.index("--sock") + s = argv[i + 1] + del argv[i:i + 2] + return s + if os.environ.get("GBCTL_SOCK"): + return os.environ["GBCTL_SOCK"] + live = _live_instances() + if len(live) == 1: + return live[0]["sock"] + if not live: + die("no live emulator; run: gbctl spawn <rom>") + die("multiple instances; pass --sock <path>:\n" + + "\n".join(" %s %s" % (d["sock"], d.get("rom", "")) for d in live)) + +class Conn: + """Line-buffered wrapper around a connected unix socket.""" + def __init__(self, sock_path, timeout=5.0): + self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.s.settimeout(timeout) + self.s.connect(sock_path) + self.buf = b"" + + def read_line(self, timeout=None): + if timeout is not None: + self.s.settimeout(timeout) + while b"\n" not in self.buf: + try: + d = self.s.recv(65536) + except socket.timeout: + return None + if not d: + if self.buf: + line, self.buf = self.buf, b"" + return line.decode(errors="replace") + return None + self.buf += d + line, _, self.buf = self.buf.partition(b"\n") + return line.decode(errors="replace") + + def send(self, line): + self.s.sendall((line + "\n").encode()) + + def close(self): + try: + self.s.close() + except Exception: + pass + +def _connect(sock_path, timeout=5.0): + c = Conn(sock_path, timeout) + c.read_line() # consume greeting + return c + +def send_cmd(sock_path, line, wait_event=0.0): + """Send one protocol line; print the reply. If wait_event>0, keep reading + for an async 'event ...' line up to that many seconds and print it too.""" + c = _connect(sock_path) + c.send(line) + reply = c.read_line(timeout=5.0) + if reply is not None: + print(reply) + if wait_event > 0: + deadline = time.time() + wait_event + while time.time() < deadline: + ev = c.read_line(timeout=max(0.01, deadline - time.time())) + if ev is None: + break + if ev.startswith("event"): + print(ev) + break + c.close() + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- +def die(msg, code=1): + print(msg, file=sys.stderr) + sys.exit(code) + +def _wait_ready(sock_path, secs=10.0): + deadline = time.time() + secs + while time.time() < deadline: + if os.path.exists(sock_path): + try: + c = _connect(sock_path, timeout=1.0) + c.send("ping") + ok = (c.read_line(1.0) or "").strip() == "pong" + c.close() + if ok: + return True + except Exception: + pass + time.sleep(0.1) + return False + +# --------------------------------------------------------------------------- +# subcommands +# --------------------------------------------------------------------------- +def cmd_spawn(argv): + if not os.path.exists(BIN): + die("emulator not built: run `make` in %s" % HERE) + split = "-v" # stacked keeps full width for render + while argv and argv[0].startswith("--split"): + val = argv.pop(0).split("=", 1) + mode = (val[1] if len(val) == 2 else argv.pop(0)) if val[0] == "--split" else "v" + split = {"h": "-h", "v": "-v", "window": "window"}.get(mode, "-v") + if not argv: + die("usage: gbctl spawn [--split h|v|window] <rom> [emu-args...]") + rom = argv[0] + emu_args = argv[1:] + if not os.path.exists(rom): + die("rom not found: %s" % rom) + + _ensure_regdir() + inst_id = "%d-%s" % (os.getpid(), os.urandom(3).hex()) + sock_path = os.path.join(REGDIR, inst_id + ".sock") + + if "TMUX" not in os.environ: + die("not inside tmux; gbctl spawn needs a tmux session") + + cmd = [BIN, rom, "--sock", sock_path] + emu_args + # exec via a shell wrapper so the pane stays around & shows a title + shell_cmd = "printf '\\033]2;gbc %s\\007'; exec %s" % ( + os.path.basename(rom), " ".join(_shquote(c) for c in cmd)) + if split == "window": + r = subprocess.run(["tmux", "new-window", "-P", "-F", "#{pane_id}", + shell_cmd], capture_output=True, text=True) + else: + r = subprocess.run(["tmux", "split-window", split, "-P", "-F", + "#{pane_id}", shell_cmd], capture_output=True, text=True) + if r.returncode != 0: + die("tmux spawn failed: %s" % r.stderr.strip()) + pane = r.stdout.strip() + + with open(_reg_path(inst_id), "w") as f: + json.dump({"id": inst_id, "sock": sock_path, "pane": pane, + "rom": rom, "created": time.time()}, f) + + if _wait_ready(sock_path): + print("ready pane=%s sock=%s rom=%s" % (pane, sock_path, rom)) + else: + die("spawned pane=%s but control socket never became ready\n" + " check the pane for errors (gbctl screen)" % pane) + +def _shquote(s): + if re.fullmatch(r"[A-Za-z0-9_./=:+-]+", s or ""): + return s + return "'" + s.replace("'", "'\\''") + "'" + +def cmd_list(argv): + prune = "--prune" in argv + live = _live_instances(prune=prune) + if not live: + print("(no live instances)") + return + for d in live: + print("pane=%s sock=%s rom=%s" % (d.get("pane"), d["sock"], + d.get("rom", ""))) + +def cmd_stop(argv): + sock_path = _resolve_sock(argv) + # find matching registry entry (for pane id) + inst = None + for d in _load_all(): + if d.get("sock") == sock_path: + inst = d + break + # ask the emulator to quit + try: + c = _connect(sock_path, timeout=2.0) + c.send("quit") + c.read_line(1.0) + c.close() + except Exception: + pass + time.sleep(0.2) + if inst: + if _pane_alive(inst.get("pane")): + subprocess.run(["tmux", "kill-pane", "-t", inst["pane"]], + capture_output=True) + try: + os.unlink(inst["_path"]) + except Exception: + pass + if os.path.exists(sock_path): + try: + os.unlink(sock_path) + except Exception: + pass + print("stopped %s" % sock_path) + +def cmd_screen(argv): + color = "color" in argv + argv = [a for a in argv if a != "color"] + sock_path = _resolve_sock(argv) + inst = next((d for d in _load_all() if d.get("sock") == sock_path), None) + if not inst or not inst.get("pane"): + die("no pane for %s" % sock_path) + args = ["tmux", "capture-pane", "-p", "-t", inst["pane"]] + if color: + args.insert(2, "-e") + r = subprocess.run(args, capture_output=True, text=True) + sys.stdout.write(r.stdout) + +def cmd_monitor(argv): + secs = float(argv[0]) if argv and _isnum(argv[0]) else None + if secs is not None: + argv = argv[1:] + sock_path = _resolve_sock(argv) + c = _connect(sock_path, timeout=None) + print("# monitoring %s (Ctrl-C to stop)" % sock_path, file=sys.stderr) + deadline = (time.time() + secs) if secs else None + try: + while True: + if deadline and time.time() >= deadline: + break + to = None if deadline is None else max(0.01, deadline - time.time()) + line = c.read_line(timeout=to) + if line is None: + if deadline: + break + continue + print(line, flush=True) + except KeyboardInterrupt: + pass + finally: + c.close() + +def _isnum(x): + try: + float(x) + return True + except Exception: + return False + +# --------------------------------------------------------------------------- +# main dispatch +# --------------------------------------------------------------------------- +LIFECYCLE = {"spawn": cmd_spawn, "list": cmd_list, "stop": cmd_stop, + "screen": cmd_screen, "monitor": cmd_monitor} + +# convenience aliases -> raw protocol verbs +ALIASES = {"press": None, "tap": None, "hold": None, "buttons": None} + +def main(): + argv = sys.argv[1:] + if not argv or argv[0] in ("-h", "--help", "help") and len(argv) == 1: + print(__doc__) + return + verb = argv[0] + + if verb in LIFECYCLE: + return LIFECYCLE[verb](argv[1:]) + + # everything else is a protocol line sent to the resolved socket + rest = argv[1:] + sock_path = _resolve_sock(rest) # strips --sock if present + + # input convenience: press/tap/hold map to button tokens + if verb in ("press", "tap"): + line = " ".join(rest) # e.g. "a b" + elif verb == "hold": + line = " ".join("+" + t for t in rest) # +left +a + elif verb == "send": + line = " ".join(rest) # raw passthrough + else: + line = " ".join([verb] + rest) # verb + args + + # continue with an optional wait-for-event timeout (positional seconds) + wait = 0.0 + if verb in ("continue", "cont", "c", "run"): + # a trailing numeric arg means "block this long for a stop event" + if rest and _isnum(rest[-1]): + wait = float(rest[-1]) + line = " ".join([verb] + rest[:-1]) + + send_cmd(sock_path, line, wait_event=wait) + +if __name__ == "__main__": + main() diff --git a/src/control.c b/src/control.c new file mode 100644 index 0000000..322646b --- /dev/null +++ b/src/control.c @@ -0,0 +1,591 @@ +#include "control.h" +#include "cpu.h" +#include "render.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <stdarg.h> +#include <ctype.h> +#include <errno.h> +#include <unistd.h> +#include <fcntl.h> +#include <sys/socket.h> +#include <sys/un.h> + +// --------------------------------------------------------------------------- +// Socket-based control + debug channel. See control.h for the protocol summary. +// --------------------------------------------------------------------------- + +#define MAX_CLIENTS 8 +#define MAX_BP 64 +#define MAX_WP 32 +#define LINEBUF 512 +#define READ_CAP 4096 // max bytes per `read` command + +typedef struct { + int fd; + char line[LINEBUF]; + int len; +} Client; + +static bool active = false; +static int listen_fd = -1; +static char sock_path[512]; +static Client clients[MAX_CLIENTS]; +static bool quit_requested = false; + +// ---- debugger execution state ---- +enum { RUN, PAUSED }; +static int run_state = RUN; +static int step_remaining = 0; // synchronous stepping is done inline, but + // a nonzero value also lets run_frame step +static bool ignore_bp_once = false; // don't re-break on the addr we resumed from + +static u16 bp_addr[MAX_BP]; +static bool bp_used[MAX_BP]; + +static u16 wp_addr[MAX_WP]; +static bool wp_used[MAX_WP]; +static u8 wp_last[MAX_WP]; + +// Last reason execution halted ("breakpoint 0x0150", "watch 0xFF44", "step", +// "pause"), so a per-request client that wasn't connected when an async event +// fired can still recover it via the `stopinfo` command. +static char last_stop[64] = "none"; + +// --------------------------------------------------------------------------- +// small helpers +// --------------------------------------------------------------------------- + +static void set_nonblock(int fd) { + int fl = fcntl(fd, F_GETFL, 0); + if (fl >= 0) fcntl(fd, F_SETFL, fl | O_NONBLOCK); +} + +// Parse a decimal or 0x-hex number. On success returns value and *ok=true. +static long parse_num(const char *s, bool *ok) { + if (!s || !*s) { *ok = false; return 0; } + char *end = NULL; + errno = 0; + long v = strtol(s, &end, 0); // base 0: handles 0x.. and decimal + *ok = (errno == 0 && end && *end == '\0'); + return v; +} + +static void reply(int fd, const char *fmt, ...) { + char buf[READ_CAP * 2 + 256]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf) - 2, fmt, ap); + va_end(ap); + if (n < 0) return; + if (n > (int)sizeof(buf) - 2) n = sizeof(buf) - 2; + buf[n++] = '\n'; + // best-effort; ignore short writes / EPIPE + ssize_t w = write(fd, buf, n); + (void)w; +} + +static void broadcast(const char *fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf) - 2, fmt, ap); + va_end(ap); + if (n < 0) return; + if (n > (int)sizeof(buf) - 2) n = sizeof(buf) - 2; + buf[n++] = '\n'; + for (int i = 0; i < MAX_CLIENTS; i++) + if (clients[i].fd >= 0) { + ssize_t w = write(clients[i].fd, buf, n); + (void)w; + } +} + +// Format the CPU/machine context into a single line of key=value pairs. +static int fmt_cpu(GB *gb, char *buf, size_t n) { + CPU *c = &gb->cpu; + return snprintf(buf, n, + "af=0x%04X bc=0x%04X de=0x%04X hl=0x%04X sp=0x%04X pc=0x%04X " + "ime=%d halted=%d cycles=%llu", + c->af, c->bc, c->de, c->hl, c->sp, c->pc, + c->ime ? 1 : 0, c->halted ? 1 : 0, (unsigned long long)gb->cycles); +} + +// --------------------------------------------------------------------------- +// breakpoints / watchpoints +// --------------------------------------------------------------------------- + +static bool bp_hit(u16 pc) { + for (int i = 0; i < MAX_BP; i++) + if (bp_used[i] && bp_addr[i] == pc) return true; + return false; +} + +// Return watchpoint index whose byte changed since last check (and update its +// remembered value), or -1 if none. +static int wp_changed(GB *gb) { + for (int i = 0; i < MAX_WP; i++) { + if (!wp_used[i]) continue; + u8 v = gb_read(gb, wp_addr[i]); + if (v != wp_last[i]) { u8 old = wp_last[i]; wp_last[i] = v; + (void)old; return i; } + } + return -1; +} + +static void enter_paused(void) { run_state = PAUSED; step_remaining = 0; } + +static void set_stop(const char *fmt, ...) { + va_list ap; va_start(ap, fmt); + vsnprintf(last_stop, sizeof(last_stop), fmt, ap); + va_end(ap); +} + +// --------------------------------------------------------------------------- +// synchronous single-stepping (used by the `step` command for an immediate +// reply). Executes up to `n` instructions, stopping early on a breakpoint or +// watchpoint. Writes a reason string ("step"/"breakpoint"/"watch") and, for a +// watch hit, the watched address into *waddr. +// --------------------------------------------------------------------------- +static const char *do_steps(GB *gb, int n, u16 *waddr) { + const char *reason = "step"; + bool first = true; + for (int i = 0; i < n; i++) { + // honor a breakpoint we land on, but never on the very first step off + // the current address (otherwise we could never leave it) + if (!first && bp_hit(gb->cpu.pc)) { + set_stop("breakpoint 0x%04X", gb->cpu.pc); return "breakpoint"; + } + first = false; + cpu_step(gb); + int w = wp_changed(gb); + if (w >= 0) { if (waddr) *waddr = wp_addr[w]; + set_stop("watch 0x%04X", wp_addr[w]); return "watch"; } + } + set_stop("step 0x%04X", gb->cpu.pc); + return reason; +} + +// --------------------------------------------------------------------------- +// command dispatch +// --------------------------------------------------------------------------- + +static void cmd_break_list(int fd) { + char buf[400]; int o = 0; + o += snprintf(buf + o, sizeof(buf) - o, "ok breaks"); + for (int i = 0; i < MAX_BP; i++) + if (bp_used[i]) + o += snprintf(buf + o, sizeof(buf) - o, " #%d=0x%04X", i, bp_addr[i]); + reply(fd, "%s", buf); +} + +static void cmd_watch_list(int fd) { + char buf[400]; int o = 0; + o += snprintf(buf + o, sizeof(buf) - o, "ok watches"); + for (int i = 0; i < MAX_WP; i++) + if (wp_used[i]) + o += snprintf(buf + o, sizeof(buf) - o, " #%d=0x%04X:0x%02X", + i, wp_addr[i], wp_last[i]); + reply(fd, "%s", buf); +} + +// Set a named register/flag. Returns true on success. +static bool set_reg(GB *gb, const char *name, long v) { + CPU *c = &gb->cpu; + u16 w = (u16)v; u8 b = (u8)v; + if (!strcasecmp(name, "a")) { c->a = b; return true; } + if (!strcasecmp(name, "f")) { c->f = b & 0xF0; return true; } + if (!strcasecmp(name, "b")) { c->b = b; return true; } + if (!strcasecmp(name, "c")) { c->c = b; return true; } + if (!strcasecmp(name, "d")) { c->d = b; return true; } + if (!strcasecmp(name, "e")) { c->e = b; return true; } + if (!strcasecmp(name, "h")) { c->h = b; return true; } + if (!strcasecmp(name, "l")) { c->l = b; return true; } + if (!strcasecmp(name, "af")) { c->af = w & 0xFFF0; return true; } + if (!strcasecmp(name, "bc")) { c->bc = w; return true; } + if (!strcasecmp(name, "de")) { c->de = w; return true; } + if (!strcasecmp(name, "hl")) { c->hl = w; return true; } + if (!strcasecmp(name, "sp")) { c->sp = w; return true; } + if (!strcasecmp(name, "pc")) { c->pc = w; return true; } + if (!strcasecmp(name, "ime")) { c->ime = (v != 0); return true; } + return false; +} + +static int hexval(int ch) { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +// Parse a single byte written as hex ("de", "0xDE", "FF", "7"). Memory writes +// default to hex so they round-trip with `read` output. Returns 0..255 or -1. +static int parse_byte_hex(const char *s) { + if (!s || !*s) return -1; + if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s += 2; + int val = 0, digits = 0; + for (; *s; s++) { + int h = hexval((unsigned char)*s); + if (h < 0) return -1; + val = (val << 4) | h; + if (++digits > 2) return -1; + } + return digits ? val : -1; +} + +// Dispatch one command line from client `fd`. `orig` is the untouched line +// (used for the button-command fallback); `line` is a mutable copy. +static void dispatch(GB *gb, int fd, const char *orig, char *line) { + // tokenize (whitespace); keep argv for structured commands + char *argv[68]; + int argc = 0; + for (char *t = strtok(line, " \t\r\n"); t && argc < 68; + t = strtok(NULL, " \t\r\n")) + argv[argc++] = t; + if (argc == 0) return; + + const char *v = argv[0]; + char cbuf[256]; + + if (!strcasecmp(v, "ping")) { reply(fd, "pong"); return; } + if (!strcasecmp(v, "help")) { + reply(fd, "ok commands: ping state stopinfo cpu reg read write step " + "continue pause break watch delete unwatch quit | button tokens " + "(a b start select up down left right, +x -x, name:N, release)"); + return; + } + if (!strcasecmp(v, "quit") || !strcasecmp(v, "exit")) { + quit_requested = true; reply(fd, "ok bye"); return; + } + if (!strcasecmp(v, "state")) { + reply(fd, "ok state %s", run_state == PAUSED ? "paused" : "running"); + return; + } + if (!strcasecmp(v, "stopinfo") || !strcasecmp(v, "laststop") || + !strcasecmp(v, "why")) { + fmt_cpu(gb, cbuf, sizeof(cbuf)); + if (run_state == PAUSED) reply(fd, "ok stop %s %s", last_stop, cbuf); + else reply(fd, "ok running %s", cbuf); + return; + } + if (!strcasecmp(v, "cpu") || !strcasecmp(v, "regs") || + !strcasecmp(v, "context")) { + fmt_cpu(gb, cbuf, sizeof(cbuf)); + reply(fd, "ok cpu %s", cbuf); + return; + } + if (!strcasecmp(v, "reg")) { + bool ok; long val; + if (argc < 3) { reply(fd, "err reg <name> <value>"); return; } + val = parse_num(argv[2], &ok); + if (!ok) { reply(fd, "err bad value"); return; } + if (!set_reg(gb, argv[1], val)) { reply(fd, "err unknown reg '%s'", + argv[1]); return; } + reply(fd, "ok reg %s=0x%04X", argv[1], (unsigned)val); + return; + } + if (!strcasecmp(v, "read") || !strcasecmp(v, "r") || + !strcasecmp(v, "mem")) { + bool ok; long addr, len = 1; + if (argc < 2) { reply(fd, "err read <addr> [len]"); return; } + addr = parse_num(argv[1], &ok); + if (!ok) { reply(fd, "err bad addr"); return; } + if (argc >= 3) { len = parse_num(argv[2], &ok); + if (!ok) { reply(fd, "err bad len"); return; } } + if (len < 1) len = 1; + if (len > READ_CAP) len = READ_CAP; + static char hex[READ_CAP * 2 + 1]; + int o = 0; + for (long i = 0; i < len; i++) { + u8 val = gb_read(gb, (u16)(addr + i)); + static const char *H = "0123456789abcdef"; + hex[o++] = H[val >> 4]; + hex[o++] = H[val & 0xF]; + } + hex[o] = 0; + reply(fd, "ok read 0x%04X %ld %s", (unsigned)addr, len, hex); + return; + } + if (!strcasecmp(v, "write") || !strcasecmp(v, "w")) { + bool ok; + if (argc < 3) { reply(fd, "err write <addr> <byte...|hexstring>"); + return; } + long addr = parse_num(argv[1], &ok); + if (!ok) { reply(fd, "err bad addr"); return; } + int count = 0; + // form 1: a single contiguous even-length hex string "deadbeef" + if (argc == 3) { + const char *s = argv[2]; + size_t sl = strlen(s); + bool allhex = (sl >= 2 && sl % 2 == 0); + for (size_t i = 0; i < sl && allhex; i++) + if (hexval(s[i]) < 0) allhex = false; + if (allhex) { + for (size_t i = 0; i + 1 < sl; i += 2) { + u8 val = (u8)((hexval(s[i]) << 4) | hexval(s[i + 1])); + gb_write(gb, (u16)(addr + count), val); + count++; + } + reply(fd, "ok write 0x%04X %d", (unsigned)addr, count); + return; + } + } + // form 2: space-separated hex bytes ("de ad be ef", 0x-prefix ok) + (void)ok; + for (int i = 2; i < argc; i++) { + int b = parse_byte_hex(argv[i]); + if (b < 0) { reply(fd, "err bad byte '%s'", argv[i]); return; } + gb_write(gb, (u16)(addr + count), (u8)b); + count++; + } + reply(fd, "ok write 0x%04X %d", (unsigned)addr, count); + return; + } + if (!strcasecmp(v, "step") || !strcasecmp(v, "s") || + !strcasecmp(v, "si")) { + bool ok; long n = 1; + if (argc >= 2) { n = parse_num(argv[1], &ok); + if (!ok || n < 1) n = 1; } + ignore_bp_once = true; + u16 waddr = 0; + const char *reason = do_steps(gb, (int)n, &waddr); + enter_paused(); + fmt_cpu(gb, cbuf, sizeof(cbuf)); + if (!strcmp(reason, "watch")) + reply(fd, "ok stop watch 0x%04X %s", waddr, cbuf); + else + reply(fd, "ok stop %s %s", reason, cbuf); + return; + } + if (!strcasecmp(v, "continue") || !strcasecmp(v, "cont") || + !strcasecmp(v, "c") || !strcasecmp(v, "run")) { + run_state = RUN; + step_remaining = 0; + ignore_bp_once = true; // step off current bp/pc before re-checking + reply(fd, "ok running"); + return; + } + if (!strcasecmp(v, "pause") || !strcasecmp(v, "stop") || + !strcasecmp(v, "halt") || !strcasecmp(v, "break!")) { + enter_paused(); + set_stop("pause 0x%04X", gb->cpu.pc); + fmt_cpu(gb, cbuf, sizeof(cbuf)); + reply(fd, "ok paused %s", cbuf); + return; + } + if (!strcasecmp(v, "break") || !strcasecmp(v, "bp") || + !strcasecmp(v, "b")) { + if (argc < 2) { cmd_break_list(fd); return; } + bool ok; long addr = parse_num(argv[1], &ok); + if (!ok) { reply(fd, "err bad addr"); return; } + for (int i = 0; i < MAX_BP; i++) // dedup + if (bp_used[i] && bp_addr[i] == (u16)addr) { + reply(fd, "ok break #%d 0x%04X", i, (unsigned)addr); return; } + for (int i = 0; i < MAX_BP; i++) + if (!bp_used[i]) { bp_used[i] = true; bp_addr[i] = (u16)addr; + reply(fd, "ok break #%d 0x%04X", i, (unsigned)addr); return; } + reply(fd, "err breakpoint table full"); + return; + } + if (!strcasecmp(v, "delete") || !strcasecmp(v, "del") || + !strcasecmp(v, "unbreak") || !strcasecmp(v, "d")) { + if (argc < 2) { reply(fd, "err delete <0xaddr|#idx|all>"); return; } + if (!strcasecmp(argv[1], "all")) { + memset(bp_used, 0, sizeof(bp_used)); + reply(fd, "ok deleted all"); return; + } + if (argv[1][0] == '#') { + int idx = atoi(argv[1] + 1); + if (idx >= 0 && idx < MAX_BP && bp_used[idx]) { + bp_used[idx] = false; reply(fd, "ok deleted #%d", idx); return; } + reply(fd, "err no such breakpoint"); return; + } + bool ok; long addr = parse_num(argv[1], &ok); + if (!ok) { reply(fd, "err bad addr"); return; } + for (int i = 0; i < MAX_BP; i++) + if (bp_used[i] && bp_addr[i] == (u16)addr) { + bp_used[i] = false; + reply(fd, "ok deleted #%d 0x%04X", i, (unsigned)addr); return; } + reply(fd, "err no breakpoint at 0x%04X", (unsigned)addr); + return; + } + if (!strcasecmp(v, "watch") || !strcasecmp(v, "wp")) { + if (argc < 2) { cmd_watch_list(fd); return; } + bool ok; long addr = parse_num(argv[1], &ok); + if (!ok) { reply(fd, "err bad addr"); return; } + for (int i = 0; i < MAX_WP; i++) + if (!wp_used[i]) { + wp_used[i] = true; wp_addr[i] = (u16)addr; + wp_last[i] = gb_read(gb, (u16)addr); + reply(fd, "ok watch #%d 0x%04X:0x%02X", i, (unsigned)addr, + wp_last[i]); + return; + } + reply(fd, "err watchpoint table full"); + return; + } + if (!strcasecmp(v, "unwatch")) { + if (argc < 2) { reply(fd, "err unwatch <0xaddr|#idx|all>"); return; } + if (!strcasecmp(argv[1], "all")) { + memset(wp_used, 0, sizeof(wp_used)); + reply(fd, "ok unwatched all"); return; + } + if (argv[1][0] == '#') { + int idx = atoi(argv[1] + 1); + if (idx >= 0 && idx < MAX_WP && wp_used[idx]) { + wp_used[idx] = false; reply(fd, "ok unwatched #%d", idx); + return; } + reply(fd, "err no such watchpoint"); return; + } + bool ok; long addr = parse_num(argv[1], &ok); + if (!ok) { reply(fd, "err bad addr"); return; } + for (int i = 0; i < MAX_WP; i++) + if (wp_used[i] && wp_addr[i] == (u16)addr) { + wp_used[i] = false; + reply(fd, "ok unwatched 0x%04X", (unsigned)addr); return; } + reply(fd, "err no watchpoint at 0x%04X", (unsigned)addr); + return; + } + + // fallback: treat the whole line as button-command tokens (legacy FIFO + // vocabulary). Reply ok so scripted callers can synchronize. + input_handle_command(orig); + reply(fd, "ok"); +} + +// --------------------------------------------------------------------------- +// connection handling +// --------------------------------------------------------------------------- + +static void drop_client(int i) { + if (clients[i].fd >= 0) { close(clients[i].fd); clients[i].fd = -1; } + clients[i].len = 0; +} + +static void accept_new(void) { + for (;;) { + int cf = accept(listen_fd, NULL, NULL); + if (cf < 0) break; // EAGAIN / no pending + set_nonblock(cf); + int slot = -1; + for (int i = 0; i < MAX_CLIENTS; i++) + if (clients[i].fd < 0) { slot = i; break; } + if (slot < 0) { close(cf); continue; } // table full + clients[slot].fd = cf; + clients[slot].len = 0; + reply(cf, "ok gbc control channel; 'help' for commands"); + } +} + +static void service_client(GB *gb, int i) { + char buf[512]; + for (;;) { + int n = (int)read(clients[i].fd, buf, sizeof(buf)); + if (n == 0) { drop_client(i); return; } // peer closed + if (n < 0) break; // EAGAIN + for (int k = 0; k < n; k++) { + char c = buf[k]; + if (c == '\n' || c == '\r') { + clients[i].line[clients[i].len] = 0; + if (clients[i].len) { + char orig[LINEBUF]; + memcpy(orig, clients[i].line, clients[i].len + 1); + dispatch(gb, clients[i].fd, orig, clients[i].line); + } + clients[i].len = 0; + } else if (clients[i].len < LINEBUF - 1) { + clients[i].line[clients[i].len++] = c; + } + } + } +} + +// --------------------------------------------------------------------------- +// public API +// --------------------------------------------------------------------------- + +void control_open(const char *path) { + for (int i = 0; i < MAX_CLIENTS; i++) clients[i].fd = -1; + + listen_fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (listen_fd < 0) { perror("socket"); return; } + + struct sockaddr_un sa; + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", path); + snprintf(sock_path, sizeof(sock_path), "%s", path); + + unlink(sock_path); // clear a stale socket + if (bind(listen_fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + perror("bind"); close(listen_fd); listen_fd = -1; return; + } + if (listen(listen_fd, 4) < 0) { + perror("listen"); close(listen_fd); listen_fd = -1; + unlink(sock_path); return; + } + set_nonblock(listen_fd); + active = true; +} + +void control_close(void) { + if (!active) return; + for (int i = 0; i < MAX_CLIENTS; i++) drop_client(i); + if (listen_fd >= 0) { close(listen_fd); listen_fd = -1; } + unlink(sock_path); + active = false; +} + +bool control_active(void) { return active; } +bool control_paused(void) { return active && run_state == PAUSED; } + +bool control_poll(GB *gb) { + if (!active) return true; + accept_new(); + for (int i = 0; i < MAX_CLIENTS; i++) + if (clients[i].fd >= 0) service_client(gb, i); + return !quit_requested; +} + +void control_run_frame(GB *gb) { + // Plain full-frame emulation when there's no active debug channel. + if (!active) { + gb->ppu.frame_ready = false; + u64 budget = gb->cycles + 70224 * 2; + while (!gb->ppu.frame_ready && gb->cycles < budget) cpu_step(gb); + return; + } + + // Fully paused (not stepping): freeze the machine entirely. + if (run_state == PAUSED) return; + + gb->ppu.frame_ready = false; + u64 budget = gb->cycles + 70224 * 2; + while (gb->cycles < budget) { + // breakpoint check before executing the next instruction + if (!ignore_bp_once && bp_hit(gb->cpu.pc)) { + enter_paused(); + set_stop("breakpoint 0x%04X", gb->cpu.pc); + char cbuf[256]; fmt_cpu(gb, cbuf, sizeof(cbuf)); + broadcast("event stop breakpoint %s", cbuf); + return; + } + ignore_bp_once = false; + + cpu_step(gb); + + int w = wp_changed(gb); + if (w >= 0) { + enter_paused(); + set_stop("watch 0x%04X", wp_addr[w]); + char cbuf[256]; fmt_cpu(gb, cbuf, sizeof(cbuf)); + broadcast("event stop watch 0x%04X %s", wp_addr[w], cbuf); + return; + } + + if (gb->ppu.frame_ready) return; + } +} diff --git a/src/control.h b/src/control.h new file mode 100644 index 0000000..a625853 --- /dev/null +++ b/src/control.h @@ -0,0 +1,36 @@ +#ifndef GBC_CONTROL_H +#define GBC_CONTROL_H + +#include "gb.h" + +// Socket-based external control + debug channel. +// +// This is a superset of the legacy input FIFO: a Unix-domain stream socket that +// accepts the same button-command vocabulary (see render.h / README) *plus* +// emulator-introspection commands (memory read/write, CPU context, single-step, +// breakpoints, watchpoints, run/pause). Because it is a socket it can reply to +// each command and push asynchronous "event stop ..." lines when execution +// halts at a breakpoint / step boundary / watchpoint. +// +// The protocol is line based (one command per line, '\n' terminated). Replies +// begin with "ok" or "err"; asynchronous notifications begin with "event". + +// Create and start listening on a Unix-domain socket at `path`. Safe to call +// once at startup. On failure prints to stderr and leaves the channel inactive. +void control_open(const char *path); +void control_close(void); +bool control_active(void); + +// Accept new connections and dispatch any pending commands against `gb`. +// Returns false if a client requested the emulator to quit. +bool control_poll(GB *gb); + +// True while the debugger is holding execution (no CPU advancement). +bool control_paused(void); + +// Advance emulation by up to one video frame, honoring the debugger's +// pause / single-step / breakpoint / watchpoint state. When the control channel +// is inactive this simply runs a full frame like the plain emulator loop. +void control_run_frame(GB *gb); + +#endif @@ -1,6 +1,7 @@ #include "gb.h" #include "cpu.h" #include "render.h" +#include "control.h" #include <stdio.h> #include <stdlib.h> #include <string.h> @@ -26,7 +27,10 @@ static void run_frame(GB *gb) { int main(int argc, char **argv) { if (argc < 2) { - fprintf(stderr, "usage: %s <rom> [--test seconds]\n", argv[0]); + fprintf(stderr, "usage: %s <rom> [--test seconds] [--sixel [scale]] " + "[--frameskip n] [--fps n] [--uncapped] [--fifo [path]] " + "[--sock [path]]\n", + argv[0]); return 1; } const char *rom = argv[1]; @@ -35,8 +39,11 @@ int main(int argc, char **argv) { int shot_frames = -1; const char *shot_path = "shot.ppm"; const char *fifo_path = NULL; + const char *sock_path = NULL; double target_fps = 59.73; // emulation speed cap; 0 = uncapped 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 for (int i = 2; i < argc; i++) { if (!strcmp(argv[i], "--test")) { test = true; @@ -47,10 +54,16 @@ int main(int argc, char **argv) { } else if (!strcmp(argv[i], "--fifo")) { fifo_path = (i + 1 < argc && argv[i+1][0] != '-') ? argv[++i] : "/tmp/gbc.fifo"; + } else if (!strcmp(argv[i], "--sock") || !strcmp(argv[i], "--socket")) { + sock_path = (i + 1 < argc && argv[i+1][0] != '-') + ? argv[++i] : "/tmp/gbc.sock"; } else if (!strcmp(argv[i], "--fps")) { if (i + 1 < argc) target_fps = atof(argv[++i]); } else if (!strcmp(argv[i], "--frameskip")) { if (i + 1 < argc) frameskip = atoi(argv[++i]); + } else if (!strcmp(argv[i], "--sixel")) { + sixel = true; + if (i + 1 < argc && argv[i+1][0] != '-') sixel_scale = atoi(argv[++i]); } else if (!strcmp(argv[i], "--uncapped") || !strcmp(argv[i], "--turbo")) { target_fps = 0; } @@ -90,6 +103,14 @@ int main(int argc, char **argv) { fprintf(stderr, "control fifo: echo 'a' > %s (a b start select " "up down left right; +x/-x hold/release; release)\n", fifo_path); } + if (sock_path) { + control_open(sock_path); + if (control_active()) + fprintf(stderr, "control socket: %s (buttons + debug: cpu read " + "write step break watch continue pause; 'help')\n", + sock_path); + } + if (sixel) render_set_sixel(true, sixel_scale); term_init(); // The emulator always advances every GB frame at the paced native rate so @@ -107,13 +128,16 @@ int main(int argc, char **argv) { while (running) { frames++; if (!input_poll(gb)) break; + if (!control_poll(gb)) break; // socket may inject input / debug cmds if (input_take_turbo()) turbo = !turbo; frameskip += input_take_frameskip_delta(); if (frameskip < 0) frameskip = 0; if (frameskip > 30) frameskip = 30; render_set_hud(frameskip, turbo); - run_frame(gb); // always emulate a full frame + // Emulate a full frame, honoring debugger pause/step/breakpoints when + // the control socket is active (otherwise a plain full frame). + control_run_frame(gb); // decide whether to draw this frame bool do_render = (since_render >= frameskip); @@ -148,6 +172,7 @@ int main(int argc, char **argv) { term_restore(); input_close_fifo(); + control_close(); double elapsed = now_sec() - start_time; if (elapsed > 0) fprintf(stderr, "emulated %llu frames (%.1f fps), drew %llu (%.1f fps) " diff --git a/src/render.c b/src/render.c index df39c99..2320f02 100644 --- a/src/render.c +++ b/src/render.c @@ -54,6 +54,31 @@ static volatile sig_atomic_t resized = 0; // set by SIGWINCH static int hud_frameskip = 0; static bool hud_turbo = false; +// ---- sixel output ---- +static bool sixel_enabled = false; +static int sixel_scale = 2; +static char *sixel_buf = NULL; // reusable output buffer +static size_t sixel_buf_cap = 0; + +void render_set_sixel(bool on, int scale) { + if (scale < 1) scale = 1; + if (scale > 6) scale = 6; + sixel_enabled = on; + sixel_scale = scale; + if (on) { + int W2 = SCREEN_W * scale, H2 = SCREEN_H * scale; + int bands = (H2 + 5) / 6; + // worst case: every band emits every palette color across full width + size_t cap = (size_t)bands * 256 * (W2 + 8) + 65536; + if (cap > sixel_buf_cap) { + free(sixel_buf); + sixel_buf = malloc(cap); + sixel_buf_cap = sixel_buf ? cap : 0; + } + } + need_full = true; +} + void render_force_full(void) { need_full = true; } // Update the on-screen HUD values; forces a status-line repaint on change. @@ -66,7 +91,110 @@ void render_set_hud(int frameskip, bool turbo) { } static void on_winch(int s) { (void)s; resized = 1; } +// ---- sixel encoder --------------------------------------------------------- +// The GB framebuffer is truecolor RGB but any given frame uses few distinct +// colors (4 shades on DMG, a modest set on CGB). We build a per-frame palette +// (<=256 entries), quantizing more coarsely only if a frame overflows, then +// emit standard sixel bands. +static int pal_r[256], pal_g[256], pal_b[256]; +static int pal_key[256]; +static u8 idxmap[SCREEN_H][SCREEN_W]; // palette index per source pixel + +static int build_palette(PPU *p) { + static int htab[8192]; + for (int shift = 0; ; shift++) { + memset(htab, -1, sizeof(htab)); + int mask = (0xFF << shift) & 0xFF; + int n = 0, overflow = 0; + for (int y = 0; y < SCREEN_H && !overflow; y++) { + for (int x = 0; x < SCREEN_W; x++) { + int r = p->fb[y][x][0] & mask; + int g = p->fb[y][x][1] & mask; + int b = p->fb[y][x][2] & mask; + int key = (r << 16) | (g << 8) | b; + unsigned h = ((unsigned)key * 2654435761u) & 8191u; + while (htab[h] != -1 && pal_key[htab[h]] != key) + h = (h + 1) & 8191u; + if (htab[h] == -1) { + if (n >= 256) { overflow = 1; break; } + htab[h] = n; + pal_key[n] = key; + pal_r[n] = r; pal_g[n] = g; pal_b[n] = b; + n++; + } + idxmap[y][x] = (u8)htab[h]; + } + } + if (!overflow || shift >= 7) return n; + } +} + +static void render_frame_sixel(GB *gb) { + PPU *p = &gb->ppu; + if (!sixel_buf) return; + int sc = sixel_scale; + int W2 = SCREEN_W * sc, H2 = SCREEN_H * sc; + int ncol = build_palette(p); + char *o = sixel_buf; + + // home cursor, start sixel with 1:1 pixel aspect + raster size + o += sprintf(o, "\x1b[H\x1bPq\"1;1;%d;%d", W2, H2); + for (int i = 0; i < ncol; i++) // palette (0-100 percent) + o += sprintf(o, "#%d;2;%d;%d;%d", i, + (pal_r[i] * 100 + 127) / 255, + (pal_g[i] * 100 + 127) / 255, + (pal_b[i] * 100 + 127) / 255); + + static u8 present[256]; + static u8 sx[SCREEN_W * 6]; // sixel value per column for the active color + for (int by = 0; by < H2; by += 6) { + // which palette colors appear in this 6-row band? + memset(present, 0, ncol); + int rows = (H2 - by < 6) ? (H2 - by) : 6; + for (int k = 0; k < rows; k++) { + int sy = (by + k) / sc; + for (int x = 0; x < SCREEN_W; x++) present[idxmap[sy][x]] = 1; + } + int emitted = 0; + for (int c = 0; c < ncol; c++) { + if (!present[c]) continue; + if (emitted) *o++ = '$'; // graphics CR: back to band start + emitted = 1; + // build this color's 6-bit column values + for (int X2 = 0; X2 < W2; X2++) { + int sx0 = X2 / sc, bits = 0; + for (int k = 0; k < rows; k++) + if (idxmap[(by + k) / sc][sx0] == c) bits |= (1 << k); + sx[X2] = (u8)(0x3f + bits); + } + o += sprintf(o, "#%d", c); + // run-length encode + int X2 = 0; + while (X2 < W2) { + int run = 1; + while (X2 + run < W2 && sx[X2 + run] == sx[X2]) run++; + if (run >= 4) o += sprintf(o, "!%d%c", run, sx[X2]); + else for (int j = 0; j < run; j++) *o++ = sx[X2]; + X2 += run; + } + } + *o++ = '-'; // next band + } + o += sprintf(o, "\x1b\\"); // ST: end sixel + + // status line just below the image + o += sprintf(o, "\r\n\x1b[38;2;150;150;150m %.*s skip:%d%s " + "[f]ast [ ][ ] q:quit\x1b[0m\x1b[K", + 12, gb->cart.title, hud_frameskip, + hud_turbo ? " TURBO" : ""); + + need_full = false; + fwrite(sixel_buf, 1, o - sixel_buf, stdout); + fflush(stdout); +} + void render_frame(GB *gb) { + if (sixel_enabled) { render_frame_sixel(gb); return; } PPU *p = &gb->ppu; char *o = outbuf; @@ -190,6 +318,13 @@ static void handle_line(char *line) { handle_token(t); } +// Public entry: process a line of button tokens from any control channel. +void input_handle_command(const char *line) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "%s", line); + handle_line(tmp); +} + void input_open_fifo(const char *path) { snprintf(fifo_path, sizeof(fifo_path), "%s", path); if (mkfifo(fifo_path, 0666) == 0) fifo_created = true; diff --git a/src/render.h b/src/render.h index 3b6c7e4..03d6c7c 100644 --- a/src/render.h +++ b/src/render.h @@ -8,6 +8,9 @@ void term_restore(void); void render_frame(GB *gb); void render_force_full(void); // force a complete repaint next frame 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); // Poll keyboard + control FIFO, update gb->buttons. False if quit requested. bool input_poll(GB *gb); @@ -16,6 +19,11 @@ bool input_poll(GB *gb); void input_open_fifo(const char *path); void input_close_fifo(void); +// Process one line of button-command tokens (the FIFO/socket input vocabulary: +// a b start select up down left right; name:N; +name / -name; release). Shared +// by the FIFO and the richer socket control channel. +void input_handle_command(const char *line); + // Returns true once for each pending fast-forward (turbo) toggle keypress. bool input_take_turbo(void); // Net frameskip adjustment requested via '[' / ']' since last call. |
