From 88c54904f9456f58f4bda01e83dbac552c4a7005 Mon Sep 17 00:00:00 2001 From: themuffinator Date: Thu, 27 Aug 2026 14:40:57 +0100 Subject: [PATCH] Preserve user time and movement CVars --- .github/workflows/commit-validation.yml | 2 + .github/workflows/push-verification.yml | 2 + docs/dev/release-completion.md | 1 + docs/dev/releases/v0.12.0.md | 2 + src/framework/Common.cpp | 36 +++++++-- src/framework/Common.h | 4 + tools/tests/weapon_wheel_time_scale.py | 97 +++++++++++++++++++++++++ tools/validation/openq4_validate.py | 1 + 8 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 tools/tests/weapon_wheel_time_scale.py diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index da51a07f..e830e5a9 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -134,6 +134,7 @@ jobs: tools/tests/loading_continue_input.py \ tools/tests/loading_pacifier_pacing.py \ tools/tests/map_entity_strings.py \ + tools/tests/weapon_wheel_time_scale.py \ tools/tests/macos_apple_gl21_arb2_corridor.py \ tools/tests/macos_dedicated_server_smoke.py \ tools/tests/macos_dedicated_server_smoke_contract.py \ @@ -292,6 +293,7 @@ jobs: python tools/tests/loading_continue_input.py python tools/tests/loading_pacifier_pacing.py python tools/tests/map_entity_strings.py + python tools/tests/weapon_wheel_time_scale.py python tools/tests/macos_apple_gl21_arb2_corridor.py python tools/tests/macos_dedicated_server_smoke_contract.py python tools/tests/macos_evidence_plumbing.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index 0325a26c..ca04127c 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -134,6 +134,7 @@ jobs: tools/tests/loading_continue_input.py \ tools/tests/loading_pacifier_pacing.py \ tools/tests/map_entity_strings.py \ + tools/tests/weapon_wheel_time_scale.py \ tools/tests/macos_apple_gl21_arb2_corridor.py \ tools/tests/macos_dedicated_server_smoke.py \ tools/tests/macos_dedicated_server_smoke_contract.py \ @@ -292,6 +293,7 @@ jobs: python tools/tests/loading_continue_input.py python tools/tests/loading_pacifier_pacing.py python tools/tests/map_entity_strings.py + python tools/tests/weapon_wheel_time_scale.py python tools/tests/macos_apple_gl21_arb2_corridor.py python tools/tests/macos_dedicated_server_smoke_contract.py python tools/tests/macos_evidence_plumbing.py diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index 1d092477..f044b32c 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -563,6 +563,7 @@ is `docs/dev/macos-moltenvk-decision.md`. - [x] Linux Wayland startup guidance is clearer before SDL video initialization: mixed and pure Wayland sessions now report openQ4's `OPENQ4_FORCE_X11`, `OPENQ4_WAYLAND_PREFER_LIBDECOR`, and `OPENQ4_WAYLAND_SYNC_WINDOW_OPS` fallback/diagnostic switches instead of only pointing users at raw SDL driver variables. - [x] SDL3 Linux desktop-resolution detection is more robust on unusual compositors: if SDL cannot report a desktop display mode, openQ4 now falls back to the current display mode and then display bounds before failing the query. - [x] Multiplayer weapon-wheel handling no longer tries to restore the cheat-protected `timescale` cvar every frame, removing the repeated "timescale cannot be changed in multiplayer" warning while preserving normal MP weapon selection. +- [x] The single-player weapon wheel no longer owns the public `timescale` cvar: a dedicated transient engine/GameLib time-scale channel now drives smooth simulation and audio slowdown and releases cleanly on wheel close or Escape. The Quake 4 SDK walk/run condition is restored as well, so console changes to `timescale` remain intact and `pm_walkspeed` once again governs walking instead of being bypassed by forced running. - [x] Startup and SP map validation logs are cleaner: stock intro loading screens now select their cinematic loading background from engine code without a mapDef override, openQ4 napalm gameplay decl replacements apply without duplicate-decl noise, stale staged intro-video material overrides no longer shadow stock assets, and optional packed-MD5R shader probes skip missing variants quietly. - [x] Stock mappack material loading is quieter: known Quake 4 addon redeclarations between `mapobjects_mp2`, `mappack1`, and `stroyent_mp` no longer flood developer logs while unrelated duplicate material warnings remain visible. - [x] Developer map-launch logs are less noisy during stock content validation: duplicate/tripled silhouette-edge topology diagnostics are now opt-in through `r_reportSilhouetteEdgeWarnings` instead of flooding normal `developer 1` runs. diff --git a/docs/dev/releases/v0.12.0.md b/docs/dev/releases/v0.12.0.md index 95b60dea..f7cab81e 100644 --- a/docs/dev/releases/v0.12.0.md +++ b/docs/dev/releases/v0.12.0.md @@ -19,6 +19,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. - **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. - **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. @@ -84,6 +85,7 @@ - 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. +- Replaced the single-player weapon wheel's per-frame `timescale` writes with a dedicated engine/GameLib time-scale channel, preserving user CVar values while retaining smooth simulation and audio slowdown and clearing the transient effect when Escape opens the menu. Restored the Quake 4 SDK walk/run condition so `pm_walkspeed` is no longer bypassed by an old forced-run shortcut. - 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. diff --git a/src/framework/Common.cpp b/src/framework/Common.cpp index 6e0bef15..0062f401 100644 --- a/src/framework/Common.cpp +++ b/src/framework/Common.cpp @@ -664,6 +664,8 @@ class idCommonLocal : public idCommon { virtual void StartupVariable( const char *match, bool once ); virtual int GetUserCmdHz( void ) const; virtual int GetUserCmdMSec( void ) const; + virtual void SetGameTimeScale( float scale ); + virtual float GetGameTimeScale( void ) const; virtual int GetUserCmdTime( int ticNumber ) const; virtual int GetUserCmdDeltaMsec( int ticNumber ) const; virtual int GetFrameTime( void ) const; @@ -1837,6 +1839,27 @@ int idCommonLocal::GetUserCmdMSec( void ) const { return USERCMD_MSEC; } +static std::atomic openQ4_gameTimeScale( 1.0f ); + +void idCommonLocal::SetGameTimeScale( float scale ) { + if ( FLOAT_IS_NAN( scale ) || FLOAT_IS_INF( scale ) ) { + scale = 1.0f; + } + scale = idMath::ClampFloat( 0.01f, 1.0f, scale ); + openQ4_gameTimeScale.store( scale, std::memory_order_release ); + + if ( soundSystem != NULL ) { + idSoundWorld *soundWorld = soundSystem->GetPlayingSoundWorld(); + if ( soundWorld != NULL ) { + soundWorld->SetSlowmoSpeed( scale ); + } + } +} + +float idCommonLocal::GetGameTimeScale( void ) const { + return openQ4_gameTimeScale.load( std::memory_order_acquire ); +} + /* ================== idCommonLocal::GetUserCmdTime @@ -5871,13 +5894,13 @@ void openQ4_GetAsyncTimingStats( openq4AsyncTimingStats_t &stats, int maxSamples // only bit of module state needed by the timer after game->Init() completes. static std::atomic openQ4_singleplayerGameModuleReady( false ); -static bool openQ4_ShouldUseSmoothSingleplayerSlowTime( void ) { +static bool openQ4_ShouldUseSmoothSingleplayerSlowTime( float effectiveTimeScale ) { if ( !openQ4_singleplayerGameModuleReady.load( std::memory_order_acquire ) || idAsyncNetwork::IsActive() ) { return false; } - return com_timescale.GetFloat() < 0.999f; + return effectiveTimeScale < 0.999f; } void idCommonLocal::SingleAsyncTic( void ) { @@ -5937,9 +5960,10 @@ void idCommonLocal::Async( void ) { double ticMsec = GetUserCmdMsecFloat(); - // the number of msec per tic can be varies with the timescale cvar - float timescale = com_timescale.GetFloat(); - const bool smoothSlowTime = openQ4_ShouldUseSmoothSingleplayerSlowTime(); + // Accelerated time produces more tics. Slow time keeps the normal tic cadence + // and lets the single-player game advance simulation by a smaller delta. + float timescale = com_timescale.GetFloat() * openQ4_gameTimeScale.load( std::memory_order_acquire ); + const bool smoothSlowTime = openQ4_ShouldUseSmoothSingleplayerSlowTime( timescale ); if ( !smoothSlowTime && timescale != 1.0f ) { ticMsec /= timescale; if ( ticMsec < 1.0 ) { @@ -6208,6 +6232,7 @@ idCommonLocal::LoadGameDLL */ void idCommonLocal::LoadGameDLL( void ) { openQ4_singleplayerGameModuleReady.store( false, std::memory_order_release ); + SetGameTimeScale( 1.0f ); gameShutdownCalled = false; gameShutdownAfterDeclsCalled = false; const char *gameModuleBaseName = openQ4_SelectGameModuleBaseName(); @@ -6934,6 +6959,7 @@ void idCommonLocal::ShutdownGame( bool reloading ) { // Stop advertising a ready single-player module before any shutdown work can // race the async thread or mutate game-owned state. openQ4_singleplayerGameModuleReady.store( false, std::memory_order_release ); + SetGameTimeScale( 1.0f ); // kill sound first //idSoundWorld *sw = soundSystem->GetPlayingSoundWorld(); diff --git a/src/framework/Common.h b/src/framework/Common.h index 6864fe05..64a34fd6 100644 --- a/src/framework/Common.h +++ b/src/framework/Common.h @@ -238,6 +238,10 @@ class idCommon { virtual int GetUserCmdHz(void) const = 0; virtual int GetUserCmdMSec(void) const = 0; + // Transient game-owned simulation scale. This is separate from the public + // timescale CVar so gameplay features never overwrite a user's setting. + virtual void SetGameTimeScale(float scale) = 0; + virtual float GetGameTimeScale(void) const = 0; virtual int GetUserCmdTime(int ticNumber) const { if ( ticNumber <= 0 ) { return 0; diff --git a/tools/tests/weapon_wheel_time_scale.py b/tools/tests/weapon_wheel_time_scale.py new file mode 100644 index 00000000..ff25f14e --- /dev/null +++ b/tools/tests/weapon_wheel_time_scale.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Regression checks for the weapon wheel's transient slow-motion channel.""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +GAME_ROOT = Path(os.environ.get("OPENQ4_GAMELIBS_REPO", ROOT.parent / "openQ4-game")).resolve() + + +def read(path: Path) -> str: + data = path.read_bytes() + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp1252") + + +def require(source: str, token: str, context: str) -> None: + if token not in source: + raise AssertionError(f"Missing {token!r} in {context}") + + +def function_body(source: str, signature: str) -> str: + start = source.find(signature) + if start == -1: + raise AssertionError(f"Missing function signature {signature!r}") + + depth = 0 + for index in range(start, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + raise AssertionError(f"Could not find the end of {signature!r}") + + +def main() -> None: + engine_header = read(ROOT / "src/framework/Common.h") + game_header = read(GAME_ROOT / "src/framework/Common.h") + if engine_header != game_header: + raise AssertionError("Engine and GameLib Common.h copies must remain identical") + + require(engine_header, "SetGameTimeScale(float scale)", "idCommon transient time-scale API") + require(engine_header, "GetGameTimeScale(void) const", "idCommon transient time-scale API") + + common = read(ROOT / "src/framework/Common.cpp") + setter = function_body(common, "void idCommonLocal::SetGameTimeScale( float scale ) {") + require(setter, "openQ4_gameTimeScale.store", "atomic transient time-scale publication") + require(setter, "soundWorld->SetSlowmoSpeed( scale );", "transient audio slowdown") + require( + common, + "com_timescale.GetFloat() * openQ4_gameTimeScale.load", + "engine effective time-scale composition", + ) + + player = read(GAME_ROOT / "src/game/Player.cpp") + if re.search(r"SetCVar(?:Float|String|Integer)\s*\(\s*\"timescale\"", player): + raise AssertionError("The weapon wheel must not write the public timescale CVar") + if "weaponWheelBaseTimescale" in player or "weaponWheelBaseTimescale" in read( + GAME_ROOT / "src/game/Player.h" + ): + raise AssertionError("The weapon wheel must not cache ownership of the public timescale CVar") + + effects = function_body(player, "void idPlayer::UpdateWeaponWheelEffects( void ) {") + require(effects, "common->SetGameTimeScale", "weapon-wheel transient time scale") + require(effects, "WeaponWheelLerp( 1.0f, WEAPON_WHEEL_TIMESCALE_SCALE, blend )", "wheel blend") + reset = function_body(player, "void idPlayer::ResetWeaponWheel( bool instantRestore ) {") + require(reset, "common->SetGameTimeScale( 1.0f );", "weapon-wheel transient reset") + handle_esc = function_body(player, "bool idPlayer::HandleESC( void ) {") + require(handle_esc, "ResetWeaponWheel( true );", "Escape transient reset") + + adjust_speed = function_body(player, "void idPlayer::AdjustSpeed( void ) {") + require( + adjust_speed, + "!physicsObj.OnLadder() && ( usercmd.buttons & BUTTON_RUN )", + "single-player walk/run selection", + ) + + game_local = read(GAME_ROOT / "src/game/Game_local.cpp") + run_frame = function_body(game_local, "gameReturn_t idGameLocal::RunFrame(") + require(run_frame, "cvarSystem->GetCVarFloat( \"timescale\" ) * common->GetGameTimeScale()", "SP simulation scale") + + game_api = read(GAME_ROOT / "src/game/Game.h") + require(game_api, "const int GAME_API_VERSION\t\t= 44;", "updated engine/GameLib ABI") + + print("single-player time-scale and walking CVar checks passed") + + +if __name__ == "__main__": + main() diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index fad33f1a..45850bec 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -454,6 +454,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "loading_continue_input.py", root / "tools" / "tests" / "loading_pacifier_pacing.py", root / "tools" / "tests" / "map_entity_strings.py", + root / "tools" / "tests" / "weapon_wheel_time_scale.py", root / "tools" / "tests" / "macos_apple_gl21_arb2_corridor.py", root / "tools" / "tests" / "macos_dedicated_server_smoke_contract.py", root / "tools" / "tests" / "macos_evidence_plumbing.py",