From 22684446436727541df143f2817f89b40f28ac7f Mon Sep 17 00:00:00 2001 From: themuffinator Date: Fri, 4 Sep 2026 19:05:48 +0100 Subject: [PATCH] Fix controller menu focus and expose light-grid preloading --- .github/workflows/commit-validation.yml | 2 + .github/workflows/push-verification.yml | 2 + content/baseoq4/pak0/guis/mainmenu.gui | 134 ++++++++------- .../pak0/guis/menu/settings/system.gui | 102 +++++++---- .../baseoq4/pak0/strings/english_openq4.lang | 3 + .../baseoq4/pak0/strings/french_openq4.lang | 3 + .../baseoq4/pak0/strings/italian_openq4.lang | 3 + .../baseoq4/pak0/strings/polish_openq4.lang | 3 + .../baseoq4/pak0/strings/russian_openq4.lang | 3 + .../baseoq4/pak0/strings/spanish_openq4.lang | 3 + docs/dev/release-completion.md | 3 + docs/dev/releases/v0.12.0.md | 7 +- docs/dev/settings-menu-registry.json | 20 +++ docs/dev/settings-menu-structure.md | 1 + docs/user/input-settings.md | 4 + docs/user/light-grids.md | 4 + src/framework/Session_menu.cpp | 13 +- src/ui/UserInterface.cpp | 4 + src/ui/UserInterfaceLocal.h | 3 + src/ui/Window.cpp | 128 +++++++------- src/ui/Window.h | 1 + tools/tests/menu_controller_navigation.py | 158 ++++++++++++++++++ tools/tests/settings_menu_coverage.py | 10 +- tools/validation/openq4_validate.py | 1 + 24 files changed, 450 insertions(+), 165 deletions(-) create mode 100644 tools/tests/menu_controller_navigation.py diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index c7faeeb18..ff4dc870d 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -230,6 +230,7 @@ jobs: tools/tests/savegame_pointer_width_safety.py \ tools/tests/savegame_v3_contract.py \ tools/tests/sdl3_input_parity.py \ + tools/tests/menu_controller_navigation.py \ tools/tests/sdl3_multidisplay_windowing.py \ tools/tests/settings_menu_coverage.py \ tools/tests/steam_deck_support.py \ @@ -393,6 +394,7 @@ jobs: python tools/tests/savegame_pointer_width_safety.py python tools/tests/savegame_v3_contract.py python tools/tests/sdl3_input_parity.py + python tools/tests/menu_controller_navigation.py python tools/tests/sdl3_multidisplay_windowing.py python tools/tests/settings_menu_coverage.py python tools/tests/steam_deck_support.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index 1a63b8fe9..f67f3435e 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -230,6 +230,7 @@ jobs: tools/tests/savegame_pointer_width_safety.py \ tools/tests/savegame_v3_contract.py \ tools/tests/sdl3_input_parity.py \ + tools/tests/menu_controller_navigation.py \ tools/tests/sdl3_multidisplay_windowing.py \ tools/tests/settings_menu_coverage.py \ tools/tests/steam_deck_support.py \ @@ -393,6 +394,7 @@ jobs: python tools/tests/savegame_pointer_width_safety.py python tools/tests/savegame_v3_contract.py python tools/tests/sdl3_input_parity.py + python tools/tests/menu_controller_navigation.py python tools/tests/sdl3_multidisplay_windowing.py python tools/tests/settings_menu_coverage.py python tools/tests/steam_deck_support.py diff --git a/content/baseoq4/pak0/guis/mainmenu.gui b/content/baseoq4/pak0/guis/mainmenu.gui index 0bdf09de9..01bbfd7db 100644 --- a/content/baseoq4/pak0/guis/mainmenu.gui +++ b/content/baseoq4/pak0/guis/mainmenu.gui @@ -16631,6 +16631,7 @@ windowDef Desktop set "set_sys_tonemap_val::noevents" "0" ; set "set_sys_crt_val::noevents" "0" ; set "set_sys_irradiance_val::noevents" "0" ; + set "set_sys_preload_val::noevents" "0" ; set "set_sys_ui_aspect_val::noevents" "0" ; set "set_sys_refresh_val::noevents" "0" ; set "set_sys_window_width_val::noevents" "0" ; @@ -16733,6 +16734,7 @@ windowDef Desktop set "set_sys_tonemap_val::noevents" "1" ; set "set_sys_crt_val::noevents" "1" ; set "set_sys_irradiance_val::noevents" "1" ; + set "set_sys_preload_val::noevents" "1" ; set "set_sys_ui_aspect_val::noevents" "1" ; set "set_sys_refresh_val::noevents" "1" ; set "set_sys_window_width_val::noevents" "1" ; @@ -16828,37 +16830,41 @@ windowDef Desktop set "set_sys_b21::rect" "228,644,377,25" ; set "set_sys_irradiance::rect" "259,646,172,18" ; set "set_sys_irradiance_val::rect" "468,646,160,15" ; - set "set_sys_b22::rect" "228,666,377,25" ; - set "set_sys_bloom::rect" "259,668,172,18" ; - set "set_sys_bloom_val::rect" "468,668,160,15" ; - set "set_sys_b23::rect" "228,688,377,25" ; - set "set_sys_ssao::rect" "259,690,172,18" ; - set "set_sys_ssao_val::rect" "468,690,160,15" ; - set "set_sys_b24::rect" "228,710,377,25" ; - set "set_sys_tonemap::rect" "259,712,172,18" ; - set "set_sys_tonemap_val::rect" "468,712,160,15" ; - set "set_sys_b25::rect" "228,732,377,25" ; - set "set_sys_crt::rect" "259,734,172,18" ; - set "set_sys_crt_val::rect" "468,734,160,15" ; - set "set_sys_display_tuning::rect" "259,762,327,13" ; - set "set_sys_b26::rect" "228,776,377,25" ; - set "set_sys_ui_aspect::rect" "259,778,172,18" ; - set "set_sys_ui_aspect_val::rect" "468,778,160,15" ; - set "set_sys_b27::rect" "228,798,377,25" ; - set "set_sys_refresh::rect" "259,800,172,18" ; - set "set_sys_refresh_val::rect" "468,800,160,15" ; - set "set_sys_b28::rect" "228,820,377,25" ; - set "set_sys_window_width::rect" "259,822,172,18" ; - set "set_sys_window_width_val::rect" "468,822,160,18" ; - set "set_sys_b29::rect" "228,842,377,25" ; - set "set_sys_window_height::rect" "259,844,172,18" ; - set "set_sys_window_height_val::rect" "468,844,160,18" ; - set "set_sys_b30::rect" "228,864,377,25" ; - set "set_sys_custom_width::rect" "259,866,172,18" ; - set "set_sys_custom_width_val::rect" "468,866,160,18" ; - set "set_sys_b31::rect" "228,886,377,25" ; - set "set_sys_custom_height::rect" "259,888,172,18" ; - set "set_sys_custom_height_val::rect" "468,888,160,18" ; + set "set_sys_b22::rect" "228,710,377,25" ; + set "set_sys_bloom::rect" "259,712,172,18" ; + set "set_sys_bloom_val::rect" "468,712,160,15" ; + set "set_sys_b23::rect" "228,732,377,25" ; + set "set_sys_ssao::rect" "259,734,172,18" ; + set "set_sys_ssao_val::rect" "468,734,160,15" ; + set "set_sys_b24::rect" "228,754,377,25" ; + set "set_sys_tonemap::rect" "259,756,172,18" ; + set "set_sys_tonemap_val::rect" "468,756,160,15" ; + set "set_sys_b25::rect" "228,776,377,25" ; + set "set_sys_crt::rect" "259,778,172,18" ; + set "set_sys_crt_val::rect" "468,778,160,15" ; + set "set_sys_display_tuning::rect" "259,806,327,13" ; + set "set_sys_b26::rect" "228,820,377,25" ; + set "set_sys_ui_aspect::rect" "259,822,172,18" ; + set "set_sys_ui_aspect_val::rect" "468,822,160,15" ; + set "set_sys_b27::rect" "228,842,377,25" ; + set "set_sys_refresh::rect" "259,844,172,18" ; + set "set_sys_refresh_val::rect" "468,844,160,15" ; + set "set_sys_b28::rect" "228,864,377,25" ; + set "set_sys_window_width::rect" "259,866,172,18" ; + set "set_sys_window_width_val::rect" "468,866,160,18" ; + set "set_sys_b29::rect" "228,886,377,25" ; + set "set_sys_window_height::rect" "259,888,172,18" ; + set "set_sys_window_height_val::rect" "468,888,160,18" ; + set "set_sys_b30::rect" "228,908,377,25" ; + set "set_sys_custom_width::rect" "259,910,172,18" ; + set "set_sys_custom_width_val::rect" "468,910,160,18" ; + set "set_sys_b31::rect" "228,930,377,25" ; + set "set_sys_custom_height::rect" "259,932,172,18" ; + set "set_sys_custom_height_val::rect" "468,932,160,18" ; + set "set_sys_preload_bg::rect" "228,666,377,25" ; + set "set_sys_preload::rect" "259,668,205,18" ; + set "set_sys_preload_val::rect" "468,668,137,15" ; + set "set_sys_preload_help::rect" "259,686,346,26" ; } onNamedEvent layoutSetSystemCompact { @@ -16903,37 +16909,41 @@ windowDef Desktop set "set_sys_b21::rect" "228,580,377,25" ; set "set_sys_irradiance::rect" "259,582,172,18" ; set "set_sys_irradiance_val::rect" "468,582,160,15" ; - set "set_sys_b22::rect" "228,602,377,25" ; - set "set_sys_bloom::rect" "259,604,172,18" ; - set "set_sys_bloom_val::rect" "468,604,160,15" ; - set "set_sys_b23::rect" "228,624,377,25" ; - set "set_sys_ssao::rect" "259,626,172,18" ; - set "set_sys_ssao_val::rect" "468,626,160,15" ; - set "set_sys_b24::rect" "228,646,377,25" ; - set "set_sys_tonemap::rect" "259,648,172,18" ; - set "set_sys_tonemap_val::rect" "468,648,160,15" ; - set "set_sys_b25::rect" "228,668,377,25" ; - set "set_sys_crt::rect" "259,670,172,18" ; - set "set_sys_crt_val::rect" "468,670,160,15" ; - set "set_sys_display_tuning::rect" "259,698,327,13" ; - set "set_sys_b26::rect" "228,712,377,25" ; - set "set_sys_ui_aspect::rect" "259,714,172,18" ; - set "set_sys_ui_aspect_val::rect" "468,714,160,15" ; - set "set_sys_b27::rect" "228,734,377,25" ; - set "set_sys_refresh::rect" "259,736,172,18" ; - set "set_sys_refresh_val::rect" "468,736,160,15" ; - set "set_sys_b28::rect" "228,756,377,25" ; - set "set_sys_window_width::rect" "259,758,172,18" ; - set "set_sys_window_width_val::rect" "468,758,160,18" ; - set "set_sys_b29::rect" "228,778,377,25" ; - set "set_sys_window_height::rect" "259,780,172,18" ; - set "set_sys_window_height_val::rect" "468,780,160,18" ; - set "set_sys_b30::rect" "228,800,377,25" ; - set "set_sys_custom_width::rect" "259,802,172,18" ; - set "set_sys_custom_width_val::rect" "468,802,160,18" ; - set "set_sys_b31::rect" "228,822,377,25" ; - set "set_sys_custom_height::rect" "259,824,172,18" ; - set "set_sys_custom_height_val::rect" "468,824,160,18" ; + set "set_sys_b22::rect" "228,646,377,25" ; + set "set_sys_bloom::rect" "259,648,172,18" ; + set "set_sys_bloom_val::rect" "468,648,160,15" ; + set "set_sys_b23::rect" "228,668,377,25" ; + set "set_sys_ssao::rect" "259,670,172,18" ; + set "set_sys_ssao_val::rect" "468,670,160,15" ; + set "set_sys_b24::rect" "228,690,377,25" ; + set "set_sys_tonemap::rect" "259,692,172,18" ; + set "set_sys_tonemap_val::rect" "468,692,160,15" ; + set "set_sys_b25::rect" "228,712,377,25" ; + set "set_sys_crt::rect" "259,714,172,18" ; + set "set_sys_crt_val::rect" "468,714,160,15" ; + set "set_sys_display_tuning::rect" "259,742,327,13" ; + set "set_sys_b26::rect" "228,756,377,25" ; + set "set_sys_ui_aspect::rect" "259,758,172,18" ; + set "set_sys_ui_aspect_val::rect" "468,758,160,15" ; + set "set_sys_b27::rect" "228,778,377,25" ; + set "set_sys_refresh::rect" "259,780,172,18" ; + set "set_sys_refresh_val::rect" "468,780,160,15" ; + set "set_sys_b28::rect" "228,800,377,25" ; + set "set_sys_window_width::rect" "259,802,172,18" ; + set "set_sys_window_width_val::rect" "468,802,160,18" ; + set "set_sys_b29::rect" "228,822,377,25" ; + set "set_sys_window_height::rect" "259,824,172,18" ; + set "set_sys_window_height_val::rect" "468,824,160,18" ; + set "set_sys_b30::rect" "228,844,377,25" ; + set "set_sys_custom_width::rect" "259,846,172,18" ; + set "set_sys_custom_width_val::rect" "468,846,160,18" ; + set "set_sys_b31::rect" "228,866,377,25" ; + set "set_sys_custom_height::rect" "259,868,172,18" ; + set "set_sys_custom_height_val::rect" "468,868,160,18" ; + set "set_sys_preload_bg::rect" "228,602,377,25" ; + set "set_sys_preload::rect" "259,604,205,18" ; + set "set_sys_preload_val::rect" "468,604,137,15" ; + set "set_sys_preload_help::rect" "259,622,346,26" ; } onNamedEvent applySetSystemScroll { diff --git a/content/baseoq4/pak0/guis/menu/settings/system.gui b/content/baseoq4/pak0/guis/menu/settings/system.gui index 69159ec18..062319f6a 100644 --- a/content/baseoq4/pak0/guis/menu/settings/system.gui +++ b/content/baseoq4/pak0/guis/menu/settings/system.gui @@ -50,7 +50,7 @@ } else if ( "gui::sys_section_choice" == 4 ) { set "gui::gui_set_sys_scroll" "21" ; } else { - set "gui::gui_set_sys_scroll" "26" ; + set "gui::gui_set_sys_scroll" "28" ; } set "cmd" "applySettingsScroll system" ; } @@ -983,7 +983,7 @@ } windowDef set_sys_bloom { - rect 259,668,172,18 + rect 259,712,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_41082" @@ -992,7 +992,7 @@ } choiceDef set_sys_bloom_val { - rect 468,668,160,15 + rect 468,712,160,15 visible 1 forecolor 1,0.745,0.137,0.8 choiceType 0 @@ -1014,7 +1014,7 @@ } windowDef set_sys_ssao { - rect 259,690,172,18 + rect 259,734,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_41088" @@ -1023,7 +1023,7 @@ } choiceDef set_sys_ssao_val { - rect 468,690,160,15 + rect 468,734,160,15 visible 1 forecolor 1,0.745,0.137,0.8 choiceType 0 @@ -1038,14 +1038,14 @@ } windowDef set_sys_b22 { - rect 228,666,377,25 + rect 228,710,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_tonemap { - rect 259,712,172,18 + rect 259,756,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_41084" @@ -1054,7 +1054,7 @@ } choiceDef set_sys_tonemap_val { - rect 468,712,160,15 + rect 468,756,160,15 visible 1 forecolor 1,0.745,0.137,0.8 choiceType 0 @@ -1069,14 +1069,14 @@ } windowDef set_sys_b23 { - rect 228,688,377,25 + rect 228,732,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_crt { - rect 259,734,172,18 + rect 259,778,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_41090" @@ -1085,7 +1085,7 @@ } choiceDef set_sys_crt_val { - rect 468,734,160,15 + rect 468,778,160,15 visible 1 forecolor 1,0.745,0.137,0.8 choiceType 0 @@ -1100,7 +1100,7 @@ } windowDef set_sys_b24 { - rect 228,710,377,25 + rect 228,754,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 @@ -1129,9 +1129,45 @@ set "cmd" "play main_menu_selection" ; } } + + windowDef set_sys_preload_bg { + rect 228,666,377,25 + background "gfx/guis/mainmenu/b3_light" + matcolor 0.545,0.588,0.294,0.4 + } + windowDef set_sys_preload { + rect 259,668,205,18 + forecolor 1,1,1,0.8 + text "#str_42820" + textscale 0.22 + textspacing -1 + font "fonts/lowpixel" + } + choiceDef set_sys_preload_val { + rect 468,668,137,15 + forecolor 1,0.745,0.137,0.8 + choiceType 0 + choices "#str_200059" + cvar "r_lightGridPreload" + textscale 0.24 + textspacing -1 + font "fonts/lowpixel" + onAction { + set "cmd" "play main_menu_selection" ; + } + } + windowDef set_sys_preload_help { + rect 259,686,346,26 + forecolor 1,1,1,0.6 + text "#str_42821" + textscale 0.16 + textspacing -1 + font "fonts/lowpixel" + noevents 1 + } windowDef set_sys_display_tuning { - rect 259,762,327,13 + rect 259,806,327,13 visible 1 forecolor 1,1,1,0.4 text "#str_229943" @@ -1140,14 +1176,14 @@ } windowDef set_sys_b25 { - rect 228,732,377,25 + rect 228,776,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_ui_aspect { - rect 259,778,172,18 + rect 259,822,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_229944" @@ -1156,7 +1192,7 @@ } choiceDef set_sys_ui_aspect_val { - rect 468,778,160,15 + rect 468,822,160,15 visible 1 forecolor 1,0.745,0.137,0.8 choiceType 0 @@ -1171,14 +1207,14 @@ } windowDef set_sys_b26 { - rect 228,776,377,25 + rect 228,820,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_refresh { - rect 259,800,172,18 + rect 259,844,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_229945" @@ -1187,7 +1223,7 @@ } choiceDef set_sys_refresh_val { - rect 468,800,160,15 + rect 468,844,160,15 visible 1 forecolor 1,0.745,0.137,0.8 choiceType 1 @@ -1208,14 +1244,14 @@ } windowDef set_sys_b27 { - rect 228,798,377,25 + rect 228,842,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_window_width { - rect 259,822,172,18 + rect 259,866,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_229946" @@ -1224,7 +1260,7 @@ } editDef set_sys_window_width_val { - rect 468,822,160,18 + rect 468,866,160,18 visible 1 forecolor 1,0.745,0.137,0.8 cvar r_windowWidth @@ -1247,14 +1283,14 @@ } windowDef set_sys_b28 { - rect 228,820,377,25 + rect 228,864,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_window_height { - rect 259,844,172,18 + rect 259,888,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_229947" @@ -1263,7 +1299,7 @@ } editDef set_sys_window_height_val { - rect 468,844,160,18 + rect 468,888,160,18 visible 1 forecolor 1,0.745,0.137,0.8 cvar r_windowHeight @@ -1286,14 +1322,14 @@ } windowDef set_sys_b29 { - rect 228,842,377,25 + rect 228,886,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_custom_width { - rect 259,866,172,18 + rect 259,910,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_229948" @@ -1302,7 +1338,7 @@ } editDef set_sys_custom_width_val { - rect 468,866,160,18 + rect 468,910,160,18 visible 1 forecolor 1,0.745,0.137,0.8 cvar r_customWidth @@ -1326,21 +1362,21 @@ } windowDef set_sys_b30 { - rect 228,864,377,25 + rect 228,908,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_b31 { - rect 228,886,377,25 + rect 228,930,377,25 visible 1 background "gfx/guis/mainmenu/b3_light" matcolor 0.545,0.588,0.294,0.4 } windowDef set_sys_custom_height { - rect 259,888,172,18 + rect 259,932,172,18 visible 1 forecolor 1,1,1,0.8 text "#str_229949" @@ -1349,7 +1385,7 @@ } editDef set_sys_custom_height_val { - rect 468,888,160,18 + rect 468,932,160,18 visible 1 forecolor 1,0.745,0.137,0.8 cvar r_customHeight @@ -1417,7 +1453,7 @@ forecolor 1,1,1,0.8 matcolor 1,1,1,0.4 low 0 - high 26 + high 28 step 1 vertical 1 scrollbar 1 diff --git a/content/baseoq4/pak0/strings/english_openq4.lang b/content/baseoq4/pak0/strings/english_openq4.lang index c97a85f9f..a88ba6515 100644 --- a/content/baseoq4/pak0/strings/english_openq4.lang +++ b/content/baseoq4/pak0/strings/english_openq4.lang @@ -923,4 +923,7 @@ "#str_42814" "SUDDEN DEATH" "#str_42815" "MATCH OVER" "#str_42816" "WAITING FOR PLAYERS" + + "#str_42820" "Preload Light Grids" + "#str_42821" "Smoother area changes; longer loads, more video memory.\nApplies next map load." } diff --git a/content/baseoq4/pak0/strings/french_openq4.lang b/content/baseoq4/pak0/strings/french_openq4.lang index 09bf82405..24a5a4861 100644 --- a/content/baseoq4/pak0/strings/french_openq4.lang +++ b/content/baseoq4/pak0/strings/french_openq4.lang @@ -923,4 +923,7 @@ "#str_42814" "MORT SUBITE" "#str_42815" "MATCH TERMINÉ" "#str_42816" "EN ATTENTE DE JOUEURS" + + "#str_42820" "Précharger la lumière" + "#str_42821" "Transitions plus fluides ; chargement plus long, plus de mémoire vidéo.\nActif au prochain chargement de carte." } diff --git a/content/baseoq4/pak0/strings/italian_openq4.lang b/content/baseoq4/pak0/strings/italian_openq4.lang index 1980814e1..375603ebe 100644 --- a/content/baseoq4/pak0/strings/italian_openq4.lang +++ b/content/baseoq4/pak0/strings/italian_openq4.lang @@ -923,4 +923,7 @@ "#str_42814" "MORTE IMPROVVISA" "#str_42815" "PARTITA CONCLUSA" "#str_42816" "IN ATTESA DI GIOCATORI" + + "#str_42820" "Precarica griglie luce" + "#str_42821" "Passaggi più fluidi; caricamenti più lunghi, più memoria video.\nSi applica al prossimo caricamento mappa." } diff --git a/content/baseoq4/pak0/strings/polish_openq4.lang b/content/baseoq4/pak0/strings/polish_openq4.lang index 26c456f11..3eb2038f6 100644 --- a/content/baseoq4/pak0/strings/polish_openq4.lang +++ b/content/baseoq4/pak0/strings/polish_openq4.lang @@ -923,4 +923,7 @@ "#str_42814" "NAGŁA ŚMIERĆ" "#str_42815" "KONIEC MECZU" "#str_42816" "OCZEKIWANIE NA GRACZY" + + "#str_42820" "Wczytaj siatki światła" + "#str_42821" "Płynniejsze przejścia; dłuższe ładowanie, więcej pamięci wideo.\nDziała od następnego wczytania mapy." } diff --git a/content/baseoq4/pak0/strings/russian_openq4.lang b/content/baseoq4/pak0/strings/russian_openq4.lang index 6ef27c7aa..2a839838f 100644 --- a/content/baseoq4/pak0/strings/russian_openq4.lang +++ b/content/baseoq4/pak0/strings/russian_openq4.lang @@ -923,4 +923,7 @@ "#str_42814" "ВНЕЗАПНАЯ СМЕРТЬ" "#str_42815" "МАТЧ ОКОНЧЕН" "#str_42816" "ОЖИДАНИЕ ИГРОКОВ" + + "#str_42820" "Предзагрузка света" + "#str_42821" "Плавнее переходы; дольше загрузка, больше видеопамяти.\nСо следующей загрузки карты." } diff --git a/content/baseoq4/pak0/strings/spanish_openq4.lang b/content/baseoq4/pak0/strings/spanish_openq4.lang index 859ebcf00..d61c6db3c 100644 --- a/content/baseoq4/pak0/strings/spanish_openq4.lang +++ b/content/baseoq4/pak0/strings/spanish_openq4.lang @@ -923,4 +923,7 @@ "#str_42814" "MUERTE SÚBITA" "#str_42815" "PARTIDA TERMINADA" "#str_42816" "ESPERANDO JUGADORES" + + "#str_42820" "Precargar iluminación" + "#str_42821" "Transiciones fluidas; cargas más largas, más memoria de vídeo.\nSe aplica al cargar el siguiente mapa." } diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index 96443c2e5..7188fd8ce 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -1,5 +1,8 @@ # openQ4 Release Completion List +- [x] Controller menu navigation now clears stale mouse highlights, hides the idle pointer, and traverses visible controls in both directions. Moving or clicking the mouse restores pointer control. +- [x] Players can reduce light-grid upload stutter through Settings > Display > Post FX > Preload Light Grids. The option applies on the next map load and explains its loading-time and video-memory costs; the default remains off. + Use this file as the source list for release changelog entries. Process: diff --git a/docs/dev/releases/v0.12.0.md b/docs/dev/releases/v0.12.0.md index 4c2f5860c..d577329a7 100644 --- a/docs/dev/releases/v0.12.0.md +++ b/docs/dev/releases/v0.12.0.md @@ -2,6 +2,9 @@ ## Highlights +- **Controller menu navigation no longer fights the mouse.** It now clears stale mouse highlights, hides the idle pointer, and traverses visible controls in both directions. Moving or clicking the mouse restores pointer control. +- **Light-grid preload is now easy to find.** Players can reduce traversal upload stutter through Settings > Display > Post FX > Preload Light Grids. The option applies on the next map load and explains its loading-time and video-memory costs; the default remains off. + - **High-refresh gameplay looks smoother without changing game logic.** The first-person camera and weapon, skeletal animation, projectiles, eligible moving-world entities, lights, client effects, and moving-world attachments now sample a presentation pose between authoritative game ticks. Animated cockpits, held weapons, and joint-bound effects stay aligned with the pose actually drawn, including the lightning gun beam, while simulation, networking, collision, demos, and savegames retain their original timing. Multiplayer actor bodies remain on the network/simulation clock; this includes the local body and world-weapon stencil shadows visible in first person. - **Experimental temporal AA can keep the 3D scene steadier while resolution follows GPU load.** `r_temporalAA 1` enables native-history TAA/TAAU on OpenGL or Vulkan, and `r_rendererDynamicResolution 1` adds a delayed, non-blocking GPU-time controller. HUD and menus stay native-sized, camera cuts and captures start from clean history, unsupported moving effects are rejected conservatively, and the established SMAA path remains one setting away. - **Repeat level-load caching is available as a guarded experiment without slowing ordinary play.** Classic source loading is again the default after testing found that cache preparation could make some stock loads much longer. Developers can opt in with `com_levelLoadModernization 1`; every reuse remains tied to exact source and runtime identity, while stale or corrupt data falls back to the installed source automatically. Repeat visits through that path are now considerably quicker than in earlier builds: on the complex `airdefense1` opening a return visit loads about a fifth faster than the classic default. The first visit to a map is still slower while the cache is prepared, which is why the setting stays off by default. @@ -13,7 +16,7 @@ - **Damaged or incomplete map data is refused instead of trusted.** Compiled world geometry with impossible surface counts or out-of-range triangle data now stops with a clear message naming the problem, matching how the engine already treats compiled shadow geometry. Every stock Quake 4 map continues to load unchanged. - **Stock maps no longer report or expose known startup gaps.** The engine first honours any loose or mod-provided versions of the three omitted brown-fluid effect images and the omitted large water-splash sample, then uses shape- and family-compatible media that did ship with Quake 4. Generated TrueType atlases also bind their uploaded images on first parse, wide loading backgrounds and generated image caches remain reliable from deeply nested save locations, and rigid bodies that the stock game deliberately clamps or forces to rest remain visible as developer diagnostics without being mislabeled as unresolved warnings. - **Autosaving no longer costs you the level you were playing.** A save could stop with a `refusing non-finite` error part way through writing, which unloaded the running map and left nothing to go back to. The value at fault belonged to a menu or HUD animation rather than to gameplay: an unset GUI variable was being read before anything had written it. Those variables now start from a defined value, and a cosmetic one that still arrives out of range costs a frame of colour or position instead of the save. Gameplay state is still checked as strictly as before. -- **Saves from older 0.12 development builds load again, and saves that cannot load are refused before they cost you the level you were playing.** Two player liquid fields were added to the save six days apart, but a single check decided both were present, so saves written in between read a field their file does not contain and failed part way through restoring — after the running map had already been unloaded. Each field is now matched to the build that introduced it. Saves too old for this build are turned away immediately, with a message naming which build wrote them, and you stay in the game you were playing. New saves use one deterministic class-frame layout in debug and release builds. +- **Saves from older 0.12 development builds load again, and saves that cannot load are refused before they cost you the level you were playing.** Two player liquid fields were added to the save six days apart, but a single check decided both were present, so saves written in between read a field their file does not contain and failed part way through restoring — after the running map had already been unloaded. Each field is now matched to the build that introduced it. Saves too old for this build are turned away immediately, with a message naming which build wrote them, and you stay in the game you were playing. New saves use one deterministic class-frame layout in debug and release builds. - **Polish and Russian are now built in.** Text rendering works by Unicode code point, the Polish tables use the correct Central European encoding path, and generated font data is checksum-validated so translated menus and HUD text remain reproducible across packages. - **The marine hovertank has its vehicle audio back.** Single-player once again creates and updates the engine and hover-pad loops used throughout the vehicle sequence, with safe cleanup during map or engine shutdown. - **Long macOS sessions keep their sound instead of dropping back to the menu.** Apple Silicon packages now include OpenAL Soft, avoiding the fixed source and buffer limits in Apple's legacy OpenAL framework that could stop stock levels after sustained audio use. @@ -21,7 +24,7 @@ - **Multiplayer setup is easier to control.** The server menu exposes guarded bot controls, and competitive free-for-all disclosure now accepts the active recipients intended by the match policy. - **Joining a multiplayer match takes one click.** Connecting now opens a compact join card over the map instead of the full in-game menu. It names the gametype and map, the scoring limit, how full the server is and whether the match is in warmup, and offers just the choices that mode allows - join, pick a side, or spectate - with settings and leave-match symbols in its header. The card is drawn in Quake 4's own menu style, keeps its shape and stays centred on a widescreen display, and the live view behind it is pushed out of focus so the text stays readable. The join screen is the default again: set `ui_autoJoin 1` if you would rather drop straight into play. - **The in-game depth-of-field blur now looks the same on both renderers.** OpenGL ignored the effect strength the game asked for and always blurred by a fixed amount, while Vulkan honoured it; a request for a gentle blur came out heavy on one renderer and a request for none at all came out blurred. Both now read the same controller the same way, so the join screen and the Arena Campaign presentation match whichever renderer you run. -- **Multiplayer function keys work as shown.** F1/F2 voting, F3 ready-up, F6 team switching, and F7 spectating once again use Quake 4's real impulse actions. Ready presses travel over the reliable path, the two-line warmup instruction remains fully visible, and only the exact older openQ4 defaults are upgraded—custom bindings stay untouched. No-time-limit deathmatch also starts without a spurious competitive-rules rejection. +- **Multiplayer function keys work as shown.** F1/F2 voting, F3 ready-up, F6 team switching, and F7 spectating once again use Quake 4's real impulse actions. Ready presses travel over the reliable path, the two-line warmup instruction remains fully visible, and only the exact older openQ4 defaults are upgraded—custom bindings stay untouched. No-time-limit deathmatch also starts without a spurious competitive-rules rejection. - **Pausing single-player is immediate.** Opening the in-game menu no longer blocks on save, mod, device, display, key-binding, or multiplayer-model discovery. Those lists refresh only when their own page is opened, and the normal level-loading phase prepares the menu art, fallback image, and music before the first Escape press. - **Single-player console tuning remains under the player's control.** The weapon wheel now uses a dedicated transient slow-motion channel instead of rewriting `timescale`, so values such as `timescale 0.5` remain set after the console closes. The old forced-run shortcut is also gone: `pm_walkspeed` once again controls actual walking when Always Run is off (or the run key temporarily inverts it). The wheel still slows simulation and audio while held, then restores only its own temporary effect. - **Smoke and effects no longer cut a hard line where they meet the world.** Soft particles are on by default now. Eligible effect sprites fade out as they approach solid surfaces instead of ending in a visible straight edge against a floor, wall, or crate, which is most obvious in the smoke and steam of scenes like the `airdefense1` opening. Decals, beams, electricity, trails, model debris, and custom-shader effects are untouched, and the fade width stays adjustable with `r_softParticleFadeDistance`. diff --git a/docs/dev/settings-menu-registry.json b/docs/dev/settings-menu-registry.json index 2cc95d427..7d9c28e37 100644 --- a/docs/dev/settings-menu-registry.json +++ b/docs/dev/settings-menu-registry.json @@ -1893,6 +1893,26 @@ "restart_required": false, "accessibility": "Standard focusable choiceDef row." }, + { + "id": "system.light_grid_preload", + "pane": "System", + "section": "Post Effects", + "path": "Settings > Display > Post FX > Preload Light Grids", + "source": "system", + "container": "set_sys_preload", + "widgets": [ + "set_sys_preload_val", + "set_sys_preload_help" + ], + "widget_type": "choice", + "label_key": "#str_42820", + "choices_key": "#str_200059", + "target_type": "cvar", + "target": "r_lightGridPreload", + "default": "0", + "restart_required": false, + "accessibility": "Focusable choice. Localized help explains loading and VRAM costs; applies on the next map load." + }, { "id": "system.ui_aspect", "pane": "System", diff --git a/docs/dev/settings-menu-structure.md b/docs/dev/settings-menu-structure.md index e445a3adf..7d304933b 100644 --- a/docs/dev/settings-menu-structure.md +++ b/docs/dev/settings-menu-structure.md @@ -280,6 +280,7 @@ The pane has two display layouts: | Label | Widget type | Value widget | Target | Values or range | Notes | |---|---|---|---|---|---| | Renderer Fallback | `choiceDef` | `set_sys_vidqual_val` | `r_renderer` | `best;arb2` | Displays `Auto;ARB2`. This is the compatibility rollback selector, not the historical multi-backend picker; legacy console/config values still fall back in engine code. Marks `desktop::vidwarn`. | +| Preload Light Grids | `choiceDef` | `set_sys_preload_val` | `r_lightGridPreload` | `No;Yes` | Preloads baked lighting on the next map load. Help explains smoother traversal, longer loads, and higher VRAM use. | | Irradiance Volumes | `choiceDef` | `set_sys_irradiance_val` | `r_useLightGrid` | `No;Yes` | Enables baked light-grid indirect diffuse when available. | | Bloom | `choiceDef` | `set_sys_bloom_val` | `r_bloom` | `No;Yes` | Boolean picker. | | SSAO | `choiceDef` | `set_sys_ssao_val` | `r_ssao` | `No;Yes` | Boolean picker. | diff --git a/docs/user/input-settings.md b/docs/user/input-settings.md index b208b2a15..82a1da283 100644 --- a/docs/user/input-settings.md +++ b/docs/user/input-settings.md @@ -320,3 +320,7 @@ On Steam Deck or SteamOS, direct `openQ4-client_x64` launches auto-select this p If you want a final personal override layer that survives menu changes, place your preferred `seta` and `bind` commands in `autoexec.cfg`. For Steam Deck launcher details and Deck-specific notes, see [steam-deck.md](steam-deck.md). + +### Switching between controller and mouse in menus + +Controller navigation clears the old mouse highlight and hides the menu pointer. Moving or clicking the mouse restores pointer control. Directional navigation follows the visible controls in either direction and wraps at the ends; hidden pages cannot retain navigation focus. Binding capture still receives the original controller button. diff --git a/docs/user/light-grids.md b/docs/user/light-grids.md index 04aff8bbf..35e7138df 100644 --- a/docs/user/light-grids.md +++ b/docs/user/light-grids.md @@ -437,6 +437,10 @@ openQ4-client_x64.exe +set logFileName logs/openq4_lightgrids.log +bakeLightGrid On a standard local setup, logs are written under `fs_savepath/baseoq4/logs/`. +### Reducing traversal stutter + +Use **Settings > Display > Post FX > Preload Light Grids** to load all baked light-grid atlases during the next map load. This can avoid upload-related pauses when entering new areas, at the cost of longer loading and more video memory. The default remains off. From the console, use `r_lightGridPreload 1`, then reload the map or load a save. + ## Related Documentation - [README.md](../../README.md) diff --git a/src/framework/Session_menu.cpp b/src/framework/Session_menu.cpp index 20aec93f6..d296cd87f 100644 --- a/src/framework/Session_menu.cpp +++ b/src/framework/Session_menu.cpp @@ -43,7 +43,7 @@ extern idCVar com_skipLogoVideos; extern glconfig_t glConfig; idCVar idSessionLocal::gui_configServerRate( "gui_configServerRate", "0", CVAR_GUI | CVAR_ARCHIVE | CVAR_ROM | CVAR_INTEGER, "" ); -idCVar gui_set_sys_scroll( "gui_set_sys_scroll", "0", CVAR_GUI | CVAR_INTEGER, "display menu scroll step", 0, 26 ); +idCVar gui_set_sys_scroll( "gui_set_sys_scroll", "0", CVAR_GUI | CVAR_INTEGER, "display menu scroll step", 0, 28 ); idCVar gui_set_audio_scroll( "gui_set_audio_scroll", "0", CVAR_GUI | CVAR_INTEGER, "audio menu scroll step", 0.0f, 0.0f ); idCVar gui_set_game_scroll( "gui_set_game_scroll", "0", CVAR_GUI | CVAR_INTEGER, "game menu scroll step", 0, 46 ); @@ -3328,10 +3328,10 @@ static const mainMenuSettingsScrollPage_t MAINMENU_SETTINGS_SCROLL_PAGES[] = { "sys_section_choice", 22, 0, - 26, + 28, -24, -88, - 914, + 958, 22.0f }, { @@ -3432,7 +3432,7 @@ static int MainMenuSettingsSectionChoiceForScroll( const mainMenuSettingsScrollP if ( scrollValue < 21 ) { return 3; } - if ( scrollValue < 26 ) { + if ( scrollValue < 28 ) { return 4; } return 5; @@ -3476,8 +3476,8 @@ static bool ApplyMainMenuSettingsScrollPage( idUserInterface *gui, const mainMen int contentHeight = page.contentHeight; float stepY = page.stepY; if ( idStr::Icmp( page.name, "system" ) == 0 && gui->GetStateInt( "display_count", "0" ) <= 1 ) { - contentHeight = 858; - stepY = 18.5f; + contentHeight = 902; + stepY = 19.5f; } const int scrollOffset = idMath::Ftoi( stepY * static_cast( scrollValue ) + 0.5f ); @@ -4160,4 +4160,3 @@ void idSessionLocal::HandleNoteCommands( const char *menuCommand ) { cvarSystem->SetCVarBool( "con_noPrint", bCon ); } } - diff --git a/src/ui/UserInterface.cpp b/src/ui/UserInterface.cpp index 349cf3b3b..56290248e 100644 --- a/src/ui/UserInterface.cpp +++ b/src/ui/UserInterface.cpp @@ -320,6 +320,7 @@ idUserInterfaceLocal::idUserInterfaceLocal() { interactive = false; uniqued = false; initialized = false; + controllerNavigation = false; bindHandler = NULL; lightColorVar = NULL; //so the reg eval in gui parsing doesn't get bogus values @@ -508,6 +509,9 @@ void idUserInterfaceLocal::Redraw( int _time, bool useAspectCorrection ) { } void idUserInterfaceLocal::DrawCursor() { + if ( controllerNavigation ) { + return; + } if ( !desktop || desktop->GetFlags() & WIN_MENUGUI ) { uiManagerLocal.dc.DrawCursor(&cursorX, &cursorY, 32.0f ); } else { diff --git a/src/ui/UserInterfaceLocal.h b/src/ui/UserInterfaceLocal.h index 79fc92181..d2342231a 100644 --- a/src/ui/UserInterfaceLocal.h +++ b/src/ui/UserInterfaceLocal.h @@ -84,6 +84,8 @@ class idUserInterfaceLocal : public idUserInterface { virtual idWindow * GetDesktop() const { return desktop; } void SetBindHandler( idWindow *win ) { bindHandler = win; } bool Active() const { return active; } + bool ControllerNavigation() const { return controllerNavigation; } + void SetControllerNavigation( bool enabled ) { controllerNavigation = enabled; } int GetTime() const { return time; } void SetTime( int _time ) { time = _time; } @@ -102,6 +104,7 @@ class idUserInterfaceLocal : public idUserInterface { bool interactive; bool uniqued; bool initialized; + bool controllerNavigation; // transient menu ownership; never serialized idDict state; idWindow * desktop; diff --git a/src/ui/Window.cpp b/src/ui/Window.cpp index b19803b20..a1d689d84 100644 --- a/src/ui/Window.cpp +++ b/src/ui/Window.cpp @@ -274,69 +274,48 @@ static bool WindowHasNativeEnterHandling( idWindow *window ) { dynamic_cast( window ) != NULL; } +static void CollectFocusWindows( idWindow *window, idList &candidates ) { + for ( int i = 0; i < window->GetChildCount(); ++i ) { + idWindow *child = window->GetChild( i ); + if ( child == NULL || !child->IsVisible() || child->HasNoEvents() ) { + continue; + } + if ( child->GetFlags() & WIN_CANFOCUS ) { + candidates.Append( child ); + } else { + CollectFocusWindows( child, candidates ); + } + } +} + static bool NavigateFocus( idWindow *window, int direction, bool runHoverScripts = false ) { if ( window == NULL || direction == 0 ) { return false; } + // Build one visible traversal order in both directions. Hidden pages can + // retain focus, and walking their parents used to trap navigation there. + idList candidates; + CollectFocusWindows( window, candidates ); + if ( candidates.Num() == 0 ) { + return false; + } idWindow *currentFocus = window->GetFocusedChild(); - idWindow *child = currentFocus; - idWindow *parent = child ? child->GetParent() : window; - - while ( parent ) { - bool foundFocus = false; - bool recurse = false; - int index = 0; - if ( child ) { - index = parent->GetChildIndex( child ) + direction; - } else if ( direction < 0 ) { - index = parent->GetChildCount() - 1; - } - while ( index < parent->GetChildCount() && index >= 0 ) { - idWindow *testWindow = parent->GetChild( index ); - if ( testWindow == currentFocus ) { - // we managed to wrap around and get back to our starting window - foundFocus = true; - break; + const int currentIndex = candidates.FindIndex( currentFocus ); + const int nextIndex = currentIndex < 0 + ? ( direction < 0 ? candidates.Num() - 1 : 0 ) + : ( currentIndex + ( direction < 0 ? -1 : 1 ) + candidates.Num() ) % candidates.Num(); + idWindow *nextFocus = candidates[ nextIndex ]; + if ( nextFocus != currentFocus ) { + window->SetFocus( nextFocus, false ); + if ( runHoverScripts ) { + if ( currentFocus != NULL ) { + currentFocus->MouseExit(); } - if ( testWindow && !testWindow->HasNoEvents() && testWindow->IsVisible() ) { - if ( testWindow->GetFlags() & WIN_CANFOCUS ) { - idWindow *lastFocus = window->SetFocus( testWindow, false ); - if ( runHoverScripts ) { - if ( lastFocus != NULL && lastFocus != testWindow ) { - lastFocus->MouseExit(); - } - testWindow->MouseEnter(); - } - foundFocus = true; - break; - } else if ( testWindow->GetChildCount() > 0 ) { - parent = testWindow; - child = NULL; - recurse = true; - break; - } - } - index += direction; - } - if ( foundFocus ) { - return true; - } - if ( recurse ) { - continue; - } - - // We didn't find anything, so go back up to our parent. - child = parent; - parent = child->GetParent(); - if ( parent != NULL && parent->GetGui() != NULL && parent == parent->GetGui()->GetDesktop() ) { - // We got back to the desktop, so wrap around but don't actually go to the desktop. - parent = NULL; - child = NULL; + nextFocus->MouseEnter(); } } - - return false; + return true; } } @@ -849,6 +828,22 @@ void idWindow::MouseExit() { RunScript(ON_MOUSEEXIT); } +void idWindow::ClearMouseHover() { + // Hover is a path through nested containers, independent of focusedChild. + // Clear the whole path so an old mouse highlight cannot outlive takeover. + for ( int i = 0; i < children.Num(); ++i ) { + children[i]->ClearMouseHover(); + } + hover = false; + if ( overChild != NULL ) { + overChild->MouseExit(); + gui->GetPendingCmd() += overChild->cmd; + gui->GetPendingCmd() += " ; "; + overChild->cmd.Clear(); + overChild = NULL; + } +} + /* ================ @@ -1193,7 +1188,8 @@ const char *idWindow::HandleEvent(const sysEvent_t *event, bool *updateVisuals) return ""; } - if ( TranslateControllerMenuKey( event, translatedEvent, controllerFocusDirection ) ) { + const bool controllerEvent = TranslateControllerMenuKey( event, translatedEvent, controllerFocusDirection ); + if ( controllerEvent ) { event = &translatedEvent; } @@ -1208,6 +1204,26 @@ const char *idWindow::HandleEvent(const sysEvent_t *event, bool *updateVisuals) RunTimeEvents(gui->GetTime()); CalcRects(0,0); dc->SetCursor( idDeviceContext::CURSOR_ARROW ); + + if ( controllerEvent && event->evValue2 && !gui->ControllerNavigation() && !GetCaptureChild() ) { + ClearMouseHover(); + gui->SetControllerNavigation( true ); + if ( focusedChild != NULL ) { + focusedChild->MouseEnter(); + } + } else if ( gui->ControllerNavigation() ) { + const bool mouseMoved = event->evType == SE_MOUSE && ( event->evValue != 0 || event->evValue2 != 0 ); + const bool mousePressed = event->evType == SE_KEY && event->evValue2 && + event->evValue >= K_MOUSE1 && event->evValue <= K_MWHEELUP; + if ( mouseMoved || mousePressed ) { + if ( focusedChild != NULL ) { + focusedChild->MouseExit(); + } + ClearMouseHover(); + gui->SetControllerNavigation( false ); + RouteMouseCoords( 0, 0 ); + } + } } if (visible && !noEvents) { @@ -1390,7 +1406,7 @@ const char *idWindow::HandleEvent(const sysEvent_t *event, bool *updateVisuals) } } - } else if (event->evType == SE_MOUSE) { + } else if (event->evType == SE_MOUSE && !gui->ControllerNavigation()) { if (updateVisuals) { *updateVisuals = true; } diff --git a/src/ui/Window.h b/src/ui/Window.h index b8366026e..d96ea0d26 100644 --- a/src/ui/Window.h +++ b/src/ui/Window.h @@ -310,6 +310,7 @@ class idWindow { virtual void Moved(); virtual void Draw(int time, float x, float y); virtual void MouseExit(); + void ClearMouseHover(); virtual void MouseEnter(); virtual void DrawBackground(const idRectangle &drawRect); virtual const char *RouteMouseCoords(float xd, float yd); diff --git a/tools/tests/menu_controller_navigation.py b/tools/tests/menu_controller_navigation.py new file mode 100644 index 000000000..c31c20cc7 --- /dev/null +++ b/tools/tests/menu_controller_navigation.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Execute the production menu traversal/hover code against an isolated tree. + +No window, input device, or running game is used. A C++ compiler is required. +""" +from pathlib import Path +import os +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[2] + + +def extract(source: str, signature: str) -> str: + start = source.index(signature) + opening = source.index('{', start) + depth = 0 + for i in range(opening, len(source)): + depth += (source[i] == '{') - (source[i] == '}') + if depth == 0: + return source[start:i + 1] + raise AssertionError(f'Unterminated {signature}') + + +HARNESS = r''' +#include +#include +#include +#include +#include +template struct idList : std::vector { + using std::vector::operator=; + int Num() const { return static_cast(this->size()); } + void Append(T v) { this->push_back(v); } + int FindIndex(T v) const { + auto i = std::find(this->begin(), this->end(), v); + return i == this->end() ? -1 : static_cast(i - this->begin()); + } +}; +struct Text : std::string { void Clear() { clear(); } }; +enum { WIN_CANFOCUS = 1, SE_MOUSE, SE_KEY, K_MOUSE1 = 187, K_MWHEELUP = 196 }; +struct sysEvent_t { int evType, evValue, evValue2; }; +struct Gui { + bool controller = false; + Text pending; + bool ControllerNavigation() const { return controller; } + void SetControllerNavigation(bool v) { controller = v; } + Text& GetPendingCmd() { return pending; } +}; +struct idWindow { + idList children; + idWindow *focusedChild = nullptr, *overChild = nullptr, *capture = nullptr; + Gui *gui; + Text cmd; + bool visible = true, noEvents = false, hover = false; + int flags = 0, enters = 0, exits = 0, routes = 0; + explicit idWindow(Gui& g, bool focusable = false) : gui(&g), flags(focusable ? WIN_CANFOCUS : 0) {} + int GetChildCount() const { return children.Num(); } + idWindow* GetChild(int i) { return children[i]; } + bool IsVisible() const { return visible; } + bool HasNoEvents() const { return noEvents; } + int GetFlags() const { return flags; } + idWindow* GetFocusedChild() { return focusedChild; } + idWindow* GetCaptureChild() { return capture; } + idWindow* SetFocus(idWindow* w, bool) { auto old = focusedChild; focusedChild = w; return old; } + void MouseEnter() { ++enters; } + void MouseExit() { ++exits; } + void RouteMouseCoords(float, float) { ++routes; } + void ClearMouseHover(); + void UpdateOwnership(bool controllerEvent, const sysEvent_t* event); +}; +''' + +TESTS = r''' +int main() { + Gui gui; + idWindow root(gui), group(gui), a(gui,true), b(gui,true), c(gui,true), hidden(gui), h(gui,true), disabled(gui,true); + root.children = {&group, &c, &hidden, &disabled}; + group.children = {&a, &b}; hidden.children = {&h}; + hidden.visible = false; disabled.noEvents = true; + assert(NavigateFocus(&root, -1, true) && root.focusedChild == &c); + assert(NavigateFocus(&root, -1, true) && root.focusedChild == &b); + assert(NavigateFocus(&root, -1, true) && root.focusedChild == &a); + assert(NavigateFocus(&root, -1, true) && root.focusedChild == &c); + assert(NavigateFocus(&root, 1, true) && root.focusedChild == &a); + assert(NavigateFocus(&root, 1, true) && root.focusedChild == &b); + assert(NavigateFocus(&root, 1, true) && root.focusedChild == &c); + root.focusedChild = &h; + assert(NavigateFocus(&root, 1, true) && root.focusedChild == &a); + root.focusedChild = &h; + assert(NavigateFocus(&root, -1, true) && root.focusedChild == &c); + assert(!NavigateFocus(&root, 0)); + idWindow empty(gui); + assert(!NavigateFocus(&empty, 1)); + + // Hover follows a nested mouse path, not the current focused window. + root.overChild = &group; group.overChild = &b; + group.hover = b.hover = true; + const int groupExits = group.exits, bExits = b.exits; + sysEvent_t controllerDown{SE_KEY, 0, 1}; + root.UpdateOwnership(true, &controllerDown); + assert(gui.controller && !root.overChild && !group.overChild); + assert(!group.hover && !b.hover); + assert(group.exits == groupExits + 1 && b.exits == bExits + 1); + int focusEnters = c.enters; + root.UpdateOwnership(true, &controllerDown); + assert(c.enters == focusEnters); // repeats don't re-enter old focus + + sysEvent_t idleMouse{SE_MOUSE, 0, 0}; + root.UpdateOwnership(false, &idleMouse); + assert(gui.controller && root.routes == 0); + sysEvent_t controllerUp{SE_KEY, 0, 0}; + root.UpdateOwnership(true, &controllerUp); + assert(gui.controller); + sysEvent_t motion{SE_MOUSE, 1, 0}; + root.UpdateOwnership(false, &motion); + assert(!gui.controller && root.routes == 1); + root.UpdateOwnership(true, &controllerDown); + sysEvent_t click{SE_KEY, K_MOUSE1, 1}; + root.UpdateOwnership(false, &click); + assert(!gui.controller && root.routes == 2); + root.capture = &a; + root.UpdateOwnership(true, &controllerDown); + assert(!gui.controller); // do not interrupt an active mouse drag + std::puts("menu_controller_navigation: behavioral checks passed"); +} +''' + + +def main() -> None: + source = (ROOT / 'src/ui/Window.cpp').read_text(encoding='utf-8') + snippets = '\n'.join(extract(source, sig) for sig in ( + 'static void CollectFocusWindows(', 'static bool NavigateFocus(', + 'void idWindow::ClearMouseHover()')) + ownership = extract(source, 'if ( controllerEvent && event->evValue2') + next_start = source.index(ownership) + len(ownership) + ownership += ' else ' + extract(source[next_start:], 'if ( gui->ControllerNavigation() )') + # Keep dispatch/cursor suppression tied to the code under test. + assert 'event->evType == SE_MOUSE && !gui->ControllerNavigation()' in source + ui = (ROOT / 'src/ui/UserInterface.cpp').read_text(encoding='utf-8') + assert 'if ( controllerNavigation )' in extract(ui, 'void idUserInterfaceLocal::DrawCursor()') + handle = extract(ui, 'const char *idUserInterfaceLocal::HandleEvent(') + assert handle.index('bindHandler->HandleEvent') < handle.index('desktop->HandleEvent') + build = ROOT / '.tmp/menu-controller-navigation' + build.mkdir(parents=True, exist_ok=True) + cpp = build / 'test.cpp' + cpp.write_text(HARNESS + snippets + '\nvoid idWindow::UpdateOwnership(bool controllerEvent, const sysEvent_t* event) {\n' + ownership + '\n}\n' + TESTS, encoding='utf-8') + compiler = os.environ.get('CXX') or shutil.which('clang++') or shutil.which('g++') or shutil.which('cl') + if not compiler: + raise RuntimeError('Set CXX to a C++ compiler or run from a developer shell.') + exe = build / ('test.exe' if os.name == 'nt' else 'test') + args = [compiler, '/nologo', '/EHsc', '/std:c++17', str(cpp), f'/Fe:{exe}', f'/Fo:{build}/'] if Path(compiler).stem.lower() == 'cl' else [compiler, '-std=c++17', str(cpp), '-o', str(exe)] + subprocess.run(args, check=True, cwd=build) + subprocess.run([str(exe)], check=True, cwd=build) + + +if __name__ == '__main__': + main() diff --git a/tools/tests/settings_menu_coverage.py b/tools/tests/settings_menu_coverage.py index f0d51fc9a..049d21977 100644 --- a/tools/tests/settings_menu_coverage.py +++ b/tools/tests/settings_menu_coverage.py @@ -952,7 +952,7 @@ def main() -> None: require(game_hovers, f"windowDef {row}_hover", "Game Options hovers") system_surface = system_gui + "\n" + popups_gui + "\n" + mainmenu - for token in ("high\t26", "cvar\tgui_set_sys_scroll"): + for token in ("high\t28", "cvar\tgui_set_sys_scroll"): require(system_surface, token, "System scroll coverage") for token in ( "windowDef set_sys_section", @@ -965,7 +965,7 @@ def main() -> None: 'set "gui::gui_set_sys_scroll" "9"', 'set "gui::gui_set_sys_scroll" "14"', 'set "gui::gui_set_sys_scroll" "21"', - 'set "gui::gui_set_sys_scroll" "26"', + 'set "gui::gui_set_sys_scroll" "28"', 'set "cmd" "applySettingsScroll system"', "set_sys_section_choice::noevents", ): @@ -1005,7 +1005,7 @@ def main() -> None: reject(mainmenu, 'set "gui_set_audio_scroll"', "Audio scroll GUI state assignment") reject(audio_gui + mainmenu, "set gui_set_audio_scroll", "Audio scroll cvar command") for token in ( - 'gui_set_sys_scroll( "gui_set_sys_scroll", "0", CVAR_GUI | CVAR_INTEGER, "display menu scroll step", 0, 26 )', + 'gui_set_sys_scroll( "gui_set_sys_scroll", "0", CVAR_GUI | CVAR_INTEGER, "display menu scroll step", 0, 28 )', 'gui_set_game_scroll( "gui_set_game_scroll", "0", CVAR_GUI | CVAR_INTEGER, "game menu scroll step", 0, 46 )', "HandleMainMenuSettingsScrollInput( guiActive, event->evValue )", 'MainMenuWindowStateEqualsInt( gui, "desktop::curr", page.expectedPage )', @@ -1018,8 +1018,8 @@ def main() -> None: '"set_game_content::rect"', '"set_sys_content::rect"', '"set_audio_content::rect"', - "914", - "858", + "958", + "902", "24.0f", ): require(session_menu, token, "C++ settings scroll bounds") diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index 5814dc060..757f13e68 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -545,6 +545,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "savegame_pointer_width_safety.py", root / "tools" / "tests" / "savegame_v3_contract.py", root / "tools" / "tests" / "sdl3_input_parity.py", + root / "tools" / "tests" / "menu_controller_navigation.py", root / "tools" / "tests" / "sdl3_multidisplay_windowing.py", root / "tools" / "tests" / "settings_menu_coverage.py", root / "tools" / "tests" / "source_charset_integrity.py",