From 3bf7bf13610a55a366513889c12955be5d11b51e Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:03:33 -0700 Subject: [PATCH] auto_ui_squash: join on submission order; instrument the silent gates Three changes, one fix and two pieces of visibility. 1. ws_ui_group_assign gains a third join: consecutive in the ordering-table walk AND touching. WipEout 3's speed/shield readout ends at x=288 and the meter bar it belongs to starts at x=306 with a different CLUT, so neither shared columns nor a matching key could join them. The readout stood alone, its bbox midpoint 256.5 landed in the middle third against a screen centre of 254, and it stayed pinned near centre while its own meters went to the right edge -- a 2 px gap in 4:3 opening to 159 framebuffer pixels at 32:9. No anchor threshold can fix that: the genuinely centred "pit ... check" message sits FARTHER RIGHT (midpoint 300.5) than the mis-anchored readout, so any threshold reaching the readout drags the message with it. Submission order is the only signal that separates them, and the array already carries it. Simulated against seven captures spanning three aspects and two code states, this forms three unions and changes exactly one anchor: [225,288] centre becomes [225,486] right. The other two unions ([208,240]+[240,361], [364,447]+[450,473]) are within an anchor. Confirmed in-race at 32:9: 7 runs, lap readout left, speed readout and meters together on the right. 2. ws_ui_groups reports why primitives were NOT admitted to the partition. Each gate in ws_ui_prepass_add is a silent return, so a primitive left at its raw 4:3 X while its neighbours are squashed -- which is how a HUD mark ends up stranded mid-screen -- was indistinguishable from one never drawn. In-race at 32:9: opcode 367, not_axis 410, everything else 0. 3. geom_correction reports perspective-UV arming with a real denominator. perspective_triangles could previously only be compared against gp0_draw, which counts untextured primitives that are correctly never armed, so it read as a coverage figure without being one. texcorr.attempts counts exactly the textured triangles reaching the predicate. In-race at 32:9: 199599 submitted, 96.15% armed, 3.85% rejected for a missing per-vertex Z, 0% for correction disabled or a missing packet address. Co-Authored-By: Claude Opus 5 (cherry picked from commit 572b9210d87eb8becc1b28a66e3dbbfd94c01241) --- runtime/include/gpu.h | 8 +++ runtime/runtime.cmake | 27 ++++++++--- runtime/src/debug_server.c | 12 +++++ runtime/src/gpu.c | 83 +++++++++++++++++++++++++++----- runtime/src/ws_ui_group.c | 50 +++++++++++++++++-- runtime/tests/test_ws_ui_group.c | 30 ++++++++++++ 6 files changed, 189 insertions(+), 21 deletions(-) diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 04aeb0b2..9abfd3e8 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -39,6 +39,14 @@ typedef struct { } GpuDisplayInfo; void gpu_get_display_info(GpuDisplayInfo* out); + +/* Perspective-correct UV arming rate, per condition. armed/attempts is the + * real perspective coverage: attempts counts only textured triangles, so it is + * the denominator that gp0_draw (which includes untextured primitives that are + * correctly never armed) cannot provide. Diagnostic; any pointer may be NULL. */ +void gpu_texture_correction_stats(uint64_t *attempts, uint64_t *armed, + uint64_t *no_correction, + uint64_t *no_source, uint64_t *no_depth); /* GP1(08h) bit4 — 24-bit display. Renderers skip FBO upload queues while set: * packed RGB888 lives in the CPU mirror; treating A0 rects as 1555 FBO uploads * both wastes bandwidth and force-flushes when UP_RECTS_MAX is hit (MotK FMV). */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index ef60dc37..cc46b149 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -771,7 +771,10 @@ function(psxrecomp_add_runtime_target target) # and stamp the pgxp overlay flavor so the shard cache and the ABI gate # keep pgxp and base DLLs fully separate. The base target is untouched — # the macros preprocess away without the define. - set(options ORACLE COSIM PGXP) + # PGXP_CLONE is internal: set only by the PSX_PGXP_VARIANT auto-clone at the + # end of this function, to mark the sibling that needs a distinguishing exe + # name. Callers pass PGXP alone. + set(options ORACLE COSIM PGXP PGXP_CLONE) set(oneValueArgs GAME_GENERATED_DISPATCH_C GAME_OVERLAY_STATIC_C @@ -936,10 +939,22 @@ function(psxrecomp_add_runtime_target target) set(_psxrt_exe_name "${_psxrt_exe_name}_oracle") endif() if(PSXRT_PGXP) - # Distinct binary beside the base one; the launcher (or the player) - # picks the variant. Same debug port as the base build — run one at a - # time (the A/B protocol is one-toggle-per-run anyway). - set(_psxrt_exe_name "${_psxrt_exe_name}_pgxp") + # The _pgxp suffix exists only to keep the auto-cloned A/B sibling + # distinct from the base binary it sits beside (PSX_PGXP_VARIANT); the + # launcher or the player picks between them. Same debug port as the + # base build — run one at a time (the A/B protocol is + # one-toggle-per-run anyway). + # + # A title that builds PGXP into its ONE runtime target has no base + # binary beside it, so suffixing there would ship a single product + # under an odd name and, worse, leave a second look-alike executable in + # the build tree for someone to launch by mistake. That is not + # hypothetical: a non-PGXP binary got played and reported as "textures + # still wobble" while the PGXP one measured 99% precise-vertex + # coverage. Suffix the clone, never the primary. + if(PSXRT_PGXP_CLONE) + set(_psxrt_exe_name "${_psxrt_exe_name}_pgxp") + endif() target_compile_definitions(${target} PRIVATE PSX_PGXP=1 PSX_OVERLAY_FLAVOR=2) # PSX_OVERLAY_FLAVOR_PGXP (overlay_api.h) @@ -1657,7 +1672,7 @@ function(psxrecomp_add_runtime_target target) option(PSX_PGXP_VARIANT "Also build the _pgxp PGXP precision-shadowing variant" OFF) if(PSX_PGXP_VARIANT) - psxrecomp_add_runtime_target(${target}-pgxp PGXP ${ARGN}) + psxrecomp_add_runtime_target(${target}-pgxp PGXP PGXP_CLONE ${ARGN}) endif() endif() endfunction() diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 313534f6..27c17f42 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5204,10 +5204,19 @@ static void handle_geom_correction(int id, const char *json) * but described a different word (stale = provenance hole to hunt). */ PGXPStats ps; pgxp_get_stats(&ps); + /* Perspective arming, with its real denominator. perspective_triangles on + * its own could only be compared against gp0_draw, which counts untextured + * primitives that are correctly never armed — so it read as a coverage + * figure without being one. texcorr.attempts counts exactly the textured + * triangles that reach the predicate. */ + uint64_t tc_att = 0, tc_arm = 0, tc_off = 0, tc_nosrc = 0, tc_noz = 0; + gpu_texture_correction_stats(&tc_att, &tc_arm, &tc_off, &tc_nosrc, &tc_noz); send_fmt("{\"id\":%d,\"ok\":true," "\"geometry_correction\":%d," "\"geometry_vertex_hits\":%u," "\"perspective_triangles\":%u," + "\"texcorr\":{\"attempts\":%llu,\"armed\":%llu," + "\"no_correction\":%llu,\"no_source\":%llu,\"no_depth\":%llu}," "\"lookups\":%u,\"miss_unrecorded\":%u,\"miss_ambiguous\":%u," "\"pgxp\":{\"enabled\":%d,\"cpu_mode\":%d,\"tolerance\":%.3f," "\"lookups\":%llu,\"dataflow_hit\":%llu,\"fallback_hit\":%llu," @@ -5218,6 +5227,9 @@ static void handle_geom_correction(int id, const char *json) gte_geometry_correction_enabled(), (unsigned)hits, (unsigned)gpu_texture_correction_hits(), + (unsigned long long)tc_att, (unsigned long long)tc_arm, + (unsigned long long)tc_off, (unsigned long long)tc_nosrc, + (unsigned long long)tc_noz, (unsigned)lookups, (unsigned)unrec, (unsigned)ambig, pgxp_enabled(), pgxp_cpu_mode(), (double)pgxp_tolerance(), (unsigned long long)ps.lookups, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 58ce32d8..2d01b614 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -107,6 +107,24 @@ static WsUiPrepassItem ws_ui_prepass[WS_UI_PREPASS_MAX]; static uint32_t ws_ui_prepass_count; static uint16_t ws_ui_prepass_rank = 0xFFFFu; +/* Why a UI-looking primitive did NOT reach the squash partition. + * + * Every rejection here leaves a primitive at its raw 4:3 X while the cluster + * around it is squashed toward an anchor -- which is precisely how a HUD mark + * ends up stranded in open screen at a wide aspect. The gates are silent + * `return`s, so a stranded primitive is indistinguishable from one that was + * never drawn at all. These counters let ws_ui_groups name the responsible gate + * from a capture instead of it being guessed at. Diagnostic only; nothing in + * the transform path reads them. */ +static struct { + uint32_t opcode; /* not in the textured quad / rect families */ + uint32_t not_axis; /* textured quad, but not axis-aligned */ + uint32_t degenerate; /* zero or negative extent */ + uint32_t too_big; /* full-screen or large-primitive reject */ + uint32_t cap; /* WS_UI_PREPASS_MAX reached */ + uint32_t rank; /* admitted, then dropped by the max_rank filter */ +} ws_ui_reject; + /* Wide-aspect mode: 0 = off (4:3 identity), 1 = squash (legacy hack — compress * a wider FOV into the 320 frame, present stretched), 2 = native-wide (render * the wider FOV into an actually wider frame and present 1:1; the GTE is NOT @@ -1609,10 +1627,16 @@ int psx_ws_backdrop_ring_json(char *buf, int cap) { int psx_ws_ui_groups_json(char *buf, int cap) { int off = snprintf(buf, (size_t)cap, "\"active\":%d,\"squash\":%d,\"dense\":%d,\"rank\":%d," - "\"disp_x\":%d,\"disp_w\":%d,\"join_gap\":%d,\"n\":%u,\"items\":[", + "\"disp_x\":%d,\"disp_w\":%d,\"join_gap\":%d," + "\"rejected\":{\"opcode\":%u,\"not_axis\":%u,\"degenerate\":%u," + "\"too_big\":%u,\"cap\":%u,\"rank\":%u}," + "\"n\":%u,\"items\":[", ws_active(), ws_auto_ui_squash, ws_auto_ui_dense, ws_ui_prepass_rank != 0xFFFFu ? (int)ws_ui_prepass_rank : -1, - ws_disp_x(), ws_disp_w(), WS_UI_GROUP_JOIN_GAP, ws_ui_prepass_count); + ws_disp_x(), ws_disp_w(), WS_UI_GROUP_JOIN_GAP, + ws_ui_reject.opcode, ws_ui_reject.not_axis, ws_ui_reject.degenerate, + ws_ui_reject.too_big, ws_ui_reject.cap, ws_ui_reject.rank, + ws_ui_prepass_count); for (uint32_t i = 0; i < ws_ui_prepass_count && off < cap - 220; i++) { const WsUiPrepassItem *it = &ws_ui_prepass[i]; /* Recover the key components so a split is attributable. Mirrors @@ -3299,26 +3323,57 @@ static void prepare_precise_triangle(int i0, int i1, int i2, gr_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], fx[2],fy[2]); } +/* Arming rate for perspective-correct UVs, per condition. + * + * perspective_triangles alone cannot answer "how much texture warp is left", + * because it has no denominator: comparing it to gp0_draw mixes in untextured + * mono and gouraud primitives that are correctly never armed, and comparing it + * to a vertex-lookup count divided by three is the same error. `attempts` + * counts exactly the textured triangles that reach this predicate, so + * armed/attempts IS the perspective coverage, and the three reject counters + * say which condition is spending it. Diagnostic only. */ +static struct { + uint64_t attempts; /* textured triangles submitted */ + uint64_t armed; /* got perspective-correct UVs */ + uint64_t no_correction; /* texture correction off */ + uint64_t no_source; /* CPU-built primitive, no packet address */ + uint64_t no_depth; /* a vertex had no recorded Z, or Z == 0 */ +} s_texcorr; + +void gpu_texture_correction_stats(uint64_t *attempts, uint64_t *armed, + uint64_t *no_correction, + uint64_t *no_source, uint64_t *no_depth) { + if (attempts) *attempts = s_texcorr.attempts; + if (armed) *armed = s_texcorr.armed; + if (no_correction) *no_correction = s_texcorr.no_correction; + if (no_source) *no_source = s_texcorr.no_source; + if (no_depth) *no_depth = s_texcorr.no_depth; +} + /* Enable perspective UVs only when every position word came from an exact * SWC2 projection store at that same DMA packet address. This preserves the * association through ordering-table reordering and rejects CPU-built UI. */ static void prepare_texture_triangle(int i0, int i1, int i2) { gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - if (!s_texture_correction_enabled || gp0_cmd_source_addr == 0xFFFFFFFFu) - return; + s_texcorr.attempts++; + if (!s_texture_correction_enabled) { s_texcorr.no_correction++; return; } + if (gp0_cmd_source_addr == 0xFFFFFFFFu) { s_texcorr.no_source++; return; } int indices[3] = { i0, i1, i2 }; uint16_t z[3]; for (int i = 0; i < 3; i++) { uint32_t addr = (gp0_cmd_source_addr + (uint32_t)indices[i] * 4u) & 0x1FFFFCu; if (!gte_precision_load_word(addr, gp0_cmd_buf[indices[i]], NULL, NULL, &z[i]) || - z[i] == 0) + z[i] == 0) { + s_texcorr.no_depth++; return; + } } float q[3] = { 1.0f / (float)z[0], 1.0f / (float)z[1], 1.0f / (float)z[2] }; float qmax = q[0]; if (q[1] > qmax) qmax = q[1]; if (q[2] > qmax) qmax = q[2]; - if (qmax <= 0.0f) return; + if (qmax <= 0.0f) { s_texcorr.no_depth++; return; } + s_texcorr.armed++; gr_set_perspective_triangle(1, q[0] / qmax, q[1] / qmax, q[2] / qmax); } @@ -4508,8 +4563,8 @@ static int gp0_command_word_count(uint8_t opcode) { static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, uint16_t rank) { - if (ws_ui_prepass_count >= WS_UI_PREPASS_MAX || rank == 0xFFFFu) - return; + if (rank == 0xFFFFu) return; + if (ws_ui_prepass_count >= WS_UI_PREPASS_MAX) { ws_ui_reject.cap++; return; } uint32_t op = words[0] >> 24; int32_t min_x, max_x, min_y, max_y; @@ -4526,7 +4581,7 @@ static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, int32_t vx[4], vy[4]; for (int i = 0; i < 4; i++) parse_vertex(words[indices[i]], &vx[i], &vy[i]); - if (!ws_axis_aligned_quad(vx, vy)) return; + if (!ws_axis_aligned_quad(vx, vy)) { ws_ui_reject.not_axis++; return; } min_x = max_x = vx[0]; min_y = max_y = vy[0]; for (int i = 1; i < 4; i++) { if (vx[i] < min_x) min_x = vx[i]; @@ -4545,18 +4600,21 @@ static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, } else { width = height = op >= 0x7Cu ? 16 : 8; } - if (width <= 0 || height <= 0) return; + if (width <= 0 || height <= 0) { ws_ui_reject.degenerate++; return; } max_x = min_x + width; max_y = min_y + height; } else { + ws_ui_reject.opcode++; return; } int32_t width = max_x - min_x, height = max_y - min_y; int32_t X = ws_disp_x(), W = ws_disp_w(), H = ws_disp_h(); if ((min_x <= X && max_x >= X + W && min_y <= 0 && max_y >= H) || - (width > W / 2 && height > H / 4)) + (width > W / 2 && height > H / 4)) { + ws_ui_reject.too_big++; return; + } WsUiPrepassItem *item = &ws_ui_prepass[ws_ui_prepass_count++]; item->group.key = @@ -4578,6 +4636,8 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { ws_ui_prepass_count = 0; ws_ui_prepass_rank = 0xFFFFu; ws_auto_ui_dense = 0; + ws_ui_reject.opcode = ws_ui_reject.not_axis = ws_ui_reject.degenerate = + ws_ui_reject.too_big = ws_ui_reject.cap = ws_ui_reject.rank = 0; if (!ws_auto_ui_squash || !ws_active()) return; uint32_t addr = psx_mod_gpu_dma_resolve_address(start_addr); @@ -4647,6 +4707,7 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { if (ws_ui_prepass[i].ot_rank == max_rank) ws_ui_prepass[out++] = ws_ui_prepass[i]; } + ws_ui_reject.rank = ws_ui_prepass_count - out; ws_ui_prepass_count = out; /* * A high final-layer primitive count is a good "dense 2D menu" signal for diff --git a/runtime/src/ws_ui_group.c b/runtime/src/ws_ui_group.c index 01f605c7..8e844fd1 100644 --- a/runtime/src/ws_ui_group.c +++ b/runtime/src/ws_ui_group.c @@ -51,6 +51,50 @@ static int same_element(const WsUiGroupItem *a, const WsUiGroupItem *b) { WS_UI_GROUP_STACK_GAP; } +/* Close enough on screen to be part of the same widget: horizontally within a + * glyph-gap, vertically overlapping or all but touching. Weaker than + * same_element(), which additionally demands shared columns. */ +static int touching(const WsUiGroupItem *a, const WsUiGroupItem *b) { + return interval_gap(a, b) <= WS_UI_GROUP_JOIN_GAP && + axis_gap(a->y, a->y + a->height, b->y, b->y + b->height) <= + WS_UI_GROUP_STACK_GAP; +} + +/* The three ways two primitives are one HUD element. + * + * The third is submission adjacency: consecutive in the ordering-table walk + * AND touching. The array arrives in submission order -- gpu_ws_prepass_linked_ + * list fills it as it walks the OT, and the max_rank filter compacts forward -- + * so "adjacent index" needs no extra field. + * + * It is what the shared-columns test was standing in for. WipEout 3's + * speed/shield readout ends at x=288 and the meter bar it belongs to starts at + * x=306: no shared column, so same_element() cannot fire, and the digit font's + * CLUT differs from the bar's, so the key join cannot fire either. The readout + * was left as its own run; its bbox midpoint, 256.5, landed in the middle third + * against a screen centre of 254; and it stayed pinned near screen centre while + * its own meters went to the right edge. A 2 px gap in 4:3 opens to 64 frame- + * buffer pixels at 16:9, 110 at 21:9 and 159 at 32:9. + * + * No anchor threshold can fix that. The genuinely centred "pit ... check" + * message sits FARTHER RIGHT (midpoint 300.5) than the mis-anchored readout, so + * any threshold that pushed the readout into the right third would drag the + * message there first and tear it apart. Submission order is the only signal + * that separates them: one widget's draw code emits its parts back to back. + * + * The gap and row tests keep it narrow -- consecutive packets that are far + * apart on screen are simply the next widget. Simulated over six captures this + * forms exactly one union that changes an anchor, and that union is the defect; + * the bottom-left cluster does not chain in, because its nearest consecutive + * neighbour pair is 66 px apart. */ +static int items_join(const WsUiGroupItem *items, size_t i, size_t j) { + const WsUiGroupItem *a = &items[i], *b = &items[j]; + if (same_element(a, b)) return 1; + if (a->key == b->key && interval_gap(a, b) <= WS_UI_GROUP_JOIN_GAP) return 1; + if (j == i + 1 && touching(a, b)) return 1; + return 0; +} + static size_t root_of(size_t *parent, size_t index) { while (parent[index] != index) { parent[index] = parent[parent[index]]; @@ -93,6 +137,7 @@ void ws_ui_group_assign(WsUiGroupItem *items, size_t count, * or a bar and its frame, could never merge. They then got INDEPENDENT * thirds anchors and were dragged toward opposite screen edges as the * frame widened. + * - SUBMISSION-ADJACENT and touching — see items_join(). * * Observed on WipEout 3's race HUD at 32:9: the lap readout's digits at * [137,202] anchored left while the box beneath them at [138,202] anchored @@ -101,10 +146,7 @@ void ws_ui_group_assign(WsUiGroupItem *items, size_t count, * aspect. */ for (size_t i = 0; i < count; i++) { for (size_t j = i + 1; j < count; j++) { - if (!same_element(&items[i], &items[j]) && - (items[i].key != items[j].key || - interval_gap(&items[i], &items[j]) > WS_UI_GROUP_JOIN_GAP)) - continue; + if (!items_join(items, i, j)) continue; size_t ri = root_of(parent, i); size_t rj = root_of(parent, j); if (ri != rj) parent[rj] = ri; diff --git a/runtime/tests/test_ws_ui_group.c b/runtime/tests/test_ws_ui_group.c index 3162db74..d00a6e1a 100644 --- a/runtime/tests/test_ws_ui_group.c +++ b/runtime/tests/test_ws_ui_group.c @@ -69,6 +69,36 @@ int main(void) { assert(stacked[2].root == stacked[0].root); assert(stacked[3].root != stacked[0].root); + /* Submission adjacency. WipEout 3's speed/shield readout ends at x=288 and + * the meter bar it belongs to starts at x=306 with a different CLUT, so + * neither shared columns nor a matching key can join them; only being + * consecutive in the ordering-table walk while touching can. The bottom + * -left cluster must NOT chain in through the same rule, so the item before + * the readout is a far-away neighbour. Coordinates are the captured ones, + * with display_width 508 rather than the 384 used above. */ + WsUiGroupItem submitted[] = { + {0x9a4d, 26, 32, 228, 16, 0, 0}, /* bottom-left, 66 px clear */ + {0xd82d, 277, 11, 234, 8, 0, 0}, /* last readout glyph */ + {0xb938, 306,112, 236, 6, 0, 0}, /* meter bar, gap 18, rows meet */ + {0xf3e1, 418, 16, 236, 8, 0, 0}, /* bar end cap */ + }; + ws_ui_group_assign(submitted, 4, 508, 0); + assert(submitted[1].root == submitted[2].root); /* readout joins meters */ + assert(submitted[2].root == submitted[3].root); + assert(submitted[0].root != submitted[1].root); /* left stays separate */ + assert(submitted[1].anchor == 508 && submitted[0].anchor == 0); + + /* The same two primitives NOT consecutive in the walk must not join -- + * otherwise the rule degenerates into plain proximity and chains the whole + * bottom row into one centre-anchored run. */ + WsUiGroupItem apart[] = { + {0xd82d, 277, 11, 234, 8, 0, 0}, + {0x1111, 10, 4, 10, 4, 0, 0}, /* unrelated, breaks adjacency */ + {0xb938, 306,112, 236, 6, 0, 0}, + }; + ws_ui_group_assign(apart, 3, 508, 0); + assert(apart[0].root != apart[2].root); + assert(ws_ui_anchor_for_bounds(8, 32, display_width) == 0); assert(ws_ui_anchor_for_bounds(344, 32, display_width) == 384); return 0;