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;