runtime: per-frame GP0 command ring buffer

Always-on ring captures every GP0 command (header + first 6 payload
words) stamped with the frame number issued. Hooked at
gp0_execute_command() for fixed-length commands and at the polyline
start path for variable-length ones. ~70 MB at 1 048 576 entries
(34 bytes/entry); lazy-allocated on first record.

New debug-server commands:
- gpu_ring_stats: total/capacity/oldest+newest frame in buffer
- gpu_frame_dump frame=N count=M: per-frame GP0 entries with raw words

Designed per CLAUDE.md ring-buffer rule (CLAUDE.md global #2):
capture is continuous; observers query a window of interest
post-hoc. No arming, no timed attach.

Use case driving this: fuzzy menu text bug on Tomba title screen
(TombaRecomp ISSUES.md #3 'D'). Lifetime opcode counts couldn't
distinguish which 0x64/0x2C calls draw the menu vs the static
text. With per-frame dump we can now show that frame N has 88 GP0
commands and isolate the 6 0x2C entries that render with degenerate
top-right vertices (= the fuzzy text).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-05-13 12:25:29 -07:00
parent 2f709ee542
commit 3cfcdaba17
3 changed files with 156 additions and 0 deletions
+21
View File
@@ -48,6 +48,27 @@ typedef struct {
void gpu_arm_shaded_quad_capture(void);
int gpu_get_shaded_quad_capture(const GpuSqCapEntry** out);
/* Per-frame GP0 command ring (always-on; query via debug server).
* Each entry records the GP0 command header + up to 6 payload words
* (longer commands like 0x3C shaded textured quad are truncated to 6).
* Stamped with the s_frame_count value at issue time so a debug
* client can pull all commands for any frame in the ring window. */
#define GPU_GP0_RING_MAX_WORDS 6
typedef struct {
uint32_t frame;
uint32_t seq;
uint8_t opcode;
uint8_t n_words; /* total command length; >MAX means truncated */
uint16_t pad;
uint32_t cmd[GPU_GP0_RING_MAX_WORDS];
} GpuGp0RingEntry;
uint64_t gpu_gp0_ring_total(void);
uint32_t gpu_gp0_ring_capacity(void);
uint32_t gpu_gp0_ring_max_words(void);
int gpu_gp0_ring_dump_frame(uint32_t frame, GpuGp0RingEntry *out, int max_out);
void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest);
/* Vblank presentation callback — called from gpu_vblank_tick(). */
typedef void (*gpu_vblank_cb)(void);
void gpu_set_vblank_callback(gpu_vblank_cb cb);
+58
View File
@@ -2711,6 +2711,62 @@ static void handle_gpu_opcodes(int id, const char *json)
send_fmt("%s", buf);
}
static void handle_gpu_ring_stats(int id, const char *json)
{
(void)json;
uint32_t oldest = 0, newest = 0;
gpu_gp0_ring_frame_span(&oldest, &newest);
send_fmt("{\"id\":%d,\"ok\":true,\"total\":%llu,\"capacity\":%u,"
"\"max_words\":%u,\"oldest_frame\":%u,\"newest_frame\":%u}",
id,
(unsigned long long)gpu_gp0_ring_total(),
gpu_gp0_ring_capacity(),
gpu_gp0_ring_max_words(),
oldest, newest);
}
static void handle_gpu_frame_dump(int id, const char *json)
{
int target = json_get_int(json, "frame", -1);
if (target < 0) { send_err(id, "missing frame"); return; }
int max_entries = json_get_int(json, "count", 8192);
if (max_entries < 1) max_entries = 1;
if (max_entries > 65536) max_entries = 65536;
GpuGp0RingEntry *entries = (GpuGp0RingEntry *)malloc(
(size_t)max_entries * sizeof(GpuGp0RingEntry));
if (!entries) { send_err(id, "alloc failed"); return; }
int n = gpu_gp0_ring_dump_frame((uint32_t)target, entries, max_entries);
/* ~140 bytes per entry in JSON; budget conservatively. */
size_t buf_sz = 256 + (size_t)n * 200u;
char *buf = (char *)malloc(buf_sz);
if (!buf) { free(entries); send_err(id, "alloc failed"); return; }
size_t pos = (size_t)snprintf(buf, buf_sz,
"{\"id\":%d,\"ok\":true,\"frame\":%u,\"count\":%d,\"max_words\":%u,\"entries\":[",
id, (uint32_t)target, n, gpu_gp0_ring_max_words());
for (int i = 0; i < n && pos < buf_sz - 256; i++) {
const GpuGp0RingEntry *e = &entries[i];
pos += (size_t)snprintf(buf + pos, buf_sz - pos,
"%s{\"seq\":%u,\"op\":\"0x%02X\",\"n\":%u,\"w\":[",
i ? "," : "", e->seq, e->opcode, e->n_words);
int show = e->n_words < GPU_GP0_RING_MAX_WORDS
? e->n_words : GPU_GP0_RING_MAX_WORDS;
for (int k = 0; k < show && pos < buf_sz - 32; k++) {
pos += (size_t)snprintf(buf + pos, buf_sz - pos,
"%s\"0x%08X\"", k ? "," : "", e->cmd[k]);
}
pos += (size_t)snprintf(buf + pos, buf_sz - pos, "]}");
}
snprintf(buf + pos, buf_sz - pos, "]}");
debug_server_send_line(buf);
free(buf);
free(entries);
}
static void handle_capture_quads(int id, const char *json)
{
(void)json;
@@ -5217,6 +5273,8 @@ static const CmdEntry s_commands[] = {
{ "screenshot", handle_screenshot },
{ "screenshot_file", handle_screenshot_file },
{ "gpu_opcodes", handle_gpu_opcodes },
{ "gpu_ring_stats", handle_gpu_ring_stats },
{ "gpu_frame_dump", handle_gpu_frame_dump },
{ "a0_history", handle_a0_history },
{ "c0_history", handle_c0_history },
{ "capture_quads", handle_capture_quads },
+77
View File
@@ -1180,10 +1180,82 @@ static uint32_t gp0_opcode_count[256];
uint32_t gpu_get_opcode_count(uint8_t op) { return gp0_opcode_count[op]; }
/* ---- Per-frame GP0 command ring (always-on, queried via debug server) ---- */
/* We record every GP0 command (header + up to 6 payload words) with the
* frame number it was issued in. Per CLAUDE.md ring-buffer rule: capture
* is continuous and observers query a window of interest later, not arm-
* then-record. ~34 MB at 1M entries. Polyline / long commands get the
* first 6 payload words; that's enough for the header + first vertex pair
* + first uv/color word for diagnosing per-primitive state. */
extern uint64_t s_frame_count; /* defined in debug_server.c */
#define GP0_RING_CAP (1u << 20) /* 1 048 576 entries */
/* GpuGp0RingEntry + GPU_GP0_RING_MAX_WORDS are public types in gpu.h. */
static GpuGp0RingEntry *gp0_ring = NULL; /* lazy-alloc on first record */
static uint64_t gp0_ring_seq = 0; /* total commands recorded */
static uint32_t gp0_ring_head = 0; /* next write slot */
static void gp0_ring_record(const uint32_t *words, int n) {
if (!gp0_ring) {
gp0_ring = (GpuGp0RingEntry *)calloc(GP0_RING_CAP, sizeof(*gp0_ring));
if (!gp0_ring) return; /* OOM — capture disabled, no other effect */
}
GpuGp0RingEntry *e = &gp0_ring[gp0_ring_head];
e->frame = (uint32_t)s_frame_count;
e->seq = (uint32_t)gp0_ring_seq;
e->opcode = (uint8_t)((words[0] >> 24) & 0xFF);
e->n_words = (uint8_t)(n > 255 ? 255 : (n < 0 ? 1 : n));
e->pad = 0;
int copy_n = e->n_words > GPU_GP0_RING_MAX_WORDS ? GPU_GP0_RING_MAX_WORDS : e->n_words;
for (int i = 0; i < copy_n; i++) e->cmd[i] = words[i];
for (int i = copy_n; i < GPU_GP0_RING_MAX_WORDS; i++) e->cmd[i] = 0;
gp0_ring_head = (gp0_ring_head + 1) % GP0_RING_CAP;
gp0_ring_seq++;
}
/* Public accessors for debug_server.c */
uint64_t gpu_gp0_ring_total(void) { return gp0_ring_seq; }
uint32_t gpu_gp0_ring_capacity(void) { return GP0_RING_CAP; }
uint32_t gpu_gp0_ring_max_words(void){ return GPU_GP0_RING_MAX_WORDS; }
/* Fill `out[0..max_out-1]` with entries from the requested frame; returns
* count. Walks from oldest in-buffer to newest so iteration order matches
* draw order within a frame. */
int gpu_gp0_ring_dump_frame(uint32_t frame, GpuGp0RingEntry *out, int max_out) {
if (!gp0_ring || max_out <= 0) return 0;
uint32_t avail = (gp0_ring_seq < GP0_RING_CAP)
? (uint32_t)gp0_ring_seq : GP0_RING_CAP;
uint32_t start = (gp0_ring_seq < GP0_RING_CAP) ? 0 : gp0_ring_head;
int n_out = 0;
for (uint32_t i = 0; i < avail && n_out < max_out; i++) {
uint32_t idx = (start + i) % GP0_RING_CAP;
if (gp0_ring[idx].frame == frame) {
out[n_out++] = gp0_ring[idx];
}
}
return n_out;
}
/* Report the frame range currently held in the ring (oldest..newest). */
void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest) {
if (out_oldest) *out_oldest = 0;
if (out_newest) *out_newest = 0;
if (!gp0_ring || gp0_ring_seq == 0) return;
uint32_t avail = (gp0_ring_seq < GP0_RING_CAP)
? (uint32_t)gp0_ring_seq : GP0_RING_CAP;
uint32_t start = (gp0_ring_seq < GP0_RING_CAP) ? 0 : gp0_ring_head;
uint32_t newest_idx = (start + avail - 1) % GP0_RING_CAP;
if (out_oldest) *out_oldest = gp0_ring[start].frame;
if (out_newest) *out_newest = gp0_ring[newest_idx].frame;
}
/* Execute a fully-collected GP0 command */
static void gp0_execute_command(void) {
uint8_t opcode = (gp0_cmd_buf[0] >> 24) & 0xFF;
gp0_opcode_count[opcode]++;
gp0_ring_record(gp0_cmd_buf, gp0_words_needed);
/* Categorize for diagnostics */
if (opcode <= 0x01) gp0_nop_count++;
@@ -1538,6 +1610,11 @@ void gpu_write_gp0(uint32_t val) {
sw_set_semi_transparency(polyline_semi_trans, (int)semi_transparency);
gp0_state = shaded ? GP0_POLYLINE_SHADED : GP0_POLYLINE_MONO;
gp0_draw_count++;
/* Record polyline header (variable-length body not captured;
* just enough so per-frame stream shows the polyline existed). */
gp0_opcode_count[opcode]++;
uint32_t hdr_only[1] = { val };
gp0_ring_record(hdr_only, 1);
return;
}