Files
psxrecomp/runtime
tetrisgm 2b2aebd8cb 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.

(cherry picked from commit 8fee64f178)
2026-08-27 21:17:20 -07:00
..
2026-08-15 14:46:26 -07:00
2026-08-13 10:37:18 -04:00
2026-07-29 03:18:10 -04:00