From 85c88d269cd6f5b9bf33d14c53f703cf9c4cbddb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 1 Oct 2026 11:13:30 -0700 Subject: [PATCH] widescreen: auto_ui_anchor = "in_place" squashes each HUD run about its own centre Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/WIDESCREEN.md | 13 +++++++ recompiler/src/config_loader.cpp | 11 ++++++ recompiler/src/config_loader.h | 5 +++ runtime/include/gpu.h | 4 ++ runtime/include/ws_ui_group.h | 11 ++++-- runtime/src/debug_server.c | 5 ++- runtime/src/gpu.c | 8 +++- runtime/src/main.cpp | 1 + runtime/src/ws_ui_group.c | 14 +++++-- .../test_gpu_textured_dot_nw_shift_exec.c | 3 +- runtime/tests/test_ws_ui_group.c | 37 +++++++++++++++---- 11 files changed, 94 insertions(+), 18 deletions(-) diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index 098654b8..e5a16fee 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -95,6 +95,10 @@ auto_ui_squash = true # pre-scan the current GPU linked list, # select its highest populated UI rank, # and share one anchor across each # complete glyph/icon run. +auto_ui_anchor = "edges" # "edges" (default): each run pins to + # its left/centre/right third. + # "in_place": each run squashes about + # its own centre. clear_reveal = true # clear synthetic native-wide side margins # at opted-in scene/map boundaries (default false). nw_left_hud_packet_lo = "0x000E3400" # optional targeted left-HUD packet range @@ -141,6 +145,15 @@ layer are grouped by texture and screen row before any command is transformed. Depth-sorted world packets, full-frame backdrops, and true 4:3 frames remain untouched. +By default each run is pinned to the nearest screen edge or centre third, so +corner HUD moves to the wide-frame corners. Some HUD widgets mix those flat +quads with GTE-projected parts the correction cannot move: Spider-Man's compass +is two ring quads around a 3D arrow. Edge-anchoring the ring pulled it ~70 px +off its arrow at 32:9. `auto_ui_anchor = "in_place"` squashes each run about +its own centre instead, which corrects its proportions and leaves it where the +stretched 4:3 layout places it. `gpu_state` reports the mode as +`ws.auto_ui.in_place`. + **Changing `sprite_tag_funcs` requires a game regen** (the tag callback is emitted into the generated C). `widescreen.cull.keep` is consumed by both the native recompiler and the dirty-RAM interpreter: regenerate main/overlay native diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index ab673327..6108e6cf 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1494,6 +1494,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { uint32_t ws_sprite_anchor_addr = 0; bool ws_hud_sprt_squash = false; bool ws_auto_ui_squash = false; + bool ws_auto_ui_in_place = false; bool ws_full_2d = false; bool ws_gte_game_mode = false; bool ws_precise_nclip = false; @@ -1640,6 +1641,15 @@ GameConfig load_game_config(const fs::path& config_path_in) { ws_hud_sprt_squash = toml::find(ws, "hud_sprt_squash"); if (ws.contains("auto_ui_squash")) ws_auto_ui_squash = toml::find(ws, "auto_ui_squash"); + if (ws.contains("auto_ui_anchor")) { + const auto anchor = toml::find(ws, "auto_ui_anchor"); + if (anchor == "in_place") + ws_auto_ui_in_place = true; + else if (anchor != "edges") + throw std::runtime_error(fmt::format( + "{}: [widescreen] auto_ui_anchor must be \"edges\" or " + "\"in_place\", got \"{}\"", config_path.string(), anchor)); + } if (ws.contains("full_2d")) ws_full_2d = toml::find(ws, "full_2d"); if (ws.contains("gte_game_mode")) @@ -2223,6 +2233,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_sprite_anchor_addr*/ ws_sprite_anchor_addr, /*ws_hud_sprt_squash*/ ws_hud_sprt_squash, /*ws_auto_ui_squash*/ ws_auto_ui_squash, + /*ws_auto_ui_in_place*/ ws_auto_ui_in_place, /*data_shard_funcs*/ data_shard_funcs, /*mod_function_entry_funcs*/ mod_function_entry_funcs, /*hot_funcs*/ hot_funcs, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 089a4f02..d69873a4 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -864,6 +864,11 @@ struct GameConfig { // the final ordering-table layer. Repeated glyph/icon rows share an anchor // so centred text and edge counters cannot split at thirds boundaries. bool ws_auto_ui_squash = false; + // auto_ui_anchor = "in_place": squash each grouped UI run about its own + // centre instead of pinning it to the nearest screen edge/centre third. + // For HUD widgets that combine flat quads with GTE-projected parts the + // correction cannot move (Spider-Man's compass ring and 3D arrow). + bool ws_auto_ui_in_place = false; // [data_shards] funcs: functions that get the memoized pure-function // replay entry/return hooks (psx_datashard_enter/psx_datashard_ret). diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index e5a282d9..8f90b2d3 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -204,6 +204,9 @@ void gpu_ws_bg2d_set_host_arena(uint32_t base, uint32_t size); struct WsViewAnchor; int gpu_ws_bg2d_get_view(unsigned layer, struct WsViewAnchor *view); void gpu_ws_set_auto_ui_squash(int on); +/* [widescreen] auto_ui_anchor = "in_place": each UI run squashes about its own + * centre rather than an edge/centre third (default "edges"). */ +void gpu_ws_set_auto_ui_in_place(int on); /* [widescreen.bg2d] Capcom 2D background tile-loop widen — hooked at the renderer's * column-count / start-tile-col / start-screen-x instructions. Identity at 4:3 * and in the engine's 512 hi-res mode. */ @@ -535,6 +538,7 @@ typedef struct { uint32_t last_ovh_frame; /* newest SUSTAINED polygon-overhang frame (the 2D-only-scene classifier's world signal) */ int auto_ui_squash; /* final-OT grouped UI correction configured */ + int auto_ui_in_place; /* runs anchor at their own centre, not thirds */ int auto_ui_dense; /* current list classified as a dense menu */ uint32_t auto_ui_ot_rank; /* highest populated UI rank in current list */ uint64_t auto_ui_candidates; diff --git a/runtime/include/ws_ui_group.h b/runtime/include/ws_ui_group.h index 73b86ee1..090be814 100644 --- a/runtime/include/ws_ui_group.h +++ b/runtime/include/ws_ui_group.h @@ -37,12 +37,17 @@ typedef struct { uint32_t root; } WsUiGroupItem; -/* Assign one thirds anchor to each complete spatial run. Items with the same +/* Assign one anchor to each complete spatial run. Items with the same * texture/row key join when their horizontal intervals touch or are separated * by at most WS_UI_GROUP_JOIN_GAP pixels. This is intentionally a whole-frame - * operation: animated text is coherent on its first frame, not one frame late. */ + * operation: animated text is coherent on its first frame, not one frame late. + * + * The anchor is the run's thirds anchor (left edge, centre, right edge), or + * with in_place the run's own centre, so it keeps the position the stretched + * 4:3 layout gives it. in_place is for HUD widgets whose GTE-projected parts + * (never corrected) must stay registered with their flat quads. */ void ws_ui_group_assign(WsUiGroupItem *items, size_t count, - int32_t display_width, int dense_menu); + int32_t display_width, int dense_menu, int in_place); int32_t ws_ui_anchor_for_bounds(int32_t x, int32_t width, int32_t display_width); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index ea63b496..9b236be9 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5385,7 +5385,7 @@ static void handle_gpu_state(int id, const char *json) "\"cur_frame\":%llu,\"last_tag_frame\":%u,\"last_3d_frame\":%u," "\"gte_verts\":%u,\"last_world3d_frame\":%u," "\"ovh_prims\":%u,\"last_ovh_frame\":%u," - "\"auto_ui\":{\"configured\":%d,\"dense\":%d,\"ot_rank\":%u," + "\"auto_ui\":{\"configured\":%d,\"in_place\":%d,\"dense\":%d,\"ot_rank\":%u," "\"candidates\":%llu,\"transforms\":%llu}," "\"aspect_cone\":{\"calls\":%llu,\"identity_43\":%llu," "\"vanilla_keep\":%llu,\"visible_keep\":%llu," @@ -5418,7 +5418,8 @@ static void handle_gpu_state(int id, const char *json) (unsigned long long)ws.cur_frame, ws.last_tag_frame, ws.last_3d_frame, ws.gte_verts, ws.last_world3d_frame, ws.ovh_prims, ws.last_ovh_frame, - ws.auto_ui_squash, ws.auto_ui_dense, ws.auto_ui_ot_rank, + ws.auto_ui_squash, ws.auto_ui_in_place, ws.auto_ui_dense, + ws.auto_ui_ot_rank, (unsigned long long)ws.auto_ui_candidates, (unsigned long long)ws.auto_ui_transforms, (unsigned long long)ws.aspect_cone_calls, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 99007189..0a969285 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -106,6 +106,7 @@ static int32_t ws_xnum = 1, ws_xden = 1; /* X squash factor; 1/1 = off */ static uint32_t ws_anchor_addr = 0; /* scratchpad addr of anchor SXY */ static int ws_hud_sprt = 0; /* edge-anchor untagged HUD SPRTs */ static int ws_auto_ui_squash; +static int ws_auto_ui_in_place; static int ws_auto_ui_dense; static int ws_active(void); static int gp0_command_word_count(uint8_t opcode); @@ -215,6 +216,10 @@ static void ws_clear_all_reveal_margins(void); * engage (gpu_ws_set_full_2d); PSX_WS_FORCE_2D=1 forces it on for testing. */ static int ws_full_2d = 0; void gpu_ws_set_full_2d(int on) { ws_full_2d = on ? 1 : 0; } +void gpu_ws_set_auto_ui_in_place(int on) { + ws_auto_ui_in_place = on ? 1 : 0; +} + void gpu_ws_set_auto_ui_squash(int on) { ws_auto_ui_squash = on ? 1 : 0; ws_ui_prepass_count = 0; @@ -2061,6 +2066,7 @@ void gpu_ws_get_debug(GpuWsDebug* out) { out->ovh_prims = ws_ovh_prev; out->last_ovh_frame = ws_sust_ovh_stamp; out->auto_ui_squash = ws_auto_ui_squash; + out->auto_ui_in_place = ws_auto_ui_in_place; out->auto_ui_dense = ws_auto_ui_dense; out->auto_ui_ot_rank = ws_ui_prepass_rank != 0xFFFFu ? ws_ui_prepass_rank : UINT32_MAX; @@ -5471,7 +5477,7 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { for (uint32_t i = 0; i < ws_ui_prepass_count; i++) groups[i] = ws_ui_prepass[i].group; ws_ui_group_assign(groups, ws_ui_prepass_count, ws_disp_w(), - ws_auto_ui_dense); + ws_auto_ui_dense, ws_auto_ui_in_place); for (uint32_t i = 0; i < ws_ui_prepass_count; i++) ws_ui_prepass[i].group.anchor = group_origin + groups[i].anchor; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a14372ec..655a5f4f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -13416,6 +13416,7 @@ int main(int argc, char** argv) { g_ws_anchor_addr = gc.ws_sprite_anchor_addr; g_ws_hud_sprt = gc.ws_hud_sprt_squash; gpu_ws_set_auto_ui_squash(gc.ws_auto_ui_squash ? 1 : 0); + gpu_ws_set_auto_ui_in_place(gc.ws_auto_ui_in_place ? 1 : 0); /* [widescreen] full_2d — opt a pure-2D sprite game (MMX6) into the * widescreen present path. Applied to the GPU layer up front so the * ws engage at game entry classifies every frame as gameplay. */ diff --git a/runtime/src/ws_ui_group.c b/runtime/src/ws_ui_group.c index 8e844fd1..1f192dca 100644 --- a/runtime/src/ws_ui_group.c +++ b/runtime/src/ws_ui_group.c @@ -103,8 +103,14 @@ static size_t root_of(size_t *parent, size_t index) { return index; } +static int32_t run_anchor(int32_t x, int32_t width, int32_t display_width, + int in_place) { + if (in_place) return x + width / 2; + return ws_ui_anchor_for_bounds(x, width, display_width); +} + void ws_ui_group_assign(WsUiGroupItem *items, size_t count, - int32_t display_width, int dense_menu) { + int32_t display_width, int dense_menu, int in_place) { if (!items || count == 0 || display_width <= 0) return; if (dense_menu) { for (size_t i = 0; i < count; i++) { @@ -117,8 +123,8 @@ void ws_ui_group_assign(WsUiGroupItem *items, size_t count, size_t *parent = (size_t *)malloc(count * sizeof(*parent)); if (!parent) { for (size_t i = 0; i < count; i++) { - items[i].anchor = ws_ui_anchor_for_bounds( - items[i].x, items[i].width, display_width); + items[i].anchor = run_anchor( + items[i].x, items[i].width, display_width, in_place); items[i].root = (uint32_t)i; /* diag: each item stands alone */ } return; @@ -164,7 +170,7 @@ void ws_ui_group_assign(WsUiGroupItem *items, size_t count, max_x = items[j].x + items[j].width; } items[i].anchor = - ws_ui_anchor_for_bounds(min_x, max_x - min_x, display_width); + run_anchor(min_x, max_x - min_x, display_width, in_place); items[i].root = (uint32_t)root; } free(parent); diff --git a/runtime/tests/test_gpu_textured_dot_nw_shift_exec.c b/runtime/tests/test_gpu_textured_dot_nw_shift_exec.c index 9925a139..49dfb9a0 100644 --- a/runtime/tests/test_gpu_textured_dot_nw_shift_exec.c +++ b/runtime/tests/test_gpu_textured_dot_nw_shift_exec.c @@ -208,8 +208,9 @@ int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, return 0; } void ws_ui_group_assign(WsUiGroupItem *items, size_t count, - int32_t display_width, int dense_menu) { + int32_t display_width, int dense_menu, int in_place) { (void)items; (void)count; (void)display_width; (void)dense_menu; + (void)in_place; } int32_t ws_ui_anchor_for_bounds(int32_t x, int32_t width, int32_t display_width) { diff --git a/runtime/tests/test_ws_ui_group.c b/runtime/tests/test_ws_ui_group.c index d00a6e1a..5ffde23b 100644 --- a/runtime/tests/test_ws_ui_group.c +++ b/runtime/tests/test_ws_ui_group.c @@ -21,7 +21,7 @@ int main(void) { {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); + ws_ui_group_assign(text, 9, display_width, 0, 0); for (int i = 0; i < 9; i++) assert(text[i].anchor == 192); assert(scale_about(184, text[3].anchor, 4, 7) == scale_about(184, text[4].anchor, 4, 7)); @@ -30,7 +30,7 @@ int main(void) { {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); + ws_ui_group_assign(monkeys, 4, display_width, 0, 0); for (int i = 0; i < 4; i++) assert(monkeys[i].anchor == 384); for (int i = 0; i < 3; i++) { int32_t a = scale_about(monkeys[i].x, monkeys[i].anchor, 3, 4); @@ -43,12 +43,12 @@ int main(void) { {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); + ws_ui_group_assign(edge_runs, 4, display_width, 0, 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, 100, 16}, {5, 340, 16, 100, 16}}; - ws_ui_group_assign(dense, 2, display_width, 1); + ws_ui_group_assign(dense, 2, display_width, 1, 0); assert(dense[0].anchor == 192 && dense[1].anchor == 192); /* A digit drawn on top of its background box is ONE element even though @@ -62,7 +62,7 @@ int main(void) { {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); + ws_ui_group_assign(stacked, 4, display_width, 0, 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. */ @@ -82,7 +82,7 @@ int main(void) { {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); + ws_ui_group_assign(submitted, 4, 508, 0, 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 */ @@ -96,9 +96,32 @@ int main(void) { {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); + ws_ui_group_assign(apart, 3, 508, 0, 0); assert(apart[0].root != apart[2].root); + /* in_place: each run squashes about its own centre. Spider-Man's compass + * is two ring quads at [381,482] around a GTE-projected arrow the UI + * correction never moves; an edge anchor (512) pulled the ring ~70 px + * off the arrow at 32:9. The run keeps one shared anchor, so its two + * halves still meet. */ + WsUiGroupItem compass[] = { + {0x5d9e901c, 381, 51, 184, 40, 0, 0}, + {0x5d9e901c, 431, 51, 184, 40, 0, 0}, + {0x2f754f62, 23, 26, 63, 12, 0, 0}, /* top-left health readout */ + }; + ws_ui_group_assign(compass, 3, 512, 0, 0); + assert(compass[0].anchor == 512 && compass[1].anchor == 512); + assert(compass[2].anchor == 0); + ws_ui_group_assign(compass, 3, 512, 0, 1); + assert(compass[0].anchor == 431 && compass[1].anchor == 431); + assert(compass[0].root == compass[1].root); + assert(compass[2].anchor == 23 + 26 / 2); + assert(scale_about(381 + 51, compass[0].anchor, 9, 32) == + scale_about(431, compass[1].anchor, 9, 32)); + /* A dense 2D menu still centres every item, whatever the anchor mode. */ + ws_ui_group_assign(dense, 2, display_width, 1, 1); + assert(dense[0].anchor == 192 && dense[1].anchor == 192); + assert(ws_ui_anchor_for_bounds(8, 32, display_width) == 0); assert(ws_ui_anchor_for_bounds(344, 32, display_width) == 384); return 0;