widescreen(8C): always-on draw-census ring + live cull-margin override

Observability for the 16:9 backdrop-cull investigation (runtime-only,
4:3-identity-safe, no regen):

- gpu.c: always-on per-prim draw-census ring (ws_census). Every drawn GP0
  primitive (0x20-0x7F) records {frame, src_addr, camX/camY, first-vertex
  screen x/y, opcode} so object spawn/despawn and edge culls are observable
  in data instead of screenshots. gpu_ws_census_dump() writes a frame range
  to CSV (large dumps must not ride TCP).
- gpu.c: gpu_ws_set_margin_override() forces psx_ws_x_margin() to any value
  for a live cull sweep at a fixed camera; gpu_ws_get_debug() snapshots live
  widescreen state.
- debug_server.c: gpu_state now reports ws.{configured,active,game_mode,
  present_native_43,x_margin,squash,cur_frame,last_tag_frame}; new ws_margin
  and ws_census TCP commands.
- gpu.h: GpuWsDebug + census/override API.
- debug_client.py: ws_margin and ws_census subcommands.

Used to prove the shipped [widescreen.cull] sites are a no-op for 8C (their
4:3 margins already cover the 16:9 view) and that the real pop is an
overlay-resident backdrop layer. See TombaRecomp ISSUES.md #8C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-12 23:00:40 -07:00
parent bd6ddc32f1
commit bee93160c4
4 changed files with 177 additions and 2 deletions
+25
View File
@@ -103,6 +103,31 @@ int gpu_ws_present_native_43(void);
* cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */
int psx_ws_x_margin(void);
/* Live widescreen state for diagnostics (TCP gpu_state). All pointers
* optional. last_tag_frame/cur_frame let the caller see game_mode freshness. */
typedef struct {
int configured; /* ws_xnum != ws_xden */
int active; /* squash currently applied this frame */
int game_mode; /* tagged char/billboard prim within 2 frames */
int present_native_43; /* frame presents pillarboxed 4:3 (FMV/full-2D) */
int x_margin; /* psx_ws_x_margin() right now */
int xnum, xden; /* squash factor */
uint64_t cur_frame;
uint32_t last_tag_frame; /* frame of newest tagged prim */
} GpuWsDebug;
void gpu_ws_get_debug(GpuWsDebug* out);
/* Diagnostic: force psx_ws_x_margin() to return v (>=0) regardless of state,
* or -1 to restore the normal computed margin. For live cull-margin sweeps. */
void gpu_ws_set_margin_override(int v);
/* Always-on draw-census ring: every drawn primitive records frame / source
* addr / camera / first-vertex screen pos, so object spawn/despawn and edge
* culls are observable in data. Dump frames [f0,f1] to a CSV file. */
int gpu_ws_census_dump(uint32_t f0, uint32_t f1, const char *path);
void gpu_ws_census_set(int on);
uint64_t gpu_ws_census_seq(void);
#ifdef __cplusplus
}
#endif
+42 -2
View File
@@ -3563,6 +3563,8 @@ static void handle_gpu_state(int id, const char *json)
gpu_get_draw_area(&da);
uint64_t nop, fill, draw, env, copy;
gpu_get_gp0_stats(&nop, &fill, &draw, &env, &copy);
GpuWsDebug ws;
gpu_ws_get_debug(&ws);
send_fmt("{\"id\":%d,\"ok\":true,"
"\"display_x\":%d,\"display_y\":%d,"
"\"width\":%d,\"height\":%d,"
@@ -3572,7 +3574,10 @@ static void handle_gpu_state(int id, const char *json)
"\"gp0_writes\":%llu,"
"\"gp0_nop\":%llu,\"gp0_fill\":%llu,\"gp0_draw\":%llu,\"gp0_env\":%llu,\"gp0_copy\":%llu,"
"\"draw_area\":[%u,%u,%u,%u],"
"\"draw_offset\":[%d,%d]}",
"\"draw_offset\":[%d,%d],"
"\"ws\":{\"configured\":%d,\"active\":%d,\"game_mode\":%d,"
"\"present_native_43\":%d,\"x_margin\":%d,\"squash\":[%d,%d],"
"\"cur_frame\":%llu,\"last_tag_frame\":%u}}",
id, di.display_x, di.display_y,
di.width, di.height,
di.depth24 ? 24 : 15, di.depth24,
@@ -3583,7 +3588,10 @@ static void handle_gpu_state(int id, const char *json)
(unsigned long long)draw, (unsigned long long)env,
(unsigned long long)copy,
da.left, da.top, da.right, da.bottom,
da.offset_x, da.offset_y);
da.offset_x, da.offset_y,
ws.configured, ws.active, ws.game_mode,
ws.present_native_43, ws.x_margin, ws.xnum, ws.xden,
(unsigned long long)ws.cur_frame, ws.last_tag_frame);
}
static void handle_mem_words(int id, const char *json)
@@ -5187,6 +5195,36 @@ static void handle_clear_input(int id, const char *json)
send_ok(id);
}
static void handle_ws_margin(int id, const char *json)
{
int v = json_get_int(json, "value", -2);
if (v < -1) { send_err(id, "missing value (>=0 to force, -1 to clear)"); return; }
gpu_ws_set_margin_override(v);
GpuWsDebug ws;
gpu_ws_get_debug(&ws);
send_fmt("{\"id\":%d,\"ok\":true,\"override\":%d,\"x_margin\":%d,\"active\":%d}",
id, v, ws.x_margin, ws.active);
}
static void handle_ws_census(int id, const char *json)
{
char act[16] = {0};
json_get_str(json, "action", act, sizeof(act));
if (strcmp(act, "on") == 0) { gpu_ws_census_set(1); send_fmt("{\"id\":%d,\"ok\":true,\"on\":1,\"seq\":%llu}", id, (unsigned long long)gpu_ws_census_seq()); return; }
if (strcmp(act, "off") == 0) { gpu_ws_census_set(0); send_fmt("{\"id\":%d,\"ok\":true,\"on\":0,\"seq\":%llu}", id, (unsigned long long)gpu_ws_census_seq()); return; }
/* default action = dump */
int f0 = json_get_int(json, "start", -1);
int f1 = json_get_int(json, "end", -1);
if (f0 < 0 || f1 < 0) { send_err(id, "missing start/end (or action on|off)"); return; }
char path[256];
if (!json_get_str(json, "out", path, sizeof(path)))
snprintf(path, sizeof(path), "psx_census.csv");
int n = gpu_ws_census_dump((uint32_t)f0, (uint32_t)f1, path);
if (n < 0) { send_err(id, "census dump: cannot open file"); return; }
send_fmt("{\"id\":%d,\"ok\":true,\"rows\":%d,\"path\":\"%s\",\"seq\":%llu}",
id, n, path, (unsigned long long)gpu_ws_census_seq());
}
static void handle_turbo(int id, const char *json)
{
int enabled = json_get_int(json, "enabled", -1);
@@ -8434,6 +8472,8 @@ static const CmdEntry s_commands[] = {
{ "dump_ram", handle_read_ram }, /* alias: one request, one response */
{ "write_ram", handle_write_ram },
{ "gpu_state", handle_gpu_state },
{ "ws_margin", handle_ws_margin },
{ "ws_census", handle_ws_census },
{ "mem_words", handle_mem_words },
{ "vram_peek", handle_vram_peek },
{ "gl_coh_ring", handle_gl_coh_ring },
+96
View File
@@ -122,11 +122,35 @@ static int ws_active(void) { return ws_configured() && !gpu_ws_present_native_43
* the visible edge moves out by 160*(1/s - 1) = 160*(xden-xnum)/xnum. Widening
* the cull window by this restores the original off-screen margin at the new
* edge. 0 whenever squash is inactive (4:3/boot/menu/FMV) → original cull. */
/* Diagnostic override (8C): when >= 0, psx_ws_x_margin() returns this value
* unconditionally so a probe can sweep the cull margin live (0 = force 4:3
* cull while still stretching; large = over-draw) at a fixed camera position.
* -1 = normal computed margin. */
static int ws_margin_override = -1;
void gpu_ws_set_margin_override(int v) { ws_margin_override = v; }
int psx_ws_x_margin(void) {
if (ws_margin_override >= 0) return ws_margin_override;
if (!ws_active()) return 0;
return (160 * (ws_xden - ws_xnum) + ws_xnum / 2) / ws_xnum;
}
/* Snapshot live widescreen state for the TCP gpu_state diagnostic. Mirrors
* the predicates above so a probe can see, mid-gameplay, whether the squash
* and the cull-margin are actually engaged (8C). */
void gpu_ws_get_debug(GpuWsDebug* out) {
if (!out) return;
out->configured = ws_configured();
out->active = ws_active();
out->game_mode = ws_game_mode();
out->present_native_43 = gpu_ws_present_native_43();
out->x_margin = psx_ws_x_margin();
out->xnum = ws_xnum;
out->xden = ws_xden;
out->cur_frame = s_frame_count;
out->last_tag_frame = ws_last_tag_stamp;
}
void gpu_ws_configure(int aspect_num, int aspect_den,
uint32_t sprite_anchor_addr, int hud_sprt_squash) {
/* X squash = (4*den)/(3*num) — the same factor the GTE applies. */
@@ -1635,12 +1659,84 @@ void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest) {
if (out_newest) *out_newest = gp0_ring[newest_idx].frame;
}
/* ---- Draw census ring (ALWAYS-ON) -----------------------------------------
* Every drawn primitive (GP0 opcode 0x20-0x7F) records: frame, the prim's DMA
* source address, the live camera (scratchpad camX/camY), and the prim's first
* vertex in DRAWING space (pre draw_offset). Purpose: see object spawn/despawn
* and edge-cull in DATA, not screenshots. When a background object despawns,
* its prim simply stops appearing in the census at a specific camX while its
* last recorded screen-x shows how far on-screen it still was — i.e. the
* effective (4:3-sized) despawn margin, which the 16:9 view exceeds. A prim
* present in the census but absent on screen would instead indict the renderer
* gate. Query via TCP `ws_census` → CSV file (large dumps mustn't ride TCP). */
#define WS_CENSUS_CAP (1u << 21) /* 2,097,152 entries * 16B = 32 MiB */
typedef struct {
uint32_t frame;
uint32_t src_addr;
int16_t cam_x, cam_y;
int16_t x, y;
uint8_t opcode;
uint8_t pad[3];
} WsCensusEntry;
static WsCensusEntry *ws_census = NULL;
static uint64_t ws_census_seq = 0;
static int ws_census_on = 1; /* always-on; toggle via ws_census */
extern uint16_t psx_read_half(uint32_t addr);
static void ws_census_record(uint8_t opcode, int32_t x, int32_t y) {
if (!ws_census_on) return;
if (!ws_census) {
ws_census = (WsCensusEntry *)calloc(WS_CENSUS_CAP, sizeof(WsCensusEntry));
if (!ws_census) { ws_census_on = 0; return; }
}
WsCensusEntry *e = &ws_census[ws_census_seq & (WS_CENSUS_CAP - 1)];
e->frame = (uint32_t)s_frame_count;
e->src_addr = gp0_cmd_source_addr;
e->cam_x = (int16_t)psx_read_half(0x1F800176);
e->cam_y = (int16_t)psx_read_half(0x1F800186);
e->x = (int16_t)x;
e->y = (int16_t)y;
e->opcode = opcode;
ws_census_seq++;
}
/* Dump census rows for frames [f0,f1] to a CSV file. Returns row count. */
int gpu_ws_census_dump(uint32_t f0, uint32_t f1, const char *path) {
if (!ws_census) return 0;
FILE *fp = fopen(path, "w");
if (!fp) return -1;
fprintf(fp, "frame,src_addr,cam_x,cam_y,x,y,opcode\n");
uint64_t total = ws_census_seq;
uint64_t avail = total < WS_CENSUS_CAP ? total : WS_CENSUS_CAP;
uint64_t start = total - avail;
int n = 0;
for (uint64_t s = start; s < total; s++) {
WsCensusEntry *e = &ws_census[s & (WS_CENSUS_CAP - 1)];
if (e->frame < f0 || e->frame > f1) continue;
fprintf(fp, "%u,0x%08X,%d,%d,%d,%d,0x%02X\n",
e->frame, e->src_addr, e->cam_x, e->cam_y, e->x, e->y, e->opcode);
n++;
}
fclose(fp);
return n;
}
void gpu_ws_census_set(int on) { ws_census_on = on ? 1 : 0; }
uint64_t gpu_ws_census_seq(void) { return ws_census_seq; }
/* 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);
/* Draw-census: capture every drawing primitive's first vertex + camera. */
if (opcode >= 0x20 && opcode <= 0x7F) {
int32_t cvx, cvy;
parse_vertex(gp0_cmd_buf[1], &cvx, &cvy);
ws_census_record(opcode, cvx, cvy);
}
/* Categorize for diagnostics */
if (opcode <= 0x01) gp0_nop_count++;
else if (opcode == 0x02) gp0_fill_count++;
+14
View File
@@ -265,6 +265,20 @@ def build_cmd(args):
return {"cmd": "set_input", "buttons": args[1]}, pretty_json
elif cmd == "clear_input":
return {"cmd": "clear_input"}, pretty_json
elif cmd == "ws_margin":
if len(args) < 2:
return None, lambda _: "Usage: ws_margin <value|-1>"
return {"cmd": "ws_margin", "value": int(args[1])}, pretty_json
elif cmd == "ws_census":
# ws_census on|off OR ws_census <start> <end> [outfile]
if len(args) >= 2 and args[1] in ("on", "off"):
return {"cmd": "ws_census", "action": args[1]}, pretty_json
if len(args) < 3:
return None, lambda _: "Usage: ws_census on|off | ws_census <start> <end> [outfile]"
d = {"cmd": "ws_census", "start": int(args[1]), "end": int(args[2])}
if len(args) > 3:
d["out"] = args[3]
return d, pretty_json
elif cmd == "quit":
return {"cmd": "quit"}, pretty_json
elif cmd == "dispatch_check":