diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index 1a4504c4..ef70f599 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -17,10 +17,16 @@ The `debug_client.py` CLI can target either, or `compare` two at once to diff st ```bash python tools/debug_client.py [args] # native (port 4370) +python tools/debug_client.py --port 4380 # psx-beetle python tools/debug_client.py --ds [args] # duckstation (port 4371) python tools/debug_client.py compare # run on both, diff results ``` +Commands without a bespoke CLI mapping pass through generically: extra +args of the form `key=value` become JSON fields (ints when numeric, else +strings), so every server command is reachable, e.g. +`debug_client.py --port 4370 gpu_frame_dump frame=14528 count=65536`. + --- ## Command inventory @@ -31,9 +37,8 @@ Columns: **N** = native, **D** = DuckStation oracle. |---|---|---|---|---| | `ping` / `frame` | ✓ | ✓ | — | Heartbeat + current frame number | | `get_registers` (`regs`) | ✓ | ✓ | — | All 32 GPRs + PC + HI + LO (native also: COP0 SR/Cause/EPC, I_STAT, I_MASK) | -| `read_ram` | ✓ | ✓ | `addr`, `len` | Read bytes from PS1 address space as hex string | +| `read_ram` | ✓ | ✓ | `addr`, `len` | Read bytes from PS1 address space as hex string — up to the full 2 MB in ONE response line. `dump_ram` is an alias (the old chunked multi-line variant is gone: it broke the one-request/one-response protocol and wedged the server) | | `write_ram` | ✓ | ✓ | `addr`, `hex` | Write bytes to PS1 address space | -| `dump_ram` | ✓ | ✓ | `addr`, `len` | Chunked RAM read (up to 64 KB) | | `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | | `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | | `gpu_state` | ✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state | @@ -47,7 +52,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `cdrom_sector_history_clear` | ✓ | | — | Reset the CD-ROM sector history ring | | `watch` | ✓ | ✓ | `addr` | Set byte-level memory watchpoint (fires per-frame on change) | | `unwatch` | ✓ | ✓ | `addr` | Remove memory watchpoint | -| `set_input` | ✓ | ✓ | `buttons` | Override pad1 buttons (PS1 inverted bitmask, 0 = pressed) | +| `set_input` | ✓ | ✓ | `buttons`, optional `frames` | Override pad1 buttons (PS1 inverted bitmask, 0 = pressed). Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames | | `clear_input` | ✓ | ✓ | — | Remove input override | | `turbo` | ✓ | | `enabled` | Enable/disable TCP-controlled frontend turbo for fast-forward validation | | `turbo_state` | ✓ | | — | Query TCP-controlled turbo state | @@ -61,11 +66,11 @@ Columns: **N** = native, **D** = DuckStation oracle. | `frame_timeseries` | ✓ | ✓ | `start`, `end` | Compact timeseries, max 200 frames | | `set_snapshot` | ✓ | ✓ | `slot`, `addr`, `size` | Configure per-frame RAM snapshot region (slots 0-3) | | `get_snapshots` | ✓ | ✓ | — | Show snapshot config | -| `screenshot` | ✓ | ✓ | `path` | Save display to PNG/file; native row dumps report `rgb555` or `rgb888` to match display depth | +| `screenshot` | ✓ | ✓ | `path` (optional) | Write a 24-bit BMP of the current display to `path` (default `psx_screenshot.bmp` in the runtime cwd); single metadata response `{path,width,height}`. `screenshot_file` is an alias; the old inline-hex-row `screenshot` is gone (it streamed h+1 response lines per request and poisoned the connection) | | `first_failure` | ✓ | | — | Find first divergence point between runs (native-side tracking) | | `read_frame_ram` | ✓ | | `addr`, `len`, `frame` | Read RAM **as of a specific frame** (from ring buffer) | | `wtrace_range` | ✓ | | `lo`, `hi` | Set RAM-write trace range (ring of 1024 writes with RA) | -| `wtrace_dump` | ✓ | | — | Dump RAM-write trace entries as JSON | +| `wtrace_dump` | ✓ | beetle | optional `addr_lo`, `addr_hi`, `count`, `newest` | Dump RAM-write trace entries as JSON. The address filter is applied server-side over the FULL ring before the emit cap — always pass it when hunting a specific buffer, otherwise you only see the oldest `count` entries of the whole ring | | `wtrace_clear` | ✓ | | — | Reset the trace ring | | `pc_break` | | ✓² | `addr` | DS execute breakpoint, state captured on hit (via `pc_hit_last`) | | `pc_unbreak` | | ✓² | `addr` | Remove an execute breakpoint | diff --git a/runtime/include/crash_trace.h b/runtime/include/crash_trace.h index fd5763af..d98ccddb 100644 --- a/runtime/include/crash_trace.h +++ b/runtime/include/crash_trace.h @@ -16,6 +16,11 @@ void psx_crash_trace_install_handlers(void); * NULL. Writes to psx_last_run_report.json (overwrite). */ void psx_crash_trace_dump(const char *reason, void *seh_info); +/* Tag a deliberate exit() so the atexit report can attribute it + * (e.g. "tcp_quit", "sdl_window_close"). An "atexit" report whose + * exit_origin is still "unknown" means an untagged exit fired. */ +void psx_crash_trace_set_exit_origin(const char *origin); + #ifdef __cplusplus } #endif diff --git a/runtime/src/beetle_debug_server.c b/runtime/src/beetle_debug_server.c index edb89f98..94a53500 100644 --- a/runtime/src/beetle_debug_server.c +++ b/runtime/src/beetle_debug_server.c @@ -230,23 +230,31 @@ static void h_read_ram(int id, const char *json) { uint32_t addr = hex_to_u32(addr_s); int len = json_get_int(json, "len", 16); if (len < 1) len = 1; - if (len > 65536) len = 65536; + /* Match the native server: whole 2 MB RAM in one response if asked. */ + if (len > 0x200000) len = 0x200000; uint8_t *buf = (uint8_t*)malloc(len); if (!buf) { send_err(id, "alloc"); return; } for (int i = 0; i < len; i++) buf[i] = beetle_read_byte((addr + i) & 0x1FFFFFFFu); - char *hex = (char*)malloc(len * 2 + 1); - if (!hex) { free(buf); send_err(id, "alloc"); return; } - static const char H[] = "0123456789ABCDEF"; + /* Build the whole response line on the heap: send_fmt's 128 KB static + * buffer would silently truncate a large read into broken JSON. */ + size_t env = 96; + char *out = (char*)malloc((size_t)len * 2 + env); + if (!out) { free(buf); send_err(id, "alloc"); return; } + int hdr = snprintf(out, env, + "{\"id\":%d,\"ok\":true,\"addr\":\"0x%08X\",\"len\":%d,\"hex\":\"", + id, addr, len); + char *hex = out + hdr; + static const char H[] = "0123456789abcdef"; for (int i = 0; i < len; i++) { hex[i*2] = H[(buf[i] >> 4) & 0xF]; hex[i*2+1] = H[buf[i] & 0xF]; } - hex[len*2] = 0; - send_fmt("{\"id\":%d,\"ok\":true,\"addr\":\"0x%08X\",\"len\":%d,\"hex\":\"%s\"}\n", - id, addr, len, hex); - free(hex); free(buf); + char *tail = hex + (size_t)len * 2; + memcpy(tail, "\"}\n", 3); + send_raw(out, (int)(hdr + (size_t)len * 2 + 3)); + free(out); free(buf); } static void h_press(int id, const char *json) { @@ -264,7 +272,8 @@ static void h_set_input(int id, const char *json) { send_err(id, "missing buttons"); return; } s_input_override = (int)hex_to_u32(val); - s_input_frames = json_get_int(json, "frames", 1); + /* 0 = hold until clear_input — matches psx-runtime semantics. */ + s_input_frames = json_get_int(json, "frames", 0); send_ok(id); } @@ -328,7 +337,7 @@ static void h_screenshot_file(int id, const char *json) { } free(row); fclose(f); - send_fmt("{\"id\":%d,\"ok\":true,\"path\":\"%s\",\"w\":%u,\"h\":%u}\n", + send_fmt("{\"id\":%d,\"ok\":true,\"path\":\"%s\",\"width\":%u,\"height\":%u}\n", id, path, w, h); } @@ -548,18 +557,30 @@ static void h_wtrace_dump(int id, const char *json) { int cap = beetle_wtrace_capacity(); uint32_t avail = (total < (uint64_t)cap) ? (uint32_t)total : (uint32_t)cap; - send_fmt("{\"id\":%d,\"ok\":true,\"total\":%llu,\"available\":%u,\"entries\":[", + /* Heap-build the whole response and send via send_raw — the per-entry + * send_fmt path drops chunks on multi-MB responses (same failure mode + * as the old read_ram truncation). ~400 bytes/entry worst case. */ + size_t out_cap = 256 + (size_t)got * 400u; + char *out = (char *)malloc(out_cap); + if (!out) { + free(seqs); free(addrs); free(vals); free(pcs); free(ras); free(sps); + free(v0s); free(v1s); free(a0s); free(a1s); free(a2s); free(a3s); + free(t0s); free(t1s); free(frames); free(slots); free(sizes); + send_err(id, "alloc"); return; + } + size_t pos = (size_t)snprintf(out, out_cap, + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"available\":%u,\"entries\":[", id, (unsigned long long)total, avail); uint32_t emitted = 0; - for (uint32_t i = 0; i < got; i++) { + for (uint32_t i = 0; i < got && pos < out_cap - 512; i++) { uint32_t phys = addrs[i] & 0x1FFFFFFFu; if (phys < flo || phys >= fhi) continue; - if (emitted > 0) send_fmt(","); /* Field names match runtime's handle_wtrace_dump exactly: addr, * new, ra, pc, cpu_pc, sp, v0/v1/a0..a3/t0/t1, frame, w. * Beetle-only extras: slot. Runtime-only fields (old, func) are * absent — tools must use .get(). */ - send_fmt("{\"seq\":%llu,\"addr\":\"0x%08X\"," + pos += (size_t)snprintf(out + pos, out_cap - pos, + "%s{\"seq\":%llu,\"addr\":\"0x%08X\"," "\"new\":\"0x%08X\",\"ra\":\"0x%08X\"," "\"pc\":\"0x%08X\",\"cpu_pc\":\"0x%08X\",\"sp\":\"0x%08X\"," "\"v0\":\"0x%08X\",\"v1\":\"0x%08X\"," @@ -567,6 +588,7 @@ static void h_wtrace_dump(int id, const char *json) { "\"a2\":\"0x%08X\",\"a3\":\"0x%08X\"," "\"t0\":\"0x%08X\",\"t1\":\"0x%08X\"," "\"frame\":%u,\"w\":%u,\"slot\":%u}", + (emitted == 0) ? "" : ",", (unsigned long long)seqs[i], addrs[i], vals[i], ras[i], pcs[i], pcs[i], sps[i], @@ -576,7 +598,9 @@ static void h_wtrace_dump(int id, const char *json) { frames[i], (unsigned)sizes[i], (unsigned)slots[i]); emitted++; } - send_fmt("],\"emitted\":%u}\n", emitted); + pos += (size_t)snprintf(out + pos, out_cap - pos, "],\"emitted\":%u}\n", emitted); + send_raw(out, (int)pos); + free(out); free(seqs); free(addrs); free(vals); free(pcs); free(ras); free(sps); free(v0s); free(v1s); free(a0s); free(a1s); free(a2s); free(a3s); @@ -1054,11 +1078,13 @@ typedef struct { const char *name; cmd_handler handler; } CmdEntry; static const CmdEntry CMDS[] = { { "ping", h_ping }, { "read_ram", h_read_ram }, + { "dump_ram", h_read_ram }, /* alias, parity with native */ { "press", h_press }, { "set_input", h_set_input }, { "clear_input", h_clear_input }, { "pad_status", h_pad_status }, - { "screenshot_file", h_screenshot_file }, + { "screenshot", h_screenshot_file }, + { "screenshot_file", h_screenshot_file }, /* alias */ { "sio_trace_reset", h_sio_trace_reset }, { "sio_trace", h_sio_trace }, { "sio_write_window", h_sio_write_window }, @@ -1221,8 +1247,11 @@ void beetle_debug_server_shutdown(void) { } int beetle_debug_server_get_input_override(void) { + int cur = s_input_override; + /* s_input_frames == 0 means hold until clear_input (psx-runtime + * semantics). A finite count delivers exactly that many frames. */ if (s_input_override >= 0 && s_input_frames > 0) { if (--s_input_frames == 0) s_input_override = -1; } - return s_input_override; + return cur; } diff --git a/runtime/src/crash_trace.c b/runtime/src/crash_trace.c index 87133aaa..bc2689ed 100644 --- a/runtime/src/crash_trace.c +++ b/runtime/src/crash_trace.c @@ -90,6 +90,18 @@ static void hex32(char *out, uint32_t v) { snprintf(out, 16, "\"0x%08X\"", v); } +/* ── Exit origin ─────────────────────────────────────────────────────── */ + +/* Deliberate exit() callers tag themselves here so an "atexit" report can + * distinguish a TCP quit from an SDL window close from an unexplained + * exit. "unknown" in a report means NOBODY tagged — main returned or an + * untagged exit() fired; that is a finding, not noise. */ +static const char *s_exit_origin = "unknown"; + +void psx_crash_trace_set_exit_origin(const char *origin) { + if (origin) s_exit_origin = origin; +} + /* ── Main entry ──────────────────────────────────────────────────────── */ void psx_crash_trace_dump(const char *reason, void *seh_info) { @@ -108,11 +120,13 @@ void psx_crash_trace_dump(const char *reason, void *seh_info) { append_fmt(buf, sizeof(buf), &pos, "{\n" " \"reason\": \"%s\",\n" + " \"exit_origin\": \"%s\",\n" " \"timestamp\": \"%s\",\n" " \"frame\": %llu,\n" " \"last_func_addr\": \"0x%08X\",\n" " \"last_store_pc\": \"0x%08X\",\n", reason ? reason : "(unknown)", + s_exit_origin, ts, (unsigned long long)s_frame_count, g_debug_current_func_addr, diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 9810f1b0..4f023862 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -24,6 +24,7 @@ #include "dirty_ram_interp.h" #include "card_read_summary.h" #include "card_data_writes.h" +#include "crash_trace.h" #include #include @@ -3277,22 +3278,42 @@ static uint32_t hex_to_u32(const char *s) /* ---- Send helpers ---- */ -static void send_all_blocking(sock_t sock, const char *data, size_t len) +/* Bounded blocking send. Returns 0 on success, -1 on failure/timeout. + * + * The server is pumped on the main thread, so an unbounded send to a + * client that has stopped draining stalls the emulator until the + * starvation watchdog (4 s) kills the whole process. Each chunk gets a + * 2 s send timeout and a watchdog heartbeat, so a healthy-but-slow + * client can take as long as it likes (progress = heartbeats), while a + * wedged client costs at most one timeout and then loses its + * connection — never the runtime. */ +static int send_all_blocking(sock_t sock, const char *data, size_t len) { + int ok = 0; #ifdef _WIN32 u_long mode = 0; ioctlsocket(sock, FIONBIO, &mode); + DWORD tmo = 2000; + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (const char *)&tmo, sizeof(tmo)); #else int flags = fcntl(sock, F_GETFL, 0); fcntl(sock, F_SETFL, flags & ~O_NONBLOCK); + struct timeval tmo = { 2, 0 }; + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tmo, sizeof(tmo)); #endif - /* send() takes int on Win32; chunk so we can transmit > 2 GB payloads. */ size_t sent = 0; while (sent < len) { size_t want = len - sent; - if (want > (1u << 30)) want = (1u << 30); + if (want > (1u << 20)) want = (1u << 20); int n = send(sock, data + sent, (int)want, 0); - if (n > 0) { sent += (size_t)n; continue; } + if (n > 0) { + sent += (size_t)n; + /* Legitimate debug traffic in flight — not starvation. */ + extern void starvation_watchdog_heartbeat(void); + starvation_watchdog_heartbeat(); + continue; + } + ok = -1; break; } #ifdef _WIN32 @@ -3301,14 +3322,20 @@ static void send_all_blocking(sock_t sock, const char *data, size_t len) #else fcntl(sock, F_SETFL, flags); #endif + return ok; } void debug_server_send_line(const char *json) { if (s_client == SOCK_INVALID) return; size_t len = strlen(json); - send_all_blocking(s_client, json, len); - send_all_blocking(s_client, "\n", 1); + if (send_all_blocking(s_client, json, len) != 0 || + send_all_blocking(s_client, "\n", 1) != 0) { + /* Client stopped draining — drop the connection, keep the + * runtime. The next poll() accepts a fresh client. */ + sock_close(s_client); + s_client = SOCK_INVALID; + } } void debug_server_send_fmt(const char *fmt, ...) @@ -3434,37 +3461,26 @@ static void handle_read_ram(int id, const char *json) "{\"id\":%d,\"ok\":true,\"addr\":\"0x%08X\",\"len\":%d,\"hex\":\"", id, addr, len); char *hex = out + hdr; - for (int i = 0; i < len; i++) - snprintf(hex + (size_t)i * 2, 3, "%02x", psx_read_byte(addr + (uint32_t)i)); + /* Nibble-table encode: snprintf per byte costs seconds for a 2 MB + * read, which stalls the main-thread-pumped server (and the SDL + * event loop) long enough to look like a wedge. */ + static const char H[] = "0123456789abcdef"; + for (int i = 0; i < len; i++) { + uint8_t b = psx_read_byte(addr + (uint32_t)i); + hex[(size_t)i * 2] = H[b >> 4]; + hex[(size_t)i * 2 + 1] = H[b & 0xF]; + } char *tail = hex + (size_t)len * 2; memcpy(tail, "\"}", 3); debug_server_send_line(out); free(out); } -static void handle_dump_ram(int id, const char *json) -{ - char addr_str[32]; - if (!json_get_str(json, "addr", addr_str, sizeof(addr_str))) { - send_err(id, "missing addr"); return; - } - uint32_t addr = hex_to_u32(addr_str); - int len = json_get_int(json, "len", 256); - if (len < 1) len = 1; - if (len > 4096) len = 4096; - - int offset = 0; - while (offset < len) { - int chunk = len - offset; - if (chunk > 256) chunk = 256; - char hex[513]; - for (int i = 0; i < chunk; i++) - snprintf(hex + i * 2, 3, "%02x", psx_read_byte(addr + offset + i)); - send_fmt("{\"id\":%d,\"ok\":true,\"addr\":\"0x%08X\",\"offset\":%d,\"len\":%d,\"hex\":\"%s\"}", - id, addr + offset, offset, chunk, hex); - offset += chunk; - } -} +/* "dump_ram" is an alias of "read_ram". The old implementation answered a + * single request with one response line per 256-byte chunk; any client that + * follows the one-request/one-response protocol left the extra lines unread, + * the socket send buffer filled, the main-thread-pumped server blocked, and + * the freeze watchdog killed the process. One request, one response. */ static void handle_write_ram(int id, const char *json) { @@ -5481,9 +5497,14 @@ static void handle_get_snapshots(int id, const char *json) s_snapshot_addrs[3], s_snapshot_active[3]); } +/* Unified screenshot: writes a 24-bit BMP of the current display to "path" + * (default psx_screenshot.bmp in the runtime cwd) and answers with a single + * metadata line. Registered as both "screenshot" and "screenshot_file"; + * the old "screenshot" inline-hex-row variant streamed h+1 response lines + * per request, which violated the one-request/one-response protocol and + * poisoned every client connection that used it. */ static void handle_screenshot_file(int id, const char *json) { - (void)json; GpuDisplayInfo di; gpu_get_display_info(&di); if (di.disabled || di.width == 0 || di.height == 0) { @@ -5493,7 +5514,10 @@ static void handle_screenshot_file(int id, const char *json) uint32_t w = di.width; if (w > 640) w = 640; uint32_t h = di.height; if (h > 512) h = 512; - const char *path = "psx_screenshot.bmp"; + char path[512]; + if (!json_get_str(json, "path", path, sizeof(path))) + strncpy(path, "psx_screenshot.bmp", sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; FILE *f = fopen(path, "wb"); if (!f) { send_err(id, "cannot open file"); return; } @@ -5541,65 +5565,29 @@ static void handle_screenshot_file(int id, const char *json) id, path, w, h); } -static void handle_screenshot(int id, const char *json) -{ - (void)json; - /* Read display area from GPU and encode as display-format hex rows. */ - GpuDisplayInfo di; - gpu_get_display_info(&di); - - if (di.disabled || di.width == 0 || di.height == 0) { - send_err(id, "display disabled"); return; - } - - uint32_t w = di.width; - uint32_t h = di.height; - if (w > 640) w = 640; - if (h > 512) h = 512; - - /* Send metadata first */ - send_fmt("{\"id\":%d,\"ok\":true,\"width\":%u,\"height\":%u,\"format\":\"%s\"}", - id, w, h, di.depth24 ? "rgb888" : "rgb555"); - - /* Send rows as hex lines */ - uint32_t chars_per_pixel = di.depth24 ? 6u : 4u; - char *hex = (char *)malloc((size_t)w * chars_per_pixel + 32u); - if (!hex) return; - - for (uint32_t y = 0; y < h; y++) { - for (uint32_t x = 0; x < w; x++) { - if (di.depth24) { - uint8_t r, g, b; - gpu_display_pixel_rgb(&di, x, y, &r, &g, &b); - snprintf(hex + x * 6, 7, "%02x%02x%02x", r, g, b); - } else { - uint16_t pixel = gpu_vram_peek((int)(di.display_x + x), (int)(di.display_y + y)); - snprintf(hex + x * 4, 5, "%04x", pixel); - } - } - send_fmt("{\"row\":%u,\"hex\":\"%s\"}", y, hex); - } - free(hex); -} - static void handle_vram_peek(int id, const char *json) { int x = json_get_int(json, "x", 0); int y = json_get_int(json, "y", 0); int w = json_get_int(json, "w", 8); int h = json_get_int(json, "h", 1); - if (w > 64) w = 64; - if (h > 64) h = 64; - char hex[64*64*4+1]; + if (w < 1) w = 1; + if (h < 1) h = 1; + if (w > 128) w = 128; + if (h > 128) h = 128; + size_t hex_len = (size_t)w * h * 4 + 1; + char *hex = (char *)malloc(hex_len); + if (!hex) { send_err(id, "alloc failed"); return; } int pos = 0; for (int row = 0; row < h; row++) { for (int col = 0; col < w; col++) { uint16_t p = gpu_vram_peek(x + col, y + row); - pos += snprintf(hex + pos, sizeof(hex) - pos, "%04x", p); + pos += snprintf(hex + pos, hex_len - pos, "%04x", p); } } send_fmt("{\"id\":%d,\"ok\":true,\"x\":%d,\"y\":%d,\"w\":%d,\"h\":%d,\"hex\":\"%s\"}", id, x, y, w, h, hex); + free(hex); } /* ---- Write trace: hook + handlers (Tier 1 reverse debugger) ---- */ @@ -6957,6 +6945,7 @@ static void handle_quit(int id, const char *json) { (void)json; send_ok(id); + psx_crash_trace_set_exit_origin("tcp_quit"); debug_server_shutdown(); exit(0); } @@ -8113,7 +8102,7 @@ static const CmdEntry s_commands[] = { { "frame", handle_frame }, { "get_registers", handle_get_registers }, { "read_ram", handle_read_ram }, - { "dump_ram", handle_dump_ram }, + { "dump_ram", handle_read_ram }, /* alias: one request, one response */ { "write_ram", handle_write_ram }, { "gpu_state", handle_gpu_state }, { "mem_words", handle_mem_words }, @@ -8245,8 +8234,8 @@ static const CmdEntry s_commands[] = { { "read_frame_ram", handle_read_frame_ram }, { "set_snapshot", handle_set_snapshot }, { "get_snapshots", handle_get_snapshots }, - { "screenshot", handle_screenshot }, - { "screenshot_file", handle_screenshot_file }, + { "screenshot", handle_screenshot_file }, + { "screenshot_file", handle_screenshot_file }, /* alias */ { "gpu_opcodes", handle_gpu_opcodes }, { "gpu_ring_stats", handle_gpu_ring_stats }, { "gpu_frame_dump", handle_gpu_frame_dump }, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0ad90501..e684f557 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -965,6 +965,7 @@ static void sdl_vblank_present(void) { SDL_Event ev; while (SDL_PollEvent(&ev)) { if (ev.type == SDL_QUIT) { + psx_crash_trace_set_exit_origin("sdl_window_close"); shutdown_runtime(); std::exit(0); } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { diff --git a/tools/debug_client.py b/tools/debug_client.py index 0745b449..ad10493e 100644 --- a/tools/debug_client.py +++ b/tools/debug_client.py @@ -59,6 +59,7 @@ REPL commands: """ import json +import re import socket import sys import argparse @@ -89,35 +90,54 @@ def connect(host=DEFAULT_HOST, port=NATIVE_PORT, timeout=10.0): def send_cmd(sock, cmd_dict): line = json.dumps(cmd_dict) + "\n" sock.sendall(line.encode()) - buf = b"" - # Server may emit one large JSON object across many TCP chunks, with raw - # newlines inside arrays. Track brace/bracket balance (string-aware) and - # parse once balance returns to zero. - depth_curly = 0 - depth_sq = 0 + # One complete JSON object = one response. Track brace balance + # (string-aware) with a persistent state machine and fast-skips so a + # multi-megabyte response costs O(n), not O(n^2) — the old per-byte + # loop took minutes on a 2 MB read_ram, the server's blocking send + # backed up, and the runtime's starvation watchdog killed the process. + buf = bytearray() + pos = 0 + depth = 0 in_str = False esc = False started = False while True: - chunk = sock.recv(65536) + chunk = sock.recv(1 << 20) if not chunk: break - for b in chunk: - buf += bytes([b]) - c = chr(b) + buf.extend(chunk) + n = len(buf) + while pos < n: if in_str: - if esc: esc = False - elif c == "\\": esc = True - elif c == '"': in_str = False + if esc: + esc = False + pos += 1 + continue + # Fast-skip to the next quote or backslash (C-speed find; + # hex payloads are one giant string with neither). + q = buf.find(b'"', pos) + bs = buf.find(b"\\", pos) + if q == -1 and bs == -1: + pos = n + break + if bs != -1 and (q == -1 or bs < q): + esc = True + pos = bs + 1 + continue + in_str = False + pos = q + 1 continue - if c == '"': in_str = True - elif c == "{": depth_curly += 1; started = True - elif c == "}": depth_curly -= 1 - elif c == "[": depth_sq += 1 - elif c == "]": depth_sq -= 1 - if started and depth_curly == 0 and depth_sq == 0: - # Message complete. - return json.loads(buf.decode().strip()) + c = buf[pos] + if c == 0x22: # '"' + in_str = True + elif c == 0x7B: # '{' + depth += 1 + started = True + elif c == 0x7D: # '}' + depth -= 1 + if started and depth == 0: + return json.loads(buf[:pos + 1].decode()) + pos += 1 return json.loads(buf.decode().strip()) @@ -378,8 +398,20 @@ def build_cmd(args): d["pc_hi"] = args[3] return d, pretty_json else: - # Pass through as raw command - return {"cmd": cmd}, pretty_json + # Pass through as raw command. Extra args of the form key=value + # become JSON fields (ints when numeric, else strings), so every + # server command is reachable without a bespoke CLI mapping. + d = {"cmd": cmd} + for a in args[1:]: + key, sep, val = a.partition("=") + if not sep: + return None, lambda _, a=a: ( + f"Unrecognized arg '{a}' for raw command; use key=value") + if re.fullmatch(r"-?\d+", val): + d[key] = int(val) + else: + d[key] = val + return d, pretty_json # ---------------------------------------------------------------------------