Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/commit-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/push-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/dev/release-completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/dev/releases/v0.12.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 31 additions & 5 deletions src/framework/Common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1837,6 +1839,27 @@ int idCommonLocal::GetUserCmdMSec( void ) const {
return USERCMD_MSEC;
}

static std::atomic<float> 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
Expand Down Expand Up @@ -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<bool> 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 ) {
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/framework/Common.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
97 changes: 97 additions & 0 deletions tools/tests/weapon_wheel_time_scale.py
Original file line number Diff line number Diff line change
@@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the GameLib ABI assertion consistent with version 45

When either checked-in validation workflow runs this new test, it checks out the companion revision explicitly documented as implementing GAME_API_VERSION 45, while this assertion requires version 44; existing tests such as game_type_module_selection.py and renderer_temporal_presentation.py also require 45. Consequently, no normal Game.h can satisfy the full validation suite, so every PR and push fails here. Update the companion pin and all ABI assertions coherently—using a new ABI version if the added virtual methods require it—rather than uniquely expecting 44 in this test.

AGENTS.md reference: AGENTS.md:L26-L27

Useful? React with 👍 / 👎.


print("single-player time-scale and walking CVar checks passed")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions tools/validation/openq4_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading