mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
Fix screenshot_hires pitch; add present_shot for the composed frame
screenshot_hires passed the pixel width as out_pitch, but the resolves (sw_render_display / sw_render_display_hires) step rows in BYTES, as the present path does (main.cpp: sw * sizeof(uint32_t)). Each row advanced a quarter of a row, tiling the frame 4x across and leaving the bottom three quarters black. Verified on WipEout 3: content rows 65/256 -> 256/256. The same handler could also emit untouched allocation: 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, filling w*h of an ow*oh buffer while still returning non-zero. The buffer is now calloc'd and the resolve must report FULL cover or the capture redoes itself honestly at native size. Add present_shot / present_shot_seq: a capture of the COMPOSED present surface. Every existing capture resolves the display buffer BEFORE the backend fits it to the window, so on a 508x256 display in a 4:3 window they answer 508x256 while the player sees 640x480. That is correct for faithfulness work but wrong for anything aspect-shaped -- a widescreen change moves the GTE squash and the present fit, which is exactly the stage those captures skip. Fulfilled by whichever backend owns the present: SDL_RenderReadPixels on the software path, glReadPixels before SwapWindow on GL (with the bottom-up flip). Notes on the contract: - GL fulfilment can run on the frame-interpolation thread (interp_present -> gl_swap_with_osd), not just the emu thread, so the path buffer and pending flag are mutex-guarded and the counters are atomic. present_shot_take() claims the request under the lock, so two present paths cannot fulfil the same shot. - A staged shot is OVERWRITTEN by a later request rather than refused, as savestate's request_save_inner does. A present that never arrives therefore cannot wedge the command. - Vulkan presents through its own swapchain and has no readback hook, so the request is refused there instead of being accepted and never fulfilled (CLAUDE.md rule 15). - present_shot_seq advances on success AND failure so a polling client always terminates; its `wrote` field reports whether a PNG landed. - The SDL2 branch sizes its buffer from SDL_GetRendererOutputSize rather than a viewport*scale reconstruction, which could be smaller than the region SDL_RenderReadPixels fills. Also: debug_client gains positional-path screenshot_file/present_shot and turbo/turbo_state mappings (they previously fell through to the raw key=value handler); TCP_COMMANDS.md marks screenshot_hires as native (it is registered on the native server and the blank N column read as Beetle-only), documents the two new commands, and the generated index is regenerated via tools/gen_tcp_commands.py. Tested on WipEout 3 SE (PAL, OpenGL, RelWithDebInfo + PSX_DEBUG_TOOLS=ON): capture matches the on-screen window at 1280x960; seq/wrote reports 1 on success and 0 for an unwritable path; back-to-back staging is accepted. The SDL software path, the SDL2 branches and Vulkan refusal are compile-verified only.
This commit is contained in:
+19
-3
@@ -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":"<name>"}` 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":"<name>"}` 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` | ✓ | | |
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
@@ -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<int> s_present_shot_seq{0}; /* bumps on every completion */
|
||||
static std::atomic<int> 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<std::mutex> 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<std::mutex> 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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user