diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index ddb53ac3..8ebed64c 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -50,7 +50,9 @@ Columns: **N** = native, **D** = DuckStation oracle. | `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 | -| `screenshot_hires` | | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | +| `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | +| `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | +| `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | | `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | | `irq_state` | ✓ | ✓ | — | `I_STAT`, `I_MASK` (both), plus chain state on native | @@ -263,9 +265,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**292 commands registered** — 279 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**306 commands registered** — 293 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -47 of 292 have prose above; **245 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +51 of 306 have prose above; **255 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -289,6 +291,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `card_buffer_dump` | ✓ | | | | `card_data_writes` | ✓ | | | | `card_data_writes_reset` | ✓ | | | +| `card_handoff` | ✓ | | | | `card_mgr_clear` | ✓ | | | | `card_mgr_trace` | ✓ | | | | `card_read_summary` | ✓ | | | @@ -379,6 +382,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_timeseries` | ✓ | ✓ | ✓ | | `freeze_check` | ✓ | | | | `game_options` | ✓ | | | +| `geom_correction` | ✓ | | ✓ | | `get_frame` | ✓ | ✓ | ✓ | | `get_quads` | ✓ | | | | `get_registers` | ✓ | ✓ | ✓ | @@ -404,6 +408,11 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `hle_dump` | ✓ | | ✓ | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | +| `input_route_append` | ✓ | | | +| `input_route_clear` | ✓ | | | +| `input_route_start` | ✓ | | | +| `input_route_status` | ✓ | | | +| `input_route_stop` | ✓ | | | | `insn_freeze` | ✓ | | | | `insn_freeze_snapshot` | ✓ | | | | `insn_freeze_status` | ✓ | | | @@ -447,10 +456,16 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `parity_ctl` | ✓ | ✓ | | | `parity_dump` | ✓ | ✓ | | | `pause` | ✓ | | ✓ | +| `pc_probe_arm` | ✓ | | | +| `pc_probe_clear` | ✓ | | | +| `pc_probe_dump` | ✓ | | | +| `pgxp` | ✓ | | | | `phase_hot` | ✓ | | | | `phase_profile` | ✓ | | | | `ping` | ✓ | ✓ | ✓ | | `present_ring` | ✓ | | | +| `present_shot` | ✓ | | ✓ | +| `present_shot_seq` | ✓ | | ✓ | | `press` | ✓ | ✓ | | | `probe_clear` | ✓ | | | | `probe_trace` | ✓ | | | @@ -477,6 +492,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `savestate` | ✓ | | | | `screenshot` | ✓ | ✓ | ✓ | | `screenshot_file` | ✓ | ✓ | ✓ | +| `screenshot_hires` | ✓ | | ✓ | | `set_input` | ✓ | ✓ | ✓ | | `set_snapshot` | ✓ | ✓ | ✓ | | `sio_arm_audit` | ✓ | | | diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 84fea146..d398d501 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8682,17 +8682,37 @@ static void handle_screenshot_hires(int id, const char *json) strncpy(path, "psx_screenshot_hires.png", sizeof(path) - 1); path[sizeof(path) - 1] = '\0'; - uint32_t *argb = (uint32_t *)malloc((size_t)ow * oh * sizeof(uint32_t)); + /* calloc, not malloc: any pixel a resolve declines to touch must be a + * deterministic black, never whatever the allocator handed back. */ + uint32_t *argb = (uint32_t *)calloc((size_t)ow * oh, sizeof(uint32_t)); if (!argb) { send_err(id, "alloc failed"); return; } - int got = gr_render_display_hires(argb, (int)ow, (int)di.display_x, + /* Pitch is BYTES, not pixels: the resolves step rows with + * (uint8_t *)out + row * out_pitch (gpu_sw_renderer.c sw_render_display / + * sw_render_display_hires), and the present path passes it that way + * (main.cpp gr_render_display_hires(sdl_pixel_buf, sw * sizeof(uint32_t))). + * Passing the pixel width here stepped 1/4 of a row per row, which tiled + * the frame 4x across and left the bottom three quarters black. */ + int got = gr_render_display_hires(argb, (int)(ow * sizeof(uint32_t)), + (int)di.display_x, (int)di.display_y, (int)w, (int)h); - if (!got) { - /* No hi-res surface (scale 1, or a backend without one): resolve the - * native display instead and say so, rather than emitting a blank. */ + /* The resolves return the pixel COUNT they wrote, and a partial cover is a + * real case: gr_scale() reports the GL backend's internal scale, but + * sw_render_display_hires falls back to the native resolve when the CPU + * hi-res mirror does not exist (gpu_sw_renderer.c: !g_hr || g_scale <= 1). + * That fills w*h of an ow*oh buffer and still returns non-zero, so testing + * `!got` alone would emit a PNG that is mostly untouched allocation. Demand + * full cover, else redo it honestly at native size. */ + if (got < (int)((size_t)ow * oh)) { + /* No hi-res surface (scale 1, a backend without one, or a partial + * cover): resolve the native display instead and say so, rather than + * emitting a blank. */ scale = 1; ow = w; oh = h; - got = gr_render_display(argb, (int)ow, (int)di.display_x, + got = gr_render_display(argb, (int)(ow * sizeof(uint32_t)), + (int)di.display_x, (int)di.display_y, (int)w, (int)h); - if (!got) { free(argb); send_err(id, "no display surface"); return; } + if (got < (int)((size_t)ow * oh)) { + free(argb); send_err(id, "no display surface"); return; + } } uint8_t *rgb = (uint8_t *)malloc((size_t)ow * oh * 3); @@ -8716,6 +8736,56 @@ static void handle_screenshot_hires(int id, const char *json) "\"height\":%u,\"scale\":%d}", id, path, ow, oh, scale); } +/* present_shot — PNG of the COMPOSED renderer output: the frame after SDL fits + * the display buffer into the logical surface, i.e. at the aspect the player is + * actually looking at. + * + * The three buffer-level captures above (screenshot / screenshot_file / + * screenshot_hires) all resolve the display buffer BEFORE that fit. On a 508x256 + * display in a 4:3 window they answer 508x256 while the window shows 640x480 — + * the same pixels at a different shape. That is correct for faithfulness work + * and WRONG for anything aspect-shaped: a widescreen change alters the GTE + * squash and the present fit, so validating it against a pre-fit buffer measures + * the one stage the change does not touch. + * + * Staged and fulfilled in the present path (see present_shot_request in + * main.cpp), so the ack means "queued", not "written" — the PNG lands on the + * next present. Sample `present_shot_seq` before staging and poll it until the + * counter moves; `wrote` in that reply says whether a file actually landed. + * + * Refused up front on headless (no present surface) and on the Vulkan backend, + * which presents through its own swapchain and has no readback hook — accepting + * there would leave a request nothing can ever fulfil. */ +static void handle_present_shot(int id, const char *json) +{ + extern int present_shot_request(const char *path); + extern int present_shot_seq(void); + char path[512]; + if (!json_get_str(json, "path", path, sizeof(path))) + strncpy(path, "psx_present_shot.png", sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + if (!present_shot_request(path)) { + send_err(id, "present_shot unavailable (headless, or the Vulkan backend " + "which has no present readback)"); + return; + } + send_fmt("{\"id\":%d,\"ok\":true,\"path\":\"%s\",\"staged\":true,\"seq\":%d}", + id, path, present_shot_seq()); +} + +/* present_shot_seq — completion counter for the staged capture above. Sample it + * before present_shot and poll until it changes; the counter advances on every + * completion, success or not, so the poll always terminates. `wrote` reports + * whether that completion actually produced a PNG. */ +static void handle_present_shot_seq(int id, const char *json) +{ + extern int present_shot_seq(void); + extern int present_shot_ok(void); + (void)json; + send_fmt("{\"id\":%d,\"ok\":true,\"seq\":%d,\"wrote\":%d}", + id, present_shot_seq(), present_shot_ok()); +} + /* dump_buffer: dump a raw 512x240 VRAM region starting at display Y = `y` to a * PNG, regardless of what the game currently displays. Used to inspect BOTH * double-buffer halves (y=0 and y=256) coherently in one call to see whether a @@ -13421,6 +13491,8 @@ static const CmdEntry s_commands[] = { { "screenshot", handle_present_screenshot }, { "screenshot_file", handle_screenshot_file }, { "screenshot_hires", handle_screenshot_hires }, + { "present_shot", handle_present_shot }, + { "present_shot_seq", handle_present_shot_seq }, { "display_ring_get", handle_display_ring_get }, { "display_ring_aux", handle_display_ring_aux }, { "display_ring_stats", handle_display_ring_stats }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 8b34a195..ed0fb7ca 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -84,6 +84,7 @@ #include #include #include +#include "png_write.h" /* png_write_rgb — present_shot readback */ #ifndef GL_BGRA #define GL_BGRA 0x80E1 @@ -3961,6 +3962,42 @@ static void gl_swap_with_osd(void) { } } host_osd_present_done(); + /* present_shot (GL backend): the default framebuffer now holds the composed + * frame — display quad fitted to the window, plus OSD — so this is the only + * capture that carries the presented aspect. Buffer-level captures resolve + * before the fit and answer the raw display size instead. Read back before + * the swap; GL rows come out bottom-up, so flip into the PNG. */ + { + extern int present_shot_take(char *out, int n); + extern void present_shot_done(int ok); + char shot_path[512]; + if (present_shot_take(shot_path, (int)sizeof(shot_path))) { + int ww = 0, wh = 0; + int wrote = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww > 0 && wh > 0) { + uint8_t *rows = (uint8_t *)malloc((size_t)ww * wh * 3); + uint8_t *flip = rows ? (uint8_t *)malloc((size_t)ww * wh * 3) : NULL; + if (rows && flip) { + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, ww, wh, GL_RGB, GL_UNSIGNED_BYTE, rows); + for (int y = 0; y < wh; y++) + memcpy(flip + (size_t)y * ww * 3, + rows + (size_t)(wh - 1 - y) * ww * 3, + (size_t)ww * 3); + FILE *pf = fopen(shot_path, "wb"); + if (pf) { + wrote = png_write_rgb(pf, flip, (uint32_t)ww, (uint32_t)wh); + fclose(pf); + } + } + /* free() tolerates NULL, so both exits are covered. */ + free(flip); + free(rows); + } + present_shot_done(wrote); + } + } SDL_GL_SwapWindow(s_win); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 97c3ab5f..80fd8315 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -25,6 +25,7 @@ #include "psx_savestate_menu.h" #include "host_osd.h" #include "host_keymap.h" +#include "png_write.h" /* png_write_rgb — present_shot readback */ #include "overlay_capture.h" #include "overlay_loader.h" #include "autocompile.h" @@ -104,6 +105,7 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #endif #include #include +#include #include #include #include @@ -416,6 +418,7 @@ static bool s_force_present_after_load = false; static int s_sw_hold_valid = 0; static SDL_Rect s_sw_hold_src; static SDL_Rect s_sw_hold_dst; + /* After LOADED: optional freeze probe (PSX_POST_LOAD_PROBE=1). Off by default. */ static int s_post_load_probe_enabled = -1; /* -1 = unread env */ static int s_post_load_probe_left = 0; @@ -1126,6 +1129,7 @@ static int g_hotkey_pad_save_state_menu = 2040;/* select + r1 */ static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ + /* FMV instant-skip via the game's OWN end-of-movie path. Tomba's MDEC player * (FUN_8001efe8) tears a movie down when the streamed frame number reaches that * movie's per-movie total minus 3; writing the current movie's total down to @@ -1611,6 +1615,77 @@ extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } static bool g_gl_active = false; /* GL context live -> GL present path */ static bool g_vk_active = false; /* Vulkan context live -> VK present path */ + +/* present_shot — capture the COMPOSED present surface, i.e. the frame after the + * backend has fitted the display buffer to the window, so the PNG carries the + * aspect the player is actually looking at. + * + * screenshot / screenshot_file / screenshot_hires all resolve the display + * buffer BEFORE that fit: on a 508x256 display in a 4:3 window they answer + * 508x256 while the window shows 640x480. Correct for faithfulness work, wrong + * for anything aspect-shaped — a widescreen change moves the GTE squash and the + * present fit, which is exactly the stage those captures skip. + * + * Staged here and fulfilled by whichever backend owns the present: the SDL + * software path reads back via SDL_RenderReadPixels, the GL path via + * glReadPixels before SwapWindow (gpu_gl_renderer.c). + * + * THREADING: the request is written from a debug-server command handler on the + * emu thread (debug_server_poll safe point), but GL fulfilment can run on the + * frame-interpolation thread (gpu_gl_renderer.c interp_present -> gl_swap_with_osd), + * so the path buffer and the pending flag are mutex-guarded and the counters are + * atomic. present_shot_take() CLAIMS the request under the lock, so two present + * paths can never fulfil the same shot. + * + * A staged shot is OVERWRITTEN by a later request rather than refused (the same + * choice savestate's request_save_inner makes). A present that never arrives — + * a static frame, a backend that stops presenting — therefore cannot wedge the + * command: the next request simply replaces it. */ +static std::mutex s_present_shot_mtx; +static char s_present_shot_path[512]; +static bool s_present_shot_pending = false; +static std::atomic s_present_shot_seq{0}; /* bumps on every completion */ +static std::atomic s_present_shot_ok{0}; /* 1 = last completion wrote a PNG */ + +extern "C" int present_shot_request(const char *path) +{ + if (!path || !*path) return 0; + if (g_headless) return 0; /* no present surface to read back */ + /* Vulkan owns its own swapchain present (see the g_vk_active branch in + * sdl_vblank_present_body) and has no readback hook, so nothing would ever + * fulfil the request. Refuse honestly instead of accepting a shot that can + * never complete — CLAUDE.md rule 15. */ + if (g_vk_active) return 0; + { + std::lock_guard lk(s_present_shot_mtx); + std::snprintf(s_present_shot_path, sizeof(s_present_shot_path), "%s", path); + s_present_shot_pending = true; + } + return 1; +} + +/* Backend hook: atomically claim a staged shot (returns 1 and fills `out`), + * then call present_shot_done(ok) once the PNG is written — or not written. */ +extern "C" int present_shot_take(char *out, int n) +{ + if (!out || n <= 0) return 0; + std::lock_guard lk(s_present_shot_mtx); + if (!s_present_shot_pending) return 0; + std::snprintf(out, (size_t)n, "%s", s_present_shot_path); + s_present_shot_pending = false; /* claimed */ + return 1; +} + +/* ok = 1 only when a PNG actually landed on disk. seq advances either way so a + * client polling it always terminates; ok tells it whether the file exists. */ +extern "C" void present_shot_done(int ok) +{ + s_present_shot_ok.store(ok ? 1 : 0, std::memory_order_release); + s_present_shot_seq.fetch_add(1, std::memory_order_release); +} + +extern "C" int present_shot_seq(void) { return s_present_shot_seq.load(std::memory_order_acquire); } +extern "C" int present_shot_ok(void) { return s_present_shot_ok.load(std::memory_order_acquire); } /* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 * to force the software readout path instead — a diagnostic/fallback that also * keeps CPU VRAM current every frame (so screenshots reflect the screen). */ @@ -6883,6 +6958,66 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { SDL_RenderClear(sdl_renderer); SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); host_osd_draw_sdl(sdl_renderer); + /* Fulfil a staged present_shot here: after RenderCopy + OSD, before + * RenderPresent, the renderer holds exactly the composed frame the player + * sees — including the logical-size aspect fit that every buffer-level + * capture misses. Reading back costs a GPU sync, so it only runs when a + * shot was explicitly requested. */ + char shot_path[512]; + if (present_shot_take(shot_path, (int)sizeof(shot_path))) { + int ow = 0, oh = 0; + uint8_t *packed = NULL; +#if defined(PSX_SDL3) + SDL_Surface *surf = SDL_RenderReadPixels(sdl_renderer, NULL); + if (surf) { + SDL_Surface *conv = SDL_ConvertSurface(surf, SDL_PIXELFORMAT_RGB24); + SDL_DestroySurface(surf); + if (conv) { + ow = conv->w; oh = conv->h; + packed = (uint8_t *)std::malloc((size_t)ow * oh * 3); + if (packed) { + /* SDL rows are pitch-aligned; png_write_rgb wants packed. */ + for (int y = 0; y < oh; y++) + std::memcpy(packed + (size_t)y * ow * 3, + (const uint8_t *)conv->pixels + (size_t)y * conv->pitch, + (size_t)ow * 3); + } + SDL_DestroySurface(conv); + } + } +#else + /* Size the buffer from the renderer's REAL output, not from a + * viewport*scale reconstruction: SDL_RenderReadPixels(NULL) fills the + * current viewport, and deriving that region from + * SDL_RenderGetViewport x SDL_RenderGetScale rounds independently of + * what SDL actually writes. The output size is a hard upper bound, so + * a full-output allocation cannot be overrun whatever SDL picks. */ + int rw = 0, rh = 0; + if (SDL_GetRendererOutputSize(sdl_renderer, &rw, &rh) == 0 && rw > 0 && rh > 0) { + SDL_Rect vp; SDL_RenderGetViewport(sdl_renderer, &vp); + float sx = 1.0f, sy = 1.0f; SDL_RenderGetScale(sdl_renderer, &sx, &sy); + ow = (int)(vp.w * sx); oh = (int)(vp.h * sy); + if (ow <= 0 || ow > rw) ow = rw; + if (oh <= 0 || oh > rh) oh = rh; + packed = (uint8_t *)std::malloc((size_t)rw * rh * 3); /* upper bound */ + if (packed && SDL_RenderReadPixels(sdl_renderer, NULL, + SDL_PIXELFORMAT_RGB24, + packed, ow * 3) != 0) { + std::free(packed); packed = NULL; + } + } +#endif + int wrote = 0; + if (packed && ow > 0 && oh > 0) { + FILE *pf = std::fopen(shot_path, "wb"); + if (pf) { + wrote = png_write_rgb(pf, packed, (uint32_t)ow, (uint32_t)oh); + std::fclose(pf); + } + } + std::free(packed); + present_shot_done(wrote); + } /* §33: remember active rect for resim hold-last (not full 640x512). */ s_sw_hold_src = src; s_sw_hold_dst = dst; diff --git a/tools/debug_client.py b/tools/debug_client.py index b6d264cf..4d57997c 100644 --- a/tools/debug_client.py +++ b/tools/debug_client.py @@ -337,6 +337,29 @@ def build_cmd(args): if len(args) > 1: d["path"] = args[1] return d, pretty_json + elif cmd in ("screenshot_file", "shot_file"): + # Canonical native 15-bit VRAM frame (pre-compositor, pre-stretch). + # Takes a positional path like its two sibling capture commands. + d = {"cmd": "screenshot_file"} + if len(args) > 1: + d["path"] = args[1] + return d, pretty_json + elif cmd in ("present_shot", "shot_present"): + # The composed renderer output -- what the window actually shows, + # including the logical-size aspect fit the buffer captures miss. + # Use this to verify anything aspect-shaped (widescreen, letterbox). + d = {"cmd": "present_shot"} + if len(args) > 1: + d["path"] = args[1] + return d, pretty_json + elif cmd == "present_shot_seq": + return {"cmd": "present_shot_seq"}, pretty_json + elif cmd == "turbo": + if len(args) < 2: + return None, lambda _: "Usage: turbo <0|1>" + return {"cmd": "turbo", "enabled": int(args[1])}, pretty_json + elif cmd == "turbo_state": + return {"cmd": "turbo_state"}, pretty_json elif cmd == "bios_trace": d = {"cmd": "bios_trace"} if len(args) > 1: