tooling: unify dump/screenshot surface, bound TCP sends, fix beetle input + big-dump truncation

- debug_server.c: dump_ram = alias of read_ram (one request, one response,
  up to full 2 MB); screenshot = screenshot_file (24-bit BMP, optional
  "path", default psx_screenshot.bmp); vram_peek cap 64->128; read_ram hex
  encode via nibble table (was 2M snprintf calls stalling the main thread);
  send_all_blocking bounded (1 MB chunks, SO_SNDTIMEO 2s, heartbeats the
  starvation watchdog per chunk) and drops the client on send failure
  instead of letting the watchdog kill the runtime; handle_quit tags exit
  origin.
- beetle_debug_server.c: read_ram cap 64 KB->2 MB via heap+send_raw;
  dump_ram/screenshot aliases; screenshot w/h->width/height parity;
  set_input now holds until clear_input (frames param optional) matching
  psx-runtime semantics — the old default expired after 1 frame and the
  off-by-one in the getter cleared the override before it was ever read,
  so scripted beetle navigation silently did nothing; wtrace_dump response
  heap-built + send_raw (per-entry send_fmt dropped chunks on multi-MB
  responses, producing broken JSON).
- crash_trace: new psx_crash_trace_set_exit_origin(); report gains
  exit_origin (tcp_quit / sdl_window_close / unknown).
- debug_client.py: O(n) string-aware brace parser (old per-byte loop was
  O(n^2) — minutes on a 2 MB response, which starved the server's send and
  got the runtime killed); generic key=value passthrough for any server
  command without a bespoke CLI mapping.
- TCP_COMMANDS.md updated for all of the above.

Verified: 2 MB read_ram round-trip 0.75 s; scripted beetle navigation
reaches the memcard file-select; wtrace_dump returns valid JSON at 21 MB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-10 07:49:35 -07:00
parent 035a9fa968
commit cd32149a08
7 changed files with 201 additions and 126 deletions
+10 -5
View File
@@ -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 <cmd> [args] # native (port 4370)
python tools/debug_client.py --port 4380 <cmd> # psx-beetle
python tools/debug_client.py --ds <cmd> [args] # duckstation (port 4371)
python tools/debug_client.py compare <cmd> # 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 |
+5
View File
@@ -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
+46 -17
View File
@@ -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;
}
+14
View File
@@ -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,
+70 -81
View File
@@ -24,6 +24,7 @@
#include "dirty_ram_interp.h"
#include "card_read_summary.h"
#include "card_data_writes.h"
#include "crash_trace.h"
#include <stdio.h>
#include <stdlib.h>
@@ -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 },
+1
View File
@@ -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) {
+55 -23
View File
@@ -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
# ---------------------------------------------------------------------------