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:
tetrisgm
2026-08-16 17:09:26 -07:00
parent 1181c9c9e9
commit 8fee64f178
5 changed files with 293 additions and 10 deletions
+19 -3
View File
@@ -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` | ✓ | | |
+79 -7
View File
@@ -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 },
+37
View File
@@ -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);
}
+135
View File
@@ -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;
+23
View File
@@ -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: