diff --git a/runtime/include/ws_ui_group.h b/runtime/include/ws_ui_group.h index cf29aabb..73b86ee1 100644 --- a/runtime/include/ws_ui_group.h +++ b/runtime/include/ws_ui_group.h @@ -10,10 +10,22 @@ extern "C" { #define WS_UI_GROUP_JOIN_GAP 24 +/* Vertical slack, in scanlines, still counted as "stacked on" rather than + * "somewhere else on screen". A readout drawn as a label directly above its + * box leaves a one-pixel seam; the nearest thing that must NOT join is the + * other end of the screen, ~200 scanlines away. */ +#define WS_UI_GROUP_STACK_GAP 4 + typedef struct { uint32_t key; int32_t x; int32_t width; + /* Vertical extent, needed to tell "drawn on top of each other" (one visual + * element) from "happens to share screen columns" (the top-left lap counter + * and the bottom-left lap timer). Grouping only ever produces a HORIZONTAL + * anchor, but deciding the grouping is a 2-D question. */ + int32_t y; + int32_t height; int32_t anchor; /* Diagnostic only: index of the union-find representative this item * merged into, so an observer can tell "these two share an anchor because diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 20be5b9e..6bfa9d03 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -4664,6 +4664,8 @@ static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, ws_auto_ui_group_key_words(words, op, min_y, height); item->group.x = min_x - X; item->group.width = width; + item->group.y = min_y; + item->group.height = height; item->group.anchor = 0; item->group.root = ws_ui_prepass_count - 1u; item->src_addr = psx_ram_map_read(source_addr & 0x1FFFFFFFu) & ~3u; diff --git a/runtime/src/ws_ui_group.c b/runtime/src/ws_ui_group.c index 58bdce23..01f605c7 100644 --- a/runtime/src/ws_ui_group.c +++ b/runtime/src/ws_ui_group.c @@ -10,13 +10,45 @@ int32_t ws_ui_anchor_for_bounds(int32_t x, int32_t width, return display_width / 2; } +static int32_t axis_gap(int32_t a0, int32_t a1, int32_t b0, int32_t b1) { + if (a1 < b0) return b0 - a1; + if (b1 < a0) return a0 - b1; + return 0; +} + static int32_t interval_gap(const WsUiGroupItem *a, const WsUiGroupItem *b) { - int32_t a_right = a->x + a->width; - int32_t b_right = b->x + b->width; - if (a_right < b->x) return b->x - a_right; - if (b_right < a->x) return a->x - b_right; - return 0; + return axis_gap(a->x, a->x + a->width, b->x, b->x + b->width); +} + +/* Are the two primitives one visual element -- drawn over or directly against + * each other -- as opposed to merely close? interval_gap() cannot tell those + * apart, but they are very different signals: horizontal adjacency is a weak + * hint that two runs might belong together, whereas sharing screen columns + * while touching vertically means a glyph on its background box, a bar in its + * frame, or a label sitting on the readout it annotates. Those must never be + * anchored independently. + * + * Both halves of the test are load-bearing, and each was learned from a real + * failure on WipEout 3's race HUD at 32:9: + * + * - Drop the column test and everything joins by key alone, so a digit and + * the box under it (different CLUT, different poly-vs-rect family) get + * independent thirds anchors and are dragged to opposite screen edges. + * - Drop the row test and X alone merges the top-left lap counter with the + * bottom-left lap timer 200 scanlines below. Union is transitive, so that + * chained all 71 HUD primitives into one run spanning [24,486], which + * anchors centre and pulls the corners inward. + * - Demand a strict row INTERSECTION and stacked rows are missed: the lap + * readout draws its digits at y=[219,227] and the box they label at + * y=[228,244] -- a one-pixel seam. The box then anchored centre while its + * own digits anchored left, landing 500 screen pixels apart. Hence the + * STACK_GAP slack rather than a bare intersection. */ +static int same_element(const WsUiGroupItem *a, const WsUiGroupItem *b) { + if (a->x >= b->x + b->width || b->x >= a->x + a->width) + return 0; /* no shared columns */ + return axis_gap(a->y, a->y + a->height, b->y, b->y + b->height) <= + WS_UI_GROUP_STACK_GAP; } static size_t root_of(size_t *parent, size_t index) { @@ -50,11 +82,28 @@ void ws_ui_group_assign(WsUiGroupItem *items, size_t count, for (size_t i = 0; i < count; i++) parent[i] = i; /* Transitive union is important for text: each adjacent pair is close, - * even when the complete string spans more than one thirds region. */ + * even when the complete string spans more than one thirds region. + * + * Two ways to join, and the second is not a relaxation of the first: + * + * - SAME KEY and within JOIN_GAP — a run of glyphs from one font/CLUT. + * - SAME ELEMENT, whatever the key — see same_element(). Requiring a + * matching key here was a defect: the key folds in CLUT, texpage, a 24px + * Y band and the poly-vs-rect family, so a digit and the box it sits on, + * 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. + * + * 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 + * centre, putting the box ~500 screen pixels from the digits it belongs to. + * That is the HUD pulling itself apart, and it gets worse the wider the + * aspect. */ for (size_t i = 0; i < count; i++) { for (size_t j = i + 1; j < count; j++) { - if (items[i].key != items[j].key || - interval_gap(&items[i], &items[j]) > WS_UI_GROUP_JOIN_GAP) + 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; size_t ri = root_of(parent, i); size_t rj = root_of(parent, j); diff --git a/runtime/tests/test_ws_ui_group.c b/runtime/tests/test_ws_ui_group.c index 4c74f58c..3162db74 100644 --- a/runtime/tests/test_ws_ui_group.c +++ b/runtime/tests/test_ws_ui_group.c @@ -15,10 +15,11 @@ static int32_t scale_about(int32_t x, int32_t anchor, int main(void) { const int display_width = 384; + /* {key, x, width, y, height} — anchor and root are outputs. */ WsUiGroupItem text[] = { - {1, 120, 16, 0}, {1, 136, 16, 0}, {1, 152, 16, 0}, - {1, 168, 16, 0}, {1, 184, 16, 0}, {1, 200, 16, 0}, - {1, 216, 16, 0}, {1, 232, 16, 0}, {1, 248, 16, 0}, + {1, 120, 16, 100, 16}, {1, 136, 16, 100, 16}, {1, 152, 16, 100, 16}, + {1, 168, 16, 100, 16}, {1, 184, 16, 100, 16}, {1, 200, 16, 100, 16}, + {1, 216, 16, 100, 16}, {1, 232, 16, 100, 16}, {1, 248, 16, 100, 16}, }; ws_ui_group_assign(text, 9, display_width, 0); for (int i = 0; i < 9; i++) assert(text[i].anchor == 192); @@ -26,8 +27,8 @@ int main(void) { scale_about(184, text[4].anchor, 4, 7)); WsUiGroupItem monkeys[] = { - {2, 300, 16, 0}, {2, 320, 16, 0}, - {2, 340, 16, 0}, {2, 360, 16, 0}, + {2, 300, 16, 100, 16}, {2, 320, 16, 100, 16}, + {2, 340, 16, 100, 16}, {2, 360, 16, 100, 16}, }; ws_ui_group_assign(monkeys, 4, display_width, 0); for (int i = 0; i < 4; i++) assert(monkeys[i].anchor == 384); @@ -39,17 +40,35 @@ int main(void) { } WsUiGroupItem edge_runs[] = { - {3, 20, 16, 0}, {3, 36, 16, 0}, - {3, 330, 16, 0}, {3, 346, 16, 0}, + {3, 20, 16, 100, 16}, {3, 36, 16, 100, 16}, + {3, 330, 16, 100, 16}, {3, 346, 16, 100, 16}, }; ws_ui_group_assign(edge_runs, 4, display_width, 0); assert(edge_runs[0].anchor == 0 && edge_runs[1].anchor == 0); assert(edge_runs[2].anchor == 384 && edge_runs[3].anchor == 384); - WsUiGroupItem dense[] = {{4, 8, 16, 0}, {5, 340, 16, 0}}; + WsUiGroupItem dense[] = {{4, 8, 16, 100, 16}, {5, 340, 16, 100, 16}}; ws_ui_group_assign(dense, 2, display_width, 1); assert(dense[0].anchor == 192 && dense[1].anchor == 192); + /* A digit drawn on top of its background box is ONE element even though + * the group key (CLUT / texpage / Y band / poly-vs-rect family) differs. + * A primitive elsewhere on screen that merely shares the same columns is + * NOT — overlap has to be judged in 2-D or union-find chains the whole HUD + * into a single run and the corners collapse toward the centre. */ + WsUiGroupItem stacked[] = { + { 9, 200, 90, 200, 20, 0, 0}, /* background box */ + {10, 210, 16, 204, 12, 0, 0}, /* digit sitting on the box */ + {12, 204, 60, 191, 8, 0, 0}, /* label stacked directly above it */ + {11, 205, 24, 10, 16, 0, 0}, /* top row, same columns */ + }; + ws_ui_group_assign(stacked, 4, display_width, 0); + assert(stacked[0].root == stacked[1].root); + /* One scanline of seam between the label and the box is still one + * element — this is the WipEout 3 lap readout that flew apart. */ + assert(stacked[2].root == stacked[0].root); + assert(stacked[3].root != stacked[0].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;