diff --git a/content/baseoq4/pak0/guis/mainmenu.gui b/content/baseoq4/pak0/guis/mainmenu.gui index b183cbfb..0bdf09de 100644 --- a/content/baseoq4/pak0/guis/mainmenu.gui +++ b/content/baseoq4/pak0/guis/mainmenu.gui @@ -13649,7 +13649,7 @@ windowDef Desktop notime 1 onTime 0 { - set "cmd" "MAPScan" ; + set "cmd" "refreshSaveGameList" ; transition "p_loadgame::rect" "$d_main_l::rect" "$d_main::rect" "0" ; set "p_loadgame::visible" "1" ; @@ -13712,6 +13712,7 @@ windowDef Desktop notime 1 onTime 0 { + set "cmd" "refreshSaveGameList" ; transition "p_savegame::rect" "$d_main_l::rect" "$d_main::rect" "0" ; set "p_savegame::visible" "1" ; transition "t_save_title::forecolor_w" "0" "0.5" "150" ; @@ -14119,6 +14120,13 @@ windowDef Desktop if ( "gui::ingame" == 2 ) { set "cmd" "GetCVarValue g_corpseRemoveDelayMP cvar_g_corpseRemoveDelayMP" ; } + if ( "desktop::dest" == 4 ) { + set "cmd" "refreshKeyBindings" ; + } else if ( "desktop::dest" == 22 ) { + set "cmd" "refreshSystemSettings" ; + } else if ( "desktop::dest" == 36 ) { + set "cmd" "refreshAudioSettings" ; + } transition "p_settings::rect" "$d_main_l::rect" "$d_main::rect" "0" ; @@ -14445,6 +14453,7 @@ windowDef Desktop notime 1 onTime 0 { + set "cmd" "refreshMods" ; set "pop_p_mods::visible" "1" ; namedevent "p_btns::hideMain" ; transition "pop_mods_black::backcolor_w" "0" "0.94" "200" ; @@ -18494,6 +18503,7 @@ windowDef Desktop } onAction { + set "cmd" "refreshKeyBindings" ; set "p_settings_ctrls::visible" "1" ; set "p_settings_game::visible" "0" ; set "p_settings_sys::visible" "0" ; @@ -18693,6 +18703,7 @@ windowDef Desktop } onAction { + set "cmd" "refreshSystemSettings" ; set "p_settings_ctrls::visible" "0" ; set "p_settings_game::visible" "0" ; set "p_settings_sys::visible" "1" ; @@ -18794,6 +18805,7 @@ windowDef Desktop } onAction { + set "cmd" "refreshAudioSettings" ; set "p_settings_ctrls::visible" "0" ; set "p_settings_game::visible" "0" ; set "p_settings_sys::visible" "0" ; diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index 21d335fb..1d092477 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -36,6 +36,10 @@ is `docs/dev/macos-moltenvk-decision.md`. ## Ready For Changelog +- [x] Opening the in-game menu in single-player no longer waits for save-game, mod, key-binding, audio-device, display-mode, or multiplayer-model enumeration. The pause screen now activates immediately and refreshes each potentially expensive catalog only when the player opens the page that needs it; its background, fallback levelshot, and music are also re-referenced during normal level precaching instead of on the first Escape press. +- [x] Bitmap GUI text now selects its 12-, 24-, or 48-point source atlas using the final viewport enlargement as well as the authored 640x480-era text scale. Menus and HUD text therefore stay sharp at 1080p, 1440p, and 4K without the community workaround of forcing both font-limit CVars to zero, while text layout and low-resolution behavior remain unchanged. +- [x] Developer and nightly builds now regenerate their Git-derived version header on every build invocation while avoiding downstream recompilation when the value is unchanged. The console banner, executable metadata, engine, and both game modules therefore report the current commit without requiring a fresh Meson setup. +- [x] The built-in `dmap` compiler now resolves Quake 4 `func_group` entities before compiling geometry, just like the retail compiler and runtime map loader. This fixes false world leaks and missing grouped world geometry when compiling editable retail or custom `.map` sources, including the `hangar1` failure reported in GitHub discussion #124. - [x] Linux save and configuration directories now work when an explicit home/save path contains an ordinary ellipsis (`...`) or another repeated-dot name. Directory creation still rejects a complete `..` parent component, preserving traversal protection without mistaking legitimate absolute paths for traversal attempts. This fixes the persistent-settings failure reported in GitHub issue #127. - [x] Players can now choose smooth, sharp-mip, or deliberately pixelated material-texture sampling with the restored archived `image_filter` setting. All six retail Quake 4 filter names work on OpenGL and experimental Vulkan, changes update loaded textures immediately, and nearest-texel modes avoid anisotropic blur without overriding explicitly filtered HUD, font, shadow, or internal texture sampling. This resolves GitHub issue #121. - [x] Milestone F now has a locally validated, default-off implementation for explicitly authored PBR materials, bounded OpenGL specular probes, and atomic OpenGL clustered decals. The 2026-08-23 current-source debug x64 evidence passed the static PBR/advanced-lighting checks, the final native 10/10 run, the final focused engine 2/2 rerun after an earlier 8/8 safe set, all four retail-PK4 compatibility roles, forced GL 3.3/4.1/4.3/4.5 PBR execution, the narrow validation-enabled Vulkan route, and exact `r_rendererModernQuality 0` rollback against leaf-disabled images. The stock baseline used all 40 retail PK4s with zero loose retail files; the generated PBR fixture remained temporary under `.tmp/`. PBR, probe, decal, and modern-visible controls stay default-off, `MODERN_LIGHTING_PARITY_PROVEN_DOMAINS` stays `0`, and this local implementation exit is not final committed-package/platform/driver promotion or broad authored probe/decal visual qualification. diff --git a/docs/dev/releases/v0.12.0.md b/docs/dev/releases/v0.12.0.md index 40cce90d..95b60dea 100644 --- a/docs/dev/releases/v0.12.0.md +++ b/docs/dev/releases/v0.12.0.md @@ -18,7 +18,9 @@ - **Arena Campaign scoring starts reliably.** Disabling ready-up now also clears the ready threshold, preventing deathmatch-backed Arena cards from remaining forever in a non-scoring warmup. - **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. - **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. -- **Developer map compilation is more reliable.** `dmap` and other engine-side geometry tools initialize their triangle-surface allocator before generating light volumes, fixing the Windows crash seen while compiling `game/airdefense1`. +- **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. +- **Menu and HUD text stays sharp on modern displays.** Bitmap fonts now choose their source atlas using the final viewport enlargement, preventing a small 640x480-era atlas from being magnified at 1080p, 1440p, or 4K. Existing text layout is unchanged, and manually customized font-limit CVars still take precedence. +- **Developer map compilation is more reliable.** `dmap` now resolves editable Quake 4 `func_group` entities into world geometry before compilation, matching the retail tool and fixing false leaks such as the `hangar1` report in discussion #124. Engine-side geometry tools also initialize their triangle-surface allocator before generating light volumes, fixing the Windows crash seen while compiling `game/airdefense1`. - **Liquids now look, sound, and behave like real volumes in combat.** Projectiles and hitscan shots cross the surface with a splash and sound, underwater travel produces bubble trails, clear water stays readable, and swimmers can reliably water-jump out over a clear ledge. Drowning, slime, and lava have distinct localized death-feed icons and messages. The multiplayer Liquid Volume Lab makes everything easy to inspect across deep, shallow, and wading water, vivid cellular slime, and bright heat-hazed, steaming lava, with restrained underwater ambience and boiling hazardous surfaces. - **Internet multiplayer has safer administration, package negotiation, packet handling, and pure play.** The default remote console no longer sends its password over the network, private password settings stay out of console completion and persistence paths, and unauthenticated replies are bounded. A server can now update only the userinfo or synchronized-setting class owned by each message, and a truncated settings dictionary changes nothing. Server-provided package links are limited to bounded HTTP or HTTPS URLs with syntax-checked hosts; validated web redirects remain available, while standard packages keep in-process direct PK4 transfer disabled. Truncated covered messages and snapshots now fail safely and a malformed snapshot ends the affected session before another frame, the obsolete executable updater can no longer download or launch code, and pure servers keep asset checks enabled while game modules remain trusted local package components rather than server-supplied downloads. @@ -28,6 +30,7 @@ - Mac users should replace the complete package to receive the bundled OpenAL Soft runtime; no separate OpenAL installation is needed. The package includes its corresponding licence notices and source offer. - Linux packages no longer contain duplicate root icons whose names differ only by letter case, so the same archive can be extracted safely on Windows and other case-insensitive filesystems. - Existing settings and compatible saves do not need to be reset. As a precaution, retain a copy of important saves before replacing an older installation. +- Map authors whose previously sealed Quake 4 maps reported an immediate leak in openQ4 should rerun `dmap` after upgrading; grouped world brushes are now compiled correctly. Keep the original editable `.map` source rather than replacing it with generated output. - Players upgrading from released v0.10 Windows x64 builds can retry saves that previously stopped at a physics or `ReadTrace` restore error. A modded save still requires the same mod maps, GUIs, and other assets that created it; keep a backup before testing. - The exact older broad shadow-filter defaults are upgraded once to the new balanced contact-shadow values; customized shadow profiles are left untouched. `r_shadowMapCasterCulling 2` remains the recommended value but now means topology-aware automatic culling rather than far-shell storage. Use `0` for an always-two-sided comparison or `1` to force near-shell culling. - Level-load data under the active `fs_savepath` `generated/` directory is private and disposable. `com_levelLoadModernization` defaults to `0` and overrides older archived cache settings, so ordinary play uses the classic source path. Set it to `1` only for focused cache evaluation. Never delete the original retail or mod assets. @@ -78,6 +81,10 @@ - Restored the stock F1/F2/F3/F6/F7 impulse bindings, aligned the controls menu's vote/ready rows, routed the throttled ready toggle through the reliable operation, kept the two-line readiness prompt inside its authored HUD bounds, narrowly migrated only the exact historical openQ4 defaults, and normalized no-time-limit casual matches to sudden-death overtime with a zero period. - Corrected `s_muteUnfocused` transitions so returning to the game cannot leave audio muted for the rest of the level. - Restored the SDL OpenGL context before applying startup or live swap-interval changes, keeping `r_swapInterval` effective when native Wayland window setup temporarily detaches the context. +- Made SP in-game menu activation constant-time with respect to saves, mods, devices, displays, bindings, and multiplayer model declarations by refreshing those catalogs only on their destination pages and precaching the menu art and audio during normal level loading. +- Made bitmap font atlas selection viewport-aware, retaining authored text metrics while using higher-resolution glyph sources when a modern display would otherwise magnify the 12- or 24-point atlas. +- Regenerated Git-derived build identity on every Meson build invocation, keeping the console banner, executable metadata, engine, and GameLib version strings current without unnecessary recompilation when the identity is unchanged. +- Resolved Quake 4 `func_group` entities before `dmap` geometry processing, restoring retail brush/entity totals and preventing grouped world geometry from becoming a false leak. - Initialized engine-side triangle-surface allocators for `dmap` and related geometry tools. - Restored clean, fixed-size manual save-game previews at widescreen resolutions and prevented renderer row padding from leaking into narrow captures. - Restored the exact affected v0.10 Windows x64 version-3 save layout by defaulting its absent swim-speed and liquid-surface timer fields and accepting the empty physics frame omitted by optimized builds; current writers now use source-declared class ownership so linker folding cannot change save bytes. diff --git a/meson.build b/meson.build index 5a772410..5d009d96 100644 --- a/meson.build +++ b/meson.build @@ -108,7 +108,7 @@ version_track = get_option('version_track').strip() version_iteration = get_option('version_iteration').strip() version_base_override = get_option('version_base_override').strip() openal_root_override = get_option('openal_root_override').strip() -generated_version_header = meson.current_build_dir() / 'openq4_version_generated.h' +generated_version_header_path = meson.current_build_dir() / 'openq4_version_generated.h' version_metadata_command = [ py, meson.project_source_root() / 'tools' / 'build' / 'openq4_version.py', @@ -116,8 +116,6 @@ version_metadata_command = [ meson.project_source_root(), '--track', version_track, - '--header-out', - generated_version_header, ] # Release builds pin the exact version. Everything else lets the helper resolve # it: project(version:) above is only a floor, raised to the newest published v* @@ -129,7 +127,25 @@ endif if version_iteration != '' version_metadata_command += ['--iteration', version_iteration] endif -version_metadata_result = run_command(version_metadata_command, check: true) +version_metadata_result = run_command( + version_metadata_command + ['--header-out', generated_version_header_path], + check: true, +) + +# Git metadata changes whenever HEAD or the worktree changes, without changing +# meson.build. Re-run the lightweight generator for every build invocation; +# write_if_changed keeps downstream recompilation limited to real version +# changes. The configure-time invocation above ensures the header also exists +# before a backend performs its first dependency scan. +generated_version_header = custom_target( + 'openq4_version_generated', + output: 'openq4_version_generated.h', + command: version_metadata_command + ['--header-out', '@OUTPUT@'], + depend_files: files('tools/build/openq4_version.py'), + build_always_stale: true, + build_by_default: true, + install: false, +) openq4_version_base = meson.project_version() openq4_version_short = meson.project_version() @@ -688,7 +704,7 @@ openq4_renderer_gl_sources = files(renderer_gl_source_paths) openq4_engine_sources = files(engine_source_paths) if build_engine - openq4_engine_sources += openq4_paks_generated_header + openq4_engine_sources += [openq4_paks_generated_header, generated_version_header] endif game_sp_sources = [] @@ -756,8 +772,8 @@ if build_games ), install: false, ) - game_sp_sources += [savegame_compat_header] - game_mp_sources += [savegame_compat_header] + game_sp_sources += [savegame_compat_header, generated_version_header] + game_mp_sources += [savegame_compat_header, generated_version_header] if build_engine # Session preflights the exact GameLibs source stamp before tearing down # the active map, so the engine target must see the same generated header. @@ -1306,7 +1322,7 @@ if build_engine if windows_gl_free_ded # ded keeps the static renderer while the client sheds it openq4_dedicated_sources = files(dedicated_engine_source_paths) - openq4_dedicated_sources += openq4_paks_generated_header + openq4_dedicated_sources += [openq4_paks_generated_header, generated_version_header] if build_games openq4_dedicated_sources += [savegame_compat_header] endif @@ -1314,7 +1330,7 @@ if build_engine endif if host_system == 'linux' openq4_dedicated_sources = files(dedicated_engine_source_paths) - openq4_dedicated_sources += openq4_paks_generated_header + openq4_dedicated_sources += [openq4_paks_generated_header, generated_version_header] if build_games openq4_dedicated_sources += [savegame_compat_header] endif diff --git a/src/framework/Session.cpp b/src/framework/Session.cpp index 993994bf..f0db366e 100644 --- a/src/framework/Session.cpp +++ b/src/framework/Session.cpp @@ -2515,6 +2515,28 @@ void idSessionLocal::SetMainMenuBackgroundMontageGuiVars( void ) { guiMainMenu->SetStateInt( "menu_bg_count", numMenuBackgrounds ); } +void idSessionLocal::PrimeMainMenuGuiResources( void ) { + static const char *fallbackLevelshot = "gfx/guis/loadscreens/generic"; + + if ( guiMainMenu == NULL ) { + return; + } + + SetMainMenuBackgroundMontageGuiVars(); + guiMainMenu->SetStateString( "browser_levelshot", fallbackLevelshot ); + + idMaterial *fallbackMaterial = const_cast( declManager->FindMaterial( fallbackLevelshot ) ); + if ( fallbackMaterial != NULL ) { + fallbackMaterial->EnsureNotPurged(); + fallbackMaterial->SetSort( SS_GUI ); + } + renderSystem->PreloadImage( fallbackLevelshot ); + + // The GUI buffers this command when it activates. Resolve the shader during + // startup/level load so the first in-game menu never parses it on demand. + declManager->FindSound( "main_menu_gameplay" ); +} + static void Session_DrawFallbackLoadingScreen() { static const idVec4 loadingTextColor( 0.94f, 0.62f, 0.05f, 1.0f ); @@ -3581,6 +3603,30 @@ static void Session_openQ4AssertMPGameplayView_f( const idCmdArgs &args ) { common->Printf( "OPENQ4_STOCK_BASELINE_MP_CLIENT_VIEW gui=0\n" ); } + +/* +================== +Session_openQ4AssertMenuActivation_f +================== +*/ +static void Session_openQ4AssertMenuActivation_f( const idCmdArgs &args ) { + const int maxMsec = args.Argc() > 1 ? atoi( args.Argv( 1 ) ) : 50; + if ( maxMsec < 1 || !sessLocal.IsMapSpawned() || sessLocal.GetActiveGUI() != NULL ) { + common->Error( "openq4_assertMenuActivation requires active gameplay with no GUI and a positive millisecond limit" ); + return; + } + + const int startMsec = Sys_Milliseconds(); + sessLocal.StartMenu( false ); + const int elapsedMsec = Max( 0, Sys_Milliseconds() - startMsec ); + if ( sessLocal.GetActiveMenu() == NULL || elapsedMsec > maxMsec ) { + common->Error( "openq4_assertMenuActivation failed: active=%d elapsed=%dms limit=%dms", + sessLocal.GetActiveMenu() != NULL ? 1 : 0, elapsedMsec, maxMsec ); + return; + } + + common->Printf( "OPENQ4_MENU_ACTIVATION PASS elapsed=%dms limit=%dms\n", elapsedMsec, maxMsec ); +} #endif /* @@ -5632,6 +5678,12 @@ void idSessionLocal::ExecuteMapChange( bool noFadeWipe ) { uiManager->BeginLevelLoad(); uiManager->Reload( true ); +#ifndef ID_DEDICATED + // Level-load purging clears media-use marks established at session startup. + // Re-reference every in-game menu resource while the normal precache window + // is active, keeping the first Escape press free of filesystem work. + PrimeMainMenuGuiResources(); +#endif // set the loading gui that we will wipe to LoadLoadingGui( mapString ); @@ -7858,6 +7910,7 @@ void idSessionLocal::Init() { cmdSystem->AddCommand( "openq4_startSingleplayer", Session_openQ4StartSingleplayer_f, CMD_FL_SYSTEM, "internal helper to start singleplayer after game-module switches" ); cmdSystem->AddCommand( "openq4_assertMapState", Session_openQ4AssertMapState_f, CMD_FL_SYSTEM|CMD_FL_CHEAT, "asserts the active map and entity filter for validation harnesses" ); cmdSystem->AddCommand( "openq4_assertMPGameplayView", Session_openQ4AssertMPGameplayView_f, CMD_FL_SYSTEM|CMD_FL_CHEAT, "asserts that multiplayer rendering is not covered by an active session GUI" ); + cmdSystem->AddCommand( "openq4_assertMenuActivation", Session_openQ4AssertMenuActivation_f, CMD_FL_SYSTEM|CMD_FL_CHEAT, "opens the in-game menu and asserts a bounded activation time for validation harnesses" ); cmdSystem->AddCommand( "openq4_resumeBakeLightGrids", Session_openQ4ResumeBakeLightGrids_f, CMD_FL_SYSTEM|CMD_FL_CHEAT, "internal helper to continue light-grid baking after game-module switches" ); cmdSystem->AddCommand( "iamtheduke", Session_IAmTheDuke_f, CMD_FL_SYSTEM|CMD_FL_CHEAT, "toggles the SP-only iamtheduke cheat text overlay" ); cmdSystem->AddCommand( "bakeLightGrids", Session_BakeLightGrids_f, CMD_FL_SYSTEM|CMD_FL_CHEAT, "bakes openQ4-compatible lightgrid metadata and irradiance atlases for the current map or a batch of maps" ); @@ -7925,6 +7978,10 @@ void idSessionLocal::Init() { #else guiMainMenu = uiManager->FindGui( "guis/demo_mainmenu.gui", true, false, true ); #endif + // Resolve and retain menu media while the session is initialized. Doing + // this synchronously from StartMenu made the first ESC press wait on image, + // material, and sound lookup before the GUI could be activated. + PrimeMainMenuGuiResources(); guiMainMenu_MapList = uiManager->AllocListGUI(); guiMainMenu_MapList->Config( guiMainMenu, "mapList" ); idAsyncNetwork::client.serverList.GUIConfig( guiMainMenu, "serverList" ); diff --git a/src/framework/Session_local.h b/src/framework/Session_local.h index b0d702cb..a03b81c9 100644 --- a/src/framework/Session_local.h +++ b/src/framework/Session_local.h @@ -518,8 +518,9 @@ class idSessionLocal : public idSession { void UpdateMPLevelShot( void ); void SetSaveGameGuiVars( void ); - void SetMainMenuGuiVars( void ); + void SetMainMenuGuiVars( bool refreshCatalogs ); void SetMainMenuBackgroundMontageGuiVars( void ); + void PrimeMainMenuGuiResources( void ); void SetModsMenuGuiVars( void ); void UpdateModsMenuGuiVars( void ); void SetMainMenuSkin( void ); diff --git a/src/framework/Session_menu.cpp b/src/framework/Session_menu.cpp index 6445e252..20aec93f 100644 --- a/src/framework/Session_menu.cpp +++ b/src/framework/Session_menu.cpp @@ -876,6 +876,33 @@ static void SyncMainMenuAspectVisibility( idUserInterface *gui ) { gui->HandleNamedEvent( "forceAspect0" ); } +static void PopulateMainMenuAudioDeviceChoices( idUserInterface *gui ) { + if ( gui == NULL ) { + return; + } + + idStr choiceNames; + idStr choiceValues; + BuildMainMenuAudioDeviceChoices( choiceNames, choiceValues ); + gui->SetStateString( "device_name", choiceNames.c_str() ); + gui->SetStateString( "device_value", choiceValues.c_str() ); +} + +static void PopulateMainMenuDisplayChoices( idUserInterface *gui ) { + if ( gui == NULL ) { + return; + } + + idStr choiceNames; + idStr choiceValues; + int displayCount = 0; + BuildMainMenuDisplayChoices( choiceNames, choiceValues, displayCount ); + gui->SetStateString( "display_names", choiceNames.c_str() ); + gui->SetStateString( "display_values", choiceValues.c_str() ); + gui->SetStateInt( "display_count", displayCount ); + SetMainMenuVideoGuiVars( gui ); +} + static void RefreshMainMenuDisplayChoices( idUserInterface *gui ) { if ( gui == NULL ) { return; @@ -1444,8 +1471,14 @@ void idSessionLocal::SetGUI( idUserInterface *gui, HandleGuiCommand_t handle ) { } if ( guiActive == guiMainMenu ) { - SetSaveGameGuiVars(); - SetMainMenuGuiVars(); + // Opening ESC must never wait for unbounded filesystem, device, display, + // or declaration enumeration. Those catalogs are refreshed when their + // page is opened; the title-screen path still primes them for stock GUIs. + const bool refreshCatalogs = !mapSpawned; + if ( refreshCatalogs ) { + SetSaveGameGuiVars(); + } + SetMainMenuGuiVars( refreshCatalogs ); } else if ( guiActive == guiRestartMenu ) { SetSaveGameGuiVars(); } @@ -2026,7 +2059,7 @@ void idSessionLocal::SetMainMenuSkin( void ) { idSessionLocal::SetMainMenuGuiVars =============== */ -void idSessionLocal::SetMainMenuGuiVars( void ) { +void idSessionLocal::SetMainMenuGuiVars( bool refreshCatalogs ) { guiMainMenu->SetStateString( "serverlist_sel_0", "-1" ); guiMainMenu->SetStateString( "serverlist_selid_0", "-1" ); @@ -2036,8 +2069,13 @@ void idSessionLocal::SetMainMenuGuiVars( void ) { // "inetGame" will hold a hand-typed inet address, which is not archived to a cvar guiMainMenu->SetStateString( "inetGame", "" ); - // key bind names - guiMainMenu->SetKeyBindingNames(); + if ( refreshCatalogs ) { + // These operations may consult drivers, the filesystem, or large decl + // catalogs. Keep them off the synchronous in-game activation path. + guiMainMenu->SetKeyBindingNames(); + PopulateMainMenuAudioDeviceChoices( guiMainMenu ); + PopulateMainMenuDisplayChoices( guiMainMenu ); + } // flag for in-game menu const char *inGameState = mapSpawned ? ( IsMultiplayer() ? "2" : "1" ) : "0"; @@ -2050,22 +2088,6 @@ void idSessionLocal::SetMainMenuGuiVars( void ) { guiMainMenu->SetStateString( "nightmare", cvarSystem->GetCVarBool( "g_nightmare" ) ? "1" : "0" ); #endif guiMainMenu->SetStateString( "browser_levelshot", "gfx/guis/loadscreens/generic" ); - SetMainMenuBackgroundMontageGuiVars(); - - idStr audioDeviceNames; - idStr audioDeviceValues; - BuildMainMenuAudioDeviceChoices( audioDeviceNames, audioDeviceValues ); - guiMainMenu->SetStateString( "device_name", audioDeviceNames.c_str() ); - guiMainMenu->SetStateString( "device_value", audioDeviceValues.c_str() ); - - idStr displayNames; - idStr displayValues; - int displayCount = 0; - BuildMainMenuDisplayChoices( displayNames, displayValues, displayCount ); - guiMainMenu->SetStateString( "display_names", displayNames.c_str() ); - guiMainMenu->SetStateString( "display_values", displayValues.c_str() ); - guiMainMenu->SetStateInt( "display_count", displayCount ); - SetMainMenuVideoGuiVars( guiMainMenu ); SetMainMenuQualityGuiVars( guiMainMenu ); SyncMainMenuAspectVisibility( guiMainMenu ); guiMainMenu->SetStateInt( "gui_set_sys_scroll", 0 ); @@ -2076,8 +2098,9 @@ void idSessionLocal::SetMainMenuGuiVars( void ) { gui_set_game_scroll.SetInteger( 0 ); SetMainMenuSkin(); - // Mods Menu - SetModsMenuGuiVars(); + if ( refreshCatalogs ) { + SetModsMenuGuiVars(); + } guiMsg->SetStateString( "visible_hasxp", fileSystem->HasD3XP() ? "1" : "0" ); @@ -2087,7 +2110,9 @@ void idSessionLocal::SetMainMenuGuiVars( void ) { guiMainMenu->SetStateString( "driver_prompt", "0" ); #endif - SetMainMenuMPModelVars( guiMainMenu ); + if ( refreshCatalogs ) { + SetMainMenuMPModelVars( guiMainMenu ); + } arenaCampaign.UpdateMainMenuGui( guiMainMenu ); } @@ -2100,6 +2125,14 @@ bool idSessionLocal::HandleSaveGameMenuCommand( idCmdArgs &args, int &icmd ) { const char *cmd = args.Argv(icmd-1); + if ( !idStr::Icmp( cmd, "refreshSaveGameList" ) ) { + SetSaveGameGuiVars(); + if ( guiActive != NULL ) { + guiActive->StateChanged( common->GetPresentationTime() ); + } + return true; + } + if ( !idStr::Icmp( cmd, "loadGame" ) ) { int choice = guiActive->State().GetInt("loadgame_sel_0"); if ( choice >= 0 && choice < loadGameList.Num() ) { @@ -2893,6 +2926,43 @@ void idSessionLocal::HandleMainMenuCommands( const char *menuCommand ) { continue; } + if ( !idStr::Icmp( cmd, "refreshKeyBindings" ) ) { + idUserInterface *targetGui = guiActive ? guiActive : guiMainMenu; + if ( targetGui != NULL ) { + targetGui->SetKeyBindingNames(); + targetGui->StateChanged( common->GetPresentationTime() ); + } + continue; + } + + if ( !idStr::Icmp( cmd, "refreshSystemSettings" ) ) { + idUserInterface *targetGui = guiActive ? guiActive : guiMainMenu; + PopulateMainMenuDisplayChoices( targetGui ); + SetMainMenuQualityGuiVars( targetGui ); + SyncMainMenuAspectVisibility( targetGui ); + if ( targetGui != NULL ) { + targetGui->StateChanged( common->GetPresentationTime() ); + } + continue; + } + + if ( !idStr::Icmp( cmd, "refreshAudioSettings" ) ) { + idUserInterface *targetGui = guiActive ? guiActive : guiMainMenu; + PopulateMainMenuAudioDeviceChoices( targetGui ); + if ( targetGui != NULL ) { + targetGui->StateChanged( common->GetPresentationTime() ); + } + continue; + } + + if ( !idStr::Icmp( cmd, "refreshMods" ) ) { + SetModsMenuGuiVars(); + if ( guiActive != NULL ) { + guiActive->StateChanged( common->GetPresentationTime() ); + } + continue; + } + if ( !idStr::Icmp( cmd, "applyDisplayModeChoice" ) ) { ApplyMainMenuDisplayModeChoice( guiActive ? guiActive : guiMainMenu ); continue; diff --git a/src/tools/compilers/dmap/map.cpp b/src/tools/compilers/dmap/map.cpp index 9c9bc8ad..771807e4 100644 --- a/src/tools/compilers/dmap/map.cpp +++ b/src/tools/compilers/dmap/map.cpp @@ -492,6 +492,11 @@ bool LoadDMapFile( const char *filename ) { return false; } + // Quake 4 maps preserve func_group entities in the editable source. Resolve + // them before compiling so their primitives become world geometry, matching + // the retail compiler and the runtime map-loading path. + dmapGlobals.dmapFile->Resolve(); + dmapGlobals.mapPlanes.Clear(); dmapGlobals.mapPlanes.SetGranularity( 1024 ); diff --git a/src/ui/DeviceContext.cpp b/src/ui/DeviceContext.cpp index f50fb03f..70ed031b 100644 --- a/src/ui/DeviceContext.cpp +++ b/src/ui/DeviceContext.cpp @@ -778,6 +778,31 @@ static bool openQ4_GetCurrentViewportSize( float &windowWidth, float &windowHeig return windowWidth > 0.0f && windowHeight > 0.0f; } +static float openQ4_FontSelectionScaleForViewport( float authoredScale, float canvasWidth, float canvasHeight, + float viewportWidth, float viewportHeight, bool aspectCorrect ) { + if ( authoredScale <= 0.0f || canvasWidth <= 0.0f || canvasHeight <= 0.0f || + viewportWidth <= 0.0f || viewportHeight <= 0.0f ) { + return authoredScale; + } + + const float physicalScaleX = viewportWidth / canvasWidth; + const float physicalScaleY = viewportHeight / canvasHeight; + const float physicalScale = aspectCorrect + ? Min( physicalScaleX, physicalScaleY ) + : Max( physicalScaleX, physicalScaleY ); + return authoredScale * Max( 1.0f, physicalScale ); +} + +static float openQ4_FontSelectionScale( float authoredScale, float canvasWidth, float canvasHeight, bool aspectCorrect ) { + float viewportWidth = 0.0f; + float viewportHeight = 0.0f; + if ( !openQ4_GetCurrentViewportSize( viewportWidth, viewportHeight ) ) { + return authoredScale; + } + return openQ4_FontSelectionScaleForViewport( authoredScale, canvasWidth, canvasHeight, + viewportWidth, viewportHeight, aspectCorrect ); +} + static void openQ4_CalcVirtualScreenTransform( float width, float height, float windowWidth, float windowHeight, bool aspectCorrect, q4VirtualScreenTransform_t &transform ) { openQ4_ClearVirtualScreenTransform( transform ); @@ -2200,11 +2225,17 @@ void idDeviceContext::SetFontByScale(float scale) { useFont = NULL; return; } - if (scale <= gui_smallFontLimit.GetFloat()) { + // The GUI scale is authored for a 640x480-era canvas, while the selected + // bitmap atlas is sampled at the final viewport resolution. Account for + // that physical enlargement so high-resolution displays use the 24/48-point + // source instead of magnifying a small atlas. Rendering still uses the + // authored scale below, so text layout and dimensions do not change. + const float selectionScale = openQ4_FontSelectionScale( scale, vidWidth, vidHeight, aspectCorrect ); + if (selectionScale <= gui_smallFontLimit.GetFloat()) { useFont = &activeFont->fontInfoSmall; activeFont->maxHeight = activeFont->maxHeightSmall; activeFont->maxWidth = activeFont->maxWidthSmall; - } else if (scale <= gui_mediumFontLimit.GetFloat()) { + } else if (selectionScale <= gui_mediumFontLimit.GetFloat()) { useFont = &activeFont->fontInfoMedium; activeFont->maxHeight = activeFont->maxHeightMedium; activeFont->maxWidth = activeFont->maxWidthMedium; @@ -3048,6 +3079,12 @@ bool UI_FontParity_RunSelfTest( void ) { ok &= openQ4_CheckNear( "24 point font scale", openQ4_FontRenderScale( &font, 0.5f ), 1.0f ); font.pointSize = Q4_GUI_FONT_BASE_POINT_SIZE; ok &= openQ4_CheckNear( "48 point font scale", openQ4_FontRenderScale( &font, 1.0f ), 1.0f ); + ok &= openQ4_CheckNear( "retail viewport font selection scale", + openQ4_FontSelectionScaleForViewport( 0.25f, 640.0f, 480.0f, 640.0f, 480.0f, true ), 0.25f ); + ok &= openQ4_CheckNear( "1080p font selection scale", + openQ4_FontSelectionScaleForViewport( 0.25f, 640.0f, 480.0f, 1920.0f, 1080.0f, true ), 0.5625f ); + ok &= openQ4_CheckNear( "1440p font selection scale", + openQ4_FontSelectionScaleForViewport( 0.25f, 640.0f, 480.0f, 2560.0f, 1440.0f, true ), 0.75f ); glyphInfo_t glyph = {}; glyph.horiAdvance = 7.2f; diff --git a/tools/tests/dmap_render_geometry_lifecycle.py b/tools/tests/dmap_render_geometry_lifecycle.py index f7e6018a..5ec0d61b 100644 --- a/tools/tests/dmap_render_geometry_lifecycle.py +++ b/tools/tests/dmap_render_geometry_lifecycle.py @@ -127,6 +127,23 @@ def validate_engine_tool_lifetime() -> None: raise AssertionError("renderer shutdown must release its TriSurf copy exactly once") +def validate_map_resolution_contract() -> None: + source = read("src/tools/compilers/dmap/map.cpp") + load = braced_body(source, "bool LoadDMapFile( const char *filename )", "dmap map loading") + require_order( + load, + ( + "dmapGlobals.dmapFile->Parse(filename)", + "dmapGlobals.dmapFile->Resolve();", + "dmapGlobals.dmapFile->GetNumEntities()", + "ProcessMapEntity( dmapGlobals.dmapFile->GetEntity(i) );", + ), + "dmap func_group resolution", + ) + if load.count("dmapGlobals.dmapFile->Resolve();") != 1: + raise AssertionError("dmap must resolve editable func_group entities exactly once before compiling geometry") + + def validate_build_and_ci_wiring() -> None: meson = read("meson.build") require(meson, "client_link_with = [bse_library, imagetools_library, render_geo_library]", "client render-geometry link") @@ -149,6 +166,7 @@ def validate_build_and_ci_wiring() -> None: def main() -> None: validate_allocator_lifecycle() validate_engine_tool_lifetime() + validate_map_resolution_contract() validate_build_and_ci_wiring() print("dmap_render_geometry_lifecycle: ok") diff --git a/tools/tests/openq4_pure_pack.py b/tools/tests/openq4_pure_pack.py index dfe5c78f..340dc3bb 100644 --- a/tools/tests/openq4_pure_pack.py +++ b/tools/tests/openq4_pure_pack.py @@ -1101,9 +1101,10 @@ def validate_build_pak0_contract() -> None: require(meson, "install: true", "Meson pack install") require(meson, "install: false", "Meson does not install generated header") require(meson, "install_dir: install_game_dir", "Meson installs generated packs") - require(meson, "openq4_engine_sources += openq4_paks_generated_header", "engine depends on pack checksum header") + engine_generated_headers = "openq4_engine_sources += [openq4_paks_generated_header, generated_version_header]" + require(meson, engine_generated_headers, "engine depends on pack checksum and version headers") require_order(meson, "'openq4_pak0'", "'openq4_paks_generated_header'", "packs before checksum header") - require_order(meson, "'openq4_paks_generated_header'", "openq4_engine_sources += openq4_paks_generated_header", "checksum header before engine sources") + require_order(meson, "'openq4_paks_generated_header'", engine_generated_headers, "checksum header before engine sources") require(baseoq4_meson, "baseoq4_manifest", "loose mod.json install") reject(baseoq4_meson, "install_subdir(", "baseoq4 content should be inside openQ4 PK4s") diff --git a/tools/tests/release_tooling_safety.py b/tools/tests/release_tooling_safety.py index 6d839e8c..d738c1cd 100644 --- a/tools/tests/release_tooling_safety.py +++ b/tools/tests/release_tooling_safety.py @@ -309,6 +309,25 @@ def validate_openq4_version_iteration() -> None: raise AssertionError(f"auto iteration should follow the highest suffix, got {iteration!r}") +def validate_version_header_build_refresh_contract() -> None: + meson_text = (ROOT / "meson.build").read_text(encoding="utf-8") + for token in ( + "generated_version_header = custom_target(", + "version_metadata_command + ['--header-out', '@OUTPUT@']", + "build_always_stale: true", + "build_by_default: true", + "openq4_engine_sources += [openq4_paks_generated_header, generated_version_header]", + "game_sp_sources += [savegame_compat_header, generated_version_header]", + "game_mp_sources += [savegame_compat_header, generated_version_header]", + "openq4_dedicated_sources += [openq4_paks_generated_header, generated_version_header]", + ): + if token not in meson_text: + raise AssertionError(f"version header build-refresh contract is missing {token!r}") + + if "version_metadata_command + ['--header-out', generated_version_header_path]" not in meson_text: + raise AssertionError("configure-time version header bootstrap is missing") + + def validate_release_version_floor_and_docs_classification() -> None: current = RELEASE_VERSION.parse_version("0.1.011") lower = RELEASE_VERSION.parse_version("0.1.010") @@ -1551,6 +1570,7 @@ def main() -> None: validate_changelog_input_and_markdown_safety() validate_changelog_output_and_override_guards() validate_openq4_version_iteration() + validate_version_header_build_refresh_contract() validate_release_version_floor_and_docs_classification() validate_release_docs_output_guard() validate_release_docs_layout() diff --git a/tools/tests/savegame_corruption_contract.py b/tools/tests/savegame_corruption_contract.py index 3bedcd74..d7e33013 100644 --- a/tools/tests/savegame_corruption_contract.py +++ b/tools/tests/savegame_corruption_contract.py @@ -895,8 +895,8 @@ def validate_gamelibs_save_payload_contract() -> None: "generate_savegame_compat_header.py", "openq4_savegame_compat_generated.h", "savegame_compat_header = custom_target", - "game_sp_sources += [savegame_compat_header]", - "game_mp_sources += [savegame_compat_header]", + "game_sp_sources += [savegame_compat_header, generated_version_header]", + "game_mp_sources += [savegame_compat_header, generated_version_header]", "openq4_engine_sources += [savegame_compat_header]", "depend_files: files(engine_source_paths) + game_sources", ): diff --git a/tools/tests/settings_menu_coverage.py b/tools/tests/settings_menu_coverage.py index 9f8bfd5c..f0d51fc9 100644 --- a/tools/tests/settings_menu_coverage.py +++ b/tools/tests/settings_menu_coverage.py @@ -730,6 +730,88 @@ def validate_scripted_pseudo_setting_adapters(game_gui: str, session_menu: str) require(session_menu, token, "Corpse Time typed adapter") +def validate_ingame_menu_activation_contract( + mainmenu: str, + session_menu: str, + session_cpp: str, +) -> None: + set_gui = session_menu[ + session_menu.index("void idSessionLocal::SetGUI") : + session_menu.index("idSessionLocal::ExitMenu") + ] + for token in ( + "const bool refreshCatalogs = !mapSpawned;", + "if ( refreshCatalogs )", + "SetMainMenuGuiVars( refreshCatalogs );", + ): + require(set_gui, token, "bounded in-game main-menu activation") + + set_main_menu_vars = session_menu[ + session_menu.index("void idSessionLocal::SetMainMenuGuiVars") : + session_menu.index("idSessionLocal::HandleSaveGameMenuCommand") + ] + for token in ( + "SetKeyBindingNames()", + "PopulateMainMenuAudioDeviceChoices( guiMainMenu )", + "PopulateMainMenuDisplayChoices( guiMainMenu )", + "SetModsMenuGuiVars()", + "SetMainMenuMPModelVars( guiMainMenu )", + ): + require(set_main_menu_vars, token, "lazy main-menu catalogs") + reject(set_main_menu_vars, "SetMainMenuBackgroundMontageGuiVars()", "synchronous menu activation") + + montage_init = session_cpp[ + session_cpp.index('guiMainMenu = uiManager->FindGui( "guis/mainmenu.gui"') : + session_cpp.index("guiMainMenu_MapList = uiManager->AllocListGUI()") + ] + require(montage_init, "PrimeMainMenuGuiResources();", "session-time menu resource warmup") + + map_load_prime = session_cpp[ + session_cpp.index("uiManager->BeginLevelLoad();") : + session_cpp.index("// set the loading gui that we will wipe to") + ] + require(map_load_prime, "PrimeMainMenuGuiResources();", "level-load menu resource precache") + prime_resources = session_cpp[ + session_cpp.index("void idSessionLocal::PrimeMainMenuGuiResources") : + session_cpp.index("static void Session_DrawFallbackLoadingScreen") + ] + for token in ( + "SetMainMenuBackgroundMontageGuiVars();", + 'declManager->FindMaterial( fallbackLevelshot )', + "renderSystem->PreloadImage( fallbackLevelshot );", + 'declManager->FindSound( "main_menu_gameplay" );', + ): + require(prime_resources, token, "complete in-game menu resource precache") + + load_in = gui_block(mainmenu, "anim_loadIn") + save_in = gui_block(mainmenu, "anim_saveIn") + settings_in = gui_block(mainmenu, "anim_settingsIn") + mods_in = gui_block(mainmenu, "anim_pop_modsIn") + require(load_in, 'set "cmd" "refreshSaveGameList" ;', "lazy Load Game catalog") + reject(load_in, 'set "cmd" "MAPScan" ;', "Load Game map-declaration scan") + require(save_in, 'set "cmd" "refreshSaveGameList" ;', "lazy Save Game catalog") + require(mods_in, 'set "cmd" "refreshMods" ;', "lazy Mods catalog") + for command in ("refreshKeyBindings", "refreshSystemSettings", "refreshAudioSettings"): + require(settings_in, f'set "cmd" "{command}" ;', "lazy Settings catalog") + require(session_menu, f'!idStr::Icmp( cmd, "{command}" )', "lazy Settings command handler") + + for widget, command in ( + ("set_b_controls", "refreshKeyBindings"), + ("set_b_system", "refreshSystemSettings"), + ("set_b_audio", "refreshAudioSettings"), + ): + require(gui_block(mainmenu, widget), f'set "cmd" "{command}" ;', f"{widget} lazy refresh") + for command in ("refreshSaveGameList", "refreshMods"): + require(session_menu, f'!idStr::Icmp( cmd, "{command}" )', f"{command} handler") + + for token in ( + "Session_openQ4AssertMenuActivation_f", + "OPENQ4_MENU_ACTIVATION PASS elapsed=%dms limit=%dms", + 'AddCommand( "openq4_assertMenuActivation"', + ): + require(session_cpp, token, "runtime in-game menu activation assertion") + + def main() -> None: validate_settings_registry() @@ -741,6 +823,7 @@ def main() -> None: game_gui = read(ROOT / "content/baseoq4/pak0/guis/menu/settings/game.gui") game_hovers = read(ROOT / "content/baseoq4/pak0/guis/menu/settings/game_hovers.gui") session_menu = read(ROOT / "src/framework/Session_menu.cpp") + session_cpp = read(ROOT / "src/framework/Session.cpp") common_cpp = read(ROOT / "src/framework/Common.cpp") slider_window = read(ROOT / "src/ui/SliderWindow.cpp") registry_text = read(ROOT / "docs/dev/settings-menu-registry.json") @@ -752,6 +835,7 @@ def main() -> None: validate_controls_pane_extraction(mainmenu, controls_gui) validate_scripted_pseudo_setting_adapters(game_gui, session_menu) validate_performance_preset_wiring(common_cpp, system_gui, session_menu, registry_text, display_docs, locales) + validate_ingame_menu_activation_contract(mainmenu, session_menu, session_cpp) require(mainmenu, '#include "guis/menu/settings/system.gui"', "System settings include") reject(mainmenu, "windowDef p_settings_sys", "mainmenu System pane extraction")