mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
debug: always-on display ring + full-VRAM aux capture
New always-on ring in the debug server: every vblank captures the display area's raw 15-bit pixels AND the full 1024x512 VRAM as the ACTIVE RENDERER sees it (GL: FBO truth via fbo_peek; SW: CPU truth), 64 frames of lookback. Commands: display_ring_get (frame -> PNG), display_ring_aux (frame -> raw u16 VRAM blob), display_ring_stats. Purpose: FRAME-EXACT visual forensics. screenshot_file returns whatever is on screen when the command lands (1-3 frames late), which cannot support cross-renderer same-frame comparison; the ring can. Proven use (MMX6 sprite triage): pulled the SAME deterministic frame from a GL run and a software run, micro-cosimed the suspect sprite prim from the GP0 ring against the same-frame captured texels/CLUT, and established GL and SW render the scene pixel-identically with byte-identical texture content - reclassifying the reported artifact from renderer bug to (pending Beetle oracle) game content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7007,6 +7007,158 @@ static int png_write_rgb(FILE *f, const uint8_t *rgb, uint32_t w, uint32_t h) {
|
||||
* 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. */
|
||||
/* ---- Always-on display ring ----------------------------------------------
|
||||
* The last DISP_RING_CAP vblanks' display areas, raw 15-bit VRAM halfwords,
|
||||
* captured in debug_server_record_frame (ring-buffer rule: continuous
|
||||
* capture, observers query a window after the fact). Purpose: FRAME-EXACT
|
||||
* screenshots. `screenshot_file` returns whatever is displayed when the
|
||||
* command lands — 1-3 frames after the `frame` query that named it — which
|
||||
* makes cross-renderer same-frame diffing impossible. With this ring, two
|
||||
* deterministic runs (GL vs software) can each serve the display for the
|
||||
* SAME frame number, giving a pixel-exact renderer-divergence census.
|
||||
* GL reads GPU-side truth via the fbo peek; software reads CPU VRAM (its
|
||||
* authoritative surface). depth24 scanout frames are tagged and refused
|
||||
* (15-bit only). */
|
||||
#define DISP_RING_CAP 64 /* full-VRAM entries are 1MB each; 32 frames of
|
||||
* lookback (~0.3-0.5s) for exact-frame forensics */
|
||||
#define DISP_RING_MAX_W 640
|
||||
#define DISP_RING_MAX_H 256
|
||||
/* Full-VRAM aux capture alongside the display: the entire 1024x512 raw VRAM
|
||||
* as the renderer sees it at this frame (GL: FBO truth; SW: CPU truth), so an
|
||||
* offline client can micro-cosim ANY prim of the frame — rasterize it from
|
||||
* the GP0 ring against the exact same-frame texels/CLUTs — and attribute a
|
||||
* bad pixel to sampling vs content. Streamed texture pages and palette
|
||||
* cycling make later peeks worthless; only a same-frame capture is evidence. */
|
||||
typedef struct {
|
||||
uint32_t frame;
|
||||
uint16_t w, h;
|
||||
uint8_t depth24, valid;
|
||||
uint16_t *px; /* DISP_RING_MAX_W*DISP_RING_MAX_H halfwords */
|
||||
uint16_t *vram; /* full 1024x512 */
|
||||
} DispRingEntry;
|
||||
static DispRingEntry s_disp_ring[DISP_RING_CAP];
|
||||
static uint16_t *s_disp_ring_px = NULL; /* one block for all entries */
|
||||
|
||||
static void disp_ring_capture(void)
|
||||
{
|
||||
if (!s_disp_ring_px) {
|
||||
size_t per = (size_t)DISP_RING_MAX_W * DISP_RING_MAX_H;
|
||||
size_t vram = (size_t)1024 * 512;
|
||||
s_disp_ring_px = (uint16_t *)malloc(DISP_RING_CAP * (per + vram) * sizeof(uint16_t));
|
||||
if (!s_disp_ring_px) return;
|
||||
for (int i = 0; i < DISP_RING_CAP; i++) {
|
||||
uint16_t *base = s_disp_ring_px + (size_t)i * (per + vram);
|
||||
s_disp_ring[i].px = base;
|
||||
s_disp_ring[i].vram = base + per;
|
||||
}
|
||||
}
|
||||
DispRingEntry *e = &s_disp_ring[(uint32_t)(s_frame_count % DISP_RING_CAP)];
|
||||
e->frame = (uint32_t)s_frame_count;
|
||||
e->valid = 0;
|
||||
GpuDisplayInfo di;
|
||||
gpu_get_display_info(&di);
|
||||
if (di.disabled || di.width == 0 || di.height == 0) return;
|
||||
uint32_t w = di.width, h = di.height;
|
||||
if (w > DISP_RING_MAX_W) w = DISP_RING_MAX_W;
|
||||
if (h > DISP_RING_MAX_H) h = DISP_RING_MAX_H;
|
||||
e->w = (uint16_t)w; e->h = (uint16_t)h;
|
||||
e->depth24 = (uint8_t)(di.depth24 ? 1 : 0);
|
||||
/* GPU-side truth when the GL raster pipeline is live (scissored pack of
|
||||
* just the display rect + one small readback); CPU VRAM otherwise (the
|
||||
* software backend's authoritative surface). Runs on the present thread,
|
||||
* where the GL context is current. */
|
||||
extern int gl_renderer_fbo_peek(int x, int y, int w_, int h_, uint16_t *out);
|
||||
int got = 0;
|
||||
if (di.display_x + w <= 1024 && di.display_y + h <= 512)
|
||||
got = gl_renderer_fbo_peek((int)di.display_x, (int)di.display_y,
|
||||
(int)w, (int)h, e->px);
|
||||
if (!got) {
|
||||
for (uint32_t y = 0; y < h; y++)
|
||||
for (uint32_t x = 0; x < w; x++)
|
||||
e->px[y * w + x] =
|
||||
gpu_vram_peek((int)(di.display_x + x), (int)(di.display_y + y));
|
||||
}
|
||||
/* Full-VRAM aux capture (same GL-truth/CPU-truth split as the display). */
|
||||
if (!gl_renderer_fbo_peek(0, 0, 1024, 512, e->vram)) {
|
||||
const uint16_t *v = gpu_get_vram();
|
||||
memcpy(e->vram, v, (size_t)1024 * 512 * sizeof(uint16_t));
|
||||
}
|
||||
e->valid = 1;
|
||||
}
|
||||
|
||||
/* Dump an entry's full-VRAM capture as a raw little-endian u16 blob
|
||||
* (1024x512 row-major). */
|
||||
static void handle_display_ring_aux(int id, const char *json)
|
||||
{
|
||||
int f = json_get_int(json, "frame", -1);
|
||||
if (f < 0) { send_err(id, "missing frame"); return; }
|
||||
char path[512];
|
||||
if (!json_get_str(json, "path", path, sizeof(path))) {
|
||||
send_err(id, "missing path"); return;
|
||||
}
|
||||
if (!s_disp_ring_px) { send_err(id, "display ring not started"); return; }
|
||||
DispRingEntry *e = &s_disp_ring[(uint32_t)((uint64_t)f % DISP_RING_CAP)];
|
||||
if (!e->valid || e->frame != (uint32_t)f) {
|
||||
send_err(id, "frame not in display ring"); return;
|
||||
}
|
||||
FILE *fp = fopen(path, "wb");
|
||||
if (!fp) { send_err(id, "cannot open file"); return; }
|
||||
size_t n1 = fwrite(e->vram, sizeof(uint16_t), (size_t)1024 * 512, fp);
|
||||
fclose(fp);
|
||||
send_fmt("{\"id\":%d,\"ok\":true,\"frame\":%d,\"path\":\"%s\","
|
||||
"\"vram_words\":%u}", id, f, path, (unsigned)n1);
|
||||
}
|
||||
|
||||
static void handle_display_ring_stats(int id, const char *json)
|
||||
{
|
||||
(void)json;
|
||||
uint32_t oldest = 0, newest = 0;
|
||||
int n = 0;
|
||||
for (int i = 0; i < DISP_RING_CAP; i++) {
|
||||
if (!s_disp_ring_px || !s_disp_ring[i].valid) continue;
|
||||
uint32_t f = s_disp_ring[i].frame;
|
||||
if (n == 0 || f < oldest) oldest = f;
|
||||
if (n == 0 || f > newest) newest = f;
|
||||
n++;
|
||||
}
|
||||
send_fmt("{\"id\":%d,\"ok\":true,\"capacity\":%d,\"valid\":%d,"
|
||||
"\"oldest_frame\":%u,\"newest_frame\":%u}",
|
||||
id, DISP_RING_CAP, n, oldest, newest);
|
||||
}
|
||||
|
||||
static void handle_display_ring_get(int id, const char *json)
|
||||
{
|
||||
int f = json_get_int(json, "frame", -1);
|
||||
if (f < 0) { send_err(id, "missing frame"); return; }
|
||||
char path[512];
|
||||
if (!json_get_str(json, "path", path, sizeof(path))) {
|
||||
send_err(id, "missing path"); return;
|
||||
}
|
||||
if (!s_disp_ring_px) { send_err(id, "display ring not started"); return; }
|
||||
DispRingEntry *e = &s_disp_ring[(uint32_t)((uint64_t)f % DISP_RING_CAP)];
|
||||
if (!e->valid || e->frame != (uint32_t)f) {
|
||||
send_err(id, "frame not in display ring"); return;
|
||||
}
|
||||
if (e->depth24) { send_err(id, "frame is 24bpp scanout (unsupported)"); return; }
|
||||
uint32_t w = e->w, h = e->h;
|
||||
uint8_t *rgb = (uint8_t *)malloc((size_t)w * h * 3);
|
||||
if (!rgb) { send_err(id, "alloc failed"); return; }
|
||||
for (uint32_t i = 0; i < w * h; i++) {
|
||||
uint16_t p = e->px[i];
|
||||
rgb[i * 3 + 0] = (uint8_t)((p & 0x1F) << 3);
|
||||
rgb[i * 3 + 1] = (uint8_t)(((p >> 5) & 0x1F) << 3);
|
||||
rgb[i * 3 + 2] = (uint8_t)(((p >> 10) & 0x1F) << 3);
|
||||
}
|
||||
FILE *fp = fopen(path, "wb");
|
||||
if (!fp) { free(rgb); send_err(id, "cannot open file"); return; }
|
||||
int ok = png_write_rgb(fp, rgb, w, h);
|
||||
free(rgb);
|
||||
fclose(fp);
|
||||
if (!ok) { send_err(id, "png encode failed"); return; }
|
||||
send_fmt("{\"id\":%d,\"ok\":true,\"frame\":%d,\"path\":\"%s\","
|
||||
"\"width\":%u,\"height\":%u}", id, f, path, w, h);
|
||||
}
|
||||
|
||||
static void handle_screenshot_file(int id, const char *json)
|
||||
{
|
||||
/* Under the OpenGL FBO-present path, CPU VRAM can be stale (the FBO holds
|
||||
@@ -10904,6 +11056,9 @@ static const CmdEntry s_commands[] = {
|
||||
{ "get_snapshots", handle_get_snapshots },
|
||||
{ "screenshot", handle_screenshot_file },
|
||||
{ "screenshot_file", handle_screenshot_file }, /* alias */
|
||||
{ "display_ring_get", handle_display_ring_get },
|
||||
{ "display_ring_aux", handle_display_ring_aux },
|
||||
{ "display_ring_stats", handle_display_ring_stats },
|
||||
{ "wide_full", handle_wide_full },
|
||||
{ "wide_shot", handle_wide_shot },
|
||||
{ "gpu_opcodes", handle_gpu_opcodes },
|
||||
@@ -11539,6 +11694,10 @@ void debug_server_record_frame(void)
|
||||
/* Last function */
|
||||
strcpy(r->last_func, "(no tracking)");
|
||||
|
||||
/* Always-on display ring: raw display-area pixels for THIS frame number
|
||||
* (pre-increment, matching what the `frame` command reports right now). */
|
||||
disp_ring_capture();
|
||||
|
||||
s_history_count = s_frame_count + 1;
|
||||
s_frame_count++;
|
||||
/* Layer-1 first-divergence: snapshot the cumulative write fingerprint for
|
||||
|
||||
Reference in New Issue
Block a user