From bdbed49550e72bdf1ce68555e204d8c1e1a1fa0c Mon Sep 17 00:00:00 2001 From: "Prof. Muffi Nator PhD" Date: Thu, 27 Aug 2026 14:43:45 +0100 Subject: [PATCH 1/4] Preserve user time and movement CVars (#141) --- .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", From 6a8ab71d7aedb2f375c5a3184438c6a0f00ab92c Mon Sep 17 00:00:00 2001 From: themuffinator Date: Thu, 27 Aug 2026 14:45:47 +0100 Subject: [PATCH 2/4] Update arena slow-time regression contract --- tools/tests/arena_campaign.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/tests/arena_campaign.py b/tools/tests/arena_campaign.py index 2dea7c74..12400f3f 100644 --- a/tools/tests/arena_campaign.py +++ b/tools/tests/arena_campaign.py @@ -1075,7 +1075,8 @@ def validate_engine_hooks() -> None: ) smooth_slow_time = function_body( - common, "static bool openQ4_ShouldUseSmoothSingleplayerSlowTime( void )" + common, + "static bool openQ4_ShouldUseSmoothSingleplayerSlowTime( float effectiveTimeScale )", ) require( common, @@ -1087,6 +1088,11 @@ def validate_engine_hooks() -> None: "openQ4_singleplayerGameModuleReady.load( std::memory_order_acquire )", "Arena-safe async module transition", ) + require( + smooth_slow_time, + "return effectiveTimeScale < 0.999f;", + "Composed single-player slow-time detection", + ) if ( "cvarSystem->" in smooth_slow_time or "GetCVarString" in smooth_slow_time From 9ddeb2586b47f2da8c30324dd72d118a9914aa2d Mon Sep 17 00:00:00 2001 From: themuffinator Date: Thu, 27 Aug 2026 14:54:51 +0100 Subject: [PATCH 3/4] Pin weapon wheel GameLib API integration --- .github/workflows/commit-validation.yml | 5 +++-- .github/workflows/push-verification.yml | 5 +++-- tools/tests/game_type_module_selection.py | 11 +++++++++-- tools/tests/level_load_cache.py | 2 +- tools/tests/mvd_server_api_contract.py | 2 +- tools/tests/network_ipv4_support.py | 2 +- tools/tests/renderer_temporal_presentation.py | 2 +- tools/tests/weapon_wheel_time_scale.py | 2 +- 8 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index e830e5a9..98fe751a 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -13,8 +13,9 @@ permissions: env: # Keep the engine's shared File/FileSystem ABI locked to the companion - # revision that implements GAME_API 45 and generated-animation cache v3. - OPENQ4_GAMELIBS_SHA: 09fa7e175efa5a5d819569ba577fd0e11c38a891 + # revision that implements GAME_API 46, generated-animation cache v3, and + # the game-owned transient time-scale interface. + OPENQ4_GAMELIBS_SHA: 9215976fa43353e4c22b617a175ad06b7547ddae jobs: script-smoke: diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index ca04127c..5f5645dd 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -9,8 +9,9 @@ permissions: env: # Keep the engine's shared File/FileSystem ABI locked to the companion - # revision that implements GAME_API 45 and generated-animation cache v3. - OPENQ4_GAMELIBS_SHA: 09fa7e175efa5a5d819569ba577fd0e11c38a891 + # revision that implements GAME_API 46, generated-animation cache v3, and + # the game-owned transient time-scale interface. + OPENQ4_GAMELIBS_SHA: 9215976fa43353e4c22b617a175ad06b7547ddae concurrency: group: openq4-push-verification-${{ github.ref }} diff --git a/tools/tests/game_type_module_selection.py b/tools/tests/game_type_module_selection.py index 1f01a7af..aee7454f 100644 --- a/tools/tests/game_type_module_selection.py +++ b/tools/tests/game_type_module_selection.py @@ -664,7 +664,7 @@ def validate_two_phase_game_api_contract() -> None: return game_api = read(game_api_path) - require(game_api, "const int GAME_API_VERSION\t\t= 45;", "current game API version") + require(game_api, "const int GAME_API_VERSION\t\t= 46;", "current game API version") require( game_api, "virtual void\t\t\t\tShutdownAfterDecls( void ) = 0;", @@ -831,7 +831,9 @@ def validate_swap_guard() -> None: def validate_async_module_state() -> None: common = read(ROOT / "src" / "framework" / "Common.cpp") validate_shutdown_lifecycle_contract(common) - helper_start = common.index("static bool openQ4_ShouldUseSmoothSingleplayerSlowTime( void ) {") + helper_start = common.index( + "static bool openQ4_ShouldUseSmoothSingleplayerSlowTime( float effectiveTimeScale ) {" + ) helper_end = common.index("\n}\n", helper_start) helper = common[helper_start:helper_end] @@ -850,6 +852,11 @@ def validate_async_module_state() -> None: "openQ4_singleplayerGameModuleReady.load( std::memory_order_acquire )", "async-safe game module state", ) + require( + helper, + "return effectiveTimeScale < 0.999f;", + "composed single-player slow-time detection", + ) load_start = common.index("void idCommonLocal::LoadGameDLL( void ) {") unload_start = common.index("void idCommonLocal::UnloadGameDLL( void ) {", load_start) diff --git a/tools/tests/level_load_cache.py b/tools/tests/level_load_cache.py index 0887391b..5bda15b9 100644 --- a/tools/tests/level_load_cache.py +++ b/tools/tests/level_load_cache.py @@ -53,7 +53,7 @@ def validate_abi_contract() -> None: ) require( read(GAME_ROOT, "src/game/Game.h"), - "GAME_API_VERSION\t\t= 45", + "GAME_API_VERSION\t\t= 46", "game module ABI", ) diff --git a/tools/tests/mvd_server_api_contract.py b/tools/tests/mvd_server_api_contract.py index 36795881..4d365c39 100644 --- a/tools/tests/mvd_server_api_contract.py +++ b/tools/tests/mvd_server_api_contract.py @@ -93,7 +93,7 @@ def main() -> None: "// 42: durable server MVD publication results for competitive match evidence", "append-only MVD result ABI history", ) - require(game_api_h, "GAME_API_VERSION\t\t= 45", "current game-module ABI version") + require(game_api_h, "GAME_API_VERSION\t\t= 46", "current game-module ABI version") forwarding = ( "multiViewDemo.StartNamedRecording( name )", diff --git a/tools/tests/network_ipv4_support.py b/tools/tests/network_ipv4_support.py index 05ad99bc..5a3d699a 100644 --- a/tools/tests/network_ipv4_support.py +++ b/tools/tests/network_ipv4_support.py @@ -311,7 +311,7 @@ def validate_idport_platform_data_mirror() -> None: if game_api.is_file(): require( game_api.read_text(encoding="utf-8", errors="strict"), - "const int GAME_API_VERSION\t\t= 45;", + "const int GAME_API_VERSION\t\t= 46;", "idPort-layout game API revision", ) diff --git a/tools/tests/renderer_temporal_presentation.py b/tools/tests/renderer_temporal_presentation.py index 94a410f0..abd2093e 100644 --- a/tools/tests/renderer_temporal_presentation.py +++ b/tools/tests/renderer_temporal_presentation.py @@ -65,7 +65,7 @@ def main() -> int: ) require( read(GAME_ROOT / "src" / "game" / "Game.h"), - "const int GAME_API_VERSION\t\t= 45;", + "const int GAME_API_VERSION\t\t= 46;", "game ABI v45", ) diff --git a/tools/tests/weapon_wheel_time_scale.py b/tools/tests/weapon_wheel_time_scale.py index ff25f14e..d1ffb4b2 100644 --- a/tools/tests/weapon_wheel_time_scale.py +++ b/tools/tests/weapon_wheel_time_scale.py @@ -88,7 +88,7 @@ def main() -> None: 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") + require(game_api, "const int GAME_API_VERSION\t\t= 46;", "updated engine/GameLib ABI") print("single-player time-scale and walking CVar checks passed") From dc28bec825d35d288a0263b4efec9ce4f1b1166e Mon Sep 17 00:00:00 2001 From: themuffinator Date: Fri, 28 Aug 2026 06:12:40 +0100 Subject: [PATCH 4/4] Harden input, restore, and presentation state --- .github/workflows/commit-validation.yml | 14 +- .github/workflows/push-verification.yml | 14 +- TODO.md | 2 +- docs/dev/release-completion.md | 4 + docs/dev/releases/v0.12.0.md | 4 + src/idlib/Base64.cpp | 6 +- src/idlib/Lexer.cpp | 16 +- src/idlib/Token.h | 15 +- src/renderer/GuiModel.cpp | 24 + src/renderer/ModelDecal.cpp | 84 ++- src/renderer/Model_md5r.cpp | 38 +- src/ui/SimpleWindow.cpp | 152 +++-- src/ui/UserInterface.cpp | 102 ++-- src/ui/UserInterfaceLocal.h | 1 + src/ui/Window.cpp | 568 +++++++++++++----- src/ui/Window.h | 21 +- src/ui/Winvar.h | 131 +++- tools/analysis/clang_tidy_input_safety.py | 63 +- tools/tests/base64_input_safety.py | 188 ++++++ tools/tests/clang_tidy_input_safety.py | 52 +- tools/tests/cmdargs_append_contract.py | 21 + tools/tests/lexer_input_safety.py | 97 +++ .../tests/renderer_demo_projection_safety.py | 124 ++++ tools/tests/savegame_corruption_contract.py | 57 +- tools/tests/ui_cursor_state_safety.py | 83 +++ tools/tests/ui_window_state_safety.py | 201 +++++++ tools/tests/weapon_zoom_view_alignment.py | 62 ++ tools/validation/openq4_validate.py | 6 + 28 files changed, 1789 insertions(+), 361 deletions(-) create mode 100644 tools/tests/base64_input_safety.py create mode 100644 tools/tests/lexer_input_safety.py create mode 100644 tools/tests/renderer_demo_projection_safety.py create mode 100644 tools/tests/ui_cursor_state_safety.py create mode 100644 tools/tests/ui_window_state_safety.py create mode 100644 tools/tests/weapon_zoom_view_alignment.py diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 98fe751a..782f9604 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -15,7 +15,7 @@ env: # Keep the engine's shared File/FileSystem ABI locked to the companion # revision that implements GAME_API 46, generated-animation cache v3, and # the game-owned transient time-scale interface. - OPENQ4_GAMELIBS_SHA: 9215976fa43353e4c22b617a175ad06b7547ddae + OPENQ4_GAMELIBS_SHA: 67fcd3e2142bc07c4871873ea1ec6fe592ac8a5a jobs: script-smoke: @@ -84,6 +84,7 @@ jobs: tools/tests/aas_settings_contract.py \ tools/tests/arena_campaign.py \ tools/tests/async_drop_client_contract.py \ + tools/tests/base64_input_safety.py \ tools/tests/campaign_split_state_transition.py \ tools/tests/clang_tidy_input_safety.py \ tools/tests/cmdargs_append_contract.py \ @@ -106,6 +107,7 @@ jobs: tools/tests/level_load_cache.py \ tools/tests/hdr_postprocess_math.py \ tools/tests/idstr_input_safety.py \ + tools/tests/lexer_input_safety.py \ tools/tests/lexer_peek_contract.py \ tools/tests/key_bind_presentation.py \ tools/tests/levelshot_inventory.py \ @@ -136,6 +138,7 @@ jobs: tools/tests/loading_pacifier_pacing.py \ tools/tests/map_entity_strings.py \ tools/tests/weapon_wheel_time_scale.py \ + tools/tests/weapon_zoom_view_alignment.py \ tools/tests/macos_apple_gl21_arb2_corridor.py \ tools/tests/macos_dedicated_server_smoke.py \ tools/tests/macos_dedicated_server_smoke_contract.py \ @@ -194,6 +197,7 @@ jobs: tools/tests/renderer_advanced_lighting.py \ tools/tests/renderer_budget_contract.py \ tools/tests/renderer_cel_shading.py \ + tools/tests/renderer_demo_projection_safety.py \ tools/tests/renderer_gpu_frame_timing.py \ tools/tests/renderer_gpu_skinning.py \ tools/tests/renderer_temporal_presentation.py \ @@ -233,6 +237,8 @@ jobs: tools/tests/stock_asset_baseline.py \ tools/tests/stock_media_fallbacks.py \ tools/tests/ui_embedded_icons.py \ + tools/tests/ui_cursor_state_safety.py \ + tools/tests/ui_window_state_safety.py \ tools/tests/validation_hardening.py \ tools/tests/vk_shader_header_pin.py \ tools/tests/vk_temporal_resolve_shader_pin.py \ @@ -246,6 +252,7 @@ jobs: python tools/tests/aas_settings_contract.py python tools/tests/arena_campaign.py python tools/tests/async_drop_client_contract.py + python tools/tests/base64_input_safety.py python tools/tests/campaign_split_state_transition.py python tools/tests/clang_tidy_input_safety.py python tools/tests/cmdargs_append_contract.py @@ -268,6 +275,7 @@ jobs: python tools/tests/level_load_cache.py python tools/tests/hdr_postprocess_math.py python tools/tests/idstr_input_safety.py + python tools/tests/lexer_input_safety.py python tools/tests/lexer_peek_contract.py python tools/tests/key_bind_presentation.py python tools/tests/levelshot_inventory.py @@ -295,6 +303,7 @@ jobs: 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/weapon_zoom_view_alignment.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 @@ -352,6 +361,7 @@ jobs: python tools/tests/renderer_advanced_lighting.py python tools/tests/renderer_budget_contract.py python tools/tests/renderer_cel_shading.py + python tools/tests/renderer_demo_projection_safety.py python tools/tests/renderer_gpu_frame_timing.py python tools/tests/renderer_gpu_skinning.py python tools/tests/renderer_temporal_presentation.py @@ -389,6 +399,8 @@ jobs: python tools/tests/stock_asset_baseline.py python tools/tests/stock_media_fallbacks.py python tools/tests/ui_embedded_icons.py + python tools/tests/ui_cursor_state_safety.py + python tools/tests/ui_window_state_safety.py python tools/tests/validation_hardening.py python tools/tests/vk_shader_header_pin.py python tools/tests/vk_temporal_resolve_shader_pin.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index 5f5645dd..bc40344b 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -11,7 +11,7 @@ env: # Keep the engine's shared File/FileSystem ABI locked to the companion # revision that implements GAME_API 46, generated-animation cache v3, and # the game-owned transient time-scale interface. - OPENQ4_GAMELIBS_SHA: 9215976fa43353e4c22b617a175ad06b7547ddae + OPENQ4_GAMELIBS_SHA: 67fcd3e2142bc07c4871873ea1ec6fe592ac8a5a concurrency: group: openq4-push-verification-${{ github.ref }} @@ -84,6 +84,7 @@ jobs: tools/tests/aas_settings_contract.py \ tools/tests/arena_campaign.py \ tools/tests/async_drop_client_contract.py \ + tools/tests/base64_input_safety.py \ tools/tests/campaign_split_state_transition.py \ tools/tests/clang_tidy_input_safety.py \ tools/tests/cmdargs_append_contract.py \ @@ -106,6 +107,7 @@ jobs: tools/tests/level_load_cache.py \ tools/tests/hdr_postprocess_math.py \ tools/tests/idstr_input_safety.py \ + tools/tests/lexer_input_safety.py \ tools/tests/lexer_peek_contract.py \ tools/tests/key_bind_presentation.py \ tools/tests/levelshot_inventory.py \ @@ -136,6 +138,7 @@ jobs: tools/tests/loading_pacifier_pacing.py \ tools/tests/map_entity_strings.py \ tools/tests/weapon_wheel_time_scale.py \ + tools/tests/weapon_zoom_view_alignment.py \ tools/tests/macos_apple_gl21_arb2_corridor.py \ tools/tests/macos_dedicated_server_smoke.py \ tools/tests/macos_dedicated_server_smoke_contract.py \ @@ -194,6 +197,7 @@ jobs: tools/tests/renderer_advanced_lighting.py \ tools/tests/renderer_budget_contract.py \ tools/tests/renderer_cel_shading.py \ + tools/tests/renderer_demo_projection_safety.py \ tools/tests/renderer_gpu_frame_timing.py \ tools/tests/renderer_gpu_skinning.py \ tools/tests/renderer_temporal_presentation.py \ @@ -233,6 +237,8 @@ jobs: tools/tests/stock_asset_baseline.py \ tools/tests/stock_media_fallbacks.py \ tools/tests/ui_embedded_icons.py \ + tools/tests/ui_cursor_state_safety.py \ + tools/tests/ui_window_state_safety.py \ tools/tests/validation_hardening.py \ tools/tests/vk_shader_header_pin.py \ tools/tests/vk_temporal_resolve_shader_pin.py \ @@ -246,6 +252,7 @@ jobs: python tools/tests/aas_settings_contract.py python tools/tests/arena_campaign.py python tools/tests/async_drop_client_contract.py + python tools/tests/base64_input_safety.py python tools/tests/campaign_split_state_transition.py python tools/tests/clang_tidy_input_safety.py python tools/tests/cmdargs_append_contract.py @@ -268,6 +275,7 @@ jobs: python tools/tests/level_load_cache.py python tools/tests/hdr_postprocess_math.py python tools/tests/idstr_input_safety.py + python tools/tests/lexer_input_safety.py python tools/tests/lexer_peek_contract.py python tools/tests/key_bind_presentation.py python tools/tests/levelshot_inventory.py @@ -295,6 +303,7 @@ jobs: 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/weapon_zoom_view_alignment.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 @@ -352,6 +361,7 @@ jobs: python tools/tests/renderer_advanced_lighting.py python tools/tests/renderer_budget_contract.py python tools/tests/renderer_cel_shading.py + python tools/tests/renderer_demo_projection_safety.py python tools/tests/renderer_gpu_frame_timing.py python tools/tests/renderer_gpu_skinning.py python tools/tests/renderer_temporal_presentation.py @@ -389,6 +399,8 @@ jobs: python tools/tests/stock_asset_baseline.py python tools/tests/stock_media_fallbacks.py python tools/tests/ui_embedded_icons.py + python tools/tests/ui_cursor_state_safety.py + python tools/tests/ui_window_state_safety.py python tools/tests/validation_hardening.py python tools/tests/vk_shader_header_pin.py python tools/tests/vk_temporal_resolve_shader_pin.py diff --git a/TODO.md b/TODO.md index c998dec1..1def9cb2 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ This file tracks current known issues and upcoming features. - [x] Viewport sometimes remains black when changing between SP and MP; investigate update/refresh logic during module transitions. - [ ] Menu cursor handling needs improvement (focus, capture, and consistency across input modes and resolutions). - [x] The locked door/scripted trigger progression bug inherited from Quake4Doom was fixed by porting OpenD3's x64 script-compiler pointer-temp storage guard (4-byte object-ref temp vs 8-byte pointer temp mismatch). -- [ ] Machinegun zoom projection yaw differs from viewangles yaw. +- [x] Machinegun zoom projection yaw now follows the interpolated camera actually presented in both SP and MP. ## Upcoming Features and Improvements diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index f044b32c..ff486c3e 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] Machinegun scope markings now follow the interpolated camera that is actually displayed, rather than the weapon's last 60 Hz simulation pose. Turning while zoomed therefore stays aligned at high refresh rates in both single-player and multiplayer, and the same presentation-state route is available to other weapon zoom GUIs. +- [x] Asset parsing and GUI state handling now fail safely at several legacy edge cases. Default parser tokens initialize all metadata, lexer file loads reject impossible lengths, allocation failures, and short reads before touching the buffer, and GUI cursor/save values reject NaN or infinity. Relative input, absolute input, demos, and save restores now share one finite, aspect-aware cursor-bounds path instead of allowing invalid or off-canvas state to persist. +- [x] Corrupt auxiliary data now stops at a safe boundary instead of leaking state into later rendering or restore work. Engine and GameLib Base64 paths agree on bounded allocation, null handling, whitespace, and invalid characters; GUI timelines reject malformed or overflowing timestamps and clean up failed scripts; GUI window saves validate writes, structure, identity, and finite layout values; and malformed GUI/decal demo payloads or undefined decal projections are rejected before they can produce invalid geometry. +- [x] Post-map validation configs now enter the engine command queue instead of executing synchronously from the active game draw. This removes a reentrant screen-update path that could leave SP screenshots and save previews filled with untouched allocator memory; the strict windowed retail-PK4 baseline now passes SP capture, save/restore, demo playback, MP listen/client capture, image-integrity checks, and all map-specific CPU/GPU budgets. - [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. diff --git a/docs/dev/releases/v0.12.0.md b/docs/dev/releases/v0.12.0.md index f7cab81e..374e02ce 100644 --- a/docs/dev/releases/v0.12.0.md +++ b/docs/dev/releases/v0.12.0.md @@ -63,6 +63,7 @@ - Added `r_rendererModernQuality` as the one-setting Milestone F rollback while leaving the PBR, probe, and decal leaf features default-off. Visible-lighting parity promotion remains disabled. - Added independent, default-off OpenGL/Vulkan froxel-volumetric, SSR, and SSGI leaves to the native scene presentation pass, bounded respectively to 16 view-depth slices, 16 depth-normal ray steps, and eight diffuse-GI taps. Effect-only use does not allocate temporal history, engine captures retain the result, and missing resources preserve the established presentation owner. - Added presentation-only interpolation for high-refresh rendering of eligible SP actors, skeletal animation, movers, first-person weapons, projectiles, lights, and client effects while preserving authoritative tick behavior; multiplayer actor bodies and their stencil shadows remain authoritative. +- Aligned zoom-scope yaw with the interpolated camera actually presented in both single-player and multiplayer, preventing machinegun scope markings from stepping behind high-refresh view rotation. - Removed presentation interpolation work from non-presented cinematic fast-forward ticks and limited visible-frame pose sampling to active movers, their bounded physics-team members, and cleanup members, improving `airdefense1` loading/settle time and CPU frame pacing without changing its authoritative 60 Hz endpoint. - Avoided constructing bake/debug-only fallback light-grid layouts during ordinary play on maps without baked grids; bake commands still generate the required layout on demand. - Corrected OpenGL and Vulkan shadow-map caster coverage with topology-aware face culling, near-shell depth for sealed geometry, two-sided depth for open geometry, bounded huge-light point bias, tighter 9-tap balanced filters, consistent PCSS blocker sampling, exact world/pass/storage cache identity, bind-time atlas and point-cube provenance, live point-format and projected-atlas generation validation, newest-compatible stale reuse that rejects moved or rescaled point lights, dynamic and partial-map stencil supplements, translucent-only GLOBAL ownership, hardware atlas bounds, and safely nested OpenGL GPU timing. @@ -84,6 +85,9 @@ - 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. +- Hardened parser and GUI state boundaries: default tokens now initialize every metadata field, lexer file loads reject impossible lengths and incomplete reads without leaking resources, GUI save values reject NaN and infinity, and all cursor input and restore paths use the same finite aspect-aware bounds. +- Made malformed Base64, GUI timeline/save, GUI render-demo, and decal data fail cleanly. Both engine and GameLib decoders now use the same bounded rules, timeline timestamps cannot overflow, failed event scripts are released, GUI restore data is validated before structural state is committed, and invalid demo vertices or decal intersections are skipped instead of reaching rendering. +- Fixed blank screenshots and save previews in automated post-map gameplay checks by deferring their command scripts until the active draw has completed. - 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. diff --git a/src/idlib/Base64.cpp b/src/idlib/Base64.cpp index 2076db15..e4de2dbe 100644 --- a/src/idlib/Base64.cpp +++ b/src/idlib/Base64.cpp @@ -89,6 +89,10 @@ void idBase64::Encode( const byte *from, int size ) { } EnsureAlloced( (int)encodedSize ); // ratio and padding + trailing \0 + if ( data == NULL ) { + len = 0; + return; + } to = data; w = 0; @@ -166,7 +170,6 @@ int idBase64::Decode( byte *to ) const { return 0; } - w = 0; i = 0; n = 0; byte in[4] = {0,0,0,0}; @@ -190,7 +193,6 @@ int idBase64::Decode( byte *to ) const { w >>= 8; } i = 0; - w = 0; memset( in, 0, sizeof( in ) ); } } diff --git a/src/idlib/Lexer.cpp b/src/idlib/Lexer.cpp index 82a9eeb2..fe25f061 100644 --- a/src/idlib/Lexer.cpp +++ b/src/idlib/Lexer.cpp @@ -2050,15 +2050,29 @@ int idLexer::LoadFile( const char *filename, bool OSPath ) { return false; } length = fp->Length(); + if ( length < 0 || length == idMath::INT_MAX ) { + idLib::common->Warning( "idLexer::LoadFile: invalid length %d for '%s'", length, pathname.c_str() ); + idLib::fileSystem->CloseFile( fp ); + return false; + } // RAVEN BEGIN // amccarthy: Added memory allocation tag buf = (char *) Mem_Alloc( length + 1, MA_LEXER ); if( !buf ) { + idLib::fileSystem->CloseFile( fp ); common->FatalError( "Memory system failure : out of memory" ); + return false; } // RAVEN END + const int bytesRead = fp->Read( buf, length ); + if ( bytesRead != length ) { + idLib::common->Warning( "idLexer::LoadFile: short read for '%s' (read %d of %d bytes)", + pathname.c_str(), bytesRead, length ); + idLib::fileSystem->CloseFile( fp ); + Mem_Free( buf ); + return false; + } buf[length] = '\0'; - fp->Read( buf, length ); idLexer::fileTime = fp->Timestamp(); idLexer::filename = fp->GetFullPath(); idLib::fileSystem->CloseFile( fp ); diff --git a/src/idlib/Token.h b/src/idlib/Token.h index d983eb99..9784cd0c 100644 --- a/src/idlib/Token.h +++ b/src/idlib/Token.h @@ -171,9 +171,20 @@ class idToken : public idStr { // RAVEN BEGIN // rjohnson: initialized floatvalue to prevent fpu exceptin +// openQ4: initialize the complete token state so default-constructed parser +// temporaries are deterministic and safe to inspect before a successful read. ID_INLINE idToken::idToken( void ) : - floatvalue(0.0) + type( 0 ), + subtype( 0 ), + line( 0 ), + linesCrossed( 0 ), + flags( 0 ), + intvalue( 0 ), + floatvalue( 0.0 ), + whiteSpaceStart_p( NULL ), + whiteSpaceEnd_p( NULL ), + next( NULL ) { } @@ -223,7 +234,7 @@ ID_INLINE int idToken::GetIntValue( void ) { } ID_INLINE int idToken::WhiteSpaceBeforeToken( void ) const { - return ( whiteSpaceEnd_p > whiteSpaceStart_p ); + return ( whiteSpaceStart_p != NULL && whiteSpaceEnd_p != NULL && whiteSpaceEnd_p > whiteSpaceStart_p ); } ID_INLINE void idToken::AppendDirty( const char a ) { diff --git a/src/renderer/GuiModel.cpp b/src/renderer/GuiModel.cpp index 99465c29..ee07fb6c 100644 --- a/src/renderer/GuiModel.cpp +++ b/src/renderer/GuiModel.cpp @@ -54,6 +54,21 @@ static ID_INLINE void R_SetGuiDrawVert( idDrawVert *vert, float x, float y, floa R_SetGuiDrawVertPayload( vert ); } +static bool R_IsFiniteGuiDemoDrawVert( const idDrawVert &vert ) { + for ( int component = 0; component < 3; component++ ) { + if ( !std::isfinite( vert.xyz[component] ) || !std::isfinite( vert.normal[component] ) || + !std::isfinite( vert.tangents[0][component] ) || !std::isfinite( vert.tangents[1][component] ) ) { + return false; + } + } + return std::isfinite( vert.st[0] ) && std::isfinite( vert.st[1] ); +} + +static bool R_IsFiniteGuiDemoColor( const float color[4] ) { + return std::isfinite( color[0] ) && std::isfinite( color[1] ) && + std::isfinite( color[2] ) && std::isfinite( color[3] ); +} + static bool R_RejectGuiModelDemo( idDemoFile *demo, const char *reason ) { common->Warning( "Malformed render demo GUI model: %s; playback stopped safely", reason ); if ( demo != NULL ) { @@ -167,6 +182,11 @@ bool idGuiModel::ReadFromDemo( idDemoFile *demo ) { Clear(); return R_RejectGuiModelDemo( demo, "truncated vertex payload" ); } + verts[j].color2[0] = verts[j].color2[1] = verts[j].color2[2] = verts[j].color2[3] = 255; + if ( !R_IsFiniteGuiDemoDrawVert( verts[j] ) ) { + Clear(); + return R_RejectGuiModelDemo( demo, "non-finite vertex payload" ); + } } i = 0; @@ -224,6 +244,10 @@ bool idGuiModel::ReadFromDemo( idDemoFile *demo ) { Clear(); return R_RejectGuiModelDemo( demo, "truncated surface payload" ); } + if ( !R_IsFiniteGuiDemoColor( surf->color ) ) { + Clear(); + return R_RejectGuiModelDemo( demo, "non-finite surface color" ); + } if ( surf->firstVert < 0 || surf->numVerts < 0 || surf->firstVert > verts.Num() - surf->numVerts || surf->firstIndex < 0 || surf->numIndexes < 0 || surf->firstIndex > indexes.Num() - surf->numIndexes ) { Clear(); diff --git a/src/renderer/ModelDecal.cpp b/src/renderer/ModelDecal.cpp index 257a604d..6ee2a8f4 100644 --- a/src/renderer/ModelDecal.cpp +++ b/src/renderer/ModelDecal.cpp @@ -47,15 +47,33 @@ void R_WriteDrawVertToDemo( idDemoFile *f, const idDrawVert &vert ) { } bool R_ReadDrawVertFromDemo( idDemoFile *f, idDrawVert &vert ) { - return f->ReadVec3( vert.xyz ) == sizeof( vert.xyz ) && - f->ReadVec2( vert.st ) == sizeof( vert.st ) && - f->ReadVec3( vert.normal ) == sizeof( vert.normal ) && - f->ReadVec3( vert.tangents[0] ) == sizeof( vert.tangents[0] ) && - f->ReadVec3( vert.tangents[1] ) == sizeof( vert.tangents[1] ) && - f->ReadUnsignedChar( vert.color[0] ) == sizeof( vert.color[0] ) && - f->ReadUnsignedChar( vert.color[1] ) == sizeof( vert.color[1] ) && - f->ReadUnsignedChar( vert.color[2] ) == sizeof( vert.color[2] ) && - f->ReadUnsignedChar( vert.color[3] ) == sizeof( vert.color[3] ); + if ( f->ReadVec3( vert.xyz ) != sizeof( vert.xyz ) || + f->ReadVec2( vert.st ) != sizeof( vert.st ) || + f->ReadVec3( vert.normal ) != sizeof( vert.normal ) || + f->ReadVec3( vert.tangents[0] ) != sizeof( vert.tangents[0] ) || + f->ReadVec3( vert.tangents[1] ) != sizeof( vert.tangents[1] ) || + f->ReadUnsignedChar( vert.color[0] ) != sizeof( vert.color[0] ) || + f->ReadUnsignedChar( vert.color[1] ) != sizeof( vert.color[1] ) || + f->ReadUnsignedChar( vert.color[2] ) != sizeof( vert.color[2] ) || + f->ReadUnsignedChar( vert.color[3] ) != sizeof( vert.color[3] ) ) { + return false; + } + memset( vert.color2, 0, sizeof( vert.color2 ) ); + return true; +} + +bool R_IsFiniteDecalDemoDrawVert( const idDrawVert &vert ) { + for ( int component = 0; component < 3; component++ ) { + if ( !std::isfinite( vert.xyz[component] ) || !std::isfinite( vert.normal[component] ) || + !std::isfinite( vert.tangents[0][component] ) || !std::isfinite( vert.tangents[1][component] ) ) { + return false; + } + } + return std::isfinite( vert.st[0] ) && std::isfinite( vert.st[1] ); +} + +bool R_IsFiniteDecalProjectionPoint( const idVec3 &point ) { + return std::isfinite( point.x ) && std::isfinite( point.y ) && std::isfinite( point.z ); } bool R_RejectDecalDemo( idDemoFile *f, const char *reason ) { @@ -440,25 +458,41 @@ void idRenderModelDecal::CreateDecal( const idRenderModel *model, const decalPro // create a winding with texture coordinates for the triangle idFixedWinding fw; fw.SetNumPoints( 3 ); - if ( localInfo.parallel ) { - for ( int j = 0; j < 3; j++ ) { - fw[j] = stri->verts[stri->indexes[index+j]].xyz; - fw[j].s = localInfo.textureAxis[0].Distance( fw[j].ToVec3() ); - fw[j].t = localInfo.textureAxis[1].Distance( fw[j].ToVec3() ); + bool projectionValid = true; + for ( int j = 0; j < 3; j++ ) { + const idVec3 position = stri->verts[stri->indexes[index+j]].xyz; + if ( !R_IsFiniteDecalProjectionPoint( position ) ) { + projectionValid = false; + break; } - } else { - for ( int j = 0; j < 3; j++ ) { - idVec3 dir; + + fw[j] = position; + idVec3 texturePoint = position; + if ( !localInfo.parallel ) { + const idVec3 dir = position - localInfo.projectionOrigin; float scale; + if ( !localInfo.boundingPlanes[NUM_DECAL_BOUNDING_PLANES - 1].RayIntersection( position, dir, scale ) || + !std::isfinite( scale ) ) { + projectionValid = false; + break; + } + texturePoint = position + scale * dir; + if ( !R_IsFiniteDecalProjectionPoint( texturePoint ) ) { + projectionValid = false; + break; + } + } - fw[j] = stri->verts[stri->indexes[index+j]].xyz; - dir = fw[j].ToVec3() - localInfo.projectionOrigin; - localInfo.boundingPlanes[NUM_DECAL_BOUNDING_PLANES - 1].RayIntersection( fw[j].ToVec3(), dir, scale ); - dir = fw[j].ToVec3() + scale * dir; - fw[j].s = localInfo.textureAxis[0].Distance( dir ); - fw[j].t = localInfo.textureAxis[1].Distance( dir ); + fw[j].s = localInfo.textureAxis[0].Distance( texturePoint ); + fw[j].t = localInfo.textureAxis[1].Distance( texturePoint ); + if ( !std::isfinite( fw[j].s ) || !std::isfinite( fw[j].t ) ) { + projectionValid = false; + break; } } + if ( !projectionValid ) { + continue; + } int orBits = cullBits[v1] | cullBits[v2] | cullBits[v3]; @@ -767,6 +801,10 @@ bool idRenderModelDecal::ReadFromDemoFile( idDemoFile *f ) { f->ReadFloat( decal->vertLifeSpan[vertIndex] ) != sizeof( decal->vertLifeSpan[vertIndex] ) ) { return R_RejectDecalDemo( f, "truncated vertex payload" ); } + if ( !R_IsFiniteDecalDemoDrawVert( decal->tri.verts[vertIndex] ) || + !std::isfinite( decal->vertDepthFade[vertIndex] ) || !std::isfinite( decal->vertLifeSpan[vertIndex] ) ) { + return R_RejectDecalDemo( f, "non-finite vertex payload" ); + } } if ( f->ReadInt( decal->tri.numIndexes ) != sizeof( decal->tri.numIndexes ) ) { diff --git a/src/renderer/Model_md5r.cpp b/src/renderer/Model_md5r.cpp index 1b094a70..766f7b1d 100644 --- a/src/renderer/Model_md5r.cpp +++ b/src/renderer/Model_md5r.cpp @@ -1548,22 +1548,40 @@ bool R_MD5R_CreateDecalTriangles( idRenderModelDecal *decalModel, const srfTrian idFixedWinding fw; fw.SetNumPoints( 3 ); const int localIndices[3] = { v1, v2, v3 }; + bool projectionValid = true; for ( int pointNum = 0; pointNum < 3; ++pointNum ) { const idVec3 position = batchSilTraceVerts[ localIndices[pointNum] ].xyzw.ToVec3(); + if ( !std::isfinite( position.x ) || !std::isfinite( position.y ) || !std::isfinite( position.z ) ) { + projectionValid = false; + break; + } fw[pointNum] = position; - if ( localInfo.parallel ) { - fw[pointNum].s = localInfo.textureAxis[0].Distance( position ); - fw[pointNum].t = localInfo.textureAxis[1].Distance( position ); - } else { + idVec3 texturePoint = position; + if ( !localInfo.parallel ) { const idVec3 dir = position - localInfo.projectionOrigin; - float scale = 0.0f; - localInfo.boundingPlanes[NUM_DECAL_BOUNDING_PLANES - 1].RayIntersection( position, dir, scale ); - const idVec3 projectedPoint = position + scale * dir; - fw[pointNum].s = localInfo.textureAxis[0].Distance( projectedPoint ); - fw[pointNum].t = localInfo.textureAxis[1].Distance( projectedPoint ); + float scale; + if ( !localInfo.boundingPlanes[NUM_DECAL_BOUNDING_PLANES - 1].RayIntersection( position, dir, scale ) || + !std::isfinite( scale ) ) { + projectionValid = false; + break; + } + texturePoint = position + scale * dir; + if ( !std::isfinite( texturePoint.x ) || !std::isfinite( texturePoint.y ) || !std::isfinite( texturePoint.z ) ) { + projectionValid = false; + break; + } + } + fw[pointNum].s = localInfo.textureAxis[0].Distance( texturePoint ); + fw[pointNum].t = localInfo.textureAxis[1].Distance( texturePoint ); + if ( !std::isfinite( fw[pointNum].s ) || !std::isfinite( fw[pointNum].t ) ) { + projectionValid = false; + break; } } + if ( !projectionValid ) { + continue; + } const int orBits = cullBits[v1] | cullBits[v2] | cullBits[v3]; for ( int planeNum = 0; planeNum < NUM_DECAL_BOUNDING_PLANES; ++planeNum ) { @@ -3011,7 +3029,7 @@ bool rvRenderModelMD5R::ReadLevelLoadCachePayload( idFile &file ) { } staged.meshes.SetNum( meshCount ); idList decodedMaterialFlags; - decodedMaterialFlags.SetNum( meshCount ); + decodedMaterialFlags.AssureSize( meshCount, static_cast( 0 ) ); int totalPrimBatches = 0; for ( int meshIndex = 0; meshIndex < meshCount; ++meshIndex ) { rvMD5RMesh &mesh = staged.meshes[meshIndex]; diff --git a/src/ui/SimpleWindow.cpp b/src/ui/SimpleWindow.cpp index a6eb9423..1ed048d9 100644 --- a/src/ui/SimpleWindow.cpp +++ b/src/ui/SimpleWindow.cpp @@ -613,23 +613,44 @@ idWinVar *idSimpleWindow::GetWinVarByName(const char *_name) { idSimpleWindow::WriteToSaveGame ======================== */ -void idSimpleWindow::WriteToSaveGame( idFile *savefile ) { +static bool OpenQ4_IsFiniteSimpleWindowRectangle( const idRectangle &rect ) { + return std::isfinite( rect.x ) && std::isfinite( rect.y ) && + std::isfinite( rect.w ) && std::isfinite( rect.h ); +} - savefile->Write( &flags, sizeof( flags ) ); - savefile->Write( &drawRect, sizeof( drawRect ) ); - savefile->Write( &clientRect, sizeof( clientRect ) ); - savefile->Write( &textRect, sizeof( textRect ) ); - savefile->Write( &origin, sizeof( origin ) ); - savefile->Write( &fontNum, sizeof( fontNum ) ); - savefile->Write( &matScalex, sizeof( matScalex ) ); - savefile->Write( &matScaley, sizeof( matScaley ) ); - savefile->Write( &borderSize, sizeof( borderSize ) ); - savefile->Write( &textAlign, sizeof( textAlign ) ); - savefile->Write( &textAlignx, sizeof( textAlignx ) ); - savefile->Write( &textAligny, sizeof( textAligny ) ); - savefile->Write( &textSpacing, sizeof( textSpacing ) ); - savefile->Write( &textStyle, sizeof( textStyle ) ); - savefile->Write( &textShadow, sizeof( textShadow ) ); +static bool OpenQ4_IsFiniteSimpleWindowVec2( const idVec2 &vec ) { + return std::isfinite( vec.x ) && std::isfinite( vec.y ); +} + +void idSimpleWindow::WriteToSaveGame( idFile *savefile ) { + if ( savefile == NULL ) { + common->Error( "idSimpleWindow::WriteToSaveGame: invalid output file for window '%s'", name.c_str() ); + return; + } + if ( !OpenQ4_IsFiniteSimpleWindowRectangle( drawRect ) || !OpenQ4_IsFiniteSimpleWindowRectangle( clientRect ) || + !OpenQ4_IsFiniteSimpleWindowRectangle( textRect ) || !OpenQ4_IsFiniteSimpleWindowVec2( origin ) || + !std::isfinite( matScalex ) || !std::isfinite( matScaley ) || !std::isfinite( borderSize ) || + !std::isfinite( textAlignx ) || !std::isfinite( textAligny ) ) { + common->Error( "idSimpleWindow::WriteToSaveGame: refusing non-finite layout state for window '%s'", name.c_str() ); + return; + } + if ( !OpenQ4_WriteSaveGameField( savefile, flags, "idSimpleWindow::WriteToSaveGame", "flags" ) || + !OpenQ4_WriteSaveGameField( savefile, drawRect, "idSimpleWindow::WriteToSaveGame", "draw rect" ) || + !OpenQ4_WriteSaveGameField( savefile, clientRect, "idSimpleWindow::WriteToSaveGame", "client rect" ) || + !OpenQ4_WriteSaveGameField( savefile, textRect, "idSimpleWindow::WriteToSaveGame", "text rect" ) || + !OpenQ4_WriteSaveGameField( savefile, origin, "idSimpleWindow::WriteToSaveGame", "origin" ) || + !OpenQ4_WriteSaveGameField( savefile, fontNum, "idSimpleWindow::WriteToSaveGame", "font number" ) || + !OpenQ4_WriteSaveGameField( savefile, matScalex, "idSimpleWindow::WriteToSaveGame", "material scale x" ) || + !OpenQ4_WriteSaveGameField( savefile, matScaley, "idSimpleWindow::WriteToSaveGame", "material scale y" ) || + !OpenQ4_WriteSaveGameField( savefile, borderSize, "idSimpleWindow::WriteToSaveGame", "border size" ) || + !OpenQ4_WriteSaveGameField( savefile, textAlign, "idSimpleWindow::WriteToSaveGame", "text align" ) || + !OpenQ4_WriteSaveGameField( savefile, textAlignx, "idSimpleWindow::WriteToSaveGame", "text align x" ) || + !OpenQ4_WriteSaveGameField( savefile, textAligny, "idSimpleWindow::WriteToSaveGame", "text align y" ) || + !OpenQ4_WriteSaveGameField( savefile, textSpacing, "idSimpleWindow::WriteToSaveGame", "text spacing" ) || + !OpenQ4_WriteSaveGameField( savefile, textStyle, "idSimpleWindow::WriteToSaveGame", "text style" ) || + !OpenQ4_WriteSaveGameField( savefile, textShadow, "idSimpleWindow::WriteToSaveGame", "text shadow" ) ) { + return; + } text.WriteToSaveGame( savefile ); visible.WriteToSaveGame( savefile ); @@ -647,11 +668,19 @@ void idSimpleWindow::WriteToSaveGame( idFile *savefile ) { if ( background ) { stringLen = idLib::SizeToInt( strlen( background->GetName() ), "idSimpleWindow::WriteToSaveGame" ); - savefile->Write( &stringLen, sizeof( stringLen ) ); - savefile->Write( background->GetName(), stringLen ); + if ( stringLen > 64 * 1024 ) { + common->Error( "idSimpleWindow::WriteToSaveGame: background name for window '%s' is too long (%d bytes)", + name.c_str(), stringLen ); + return; + } + if ( !OpenQ4_WriteSaveGameInt( savefile, stringLen, "idSimpleWindow::WriteToSaveGame", "background length" ) || + !OpenQ4_WriteSaveGameBytes( savefile, background->GetName(), stringLen, + "idSimpleWindow::WriteToSaveGame", "background name" ) ) { + return; + } } else { stringLen = 0; - savefile->Write( &stringLen, sizeof( stringLen ) ); + OpenQ4_WriteSaveGameInt( savefile, stringLen, "idSimpleWindow::WriteToSaveGame", "background length" ); } } @@ -662,22 +691,60 @@ idSimpleWindow::ReadFromSaveGame ======================== */ void idSimpleWindow::ReadFromSaveGame( idFile *savefile ) { - - OpenQ4_ReadSaveGameField( savefile, flags, "idSimpleWindow::ReadFromSaveGame", "flags" ); - OpenQ4_ReadSaveGameField( savefile, drawRect, "idSimpleWindow::ReadFromSaveGame", "draw rect" ); - OpenQ4_ReadSaveGameField( savefile, clientRect, "idSimpleWindow::ReadFromSaveGame", "client rect" ); - OpenQ4_ReadSaveGameField( savefile, textRect, "idSimpleWindow::ReadFromSaveGame", "text rect" ); - OpenQ4_ReadSaveGameField( savefile, origin, "idSimpleWindow::ReadFromSaveGame", "origin" ); - OpenQ4_ReadSaveGameField( savefile, fontNum, "idSimpleWindow::ReadFromSaveGame", "font number" ); - OpenQ4_ReadSaveGameField( savefile, matScalex, "idSimpleWindow::ReadFromSaveGame", "material scale x" ); - OpenQ4_ReadSaveGameField( savefile, matScaley, "idSimpleWindow::ReadFromSaveGame", "material scale y" ); - OpenQ4_ReadSaveGameField( savefile, borderSize, "idSimpleWindow::ReadFromSaveGame", "border size" ); - OpenQ4_ReadSaveGameField( savefile, textAlign, "idSimpleWindow::ReadFromSaveGame", "text align" ); - OpenQ4_ReadSaveGameField( savefile, textAlignx, "idSimpleWindow::ReadFromSaveGame", "text align x" ); - OpenQ4_ReadSaveGameField( savefile, textAligny, "idSimpleWindow::ReadFromSaveGame", "text align y" ); - OpenQ4_ReadSaveGameField( savefile, textSpacing, "idSimpleWindow::ReadFromSaveGame", "text spacing" ); - OpenQ4_ReadSaveGameField( savefile, textStyle, "idSimpleWindow::ReadFromSaveGame", "text style" ); - OpenQ4_ReadSaveGameField( savefile, textShadow, "idSimpleWindow::ReadFromSaveGame", "text shadow" ); + int savedFlags; + idRectangle savedDrawRect; + idRectangle savedClientRect; + idRectangle savedTextRect; + idVec2 savedOrigin; + int savedFontNum; + float savedMatScaleX; + float savedMatScaleY; + float savedBorderSize; + int savedTextAlign; + float savedTextAlignX; + float savedTextAlignY; + int savedTextSpacing; + signed char savedTextStyle; + int savedTextShadow; + if ( !OpenQ4_ReadSaveGameField( savefile, savedFlags, "idSimpleWindow::ReadFromSaveGame", "flags" ) || + !OpenQ4_ReadSaveGameField( savefile, savedDrawRect, "idSimpleWindow::ReadFromSaveGame", "draw rect" ) || + !OpenQ4_ReadSaveGameField( savefile, savedClientRect, "idSimpleWindow::ReadFromSaveGame", "client rect" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextRect, "idSimpleWindow::ReadFromSaveGame", "text rect" ) || + !OpenQ4_ReadSaveGameField( savefile, savedOrigin, "idSimpleWindow::ReadFromSaveGame", "origin" ) || + !OpenQ4_ReadSaveGameField( savefile, savedFontNum, "idSimpleWindow::ReadFromSaveGame", "font number" ) || + !OpenQ4_ReadSaveGameField( savefile, savedMatScaleX, "idSimpleWindow::ReadFromSaveGame", "material scale x" ) || + !OpenQ4_ReadSaveGameField( savefile, savedMatScaleY, "idSimpleWindow::ReadFromSaveGame", "material scale y" ) || + !OpenQ4_ReadSaveGameField( savefile, savedBorderSize, "idSimpleWindow::ReadFromSaveGame", "border size" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextAlign, "idSimpleWindow::ReadFromSaveGame", "text align" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextAlignX, "idSimpleWindow::ReadFromSaveGame", "text align x" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextAlignY, "idSimpleWindow::ReadFromSaveGame", "text align y" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextSpacing, "idSimpleWindow::ReadFromSaveGame", "text spacing" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextStyle, "idSimpleWindow::ReadFromSaveGame", "text style" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextShadow, "idSimpleWindow::ReadFromSaveGame", "text shadow" ) ) { + return; + } + if ( !OpenQ4_IsFiniteSimpleWindowRectangle( savedDrawRect ) || !OpenQ4_IsFiniteSimpleWindowRectangle( savedClientRect ) || + !OpenQ4_IsFiniteSimpleWindowRectangle( savedTextRect ) || !OpenQ4_IsFiniteSimpleWindowVec2( savedOrigin ) || + !std::isfinite( savedMatScaleX ) || !std::isfinite( savedMatScaleY ) || !std::isfinite( savedBorderSize ) || + !std::isfinite( savedTextAlignX ) || !std::isfinite( savedTextAlignY ) ) { + common->Error( "idSimpleWindow::ReadFromSaveGame: non-finite layout state for window '%s'", name.c_str() ); + return; + } + flags = savedFlags; + drawRect = savedDrawRect; + clientRect = savedClientRect; + textRect = savedTextRect; + origin = savedOrigin; + fontNum = savedFontNum; + matScalex = savedMatScaleX; + matScaley = savedMatScaleY; + borderSize = savedBorderSize; + textAlign = savedTextAlign; + textAlignx = savedTextAlignX; + textAligny = savedTextAlignY; + textSpacing = savedTextSpacing; + textStyle = savedTextStyle; + textShadow = savedTextShadow; text.ReadFromSaveGame( savefile ); visible.ReadFromSaveGame( savefile ); @@ -691,21 +758,30 @@ void idSimpleWindow::ReadFromSaveGame( idFile *savefile ) { shear.ReadFromSaveGame( savefile ); backGroundName.ReadFromSaveGame( savefile ); - int stringLen; + int stringLen = 0; const int stringOffset = savefile->Tell(); - OpenQ4_ReadSaveGameField( savefile, stringLen, "idSimpleWindow::ReadFromSaveGame", "background length" ); + if ( !OpenQ4_ReadSaveGameInt( savefile, stringLen, "idSimpleWindow::ReadFromSaveGame", "background length" ) ) { + background = NULL; + return; + } const int remainingBytes = Max( 0, savefile->Length() - savefile->Tell() ); const int maxSavedStringLength = 64 * 1024; if ( stringLen < 0 || stringLen > maxSavedStringLength || stringLen > remainingBytes ) { common->Error( "idSimpleWindow::ReadFromSaveGame: invalid background length %d at offset %d (remaining %d)", stringLen, stringOffset, remainingBytes ); + background = NULL; + return; } if ( stringLen > 0 ) { idStr backName; backName.Fill( ' ', stringLen ); - OpenQ4_ReadSaveGameBytes( savefile, &(backName)[0], stringLen, "idSimpleWindow::ReadFromSaveGame", "background name" ); + if ( !OpenQ4_ReadSaveGameBytes( savefile, &(backName)[0], stringLen, + "idSimpleWindow::ReadFromSaveGame", "background name" ) ) { + background = NULL; + return; + } background = declManager->FindMaterial( backName ); if ( background ) { diff --git a/src/ui/UserInterface.cpp b/src/ui/UserInterface.cpp index d7a2ed1a..349cf3b3 100644 --- a/src/ui/UserInterface.cpp +++ b/src/ui/UserInterface.cpp @@ -354,12 +354,9 @@ void idUserInterfaceLocal::SetInteractive(bool interactive) { bool idUserInterfaceLocal::InitFromFile( const char *qpath, bool rebuild, bool cache ) { if ( !( qpath && *qpath ) ) { - // FIXME: Memory leak!! return false; } - int sz = sizeof( idWindow ); - sz = sizeof( idSimpleWindow ); loading = true; if ( rebuild ) { @@ -434,36 +431,8 @@ const char *idUserInterfaceLocal::HandleEvent( const sysEvent_t *event, int _tim } if ( event->evType == SE_MOUSE ) { - cursorX += event->evValue; - cursorY += event->evValue2; - - // Retail clamps the cursor to the virtual screen, and the game's - // in-world gui interaction depends on it: idPlayer::UpdateFocus parks - // the cursor with a large negative move (expecting it to stop at the - // corner) before every absolute reposition, so an unclamped cursor - // drifts thousands of units off-canvas and clicks never hit a window. - // Menu guis may extend past the 4:3 canvas when aspect correction is - // active, so honor the expanded bounds there instead of trapping the - // cursor at the canvas edge. - float minX = 0.0f; - float minY = 0.0f; - float maxX = static_cast( VIRTUAL_WIDTH ); - float maxY = static_cast( VIRTUAL_HEIGHT ); - if ( desktop != NULL ) { - maxX = desktop->forceAspectWidth; - maxY = desktop->forceAspectHeight; - if ( ( desktop->GetFlags() & WIN_MENUGUI ) && ui_aspectCorrection.GetBool() ) { - float xExpand = 0.0f; - float yExpand = 0.0f; - uiManagerLocal.dc.GetVirtualScreenExpansion( maxX, maxY, xExpand, yExpand ); - minX -= xExpand; - maxX += xExpand; - minY -= yExpand; - maxY += yExpand; - } - } - cursorX = idMath::ClampFloat( minX, maxX, cursorX ); - cursorY = idMath::ClampFloat( minY, maxY, cursorY ); + SetCursor( cursorX + static_cast( event->evValue ), + cursorY + static_cast( event->evValue2 ) ); } if ( desktop ) { @@ -669,8 +638,11 @@ void idUserInterfaceLocal::ReadFromDemoFile( class idDemoFile *f ) { desktop->ReadFromDemoFile(f, false); } - f->ReadFloat( cursorX ); - f->ReadFloat( cursorY ); + float restoredCursorX = 0.0f; + float restoredCursorY = 0.0f; + f->ReadFloat( restoredCursorX ); + f->ReadFloat( restoredCursorY ); + SetCursor( restoredCursorX, restoredCursorY ); bool add = true; int c = uiManagerLocal.demoGuis.Num(); @@ -730,6 +702,11 @@ static bool UI_WriteSaveGameBool( idFile *savefile, bool value, const char *deta } static bool UI_WriteSaveGameFloat( idFile *savefile, float value, const char *detail ) { + if ( !std::isfinite( value ) ) { + common->Warning( "idUserInterfaceLocal::WriteToSaveGame: refusing non-finite %s", + detail ? detail : "float" ); + return false; + } const int offset = savefile->Tell(); return UI_WriteSaveGameChecked( savefile, savefile->WriteFloat( value ), static_cast( sizeof( value ) ), offset, detail ); } @@ -843,6 +820,11 @@ static bool UI_ReadSaveGameFloat( idFile *savefile, float &value, const char *de detail ? detail : "float", offset, bytesRead, static_cast( sizeof( value ) ) ); return false; } + if ( !std::isfinite( value ) ) { + common->Warning( "idUserInterfaceLocal::ReadFromSaveGame: non-finite %s at offset %d", + detail ? detail : "float", offset ); + return false; + } return true; } @@ -949,9 +931,8 @@ bool idUserInterfaceLocal::ReadFromSaveGame( idFile *savefile ) { activateStr = restoredActivateStr; pendingCmd = restoredPendingCmd; returnCmd = restoredReturnCmd; - cursorX = restoredCursorX; - cursorY = restoredCursorY; desktop->ReadFromSaveGame( savefile ); + SetCursor( restoredCursorX, restoredCursorY ); return true; } @@ -1001,6 +982,53 @@ idUserInterfaceLocal::SetCursor void idUserInterfaceLocal::SetCursor( float x, float y ) { cursorX = x; cursorY = y; + ClampCursor(); +} + +/* +============== +idUserInterfaceLocal::ClampCursor +============== +*/ +void idUserInterfaceLocal::ClampCursor( void ) { + if ( !std::isfinite( cursorX ) ) { + cursorX = 0.0f; + } + if ( !std::isfinite( cursorY ) ) { + cursorY = 0.0f; + } + + // Retail clamps the cursor to the virtual screen, and the game's in-world + // GUI interaction depends on it: idPlayer::UpdateFocus parks the cursor at + // the corner before every absolute reposition. Menu GUIs may extend past + // the 4:3 canvas, so retain their aspect-corrected bounds. + float minX = 0.0f; + float minY = 0.0f; + float maxX = static_cast( VIRTUAL_WIDTH ); + float maxY = static_cast( VIRTUAL_HEIGHT ); + if ( desktop != NULL ) { + if ( std::isfinite( desktop->forceAspectWidth ) && desktop->forceAspectWidth > 0.0f ) { + maxX = desktop->forceAspectWidth; + } + if ( std::isfinite( desktop->forceAspectHeight ) && desktop->forceAspectHeight > 0.0f ) { + maxY = desktop->forceAspectHeight; + } + if ( ( desktop->GetFlags() & WIN_MENUGUI ) && ui_aspectCorrection.GetBool() ) { + float xExpand = 0.0f; + float yExpand = 0.0f; + uiManagerLocal.dc.GetVirtualScreenExpansion( maxX, maxY, xExpand, yExpand ); + if ( std::isfinite( xExpand ) && xExpand >= 0.0f ) { + minX -= xExpand; + maxX += xExpand; + } + if ( std::isfinite( yExpand ) && yExpand >= 0.0f ) { + minY -= yExpand; + maxY += yExpand; + } + } + } + cursorX = idMath::ClampFloat( minX, maxX, cursorX ); + cursorY = idMath::ClampFloat( minY, maxY, cursorY ); } bool idUserInterfaceLocal::GetMaxTextIndex( const char *windowName, const char *text, wrapInfo_t& wrapInfo ) const { diff --git a/src/ui/UserInterfaceLocal.h b/src/ui/UserInterfaceLocal.h index 24a61883..79fc9218 100644 --- a/src/ui/UserInterfaceLocal.h +++ b/src/ui/UserInterfaceLocal.h @@ -115,6 +115,7 @@ class idUserInterfaceLocal : public idUserInterface { float cursorX; float cursorY; + void ClampCursor( void ); int time; diff --git a/src/ui/Window.cpp b/src/ui/Window.cpp index 32ed6adb..61f6aec8 100644 --- a/src/ui/Window.cpp +++ b/src/ui/Window.cpp @@ -3246,6 +3246,43 @@ void idWindow::SetInitialState(const char *_name) { flags = 0; } +/* +================ +OpenQ4_ParseTimelineMilliseconds +================ +*/ +static bool OpenQ4_ParseTimelineMilliseconds( const char *text, int &milliseconds ) { + if ( text == NULL || text[0] == '\0' ) { + return false; + } + + bool negative = false; + if ( text[0] == '+' || text[0] == '-' ) { + negative = text[0] == '-'; + text++; + } + if ( text[0] == '\0' ) { + return false; + } + + uint64 magnitude = 0; + const uint64 magnitudeLimit = negative ? 2147483648ULL : 2147483647ULL; + for ( ; text[0] != '\0'; text++ ) { + if ( text[0] < '0' || text[0] > '9' ) { + return false; + } + const uint64 digit = static_cast( text[0] - '0' ); + if ( magnitude > ( magnitudeLimit - digit ) / 10ULL ) { + return false; + } + magnitude = magnitude * 10ULL + digit; + } + + const int64 signedValue = negative ? -static_cast( magnitude ) : static_cast( magnitude ); + milliseconds = static_cast( signedValue ); + return true; +} + /* ================ idWindow::Parse @@ -3455,6 +3492,7 @@ bool idWindow::Parse( idParser *src, bool rebuild) { src->SetMarker ( ); if ( !ParseScript ( src, *ev->mEvent ) ) { + delete ev; ret = false; break; } @@ -3480,8 +3518,6 @@ bool idWindow::Parse( idParser *src, bool rebuild) { namedEvents.Append(ev); } else if ( token == "onTime" ) { - idTimeLineEvent *ev = new idTimeLineEvent; - if ( !src->ReadToken(&token) ) { src->Error( "Unexpected end of file" ); return false; @@ -3505,16 +3541,29 @@ bool idWindow::Parse( idParser *src, bool rebuild) { return false; } - ev->time = atoi( timeToken ); + int eventTime = 0; + if ( !OpenQ4_ParseTimelineMilliseconds( timeToken, eventTime ) ) { + src->Error( "Invalid onTime value '%s'", timeToken ); + return false; + } if ( relativeTime ) { const int previousTime = ( timeLineEvents.Num() > 0 ) ? timeLineEvents[ timeLineEvents.Num() - 1 ]->time : 0; - ev->time += previousTime; + const int64 combinedTime = static_cast( eventTime ) + static_cast( previousTime ); + if ( combinedTime < idMath::INT_MIN || combinedTime > idMath::INT_MAX ) { + src->Error( "onTime value '%s' overflows the timeline", timeToken ); + return false; + } + eventTime = static_cast( combinedTime ); } + + idTimeLineEvent *ev = new idTimeLineEvent; + ev->time = eventTime; // reset the mark since we dont want it to include the time src->SetMarker ( ); if (!ParseScript(src, *ev->event, &ev->time)) { + delete ev; ret = false; break; } @@ -4105,7 +4154,7 @@ intptr_t idWindow::ParseTerm( idParser *src, idWinVar *var, intptr_t component ) } else { // ugly but used for post parsing to fixup named vars char *p = new char[token.Length()+1]; - strcpy(p, token); + idStr::Copynz( p, token.c_str(), token.Length() + 1 ); a = (intptr_t)p; b = -2; return EmitOp(a, b, WOP_TYPE_VAR); @@ -4605,31 +4654,37 @@ void idWindow::WriteToDemoFile( class idDemoFile *f ) { idWindow::WriteString =============== */ -void idWindow::WriteSaveGameString( const char *string, idFile *savefile ) { +static bool OpenQ4_IsFiniteSaveGameRectangle( const idRectangle &rect ) { + return std::isfinite( rect.x ) && std::isfinite( rect.y ) && + std::isfinite( rect.w ) && std::isfinite( rect.h ); +} + +static bool OpenQ4_IsFiniteSaveGameVec2( const idVec2 &vec ) { + return std::isfinite( vec.x ) && std::isfinite( vec.y ); +} + +bool idWindow::WriteSaveGameString( const char *string, idFile *savefile ) { if ( savefile == NULL || string == NULL ) { common->Error( "idWindow::WriteSaveGameString: invalid output file/string for window '%s'", name.c_str() ); + return false; } const int len = idLib::SizeToInt( strlen( string ), "idWindow::WriteSaveGameString" ); const int maxSavedStringLength = 64 * 1024; if ( len > maxSavedStringLength ) { common->Error( "idWindow::WriteSaveGameString: string for window '%s' in gui '%s' is too long (%d bytes)", name.c_str(), gui ? gui->GetSourceFile() : "", len ); + return false; } - const int lengthOffset = savefile->Tell(); - const int lengthBytes = savefile->WriteInt( len ); - if ( lengthBytes != static_cast( sizeof( len ) ) ) { - common->Error( "idWindow::WriteSaveGameString: failed to write length for window '%s' at offset %d (%d of %d bytes)", - name.c_str(), lengthOffset, lengthBytes, static_cast( sizeof( len ) ) ); + if ( !OpenQ4_WriteSaveGameInt( savefile, len, "idWindow::WriteSaveGameString", "length" ) ) { + return false; } if ( len > 0 ) { - const int stringOffset = savefile->Tell(); - const int stringBytes = savefile->Write( string, len ); - if ( stringBytes != len ) { - common->Error( "idWindow::WriteSaveGameString: failed to write string for window '%s' at offset %d (%d of %d bytes)", - name.c_str(), stringOffset, stringBytes, len ); + if ( !OpenQ4_WriteSaveGameBytes( savefile, string, len, "idWindow::WriteSaveGameString", "string" ) ) { + return false; } } + return true; } /* @@ -4637,10 +4692,11 @@ void idWindow::WriteSaveGameString( const char *string, idFile *savefile ) { idWindow::WriteSaveGameTransition =============== */ -void idWindow::WriteSaveGameTransition( idTransitionData &trans, idFile *savefile ) { +bool idWindow::WriteSaveGameTransition( idTransitionData &trans, idFile *savefile ) { if ( savefile == NULL || gui == NULL || gui->GetDesktop() == NULL || trans.data == NULL ) { common->Error( "idWindow::WriteSaveGameTransition: invalid transition context for window '%s'", name.c_str() ); + return false; } drawWin_t dw; dw.simp = NULL; @@ -4649,27 +4705,22 @@ void idWindow::WriteSaveGameTransition( idTransitionData &trans, idFile *savefil if ( transitionOffset < 0 || transitionOffset > 0x7fffffff || ( dw.win == NULL ) == ( dw.simp == NULL ) ) { common->Error( "idWindow::WriteSaveGameTransition: could not resolve transition target for window '%s' in gui '%s'", name.c_str(), gui->GetSourceFile() ); + return false; } const idStr winName = ( dw.win != NULL ) ? dw.win->GetName() : dw.simp->name.c_str(); drawWin_t *foundWindow = gui->GetDesktop()->FindChildByName( winName ); if ( winName.IsEmpty() || foundWindow == NULL || foundWindow->win != dw.win || foundWindow->simp != dw.simp ) { common->Error( "idWindow::WriteSaveGameTransition: transition target '%s' for window '%s' in gui '%s' is missing or ambiguous", winName.c_str(), name.c_str(), gui->GetSourceFile() ); + return false; } const int savedOffset = static_cast( transitionOffset ); - const int offsetPosition = savefile->Tell(); - const int offsetBytes = savefile->WriteInt( savedOffset ); - if ( offsetBytes != static_cast( sizeof( savedOffset ) ) ) { - common->Error( "idWindow::WriteSaveGameTransition: failed to write target offset at %d (%d of %d bytes)", - offsetPosition, offsetBytes, static_cast( sizeof( savedOffset ) ) ); - } - WriteSaveGameString( winName, savefile ); - const int interpolationPosition = savefile->Tell(); - const int interpolationBytes = savefile->Write( &trans.interp, sizeof( trans.interp ) ); - if ( interpolationBytes != static_cast( sizeof( trans.interp ) ) ) { - common->Error( "idWindow::WriteSaveGameTransition: failed to write interpolate state at %d (%d of %d bytes)", - interpolationPosition, interpolationBytes, static_cast( sizeof( trans.interp ) ) ); + if ( !OpenQ4_WriteSaveGameInt( savefile, savedOffset, "idWindow::WriteSaveGameTransition", "target offset" ) || + !WriteSaveGameString( winName, savefile ) || + !OpenQ4_WriteSaveGameField( savefile, trans.interp, "idWindow::WriteSaveGameTransition", "interpolate state" ) ) { + return false; } + return true; } /* @@ -4677,28 +4728,35 @@ void idWindow::WriteSaveGameTransition( idTransitionData &trans, idFile *savefil idWindow::ReadSaveGameTransition =============== */ -void idWindow::ReadSaveGameTransition( idTransitionData &trans, idFile *savefile ) { - int offset; +bool idWindow::ReadSaveGameTransition( idTransitionData &trans, idFile *savefile ) { + int offset = -1; + trans.data = NULL; - OpenQ4_ReadSaveGameField( savefile, offset, "idWindow::ReadSaveGameTransition", "offset" ); + if ( !OpenQ4_ReadSaveGameInt( savefile, offset, "idWindow::ReadSaveGameTransition", "offset" ) ) { + return false; + } if ( offset != -1 ) { if ( offset < 0 ) { common->Error( "idWindow::ReadSaveGameTransition: invalid target offset %d for window '%s' in gui '%s'", offset, name.c_str(), gui ? gui->GetSourceFile() : "" ); + return false; } idStr winName; - ReadSaveGameString( winName, savefile ); - OpenQ4_ReadSaveGameField( savefile, trans.interp, "idWindow::ReadSaveGameTransition", "interpolate state" ); - trans.data = NULL; + if ( !ReadSaveGameString( winName, savefile ) || + !OpenQ4_ReadSaveGameField( savefile, trans.interp, "idWindow::ReadSaveGameTransition", "interpolate state" ) ) { + return false; + } trans.offset = offset; if ( winName.IsEmpty() ) { common->Error( "idWindow::ReadSaveGameTransition: transition for window '%s' in gui '%s' has an empty target name", name.c_str(), gui ? gui->GetSourceFile() : "" ); + return false; } idWinStr *strVar = new idWinStr(); strVar->Set( winName ); trans.data = dynamic_cast< idWinVar* >( strVar ); } + return true; } static const int SAVEGAME_WINDOW_REFERENCE_NULL = -1; @@ -4716,21 +4774,27 @@ int idWindow::SaveGameChildIDCompare( idWindow * const *left, idWindow * const * return 0; } -void idWindow::BuildSaveGameChildOrder( idList &orderedChildren, const char *operation ) const { +bool idWindow::BuildSaveGameChildOrder( idList &orderedChildren, const char *operation ) const { orderedChildren.SetNum( children.Num() ); for ( int i = 0; i < children.Num(); i++ ) { idWindow *child = children[i]; if ( child == NULL ) { common->Error( "%s: window '%s' in gui '%s' has a NULL child at index %d", operation, name.c_str(), gui ? gui->GetSourceFile() : "", i ); + orderedChildren.Clear(); + return false; } if ( child->parent != this ) { common->Error( "%s: child '%s' of window '%s' in gui '%s' has an inconsistent parent", operation, child->name.c_str(), name.c_str(), gui ? gui->GetSourceFile() : "" ); + orderedChildren.Clear(); + return false; } if ( child->childID < 0 ) { common->Error( "%s: child '%s' of window '%s' in gui '%s' has invalid id %d", operation, child->name.c_str(), name.c_str(), gui ? gui->GetSourceFile() : "", child->childID ); + orderedChildren.Clear(); + return false; } orderedChildren[i] = child; } @@ -4740,21 +4804,28 @@ void idWindow::BuildSaveGameChildOrder( idList &orderedChildren, con common->Error( "%s: children '%s' and '%s' of window '%s' in gui '%s' have duplicate id %d", operation, orderedChildren[i - 1]->name.c_str(), orderedChildren[i]->name.c_str(), name.c_str(), gui ? gui->GetSourceFile() : "", orderedChildren[i]->childID ); + orderedChildren.Clear(); + return false; } } + return true; } bool idWindow::FindSaveGameDescendantOrdinal( const idWindow *window, int &nextOrdinal, int &foundOrdinal, int depth ) const { if ( depth > SAVEGAME_MAX_WINDOW_DEPTH ) { common->Error( "idWindow::WriteToSaveGame: gui '%s' exceeds the maximum window nesting depth", gui ? gui->GetSourceFile() : "" ); + return false; } idList orderedChildren; - BuildSaveGameChildOrder( orderedChildren, "idWindow::WriteToSaveGame" ); + if ( !BuildSaveGameChildOrder( orderedChildren, "idWindow::WriteToSaveGame" ) ) { + return false; + } for ( int i = 0; i < orderedChildren.Num(); i++ ) { if ( nextOrdinal >= SAVEGAME_MAX_WINDOW_DESCENDANTS ) { common->Error( "idWindow::WriteToSaveGame: gui '%s' exceeds the maximum descendant count", gui ? gui->GetSourceFile() : "" ); + return false; } idWindow *child = orderedChildren[i]; const int childOrdinal = nextOrdinal++; @@ -4773,13 +4844,17 @@ idWindow *idWindow::FindSaveGameDescendantByOrdinal( int targetOrdinal, int &nex if ( depth > SAVEGAME_MAX_WINDOW_DEPTH ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' exceeds the maximum window nesting depth", gui ? gui->GetSourceFile() : "" ); + return NULL; } idList orderedChildren; - BuildSaveGameChildOrder( orderedChildren, "idWindow::ReadFromSaveGame" ); + if ( !BuildSaveGameChildOrder( orderedChildren, "idWindow::ReadFromSaveGame" ) ) { + return NULL; + } for ( int i = 0; i < orderedChildren.Num(); i++ ) { if ( nextOrdinal >= SAVEGAME_MAX_WINDOW_DESCENDANTS ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' exceeds the maximum descendant count", gui ? gui->GetSourceFile() : "" ); + return NULL; } idWindow *child = orderedChildren[i]; if ( nextOrdinal++ == targetOrdinal ) { @@ -4793,39 +4868,49 @@ idWindow *idWindow::FindSaveGameDescendantByOrdinal( int targetOrdinal, int &nex return NULL; } -void idWindow::FindSaveGameFlaggedDescendants( unsigned int flag, idWindow *&match, int &matches, int &visited, int depth ) { +bool idWindow::FindSaveGameFlaggedDescendants( unsigned int flag, idWindow *&match, int &matches, int &visited, int depth ) { if ( depth > SAVEGAME_MAX_WINDOW_DEPTH ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' exceeds the maximum window nesting depth", gui ? gui->GetSourceFile() : "" ); + return false; } idList orderedChildren; - BuildSaveGameChildOrder( orderedChildren, "idWindow::ReadFromSaveGame" ); + if ( !BuildSaveGameChildOrder( orderedChildren, "idWindow::ReadFromSaveGame" ) ) { + return false; + } for ( int i = 0; i < orderedChildren.Num(); i++ ) { if ( visited++ >= SAVEGAME_MAX_WINDOW_DESCENDANTS ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' exceeds the maximum descendant count", gui ? gui->GetSourceFile() : "" ); + return false; } idWindow *child = orderedChildren[i]; if ( ( child->flags & flag ) != 0 ) { match = child; matches++; } - child->FindSaveGameFlaggedDescendants( flag, match, matches, visited, depth + 1 ); + if ( !child->FindSaveGameFlaggedDescendants( flag, match, matches, visited, depth + 1 ) ) { + return false; + } } + return true; } -void idWindow::ValidateRestoredTrackedWindowPointers( bool hadSavedFocusReference, bool hadSavedCaptureReference ) { +bool idWindow::ValidateRestoredTrackedWindowPointers( bool hadSavedFocusReference, bool hadSavedCaptureReference ) { if ( ( flags & WIN_DESKTOP ) == 0 ) { - return; + return true; } idWindow *flaggedFocus = NULL; int focusMatches = 0; int visited = 0; - FindSaveGameFlaggedDescendants( WIN_FOCUS, flaggedFocus, focusMatches, visited, 0 ); + if ( !FindSaveGameFlaggedDescendants( WIN_FOCUS, flaggedFocus, focusMatches, visited, 0 ) ) { + return false; + } if ( focusMatches > 1 ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' restored %d focused windows", gui ? gui->GetSourceFile() : "", focusMatches ); + return false; } if ( focusMatches == 1 ) { // Legacy v2 saves could serialize a nested window using only its parent-local @@ -4834,29 +4919,42 @@ void idWindow::ValidateRestoredTrackedWindowPointers( bool hadSavedFocusReferenc } else if ( hadSavedFocusReference || focusedChild != NULL ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' restored a focused child reference without a focused window", gui ? gui->GetSourceFile() : "" ); + return false; } idWindow *flaggedCapture = NULL; int captureMatches = 0; visited = 0; - FindSaveGameFlaggedDescendants( WIN_CAPTURE, flaggedCapture, captureMatches, visited, 0 ); + if ( !FindSaveGameFlaggedDescendants( WIN_CAPTURE, flaggedCapture, captureMatches, visited, 0 ) ) { + return false; + } if ( captureMatches > 1 ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' restored %d captured windows", gui ? gui->GetSourceFile() : "", captureMatches ); + return false; } if ( captureMatches == 1 ) { captureChild = flaggedCapture; } else if ( hadSavedCaptureReference || captureChild != NULL ) { common->Error( "idWindow::ReadFromSaveGame: gui '%s' restored a capture child reference without a captured window", gui ? gui->GetSourceFile() : "" ); + return false; } + return true; } -void idWindow::WriteSaveGameChildReference( idWindow *child, idFile *savefile, const char *fieldName, bool allowDescendant ) { +bool idWindow::WriteSaveGameChildReference( idWindow *child, idFile *savefile, const char *fieldName, bool allowDescendant ) { + if ( savefile == NULL ) { + common->Error( "idWindow::WriteToSaveGame: invalid output file while writing %s for window '%s'", + fieldName ? fieldName : "child reference", name.c_str() ); + return false; + } int childId = -1; if ( child != NULL ) { idList orderedChildren; - BuildSaveGameChildOrder( orderedChildren, "idWindow::WriteToSaveGame" ); + if ( !BuildSaveGameChildOrder( orderedChildren, "idWindow::WriteToSaveGame" ) ) { + return false; + } bool isDirectChild = false; for ( int i = 0; i < orderedChildren.Num(); i++ ) { if ( orderedChildren[i] == child ) { @@ -4872,44 +4970,52 @@ void idWindow::WriteSaveGameChildReference( idWindow *child, idFile *savefile, c if ( !FindSaveGameDescendantOrdinal( child, nextOrdinal, foundOrdinal, 0 ) ) { common->Error( "idWindow::WriteToSaveGame: %s for window '%s' in gui '%s' is not a descendant", fieldName ? fieldName : "child reference", name.c_str(), gui ? gui->GetSourceFile() : "" ); + return false; } childId = SAVEGAME_WINDOW_REFERENCE_DESCENDANT_BASE - foundOrdinal; } else { common->Error( "idWindow::WriteToSaveGame: %s for window '%s' in gui '%s' is not a valid direct child", fieldName ? fieldName : "child reference", name.c_str(), gui ? gui->GetSourceFile() : "" ); + return false; } } - const int offset = savefile->Tell(); - const int bytesWritten = savefile->WriteInt( childId ); - if ( bytesWritten != static_cast( sizeof( childId ) ) ) { - common->Error( "idWindow::WriteToSaveGame: failed to write %s for window '%s' at offset %d (%d of %d bytes)", - fieldName ? fieldName : "child reference", name.c_str(), offset, bytesWritten, static_cast( sizeof( childId ) ) ); - } + return OpenQ4_WriteSaveGameInt( savefile, childId, "idWindow::WriteToSaveGame", + fieldName ? fieldName : "child reference" ); } -idWindow *idWindow::ReadSaveGameChildReference( idFile *savefile, const char *fieldName, bool allowDescendant, bool *hadSerializedReference ) { +idWindow *idWindow::ReadSaveGameChildReference( idFile *savefile, const char *fieldName, bool allowDescendant, + bool *hadSerializedReference, bool *readSucceeded ) { int savedChildId = -1; - const int offset = savefile->Tell(); - const int bytesRead = savefile->ReadInt( savedChildId ); - if ( bytesRead != static_cast( sizeof( savedChildId ) ) ) { - common->Error( "idWindow::ReadFromSaveGame: truncated %s for window '%s' at offset %d (%d of %d bytes)", - fieldName ? fieldName : "child reference", name.c_str(), offset, bytesRead, static_cast( sizeof( savedChildId ) ) ); + if ( readSucceeded != NULL ) { + *readSucceeded = false; + } + if ( hadSerializedReference != NULL ) { + *hadSerializedReference = false; + } + if ( !OpenQ4_ReadSaveGameInt( savefile, savedChildId, "idWindow::ReadFromSaveGame", + fieldName ? fieldName : "child reference" ) ) { + return NULL; } if ( hadSerializedReference != NULL ) { *hadSerializedReference = savedChildId != SAVEGAME_WINDOW_REFERENCE_NULL; } if ( savedChildId == SAVEGAME_WINDOW_REFERENCE_NULL ) { + if ( readSucceeded != NULL ) { + *readSucceeded = true; + } return NULL; } if ( savedChildId < 0 ) { if ( !allowDescendant || savedChildId > SAVEGAME_WINDOW_REFERENCE_DESCENDANT_BASE ) { common->Error( "idWindow::ReadFromSaveGame: invalid %s %d for window '%s' in gui '%s'", fieldName ? fieldName : "child reference", savedChildId, name.c_str(), gui ? gui->GetSourceFile() : "" ); + return NULL; } const int64 targetOrdinal64 = static_cast( SAVEGAME_WINDOW_REFERENCE_DESCENDANT_BASE ) - static_cast( savedChildId ); if ( targetOrdinal64 < 0 || targetOrdinal64 >= SAVEGAME_MAX_WINDOW_DESCENDANTS ) { common->Error( "idWindow::ReadFromSaveGame: invalid descendant ordinal %lld for %s in window '%s' in gui '%s'", static_cast( targetOrdinal64 ), fieldName ? fieldName : "child reference", name.c_str(), gui ? gui->GetSourceFile() : "" ); + return NULL; } const int targetOrdinal = static_cast( targetOrdinal64 ); int nextOrdinal = 0; @@ -4917,14 +5023,23 @@ idWindow *idWindow::ReadSaveGameChildReference( idFile *savefile, const char *fi if ( resolved == NULL ) { common->Error( "idWindow::ReadFromSaveGame: descendant ordinal %d for %s in window '%s' in gui '%s' could not be resolved", targetOrdinal, fieldName ? fieldName : "child reference", name.c_str(), gui ? gui->GetSourceFile() : "" ); + return NULL; + } + if ( readSucceeded != NULL ) { + *readSucceeded = true; } return resolved; } idList orderedChildren; - BuildSaveGameChildOrder( orderedChildren, "idWindow::ReadFromSaveGame" ); + if ( !BuildSaveGameChildOrder( orderedChildren, "idWindow::ReadFromSaveGame" ) ) { + return NULL; + } for ( int i = 0; i < orderedChildren.Num(); i++ ) { if ( orderedChildren[i]->childID == savedChildId ) { + if ( readSucceeded != NULL ) { + *readSucceeded = true; + } return orderedChildren[i]; } } @@ -4932,6 +5047,9 @@ idWindow *idWindow::ReadSaveGameChildReference( idFile *savefile, const char *fi // Legacy v2 wrote a nested desktop focus/capture target as its parent-local // positive child id. It cannot be resolved here, but its restored flag can // identify the intended descendant after the complete window tree is read. + if ( readSucceeded != NULL ) { + *readSucceeded = true; + } return NULL; } common->Error( "idWindow::ReadFromSaveGame: %s %d for window '%s' in gui '%s' does not match a direct child", @@ -4948,40 +5066,60 @@ void idWindow::WriteToSaveGame( idFile *savefile ) { int i; if ( savefile == NULL || gui == NULL ) { common->Error( "idWindow::WriteToSaveGame: invalid save context for window '%s'", name.c_str() ); + return; } - WriteSaveGameString( cmd, savefile ); - - savefile->Write( &actualX, sizeof( actualX ) ); - savefile->Write( &actualY, sizeof( actualY ) ); - savefile->Write( &childID, sizeof( childID ) ); - savefile->Write( &flags, sizeof( flags ) ); - savefile->Write( &lastTimeRun, sizeof( lastTimeRun ) ); - savefile->Write( &drawRect, sizeof( drawRect ) ); - savefile->Write( &clientRect, sizeof( clientRect ) ); - savefile->Write( &origin, sizeof( origin ) ); - savefile->Write( &fontNum, sizeof( fontNum ) ); - savefile->Write( &timeLine, sizeof( timeLine ) ); - savefile->Write( &xOffset, sizeof( xOffset ) ); - savefile->Write( &yOffset, sizeof( yOffset ) ); - savefile->Write( &cursor, sizeof( cursor ) ); - savefile->Write( &forceAspectWidth, sizeof( forceAspectWidth ) ); - savefile->Write( &forceAspectHeight, sizeof( forceAspectHeight ) ); - savefile->Write( &matScalex, sizeof( matScalex ) ); - savefile->Write( &matScaley, sizeof( matScaley ) ); - savefile->Write( &borderSize, sizeof( borderSize ) ); - savefile->Write( &textAlign, sizeof( textAlign ) ); - savefile->Write( &textAlignx, sizeof( textAlignx ) ); - savefile->Write( &textAligny, sizeof( textAligny ) ); - const signed char savedTextStyle = static_cast( static_cast( textstyle ) ); + const float savedTextStyleValue = textstyle; const float savedTextSpacing = textspacing; - savefile->Write( &savedTextStyle, sizeof( savedTextStyle ) ); - savefile->Write( &savedTextSpacing, sizeof( savedTextSpacing ) ); - savefile->Write( &textShadow, sizeof( textShadow ) ); - savefile->Write( &shear, sizeof( shear ) ); + if ( !std::isfinite( actualX ) || !std::isfinite( actualY ) || + !OpenQ4_IsFiniteSaveGameRectangle( drawRect ) || !OpenQ4_IsFiniteSaveGameRectangle( clientRect ) || + !OpenQ4_IsFiniteSaveGameRectangle( textRect ) || !OpenQ4_IsFiniteSaveGameVec2( origin ) || + !std::isfinite( xOffset ) || !std::isfinite( yOffset ) || + !std::isfinite( forceAspectWidth ) || !std::isfinite( forceAspectHeight ) || + !std::isfinite( matScalex ) || !std::isfinite( matScaley ) || !std::isfinite( borderSize ) || + !std::isfinite( textAlignx ) || !std::isfinite( textAligny ) || + !std::isfinite( savedTextStyleValue ) || savedTextStyleValue < -128.0f || savedTextStyleValue > 127.0f || + !std::isfinite( savedTextSpacing ) || !OpenQ4_IsFiniteSaveGameVec2( shear ) ) { + common->Error( "idWindow::WriteToSaveGame: refusing non-finite or out-of-range layout state for window '%s' in gui '%s'", + name.c_str(), gui->GetSourceFile() ); + return; + } - WriteSaveGameString( name, savefile ); - WriteSaveGameString( comment, savefile ); + if ( !WriteSaveGameString( cmd, savefile ) || + !OpenQ4_WriteSaveGameField( savefile, actualX, "idWindow::WriteToSaveGame", "actualX" ) || + !OpenQ4_WriteSaveGameField( savefile, actualY, "idWindow::WriteToSaveGame", "actualY" ) || + !OpenQ4_WriteSaveGameField( savefile, childID, "idWindow::WriteToSaveGame", "childID" ) || + !OpenQ4_WriteSaveGameField( savefile, flags, "idWindow::WriteToSaveGame", "flags" ) || + !OpenQ4_WriteSaveGameField( savefile, lastTimeRun, "idWindow::WriteToSaveGame", "last time run" ) || + !OpenQ4_WriteSaveGameField( savefile, drawRect, "idWindow::WriteToSaveGame", "draw rect" ) || + !OpenQ4_WriteSaveGameField( savefile, clientRect, "idWindow::WriteToSaveGame", "client rect" ) || + !OpenQ4_WriteSaveGameField( savefile, origin, "idWindow::WriteToSaveGame", "origin" ) || + !OpenQ4_WriteSaveGameField( savefile, fontNum, "idWindow::WriteToSaveGame", "font number" ) || + !OpenQ4_WriteSaveGameField( savefile, timeLine, "idWindow::WriteToSaveGame", "timeline" ) || + !OpenQ4_WriteSaveGameField( savefile, xOffset, "idWindow::WriteToSaveGame", "x offset" ) || + !OpenQ4_WriteSaveGameField( savefile, yOffset, "idWindow::WriteToSaveGame", "y offset" ) || + !OpenQ4_WriteSaveGameField( savefile, cursor, "idWindow::WriteToSaveGame", "cursor" ) || + !OpenQ4_WriteSaveGameField( savefile, forceAspectWidth, "idWindow::WriteToSaveGame", "force aspect width" ) || + !OpenQ4_WriteSaveGameField( savefile, forceAspectHeight, "idWindow::WriteToSaveGame", "force aspect height" ) || + !OpenQ4_WriteSaveGameField( savefile, matScalex, "idWindow::WriteToSaveGame", "material scale x" ) || + !OpenQ4_WriteSaveGameField( savefile, matScaley, "idWindow::WriteToSaveGame", "material scale y" ) || + !OpenQ4_WriteSaveGameField( savefile, borderSize, "idWindow::WriteToSaveGame", "border size" ) || + !OpenQ4_WriteSaveGameField( savefile, textAlign, "idWindow::WriteToSaveGame", "text align" ) || + !OpenQ4_WriteSaveGameField( savefile, textAlignx, "idWindow::WriteToSaveGame", "text align x" ) || + !OpenQ4_WriteSaveGameField( savefile, textAligny, "idWindow::WriteToSaveGame", "text align y" ) ) { + return; + } + const signed char savedTextStyle = static_cast( static_cast( textstyle ) ); + if ( !OpenQ4_WriteSaveGameField( savefile, savedTextStyle, "idWindow::WriteToSaveGame", "text style" ) || + !OpenQ4_WriteSaveGameField( savefile, savedTextSpacing, "idWindow::WriteToSaveGame", "text spacing" ) || + !OpenQ4_WriteSaveGameField( savefile, textShadow, "idWindow::WriteToSaveGame", "text shadow" ) || + !OpenQ4_WriteSaveGameField( savefile, shear, "idWindow::WriteToSaveGame", "shear" ) ) { + return; + } + + if ( !WriteSaveGameString( name, savefile ) || !WriteSaveGameString( comment, savefile ) ) { + return; + } // WinVars noTime.WriteToSaveGame( savefile ); @@ -5004,17 +5142,22 @@ void idWindow::WriteToSaveGame( idFile *savefile ) { if ( definedVars[i] == NULL ) { common->Error( "idWindow::WriteToSaveGame: NULL defined variable %d for window '%s' in gui '%s'", i, name.c_str(), gui->GetSourceFile() ); + return; } definedVars[i]->WriteToSaveGame( savefile ); } - savefile->Write( &textRect, sizeof( textRect ) ); + if ( !OpenQ4_WriteSaveGameField( savefile, textRect, "idWindow::WriteToSaveGame", "text rect" ) ) { + return; + } // Window pointers saved as the child ID of the window const bool desktopTrackedDescendants = ( flags & WIN_DESKTOP ) != 0; - WriteSaveGameChildReference( focusedChild, savefile, "focused child id", desktopTrackedDescendants ); - WriteSaveGameChildReference( captureChild, savefile, "capture child id", desktopTrackedDescendants ); - WriteSaveGameChildReference( overChild, savefile, "hovered child id", false ); + if ( !WriteSaveGameChildReference( focusedChild, savefile, "focused child id", desktopTrackedDescendants ) || + !WriteSaveGameChildReference( captureChild, savefile, "capture child id", desktopTrackedDescendants ) || + !WriteSaveGameChildReference( overChild, savefile, "hovered child id", false ) ) { + return; + } // Scripts @@ -5029,18 +5172,25 @@ void idWindow::WriteToSaveGame( idFile *savefile ) { if ( timeLineEvents[i] == NULL || timeLineEvents[i]->event == NULL ) { common->Error( "idWindow::WriteToSaveGame: incomplete timeline event %d for window '%s' in gui '%s'", i, name.c_str(), gui->GetSourceFile() ); + return; + } + if ( !OpenQ4_WriteSaveGameBool( savefile, timeLineEvents[i]->pending, "idWindow::WriteToSaveGame", "timeline pending flag" ) || + !OpenQ4_WriteSaveGameInt( savefile, timeLineEvents[i]->time, "idWindow::WriteToSaveGame", "timeline event time" ) ) { + return; } - OpenQ4_WriteSaveGameBool( savefile, timeLineEvents[i]->pending, "idWindow::WriteToSaveGame", "timeline pending flag" ); - OpenQ4_WriteSaveGameInt( savefile, timeLineEvents[i]->time, "idWindow::WriteToSaveGame", "timeline event time" ); timeLineEvents[i]->event->WriteToSaveGame( savefile ); } // Transitions int num = transitions.Num(); - savefile->Write( &num, sizeof( num ) ); + if ( !OpenQ4_WriteSaveGameInt( savefile, num, "idWindow::WriteToSaveGame", "transition count" ) ) { + return; + } for ( i = 0; i < transitions.Num(); i++ ) { - WriteSaveGameTransition( transitions[ i ], savefile ); + if ( !WriteSaveGameTransition( transitions[ i ], savefile ) ) { + return; + } } @@ -5049,8 +5199,11 @@ void idWindow::WriteToSaveGame( idFile *savefile ) { if ( namedEvents[i] == NULL || namedEvents[i]->mEvent == NULL || namedEvents[i]->mName.IsEmpty() ) { common->Error( "idWindow::WriteToSaveGame: incomplete named event %d for window '%s' in gui '%s'", i, name.c_str(), gui->GetSourceFile() ); + return; + } + if ( !WriteSaveGameString( namedEvents[i]->mName, savefile ) ) { + return; } - WriteSaveGameString( namedEvents[i]->mName, savefile ); namedEvents[i]->mEvent->WriteToSaveGame( savefile ); } @@ -5064,6 +5217,7 @@ void idWindow::WriteToSaveGame( idFile *savefile ) { if ( ( window.simp == NULL ) == ( window.win == NULL ) ) { common->Error( "idWindow::WriteToSaveGame: draw window %d for '%s' in gui '%s' has invalid simple/full ownership", i, name.c_str(), gui->GetSourceFile() ); + return; } if ( window.simp != NULL ) { window.simp->WriteToSaveGame( savefile ); @@ -5078,22 +5232,34 @@ void idWindow::WriteToSaveGame( idFile *savefile ) { idWindow::ReadSaveGameString =============== */ -void idWindow::ReadSaveGameString( idStr &string, idFile *savefile ) { - int len; +bool idWindow::ReadSaveGameString( idStr &string, idFile *savefile ) { + string.Clear(); + if ( savefile == NULL ) { + common->Error( "idWindow::ReadSaveGameString: invalid input file for window '%s'", name.c_str() ); + return false; + } + int len = 0; const int offset = savefile->Tell(); - OpenQ4_ReadSaveGameField( savefile, len, "idWindow::ReadSaveGameString", "length" ); + if ( !OpenQ4_ReadSaveGameInt( savefile, len, "idWindow::ReadSaveGameString", "length" ) ) { + return false; + } const int remainingBytes = Max( 0, savefile->Length() - savefile->Tell() ); const int maxSavedStringLength = 64 * 1024; if ( len < 0 || len > maxSavedStringLength || len > remainingBytes ) { common->Error( "idWindow::ReadSaveGameString: invalid length %d at offset %d (remaining %d)", len, offset, remainingBytes ); + return false; } string.Fill( ' ', len ); if ( len > 0 ) { - OpenQ4_ReadSaveGameBytes( savefile, &string[0], len, "idWindow::ReadSaveGameString", "string" ); + if ( !OpenQ4_ReadSaveGameBytes( savefile, &string[0], len, "idWindow::ReadSaveGameString", "string" ) ) { + string.Clear(); + return false; + } } + return true; } /* @@ -5105,61 +5271,123 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { int i; if ( savefile == NULL || gui == NULL ) { common->Error( "idWindow::ReadFromSaveGame: invalid restore context for parsed window '%s'", name.c_str() ); + return; } transitions.Clear(); - ReadSaveGameString( cmd, savefile ); - - OpenQ4_ReadSaveGameField( savefile, actualX, "idWindow::ReadFromSaveGame", "actualX" ); - OpenQ4_ReadSaveGameField( savefile, actualY, "idWindow::ReadFromSaveGame", "actualY" ); + idStr savedCmd; + float savedActualX; + float savedActualY; int savedChildID = -1; unsigned int savedFlags = 0; - OpenQ4_ReadSaveGameField( savefile, savedChildID, "idWindow::ReadFromSaveGame", "childID" ); - OpenQ4_ReadSaveGameField( savefile, savedFlags, "idWindow::ReadFromSaveGame", "flags" ); - OpenQ4_ReadSaveGameField( savefile, lastTimeRun, "idWindow::ReadFromSaveGame", "last time run" ); - OpenQ4_ReadSaveGameField( savefile, drawRect, "idWindow::ReadFromSaveGame", "draw rect" ); - OpenQ4_ReadSaveGameField( savefile, clientRect, "idWindow::ReadFromSaveGame", "client rect" ); - OpenQ4_ReadSaveGameField( savefile, origin, "idWindow::ReadFromSaveGame", "origin" ); - OpenQ4_ReadSaveGameField( savefile, fontNum, "idWindow::ReadFromSaveGame", "font number" ); - OpenQ4_ReadSaveGameField( savefile, timeLine, "idWindow::ReadFromSaveGame", "timeline" ); - OpenQ4_ReadSaveGameField( savefile, xOffset, "idWindow::ReadFromSaveGame", "x offset" ); - OpenQ4_ReadSaveGameField( savefile, yOffset, "idWindow::ReadFromSaveGame", "y offset" ); - OpenQ4_ReadSaveGameField( savefile, cursor, "idWindow::ReadFromSaveGame", "cursor" ); - OpenQ4_ReadSaveGameField( savefile, forceAspectWidth, "idWindow::ReadFromSaveGame", "force aspect width" ); - OpenQ4_ReadSaveGameField( savefile, forceAspectHeight, "idWindow::ReadFromSaveGame", "force aspect height" ); - OpenQ4_ReadSaveGameField( savefile, matScalex, "idWindow::ReadFromSaveGame", "material scale x" ); - OpenQ4_ReadSaveGameField( savefile, matScaley, "idWindow::ReadFromSaveGame", "material scale y" ); - OpenQ4_ReadSaveGameField( savefile, borderSize, "idWindow::ReadFromSaveGame", "border size" ); - OpenQ4_ReadSaveGameField( savefile, textAlign, "idWindow::ReadFromSaveGame", "text align" ); - OpenQ4_ReadSaveGameField( savefile, textAlignx, "idWindow::ReadFromSaveGame", "text align x" ); - OpenQ4_ReadSaveGameField( savefile, textAligny, "idWindow::ReadFromSaveGame", "text align y" ); + int savedLastTimeRun; + idRectangle savedDrawRect; + idRectangle savedClientRect; + idVec2 savedOrigin; + unsigned char savedFontNum; + int savedTimeLine; + float savedXOffset; + float savedYOffset; + unsigned char savedCursor; + float savedForceAspectWidth; + float savedForceAspectHeight; + float savedMatScaleX; + float savedMatScaleY; + float savedBorderSize; + signed char savedTextAlign; + float savedTextAlignX; + float savedTextAlignY; signed char savedTextStyle = 0; float savedTextSpacing = 0.0f; - OpenQ4_ReadSaveGameField( savefile, savedTextStyle, "idWindow::ReadFromSaveGame", "text style" ); - OpenQ4_ReadSaveGameField( savefile, savedTextSpacing, "idWindow::ReadFromSaveGame", "text spacing" ); - textstyle = static_cast( savedTextStyle ); - textspacing = savedTextSpacing; - OpenQ4_ReadSaveGameField( savefile, textShadow, "idWindow::ReadFromSaveGame", "text shadow" ); - OpenQ4_ReadSaveGameField( savefile, shear, "idWindow::ReadFromSaveGame", "shear" ); + signed char savedTextShadow; + idVec2 savedShear; + + if ( !ReadSaveGameString( savedCmd, savefile ) || + !OpenQ4_ReadSaveGameField( savefile, savedActualX, "idWindow::ReadFromSaveGame", "actualX" ) || + !OpenQ4_ReadSaveGameField( savefile, savedActualY, "idWindow::ReadFromSaveGame", "actualY" ) || + !OpenQ4_ReadSaveGameField( savefile, savedChildID, "idWindow::ReadFromSaveGame", "childID" ) || + !OpenQ4_ReadSaveGameField( savefile, savedFlags, "idWindow::ReadFromSaveGame", "flags" ) || + !OpenQ4_ReadSaveGameField( savefile, savedLastTimeRun, "idWindow::ReadFromSaveGame", "last time run" ) || + !OpenQ4_ReadSaveGameField( savefile, savedDrawRect, "idWindow::ReadFromSaveGame", "draw rect" ) || + !OpenQ4_ReadSaveGameField( savefile, savedClientRect, "idWindow::ReadFromSaveGame", "client rect" ) || + !OpenQ4_ReadSaveGameField( savefile, savedOrigin, "idWindow::ReadFromSaveGame", "origin" ) || + !OpenQ4_ReadSaveGameField( savefile, savedFontNum, "idWindow::ReadFromSaveGame", "font number" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTimeLine, "idWindow::ReadFromSaveGame", "timeline" ) || + !OpenQ4_ReadSaveGameField( savefile, savedXOffset, "idWindow::ReadFromSaveGame", "x offset" ) || + !OpenQ4_ReadSaveGameField( savefile, savedYOffset, "idWindow::ReadFromSaveGame", "y offset" ) || + !OpenQ4_ReadSaveGameField( savefile, savedCursor, "idWindow::ReadFromSaveGame", "cursor" ) || + !OpenQ4_ReadSaveGameField( savefile, savedForceAspectWidth, "idWindow::ReadFromSaveGame", "force aspect width" ) || + !OpenQ4_ReadSaveGameField( savefile, savedForceAspectHeight, "idWindow::ReadFromSaveGame", "force aspect height" ) || + !OpenQ4_ReadSaveGameField( savefile, savedMatScaleX, "idWindow::ReadFromSaveGame", "material scale x" ) || + !OpenQ4_ReadSaveGameField( savefile, savedMatScaleY, "idWindow::ReadFromSaveGame", "material scale y" ) || + !OpenQ4_ReadSaveGameField( savefile, savedBorderSize, "idWindow::ReadFromSaveGame", "border size" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextAlign, "idWindow::ReadFromSaveGame", "text align" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextAlignX, "idWindow::ReadFromSaveGame", "text align x" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextAlignY, "idWindow::ReadFromSaveGame", "text align y" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextStyle, "idWindow::ReadFromSaveGame", "text style" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextSpacing, "idWindow::ReadFromSaveGame", "text spacing" ) || + !OpenQ4_ReadSaveGameField( savefile, savedTextShadow, "idWindow::ReadFromSaveGame", "text shadow" ) || + !OpenQ4_ReadSaveGameField( savefile, savedShear, "idWindow::ReadFromSaveGame", "shear" ) ) { + return; + } idStr savedName; idStr savedComment; - ReadSaveGameString( savedName, savefile ); - ReadSaveGameString( savedComment, savefile ); + if ( !ReadSaveGameString( savedName, savefile ) || !ReadSaveGameString( savedComment, savefile ) ) { + return; + } + if ( !std::isfinite( savedActualX ) || !std::isfinite( savedActualY ) || + !OpenQ4_IsFiniteSaveGameRectangle( savedDrawRect ) || !OpenQ4_IsFiniteSaveGameRectangle( savedClientRect ) || + !OpenQ4_IsFiniteSaveGameVec2( savedOrigin ) || !std::isfinite( savedXOffset ) || !std::isfinite( savedYOffset ) || + !std::isfinite( savedForceAspectWidth ) || !std::isfinite( savedForceAspectHeight ) || + !std::isfinite( savedMatScaleX ) || !std::isfinite( savedMatScaleY ) || !std::isfinite( savedBorderSize ) || + !std::isfinite( savedTextAlignX ) || !std::isfinite( savedTextAlignY ) || + !std::isfinite( savedTextSpacing ) || !OpenQ4_IsFiniteSaveGameVec2( savedShear ) ) { + common->Error( "idWindow::ReadFromSaveGame: non-finite layout state for saved window '%s' in gui '%s'", + savedName.c_str(), gui->GetSourceFile() ); + return; + } if ( savedChildID != childID ) { common->Error( "idWindow::ReadFromSaveGame: saved child id %d for window '%s' does not match parsed id %d in gui '%s'", savedChildID, savedName.c_str(), childID, gui->GetSourceFile() ); + return; } const unsigned int structuralFlagMask = WIN_CHILD | WIN_DESKTOP; if ( ( savedFlags & structuralFlagMask ) != ( flags & structuralFlagMask ) ) { common->Error( "idWindow::ReadFromSaveGame: saved structural flags 0x%08x for window '%s' do not match parsed flags 0x%08x in gui '%s'", savedFlags & structuralFlagMask, savedName.c_str(), flags & structuralFlagMask, gui->GetSourceFile() ); + return; } if ( savedName.Icmp( name ) != 0 ) { common->Error( "idWindow::ReadFromSaveGame: saved window '%s' does not match parsed window '%s' in gui '%s'", savedName.c_str(), name.c_str(), gui->GetSourceFile() ); + return; } + cmd = savedCmd; + actualX = savedActualX; + actualY = savedActualY; + lastTimeRun = savedLastTimeRun; + drawRect = savedDrawRect; + clientRect = savedClientRect; + origin = savedOrigin; + fontNum = savedFontNum; + timeLine = savedTimeLine; + xOffset = savedXOffset; + yOffset = savedYOffset; + cursor = savedCursor; + forceAspectWidth = savedForceAspectWidth; + forceAspectHeight = savedForceAspectHeight; + matScalex = savedMatScaleX; + matScaley = savedMatScaleY; + borderSize = savedBorderSize; + textAlign = savedTextAlign; + textAlignx = savedTextAlignX; + textAligny = savedTextAlignY; + textstyle = static_cast( savedTextStyle ); + textspacing = savedTextSpacing; + textShadow = savedTextShadow; + shear = savedShear; flags = savedFlags; comment = savedComment; @@ -5186,19 +5414,37 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { if ( definedVars[i] == NULL ) { common->Error( "idWindow::ReadFromSaveGame: NULL parsed defined variable %d for window '%s' in gui '%s'", i, name.c_str(), gui->GetSourceFile() ); + return; } definedVars[i]->ReadFromSaveGame( savefile ); } - OpenQ4_ReadSaveGameField( savefile, textRect, "idWindow::ReadFromSaveGame", "text rect" ); + idRectangle savedTextRect; + if ( !OpenQ4_ReadSaveGameField( savefile, savedTextRect, "idWindow::ReadFromSaveGame", "text rect" ) ) { + return; + } + if ( !OpenQ4_IsFiniteSaveGameRectangle( savedTextRect ) ) { + common->Error( "idWindow::ReadFromSaveGame: non-finite text rectangle for window '%s' in gui '%s'", + name.c_str(), gui->GetSourceFile() ); + return; + } + textRect = savedTextRect; // Window pointers saved as the child ID of the window const bool desktopTrackedDescendants = ( flags & WIN_DESKTOP ) != 0; bool hadSavedFocusReference = false; bool hadSavedCaptureReference = false; - focusedChild = ReadSaveGameChildReference( savefile, "focused child id", desktopTrackedDescendants, &hadSavedFocusReference ); - captureChild = ReadSaveGameChildReference( savefile, "capture child id", desktopTrackedDescendants, &hadSavedCaptureReference ); - overChild = ReadSaveGameChildReference( savefile, "hovered child id", false ); + bool focusReadSucceeded = false; + bool captureReadSucceeded = false; + bool overReadSucceeded = false; + focusedChild = ReadSaveGameChildReference( savefile, "focused child id", desktopTrackedDescendants, + &hadSavedFocusReference, &focusReadSucceeded ); + captureChild = ReadSaveGameChildReference( savefile, "capture child id", desktopTrackedDescendants, + &hadSavedCaptureReference, &captureReadSucceeded ); + overChild = ReadSaveGameChildReference( savefile, "hovered child id", false, NULL, &overReadSucceeded ); + if ( !focusReadSucceeded || !captureReadSucceeded || !overReadSucceeded ) { + return; + } // Scripts for ( i = 0; i < SCRIPT_COUNT; i++ ) { @@ -5212,23 +5458,31 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { if ( timeLineEvents[i] == NULL || timeLineEvents[i]->event == NULL ) { common->Error( "idWindow::ReadFromSaveGame: incomplete parsed timeline event %d for window '%s' in gui '%s'", i, name.c_str(), gui->GetSourceFile() ); + return; + } + if ( !OpenQ4_ReadSaveGameBool( savefile, timeLineEvents[i]->pending, "idWindow::ReadFromSaveGame", "timeline pending flag" ) || + !OpenQ4_ReadSaveGameInt( savefile, timeLineEvents[i]->time, "idWindow::ReadFromSaveGame", "timeline event time" ) ) { + return; } - OpenQ4_ReadSaveGameBool( savefile, timeLineEvents[i]->pending, "idWindow::ReadFromSaveGame", "timeline pending flag" ); - OpenQ4_ReadSaveGameInt( savefile, timeLineEvents[i]->time, "idWindow::ReadFromSaveGame", "timeline event time" ); timeLineEvents[i]->event->ReadFromSaveGame( savefile ); } // Transitions - int num; - OpenQ4_ReadSaveGameField( savefile, num, "idWindow::ReadFromSaveGame", "transition count" ); + int num = 0; + if ( !OpenQ4_ReadSaveGameInt( savefile, num, "idWindow::ReadFromSaveGame", "transition count" ) ) { + return; + } if ( num < 0 || num > 4096 ) { common->Error( "idWindow::ReadFromSaveGame: invalid transition count %d for window '%s'", num, name.c_str() ); + return; } for ( i = 0; i < num; i++ ) { idTransitionData trans; trans.data = NULL; - ReadSaveGameTransition( trans, savefile ); + if ( !ReadSaveGameTransition( trans, savefile ) ) { + return; + } if ( trans.data ) { transitions.Append( trans ); } @@ -5240,10 +5494,13 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { if ( namedEvents[i] == NULL || namedEvents[i]->mEvent == NULL ) { common->Error( "idWindow::ReadFromSaveGame: incomplete parsed named event %d for window '%s' in gui '%s'", i, name.c_str(), gui->GetSourceFile() ); + return; } idStr savedEventName; - ReadSaveGameString( savedEventName, savefile ); + if ( !ReadSaveGameString( savedEventName, savefile ) ) { + return; + } int matchedIndex = -1; for ( int j = i; j < namedEvents.Num(); j++ ) { @@ -5264,6 +5521,7 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { if ( matchedIndex == -1 ) { common->Error( "idWindow::ReadFromSaveGame: saved named event '%s' is missing from parsed window '%s' in gui '%s'; restore cannot continue without desynchronizing the stream", savedEventName.c_str(), name.c_str(), gui->GetSourceFile() ); + return; } if ( matchedIndex != i ) { @@ -5275,6 +5533,7 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { if ( namedEvents[i] == NULL || namedEvents[i]->mEvent == NULL ) { common->Error( "idWindow::ReadFromSaveGame: named event '%s' for window '%s' in gui '%s' has no script payload target", savedEventName.c_str(), name.c_str(), gui->GetSourceFile() ); + return; } namedEvents[i]->mEvent->ReadFromSaveGame( savefile ); } @@ -5288,6 +5547,7 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { if ( ( window.simp == NULL ) == ( window.win == NULL ) ) { common->Error( "idWindow::ReadFromSaveGame: draw window %d for '%s' in gui '%s' has invalid simple/full ownership", i, name.c_str(), gui->GetSourceFile() ); + return; } if ( window.simp != NULL ) { window.simp->ReadFromSaveGame( savefile ); @@ -5297,8 +5557,12 @@ void idWindow::ReadFromSaveGame( idFile *savefile ) { } if ( flags & WIN_DESKTOP ) { - ValidateRestoredTrackedWindowPointers( hadSavedFocusReference, hadSavedCaptureReference ); - FixupTransitions(); + if ( !ValidateRestoredTrackedWindowPointers( hadSavedFocusReference, hadSavedCaptureReference ) ) { + return; + } + if ( !FixupTransitions() ) { + return; + } } } @@ -5321,15 +5585,17 @@ int idWindow::NumTransitions() { idWindow::FixupTransitions =============== */ -void idWindow::FixupTransitions() { +bool idWindow::FixupTransitions() { if ( gui == NULL || gui->GetDesktop() == NULL ) { common->Error( "idWindow::FixupTransitions: window '%s' has no gui desktop during savegame restore", name.c_str() ); + return false; } int i, c = transitions.Num(); for ( i = 0; i < c; i++ ) { if ( transitions[i].data == NULL ) { common->Error( "idWindow::FixupTransitions: transition %d for window '%s' in gui '%s' has no saved target", i, name.c_str(), gui->GetSourceFile() ); + return false; } const idStr transitionTarget = static_cast( transitions[i].data )->c_str(); drawWin_t *dw = gui->GetDesktop()->FindChildByName( transitionTarget ); @@ -5338,6 +5604,7 @@ void idWindow::FixupTransitions() { if ( dw != NULL && ( ( dw->win == NULL ) == ( dw->simp == NULL ) ) ) { common->Error( "idWindow::FixupTransitions: target '%s' for window '%s' in gui '%s' has invalid simple/full ownership", transitionTarget.c_str(), name.c_str(), gui->GetSourceFile() ); + return false; } if ( dw && ( dw->win || dw->simp ) ){ const intptr_t transitionOffset = (intptr_t)transitions[i].offset; @@ -5420,15 +5687,20 @@ void idWindow::FixupTransitions() { if ( transitions[i].data == NULL ) { common->Error( "idWindow::FixupTransitions: could not resolve saved transition target '%s' offset %d for window '%s' in gui '%s'", transitionTarget.c_str(), transitions[i].offset, name.c_str(), gui->GetSourceFile() ); + return false; } } for ( c = 0; c < children.Num(); c++ ) { if ( children[c] == NULL ) { common->Error( "idWindow::FixupTransitions: window '%s' in gui '%s' has a NULL child at index %d", name.c_str(), gui->GetSourceFile(), c ); + return false; + } + if ( !children[c]->FixupTransitions() ) { + return false; } - children[c]->FixupTransitions(); } + return true; } diff --git a/src/ui/Window.h b/src/ui/Window.h index a6ac0007..b8366026 100644 --- a/src/ui/Window.h +++ b/src/ui/Window.h @@ -320,15 +320,16 @@ class idWindow { virtual void WriteToDemoFile( class idDemoFile *f ); // SaveGame support - void WriteSaveGameString( const char *string, idFile *savefile ); - void WriteSaveGameTransition( idTransitionData &trans, idFile *savefile ); - void WriteSaveGameChildReference( idWindow *child, idFile *savefile, const char *fieldName, bool allowDescendant ); + bool WriteSaveGameString( const char *string, idFile *savefile ); + bool WriteSaveGameTransition( idTransitionData &trans, idFile *savefile ); + bool WriteSaveGameChildReference( idWindow *child, idFile *savefile, const char *fieldName, bool allowDescendant ); virtual void WriteToSaveGame( idFile *savefile ); - void ReadSaveGameString( idStr &string, idFile *savefile ); - void ReadSaveGameTransition( idTransitionData & trans, idFile *savefile ); - idWindow * ReadSaveGameChildReference( idFile *savefile, const char *fieldName, bool allowDescendant, bool *hadSerializedReference = NULL ); + bool ReadSaveGameString( idStr &string, idFile *savefile ); + bool ReadSaveGameTransition( idTransitionData & trans, idFile *savefile ); + idWindow * ReadSaveGameChildReference( idFile *savefile, const char *fieldName, bool allowDescendant, + bool *hadSerializedReference = NULL, bool *readSucceeded = NULL ); virtual void ReadFromSaveGame( idFile *savefile ); - void FixupTransitions(); + bool FixupTransitions(); virtual void HasAction(){}; virtual void HasScripts(){}; @@ -398,11 +399,11 @@ class idWindow { bool HasDirectChildReference( const idWindow *window ) const; bool HasDescendantReference( const idWindow *window ) const; static int SaveGameChildIDCompare( idWindow * const *left, idWindow * const *right ); - void BuildSaveGameChildOrder( idList &orderedChildren, const char *operation ) const; + bool BuildSaveGameChildOrder( idList &orderedChildren, const char *operation ) const; bool FindSaveGameDescendantOrdinal( const idWindow *window, int &nextOrdinal, int &foundOrdinal, int depth ) const; idWindow *FindSaveGameDescendantByOrdinal( int targetOrdinal, int &nextOrdinal, int depth ); - void FindSaveGameFlaggedDescendants( unsigned int flag, idWindow *&match, int &matches, int &visited, int depth ); - void ValidateRestoredTrackedWindowPointers( bool hadSavedFocusReference, bool hadSavedCaptureReference ); + bool FindSaveGameFlaggedDescendants( unsigned int flag, idWindow *&match, int &matches, int &visited, int depth ); + bool ValidateRestoredTrackedWindowPointers( bool hadSavedFocusReference, bool hadSavedCaptureReference ); void ClearTrackedWindowReference( const idWindow *window ); void ValidateTrackedWindowPointers(); void UpdateWinVars(); diff --git a/src/ui/Winvar.h b/src/ui/Winvar.h index dc5afcd8..0df310be 100644 --- a/src/ui/Winvar.h +++ b/src/ui/Winvar.h @@ -34,95 +34,138 @@ If you have questions concerning this license or the applicable additional terms static const char VAR_GUIPREFIX[] = "gui::"; static const int VAR_GUIPREFIX_LEN = static_cast( sizeof( VAR_GUIPREFIX ) - 1 ); -static ID_INLINE void OpenQ4_WriteSaveGameBytes( idFile *savefile, const void *buffer, int len, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_WriteSaveGameBytes( idFile *savefile, const void *buffer, int len, const char *context, const char *fieldName ) { if ( savefile == NULL || len < 0 || ( buffer == NULL && len > 0 ) ) { common->Error( "%s: invalid output while writing %s", context ? context : "savegame write", fieldName ? fieldName : "data" ); + return false; } const int offset = savefile->Tell(); const int bytesWritten = savefile->Write( buffer, len ); if ( bytesWritten != len ) { common->Error( "%s: failed to write %s at offset %d (wrote %d of %d)", context ? context : "savegame write", fieldName ? fieldName : "data", offset, bytesWritten, len ); + return false; } + return true; } -static ID_INLINE void OpenQ4_ReadSaveGameBytes( idFile *savefile, void *buffer, int len, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_ReadSaveGameBytes( idFile *savefile, void *buffer, int len, const char *context, const char *fieldName ) { if ( savefile == NULL || len < 0 || ( buffer == NULL && len > 0 ) ) { + if ( buffer != NULL && len > 0 ) { + memset( buffer, 0, len ); + } common->Error( "%s: invalid input while reading %s", context ? context : "savegame restore", fieldName ? fieldName : "data" ); + return false; } const int offset = savefile->Tell(); const int bytesRead = savefile->Read( buffer, len ); if ( bytesRead != len ) { + if ( buffer != NULL && len > 0 ) { + memset( buffer, 0, len ); + } common->Error( "%s: truncated %s at offset %d (read %d of %d)", context ? context : "savegame restore", fieldName ? fieldName : "data", offset, bytesRead, len ); + return false; } + return true; } -static ID_INLINE void OpenQ4_WriteSaveGameBool( idFile *savefile, bool value, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_WriteSaveGameBool( idFile *savefile, bool value, const char *context, const char *fieldName ) { const unsigned char savedValue = value ? 1 : 0; const int offset = savefile != NULL ? savefile->Tell() : -1; const int bytesWritten = savefile != NULL ? savefile->WriteUnsignedChar( savedValue ) : 0; if ( bytesWritten != 1 ) { common->Error( "%s: failed to write %s at offset %d (wrote %d of 1)", context ? context : "savegame write", fieldName ? fieldName : "boolean", offset, bytesWritten ); + return false; } + return true; } -static ID_INLINE void OpenQ4_ReadSaveGameBool( idFile *savefile, bool &value, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_ReadSaveGameBool( idFile *savefile, bool &value, const char *context, const char *fieldName ) { unsigned char savedValue = 0; const int offset = savefile != NULL ? savefile->Tell() : -1; const int bytesRead = savefile != NULL ? savefile->ReadUnsignedChar( savedValue ) : 0; if ( bytesRead != 1 || savedValue > 1 ) { + value = false; common->Error( "%s: invalid %s at offset %d (read %d bytes, value %u)", context ? context : "savegame restore", fieldName ? fieldName : "boolean", offset, bytesRead, static_cast( savedValue ) ); + return false; } value = savedValue != 0; + return true; } -static ID_INLINE void OpenQ4_WriteSaveGameInt( idFile *savefile, int value, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_WriteSaveGameInt( idFile *savefile, int value, const char *context, const char *fieldName ) { const int offset = savefile != NULL ? savefile->Tell() : -1; const int bytesWritten = savefile != NULL ? savefile->WriteInt( value ) : 0; if ( bytesWritten != static_cast( sizeof( value ) ) ) { common->Error( "%s: failed to write %s at offset %d (wrote %d of %d)", context ? context : "savegame write", fieldName ? fieldName : "integer", offset, bytesWritten, static_cast( sizeof( value ) ) ); + return false; } + return true; } -static ID_INLINE void OpenQ4_ReadSaveGameInt( idFile *savefile, int &value, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_ReadSaveGameInt( idFile *savefile, int &value, const char *context, const char *fieldName ) { const int offset = savefile != NULL ? savefile->Tell() : -1; const int bytesRead = savefile != NULL ? savefile->ReadInt( value ) : 0; if ( bytesRead != static_cast( sizeof( value ) ) ) { + value = 0; common->Error( "%s: truncated %s at offset %d (read %d of %d)", context ? context : "savegame restore", fieldName ? fieldName : "integer", offset, bytesRead, static_cast( sizeof( value ) ) ); + return false; } + return true; } -static ID_INLINE void OpenQ4_WriteSaveGameFloat( idFile *savefile, float value, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_WriteSaveGameFloat( idFile *savefile, float value, const char *context, const char *fieldName ) { + if ( !std::isfinite( value ) ) { + common->Error( "%s: refusing non-finite %s", context ? context : "savegame write", + fieldName ? fieldName : "float" ); + return false; + } const int offset = savefile != NULL ? savefile->Tell() : -1; const int bytesWritten = savefile != NULL ? savefile->WriteFloat( value ) : 0; if ( bytesWritten != static_cast( sizeof( value ) ) ) { common->Error( "%s: failed to write %s at offset %d (wrote %d of %d)", context ? context : "savegame write", fieldName ? fieldName : "float", offset, bytesWritten, static_cast( sizeof( value ) ) ); + return false; } + return true; } -static ID_INLINE void OpenQ4_ReadSaveGameFloat( idFile *savefile, float &value, const char *context, const char *fieldName ) { +static ID_INLINE bool OpenQ4_ReadSaveGameFloat( idFile *savefile, float &value, const char *context, const char *fieldName ) { const int offset = savefile != NULL ? savefile->Tell() : -1; const int bytesRead = savefile != NULL ? savefile->ReadFloat( value ) : 0; if ( bytesRead != static_cast( sizeof( value ) ) ) { common->Error( "%s: truncated %s at offset %d (read %d of %d)", context ? context : "savegame restore", fieldName ? fieldName : "float", offset, bytesRead, static_cast( sizeof( value ) ) ); + value = 0.0f; + return false; + } + if ( !std::isfinite( value ) ) { + common->Error( "%s: non-finite %s at offset %d", context ? context : "savegame restore", + fieldName ? fieldName : "float", offset ); + value = 0.0f; + return false; } + return true; +} + +template< class type > +static ID_INLINE bool OpenQ4_WriteSaveGameField( idFile *savefile, const type &value, const char *context, const char *fieldName ) { + return OpenQ4_WriteSaveGameBytes( savefile, &value, sizeof( value ), context, fieldName ); } template< class type > -static ID_INLINE void OpenQ4_ReadSaveGameField( idFile *savefile, type &value, const char *context, const char *fieldName ) { - OpenQ4_ReadSaveGameBytes( savefile, &value, sizeof( value ), context, fieldName ); +static ID_INLINE bool OpenQ4_ReadSaveGameField( idFile *savefile, type &value, const char *context, const char *fieldName ) { + return OpenQ4_ReadSaveGameBytes( savefile, &value, sizeof( value ), context, fieldName ); } class idWindow; @@ -317,33 +360,49 @@ class idWinStr : public idWinVar { // SaveGames virtual void WriteToSaveGame( idFile *savefile ) { - OpenQ4_WriteSaveGameBool( savefile, eval, "idWinStr::WriteToSaveGame", "eval flag" ); + if ( !OpenQ4_WriteSaveGameBool( savefile, eval, "idWinStr::WriteToSaveGame", "eval flag" ) ) { + return; + } const int len = data.Length(); const int maxSavedStringLength = 64 * 1024; if ( len < 0 || len > maxSavedStringLength ) { common->Error( "idWinStr::WriteToSaveGame: invalid string length %d", len ); + return; + } + if ( !OpenQ4_WriteSaveGameInt( savefile, len, "idWinStr::WriteToSaveGame", "string length" ) ) { + return; } - OpenQ4_WriteSaveGameInt( savefile, len, "idWinStr::WriteToSaveGame", "string length" ); if ( len > 0 ) { OpenQ4_WriteSaveGameBytes( savefile, data.c_str(), len, "idWinStr::WriteToSaveGame", "string" ); } } virtual void ReadFromSaveGame( idFile *savefile ) { - OpenQ4_ReadSaveGameBool( savefile, eval, "idWinStr::ReadFromSaveGame", "eval flag" ); + if ( !OpenQ4_ReadSaveGameBool( savefile, eval, "idWinStr::ReadFromSaveGame", "eval flag" ) ) { + data.Clear(); + return; + } - int len; + int len = 0; const int offset = savefile->Tell(); - OpenQ4_ReadSaveGameInt( savefile, len, "idWinStr::ReadFromSaveGame", "string length" ); + if ( !OpenQ4_ReadSaveGameInt( savefile, len, "idWinStr::ReadFromSaveGame", "string length" ) ) { + data.Clear(); + return; + } const int remainingBytes = Max( 0, savefile->Length() - savefile->Tell() ); const int maxSavedStringLength = 64 * 1024; if ( len < 0 || len > maxSavedStringLength || len > remainingBytes ) { common->Error( "idWinStr::ReadFromSaveGame: invalid string length %d at offset %d (remaining %d)", len, offset, remainingBytes ); + data.Clear(); + return; } if ( len > 0 ) { data.Fill( ' ', len ); - OpenQ4_ReadSaveGameBytes( savefile, &data[0], len, "idWinStr::ReadFromSaveGame", "string" ); + if ( !OpenQ4_ReadSaveGameBytes( savefile, &data[0], len, "idWinStr::ReadFromSaveGame", "string" ) ) { + data.Clear(); + return; + } } else { data.Clear(); } @@ -936,33 +995,61 @@ class idWinBackground : public idWinStr { } virtual void WriteToSaveGame( idFile *savefile ) { - OpenQ4_WriteSaveGameBool( savefile, eval, "idWinBackground::WriteToSaveGame", "eval flag" ); + if ( !OpenQ4_WriteSaveGameBool( savefile, eval, "idWinBackground::WriteToSaveGame", "eval flag" ) ) { + return; + } const int len = data.Length(); const int maxSavedStringLength = 64 * 1024; if ( len < 0 || len > maxSavedStringLength ) { common->Error( "idWinBackground::WriteToSaveGame: invalid material name length %d", len ); + return; + } + if ( !OpenQ4_WriteSaveGameInt( savefile, len, "idWinBackground::WriteToSaveGame", "material name length" ) ) { + return; } - OpenQ4_WriteSaveGameInt( savefile, len, "idWinBackground::WriteToSaveGame", "material name length" ); if ( len > 0 ) { OpenQ4_WriteSaveGameBytes( savefile, data.c_str(), len, "idWinBackground::WriteToSaveGame", "material name" ); } } virtual void ReadFromSaveGame( idFile *savefile ) { - OpenQ4_ReadSaveGameBool( savefile, eval, "idWinBackground::ReadFromSaveGame", "eval flag" ); + if ( !OpenQ4_ReadSaveGameBool( savefile, eval, "idWinBackground::ReadFromSaveGame", "eval flag" ) ) { + data.Clear(); + if ( mat != NULL ) { + *mat = NULL; + } + return; + } - int len; + int len = 0; const int offset = savefile->Tell(); - OpenQ4_ReadSaveGameInt( savefile, len, "idWinBackground::ReadFromSaveGame", "material name length" ); + if ( !OpenQ4_ReadSaveGameInt( savefile, len, "idWinBackground::ReadFromSaveGame", "material name length" ) ) { + data.Clear(); + if ( mat != NULL ) { + *mat = NULL; + } + return; + } const int remainingBytes = Max( 0, savefile->Length() - savefile->Tell() ); const int maxSavedStringLength = 64 * 1024; if ( len < 0 || len > maxSavedStringLength || len > remainingBytes ) { common->Error( "idWinBackground::ReadFromSaveGame: invalid material name length %d at offset %d (remaining %d)", len, offset, remainingBytes ); + data.Clear(); + if ( mat != NULL ) { + *mat = NULL; + } + return; } if ( len > 0 ) { data.Fill( ' ', len ); - OpenQ4_ReadSaveGameBytes( savefile, &data[0], len, "idWinBackground::ReadFromSaveGame", "material name" ); + if ( !OpenQ4_ReadSaveGameBytes( savefile, &data[0], len, "idWinBackground::ReadFromSaveGame", "material name" ) ) { + data.Clear(); + if ( mat != NULL ) { + *mat = NULL; + } + return; + } } else { data.Clear(); } diff --git a/tools/analysis/clang_tidy_input_safety.py b/tools/analysis/clang_tidy_input_safety.py index b33ddb14..e05aa3ac 100644 --- a/tools/analysis/clang_tidy_input_safety.py +++ b/tools/analysis/clang_tidy_input_safety.py @@ -23,8 +23,17 @@ ROOT = Path(__file__).resolve().parents[2] -PRODUCTION_SOURCE = ROOT / "src" / "framework" / "UsercmdGen.cpp" -PRODUCTION_HEADER = ROOT / "src" / "idlib" / "NumericString.h" +PRODUCTION_SOURCES = ( + ROOT / "src" / "framework" / "UsercmdGen.cpp", + ROOT / "src" / "idlib" / "Lexer.cpp", + ROOT / "src" / "idlib" / "Base64.cpp", + ROOT / "src" / "renderer" / "ModelDecal.cpp", + ROOT / "src" / "ui" / "SimpleWindow.cpp", +) +PRODUCTION_HEADERS = ( + ROOT / "src" / "idlib" / "NumericString.h", + ROOT / "src" / "idlib" / "Token.h", +) SAFETY_TEST_SOURCE = ROOT / "tools" / "tests" / "native" / "CoreSafetyTest.cpp" DEFAULT_OUTPUT_DIR = ROOT / ".tmp" / "clang-tidy-input-safety" MSVC_DRIVER_MODE = "--driver-mode=cl" @@ -43,6 +52,7 @@ "clang-analyzer-security.*", "clang-analyzer-cplusplus.NewDelete*", "clang-analyzer-cplusplus.PlacementNew", + "clang-analyzer-optin.cplusplus.UninitializedObject", "clang-analyzer-unix.Malloc", "clang-analyzer-unix.MismatchedDeallocator", "clang-analyzer-deadcode.DeadStores", @@ -52,11 +62,12 @@ "clang-analyzer-security.insecureAPI.strcpy", "clang-analyzer-cplusplus.NewDelete", "clang-analyzer-cplusplus.PlacementNew", + "clang-analyzer-optin.cplusplus.UninitializedObject", "clang-analyzer-unix.Malloc", "clang-analyzer-unix.MismatchedDeallocator", "clang-analyzer-deadcode.DeadStores", ) -HEADER_FILTER = r".*[\\/]src[\\/]idlib[\\/]NumericString[.]h$" +HEADER_FILTER = r".*[\\/]src[\\/]idlib[\\/](?:NumericString|Token)[.]h$" class AnalysisError(RuntimeError): @@ -276,25 +287,37 @@ def build_analysis_database(build_dir: Path, root: Path = ROOT) -> list[dict[str if not isinstance(raw_database, list) or not all(isinstance(entry, dict) for entry in raw_database): raise AnalysisError(f"compilation database must contain a JSON array: {database_path}") - production_source = root / "src" / "framework" / "UsercmdGen.cpp" + production_sources = tuple(root / source.relative_to(ROOT) for source in PRODUCTION_SOURCES) precompiled_header = root / "src" / "idlib" / "precompiled.h" safety_source = root / "tools" / "tests" / "native" / "CoreSafetyTest.cpp" - production_entry = choose_production_entry(raw_database, production_source) - production_arguments = sanitize_compile_arguments(compile_arguments(production_entry), precompiled_header) - directory = str(Path(production_entry["directory"]).resolve()) + analysis_database: list[dict[str, Any]] = [] + primary_arguments: list[str] | None = None + primary_directory = "" + for production_source in production_sources: + production_entry = choose_production_entry(raw_database, production_source) + production_arguments = sanitize_compile_arguments(compile_arguments(production_entry), precompiled_header) + directory = str(Path(production_entry["directory"]).resolve()) + analysis_database.append( + { + "directory": directory, + "arguments": production_arguments, + "file": str(production_source.resolve()), + } + ) + if primary_arguments is None: + primary_arguments = production_arguments + primary_directory = directory - return [ + if primary_arguments is None: + raise AnalysisError("input-safety analysis has no production translation units") + analysis_database.append( { - "directory": directory, - "arguments": production_arguments, - "file": str(production_source.resolve()), - }, - { - "directory": directory, - "arguments": safety_test_arguments(production_arguments, safety_source, root), + "directory": primary_directory, + "arguments": safety_test_arguments(primary_arguments, safety_source, root), "file": str(safety_source.resolve()), - }, - ] + } + ) + return analysis_database def validate_output_dir(output_dir: Path, root: Path = ROOT) -> Path: @@ -352,11 +375,11 @@ def clang_tidy_command( root: Path = ROOT, msvc: bool = False, ) -> list[str]: - production_source = root / "src" / "framework" / "UsercmdGen.cpp" + production_sources = tuple(root / source.relative_to(ROOT) for source in PRODUCTION_SOURCES) safety_test_source = root / "tools" / "tests" / "native" / "CoreSafetyTest.cpp" command = [ executable, - str(production_source.resolve()), + *(str(source.resolve()) for source in production_sources), str(safety_test_source.resolve()), "-p", str(database_dir.resolve()), @@ -425,7 +448,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def main(argv: Sequence[str] | None = None) -> int: args = parse_args(argv) try: - for required in (PRODUCTION_SOURCE, PRODUCTION_HEADER, SAFETY_TEST_SOURCE): + for required in (*PRODUCTION_SOURCES, *PRODUCTION_HEADERS, SAFETY_TEST_SOURCE): if not required.is_file(): raise AnalysisError(f"required input-safety source is missing: {required}") diff --git a/tools/tests/base64_input_safety.py b/tools/tests/base64_input_safety.py new file mode 100644 index 00000000..e5338af5 --- /dev/null +++ b/tools/tests/base64_input_safety.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Regression checks for bounded, deterministic Base64 handling in both repositories.""" + +from __future__ import annotations + +import base64 +import os +import random +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +GAME_ROOT = Path(os.environ.get("OPENQ4_GAMELIBS_REPO", ROOT.parent / "openQ4-game")).resolve() +ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +DECODE = {character: index for index, character in enumerate(ALPHABET)} +WHITESPACE = " \n\r\t" + + +def read(path: Path) -> str: + if not path.is_file(): + raise AssertionError(f"required Base64 source is missing: {path}") + return path.read_text(encoding="windows-1252") + + +def between(source: str, start: str, end: str) -> str: + begin = source.index(start) + finish = source.index(end, begin + len(start)) + return source[begin:finish] + + +def require(source: str, token: str, label: str) -> None: + if token not in source: + raise AssertionError(f"{label} is missing {token!r}") + + +def validate_repository(repo: Path) -> None: + cpp_path = repo / "src" / "idlib" / "Base64.cpp" + header_path = repo / "src" / "idlib" / "Base64.h" + cpp = read(cpp_path) + header = read(header_path) + label = f"{repo.name} Base64" + + encode = between(cpp, "void idBase64::Encode( const byte *from, int size )", "int idBase64::DecodeLength") + for token in ( + "if ( from == NULL || size <= 0 )", + "encodedSize = 4ULL", + "encodedSize >", + "idMath::INT_MAX", + "EnsureAlloced( (int)encodedSize )", + "if ( data == NULL )", + "w = 0;", + "len = (int)( to - data - 1 );", + ): + require(encode, token, f"{label} encoder") + if encode.index("if ( from == NULL || size <= 0 )") > encode.index("encodedSize = 4ULL"): + raise AssertionError(f"{label} performs size arithmetic before validating its input") + if encode.index("encodedSize >") > encode.index("EnsureAlloced( (int)encodedSize )"): + raise AssertionError(f"{label} validates encoded allocation size after narrowing it") + if encode.index("if ( data == NULL )") > encode.index("to = data;"): + raise AssertionError(f"{label} publishes an allocation before checking it") + if encode.index("w = 0;") > encode.index("while (size > 0)"): + raise AssertionError(f"{label} reads the encoder accumulator before initialization") + + decode_length = between(cpp, "int idBase64::DecodeLength( void ) const", "int idBase64::Decode( byte *to ) const") + for token in ( + "if ( data == NULL || len <= 0 )", + "Base64_IsWhiteSpace( *from )", + "Base64_DecodeSixtet( *from ) < 0", + "digits * 6ULL", + "idMath::INT_MAX", + ): + require(decode_length, token, f"{label} decoded-length calculation") + + decode = between(cpp, "int idBase64::Decode( byte *to ) const", "void idBase64::Encode( const idStr &src )") + for token in ( + "if ( to == NULL || from == NULL )", + "byte in[4] = {0,0,0,0};", + "Base64_IsWhiteSpace( *from )", + "Base64_DecodeSixtet( *from )", + "if ( sixtet < 0 )", + "memset( in, 0, sizeof( in ) );", + "if ( i > 1 )", + 'idLib::SizeToInt( n, "idBase64::Decode" )', + ): + require(decode, token, f"{label} decoder") + if "static char base64_to_sixtet" in decode or "static int tab_init" in decode: + raise AssertionError(f"{label} retains the racy permissive lazy decode table") + + file_decode = between(cpp, "void idBase64::Decode( idFile *dest ) const", "#if 0") + require(file_decode, "if ( dest == NULL )", f"{label} file decoder") + require(file_decode, "if ( out > 0 )", f"{label} file decoder") + + for token in ( + 'return ( data != NULL ) ? (const char *)data : "";', + "if ( size <= alloced )", + "if ( size <= 0 )", + "if ( data != NULL )", + "memcpy( data, s.c_str(), len + 1 );", + ): + require(header, token, f"{label} storage") + + +def decoded_length_model(text: str) -> int: + digits = 0 + for character in text: + if character in WHITESPACE: + continue + if character == "=" or character not in DECODE: + break + digits += 1 + return digits * 6 // 8 + + +def decode_model(text: str) -> bytes: + pending: list[int] = [] + output = bytearray() + for character in text: + if character == "=": + break + if character in WHITESPACE: + continue + sixtet = DECODE.get(character) + if sixtet is None: + return bytes(output) + pending.append(sixtet) + if len(pending) == 4: + value = (pending[0] << 18) | (pending[1] << 12) | (pending[2] << 6) | pending[3] + output.extend(((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)) + pending.clear() + if len(pending) > 1: + padded = pending + [0] * (4 - len(pending)) + value = (padded[0] << 18) | (padded[1] << 12) | (padded[2] << 6) | padded[3] + output.extend(((value >> 16) & 0xFF, (value >> 8) & 0xFF)[: len(pending) * 6 // 8]) + return bytes(output) + + +def validate_decode_model() -> None: + vectors = ( + (b"", ""), + (b"f", "Zg=="), + (b"fo", "Zm8="), + (b"foo", "Zm9v"), + (b"foob", "Zm9vYg=="), + (b"fooba", "Zm9vYmE="), + (b"foobar", "Zm9vYmFy"), + ) + for payload, encoded in vectors: + if decode_model(encoded) != payload or decoded_length_model(encoded) != len(payload): + raise AssertionError(f"Base64 model failed vector {encoded!r}") + + if decode_model(" Zm9v\r\nYmFy\t") != b"foobar": + raise AssertionError("Base64 model does not accept bounded ASCII whitespace") + if decode_model("Zm9v!YmFy") != b"foo" or decode_model("Zg!") != b"": + raise AssertionError("Base64 model does not stop safely at invalid alphabet bytes") + if decode_model("Zg==ignored") != b"f": + raise AssertionError("Base64 model does not stop at padding") + + generator = random.Random(0x0B64) + for size in (*range(65), 127, 255, 256, 257): + payload = bytes(generator.randrange(256) for _ in range(size)) + encoded = base64.b64encode(payload).decode("ascii") + decorated = "".join( + character + (WHITESPACE[generator.randrange(len(WHITESPACE))] if generator.randrange(7) == 0 else "") + for character in encoded + ) + if decode_model(decorated) != payload: + raise AssertionError(f"Base64 whitespace round trip failed at {size} bytes") + if len(decode_model(decorated)) > decoded_length_model(decorated): + raise AssertionError("Base64 decoded-length model under-allocated a valid payload") + + alphabet_and_noise = ALPHABET + "=!?@#$%^&*()[]{}" + WHITESPACE + for _ in range(5000): + text = "".join(generator.choice(alphabet_and_noise) for _ in range(generator.randrange(80))) + if len(decode_model(text)) > decoded_length_model(text): + raise AssertionError(f"Base64 decoded-length model under-allocated malformed input {text!r}") + + +def main() -> None: + validate_repository(ROOT) + validate_repository(GAME_ROOT) + validate_decode_model() + validation = read(ROOT / "tools" / "validation" / "openq4_validate.py") + require(validation, "base64_input_safety.py", "Base64 validation wiring") + print("base64 input safety: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/clang_tidy_input_safety.py b/tools/tests/clang_tidy_input_safety.py index 5dff7d80..1f7e8152 100644 --- a/tools/tests/clang_tidy_input_safety.py +++ b/tools/tests/clang_tidy_input_safety.py @@ -123,21 +123,27 @@ def fake_entry(directory: Path, source: Path, target: str) -> dict[str, object]: def check_database_generation() -> None: build_dir = WORK / "builddir" build_dir.mkdir(parents=True, exist_ok=True) - source = ROOT / "src" / "framework" / "UsercmdGen.cpp" - database = [ - fake_entry(build_dir, source, "openQ4-ded_x64.exe"), - fake_entry(build_dir, source, "openQ4-client_x64.exe"), - ] + sources = tuple(Path(source).resolve() for source in ANALYSIS.PRODUCTION_SOURCES) + database = [] + for source in sources: + database.extend( + ( + fake_entry(build_dir, source, "openQ4-ded_x64.exe"), + fake_entry(build_dir, source, "openQ4-client_x64.exe"), + ) + ) (build_dir / "compile_commands.json").write_text(json.dumps(database), encoding="utf-8") generated = ANALYSIS.build_analysis_database(build_dir) - if len(generated) != 2: - raise AssertionError("analysis database must contain production and native safety translation units") - production, safety = generated - if not any("openQ4-client_x64.exe.p" in str(argument) for argument in production["arguments"]): - raise AssertionError("analysis did not select the deterministic client compilation command") - if Path(production["file"]).resolve() != source.resolve(): - raise AssertionError("analysis production entry targets the wrong source") + if len(generated) != len(sources) + 1: + raise AssertionError("analysis database must contain every production and native safety translation unit") + production_entries = generated[:-1] + safety = generated[-1] + if tuple(Path(entry["file"]).resolve() for entry in production_entries) != sources: + raise AssertionError("analysis production entries target the wrong sources or order") + for entry in production_entries: + if not any("openQ4-client_x64.exe.p" in str(argument) for argument in entry["arguments"]): + raise AssertionError("analysis did not select the deterministic client compilation command") if Path(safety["file"]).resolve() != (ROOT / "tools" / "tests" / "native" / "CoreSafetyTest.cpp").resolve(): raise AssertionError("analysis safety entry targets the wrong source") if any(str(argument).lower().startswith(("/fi", "/yu", "/fp")) for argument in safety["arguments"]): @@ -149,6 +155,7 @@ def check_fail_closed_profile() -> None: "clang-analyzer-core.*", "clang-analyzer-security.*", "clang-analyzer-cplusplus.NewDelete*", + "clang-analyzer-optin.cplusplus.UninitializedObject", "clang-analyzer-unix.Malloc", "clang-analyzer-deadcode.DeadStores", } @@ -159,13 +166,19 @@ def check_fail_closed_profile() -> None: "clang-analyzer-core.CallAndMessage", "clang-analyzer-security.insecureAPI.strcpy", "clang-analyzer-cplusplus.NewDelete", + "clang-analyzer-optin.cplusplus.UninitializedObject", "clang-analyzer-unix.Malloc", "clang-analyzer-deadcode.DeadStores", } ): raise AssertionError("clang-tidy capability check does not fail closed") - for header in (r"E:\Repositories\openQ4\src\idlib\NumericString.h", "/repo/src/idlib/NumericString.h"): + for header in ( + r"E:\Repositories\openQ4\src\idlib\NumericString.h", + "/repo/src/idlib/NumericString.h", + r"E:\Repositories\openQ4\src\idlib\Token.h", + "/repo/src/idlib/Token.h", + ): if re.match(ANALYSIS.HEADER_FILTER, header) is None: raise AssertionError(f"header filter does not match production path style {header!r}") if re.match(ANALYSIS.HEADER_FILTER, "/repo/src/idlib/Str.h") is not None: @@ -174,14 +187,13 @@ def check_fail_closed_profile() -> None: command = ANALYSIS.clang_tidy_command("clang-tidy", WORK) if "--warnings-as-errors=*" not in command: raise AssertionError("clang-tidy diagnostics are not fail-closed") - if not any(argument.startswith("--header-filter=") and "NumericString[.]h" in argument for argument in command): - raise AssertionError("production numeric helper is excluded from header diagnostics") - expected_sources = { - str((ROOT / "src" / "framework" / "UsercmdGen.cpp").resolve()), - str((ROOT / "tools" / "tests" / "native" / "CoreSafetyTest.cpp").resolve()), - } + header_argument = next((argument for argument in command if argument.startswith("--header-filter=")), "") + if "NumericString|Token" not in header_argument: + raise AssertionError("production numeric/token helpers are excluded from header diagnostics") + expected_sources = {str(Path(source).resolve()) for source in ANALYSIS.PRODUCTION_SOURCES} + expected_sources.add(str((ROOT / "tools" / "tests" / "native" / "CoreSafetyTest.cpp").resolve())) if not expected_sources.issubset(set(command)): - raise AssertionError("clang-tidy command does not cover both safety translation units") + raise AssertionError("clang-tidy command does not cover every safety translation unit") def check_output_guard() -> None: diff --git a/tools/tests/cmdargs_append_contract.py b/tools/tests/cmdargs_append_contract.py index 2314d0c2..609bac7d 100644 --- a/tools/tests/cmdargs_append_contract.py +++ b/tools/tests/cmdargs_append_contract.py @@ -83,9 +83,30 @@ def validate_tree(root: Path, context: str) -> None: raise AssertionError(f"{context}: guards must precede argv installation") +def validate_auto_exec_capture_boundary() -> None: + for relative_path in ("src/game/Game_local.cpp", "src/mpgame/Game_local.cpp"): + source = (GAME_LIBS_ROOT / relative_path).read_text(encoding="utf-8") + body = function_body( + source, + "void idGameLocal::CheckAutoExecAfterMapLoad", + relative_path, + ) + safe_dispatch = "BufferCommandArgs( CMD_EXEC_APPEND, execArgs )" + unsafe_dispatch = "BufferCommandArgs( CMD_EXEC_NOW, execArgs )" + if safe_dispatch not in body: + raise AssertionError( + f"{relative_path}: post-map cfg must be queued outside the active draw" + ) + if unsafe_dispatch in body: + raise AssertionError( + f"{relative_path}: post-map cfg still executes synchronously during Draw" + ) + + def main() -> None: validate_tree(ROOT, "engine idCmdArgs") validate_tree(GAME_LIBS_ROOT, "GameLibs idCmdArgs") + validate_auto_exec_capture_boundary() argc = 0 for _ in range(MAX_COMMAND_ARGS): diff --git a/tools/tests/lexer_input_safety.py b/tools/tests/lexer_input_safety.py new file mode 100644 index 00000000..b1b36549 --- /dev/null +++ b/tools/tests/lexer_input_safety.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Regression checks for deterministic tokens and fail-closed lexer file reads.""" + +from __future__ import annotations + +import os +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: + # The SDK lexer intentionally retains Windows-1252 inverted punctuation + # literals. This single-byte decoder also preserves every ASCII contract + # checked below in the UTF-8 engine copy. + return path.read_text(encoding="windows-1252") + + +def section(source: str, start: str, end: str) -> str: + begin = source.index(start) + finish = source.index(end, begin + len(start)) + return source[begin:finish] + + +def validate_repository(repo: Path) -> tuple[str, str]: + token_path = repo / "src" / "idlib" / "Token.h" + lexer_path = repo / "src" / "idlib" / "Lexer.cpp" + if not token_path.is_file() or not lexer_path.is_file(): + raise AssertionError(f"lexer inputs are missing from {repo}") + + token_source = read(token_path) + constructor = section( + token_source, + "ID_INLINE idToken::idToken( void ) :", + "ID_INLINE idToken::idToken( const idToken *token )", + ) + for initializer in ( + "type( 0 )", + "subtype( 0 )", + "line( 0 )", + "linesCrossed( 0 )", + "flags( 0 )", + "intvalue( 0 )", + "floatvalue( 0.0 )", + "whiteSpaceStart_p( NULL )", + "whiteSpaceEnd_p( NULL )", + "next( NULL )", + ): + if initializer not in constructor: + raise AssertionError(f"{token_path} does not initialize {initializer}") + whitespace_guard = ( + "whiteSpaceStart_p != NULL && whiteSpaceEnd_p != NULL && " + "whiteSpaceEnd_p > whiteSpaceStart_p" + ) + if whitespace_guard not in token_source: + raise AssertionError(f"{token_path} compares token whitespace pointers without null guards") + + lexer_source = read(lexer_path) + load_file = section(lexer_source, "int idLexer::LoadFile( const char *filename, bool OSPath )", "int idLexer::LoadMemory(") + for guard in ( + "length < 0 || length == idMath::INT_MAX", + "idLib::fileSystem->CloseFile( fp );", + "const int bytesRead = fp->Read( buf, length );", + "if ( bytesRead != length )", + "Mem_Free( buf );", + "buf[length] = '\\0';", + ): + if guard not in load_file: + raise AssertionError(f"{lexer_path} is missing file-read guard {guard!r}") + if load_file.index("length < 0 || length == idMath::INT_MAX") > load_file.index("Mem_Alloc( length + 1"): + raise AssertionError(f"{lexer_path} validates the length after allocation arithmetic") + if load_file.index("if ( bytesRead != length )") > load_file.index("buf[length] = '\\0';"): + raise AssertionError(f"{lexer_path} terminates a buffer before validating the complete read") + allocation_failure = section(load_file, "if( !buf )", "// RAVEN END") + if allocation_failure.index("CloseFile( fp )") > allocation_failure.index("FatalError"): + raise AssertionError(f"{lexer_path} leaks its file handle on allocation failure") + if "FatalError( \"Memory system failure : out of memory\" );\n\t}" in allocation_failure: + raise AssertionError(f"{lexer_path} can continue after a returning fatal-error callback") + return constructor, load_file + + +def main() -> None: + engine_contract = validate_repository(ROOT) + game_contract = validate_repository(GAME_ROOT) + if engine_contract != game_contract: + raise AssertionError("engine and GameLib lexer/token safety contracts diverged") + + validation = (ROOT / "tools" / "validation" / "openq4_validate.py").read_text(encoding="utf-8") + if "lexer_input_safety.py" not in validation: + raise AssertionError("lexer input-safety regression is not wired into validation") + print("lexer input safety: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/renderer_demo_projection_safety.py b/tools/tests/renderer_demo_projection_safety.py new file mode 100644 index 00000000..5d558214 --- /dev/null +++ b/tools/tests/renderer_demo_projection_safety.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Guard finite render-demo payloads and fail-closed decal projection.""" + +from __future__ import annotations + +import math +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def require(source: str, token: str, label: str) -> None: + if token not in source: + raise AssertionError(f"{label} is missing {token!r}") + + +def dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: + return sum(a * b for a, b in zip(left, right)) + + +def finite_vec3(value: tuple[float, float, float]) -> bool: + return all(math.isfinite(component) for component in value) + + +def project_texture_point( + position: tuple[float, float, float], + projection_origin: tuple[float, float, float], + plane_normal: tuple[float, float, float], + plane_distance: float, + parallel: bool, +) -> tuple[float, float, float] | None: + if not finite_vec3(position): + return None + if parallel: + return position + direction = tuple(position[index] - projection_origin[index] for index in range(3)) + denominator = dot(plane_normal, direction) + numerator = -(dot(plane_normal, position) + plane_distance) + if not math.isfinite(denominator) or denominator == 0.0: + return None + scale = numerator / denominator + if not math.isfinite(scale): + return None + projected = tuple(position[index] + scale * direction[index] for index in range(3)) + return projected if finite_vec3(projected) else None + + +def validate_projection_model() -> None: + projected = project_texture_point((1.0, 2.0, 3.0), (0.0, 0.0, 10.0), (0.0, 0.0, 1.0), 0.0, False) + if projected is None or abs(projected[2]) > 1e-6: + raise AssertionError("decal projection model failed a finite plane intersection") + if project_texture_point((1.0, 2.0, 3.0), (0.0, 0.0, 3.0), (0.0, 0.0, 1.0), 0.0, False) is not None: + raise AssertionError("decal projection model accepted a parallel ray") + if project_texture_point((math.nan, 2.0, 3.0), (0.0, 0.0, 10.0), (0.0, 0.0, 1.0), 0.0, True) is not None: + raise AssertionError("parallel decal projection accepted a non-finite point") + if project_texture_point((1.0, 2.0, math.inf), (0.0, 0.0, 10.0), (0.0, 0.0, 1.0), 0.0, False) is not None: + raise AssertionError("perspective decal projection accepted a non-finite point") + if project_texture_point((1.0, 2.0, 3.0), (0.0, 0.0, 10.0), (0.0, 0.0, 1.0), 0.0, True) != (1.0, 2.0, 3.0): + raise AssertionError("parallel decal projection changed a finite source point") + + +def validate_source_contract() -> None: + decal = read("src/renderer/ModelDecal.cpp") + packed = read("src/renderer/Model_md5r.cpp") + gui = read("src/renderer/GuiModel.cpp") + + for token in ( + "bool R_IsFiniteDecalDemoDrawVert", + "memset( vert.color2, 0, sizeof( vert.color2 ) );", + "R_IsFiniteDecalDemoDrawVert( decal->tri.verts[vertIndex] )", + "std::isfinite( decal->vertDepthFade[vertIndex] )", + "std::isfinite( decal->vertLifeSpan[vertIndex] )", + "const idVec3 position", + "R_IsFiniteDecalProjectionPoint( position )", + "idVec3 texturePoint = position;", + "if ( !localInfo.parallel )", + ".RayIntersection( position, dir, scale )", + "!std::isfinite( scale )", + "R_IsFiniteDecalProjectionPoint( texturePoint )", + "!std::isfinite( fw[j].s ) || !std::isfinite( fw[j].t )", + "if ( !projectionValid )", + ): + require(decal, token, "classic decal safety") + + for token in ( + "const idVec3 position", + "!std::isfinite( position.x )", + "idVec3 texturePoint = position;", + "if ( !localInfo.parallel )", + ".RayIntersection( position, dir, scale )", + "!std::isfinite( scale )", + "!std::isfinite( texturePoint.x )", + "!std::isfinite( fw[pointNum].s ) || !std::isfinite( fw[pointNum].t )", + "if ( !projectionValid )", + "decodedMaterialFlags.AssureSize( meshCount, static_cast( 0 ) );", + ): + require(packed, token, "packed decal/cache safety") + + for token in ( + "static bool R_IsFiniteGuiDemoDrawVert", + "static bool R_IsFiniteGuiDemoColor", + "verts[j].color2[0] = verts[j].color2[1] = verts[j].color2[2] = verts[j].color2[3] = 255;", + "R_IsFiniteGuiDemoDrawVert( verts[j] )", + "R_IsFiniteGuiDemoColor( surf->color )", + 'R_RejectGuiModelDemo( demo, "non-finite vertex payload" )', + 'R_RejectGuiModelDemo( demo, "non-finite surface color" )', + ): + require(gui, token, "GUI render-demo safety") + + +def main() -> None: + validate_projection_model() + validate_source_contract() + require(read("tools/validation/openq4_validate.py"), "renderer_demo_projection_safety.py", "renderer safety validation wiring") + print("renderer demo/projection safety: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/savegame_corruption_contract.py b/tools/tests/savegame_corruption_contract.py index d7e33013..4179839d 100644 --- a/tools/tests/savegame_corruption_contract.py +++ b/tools/tests/savegame_corruption_contract.py @@ -1117,8 +1117,10 @@ def decode_window_reference( "UI_ReadSaveGameInt( savefile, num, \"state count\" )", "UI_ReadSaveGameBool( savefile, restoredActive, \"active flag\" )", "savedValue > 1", + "idUserInterfaceLocal::ReadFromSaveGame: non-finite %s at offset %d", "restoredState.FindKey( key ) != NULL", "savefile == NULL || desktop == NULL", + "SetCursor( restoredCursorX, restoredCursorY );", "desktop->ReadFromSaveGame( savefile );", ): require(ui_source, token, "top-level GUI positional restore validation") @@ -1129,21 +1131,21 @@ def decode_window_reference( "const unsigned int structuralFlagMask = WIN_CHILD | WIN_DESKTOP;", "savedName.Icmp( name ) != 0", "SAVEGAME_WINDOW_REFERENCE_DESCENDANT_BASE = -2", - "BuildSaveGameChildOrder( orderedChildren, \"idWindow::ReadFromSaveGame\" );", + "if ( !BuildSaveGameChildOrder( orderedChildren, \"idWindow::ReadFromSaveGame\" ) )", "static_cast( SAVEGAME_WINDOW_REFERENCE_DESCENDANT_BASE ) - static_cast( savedChildId )", - "focusedChild = ReadSaveGameChildReference( savefile, \"focused child id\", desktopTrackedDescendants, &hadSavedFocusReference );", - "captureChild = ReadSaveGameChildReference( savefile, \"capture child id\", desktopTrackedDescendants, &hadSavedCaptureReference );", - "overChild = ReadSaveGameChildReference( savefile, \"hovered child id\", false );", - "ValidateRestoredTrackedWindowPointers( hadSavedFocusReference, hadSavedCaptureReference );", + "&hadSavedFocusReference, &focusReadSucceeded", + "&hadSavedCaptureReference, &captureReadSucceeded", + "NULL, &overReadSucceeded", + "if ( !ValidateRestoredTrackedWindowPointers( hadSavedFocusReference, hadSavedCaptureReference ) )", "if ( hadSavedFocusReference || focusedChild != NULL )", "if ( hadSavedCaptureReference || captureChild != NULL )", "if ( focusMatches > 1 )", "if ( captureMatches > 1 )", "have duplicate id", - "savefile->WriteInt( len )", + "OpenQ4_WriteSaveGameInt( savefile, len, \"idWindow::WriteSaveGameString\", \"length\" )", "could not resolve transition target for window", "transition target '%s' for window '%s' in gui '%s' is missing or ambiguous", - "savefile->WriteInt( savedOffset )", + "OpenQ4_WriteSaveGameInt( savefile, savedOffset, \"idWindow::WriteSaveGameTransition\", \"target offset\" )", "if ( offset < 0 )", "restore cannot continue without desynchronizing the stream", "namedEvents[i] == NULL || namedEvents[i]->mEvent == NULL", @@ -1186,16 +1188,20 @@ def decode_window_reference( def validate_minigame_restore_contract() -> None: ui_restore_contracts = { "src/ui/Winvar.h": ( - "static ID_INLINE void OpenQ4_WriteSaveGameBytes( idFile *savefile, const void *buffer, int len, const char *context, const char *fieldName )", - "static ID_INLINE void OpenQ4_ReadSaveGameBytes( idFile *savefile, void *buffer, int len, const char *context, const char *fieldName )", - "static ID_INLINE void OpenQ4_WriteSaveGameBool( idFile *savefile, bool value, const char *context, const char *fieldName )", - "static ID_INLINE void OpenQ4_ReadSaveGameBool( idFile *savefile, bool &value, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_WriteSaveGameBytes( idFile *savefile, const void *buffer, int len, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_ReadSaveGameBytes( idFile *savefile, void *buffer, int len, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_WriteSaveGameBool( idFile *savefile, bool value, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_ReadSaveGameBool( idFile *savefile, bool &value, const char *context, const char *fieldName )", "savedValue > 1", - "static ID_INLINE void OpenQ4_WriteSaveGameInt( idFile *savefile, int value, const char *context, const char *fieldName )", - "static ID_INLINE void OpenQ4_ReadSaveGameInt( idFile *savefile, int &value, const char *context, const char *fieldName )", - "static ID_INLINE void OpenQ4_WriteSaveGameFloat( idFile *savefile, float value, const char *context, const char *fieldName )", - "static ID_INLINE void OpenQ4_ReadSaveGameFloat( idFile *savefile, float &value, const char *context, const char *fieldName )", - "static ID_INLINE void OpenQ4_ReadSaveGameField( idFile *savefile, type &value, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_WriteSaveGameInt( idFile *savefile, int value, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_ReadSaveGameInt( idFile *savefile, int &value, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_WriteSaveGameFloat( idFile *savefile, float value, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_ReadSaveGameFloat( idFile *savefile, float &value, const char *context, const char *fieldName )", + "common->Error( \"%s: refusing non-finite %s\"", + "common->Error( \"%s: non-finite %s at offset %d\"", + "value = 0.0f;", + "static ID_INLINE bool OpenQ4_WriteSaveGameField( idFile *savefile, const type &value, const char *context, const char *fieldName )", + "static ID_INLINE bool OpenQ4_ReadSaveGameField( idFile *savefile, type &value, const char *context, const char *fieldName )", "common->Error( \"%s: truncated %s at offset %d (read %d of %d)\"", "OpenQ4_ReadSaveGameBool( savefile, eval, \"idWinBool::ReadFromSaveGame\", \"eval flag\" )", "OpenQ4_ReadSaveGameBool( savefile, data, \"idWinBool::ReadFromSaveGame\", \"value\" )", @@ -1205,21 +1211,22 @@ def validate_minigame_restore_contract() -> None: "OpenQ4_ReadSaveGameBytes( savefile, &data[0], len, \"idWinBackground::ReadFromSaveGame\", \"material name\" )", ), "src/ui/Window.cpp": ( - "OpenQ4_ReadSaveGameField( savefile, offset, \"idWindow::ReadSaveGameTransition\", \"offset\" )", + "OpenQ4_ReadSaveGameInt( savefile, offset, \"idWindow::ReadSaveGameTransition\", \"offset\" )", "OpenQ4_ReadSaveGameField( savefile, trans.interp, \"idWindow::ReadSaveGameTransition\", \"interpolate state\" )", "OpenQ4_ReadSaveGameBytes( savefile, &string[0], len, \"idWindow::ReadSaveGameString\", \"string\" )", - "OpenQ4_ReadSaveGameField( savefile, actualX, \"idWindow::ReadFromSaveGame\", \"actualX\" )", - "focusedChild = ReadSaveGameChildReference( savefile, \"focused child id\", desktopTrackedDescendants, &hadSavedFocusReference );", - "captureChild = ReadSaveGameChildReference( savefile, \"capture child id\", desktopTrackedDescendants, &hadSavedCaptureReference );", - "overChild = ReadSaveGameChildReference( savefile, \"hovered child id\", false );", + "OpenQ4_ReadSaveGameField( savefile, savedActualX, \"idWindow::ReadFromSaveGame\", \"actualX\" )", + "&hadSavedFocusReference, &focusReadSucceeded", + "&hadSavedCaptureReference, &captureReadSucceeded", + "NULL, &overReadSucceeded", "OpenQ4_ReadSaveGameBool( savefile, timeLineEvents[i]->pending, \"idWindow::ReadFromSaveGame\", \"timeline pending flag\" )", "OpenQ4_ReadSaveGameInt( savefile, timeLineEvents[i]->time, \"idWindow::ReadFromSaveGame\", \"timeline event time\" )", - "OpenQ4_ReadSaveGameField( savefile, num, \"idWindow::ReadFromSaveGame\", \"transition count\" )", + "OpenQ4_ReadSaveGameInt( savefile, num, \"idWindow::ReadFromSaveGame\", \"transition count\" )", ), "src/ui/SimpleWindow.cpp": ( - "OpenQ4_ReadSaveGameField( savefile, flags, \"idSimpleWindow::ReadFromSaveGame\", \"flags\" )", - "OpenQ4_ReadSaveGameField( savefile, stringLen, \"idSimpleWindow::ReadFromSaveGame\", \"background length\" )", - "OpenQ4_ReadSaveGameBytes( savefile, &(backName)[0], stringLen, \"idSimpleWindow::ReadFromSaveGame\", \"background name\" )", + "OpenQ4_ReadSaveGameField( savefile, savedFlags, \"idSimpleWindow::ReadFromSaveGame\", \"flags\" )", + "OpenQ4_ReadSaveGameInt( savefile, stringLen, \"idSimpleWindow::ReadFromSaveGame\", \"background length\" )", + "OpenQ4_ReadSaveGameBytes( savefile, &(backName)[0], stringLen,", + "\"idSimpleWindow::ReadFromSaveGame\", \"background name\" )", ), "src/ui/GuiScript.cpp": ( "const int conditionBytes = savefile->ReadInt( savedConditionReg );", diff --git a/tools/tests/ui_cursor_state_safety.py b/tools/tests/ui_cursor_state_safety.py new file mode 100644 index 00000000..1225f682 --- /dev/null +++ b/tools/tests/ui_cursor_state_safety.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Guard GUI cursor normalization and finite save/demo state restoration.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def between(source: str, start: str, end: str) -> str: + begin = source.index(start) + finish = source.index(end, begin + len(start)) + return source[begin:finish] + + +def require(source: str, token: str, label: str) -> None: + if token not in source: + raise AssertionError(f"{label} is missing {token!r}") + + +def main() -> None: + source = read("src/ui/UserInterface.cpp") + header = read("src/ui/UserInterfaceLocal.h") + winvar = read("src/ui/Winvar.h") + + require(header, "void\t\t\t\t\t\tClampCursor( void );", "GUI cursor helper declaration") + set_cursor = between(source, "void idUserInterfaceLocal::SetCursor( float x, float y )", "idUserInterfaceLocal::ClampCursor") + require(set_cursor, "ClampCursor();", "GUI absolute cursor route") + + handle_event = between(source, "const char *idUserInterfaceLocal::HandleEvent", "void idUserInterfaceLocal::HandleNamedEvent") + require(handle_event, "SetCursor( cursorX + static_cast( event->evValue )", "GUI relative cursor route") + if "cursorX +=" in handle_event or "cursorY +=" in handle_event: + raise AssertionError("GUI relative input bypasses the shared cursor normalization path") + + clamp_cursor = between(source, "void idUserInterfaceLocal::ClampCursor( void )", "bool idUserInterfaceLocal::GetMaxTextIndex") + for token in ( + "!std::isfinite( cursorX )", + "!std::isfinite( cursorY )", + "std::isfinite( desktop->forceAspectWidth )", + "std::isfinite( desktop->forceAspectHeight )", + "std::isfinite( xExpand ) && xExpand >= 0.0f", + "std::isfinite( yExpand ) && yExpand >= 0.0f", + "idMath::ClampFloat( minX, maxX, cursorX )", + "idMath::ClampFloat( minY, maxY, cursorY )", + ): + require(clamp_cursor, token, "GUI cursor normalization") + + demo_restore = between(source, "void idUserInterfaceLocal::ReadFromDemoFile", "void idUserInterfaceLocal::WriteToDemoFile") + require(demo_restore, "SetCursor( restoredCursorX, restoredCursorY );", "GUI demo cursor restore") + save_restore = between(source, "bool idUserInterfaceLocal::ReadFromSaveGame", "void idUserInterfaceLocal::SetKeyBindingNames") + require(save_restore, "SetCursor( restoredCursorX, restoredCursorY );", "GUI save cursor restore") + if save_restore.index("desktop->ReadFromSaveGame( savefile );") > save_restore.index( + "SetCursor( restoredCursorX, restoredCursorY );" + ): + raise AssertionError("GUI save cursor is clamped before its restored desktop bounds are available") + if "cursorX = restoredCursorX" in save_restore or "cursorY = restoredCursorY" in save_restore: + raise AssertionError("GUI save cursor restore bypasses normalization") + + for token in ( + "idUserInterfaceLocal::WriteToSaveGame: refusing non-finite %s", + "idUserInterfaceLocal::ReadFromSaveGame: non-finite %s at offset %d", + ): + require(source, token, "top-level GUI finite-state validation") + for token in ( + "refusing non-finite %s", + "non-finite %s at offset %d", + "value = 0.0f;", + ): + require(winvar, token, "GUI window-variable finite-state validation") + + validation = read("tools/validation/openq4_validate.py") + require(validation, "ui_cursor_state_safety.py", "GUI cursor regression wiring") + print("GUI cursor state safety: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/ui_window_state_safety.py b/tools/tests/ui_window_state_safety.py new file mode 100644 index 00000000..01bda0b1 --- /dev/null +++ b/tools/tests/ui_window_state_safety.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Guard GUI timeline parsing and transactional window save-state handling.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +INT_MIN = -(1 << 31) +INT_MAX = (1 << 31) - 1 + + +def read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def between(source: str, start: str, end: str) -> str: + begin = source.index(start) + finish = source.index(end, begin + len(start)) + return source[begin:finish] + + +def require(source: str, token: str, label: str) -> None: + if token not in source: + raise AssertionError(f"{label} is missing {token!r}") + + +def parse_timeline_milliseconds(text: str) -> int | None: + if not text: + return None + negative = text[0] == "-" + if text[0] in "+-": + text = text[1:] + if not text: + return None + limit = 1 << 31 if negative else INT_MAX + magnitude = 0 + for character in text: + if character < "0" or character > "9": + return None + digit = ord(character) - ord("0") + if magnitude > (limit - digit) // 10: + return None + magnitude = magnitude * 10 + digit + return -magnitude if negative else magnitude + + +def add_relative(previous: int, value: int) -> int | None: + combined = previous + value + return combined if INT_MIN <= combined <= INT_MAX else None + + +def validate_timeline_model() -> None: + valid = { + "0": 0, + "+0": 0, + "-0": 0, + "1": 1, + "+17": 17, + "-17": -17, + "00042": 42, + "2147483647": INT_MAX, + "-2147483648": INT_MIN, + } + for text, expected in valid.items(): + actual = parse_timeline_milliseconds(text) + if actual != expected: + raise AssertionError(f"timeline parser rejected {text!r}: expected {expected}, got {actual}") + for text in ("", "+", "-", " 1", "1 ", "1.0", "0x10", "++1", "--1", "2147483648", "-2147483649", "1"): + if parse_timeline_milliseconds(text) is not None: + raise AssertionError(f"timeline parser accepted malformed or overflowing value {text!r}") + if add_relative(INT_MAX - 7, 7) != INT_MAX or add_relative(INT_MIN + 7, -7) != INT_MIN: + raise AssertionError("timeline relative parser rejected a signed boundary") + if add_relative(INT_MAX, 1) is not None or add_relative(INT_MIN, -1) is not None: + raise AssertionError("timeline relative parser accepted signed overflow") + + +def validate_parser_contract(window: str) -> None: + helper = between(window, "static bool OpenQ4_ParseTimelineMilliseconds", "bool idWindow::Parse(") + for token in ( + "uint64 magnitude = 0;", + "negative ? 2147483648ULL : 2147483647ULL", + "magnitude > ( magnitudeLimit - digit ) / 10ULL", + "const int64 signedValue", + ): + require(helper, token, "GUI timeline integer parser") + + on_time = between(window, 'else if ( token == "onTime" )', 'else if ( token == "definefloat" )') + for token in ( + "OpenQ4_ParseTimelineMilliseconds( timeToken, eventTime )", + "const int64 combinedTime", + "combinedTime < idMath::INT_MIN || combinedTime > idMath::INT_MAX", + "idTimeLineEvent *ev = new idTimeLineEvent;", + "delete ev;", + ): + require(on_time, token, "GUI timeline event parser") + if on_time.index("idTimeLineEvent *ev = new idTimeLineEvent;") < on_time.index( + "OpenQ4_ParseTimelineMilliseconds( timeToken, eventTime )" + ): + raise AssertionError("GUI timeline event is allocated before its timestamp is validated") + + named = between(window, 'else if ( token == "onNamedEvent" )', 'else if ( token == "onTime" )') + require(named, "delete ev;", "GUI named-event parse failure cleanup") + require(window, "idStr::Copynz( p, token.c_str(), token.Length() + 1 );", "GUI deferred-variable copy") + if "strcpy( p, token.c_str() )" in window: + raise AssertionError("GUI deferred-variable parsing retains an unbounded copy") + + +def validate_save_contract(window: str, header: str, winvar: str, simple: str) -> None: + for token in ( + "static ID_INLINE bool OpenQ4_WriteSaveGameBytes", + "static ID_INLINE bool OpenQ4_ReadSaveGameBytes", + "static ID_INLINE bool OpenQ4_WriteSaveGameField", + "static ID_INLINE bool OpenQ4_ReadSaveGameField", + "memset( buffer, 0, len );", + "value = false;", + "value = 0;", + "value = 0.0f;", + "if ( !std::isfinite( value ) )", + ): + require(winvar, token, "GUI save primitive") + for token in ( + "bool\t\t\tWriteSaveGameString", + "bool\t\t\tWriteSaveGameTransition", + "bool\t\t\tWriteSaveGameChildReference", + "bool\t\t\tReadSaveGameString", + "bool\t\t\tReadSaveGameTransition", + "bool\t\t\tFixupTransitions", + "bool BuildSaveGameChildOrder", + "bool *readSucceeded = NULL", + ): + require(header, token, "GUI save API") + + writer = between(window, "void idWindow::WriteToSaveGame( idFile *savefile )", "bool idWindow::ReadSaveGameString") + for token in ( + "savefile == NULL || gui == NULL", + "refusing non-finite or out-of-range layout state", + "OpenQ4_WriteSaveGameField", + "WriteSaveGameChildReference", + "incomplete timeline event", + "incomplete named event", + "invalid simple/full ownership", + ): + require(writer, token, "GUI window save writer") + + reader = between(window, "void idWindow::ReadFromSaveGame( idFile *savefile )", "int idWindow::NumTransitions()") + for token in ( + "idStr savedCmd;", + "idStr savedName;", + "non-finite layout state", + "saved child id", + "saved structural flags", + "savedName.Icmp( name ) != 0", + "bool focusReadSucceeded = false;", + "!focusReadSucceeded || !captureReadSucceeded || !overReadSucceeded", + "invalid transition count", + "ValidateRestoredTrackedWindowPointers", + "!FixupTransitions()", + ): + require(reader, token, "GUI window save reader") + if reader.index("cmd = savedCmd;") < reader.index("savedName.Icmp( name ) != 0"): + raise AssertionError("GUI window restore commits header state before validating window identity") + + simple_writer = between(simple, "void idSimpleWindow::WriteToSaveGame", "void idSimpleWindow::ReadFromSaveGame") + for token in ( + "savefile == NULL", + "OpenQ4_IsFiniteSimpleWindowRectangle", + "OpenQ4_WriteSaveGameField", + "OpenQ4_WriteSaveGameInt", + "OpenQ4_WriteSaveGameBytes", + "64 * 1024", + ): + require(simple_writer, token, "simple GUI window save writer") + simple_reader = simple[simple.index("void idSimpleWindow::ReadFromSaveGame") :] + for token in ( + "idRectangle savedDrawRect;", + "OpenQ4_ReadSaveGameField", + "OpenQ4_IsFiniteSimpleWindowRectangle( savedDrawRect )", + "OpenQ4_ReadSaveGameInt", + "remainingBytes", + "background = NULL;", + ): + require(simple_reader, token, "simple GUI window save reader") + if simple_reader.index("drawRect = savedDrawRect;") < simple_reader.index( + "OpenQ4_IsFiniteSimpleWindowRectangle( savedDrawRect )" + ): + raise AssertionError("simple GUI window restore commits layout before finite validation") + + +def main() -> None: + window = read("src/ui/Window.cpp") + validate_timeline_model() + validate_parser_contract(window) + validate_save_contract(window, read("src/ui/Window.h"), read("src/ui/Winvar.h"), read("src/ui/SimpleWindow.cpp")) + require(read("tools/validation/openq4_validate.py"), "ui_window_state_safety.py", "GUI window validation wiring") + print("GUI window state safety: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/weapon_zoom_view_alignment.py b/tools/tests/weapon_zoom_view_alignment.py new file mode 100644 index 00000000..4cad0dbe --- /dev/null +++ b/tools/tests/weapon_zoom_view_alignment.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Keep SP/MP zoom-scope yaw aligned with the camera actually presented.""" + +from __future__ import annotations + +import os +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: + if not path.is_file(): + raise AssertionError(f"required GameLib source is missing: {path}") + return path.read_text(encoding="utf-8") + + +def require(source: str, token: str, label: str) -> None: + if token not in source: + raise AssertionError(f"{label} is missing {token!r}") + + +def validate_mode(directory: str) -> None: + player_cpp = read(GAME_ROOT / "src" / directory / "Player.cpp") + player_h = read(GAME_ROOT / "src" / directory / "Player.h") + machinegun = read(GAME_ROOT / "src" / directory / "weapon" / "WeaponMachinegun.cpp") + label = f"{directory} zoom presentation" + + require(player_h, "void\t\t\t\t\tUpdateZoomGuiViewState( void );", label) + for token in ( + "void idPlayer::UpdateZoomGuiViewState( void )", + "weapon == NULL || weapon->GetZoomGui() == NULL", + "renderView != NULL ? renderView->viewaxis : firstPersonViewAxis", + 'weapon->GetZoomGui()->SetStateFloat( "playerYaw", presentedViewAxis.ToAngles().yaw );', + "UpdateZoomGuiViewState();", + "Redraw( gameLocal.time );", + ): + require(player_cpp, token, label) + + draw_hud = player_cpp.index("void idPlayer::DrawHUD( idUserInterface *_hud )") + update_call = player_cpp.index("UpdateZoomGuiViewState();", draw_hud) + debug_read = player_cpp.index('State().GetFloat( "playerYaw" )', draw_hud) + first_redraw = player_cpp.index("Redraw( gameLocal.time );", debug_read) + if update_call > debug_read or update_call > first_redraw: + raise AssertionError(f"{label} updates scope yaw after it is inspected or drawn") + + if "rvWeaponMachinegun::Think" in machinegun or "playerViewAxis.ToAngles().yaw" in machinegun: + raise AssertionError(f"{label} still writes scope yaw from simulation-time weapon state") + + +def main() -> None: + validate_mode("game") + validate_mode("mpgame") + validation = (ROOT / "tools" / "validation" / "openq4_validate.py").read_text(encoding="utf-8") + require(validation, "weapon_zoom_view_alignment.py", "zoom presentation regression wiring") + print("weapon zoom view alignment: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index 45850bec..295636e7 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -407,6 +407,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "aas_settings_contract.py", root / "tools" / "tests" / "arena_campaign.py", root / "tools" / "tests" / "async_drop_client_contract.py", + root / "tools" / "tests" / "base64_input_safety.py", root / "tools" / "tests" / "campaign_split_state_transition.py", root / "tools" / "tests" / "clang_tidy_input_safety.py", root / "tools" / "tests" / "cmdargs_append_contract.py", @@ -429,6 +430,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "level_load_cache.py", root / "tools" / "tests" / "hdr_postprocess_math.py", root / "tools" / "tests" / "idstr_input_safety.py", + root / "tools" / "tests" / "lexer_input_safety.py", root / "tools" / "tests" / "lexer_peek_contract.py", root / "tools" / "tests" / "key_bind_presentation.py", root / "tools" / "tests" / "levelshot_inventory.py", @@ -512,6 +514,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "renderer_advanced_lighting.py", root / "tools" / "tests" / "renderer_budget_contract.py", root / "tools" / "tests" / "renderer_cel_shading.py", + root / "tools" / "tests" / "renderer_demo_projection_safety.py", root / "tools" / "tests" / "renderer_gpu_frame_timing.py", root / "tools" / "tests" / "renderer_gpu_skinning.py", root / "tools" / "tests" / "renderer_mp_flat_items.py", @@ -551,10 +554,13 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "stock_asset_baseline.py", root / "tools" / "tests" / "stock_media_fallbacks.py", root / "tools" / "tests" / "ui_embedded_icons.py", + root / "tools" / "tests" / "ui_cursor_state_safety.py", + root / "tools" / "tests" / "ui_window_state_safety.py", root / "tools" / "tests" / "validation_hardening.py", root / "tools" / "tests" / "vk_shader_header_pin.py", root / "tools" / "tests" / "vk_temporal_resolve_shader_pin.py", root / "tools" / "tests" / "vscode_fast_build.py", + root / "tools" / "tests" / "weapon_zoom_view_alignment.py", ] failures: list[str] = [] for test_script in tests: