From 3557b48e7a48ee31485647866ce3fb2e042ba5a0 Mon Sep 17 00:00:00 2001 From: akacross Date: Tue, 13 Oct 2026 17:45:22 -0400 Subject: [PATCH 1/8] Include where retained UI motion state uses std::uint64_t GCC 16 no longer provides std::uint64_t through the retained UI's other headers, so Motion.h failed to compile on current Linux toolchains. Co-Authored-By: Claude Opus 5.5 --- src/ui/retained/Motion.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/retained/Motion.h b/src/ui/retained/Motion.h index 78762c39b..761f136a1 100644 --- a/src/ui/retained/Motion.h +++ b/src/ui/retained/Motion.h @@ -3,6 +3,8 @@ #include "Document.h" +#include + namespace openq4::ui { using PropertyKey = std::pair; From 842acc25953765d44719a6616f959a5b11dc2197 Mon Sep 17 00:00:00 2001 From: akacross Date: Tue, 13 Oct 2026 17:45:22 -0400 Subject: [PATCH 2/8] Restore format-3 saves and stop v4 restores depending on the build number 0.14.000 rewrote guis/hud.gui and guis/hud_strogg.gui in the same commit that moved the payload to v4. A saved GUI's window tree is positional and the payload does not record which definition wrote it, so the v3 decoder parsed v3 HUD window trees with the v4 definitions. Every v0.10-v0.13.2 save desynced in the player HUD after map teardown (idWinStr::ReadFromSaveGame: invalid string length). The exact v3 HUD definitions now ship under guis/savegame/v3/. For v3 payloads both game libraries read those window trees into a private instance of the v3 definition, with every normal check, then move its state, interaction flags and cursor to the live HUD. Build numbers are commit counts: a source archive stamps 0 and a shallow clone 1. The engine build floor and the liquid-field thresholds now apply to v3 only, so such builds restore their own v4 saves. savegame_gui_layout_contract.py pins every serialized openQ4 GUI by payload version. savegame_restore_matrix.py is an opt-in runtime restore check. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/commit-validation.yml | 2 + .github/workflows/push-verification.yml | 2 + content/baseoq4/pak0/guis/savegame/v3/hud.gui | 2550 +++++++++++++++++ .../pak0/guis/savegame/v3/hud_strogg.gui | 1961 +++++++++++++ docs/dev/release-completion.md | 26 + docs/dev/savegame-compatibility-policy.md | 55 +- docs/dev/savegame-reliability.md | 69 +- src/framework/Session.cpp | 6 +- src/game/gamesys/SaveGame.cpp | 106 +- src/game/gamesys/SaveGame.h | 1 + src/mpgame/gamesys/SaveGame.cpp | 106 +- src/mpgame/gamesys/SaveGame.h | 1 + tools/tests/savegame_gui_layout_contract.py | 298 ++ tools/tests/savegame_restore_matrix.py | 287 ++ tools/tests/validation_hardening.py | 2 + tools/validation/openq4_validate.py | 1 + 16 files changed, 5446 insertions(+), 27 deletions(-) create mode 100644 content/baseoq4/pak0/guis/savegame/v3/hud.gui create mode 100644 content/baseoq4/pak0/guis/savegame/v3/hud_strogg.gui create mode 100644 tools/tests/savegame_gui_layout_contract.py create mode 100644 tools/tests/savegame_restore_matrix.py diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 3ff9a9bd8..91a7e7704 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -319,6 +319,7 @@ jobs: tools/tests/renderer_vulkan_world_interaction_compatibility.py \ tools/tests/rigid_body_recovery_diagnostics.py \ tools/tests/savegame_corruption_contract.py \ + tools/tests/savegame_gui_layout_contract.py \ tools/tests/savegame_pointer_width_safety.py \ tools/tests/savegame_v3_contract.py \ tools/tests/sdl3_input_parity.py \ @@ -688,6 +689,7 @@ jobs: python tools/tests/renderer_vulkan_world_interaction_compatibility.py python tools/tests/rigid_body_recovery_diagnostics.py python tools/tests/savegame_corruption_contract.py + python tools/tests/savegame_gui_layout_contract.py python tools/tests/savegame_pointer_width_safety.py python tools/tests/savegame_v3_contract.py python tools/tests/sdl3_input_parity.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index 564d89a12..392585bbb 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -319,6 +319,7 @@ jobs: tools/tests/renderer_vulkan_world_interaction_compatibility.py \ tools/tests/rigid_body_recovery_diagnostics.py \ tools/tests/savegame_corruption_contract.py \ + tools/tests/savegame_gui_layout_contract.py \ tools/tests/savegame_pointer_width_safety.py \ tools/tests/savegame_v3_contract.py \ tools/tests/sdl3_input_parity.py \ @@ -688,6 +689,7 @@ jobs: python tools/tests/renderer_vulkan_world_interaction_compatibility.py python tools/tests/rigid_body_recovery_diagnostics.py python tools/tests/savegame_corruption_contract.py + python tools/tests/savegame_gui_layout_contract.py python tools/tests/savegame_pointer_width_safety.py python tools/tests/savegame_v3_contract.py python tools/tests/sdl3_input_parity.py diff --git a/content/baseoq4/pak0/guis/savegame/v3/hud.gui b/content/baseoq4/pak0/guis/savegame/v3/hud.gui new file mode 100644 index 000000000..d403389d3 --- /dev/null +++ b/content/baseoq4/pak0/guis/savegame/v3/hud.gui @@ -0,0 +1,2550 @@ +windowDef Desktop +{ + rect 0,0,640,480 + visible 1 + nocursor 1 + menugui 0 + +//----------------------- +// floats and defines +//----------------------- + + definevec4 "ws_on" 1,0.5,0,1 + definevec4 "ws_off" 0.658,0.639,0.376,0.6 + definevec4 "ws_glowon" 0.7,0.8,0.5,1 + definevec4 "ws_glowoff" 0.7,0.8,0.5,0 + definevec4 "ws_grey" 0.190,0.284,0.168,0.6 + definevec4 "typecolor" 0.866,0.921,0.764,1 + definevec4 "typecolor_strogg" 0.988,1,0.784,1 + definevec4 "healthicon" 0.658,0.639,0.376,0.6 + definevec4 "armoricon" 0.658,0.639,0.376,0.6 + definevec4 "healthicon_mp" 0.980,0.168,0.0196,0.8 + definevec4 "armoricon_mp" 0.968,0.752,0.0156,0.8 + definevec4 "white" 1,1,1,1 + + float "objdone" 0 + float "weapswitch" 0 + float "teamstrogg" 0 + float "hidden" 0 + float "deathCount" 0 + + definefloat "showGunInfo" 1 + definefloat "switchLeft" (640 - (("gui::weaponcount" * 30) - 2)) / 2 + +//----------------------- +// init and namedevents +//----------------------- + + onInit { + namedEvent "updateHealth"; + namedEvent "updateArmor"; + namedEvent "updateAmmo"; + namedEvent "clipCheck"; + namedEvent "updatePowerups"; + namedEvent "hideBrackets"; + namedEvent "weaponChange"; + namedEvent "radioChatterDown"; + } + + onNamedEvent hideHud { + set "desktop::hidden" "1" ; + set "p_ammo::visible" "0" ; + set "p_armor::visible" "0" ; + set "p_health::visible" "0" ; + set "p_weapswitch::visible" "0" ; + set "powerups::visible" "0" ; + } + + onNamedEvent showHud { + set "desktop::hidden" "0" ; + set "p_ammo::visible" "1" ; + set "p_armor::visible" "1" ; + set "p_health::visible" "1" ; + set "p_weapswitch::visible" "1" ; + set "powerups::visible" "1" ; + } + + onNamedEvent removeDeathLine { + if ( "gui::gametype" != 2 ) { + set "deathhistory1::text" "$deathhistory2::text"; + set "deathhistory1_fade::text" "$deathhistory2::text"; + set "deathhistory2::text" "$deathhistory3::text"; + set "deathhistory3::text" "$deathhistory4::text"; + if ( "desktop::deathCount" >= 4 ) { + set "desktop::deathCount" "3"; + set "deathhistory4::visible" "0"; + resetTime "deathanim_remove" "0"; + } else if ( "desktop::deathCount" == 3 ) { + set "desktop::deathCount" "2"; + set "deathhistory3::visible" "0"; + resetTime "deathanim_remove" "0"; + } else if ( "desktop::deathCount" == 2 ) { + set "desktop::deathCount" "1"; + set "deathhistory2::visible" "0"; + resetTime "deathanim_remove" "0"; + } else if ( "desktop::deathCount" <= 1 ) { + set "desktop::deathCount" "0"; + set "deathhistory1::visible" "0"; + } + } else { + set "deathhistory1_tourney::text" "$deathhistory2_tourney::text"; + set "deathhistory1_fade_tourney::text" "$deathhistory2_tourney::text"; + + if ( "desktop::deathCount" == 2 ) { + set "desktop::deathCount" "1"; + set "deathhistory2_tourney::visible" "0"; + resetTime "deathanim_remove" "0"; + } else if ( "desktop::deathCount" <= 1 ) { + set "desktop::deathCount" "0"; + set "deathhistory1_tourney::visible" "0"; + } + } + } + + onNamedEvent addDeathLine { + if ( "gui::gametype" != 2 ) { + if ( "desktop::deathCount" >= 4 ) { + namedEvent "desktop::removedeathline"; + } + + if ( "desktop::deathCount" == 3 ) { + set "deathhistory4::text" "$gui::deathinfo"; + set "deathhistory4::visible" "1"; + set "desktop::deathCount" "4"; + } else if ( "desktop::deathCount" == 2 ) { + set "deathhistory3::text" "$gui::deathinfo"; + set "deathhistory3::visible" "1"; + set "desktop::deathCount" "3"; + } else if ( "desktop::deathCount" == 1 ) { + set "deathhistory2::text" "$gui::deathinfo"; + set "deathhistory2::visible" "1"; + set "desktop::deathCount" "2"; + } else { + set "deathhistory1::text" "$gui::deathinfo"; + set "deathhistory1_fade::text" "$gui::deathinfo"; + set "deathhistory1::visible" "1"; + set "desktop::deathCount" "1"; + resetTime "deathanim_remove" "0"; + } + } else { + if ( "desktop::deathCount" >= 2 ) { + namedEvent "desktop::removedeathline"; + } + + if ( "desktop::deathCount" == 1 ) { + set "deathhistory2_tourney::text" "$gui::deathinfo"; + set "deathhistory2_tourney::visible" "1"; + set "desktop::deathCount" "2"; + } else { + set "deathhistory1_tourney::text" "$gui::deathinfo"; + set "deathhistory1_fade_tourney::text" "$gui::deathinfo"; + set "deathhistory1_tourney::visible" "1"; + set "desktop::deathCount" "1"; + resetTime "deathanim_remove" "0"; + } + } + } + + onNamedEvent updateHealth { + if ( "gui::mp" == 0 ) + { + if ( "gui::player_healthpct" < 0.26 ) + { + set "health_valbar::matcolor" "1,0.3,0,0.35" ; + set "health_val::matcolor" "1,0.3,0,0.5" ; + set "health_ekg::matcolor" "0.3,0.05,0,1" ; + set "health_backbar_add::visible" "1" ; + } else { + set "health_valbar::matcolor" "0.698,0.811,0.427,0.2" ; + set "health_val::matcolor" "0.698,0.811,0.427,0.35" ; + set "health_ekg::matcolor" "0.0862,0.105,0.0549,1" ; + set "health_backbar_add::visible" "0" ; + } + } else { + if ( "gui::player_healthpct" < 0.26 ) + { + set "health_backbar_add_mp::visible" "1" ; + } else { + set "health_backbar_add_mp::visible" "0" ; + } + } + + if ( "gui::player_healthdelta" < 0 ) + { + resettime "health_amount" "0" ; + } + } + + onNamedEvent updateArmor { + if ( "gui::player_armordelta" < 0 ) + { + resettime "armor_amount" "0" ; + } + } + +// Not used currently +// +// onNamedEvent updateAmmo { +// if ( "gui::player_ammodelta" < 0 ) +// { +// resettime "ammo_amount" "0" ; +// } +// } + + onNamedEvent weaponChange { + namedEvent "clipCheck"; + +//----------------------- +// per weapon ammo bar colors for mp +//----------------------- + if ( "gui::mp" == 1 ) { + if ( "gui::player_weapon" == 0 ) { + set "ammo_valbar_mp::matcolor" "0,0.85,1,0.2" ; + set "ammo_val_mp::matcolor" "0,0.85,1,0.5" ; + } else if ( "gui::player_weapon" == 1 ) { + set "ammo_valbar_mp::matcolor" "1,1,0,0.2" ; + set "ammo_val_mp::matcolor" "1,1,0,0.5" ; + } else if ( "gui::player_weapon" == 2 ) { + set "ammo_valbar_mp::matcolor" "1,0.55,0,0.2" ; + set "ammo_val_mp::matcolor" "1,0.55,0,0.5" ; + } else if ( "gui::player_weapon" == 3 ) { + set "ammo_valbar_mp::matcolor" "0,0.45,1,0.2" ; + set "ammo_val_mp::matcolor" "0,0.45,1,0.5" ; + } else if ( "gui::player_weapon" == 4 ) { + set "ammo_valbar_mp::matcolor" "0.2,0.56,0.07,0.2" ; + set "ammo_val_mp::matcolor" "0.2,0.56,0.07,0.5" ; + } else if ( "gui::player_weapon" == 5 ) { + set "ammo_valbar_mp::matcolor" "0.6,0.8,0.8,0.2" ; + set "ammo_val_mp::matcolor" "0.6,0.8,0.8,0.5" ; + } else if ( "gui::player_weapon" == 6 ) { + set "ammo_valbar_mp::matcolor" "1,0.25,0,0.2" ; + set "ammo_val_mp::matcolor" "1,0.25,0,0.5" ; + } else if ( "gui::player_weapon" == 7 ) { + set "ammo_valbar_mp::matcolor" "0,1,0,0.2" ; + set "ammo_val_mp::matcolor" "0,1,0,0.5" ; + } else if ( "gui::player_weapon" == 8 ) { + set "ammo_valbar_mp::matcolor" "1,1,0.6,0.2" ; + set "ammo_val_mp::matcolor" "1,1,0.6,0.5" ; + } else if ( "gui::player_weapon" == 9 ) { + set "ammo_valbar_mp::matcolor" "0.77,0.2,1,0.2" ; + set "ammo_val_mp::matcolor" "0.77,0.2,1,0.5" ; + } else if ( "gui::player_weapon" == 10 ) { + set "ammo_valbar_mp::matcolor" "1,0.75,0.25,0.2" ; + set "ammo_val_mp::matcolor" "1,0.75,0.25,0.5" ; + } + } + +//----------------------- +// If we had a valid weapon as out last weapon then show the weapon +// switch icons. This check prevents the indicators from coming up +// when you first start the game. +//----------------------- + + set "p_weapswitch::visible" "0"; + if ( "gui::player_lastweapon" >= 0 && "desktop::hidden" == 0) + { + namedEvent "p_weapswitch::UpdateWeapons"; + set "p_weapswitch::visible" "1"; + set "desktop::weapswitch" "1"; + resettime "p_weapswitch" "0"; + } + } + + onNamedEvent weaponFire { + set "p_weapswitch::visible" "0"; + } + + onNamedEvent clipCheck { + if ( "gui::player_totalammo" == -1 ) { + set "ammo_amount::visible" "0"; + set "ammo_amount_mp::visible" "0"; + set "total_ammo::visible" "0"; + set "ammo_amount_nc::visible" "0"; + } else if ( "gui::player_ammo" == -1 || "gui::player_clip_size" < 2 ) { + set "ammo_amount::visible" "0"; + set "ammo_amount_mp::visible" "1"; + set "total_ammo::visible" "0"; + set "ammo_amount_nc::visible" "1"; + } else { + set "ammo_amount_mp::visible" "1"; + set "ammo_amount::visible" "1"; + set "total_ammo::visible" "1"; + set "ammo_amount_nc::visible" "0"; + } + } + + onNamedEvent itemPickup { + set "p_itempickup::visible" "1" ; + resettime "p_itempickup" "0" ; + } + + onNamedEvent radioChatterUp { + set "p_radio::visible" "1" ; + + transition "rhino::rect" "$rhino::rect" "$d_rhino::rect" "500" ; + transition "rhino_backbar::rect" "$rhino_backbar::rect" "$d_rhino_backbar::rect" "500" ; + } + + onNamedEvent radioChatterDown { + set "p_radio::visible" "0" ; + } + + onNamedEvent showBrackets { + set "brackets::alpha" "1"; + set "brackets::visible" "1"; + stopTransitions "brackets"; + resetTime "brackets" "0"; + set "brackets::notime" "1"; + } + + onNamedEvent fadeBrackets { + resetTime "brackets" "0"; + } + + onNamedEvent hideBrackets { + stopTransitions "brackets"; + resetTime "brackets" "0"; + set "brackets::notime" "1"; + set "brackets::visible" "0"; + } + + onNamedEvent playerHit { + resetTime "hit_indicator" "0"; + } + + onNamedEvent clearPowerups { + set "gui::powerup0_visible" "0"; + set "gui::powerup1_visible" "0"; + set "gui::powerup2_visible" "0"; + set "gui::powerup3_visible" "0"; + set "gui::powerup4_visible" "0"; + set "gui::powerup_flag_visible" "0"; + } + + onNamedEvent fadeout { + resettime "anim_fade" "0" ; + } + + onNamedEvent resetfade { + set "fade::backcolor" "0,0,0,0" ; + set "failedtext::forecolor" "0,0,0,0" ; + } + + onNamedEvent showBossBar { + set "BossBar::visible" "1"; + } + + onNamedEvent hideBossBar { + set "BossBar::visible" "0"; + } + + onNamedEvent showBossShieldBar { + set "p_boss_shield_bar_left::visible" "1"; + set "p_boss_shield_bar_right::visible" "1"; + } + + onNamedEvent hideBossShieldBar { + set "p_boss_shield_bar_left::visible" "0"; + set "p_boss_shield_bar_right::visible" "0"; + } + + onNamedEvent showBossShieldWarn { + set "p_boss_shield_bar_leftwarn::visible" "1"; + set "p_boss_shield_bar_rightwarn::visible" "1"; + set "p_boss_shield_bar_left::visible" "0"; + set "p_boss_shield_bar_right::visible" "0"; + } + + onNamedEvent hideBossShieldWarn { + set "p_boss_shield_bar_leftwarn::visible" "0"; + set "p_boss_shield_bar_rightwarn::visible" "0"; + set "p_boss_shield_bar_left::visible" "1"; + set "p_boss_shield_bar_right::visible" "1"; + } + + onNamedEvent showExit { + set "p_exit_level::visible" "1"; + resettime "anim_levelExitAlert" "0"; + } + + onNamedEvent hideExit { + set "p_exit_level::visible" "0"; + } + + onNamedEvent setTeam_marine { + set "health_valbar_mp::matcolor" "0.698,0.811,0.427,0.2" ; + set "health_val_mp::matcolor" "0.698,0.811,0.427,0.5" ; + + set "armor_valbar_mp::matcolor" "0.698,0.811,0.427,0.2" ; + set "armor_val_mp::matcolor" "0.698,0.811,0.427,0.5" ; + + set "powerup_flag_icon::matcolor" "1,0.482,0.0156,0.8"; + + set "desktop::teamstrogg" "0" ; + } + + onNamedEvent setTeam_strogg { + set "health_valbar_mp::matcolor" "1,0.5,0,0.2" ; + set "health_val_mp::matcolor" "1,0.5,0,0.5" ; + + set "armor_valbar_mp::matcolor" "1,0.5,0,0.2" ; + set "armor_val_mp::matcolor" "1,0.5,0,0.5" ; + + set "powerup_flag_icon::matcolor" "0.415,0.643,0.168,0.8"; + + set "desktop::teamstrogg" "1" ; + } + + onNamedEvent enterFlatbed + { + set "p_flatbed::visible" "1"; + set "p_ammo::visible" "0"; + set "p_health::visible" "0"; + set "p_armor::visible" "0"; + } + + onNamedEvent leaveFlatbed + { + set "p_flatbed::visible" "0"; + set "p_ammo::visible" "1"; + set "p_health::visible" "1"; + set "p_armor::visible" "1"; + } + + onNamedEvent saveMessage { + stoptransitions "quicksave_msg" ; + set "quicksave_msg::forecolor_w" "1" ; + set "quicksave_msg::visible" "1" ; + resettime "quicksave_msg" "0" ; + } + + onNamedEvent missileThreatUp { + set "missile_indicator::visible" "1"; + resetTime "missile_indicator" "0"; + + set "missile_text::visible" "1"; + resetTime "missile_text" "0"; + } + + onNamedEvent missileThreatDown { + set "missile_indicator::visible" "0"; + stoptransitions "missile_indicator"; + set "missile_indicator::matcolor" "1,.1,.1,.7" ; + + set "missile_text::visible" "0"; + stoptransitions "missile_text"; + set "missile_text::forecolor" "1,.1,.1,.7" ; + } + + onNamedEvent showSpeed { + set "speed::visible" "1" ; + } + + onNamedEvent hideSpeed { + set "speed::visible" "0" ; + } + +//----------------------- +// MP speed meter +//----------------------- + + windowDef speed + { + rect 550,70,80,20 + screenalignx right + screenaligny top + text "gui::player_speed" + font "fonts/lowpixel" + textalign 2 + textscale 0.25 + textstyle 1 + forecolor 1,1,1,0.6 + } + + +//----------------------- +// Comment controller - dev only +//----------------------- + onNamedEvent showViewComments { + set "ViewComments::visible" "1"; + } + + onNamedEvent hideViewComments { + set "ViewComments::visible" "0"; + } + + windowDef ViewComments { + rect 2, 5, 638, 200 + screenalignx left + screenaligny top + backcolor 0, 0, 0, 0 + forecolor 0.9, 0.9, 0.0, 0.9 + visible 0 + textscale 0.25 + text "gui::viewcomments" + } + +//----------------------- +// Sound Meter - Dev Only +//----------------------- + windowDef BgSOUND { + rect 0 ,0 ,128 ,64 + screenalignx left + screenaligny top + background "gfx/guis/soundmeter/audiobg" + matcolor 1 ,1 ,1 ,1 + visible ("gui::s_debug" == 1) + } + windowDef OverlaySOUND { + rect 0 ,0 ,128 ,64 + screenalignx left + screenaligny top + background "gfx/guis/soundmeter/audioverlay" + matcolor 1 ,1 ,1 ,1 + visible 0 + } + +//----------------------- +// flatbed truck health +//----------------------- + + windowDef p_flatbed + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible 0 + windowDef flatbed_backbar + { + rect 13,420,125,59 + visible 1 + background "gfx/guis/hud/backbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef p_flatbed_val + { + rect 133 - ( "gui::vehicle_health" * 115 ),430,115,40 + visible 1 + shear 0,-.22 + windowDef flatbed_val + { + rect -116 + ( "gui::vehicle_health" * 115 ),0,118,40 + visible 1 + background "gfx/guis/hud/flatbedbar" + matcolor 0.701,0.815,0.431,0.35 + shear 0,-.22 + } + } + windowDef flatbed_valbar + { + rect 17,430,118,40 + visible 1 + background "gfx/guis/hud/flatbededge" + matcolor 0.701,0.815,0.431,0.4 + shear 0,-.22 + } + windowDef tramheat_backbar + { + rect 143,429,220,48 + visible 0 + background "gfx/guis/hud/veh_backbar2" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef tramheat_mgun + { + rect 237,449,38,20 + background "gfx/guis/vehicles/veh_mgun" + visible 0 + matcolor 0.701,0.815,0.431,0.6 + shear 0,-.22 + matscalex -1 + } + windowDef tramheat_valbar_bg + { + rect 147,430,110,43 + visible 0 + //visible ("gui::tram_overheat" == 0) + background "gfx/guis/hud/veh_backbar2a" + matcolor 0.976,0.756,0.0156,0.298 + shear 0,-.22 + } + windowDef p_ammo_valbar + { + rect 151,430,"gui::tram_heatpct" * 86,43 + visible 0 + //visible ("gui::tram_overheat" == 0) + shear 0,-.22 + windowDef tramheat_valbar + { + rect -4,0,110,43 + visible 1 + background "gfx/guis/hud/veh_backbar2a" + matcolor 0.980,0.760,0.0196,0.5 + shear 0,-.22 + } + } + windowDef tramheat_valbar_bg_oh + { + rect 147,430,110,43 + visible 0 + //visible ("gui::tram_overheat" == 1) + background "gfx/guis/hud/veh_backbar2a" + matcolor 1,0.5,0,0.3*guitable_10_5[time*0.002] + shear 0,-.22 + } + windowDef p_ammo_valbar_oh + { + rect 151,430,"gui::tram_heatpct" * 86,43 + visible 0 + //visible ("gui::tram_overheat" == 1) + shear 0,-.22 + windowDef tramheat_valbar_oh + { + rect -4,0,110,43 + visible 1 + background "gfx/guis/hud/veh_backbar2a" + matcolor 1,0.5,0,0.5*guitable_10_5[time*0.002] + shear 0,-.22 + } + } + windowDef t_tramheat + { + rect 152,433,81,10 + visible 0 + forecolor 1,1,1,0.4 + text "#str_200978" + textscale 0.12 + font "fonts/lowpixel" + } + } + +//----------------------- +// ammo +//----------------------- + + windowDef p_ammo + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible ("gui::mp" == 0 ) + windowDef ammo_backbar + { + rect 13,420,125,59 + visible 1 + background "gfx/guis/hud/backbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ammo_valbar + { + rect 13,420,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.701,0.815,0.431,0.2 + shear 0,-.22 + } + + windowDef p_ammo_val + { + rect 133 - ( "gui::player_ammopct" * 115 ),420,115,59 + visible 1 + shear 0,-.22 + windowDef ammo_val + { + rect -120 + ( "gui::player_ammopct" * 115 ),0,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.701,0.815,0.431,0.35 + shear 0,-.22 + } + } + windowDef current_gun + { + rect 11,435,106,26 + visible "desktop::showGunInfo" != 0 + background "gui::weapicon" + matcolor 0.780,0.741,0.470,0.4 + } + windowDef ammo_amount + { + rect 44,429,49,26 + visible 1 + forecolor 0.866,0.921,0.764,1 + text "gui::player_ammo" + textscale 0.5 + font "fonts/chain" + shear 0,-.22 + textalign 2 + } + windowDef total_ammo + { + rect 91,432,40,18 + visible 1 + forecolor 0.866,0.921,0.764,1 + text "gui::player_totalammo" + textscale 0.32 + font "fonts/chain" + shear 0,-.22 + textalign 2 + } + windowDef ammo_amount_nc + { + rect 82,429,49,26 + visible 0 + forecolor 0.866,0.921,0.764,1 + text "gui::player_totalammo" + textscale 0.5 + font "fonts/chain" + shear 0,-.22 + textalign 2 + } + windowDef icon_ammoweapon + { + rect 113,458,16,16 + visible 1 + background "gui::ammoicon" + matcolor 0.658,0.639,0.376,0.6 + } + } + +//----------------------- +// health +//----------------------- + + windowDef p_health + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible ("gui::mp" == 0 ) + windowDef health_backbar + { + rect 190,420,125,59 + visible 1 + background "gfx/guis/hud/backbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef health_valbar + { + rect 190,420,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.701,0.815,0.431,0.2 + shear 0,-.22 + } + windowDef health_ekg + { + rect 190,420,125,59 + visible 1 + background "gfx/guis/hud/ekg" + matcolor 0.0862,0.105,0.0549,1 + shear 0,-.22 + } + windowDef p_health_val + { + rect 310 - ( "gui::player_healthpct" * 115 ),420,115,59 + visible 1 + shear 0,-.22 + windowDef health_val + { + rect -120 + ( "gui::player_healthpct" * 115 ),0,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.701,0.815,0.431,0.35 + shear 0,-.22 + } + } + windowDef d_health_amount + { + rect 254,427,58,31 + visible 0 + } + windowDef health_amount + { + rect 256,429,52,26 + visible 1 + forecolor 0.866,0.921,0.764,1 + text "gui::player_health" + textscale 0.5 + font "fonts/chain" + shear 0,-.22 + textalign 2 + notime 1 + + onTime 0 + { + transition "forecolor" "$desktop::white" "$desktop::typecolor" "250" ; + transition "rect" "$d_health_amount::rect" "$health_amount::rect" "250" ; + transition "textscale" "0.6" "0.5" "250" ; + + transition "icon_health::matcolor" "$desktop::white" "$desktop::healthicon" "250" ; + } + } + windowDef icon_health + { + rect 288,458,16,16 + visible 1 + background "gfx/guis/hud/icons/item_health" + matcolor 0.658,0.639,0.376,0.6 + } + windowDef health_backbar_add + { + rect 190,420,125,59 + visible 0 + background "gfx/guis/hud/backbar_add" + matcolor 1*guitable_10_5[time*0.002],0,0,1 + shear 0,-.22 + } + } + +//----------------------- +// armor +//----------------------- + + windowDef p_armor + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible ("gui::player_armor" > 0 && "gui::mp" == 0 ) + windowDef armor_backbar + { + rect 326,420,125,59 + visible 1 + background "gfx/guis/hud/backbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef armor_valbar + { + rect 326,420,125,59 + visible 1 + background "gfx/guis/hud/arbar" + matcolor 0.698,0.811,0.427,0.2 + shear 0,-.22 + } + windowDef p_armor_val + { + rect 446 - ( "gui::player_armorpct" * 115 ),420,115,59 + visible 1 + shear 0,-.22 + windowDef armor_val + { + rect -120 + ( "gui::player_armorpct" * 115 ),0,125,59 + visible 1 + background "gfx/guis/hud/arbar" + matcolor 0.698,0.811,0.427,0.349 + shear 0,-.22 + } + } + windowDef d_armor_amount + { + rect 390,427,58,31 + visible 0 + } + windowDef armor_amount + { + rect 392,429,52,26 + visible 1 + forecolor 0.866,0.921,0.764,1 + text "gui::player_armor" + textscale 0.5 + font "fonts/chain" + shear 0,-.22 + textalign 2 + notime 1 + + onTime 0 + { + transition "forecolor" "$desktop::white" "$desktop::typecolor" "250" ; + transition "rect" "$d_armor_amount::rect" "$armor_amount::rect" "250" ; + transition "textscale" "0.6" "0.5" "250" ; + + transition "icon_armorvest::matcolor" "$desktop::white" "$desktop::armoricon" "250" ; + } + } + + + windowDef icon_armorvest + { + rect 424,458,16,16 + visible 1 + background "gfx/guis/hud/icons/item_armor" + matcolor 0.658,0.639,0.376,0.6 + } + } + +//----------------------- +// MP ammo +//----------------------- + + windowDef p_ammo_mp + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible ("gui::mp" == 1 ) + windowDef ammo_backbar_mp + { + rect 13,420,125,59 + visible 1 + background "gfx/guis/hud/backbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ammo_valbar_mp + { + rect 13,420,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.5,0.5,0.5,0.2 + shear 0,-.22 + } + + windowDef p_ammo_val_mp + { + rect 133 - ( "gui::player_ammopct" * 115 ),420,115,59 + visible 1 + shear 0,-.22 + windowDef ammo_val_mp + { + rect -120 + ( "gui::player_ammopct" * 115 ),0,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.5,0.5,0.5,0.5 + shear 0,-.22 + } + } + + windowDef ammo_amount_mp + { + rect 81,429,50,26 + visible 1 + forecolor 1,1,1,1 + text "gui::player_totalammo" + textscale 0.5 + font "fonts/chain" + shear 0,-.22 + textalign 2 + textstyle 1 + } + windowDef icon_ammoweapon_mp + { + rect 113,458,16,16 + visible 1 + background "gui::ammoicon" + matcolor 1,1,1,1 + } + } +//----------------------- +// MP health +//----------------------- + + windowDef p_health_mp + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible ("gui::mp" == 1 ) + windowDef health_backbar_mp + { + rect 190,420,125,59 + visible 1 + background "gfx/guis/hud/backbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef health_valbar_mp + { + rect 190,420,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.701,0.815,0.431,0.2 + shear 0,-.22 + } + windowDef health_ekg_mp + { + rect 190,420,125,59 + visible 1 + background "gfx/guis/hud/ekg" + matcolor 0.0862,0.105,0.0549,1 + shear 0,-.22 + } + windowDef p_health_val_mp + { + rect 310 - ( "gui::player_healthpct" * 115 ),420,115,59 + visible 1 + shear 0,-.22 + windowDef health_val_mp + { + rect -120 + ( "gui::player_healthpct" * 115 ),0,125,59 + visible 1 + background "gfx/guis/hud/valbar" + matcolor 0.701,0.815,0.431,0.5 + shear 0,-.22 + } + } + windowDef d_health_amount_mp + { + rect 254,427,58,31 + visible 0 + } + windowDef health_amount_mp + { + rect 258,429,50,26 + visible 1 + forecolor 1,1,1,1 + text "gui::player_health" + textscale 0.5 + shear 0,-.22 + textalign 2 + notime 1 + textstyle 1 + + onTime 0 { + if ( "desktop::teamstrogg" == 0 ) + { + transition "forecolor" "$desktop::white" "$desktop::typecolor" "250" ; + } else { + transition "forecolor" "$desktop::white" "$desktop::typecolor_strogg" "250" ; + } + transition "rect" "$d_health_amount::rect" "$health_amount::rect" "250" ; + transition "textscale" "0.6" "0.5" "250" ; + + transition "icon_health::matcolor" "$desktop::white" "$desktop::healthicon_mp" "250" ; + } + + } + windowDef icon_health_mp + { + rect 288,458,16,16 + visible 1 + background "gfx/guis/hud/icons/item_health" + matcolor 0.968,0.752,0.0156,0.8 + } + windowDef health_backbar_add_mp + { + rect 190,420,125,59 + visible 0 + background "gfx/guis/hud/backbar_add" + matcolor 1*guitable_10_5[time*0.002],0,0,1 + shear 0,-.22 + } + } + +//----------------------- +// MP armor +//----------------------- + + windowDef p_armor_mp + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible ("gui::player_armor" > 0 && "gui::mp" == 1 ) + windowDef armor_backbar_mp + { + rect 326,420,125,59 + visible 1 + background "gfx/guis/hud/backbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef armor_valbar_mp + { + rect 326,420,125,59 + visible 1 + background "gfx/guis/hud/arbar" + matcolor 0.698,0.811,0.427,0.2 + shear 0,-.22 + } + windowDef p_armor_val_mp + { + rect 446 - ( "gui::player_armorpct" * 115 ),420,115,59 + visible 1 + shear 0,-.22 + windowDef armor_val_mp + { + rect -120 + ( "gui::player_armorpct" * 115 ),0,125,59 + visible 1 + background "gfx/guis/hud/arbar" + matcolor 0.698,0.811,0.427,0.5 + shear 0,-.22 + } + } + windowDef d_armor_amount_mp + { + rect 388,426,60,34 + visible 0 + } + windowDef armor_amount_mp + { + rect 394,429,50,26 + visible 1 + forecolor 1,1,1,1 + text "gui::player_armor" + textscale 0.5 + shear 0,-.22 + textalign 2 + notime 1 + textstyle 1 + + onTime 0 { + if ( "desktop::teamstrogg" == 0 ) + { + transition "forecolor" "$desktop::white" "$desktop::typecolor" "250" ; + } else { + transition "forecolor" "$desktop::white" "$desktop::typecolor_strogg" "250" ; + } + transition "rect" "$d_armor_amount::rect" "$armor_amount::rect" "250" ; + transition "textscale" "0.6" "0.5" "250" ; + + transition "icon_armorvest::matcolor" "$desktop::white" "$desktop::armoricon_mp" "250" ; + } + + } + windowDef icon_armorvest_mp + { + rect 424,458,16,16 + visible 1 + background "gfx/guis/hud/icons/item_armor" + matcolor 0.980,0.168,0.0196,0.8 + } + } + +//----------------------- +// weapon switching +//----------------------- + + windowDef p_weapswitch + { + rect 0,350,640,80 + screenalignx middle + screenaligny bottom + visible 0 + notime 1 + + onNamedEvent UpdateWeapons { + if ( "gui::weapon0" ) { + set "ws_weapon0::visible" "1"; + if ( "gui::weapon0_ammo" != 0 ) { + set "ws_weapon0_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 0 ) { + set "ws_weapon0_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon0_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon0_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon0_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon0_noammo::visible" "1"; + set "ws_weapon0_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon0_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon0::visible" "0"; + } + + if ( "gui::weapon1" ) { + set "ws_weapon1::visible" "1"; + if ( "gui::weapon1_ammo" != 0 ) { + set "ws_weapon1_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 1 ) { + set "ws_weapon1_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon1_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon1_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon1_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon1_noammo::visible" "1"; + set "ws_weapon1_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon1_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon1::visible" "0"; + } + + if ( "gui::weapon2" ) { + set "ws_weapon2::visible" "1"; + if ( "gui::weapon2_ammo" != 0 ) { + set "ws_weapon2_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 2 ) { + set "ws_weapon2_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon2_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon2_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon2_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon2_noammo::visible" "1"; + set "ws_weapon2_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon2_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon2::visible" "0"; + } + + if ( "gui::weapon3" ) { + set "ws_weapon3::visible" "1"; + if ( "gui::weapon3_ammo" != 0 ) { + set "ws_weapon3_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 3 ) { + set "ws_weapon3_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon3_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon3_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon3_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon3_noammo::visible" "1"; + set "ws_weapon3_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon3_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon3::visible" "0"; + } + + if ( "gui::weapon4" ) { + set "ws_weapon4::visible" "1"; + if ( "gui::weapon4_ammo" != 0 ) { + set "ws_weapon4_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 4 ) { + set "ws_weapon4_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon4_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon4_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon4_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon4_noammo::visible" "1"; + set "ws_weapon4_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon4_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon4::visible" "0"; + } + + if ( "gui::weapon5" ) { + set "ws_weapon5::visible" "1"; + if ( "gui::weapon5_ammo" != 0 ) { + set "ws_weapon5_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 5 ) { + set "ws_weapon5_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon5_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon5_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon5_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon5_noammo::visible" "1"; + set "ws_weapon5_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon5_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon5::visible" "0"; + } + + if ( "gui::weapon6" ) { + set "ws_weapon6::visible" "1"; + if ( "gui::weapon6_ammo" != 0 ) { + set "ws_weapon6_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 6 ) { + set "ws_weapon6_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon6_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon6_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon6_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon6_noammo::visible" "1"; + set "ws_weapon6_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon6_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon6::visible" "0"; + } + + if ( "gui::weapon7" ) { + set "ws_weapon7::visible" "1"; + if ( "gui::weapon7_ammo" != 0 ) { + set "ws_weapon7_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 7 ) { + set "ws_weapon7_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon7_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon7_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon7_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon7_noammo::visible" "1"; + set "ws_weapon7_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon7_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon7::visible" "0"; + } + + if ( "gui::weapon8" ) { + set "ws_weapon8::visible" "1"; + if ( "gui::weapon8_ammo" != 0 ) { + set "ws_weapon8_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 8 ) { + set "ws_weapon8_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon8_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon8_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon8_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon8_noammo::visible" "1"; + set "ws_weapon8_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon8_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon8::visible" "0"; + } + + if ( "gui::weapon9" ) { + set "ws_weapon9::visible" "1"; + if ( "gui::weapon9_ammo" != 0 ) { + set "ws_weapon9_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 9 ) { + set "ws_weapon9_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon9_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon9_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon9_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon9_noammo::visible" "1"; + set "ws_weapon9_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon9_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon9::visible" "0"; + } + + if ( "gui::weapon10" ) { + set "ws_weapon10::visible" "1"; + if ( "gui::weapon10_ammo" != 0 ) { + set "ws_weapon10_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 10 ) { + set "ws_weapon10_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon10_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon10_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon10_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon10_noammo::visible" "1"; + set "ws_weapon10_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon10_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon10::visible" "0"; + } + } + + onTime 1000 { + set "notime" "0"; + set "visible" "0"; + set "desktop::weapswitch" "0"; + } + + windowDef ws_name + { + rect 0,42,640,40 + forecolor 0.690,0.784,0.568,1 + textalign 1 + font "fonts/marine" + textscale .25 + textstyle 1 + text "gui::weaponname" + } + + windowDef ws_weapon0 + { + rect ("desktop::switchLeft" + "gui::weapon0_index" * 30),20,26,26 + windowDef ws_weapon0_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon0_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon0_icon + { + rect 4,4,18,18 + background "gui::weapon0_icon" + } + windowDef ws_weapon0_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon1 + { + rect ("desktop::switchLeft" + "gui::weapon1_index" * 30),20,26,26 + windowDef ws_weapon1_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon1_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon1_icon + { + rect 4,4,18,18 + background "gui::weapon1_icon" + } + windowDef ws_weapon1_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + + } + windowDef ws_weapon2 + { + rect ("desktop::switchLeft" + "gui::weapon2_index" * 30),20,26,26 + windowDef ws_weapon2_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon2_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon2_icon + { + rect 4,4,18,18 + background "gui::weapon2_icon" + } + windowDef ws_weapon2_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + + windowDef ws_weapon3 + { + rect ("desktop::switchLeft" + "gui::weapon3_index" * 30),20,26,26 + windowDef ws_weapon3_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon3_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon3_icon + { + rect 4,4,18,18 + background "gui::weapon3_icon" + } + windowDef ws_weapon3_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon4 + { + rect ("desktop::switchLeft" + "gui::weapon4_index" * 30),20,26,26 + windowDef ws_weapon4_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon4_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon4_icon + { + rect 4,4,18,18 + background "gui::weapon4_icon" + } + windowDef ws_weapon4_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon5 + { + rect ("desktop::switchLeft" + "gui::weapon5_index" * 30),20,26,26 + windowDef ws_weapon5_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon5_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon5_icon + { + rect 4,4,18,18 + background "gui::weapon5_icon" + } + windowDef ws_weapon5_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon6 + { + rect ("desktop::switchLeft" + "gui::weapon6_index" * 30),20,26,26 + windowDef ws_weapon6_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon6_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon6_icon + { + rect 4,4,18,18 + background "gui::weapon6_icon" + } + windowDef ws_weapon6_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon7 + { + rect ("desktop::switchLeft" + "gui::weapon7_index" * 30),20,26,26 + windowDef ws_weapon7_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon7_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon7_icon + { + rect 4,4,18,18 + background "gui::weapon7_icon" + } + windowDef ws_weapon7_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon8 + { + rect ("desktop::switchLeft" + "gui::weapon8_index" * 30),20,26,26 + windowDef ws_weapon8_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon8_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon8_icon + { + rect 4,4,18,18 + background "gui::weapon8_icon" + } + windowDef ws_weapon8_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon9 + { + rect ("desktop::switchLeft" + "gui::weapon9_index" * 30),20,26,26 + windowDef ws_weapon9_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon9_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon9_icon + { + rect 4,4,18,18 + background "gui::weapon9_icon" + } + windowDef ws_weapon9_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon10 + { + rect ("desktop::switchLeft" + "gui::weapon10_index" * 30),20,26,26 + windowDef ws_weapon10_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef ws_weapon10_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,-.22 + } + windowDef ws_weapon10_icon + { + rect 4,4,18,18 + background "gui::weapon10_icon" + } + windowDef ws_weapon10_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + } + +//----------------------- +// radio messages +//----------------------- + + windowDef p_radio + { + rect 0,0,640,480 + screenalignx right + screenaligny top + visible 1 + windowDef radio_backbar + { + rect 520,5,113,28 + visible 1 + background "gfx/guis/hud_rev3/radiobar" + matcolor 0,0,0,0.498 + shear 0,-.22 + } + windowDef t_radio1 + { + rect 545,6,81,12 + visible 1 + forecolor 1,1,1,1 + text "#str_200272" + textscale 0.2 + font "fonts/marine" + textspacing 0 + } + windowDef t_radio2 + { + rect 545,13,81,12 + visible 1 + forecolor 1,1,1,1 + text "#str_200273" + textscale 0.2 + font "fonts/marine" + textspacing 0 + } + windowDef waveform + { + rect 513,7,41,25 + visible 1 + background "gfx/guis/hud/waveform" + matcolor 1,0.5,0,1 + } + windowDef rhino_backbar + { + rect 468,5,46,47 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.4 + shear 0,-.22 + visible "gui::rhinochatter" + } + windowDef rhino + { + rect 471,9,42,39 + background "gfx/guis/hud/radio_rhino" + visible "gui::rhinochatter" + matcolor 1,1,0,1 + notime 1 + shear 0,-.22 + } + windowDef d_rhino_backbar + { + rect 493,6,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.4 + shear 0,-.22 + visible 0 + } + windowDef d_rhino + { + rect 495,8,22,22 + background "gfx/guis/hud/radio_rhino" + visible 0 + matcolor 1,1,1,1 + shear 0,-.22 + } + } + +//----------------------- +// item/ammo pickups +//----------------------- + + windowDef p_itempickup + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible 0 + notime 1 + + onTime 3000 { + set "visible" "0"; + } + + windowDef pickup_backbar + { + rect 21,401,230,24 + visible 1 + background "gfx/guis/hud/pickupbar" + matcolor 0,0,0,0.5 + shear 0,-.22 + } + windowDef t_pickupname + { + rect 42,404,236,15 + visible 1 + forecolor 0.862,0.917,0.760,1 + text "gui::itemtext" + textscale 0.2 + font "fonts/marine" + textspacing 0 + } + windowDef icon_pickup + { + rect 26,405,16,16 + visible 1 + background "gui::itemicon" + matcolor 0.666,0.674,0.435,0.8 + } + } + +//----------------------- +// powerups +//----------------------- + + windowDef powerups + { + rect 520,0,120,480 + screenalignx right + screenaligny bottom + visible ("gui::stat_visible" == 0) + windowDef powerup_flag + { + rect 38,435,84,39 + visible "gui::powerup_flag_visible" + windowDef powerup_flag_bg + { + rect 2,-1,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup_flag_icon + { + rect 23,8,22,22 + visible 1 + background "gfx/guis/hud/ctf_flag" + matcolor 1,0.482,0.0156,0.8 + shear 0,-.22 + + onTime 0 { + transition "powerup_flag_icon::rect" "$powerup_flag_icon::rect" "$d_powerup_flag_icon::rect" "150" ; + } + + onTime +150 { + transition "powerup_flag_icon::rect" "$d_powerup_flag_icon::rect" "$powerup_flag_icon::rect" "500" ; + } + + onTime +500 { + resettime "0" ; + } + + } + windowDef d_powerup_flag_icon + { + rect 20,5,28,28 + visible 0 + background "gfx/guis/hud/ctf_flag" + matcolor 1,0.482,0.0156,0.8 + shear 0,-.22 + } + } + windowDef powerup4 + { + rect 37,400,84,38 + visible "gui::powerup4_visible" + windowDef powerup4_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup4_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup4_icon" + matcolor 1,1,1,1 + } + windowDef powerup4_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup4_time" + forecolor 0.866,0.921,0.764,1 + textscale 0.4 + font "fonts/chain" + shear 0,-.22 + } + } + windowDef powerup0 + { + rect 37,365,84,38 + visible "gui::powerup0_visible" + windowDef powerup0_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup0_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup0_icon" + matcolor 1,1,1,1 + } + windowDef powerup0_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup0_time" + forecolor 0.866,0.921,0.764,1 + textscale 0.4 + font "fonts/chain" + shear 0,-.22 + } + } + windowDef powerup1 + { + rect 37,330,84,38 + visible "gui::powerup1_visible" + windowDef powerup1_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup1_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup1_icon" + matcolor 1,1,1,1 + } + windowDef powerup1_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup1_time" + forecolor 0.866,0.921,0.764,1 + textscale 0.4 + font "fonts/chain" + shear 0,-.22 + } + } + windowDef powerup2 + { + rect 37,295,84,38 + visible "gui::powerup2_visible" + windowDef powerup2_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup2_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup2_icon" + matcolor 1,1,1,1 + } + windowDef powerup2_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup2_time" + forecolor 0.866,0.921,0.764,1 + textscale 0.4 + font "fonts/chain" + shear 0,-.22 + } + } + windowDef powerup3 + { + rect 37,260,84,38 + visible "gui::powerup3_visible" + windowDef powerup3_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup3_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup3_icon" + matcolor 1,1,1,1 + } + windowDef powerup3_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup3_time" + forecolor 0.866,0.921,0.764,1 + textscale 0.4 + font "fonts/chain" + shear 0,-.22 + } + } + } + +//----------------------- +// death messages for mp +//----------------------- + + windowDef deathanim_remove + { + visible 0 + rect 0,0,1,1 + + onTime 0 { + set "deathhistory1::visible" "1"; + set "deathhistory1_fade::visible" "0"; + } + + onTime +2000 { + set "deathhistory1::visible" "0"; + set "deathhistory1_fade::visible" "1"; + set "deathhistory1_fade::forecolor" "1,1,.552,1"; + transition "deathhistory1_fade::forecolor_w" "1" "0" 1000; + } + + onTime +1100 { + set "deathhistory1_fade::visible" "0"; + namedEvent "removeDeathLine"; + } + + } + windowDef p_deathhistory + { + rect 351,5,284,70 + screenalignx right + screenaligny top + visible ("gui::gametype" !=2) + windowDef deathhistory1 + { + rect 0,3,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + windowDef deathhistory1_fade + { + rect 0,3,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + windowDef deathhistory2 + { + rect 0,18,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + windowDef deathhistory3 + { + rect 0,33,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + windowDef deathhistory4 + { + rect 0,48,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + } + windowDef p_deathhistory_tourney + { + rect 351,5,284,70 + screenalignx right + screenaligny top + visible ("gui::gametype" ==2) + windowDef deathhistory1_tourney + { + rect 0,33,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + windowDef deathhistory1_fade_tourney + { + rect 0,33,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + windowDef deathhistory2_tourney + { + rect 0,48,282,18 + forecolor 1,1,.552,1 + textscale 0.25 + font "fonts/lowpixel" + visible 0 + textstyle 1 + textalign 2 + nowrap 1 + } + } + +//----------------------- +// interactive gui brackets +//----------------------- + + windowDef brackets + { + visible 0 + rect "gui::bracket_left" - 10, "gui::bracket_top" - 10, "gui::bracket_width" + 20, "gui::bracket_height" + 20 + noclip 1 + notime 1 + float "alpha" 1 + + onTime 400 { + transition "brackets::alpha" "1" "0" 400; + } + + onTime +400 { + set "visible" "0"; + } + + windowDef bracket_text + { + rect -50,-30,"gui::bracket_width"+120,30 + text "#str_200277" + font "fonts/marine" + textalign 1 + forecolor 0.686,0.870,0.564,"brackets::alpha" + textscale .25 + } + windowDef left_bracket + { + rect 0,0,2,"gui::bracket_height" + 20 + backcolor 0.686,0.870,0.564,"brackets::alpha" + noclip 1 + windowDef lbt_prong + { + rect 2,0,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + windowDef lbb_prong + { + rect 2,"gui::bracket_height"+18,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + } + windowDef right_bracket + { + rect "gui::bracket_width" + 18,0,2,"gui::bracket_height" + 20 + backcolor 0.686,0.870,0.564,"brackets::alpha" + visible 1 + noclip 1 + windowDef rbt_prong + { + rect -5,0,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + windowDef rbb_prong + { + rect -5,"gui::bracket_height"+18,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + } + } + +//----------------------- +// boss bar +//----------------------- + + windowDef Bossbar + { + rect -20,0,640,480 + screenalignx middle + screenaligny top + visible 0 + windowDef boss_backbar + { + rect 240,40,200,50 + visible 1 + background "gfx/guis/hud/s_bossbar" + matcolor 0,0,0,0.5 + } + windowDef boss_healthicon + { + rect 334,75,12,12 + visible 1 + background "gfx/guis/hud/icons/item_health" + matcolor 1,1,1,0.2 + } + windowDef boss_health_bar2 + { + rect 239,40,200,50 + visible 1 + background "gfx/guis/hud/s_boss_healthbar" + matcolor 0.917,0.560,0.0823,0.2 + } + windowDef boss_shield_bar_left2 + { + rect 240,40,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.2 + } + windowDef boss_shield_bar_right2 + { + rect 340,40,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.2 + matscalex -1 + } + windowDef p_boss_health_bar + { + rect 266,40,149 * "gui::boss_health" / ("gui::boss_maxhealth" + 1),50 + visible 1 + windowDef boss_health_bar + { + rect -26,0,200,50 + visible 1 + background "gfx/guis/hud/s_boss_healthbar" + matcolor 0.917,0.560,0.0823,0.35 + } + } + windowDef p_boss_shield_bar_left + { + rect 250,40,89 * "gui::boss_shield_percent",50 + visible 0 + windowDef boss_shield_bar_left + { + rect -10,0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.35 + } + } + windowDef p_boss_shield_bar_right + { + rect 341 + 89 * ( 1 - "gui::boss_shield_percent" ),40,89,50 + visible 0 + windowDef boss_shield_bar_right + { + rect -90 * ( 1 - "gui::boss_shield_percent" ),0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.35 + matscalex -1 + } + } + windowDef p_boss_shield_bar_leftwarn + { + rect 250,40,89 * "gui::boss_shield_percent",50 + visible 0 + windowDef boss_shield_bar_leftwarn + { + rect -9,0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 1,0.25,0,1*guitable_10_5[time*0.002] + } + } + windowDef p_boss_shield_bar_rightwarn + { + rect 341 + 89 * ( 1 - "gui::boss_shield_percent" ),40,89,50 + visible 0 + windowDef boss_shield_bar_rightwarn + { + rect -90 * ( 1 - "gui::boss_shield_percent" ),0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 1,0.25,0,1*guitable_10_5[time*0.002] + matscalex -1 + } + } + } + +//----------------------- +// missile indicator +//----------------------- + + windowDef missile_indicator + { + rect 0,0,640,480 + background "gfx/guis/hud/directional_hit2" + matcolor 1,.1,.1,.7 + rotate "gui::missiledir" + visible 0 + notime 1 + + onTime 0 { + transition "matcolor_w" "0.7" "0.3" "200" ; + } + + onTime +200 { + transition "matcolor_w" "0.3" "0.7" "200" ; + } + + onTime +400 { + resettime "0" ; + } + + } + windowDef missile_text + { + rect 0,61,640,33 + screenalignx middle + screenaligny top + visible 0 + notime 1 + forecolor 1,.1,.1,.7 + text "#str_200891" + textscale 0.4 + font "fonts/marine" + textalign 1 + textspacing -2 + + onTime 0 { + transition "forecolor_w" "0.7" "0.3" "200" ; + } + + onTime +200 { + transition "forecolor_w" "0.3" "0.7" "200" ; + } + + onTime +400 { + resettime "0" ; + } + + } + +//----------------------- +// exit level status +//----------------------- + + windowDef p_exit_level + { + rect 0,0,640,75 + screenalignx middle + screenaligny top + visible 0 + windowDef p_exit_text + { + rect 225,36,199,26 + visible 1 + forecolor 1,0.4,0,1 + text "#str_200013" + textscale 0.4 + font "fonts/marine" + textalign 1 + } + windowDef p_exit_arr1 + { + rect 261,11,130,33 + visible 1 + background "gfx/guis/common/arrow5" + matcolor 1,0.4,0,0.5 + matscaley -1 + + onTime 0 { + transition "matcolor_w" ".5" "0" "1000" ; + transition "rect" "$p_exit_arr1::rect" "$p_exit_arr2::rect" "1000" "300" "300" ; + } + + onTime 1000 { + resettime "0" ; + } + + } + windowDef p_exit_arr2 + { + rect 261,0,130,33 + visible 0 + background "gfx/guis/common/arrow5" + matcolor 1,1,1,1 + matscaley -1 + } + } + +//----------------------- +// quicksave notice +//----------------------- + + windowDef quicksave_msg + { + rect 0,64,640,20 + screenalignx middle + screenaligny top + visible 0 + forecolor 1,1,1,1 + text "#str_200908" + textscale 0.25 + font "fonts/lowpixel" + textspacing 0 + textalign 1 + textstyle 1 + + onTime 2000 { + transition "forecolor_w" "1" "0" "1000" ; + } + + onTime +1000 { + set "quicksave_msg::visible" "0" ; + } + + } + +//----------------------- +// hit indicator +//----------------------- + + windowDef hit_indicator + { + rect 0,0,640,480 + background "gfx/guis/hud/directional_hit" + matcolor 1,.1,.1,.7 + rotate "gui::hitdir" + visible 0 + notime 1 + + onTime 0 + { + set "visible" "1"; + transition "matcolor" "1,.1,.1,.7" "1,.1,.1,0" "500" ; + } + + onTime +600 + { + set "visible" "0"; + } + } + +//----------------------- +// over top black/failed text +//----------------------- + + windowDef fade + { + rect 0,0,640,480 + backcolor 0,0,0,0 + visible 1 + } + + windowDef failedtext + { + rect 0,220,640,20 + visible 1 + forecolor 1,1,1,0 + text "#str_200279" + textscale 0.5 + font "fonts/marine" + textalign 1 + } + +//----------------------- +// animations +//----------------------- + + windowDef anim_fade + { + rect 0,0,1,1 + visible 1 + notime 1 + + onTime 0 { + transition "fade::backcolor" "0,0,0,0" "0,0,0,1" "3000" ; + } + + onTime 3000 { + transition "failedtext::forecolor" "1,1,1,0" "1,1,1,1" "250" ; + } + } + + windowDef anim_levelExitAlert + { + rect 0,0,0.1,0.1 + visible 0 + notime 1 + + onTime 5000 { + set "p_exit_level::visible" "0"; + } + } + +//------------------------- + +} diff --git a/content/baseoq4/pak0/guis/savegame/v3/hud_strogg.gui b/content/baseoq4/pak0/guis/savegame/v3/hud_strogg.gui new file mode 100644 index 000000000..cfea8778e --- /dev/null +++ b/content/baseoq4/pak0/guis/savegame/v3/hud_strogg.gui @@ -0,0 +1,1961 @@ +windowDef Desktop +{ + rect 0,0,640,480 + visible 1 + nocursor 1 + menugui 0 + +//----------------------- +// floats and defines +//----------------------- + + definevec4 "ws_on" 1,0.8,0,1 + definevec4 "ws_off" 0.960,0.584,0.0705,0.6 + definevec4 "ws_glowon" 0.949,0.678,0.0431,0.6 + definevec4 "ws_glowoff" 0.949,0.678,0.0431,0 + definevec4 "ws_grey" 0.190,0.284,0.168,0.6 + definevec4 "typecolor" 0.988,1,0.784,1 + definevec4 "healthicon" 1,0.8,0,0.6 + definevec4 "armoricon" 1,0.8,0,0.6 + definevec4 "white" 1,1,1,1 + + float "objdone" 0 + float "weapswitch" 0 + float "teamstrogg" 0 + float "hidden" 0 + + definefloat "showGunInfo" 1 + definefloat "switchLeft" (640 - (("gui::weaponcount" * 30) - 2)) / 2 + +//----------------------- +// init and namedevents +//----------------------- + + onInit { + namedEvent "updateHealth"; + namedEvent "updateArmor"; + namedEvent "updateAmmo"; + namedEvent "clipCheck"; + namedEvent "updatePowerups"; + namedEvent "hideBrackets"; + namedEvent "weaponChange"; + namedEvent "radioChatterDown"; + // namedEvent "InitHud"; + } + + onNamedEvent hideHud { + set "desktop::hidden" "1" ; + set "p_ammo::visible" "0" ; + set "p_armor::visible" "0" ; + set "p_health::visible" "0" ; + set "p_weapswitch::visible" "0" ; + set "powerups::visible" "0" ; + } + + onNamedEvent showHud { + set "desktop::hidden" "0" ; + set "p_ammo::visible" "1" ; + set "p_armor::visible" "1" ; + set "p_health::visible" "1" ; + set "p_weapswitch::visible" "1" ; + set "powerups::visible" "1" ; + } + + onNamedEvent updateHealth { + if ( "gui::player_healthpct" < 0.25 ) + { + set "health_valbar::matcolor" "1,0.3,0,0.35" ; + set "health_val::matcolor" "1,0.3,0,0.5" ; + set "health_ekg::matcolor" "0.3,0.05,0,1" ; + set "health_backbar_add::visible" "1" ; + } else { + set "health_valbar::matcolor" "1,0.5,0,0.2" ; + set "health_val::matcolor" "1,0.5,0,0.35" ; + set "health_ekg::matcolor" "0.113,0.0784,0.0117,1" ; + set "health_backbar_add::visible" "0" ; + } + + if ( "gui::player_healthdelta" < 0 ) + { + resettime "health_amount" "0" ; + } + } + + onNamedEvent updateArmor { + if ( "gui::player_armordelta" < 0 ) + { + resettime "armor_amount" "0" ; + } + } + +// onNamedEvent updateAmmo { +// if ( "gui::player_ammodelta" < 0 ) +// { +// resettime "ammo_amount" "0" ; +// } +// } + + onNamedEvent weaponChange { + namedEvent "clipCheck"; + +//----------------------- +// If we had a valid weapon as out last weapon then show the weapon +// switch icons. This check prevents the indicators from coming up +// when you first start the game. +//----------------------- + + set "p_weapswitch::visible" "0"; + if ( "gui::player_lastweapon" >= 0 && "desktop::hidden" == 0) + { + namedEvent "p_weapswitch::UpdateWeapons"; + set "p_weapswitch::visible" "1"; + set "desktop::weapswitch" "1"; + resettime "p_weapswitch" "0"; + } + } + + onNamedEvent weaponFire { + set "p_weapswitch::visible" "0"; + } + + onNamedEvent clipCheck { + if ( "gui::player_totalammo" == -1 ) { + set "ammo_amount::visible" "0"; + set "total_ammo::visible" "0"; + set "ammo_amount_nc::visible" "0"; + } else if ( "gui::player_ammo" == -1 || "gui::player_clip_size" < 2 ) { + set "ammo_amount::visible" "0"; + set "total_ammo::visible" "0"; + set "ammo_amount_nc::visible" "1"; + } else { + set "ammo_amount::visible" "1"; + set "total_ammo::visible" "1"; + set "ammo_amount_nc::visible" "0"; + } + } + + onNamedEvent itemPickup { + //if ( "desktop::weapswitch" == 0 ) { + set "p_itempickup::visible" "1" ; + resettime "p_itempickup" "0" ; + //} + } + + onNamedEvent radioChatterUp { + set "p_radio::visible" "1" ; + + transition "rhino::rect" "$rhino::rect" "$d_rhino::rect" "500" ; + transition "rhino_backbar::rect" "$rhino_backbar::rect" "$d_rhino_backbar::rect" "500" ; + + } + + onNamedEvent radioChatterDown { + set "p_radio::visible" "0" ; + } + + onNamedEvent showBrackets { + set "brackets::alpha" "1"; + set "brackets::visible" "1"; + stopTransitions "brackets"; + resetTime "brackets" "0"; + set "brackets::notime" "1"; + } + + onNamedEvent fadeBrackets { + resetTime "brackets" "0"; + } + + onNamedEvent hideBrackets { + stopTransitions "brackets"; + resetTime "brackets" "0"; + set "brackets::notime" "1"; + set "brackets::visible" "0"; + } + + onNamedEvent playerHit { + resetTime "hit_indicator" "0"; + } + + onNamedEvent updatePowerups { + set "powerup_flag::visible" "0"; + set "powerup_mod::visible" "0"; + set "powerup0::visible" "0"; + set "powerup1::visible" "0"; + set "powerup2::visible" "0"; + set "powerup3::visible" "0"; + + if ( "gui::powerup0_time" > 0 ) { + set "powerup0::visible" "1"; + + if ( "gui::powerup1_time" > 0 ) { + set "powerup1::visible" "1"; + + if ( "gui::powerup2_time" > 0 ) { + set "powerup2::visible" "1"; + if ( "gui::powerup3_time" > 0 ) { + set "powerup2::visible" "1"; + } + } + } + } + + if ( "gui::powerup_flag" == 1 ) { + set "powerup_flag::visible" "1"; + } + + if ( "gui::powerup_mod" == 1 ) { + set "powerup_mod::visible" "1"; + } + } + + onNamedEvent fadeout { + resettime "anim_fade" "0" ; + } + + onNamedEvent resetfade { + set "fade::backcolor" "0,0,0,0" ; + set "failedtext::forecolor" "0,0,0,0" ; + } + + onNamedEvent showBossBar { + set "BossBar::visible" "1"; + } + + onNamedEvent hideBossBar { + set "BossBar::visible" "0"; + } + + onNamedEvent showBossShieldBar { + set "p_boss_shield_bar_left::visible" "1"; + set "p_boss_shield_bar_right::visible" "1"; + } + + onNamedEvent hideBossShieldBar { + set "p_boss_shield_bar_left::visible" "0"; + set "p_boss_shield_bar_right::visible" "0"; + } + + onNamedEvent showBossShieldWarn { + set "p_boss_shield_bar_leftwarn::visible" "1"; + set "p_boss_shield_bar_rightwarn::visible" "1"; + set "p_boss_shield_bar_left::visible" "0"; + set "p_boss_shield_bar_right::visible" "0"; + } + + onNamedEvent hideBossShieldWarn { + set "p_boss_shield_bar_leftwarn::visible" "0"; + set "p_boss_shield_bar_rightwarn::visible" "0"; + set "p_boss_shield_bar_left::visible" "1"; + set "p_boss_shield_bar_right::visible" "1"; + } + + onNamedEvent showExit { + set "p_exit_level::visible" "1"; + resettime "anim_levelExitAlert" "0"; + } + + onNamedEvent hideExit { + set "p_exit_level::visible" "0"; + } + + onNamedEvent enterTram { + set "p_ammo::visible" "0"; + set "p_health::visible" "0"; + set "p_armor::visible" "0"; + set "p_tram::visible" "1"; + } + + onNamedEvent leaveTram { + set "p_ammo::visible" "1"; + set "p_health::visible" "1"; + set "p_armor::visible" "1"; + set "p_tram::visible" "0"; + } + + onNamedEvent saveMessage { + stoptransitions "quicksave_msg" ; + set "quicksave_msg::forecolor_w" "1" ; + set "quicksave_msg::visible" "1" ; + resettime "quicksave_msg" "0" ; + } + +//----------------------- +// Comment controller - dev only +//----------------------- + onNamedEvent showViewComments { + set "ViewComments::visible" "1"; + } + + onNamedEvent hideViewComments { + set "ViewComments::visible" "0"; + } + + windowDef ViewComments { + rect 2, 5, 638, 200 + screenalignx left + screenaligny top + backcolor 0, 0, 0, 0 + forecolor 0.9, 0.9, 0.0, 0.9 + visible 0 + textscale 0.25 + text "gui::viewcomments" + } + +//----------------------- +// Sound Meter - Dev Only +//----------------------- + windowDef BgSOUND { + rect 0 ,0 ,128 ,64 + screenalignx left + screenaligny top + background "gfx/guis/soundmeter/audiobg" + matcolor 1 ,1 ,1 ,1 + visible ("gui::s_debug" == 1) + } + windowDef OverlaySOUND { + rect 0 ,0 ,128 ,64 + screenalignx left + screenaligny top + background "gfx/guis/soundmeter/audioverlay" + matcolor 1 ,1 ,1 ,1 + visible 0 + } + +//----------------------- +// ammo +//----------------------- + + windowDef p_ammo + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible 1 + windowDef ammo_backbar + { + rect 10,419,129,67 + visible 1 + background "gfx/guis/hud/s_backbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ammo_valbar + { + rect 10,419,129,67 + visible 1 + background "gfx/guis/hud/s_valbar" + matcolor 1,0.564,0,0.2 + shear 0,.22 + } + windowDef p_ammo_val + { + rect 134 - ( "gui::player_ammopct" * 119 ),419,119,67 + visible 1 + shear 0,.22 + windowDef ammo_val + { + rect -124 + ( "gui::player_ammopct" * 119 ),0,129,67 + visible 1 + background "gfx/guis/hud/s_valbar" + matcolor 1,0.564,0,0.35 + shear 0,.22 + } + } + windowDef p_current_gun + { + rect 35,435,104,23 + visible "desktop::showGunInfo" != 0 + background "gui::weapicon" + matcolor 0.776,0.737,0.466,0.4 + } + windowDef ammo_amount + { + rect 69,430,60,26 + visible 1 + forecolor 0.988,1,0.784,1 + text "gui::player_ammo" + textscale 0.5 + font "fonts/r_strogg" + textalign 2 + } + windowDef d_ammo_amount + { + rect 58,427,74,32 + visible 0 + } + windowDef total_ammo + { + rect 91,457,40,18 + visible 1 + forecolor 0.988,1,0.784,0.8 + text "gui::player_totalammo" + textscale 0.26 + font "fonts/r_strogg" + textalign 2 + } + windowDef icon_ammoweapon + { + rect 22,433,16,16 + visible 1 + background "gui::ammoicon" + matcolor 1,0.8,0,0.6 + } + windowDef ammo_amount_nc + { + rect 69,430,60,26 + visible 0 + forecolor 0.988,1,0.784,1 + text "gui::player_totalammo" + textscale 0.5 + font "fonts/r_strogg" + textalign 2 + } + windowDef ammo_static + { + rect 10,419,129,67 + visible 1 + background "gfx/guis/hud/s_hudstatic" + matcolor 0.0862,0.0862,0.0862,1 + shear 0,.22 + } + } + +//----------------------- +// tram health +//----------------------- + + windowDef p_tram + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible 0 + windowDef tram_backbar + { + rect 10,419,129,67 + visible 1 + background "gfx/guis/hud/s_backbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef p_tram_valbar + { + rect 129 - ( "gui::tram_healthpct" * 105 ),429,105,45 + visible 1 + windowDef tram_valbar + { + rect -110 + ( "gui::tram_healthpct" * 105 ),0,115,45 + visible 1 + background "gfx/guis/hud/trambar" + matcolor 1,0.564,0,0.2 + matscalex -1 + } + } + windowDef tram_edge + { + rect 19,429,115,45 + visible 1 + background "gfx/guis/hud/tramedge" + matcolor 1,0.564,0,0.4 + matscalex -1 + } + + windowDef tramheat_backbar + { + rect 143,430,220,43 + visible 0 + background "gfx/guis/hud/veh_backbar2" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef tramheat_mgun + { + rect 239,449,38,20 + background "gfx/guis/vehicles/veh_mgun" + visible 0 + matcolor 1,0.564,0,0.6 + shear 0,.22 + matscalex -1 + } + windowDef tramheat_valbar_bg + { + rect 145,430,110,43 + visible 0 + //visible ("gui::tram_overheat" == 0) + background "gfx/guis/hud/veh_backbar2a" + matcolor 0.980,0.760,0.0196,0.3 + shear 0,.22 + } + windowDef p_ammo_valbar + { + rect 147,430,"gui::tram_heatpct" * 88,43 + visible 0 + //visible ("gui::tram_overheat" == 0) + shear 0,.22 + windowDef tramheat_valbar + { + rect -2,0,110,43 + visible 1 + background "gfx/guis/hud/veh_backbar2a" + matcolor 0.980,0.760,0.0196,0.5 + shear 0,.22 + } + } + windowDef tramheat_valbar_bg_oh + { + rect 145,430,110,43 + visible 0 + //visible ("gui::tram_overheat" == 1) + background "gfx/guis/hud/veh_backbar2a" + matcolor 1,0.5,0,0.3*guitable_10_5[time*0.002] + shear 0,.22 + } + windowDef p_ammo_valbar_oh + { + rect 147,430,"gui::tram_heatpct" * 88,43 + visible 0 + //visible ("gui::tram_overheat" == 1) + shear 0,.22 + windowDef tramheat_valbar_oh + { + rect -2,0,110,43 + visible 1 + background "gfx/guis/hud/veh_backbar2a" + matcolor 1,0.5,0,0.5*guitable_10_5[time*0.002] + shear 0,.22 + } + } + windowDef t_tramheat + { + rect 147,433,81,10 + visible 0 + forecolor 1,1,1,0.4 + text "#str_200978" + textscale 0.12 + font "fonts/r_strogg" + } + } + +//----------------------- +// health +//----------------------- + + windowDef p_health + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible 1 + windowDef health_backbar + { + rect 190,419,129,67 + visible 1 + background "gfx/guis/hud/s_backbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef health_valbar + { + rect 190,419,129,67 + visible 1 + background "gfx/guis/hud/s_valbar" + matcolor 1,0.564,0,0.2 + shear 0,.22 + } + windowDef health_ekg + { + rect 190,419,129,67 + visible 1 + background "gfx/guis/hud/s_ekg" + matcolor 0.113,0.0784,0.0117,1 + shear 0,.22 + } + windowDef p_health_val + { + rect 314 - ( "gui::player_healthpct" * 119 ),419,119,67 + visible 1 + shear 0,.22 + windowDef health_val + { + rect -124 + ( "gui::player_healthpct" * 119 ),0,129,67 + visible 1 + background "gfx/guis/hud/s_valbar" + matcolor 1,0.564,0,0.35 + shear 0,.22 + } + } + windowDef health_amount + { + rect 249,429,60,26 + visible 1 + forecolor 0.988,1,0.784,1 + text "gui::player_health" + textscale 0.5 + font "fonts/r_strogg" + textalign 2 + notime 1 + + onTime 0 + { + transition "forecolor" "$desktop::white" "$desktop::typecolor" "250" ; + transition "rect" "$d_health_amount::rect" "$health_amount::rect" "250" ; + transition "textscale" "0.6" "0.5" "250" ; + + transition "icon_health::matcolor" "$desktop::white" "$desktop::healthicon" "250" ; + } + } + windowDef d_health_amount + { + rect 239,427,74,32 + visible 0 + } + windowDef icon_health + { + rect 202,433,16,16 + visible 1 + background "gfx/guis/hud/icons/item_health" + matcolor 1,0.8,0,0.6 + } + windowDef health_static + { + rect 190,419,129,67 + visible 1 + background "gfx/guis/hud/s_hudstatic" + matcolor 0.0862,0.0862,0.0862,1 + shear 0,.22 + } + windowDef health_backbar_add + { + rect 190,419,129,67 + visible 0 + background "gfx/guis/hud/s_backbar_add" + matcolor 1*guitable_10_5[time*0.002],0,0,1 + shear 0,.22 + } + } + +//----------------------- +// armor +//----------------------- + + windowDef p_armor + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible "gui::player_armor" + windowDef armor_backbar + { + rect 330,419,129,67 + visible 1 + background "gfx/guis/hud/s_backbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef armor_valbar + { + rect 330,419,129,67 + visible 1 + background "gfx/guis/hud/s_arbar" + matcolor 1,0.564,0,0.2 + shear 0,.22 + } + windowDef p_armor_val + { + rect 454 - ( "gui::player_armorpct" * 119 ),419,129,67 + visible 1 + shear 0,-.22 + windowDef armor_val + { + rect -124 + ( "gui::player_armorpct" * 119 ),0,129,67 + visible 1 + background "gfx/guis/hud/s_arbar" + matcolor 1,0.564,0,0.35 + shear 0,.22 + } + } + windowDef armor_amount + { + rect 387,429,60,26 + visible 1 + forecolor 0.988,1,0.784,1 + text "gui::player_armor" + textscale 0.5 + font "fonts/r_strogg" + textalign 2 + notime 1 + + onTime 0 + { + transition "forecolor" "$desktop::white" "$desktop::typecolor" "250" ; + transition "rect" "$d_armor_amount::rect" "$armor_amount::rect" "250" ; + transition "textscale" "0.6" "0.5" "250" ; + + transition "icon_armorvest::matcolor" "$desktop::white" "$desktop::armoricon" "250" ; + } + } + windowDef d_armor_amount + { + rect 384,427,69,32 + visible 0 + } + windowDef icon_armorvest + { + rect 342,433,16,16 + visible 1 + background "gfx/guis/hud/icons/item_armor" + matcolor 1,0.8,0,0.6 + } + windowDef armor_static + { + rect 330,419,129,67 + visible 1 + background "gfx/guis/hud/s_hudstatic" + matcolor 0.0862,0.0862,0.0862,1 + shear 0,.22 + } + } + +//----------------------- +// weapon switching +//----------------------- + + windowDef p_weapswitch + { + rect 0,350,640,80 + screenalignx middle + screenaligny bottom + visible 0 + notime 1 + + onNamedEvent UpdateWeapons { + if ( "gui::weapon0" ) { + set "ws_weapon0::visible" "1"; + if ( "gui::weapon0_ammo" != 0 ) { + set "ws_weapon0_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 0 ) { + set "ws_weapon0_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon0_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon0_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon0_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon0_noammo::visible" "1"; + set "ws_weapon0_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon0_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon0::visible" "0"; + } + + if ( "gui::weapon1" ) { + set "ws_weapon1::visible" "1"; + if ( "gui::weapon1_ammo" != 0 ) { + set "ws_weapon1_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 1 ) { + set "ws_weapon1_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon1_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon1_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon1_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon1_noammo::visible" "1"; + set "ws_weapon1_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon1_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon1::visible" "0"; + } + + if ( "gui::weapon2" ) { + set "ws_weapon2::visible" "1"; + if ( "gui::weapon2_ammo" != 0 ) { + set "ws_weapon2_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 2 ) { + set "ws_weapon2_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon2_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon2_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon2_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon2_noammo::visible" "1"; + set "ws_weapon2_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon2_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon2::visible" "0"; + } + + if ( "gui::weapon3" ) { + set "ws_weapon3::visible" "1"; + if ( "gui::weapon3_ammo" != 0 ) { + set "ws_weapon3_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 3 ) { + set "ws_weapon3_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon3_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon3_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon3_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon3_noammo::visible" "1"; + set "ws_weapon3_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon3_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon3::visible" "0"; + } + + if ( "gui::weapon4" ) { + set "ws_weapon4::visible" "1"; + if ( "gui::weapon4_ammo" != 0 ) { + set "ws_weapon4_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 4 ) { + set "ws_weapon4_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon4_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon4_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon4_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon4_noammo::visible" "1"; + set "ws_weapon4_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon4_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon4::visible" "0"; + } + + if ( "gui::weapon5" ) { + set "ws_weapon5::visible" "1"; + if ( "gui::weapon5_ammo" != 0 ) { + set "ws_weapon5_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 5 ) { + set "ws_weapon5_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon5_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon5_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon5_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon5_noammo::visible" "1"; + set "ws_weapon5_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon5_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon5::visible" "0"; + } + + if ( "gui::weapon6" ) { + set "ws_weapon6::visible" "1"; + if ( "gui::weapon6_ammo" != 0 ) { + set "ws_weapon6_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 6 ) { + set "ws_weapon6_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon6_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon6_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon6_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon6_noammo::visible" "1"; + set "ws_weapon6_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon6_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon6::visible" "0"; + } + + if ( "gui::weapon7" ) { + set "ws_weapon7::visible" "1"; + if ( "gui::weapon7_ammo" != 0 ) { + set "ws_weapon7_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 7 ) { + set "ws_weapon7_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon7_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon7_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon7_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon7_noammo::visible" "1"; + set "ws_weapon7_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon7_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon7::visible" "0"; + } + + if ( "gui::weapon8" ) { + set "ws_weapon8::visible" "1"; + if ( "gui::weapon8_ammo" != 0 ) { + set "ws_weapon8_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 8 ) { + set "ws_weapon8_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon8_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon8_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon8_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon8_noammo::visible" "1"; + set "ws_weapon8_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon8_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon8::visible" "0"; + } + + if ( "gui::weapon9" ) { + set "ws_weapon9::visible" "1"; + if ( "gui::weapon9_ammo" != 0 ) { + set "ws_weapon9_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 9 ) { + set "ws_weapon9_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon9_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon9_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon9_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon9_noammo::visible" "1"; + set "ws_weapon9_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon9_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon9::visible" "0"; + } + + if ( "gui::weapon10" ) { + set "ws_weapon10::visible" "1"; + if ( "gui::weapon10_ammo" != 0 ) { + set "ws_weapon10_noammo::visible" "0"; + if ( "gui::player_idealWeapon" != 10 ) { + set "ws_weapon10_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon10_glow::matcolor" "$desktop::ws_glowoff"; + } else { + set "ws_weapon10_icon::matcolor" "$desktop::ws_on"; + set "ws_weapon10_glow::matcolor" "$desktop::ws_glowon"; + } + } else { + set "ws_weapon10_noammo::visible" "1"; + set "ws_weapon10_icon::matcolor" "$desktop::ws_off"; + set "ws_weapon10_glow::matcolor" "$desktop::ws_glowoff"; + } + } else { + set "ws_weapon10::visible" "0"; + } + } + + onTime 1000 { + set "notime" "0"; + set "visible" "0"; + set "desktop::weapswitch" "0"; + } + + windowDef ws_name + { + rect 0,46,640,40 + forecolor 0.988,1,0.784,1 + textalign 1 + font "fonts/r_strogg" + textscale .2 + textstyle 1 + text "gui::weaponname" + } + + windowDef ws_weapon0 + { + rect ("desktop::switchLeft" + "gui::weapon0_index" * 30),20,26,26 + windowDef ws_weapon0_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon0_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon0_icon + { + rect 4,4,18,18 + background "gui::weapon0_icon" + } + windowDef ws_weapon0_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon1 + { + rect ("desktop::switchLeft" + "gui::weapon1_index" * 30),20,26,26 + windowDef ws_weapon1_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon1_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon1_icon + { + rect 4,4,18,18 + background "gui::weapon1_icon" + } + windowDef ws_weapon1_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + + } + windowDef ws_weapon2 + { + rect ("desktop::switchLeft" + "gui::weapon2_index" * 30),20,26,26 + windowDef ws_weapon2_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon2_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon2_icon + { + rect 4,4,18,18 + background "gui::weapon2_icon" + } + windowDef ws_weapon2_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + + windowDef ws_weapon3 + { + rect ("desktop::switchLeft" + "gui::weapon3_index" * 30),20,26,26 + windowDef ws_weapon3_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon3_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon3_icon + { + rect 4,4,18,18 + background "gui::weapon3_icon" + } + windowDef ws_weapon3_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon4 + { + rect ("desktop::switchLeft" + "gui::weapon4_index" * 30),20,26,26 + windowDef ws_weapon4_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon4_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon4_icon + { + rect 4,4,18,18 + background "gui::weapon4_icon" + } + windowDef ws_weapon4_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon5 + { + rect ("desktop::switchLeft" + "gui::weapon5_index" * 30),20,26,26 + windowDef ws_weapon5_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon5_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon5_icon + { + rect 4,4,18,18 + background "gui::weapon5_icon" + } + windowDef ws_weapon5_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon6 + { + rect ("desktop::switchLeft" + "gui::weapon6_index" * 30),20,26,26 + windowDef ws_weapon6_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon6_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon6_icon + { + rect 4,4,18,18 + background "gui::weapon6_icon" + } + windowDef ws_weapon6_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon7 + { + rect ("desktop::switchLeft" + "gui::weapon7_index" * 30),20,26,26 + windowDef ws_weapon7_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon7_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon7_icon + { + rect 4,4,18,18 + background "gui::weapon7_icon" + } + windowDef ws_weapon7_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon8 + { + rect ("desktop::switchLeft" + "gui::weapon8_index" * 30),20,26,26 + windowDef ws_weapon8_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon8_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon8_icon + { + rect 4,4,18,18 + background "gui::weapon8_icon" + } + windowDef ws_weapon8_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon9 + { + rect ("desktop::switchLeft" + "gui::weapon9_index" * 30),20,26,26 + windowDef ws_weapon9_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon9_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon9_icon + { + rect 4,4,18,18 + background "gui::weapon9_icon" + } + windowDef ws_weapon9_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + windowDef ws_weapon10 + { + rect ("desktop::switchLeft" + "gui::weapon10_index" * 30),20,26,26 + windowDef ws_weapon10_bg + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef ws_weapon10_glow + { + rect 0,0,26,26 + background "gfx/guis/hud/wsbarglow" + matcolor 0.690,0.784,0.568,0 + shear 0,.22 + } + windowDef ws_weapon10_icon + { + rect 4,4,18,18 + background "gui::weapon10_icon" + } + windowDef ws_weapon10_noammo + { + rect 4,4,18,18 + background "gfx/guis/hud/noammo" + matcolor 1,1,1,1 + visible 0 + } + } + } + +//----------------------- +// radio messages +//----------------------- + + windowDef p_radio + { + rect 0,0,640,480 + screenalignx right + screenaligny top + visible 0 + windowDef radio_backbar + { + rect 519,6,113,28 + visible 1 + background "gfx/guis/hud_rev3/radiobar" + matcolor 0,0,0,0.498 + shear 0,.22 + } + windowDef t_radio1 + { + rect 546,9,81,12 + visible 1 + forecolor 1,1,1,1 + text "#str_200272" + textscale 0.16 + font "fonts/r_strogg" + textspacing 0 + } + windowDef t_radio2 + { + rect 547,17,81,12 + visible 1 + forecolor 1,1,1,1 + text "#str_200273" + textscale 0.16 + font "fonts/r_strogg" + textspacing 0 + } + windowDef waveform + { + rect 515,8,41,25 + visible 1 + background "gfx/guis/hud/s_waveform" + matcolor 1,0.8,0,1 + } + windowDef rhino_backbar + { + rect 474,6,46,47 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.4 + shear 0,.22 + visible "gui::rhinochatter" + } + windowDef rhino + { + rect 477,10,42,39 + background "gfx/guis/hud/radio_rhino" + visible "gui::rhinochatter" + matcolor 1,1,0,1 + notime 1 + shear 0,.22 + } + windowDef d_rhino_backbar + { + rect 492,7,26,26 + background "gfx/guis/hud/wsbar" + matcolor 0,0,0,0.4 + shear 0,.22 + visible 0 + } + windowDef d_rhino + { + rect 494,9,22,22 + background "gfx/guis/hud/radio_rhino" + visible 0 + matcolor 1,1,1,1 + shear 0,.22 + } + } + +//----------------------- +// item/ammo pickups +//----------------------- + + windowDef p_itempickup + { + rect 0,0,640,480 + screenalignx middle + screenaligny bottom + visible 0 + notime 1 + + onTime 3000 { + set "visible" "0"; + } + + windowDef pickup_backbar + { + rect 9,397,230,24 + visible 1 + background "gfx/guis/hud/pickupbar" + matcolor 0,0,0,0.5 + shear 0,.22 + } + windowDef t_pickupname + { + rect 31,402,236,11 + visible 1 + forecolor 0.988,1,0.784,1 + text "gui::itemtext" + textscale 0.16 + font "fonts/r_strogg" + textspacing 0 + } + windowDef icon_pickup + { + rect 14,401,16,16 + visible 1 + background "gui::itemicon" + matcolor 1,0.8,0,0.6 + } + } + +//----------------------- +// powerups +//----------------------- + + windowDef powerups + { + rect 520,0,120,480 + screenalignx right + screenaligny bottom + visible 1 + windowDef powerup_flag + { + rect 38,435,84,39 + visible "gui::powerup_flag_visible" + windowDef powerup_flag_bg + { + rect 2,-1,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup_flag_icon + { + rect 23,8,22,22 + visible 1 + background "gfx/guis/hud/ctf_flag" + matcolor 1,0.482,0.0156,0.8 + shear 0,-.22 + + onTime 0 { + transition "powerup_flag_icon::rect" "$powerup_flag_icon::rect" "$d_powerup_flag_icon::rect" "150" ; + } + + onTime +150 { + transition "powerup_flag_icon::rect" "$d_powerup_flag_icon::rect" "$powerup_flag_icon::rect" "500" ; + } + + onTime +500 { + resettime "0" ; + } + + } + windowDef d_powerup_flag_icon + { + rect 20,5,28,28 + visible 0 + background "gfx/guis/hud/ctf_flag" + matcolor 1,0.482,0.0156,0.8 + shear 0,-.22 + } + } + windowDef powerup4 + { + rect 37,400,84,38 + visible "gui::powerup4_visible" + windowDef powerup4_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup4_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup4_icon" + matcolor 1,1,1,1 + } + windowDef powerup4_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup4_time" + forecolor 0.988,1,0.784,1 + textscale 0.4 + font "fonts/r_strogg" + } + } + windowDef powerup0 + { + rect 37,365,84,38 + visible "gui::powerup0_visible" + windowDef powerup0_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup0_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup0_icon" + matcolor 1,1,1,1 + } + windowDef powerup0_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup0_time" + forecolor 0.988,1,0.784,1 + textscale 0.4 + font "fonts/r_strogg" + } + } + windowDef powerup1 + { + rect 37,330,84,38 + visible "gui::powerup1_visible" + windowDef powerup1_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup1_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup1_icon" + matcolor 1,1,1,1 + } + windowDef powerup1_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup1_time" + forecolor 0.988,1,0.784,1 + textscale 0.4 + font "fonts/r_strogg" + } + } + windowDef powerup2 + { + rect 37,295,84,38 + visible "gui::powerup2_visible" + windowDef powerup2_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup2_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup2_icon" + matcolor 1,1,1,1 + } + windowDef powerup2_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup2_time" + forecolor 0.988,1,0.784,1 + textscale 0.4 + font "fonts/r_strogg" + } + } + windowDef powerup3 + { + rect 37,260,84,38 + visible "gui::powerup3_visible" + windowDef powerup3_bg + { + rect 2,0,81,42 + background "gfx/guis/hud/powerup_bgbar" + visible 1 + matcolor 0,0,0,1 + matscalex -1 + matscaley -1 + } + windowDef powerup3_icon + { + rect 21,7,28,28 + visible 1 + background "gui::powerup3_icon" + matcolor 1,1,1,1 + } + windowDef powerup3_time + { + rect 48,7,31,25 + visible 1 + text "gui::powerup3_time" + forecolor 0.988,1,0.784,1 + textscale 0.4 + font "fonts/r_strogg" + } + } + } + +//----------------------- +// interactive gui brackets +//----------------------- + + windowDef brackets + { + visible 0 + rect "gui::bracket_left" - 10, "gui::bracket_top" - 10, "gui::bracket_width" + 20, "gui::bracket_height" + 20 + noclip 1 + notime 1 + float "alpha" 1 + + onTime 400 { + transition "brackets::alpha" "1" "0" 400; + } + + onTime +400 { + set "visible" "0"; + } + + windowDef bracket_text + { + rect -50,-30,"gui::bracket_width"+120,30 + text "#str_200277" + font "fonts/marine" + textalign 1 + forecolor 0.686,0.870,0.564,"brackets::alpha" + textscale .25 + } + windowDef left_bracket + { + rect 0,0,2,"gui::bracket_height" + 20 + backcolor 0.686,0.870,0.564,"brackets::alpha" + noclip 1 + windowDef lbt_prong + { + rect 2,0,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + windowDef lbb_prong + { + rect 2,"gui::bracket_height"+18,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + } + windowDef right_bracket + { + rect "gui::bracket_width" + 18,0,2,"gui::bracket_height" + 20 + backcolor 0.686,0.870,0.564,"brackets::alpha" + visible 1 + noclip 1 + windowDef rbt_prong + { + rect -5,0,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + windowDef rbb_prong + { + rect -5,"gui::bracket_height"+18,5,2 + backcolor 0.686,0.870,0.564,"brackets::alpha" + } + } + } + +//----------------------- +// boss bar +//----------------------- + + windowDef Bossbar + { + rect -20,0,640,480 + screenalignx middle + screenaligny top + visible 0 + windowDef boss_backbar + { + rect 240,40,200,50 + visible 1 + background "gfx/guis/hud/s_bossbar" + matcolor 0,0,0,0.5 + } + windowDef boss_healthicon + { + rect 334,75,12,12 + visible 1 + background "gfx/guis/hud/icons/item_health" + matcolor 1,1,1,0.2 + } + windowDef boss_health_bar2 + { + rect 239,40,200,50 + visible 1 + background "gfx/guis/hud/s_boss_healthbar" + matcolor 0.917,0.560,0.0823,0.2 + } + windowDef boss_shield_bar_left2 + { + rect 240,40,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.2 + } + windowDef boss_shield_bar_right2 + { + rect 340,40,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.2 + matscalex -1 + } + windowDef p_boss_health_bar + { + rect 266,40,149 * "gui::boss_health" / ("gui::boss_maxhealth" + 1),50 + visible 1 + windowDef boss_health_bar + { + rect -26,0,200,50 + visible 1 + background "gfx/guis/hud/s_boss_healthbar" + matcolor 0.917,0.560,0.0823,0.35 + } + } + windowDef p_boss_shield_bar_left + { + rect 250,40,89 * "gui::boss_shield_percent",50 + visible 0 + windowDef boss_shield_bar_left + { + rect -10,0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.35 + } + } + windowDef p_boss_shield_bar_right + { + rect 341 + 89 * ( 1 - "gui::boss_shield_percent" ),40,89,50 + visible 0 + windowDef boss_shield_bar_right + { + rect -90 * ( 1 - "gui::boss_shield_percent" ),0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 0.415,0.549,0.541,0.35 + matscalex -1 + } + } + windowDef p_boss_shield_bar_leftwarn + { + rect 250,40,89 * "gui::boss_shield_percent",50 + visible 0 + windowDef boss_shield_bar_leftwarn + { + rect -9,0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 1,0.25,0,1*guitable_10_5[time*0.002] + } + } + windowDef p_boss_shield_bar_rightwarn + { + rect 341 + 89 * ( 1 - "gui::boss_shield_percent" ),40,89,50 + visible 0 + windowDef boss_shield_bar_rightwarn + { + rect -90 * ( 1 - "gui::boss_shield_percent" ),0,100,50 + visible 1 + background "gfx/guis/hud/s_boss_shieldbar" + matcolor 1,0.25,0,1*guitable_10_5[time*0.002] + matscalex -1 + } + } + } + +//----------------------- +// exit level status +//----------------------- + + windowDef p_exit_level + { + rect 0,0,640,75 + screenalignx middle + screenaligny top + visible 0 + windowDef p_exit_text + { + rect 225,36,199,26 + visible 1 + forecolor 1,0.4,0,1 + text "#str_200013" + textscale 0.4 + font "fonts/marine" + textalign 1 + } + windowDef p_exit_arr1 + { + rect 261,11,130,33 + visible 1 + background "gfx/guis/common/arrow5" + matcolor 1,0.4,0,0.5 + matscaley -1 + + onTime 0 { + transition "matcolor_w" ".5" "0" "1000" ; + transition "rect" "$p_exit_arr1::rect" "$p_exit_arr2::rect" "1000" "300" "300" ; + } + + onTime 1000 { + resettime "0" ; + } + + } + windowDef p_exit_arr2 + { + rect 261,0,130,33 + visible 0 + background "gfx/guis/common/arrow5" + matcolor 1,1,1,1 + matscaley -1 + } + } + +//----------------------- +// quicksave notice +//----------------------- + + windowDef quicksave_msg + { + rect 0,64,640,20 + screenalignx middle + screenaligny top + visible 0 + forecolor 1,1,1,1 + text "#str_200908" + textscale 0.25 + font "fonts/lowpixel" + textspacing 0 + textalign 1 + textstyle 1 + + onTime 2000 { + transition "forecolor_w" "1" "0" "1000" ; + } + + onTime +1000 { + set "quicksave_msg::visible" "0" ; + } + + } + +//----------------------- +// hit indicator +//----------------------- + + windowDef hit_indicator + { + rect 0,0,640,480 + background "gfx/guis/hud/directional_hit" + matcolor 1,.1,.1,.7 + rotate "gui::hitdir" + visible 0 + notime 1 + + onTime 0 + { + set "visible" "1"; + transition "matcolor" "1,.1,.1,.7" "1,.1,.1,0" "500" ; + } + + onTime +600 + { + set "visible" "0"; + } + } + +//----------------------- +// over top black/failed text +//----------------------- + + windowDef fade + { + rect 0,0,640,480 + backcolor 0,0,0,0 + visible 1 + } + + windowDef failedtext + { + rect 0,220,640,20 + visible 1 + forecolor 1,1,1,0 + text "#str_200279" + textscale 0.5 + font "fonts/marine" + textalign 1 + } + +//----------------------- +// animations +//----------------------- + + windowDef anim_fade + { + rect 0,0,1,1 + visible 1 + notime 1 + + onTime 0 { + transition "fade::backcolor" "0,0,0,0" "0,0,0,1" "3000" ; + } + + onTime 3000 { + transition "failedtext::forecolor" "1,1,1,0" "1,1,1,1" "250" ; + } + } + + windowDef anim_levelExitAlert + { + rect 0,0,0.1,0.1 + visible 0 + notime 1 + + onTime 5000 { + set "p_exit_level::visible" "0"; + } + } + +//------------------------- + +} diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index b9bff7240..2d74bc85f 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -2,6 +2,32 @@ ## Post-0.14.000 corrections +- [x] Restore v0.10-v0.13.2 (format 3) saves. 0.14.000 rewrote both player HUD + definitions in the change that introduced format 4, but its format-3 decoder + kept parsing the saved HUD window tree with the new definitions. Every format-3 + save therefore failed part way through its player HUD, after the running map + had been torn down. The verbatim format-3 HUD definitions now ship under + `guis/savegame/v3/`, and both game libraries decode format-3 HUDs through them. + `savegame_gui_layout_contract.py` pins every serialized openQ4 GUI by format, so + an edit to one fails until it is classified. +- [x] Stop format-4 restores depending on the writer's commit count. Builds from a + source archive (build 0) or shallow clone (build 1) refused their own saves in + preflight, and their payloads were read against the v3 liquid-field thresholds. + The build floor and those thresholds now apply to format 3 only. +- [x] Add `savegame_restore_matrix.py`, an opt-in runtime check that restores + existing saves with a staged client, optionally under rewritten build numbers, + and confirms the input files are unchanged. Linux x64 results, including real + v0.13.2 saves that 0.14.000 cannot restore, are in + [Savegame Reliability](savegame-reliability.md#cross-build-restore-matrix-2026-10-13-linux-x64). + Windows, macOS and ARM64 restores were not run. + +Candidate save release wording: Saves from openQ4 0.10 through 0.13.2 load again. +0.14.000 could not restore them, despite its upgrade note saying they would. Your +original save files were never changed, so slots that failed in 0.14.000 work +after updating. HUD animations that were mid-flight when such a save was made +restart on load. Format-4 saves from 0.14.000 are unaffected. Builds compiled from +a source archive or shallow Git clone now load their own saves. + - [x] Reduce repeated hidden HUD animation checks without changing animation phase, appearance or reveal behavior. Eight optimized native UI suites pass. Matched 720p gameplay comparisons in both condition orders add 2.60-4.32% diff --git a/docs/dev/savegame-compatibility-policy.md b/docs/dev/savegame-compatibility-policy.md index 7bf7cbad6..8334b8fc0 100644 --- a/docs/dev/savegame-compatibility-policy.md +++ b/docs/dev/savegame-compatibility-policy.md @@ -8,8 +8,8 @@ load. Reliability mechanisms and implementation detail are documented in | Save payload | Current decision | | --- | --- | -| Version 4, exact wire-ABI stamp, build at or above the verified floor, valid integrity/footer | Current writer and reader; persistent Awakening status | -| Version 3, exact wire-ABI stamp, build at or above the verified floor, valid integrity/footer | Supported format path; build/source drift is diagnostic only | +| Version 4, exact wire-ABI stamp, valid integrity/footer | Current writer and reader; persistent Awakening status; build number is diagnostic only | +| Version 3, exact wire-ABI stamp, build at or above the verified floor, valid integrity/footer | Supported through the v3 decoder, including its player HUD layouts; build/source drift above the floor is diagnostic only | | Version 3, build below the verified floor | Rejected before map teardown | | Version 3, different wire-ABI stamp | Rejected before map teardown | | Version 2, any tuple | Rejected before map teardown | @@ -51,6 +51,33 @@ Retch ownership and effects are rebuilt after every saved entity has restored. The v4 reader instead preserves the original timer, layered effects and animation rates. This is a bounded old-format recovery, not an exported v3 save option. +### Saved GUI Layouts + +A saved GUI's window tree is positional. Its bytes follow the windows, defined +variables, scripts and timeline events parsed from the `.gui` file, and the +payload does not record which definition that was. A serialized GUI definition +is therefore part of the wire format, exactly like a C++ field. + +The change that introduced v4 also rewrote openQ4's `guis/hud.gui` and +`guis/hud_strogg.gui`. Until this was corrected, the v3 decoder read v3 HUD +window trees with the v4 definitions, so every v3 save desynced part way through +its player HUD, after map teardown. 0.14.000 shipped with this defect: its +release note's promise that supported format 3 saves still load was not true. + +Every v3 writer used the same two earlier definitions (unchanged from June 2026 +through v0.13.2), so the payload version identifies them exactly. They ship +unchanged as `guis/savegame/v3/hud.gui` and `guis/savegame/v3/hud_strogg.gui`. +For a v3 payload, both game libraries read those HUD window trees into a private +instance of the v3 definition, with every normal restore check, then move its +state dictionary, interaction flags and cursor to the live HUD. Window-local +animation progress is not carried over; the live HUD starts from its current +definition, as when a map spawns it. No other serialized openQ4 GUI changed +between v3 and v4. + +`tools/tests/savegame_gui_layout_contract.py` pins every serialized openQ4 GUI by +payload version and requires every openQ4 GUI to be classified as serialized or +never serialized. Editing a pinned GUI fails until the edit is classified. + Within v3, build number, generated source hash, and source-file count are recorded for diagnosis but do not reject a save. The policy assumes every wire-incompatible change bumps the gameplay compatibility version. A v3 save from another build is @@ -105,6 +132,13 @@ Within version 3, support is expressed as the oldest build whose layout a verified decoder covers (`SESSION_OPENQ4_SAVEGAME_MINIMUM_SUPPORTED_BUILD`, currently 661). Payloads below it are refused during preflight. +The floor and the per-field thresholds below apply to v3 payloads only. A build +number is the engine's Git commit count, not a schema identity: a source archive +stamps 0, a shallow clone 1, and unrelated branches can share a count. No v4 +layout depends on the writer's build, so a v4 save is never refused or decoded +differently because of it. Before this was corrected, a v4 build without +complete history refused its own saves during preflight. + 661 is where the first of the two player liquid fields entered the save. Builds from 661 upward restore cleanly, including the range that previously failed; below it the only sample available, the released v0.10 payload (build 1), @@ -132,7 +166,8 @@ which answered "present" for every other v3 payload: every save written between those builds read a field its file does not contain and desynced the rest of the restore. Adding a field this way is still discouraged -- a version bump is clearer -- but when it happens the threshold must be recorded here and in both -game trees. +game trees. Version 4 and later payloads must not use build thresholds: commit +counts do not identify a layout, and every v4 writer emits both fields. ## Unstamped Legacy Payloads @@ -152,7 +187,9 @@ whole-file CRC/footer retroactively. Backward compatibility means a newer runtime reading an older save. It is supported only through an explicit decoder or allowlist: -- current v4 and decoded v3 on the exact ABI path at or above the verified build floor; +- current v4 on the exact ABI path, whatever build number its writer stamped; +- decoded v3 on the exact ABI path at or above the verified build floor, + including its v3 player HUD layouts; - fields added within v3, through their recorded per-field build thresholds and legacy empty-physics-frame recognition; - the narrow same-build unstamped Windows x64 legacy path. @@ -211,7 +248,10 @@ before merge. - an object/reference representation changes in a way an existing reader cannot preserve; - native structure layout or raw-write meaning changes; -- SP and MP would otherwise interpret the same version differently; or +- SP and MP would otherwise interpret the same version differently; +- a serialized GUI definition (player HUD, objective, cinematic, weapon or + vehicle GUI) adds, removes or reorders windows, defined variables, scripts, + owned script values, or timeline or named events; or - a valid old payload would be consumed at a different offset. ### A payload-version bump is not normally required when @@ -229,7 +269,10 @@ Every versioned change must update, in one change set: 3. the wire-ABI/raw revision when native layout changes; 4. footer/integrity versions when those structures change; 5. corruption models, raw-write inventory, and ABI contracts; -6. this policy, reliability documentation, and player-facing upgrade notes. +6. the serialized-GUI pins, keeping every changed definition the previous + decoder still needs under `guis/savegame/v/` and in both game libraries' + layout tables; and +7. this policy, reliability documentation, and player-facing upgrade notes. The generated source hash is evidence and diagnosis for v3, not a substitute for this versioning decision. The v0.10 decoder above is a narrowly reviewed repair diff --git a/docs/dev/savegame-reliability.md b/docs/dev/savegame-reliability.md index a98085514..faa5c51fd 100644 --- a/docs/dev/savegame-reliability.md +++ b/docs/dev/savegame-reliability.md @@ -57,11 +57,20 @@ it provides no confidentiality. The description and preview sidecars are not covered by the `.save` CRC; they are validated and committed separately as part of the same slot transaction. -The v4 reader and retained v3 decoder require a matching wire ABI and a build number of at least -661. Build/source differences above that floor are diagnostic, but two player -fields still depend on historical build thresholds: `swimSpeed` at 661 and -`nextLiquidSurfaceSoundTime` at 721. Payload-version discipline remains necessary; -the build count is not a substitute for an explicit future schema revision. +The v4 reader and retained v3 decoder require a matching wire ABI. Only v3 +payloads also require a build number of at least 661, and only v3 player records +consult the historical field thresholds: `swimSpeed` at 661 and +`nextLiquidSurfaceSoundTime` at 721. Build/source differences are otherwise +diagnostic. The build number is the engine's commit count, so it is not a schema +identity and no v4 layout depends on it; payload-version discipline remains the +only way to change a layout. + +Serialized GUIs are part of the layout. A saved GUI's window tree follows the +`.gui` definition the writer parsed, and the payload does not identify that +definition. The v3 decoder therefore restores the player HUDs through the v3 +definitions shipped under `guis/savegame/v3/`, then moves their state to the live +HUD; see the +[compatibility policy](savegame-compatibility-policy.md#saved-gui-layouts). Version 4 appends typed animator playback multipliers in both libraries and Awakening freeze/buff state to SP AI records. It changes no raw-write structure, @@ -73,12 +82,14 @@ after the complete entity graph restores. The defines these fields and the upgrade boundary. Release compilation must use complete engine Git history. A shallow checkout -incorrectly produces build 1, causing the engine to reject its own staged saves -and causing field-presence checks to describe the wrong layout. Starting with -0.13.1, stable version generation rejects incomplete history. Lowering the restore -floor alone would not repair these incorrectly stamped payloads. Older released -build-1 saves are not supported by the current reader; previous snapshot-specific -compatibility claims were withdrawn when actual restores failed. +produces build 1 and a source archive build 0. For v3 that made the engine reject +its own staged saves and made field-presence checks describe the wrong layout. +Starting with 0.13.1, stable version generation rejects incomplete history. v4 +payloads no longer consult the build number, so development, source-archive and +shallow-clone builds restore their own v4 saves and those of complete-history +builds. Older released build-1 v3 saves remain unsupported: their layout is +unknown, and previous snapshot-specific compatibility claims were withdrawn when +actual restores failed. Older releases could also serialize a different class-frame sequence depending on link optimization. The old dispatcher compared member-function addresses, and @@ -276,6 +287,12 @@ Use these rules for future changes: a gameplay-version decision. 6. Update corruption models, ABI contracts, compatibility policy, and release notes with the code change. +7. Treat a serialized GUI definition as schema. `savegame_gui_layout_contract.py` + pins each one by payload version. An edit that changes its window tree needs + a payload bump and its previous definition under `guis/savegame/v/` for + the older decoder. Never decode a GUI positionally with a definition other than + the one its payload version identifies. +8. Never gate a layout on the build number outside the historical v3 thresholds. Parser tightening that rejects values impossible in a valid existing save can remain within a version. A new writer representation is not merely parser @@ -335,9 +352,10 @@ The focused regression set is: python tools/tests/savegame_v3_contract.py python tools/tests/savegame_pointer_width_safety.py python tools/tests/savegame_corruption_contract.py +python tools/tests/savegame_gui_layout_contract.py ``` -All three savegame contracts pass for the current tree. They cover the v3 +All four savegame contracts pass for the current tree. They cover the v3 integrity/transaction model, the exact v0.10 compatibility tuple and decoder, source-declared class-frame ownership, total-file boundary arithmetic, pointer-width safety, bounded and typed restore rules, raw-write inventory, and @@ -358,6 +376,33 @@ The old v2 compatibility claim above has since been withdrawn. The 0.13.1 Window candidate passes fresh manual save, quicksave, restore, and save-after-restore in stock `game/airdefense1`; incorrectly stamped build-1 saves remain rejected. +### Cross-build restore matrix (2026-10-13, Linux x64) + +`tools/tests/savegame_gui_layout_contract.py` joins the focused set above. It fails +on the 0.14.000 tree (no v3 HUD layouts), passes once they ship, and fails again +for a simulated HUD edit or an altered v3 layout. + +`tools/tests/savegame_restore_matrix.py` (opt-in, retail assets) restores existing +saves with a staged client and requires the source files to stay unchanged. It +was run on physical Linux x64 under Wayland with OpenGL, against real v0.13.2 +build-878 player saves (not committed) and fresh map-start fixtures: + +| Runtime | Input | Result | +| --- | --- | --- | +| 0.14.000 release | v3 build 878 (`airdefense1`, `airdefense2`) | Desync in `guis/hud.gui` after map teardown (`idWinStr::ReadFromSaveGame: invalid string length`) | +| 0.14.000 release, loose v3 `hud.gui` beside its paks | v3 build 878 | Restored: the HUD definition alone decides the outcome | +| 0.14.000 release | v4 build 1574 with only the build field set to 1 | Refused in preflight as below build 661 | +| fixed, `debugoptimized` | v3 builds 878 and 1109 (marine and Strogg HUDs), v4 builds 1574 and 1576 | All restored; v3 HUDs report their saved layout | +| fixed | v4 saves restamped to build 0 and 1 | All restored | +| fixed, `debug` (`-O0`) | v4 saves from the `-O2` build, v3 builds 878 and 1109 | All restored, and the `-O2` build restores a `-O0` save | +| fixed, `ui_retained 1` | v3 builds 878 and 1109, v4 build 1574 | All restored | +| fixed | wrong ABI, payload v5, CRC damage, truncation, empty file, v3 build 1 | Each refused in preflight with its own reason; map not torn down | +| v3-only 0.13.2-dev build 1109 | v4 saves | Refused in preflight as unsupported payload version 4 | + +Restores were checked by running at least 60 post-restore frames without an +error, not by inspecting gameplay state. Windows, macOS and ARM64 were not +exercised. + Historical Linux x64/Wayland save/load evidence predates v3 and does not prove the current candidate on physical Linux hardware. ARM64 contract/build results are not ARM64 runtime evidence, and no current v3 macOS runtime result is claimed. diff --git a/src/framework/Session.cpp b/src/framework/Session.cpp index 03bb66fd0..ec02b1e00 100644 --- a/src/framework/Session.cpp +++ b/src/framework/Session.cpp @@ -884,7 +884,11 @@ static bool Session_ValidateSaveGamePayload( idFile *file, const idStr &savePath common->Warning( "Savegame '%s' wire ABI '%s' is incompatible with this '%s' build", savePath.c_str(), payloadWireABI.c_str(), Session_GetSaveGameWireABI() ); valid = false; - } else if ( payloadBuild < SESSION_OPENQ4_SAVEGAME_MINIMUM_SUPPORTED_BUILD ) { + } else if ( payloadVersion == SESSION_OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION && + payloadBuild < SESSION_OPENQ4_SAVEGAME_MINIMUM_SUPPORTED_BUILD ) { + // Only v3 layouts vary with the writer's build. Build numbers are commit + // counts, so a v4 writer built from a source archive or shallow clone + // stamps 0 or 1 and must not be refused for it. common->Warning( "Savegame '%s' was written by an older openQ4 (build %d, below build %d, the oldest this build can restore)", savePath.c_str(), payloadBuild, SESSION_OPENQ4_SAVEGAME_MINIMUM_SUPPORTED_BUILD ); valid = false; diff --git a/src/game/gamesys/SaveGame.cpp b/src/game/gamesys/SaveGame.cpp index 838c6200e..30ca31494 100644 --- a/src/game/gamesys/SaveGame.cpp +++ b/src/game/gamesys/SaveGame.cpp @@ -112,6 +112,33 @@ struct openQ4SaveGameSnapshot_t { static const int OPENQ4_SAVEGAME_BUILD_WITH_PLAYER_SWIM_SPEED = 661; static const int OPENQ4_SAVEGAME_BUILD_WITH_PLAYER_LIQUID_SOUND = 721; +// A saved GUI's window tree is positional: its bytes follow the window, variable +// and script layout parsed from the .gui file, which the save does not record. +// openQ4 commit 38bdc687 rewrote both player HUDs in the same change that moved the +// payload to v4, so every v3 save carries these exact earlier definitions. They +// are kept unchanged under guis/savegame/v3/ only to decode that window tree. +struct openQ4SaveGameGuiLayout_t { + const char *gui; + const char *v3Layout; +}; + +static const openQ4SaveGameGuiLayout_t openQ4SaveGameV3GuiLayouts[] = { + { "guis/hud.gui", "guis/savegame/v3/hud.gui" }, + { "guis/hud_strogg.gui", "guis/savegame/v3/hud_strogg.gui" }, +}; + +static const char *SaveGame_FindGuiLayoutForPayload( const char *gui, int payloadVersion ) { + if ( payloadVersion != OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION ) { + return NULL; + } + for ( int i = 0; i < static_cast( sizeof( openQ4SaveGameV3GuiLayouts ) / sizeof( openQ4SaveGameV3GuiLayouts[0] ) ); i++ ) { + if ( idStr::Icmp( gui, openQ4SaveGameV3GuiLayouts[i].gui ) == 0 ) { + return openQ4SaveGameV3GuiLayouts[i].v3Layout; + } + } + return NULL; +} + static const int MAX_SAVEGAME_OBJECTS = MAX_GENTITIES + MAX_CENTITIES + 4096; static const int MAX_SAVEGAME_DICT_ENTRIES = 16384; @@ -1990,17 +2017,82 @@ void idRestoreGame::ReadUserInterface( idUserInterface *&ui, const idDict *args ReadBool( unique ); ui = uiManager->FindGui( name, true, unique ); if ( ui ) { - if ( ui->ReadFromSaveGame( file ) == false ) { + const char *payloadLayout = SaveGame_FindGuiLayoutForPayload( name, openQ4SaveGameCompatibilityVersion ); + if ( payloadLayout != NULL ) { + ReadUserInterfaceWithLayout( ui, name, payloadLayout ); + } else if ( ui->ReadFromSaveGame( file ) == false ) { Error( "idSaveGame::ReadUserInterface: ui failed to read properly\n" ); - } else { + } // RAVEN BEGIN - UpdateGuiParms( ui, args ); + UpdateGuiParms( ui, args ); // RAVEN END - } } } } +/* +================ +idRestoreGame::ReadUserInterfaceWithLayout + +Reads a GUI whose saved window tree follows an earlier definition than the one +this build loads. A private instance of that definition consumes the window tree +with every normal check, then hands its state dictionary and interaction flags to +the live GUI. Window-local animation progress is not carried over: the live +window tree starts from its definition, as it does when a map spawns the GUI. +================ +*/ +class idScopedSavedLayoutGui { +public: + explicit idScopedSavedLayoutGui( idUserInterface *savedGui ) : gui( savedGui ) { + } + + ~idScopedSavedLayoutGui( void ) { + if ( gui != NULL ) { + uiManager->DeAlloc( gui ); + } + } + + idUserInterface *gui; + +private: + idScopedSavedLayoutGui( const idScopedSavedLayoutGui & ); + idScopedSavedLayoutGui &operator=( const idScopedSavedLayoutGui & ); +}; + +void idRestoreGame::ReadUserInterfaceWithLayout( idUserInterface *ui, const char *name, const char *layout ) { + const int offset = file->Tell(); + // Released on every exit, including the error unwinds a failed read raises. + idScopedSavedLayoutGui scopedSaved( uiManager->FindGui( layout, true, true ) ); + idUserInterface *saved = scopedSaved.gui; + if ( saved == NULL ) { + Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d needs its saved layout '%s', which is missing", + openQ4SaveGameCompatibilityVersion, name, offset, layout ); + } + if ( saved->ReadFromSaveGame( file ) == false ) { + Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d failed to read with its saved layout '%s'", + openQ4SaveGameCompatibilityVersion, name, offset, layout ); + } + + const idDict &savedState = saved->State(); + const idDict liveState = ui->State(); + for ( int i = 0; i < liveState.GetNumKeyVals(); i++ ) { + const idKeyValue *kv = liveState.GetKeyVal( i ); + if ( kv != NULL && savedState.FindKey( kv->GetKey() ) == NULL ) { + ui->DeleteStateVar( kv->GetKey() ); + } + } + for ( int i = 0; i < savedState.GetNumKeyVals(); i++ ) { + const idKeyValue *kv = savedState.GetKeyVal( i ); + ui->SetStateString( kv->GetKey(), kv->GetValue() ); + } + ui->SetInteractive( saved->IsInteractive() ); + ui->SetUniqued( saved->IsUniqued() ); + ui->SetCursor( saved->CursorX(), saved->CursorY() ); + + common->Printf( "Restored v%d gui '%s' through its saved layout '%s' (%d state values; window animations restart)\n", + openQ4SaveGameCompatibilityVersion, name, layout, savedState.GetNumKeyVals() ); +} + // RAVEN BEGIN // abahr /* @@ -2798,6 +2890,12 @@ bool idRestoreGame::HasOpenQ4PlayerLiquidSaveFieldForBuild( int firstBuildWithFi openQ4SaveGameCompatibilityVersion < OPENQ4_SAVEGAME_TYPED_COMPATIBILITY_VERSION ) { return false; } + // Both fields entered the save inside v3, so only a v3 payload can lack them. + // Every later writer emits them, whatever commit count its build was stamped + // with: a source archive stamps 0 and a shallow clone 1. + if ( openQ4SaveGameCompatibilityVersion > OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION ) { + return true; + } return buildNumber >= firstBuildWithField; } diff --git a/src/game/gamesys/SaveGame.h b/src/game/gamesys/SaveGame.h index e630f5b2b..ce0b77693 100644 --- a/src/game/gamesys/SaveGame.h +++ b/src/game/gamesys/SaveGame.h @@ -164,6 +164,7 @@ class idRestoreGame { void ReadModel( idRenderModel *&model ); // RAVEN BEGIN void ReadUserInterface( idUserInterface *&ui, const idDict *args ); + void ReadUserInterfaceWithLayout( idUserInterface *ui, const char *name, const char *layout ); // bdube: material type void ReadMaterialType ( const rvDeclMatType* &matType ); void ReadTable ( const idDeclTable* &table ); diff --git a/src/mpgame/gamesys/SaveGame.cpp b/src/mpgame/gamesys/SaveGame.cpp index 8dea2b067..469dfe7dc 100644 --- a/src/mpgame/gamesys/SaveGame.cpp +++ b/src/mpgame/gamesys/SaveGame.cpp @@ -125,6 +125,33 @@ struct openQ4SaveGameSnapshot_t { static const int OPENQ4_SAVEGAME_BUILD_WITH_PLAYER_SWIM_SPEED = 661; static const int OPENQ4_SAVEGAME_BUILD_WITH_PLAYER_LIQUID_SOUND = 721; +// A saved GUI's window tree is positional: its bytes follow the window, variable +// and script layout parsed from the .gui file, which the save does not record. +// openQ4 commit 38bdc687 rewrote both player HUDs in the same change that moved the +// payload to v4, so every v3 save carries these exact earlier definitions. They +// are kept unchanged under guis/savegame/v3/ only to decode that window tree. +struct openQ4SaveGameGuiLayout_t { + const char *gui; + const char *v3Layout; +}; + +static const openQ4SaveGameGuiLayout_t openQ4SaveGameV3GuiLayouts[] = { + { "guis/hud.gui", "guis/savegame/v3/hud.gui" }, + { "guis/hud_strogg.gui", "guis/savegame/v3/hud_strogg.gui" }, +}; + +static const char *SaveGame_FindGuiLayoutForPayload( const char *gui, int payloadVersion ) { + if ( payloadVersion != OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION ) { + return NULL; + } + for ( int i = 0; i < static_cast( sizeof( openQ4SaveGameV3GuiLayouts ) / sizeof( openQ4SaveGameV3GuiLayouts[0] ) ); i++ ) { + if ( idStr::Icmp( gui, openQ4SaveGameV3GuiLayouts[i].gui ) == 0 ) { + return openQ4SaveGameV3GuiLayouts[i].v3Layout; + } + } + return NULL; +} + static const int MAX_SAVEGAME_OBJECTS = MAX_GENTITIES + MAX_CENTITIES + 4096; static const int MAX_SAVEGAME_DICT_ENTRIES = 16384; @@ -2003,17 +2030,82 @@ void idRestoreGame::ReadUserInterface( idUserInterface *&ui, const idDict *args ReadBool( unique ); ui = uiManager->FindGui( name, true, unique ); if ( ui ) { - if ( ui->ReadFromSaveGame( file ) == false ) { + const char *payloadLayout = SaveGame_FindGuiLayoutForPayload( name, openQ4SaveGameCompatibilityVersion ); + if ( payloadLayout != NULL ) { + ReadUserInterfaceWithLayout( ui, name, payloadLayout ); + } else if ( ui->ReadFromSaveGame( file ) == false ) { Error( "idSaveGame::ReadUserInterface: ui failed to read properly\n" ); - } else { + } // RAVEN BEGIN - UpdateGuiParms( ui, args ); + UpdateGuiParms( ui, args ); // RAVEN END - } } } } +/* +================ +idRestoreGame::ReadUserInterfaceWithLayout + +Reads a GUI whose saved window tree follows an earlier definition than the one +this build loads. A private instance of that definition consumes the window tree +with every normal check, then hands its state dictionary and interaction flags to +the live GUI. Window-local animation progress is not carried over: the live +window tree starts from its definition, as it does when a map spawns the GUI. +================ +*/ +class idScopedSavedLayoutGui { +public: + explicit idScopedSavedLayoutGui( idUserInterface *savedGui ) : gui( savedGui ) { + } + + ~idScopedSavedLayoutGui( void ) { + if ( gui != NULL ) { + uiManager->DeAlloc( gui ); + } + } + + idUserInterface *gui; + +private: + idScopedSavedLayoutGui( const idScopedSavedLayoutGui & ); + idScopedSavedLayoutGui &operator=( const idScopedSavedLayoutGui & ); +}; + +void idRestoreGame::ReadUserInterfaceWithLayout( idUserInterface *ui, const char *name, const char *layout ) { + const int offset = file->Tell(); + // Released on every exit, including the error unwinds a failed read raises. + idScopedSavedLayoutGui scopedSaved( uiManager->FindGui( layout, true, true ) ); + idUserInterface *saved = scopedSaved.gui; + if ( saved == NULL ) { + Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d needs its saved layout '%s', which is missing", + openQ4SaveGameCompatibilityVersion, name, offset, layout ); + } + if ( saved->ReadFromSaveGame( file ) == false ) { + Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d failed to read with its saved layout '%s'", + openQ4SaveGameCompatibilityVersion, name, offset, layout ); + } + + const idDict &savedState = saved->State(); + const idDict liveState = ui->State(); + for ( int i = 0; i < liveState.GetNumKeyVals(); i++ ) { + const idKeyValue *kv = liveState.GetKeyVal( i ); + if ( kv != NULL && savedState.FindKey( kv->GetKey() ) == NULL ) { + ui->DeleteStateVar( kv->GetKey() ); + } + } + for ( int i = 0; i < savedState.GetNumKeyVals(); i++ ) { + const idKeyValue *kv = savedState.GetKeyVal( i ); + ui->SetStateString( kv->GetKey(), kv->GetValue() ); + } + ui->SetInteractive( saved->IsInteractive() ); + ui->SetUniqued( saved->IsUniqued() ); + ui->SetCursor( saved->CursorX(), saved->CursorY() ); + + common->Printf( "Restored v%d gui '%s' through its saved layout '%s' (%d state values; window animations restart)\n", + openQ4SaveGameCompatibilityVersion, name, layout, savedState.GetNumKeyVals() ); +} + // RAVEN BEGIN // abahr /* @@ -2811,6 +2903,12 @@ bool idRestoreGame::HasOpenQ4PlayerLiquidSaveFieldForBuild( int firstBuildWithFi openQ4SaveGameCompatibilityVersion < OPENQ4_SAVEGAME_TYPED_COMPATIBILITY_VERSION ) { return false; } + // Both fields entered the save inside v3, so only a v3 payload can lack them. + // Every later writer emits them, whatever commit count its build was stamped + // with: a source archive stamps 0 and a shallow clone 1. + if ( openQ4SaveGameCompatibilityVersion > OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION ) { + return true; + } return buildNumber >= firstBuildWithField; } diff --git a/src/mpgame/gamesys/SaveGame.h b/src/mpgame/gamesys/SaveGame.h index e630f5b2b..ce0b77693 100644 --- a/src/mpgame/gamesys/SaveGame.h +++ b/src/mpgame/gamesys/SaveGame.h @@ -164,6 +164,7 @@ class idRestoreGame { void ReadModel( idRenderModel *&model ); // RAVEN BEGIN void ReadUserInterface( idUserInterface *&ui, const idDict *args ); + void ReadUserInterfaceWithLayout( idUserInterface *ui, const char *name, const char *layout ); // bdube: material type void ReadMaterialType ( const rvDeclMatType* &matType ); void ReadTable ( const idDeclTable* &table ); diff --git a/tools/tests/savegame_gui_layout_contract.py b/tools/tests/savegame_gui_layout_contract.py new file mode 100644 index 000000000..92056c8dc --- /dev/null +++ b/tools/tests/savegame_gui_layout_contract.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Saved GUI layouts are part of the savegame wire format. + +A saved GUI's window tree is positional. Its bytes follow the windows, defined +variables, scripts and timeline events parsed from the .gui file, and nothing in +the payload records which definition that was. Editing a GUI that the game +serializes therefore changes the byte stream exactly as adding a C++ field does, +but neither the payload version nor the generated source hash notices. + +openQ4 0.14.000 edited both player HUDs in the change that moved the payload to +v4. The v3 decoder handled the new C++ fields and kept parsing the v3 HUD window +tree with the v4 definition, so every v0.10-v0.13.2 save failed part way through +the restore, after the running map had been torn down. + +This contract pins each serialized openQ4 GUI per payload version: + +* the current definition must match its pin for the current payload version; +* where an older decodable version used a different definition, that exact + definition must ship under guis/savegame/v/ and both game modules must map + the GUI to it; and +* every openQ4 GUI must be classified as serialized or never serialized. + +Changing a pinned GUI therefore fails here until the change is classified: either +prove it leaves the saved window tree unchanged and repin, or bump the payload +version and keep the previous definition for its decoder. + +It also checks that only v3 payloads consult the writer's build number. Build +numbers are commit counts: a source archive stamps 0 and a shallow clone 1. +""" + +from __future__ import annotations + +import hashlib +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +PAK0 = ROOT / "content" / "baseoq4" / "pak0" +LAYOUT_ROOT = "guis/savegame" + +# The last release that wrote each older payload version. Used only for an +# optional provenance check when Git history is available. +LAST_RELEASE_FOR_VERSION = {3: "v0.13.2"} + +# Git blob ids (LF-normalized) of every openQ4 GUI the game writes into a save, +# by payload version: player HUDs, objective and cinematic GUIs, weapon and +# vehicle zoom GUIs and vehicle HUDs. +SERIALIZED_GUI_LAYOUTS: dict[str, dict[int, str]] = { + "guis/cinematic.gui": { + 3: "63d5f187afe545a3533f14780605a7e614463f5c", + 4: "63d5f187afe545a3533f14780605a7e614463f5c", + }, + "guis/hud.gui": { + 3: "d403389d3b651fb9dd536bae1cbfe41e950c6f3c", + 4: "51c029611142acc27f7e0e03c2f09791d108994e", + }, + "guis/hud_strogg.gui": { + 3: "cfea8778e2824f7b04348c3bc1f63e6d2fa530bc", + 4: "98430b36f89e9b2887eff70d49980178cca8a0f8", + }, + "guis/vehicles/airdef_cannon.gui": { + 3: "e92ae101f581b82a2885ac970b8755a92e1cbcae", + 4: "e92ae101f581b82a2885ac970b8755a92e1cbcae", + }, + "guis/vehicles/hud.gui": { + 3: "fa96e0421d400e4e880236fe27b623bdfd2e7e2e", + 4: "fa96e0421d400e4e880236fe27b623bdfd2e7e2e", + }, + "guis/vehicles/strogg_noise.gui": { + 3: "d0777427b93ec45c90c6dfdf3ba6352aec981dc6", + 4: "d0777427b93ec45c90c6dfdf3ba6352aec981dc6", + }, + "guis/weapons/machinegun_scope.gui": { + 3: "d2690deda0fc05b110508071a34051631df8c1c9", + 4: "d2690deda0fc05b110508071a34051631df8c1c9", + }, + "guis/weapons/nailgun_scope.gui": { + 3: "ae6876c4199d6d252ea11d80ce284bc6d145d34a", + 4: "ae6876c4199d6d252ea11d80ce284bc6d145d34a", + }, + "guis/weapons/railgun_scope.gui": { + 3: "baaa7a75e8daac09597f201c6836a54dafd19f9b", + 4: "baaa7a75e8daac09597f201c6836a54dafd19f9b", + }, + "guis/wristcomm.gui": { + 3: "1f4d8e60cfcd56a46c86b1226240f7b097f30f37", + 4: "1f4d8e60cfcd56a46c86b1226240f7b097f30f37", + }, + "guis/wristcomm_strogg.gui": { + 3: "226aeadf3791732aaf07b8793edb387ade4be03b", + 4: "226aeadf3791732aaf07b8793edb387ade4be03b", + }, +} + +# openQ4 GUIs that single-player saves never contain: menus, loading screens and +# multiplayer-only GUIs (idPlayer deliberately skips mphud). +NEVER_SERIALIZED_GUIS = { + "guis/arena_menu.gui", + "guis/buymenu.gui", + "guis/campaign_menu.gui", + "guis/demo_menu.gui", + "guis/gameover.gui", + "guis/level_editor_experimental.gui", + "guis/loading/generic.gui", + "guis/loading/intro.gui", + "guis/loading/mplevel.gui", + "guis/mainmenu.gui", + "guis/matchcontrol.gui", + "guis/menu/settings/audio.gui", + "guis/menu/settings/controls.gui", + "guis/menu/settings/game.gui", + "guis/menu/settings/game_hovers.gui", + "guis/menu/settings/popups.gui", + "guis/menu/settings/system.gui", + "guis/mphud.gui", + "guis/mpmain.gui", + "guis/mpmsgmode.gui", + "guis/scoreboard.gui", + "guis/summary.gui", +} + +LAYOUT_TABLE_ENTRY = re.compile(r'\{\s*"(guis/[^"]+\.gui)"\s*,\s*"(guis/savegame/v(\d+)/[^"]+\.gui)"\s*\}') + + +def git_blob_id(data: bytes) -> str: + data = data.replace(b"\r\n", b"\n") + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def read(path: Path) -> str: + if not path.is_file(): + raise AssertionError(f"Required source file not found: {path}") + return path.read_text(encoding="utf-8") + + +def constant(source: str, name: str) -> int: + match = re.search(rf"\b{name}\s*=\s*(\d+)\s*;", source) + if match is None: + raise AssertionError(f"Missing integer constant {name}") + return int(match.group(1)) + + +def shipped_guis() -> dict[str, Path]: + guis: dict[str, Path] = {} + for pak in sorted((ROOT / "content" / "baseoq4").glob("pak*")): + for path in pak.glob("guis/**/*.gui"): + relative = path.relative_to(pak).as_posix() + if relative.startswith(LAYOUT_ROOT + "/"): + continue + if relative in guis: + raise AssertionError(f"{relative} ships in more than one pak") + guis[relative] = path + return guis + + +def layout_table(source: str, context: str) -> dict[str, tuple[str, int]]: + match = re.search(r"openQ4SaveGameV3GuiLayouts\[\]\s*=\s*\{(?P.*?)\n\};", source, re.DOTALL) + if match is None: + return {} + table: dict[str, tuple[str, int]] = {} + for gui, layout, version in LAYOUT_TABLE_ENTRY.findall(match.group("body")): + if gui in table: + raise AssertionError(f"{context} maps {gui} twice") + table[gui.lower()] = (layout, int(version)) + return table + + +def validate_classification(guis: dict[str, Path]) -> None: + serialized = set(SERIALIZED_GUI_LAYOUTS) + overlap = serialized & NEVER_SERIALIZED_GUIS + if overlap: + raise AssertionError(f"GUIs classified both ways: {sorted(overlap)}") + unclassified = sorted(set(guis) - serialized - NEVER_SERIALIZED_GUIS) + if unclassified: + raise AssertionError( + "Unclassified openQ4 GUI(s) " + + ", ".join(unclassified) + + ": add each to SERIALIZED_GUI_LAYOUTS if any save writes it (player, weapon, vehicle " + "or entity GUI), otherwise to NEVER_SERIALIZED_GUIS" + ) + missing = sorted((serialized | NEVER_SERIALIZED_GUIS) - set(guis)) + if missing: + raise AssertionError(f"Classified GUI(s) no longer ship: {missing}") + + +def validate_layout_pins(guis: dict[str, Path], current: int, previous: int, tables: dict[str, dict]) -> None: + for gui, pins in sorted(SERIALIZED_GUI_LAYOUTS.items()): + if set(pins) != {previous, current}: + raise AssertionError( + f"{gui} must be pinned for payload v{previous} and v{current}, got {sorted(pins)}; " + "a payload bump must pin the new version and keep the decoded one" + ) + blob = git_blob_id(guis[gui].read_bytes()) + if blob != pins[current]: + raise AssertionError( + f"{gui} changed (blob {blob}, pinned {pins[current]} for payload v{current}). Its window tree " + "is serialized positionally, so this edit can move every later byte of a save. If it adds, " + "removes or reorders windows, defined vars, scripts, script statements with owned values, " + "timeline or named events, bump the payload version and keep the old definition under " + f"{LAYOUT_ROOT}/v{current}/ for its decoder. Repin only after proving the window tree is unchanged." + ) + + expected: dict[str, tuple[str, int]] = {} + for gui, pins in sorted(SERIALIZED_GUI_LAYOUTS.items()): + if pins[previous] == pins[current]: + continue + layout = f"{LAYOUT_ROOT}/v{previous}/{gui[len('guis/'):]}" + layout_path = PAK0 / layout + if not layout_path.is_file(): + raise AssertionError( + f"Payload v{previous} saves contain {gui} as pinned {pins[previous]}, but {layout} is missing; " + f"the v{previous} decoder would parse their window tree with the v{current} definition" + ) + layout_blob = git_blob_id(layout_path.read_bytes()) + if layout_blob != pins[previous]: + raise AssertionError(f"{layout} is {layout_blob}, not the pinned v{previous} definition {pins[previous]}") + expected[gui] = (layout, previous) + + for context, table in tables.items(): + if table != expected: + raise AssertionError(f"{context} v{previous} GUI layout table {table} does not match the pins {expected}") + + shipped_layouts = { + path.relative_to(PAK0).as_posix() for path in (PAK0 / LAYOUT_ROOT).glob("**/*.gui") + } if (PAK0 / LAYOUT_ROOT).is_dir() else set() + orphaned = shipped_layouts - {layout for layout, _ in expected.values()} + if orphaned: + raise AssertionError(f"Saved-GUI layouts no decoder uses: {sorted(orphaned)}") + + +def validate_release_provenance(previous: int) -> None: + """Optional: the decoder's pins must be what the last such release shipped.""" + tag = LAST_RELEASE_FOR_VERSION.get(previous) + if tag is None: + raise AssertionError(f"No last-release tag recorded for payload v{previous}") + for gui, pins in sorted(SERIALIZED_GUI_LAYOUTS.items()): + result = subprocess.run( + ["git", "-C", str(ROOT), "rev-parse", "--verify", "--quiet", f"{tag}:content/baseoq4/pak0/{gui}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"savegame_gui_layout_contract: {tag} not available; skipped release provenance") + return + if result.stdout.strip() != pins[previous]: + raise AssertionError(f"{gui} v{previous} pin {pins[previous]} is not what {tag} shipped ({result.stdout.strip()})") + + +def validate_build_number_scope(session: str, games: dict[str, str]) -> None: + if re.search( + r"payloadVersion\s*==\s*SESSION_OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION\s*&&\s*" + r"payloadBuild\s*<\s*SESSION_OPENQ4_SAVEGAME_MINIMUM_SUPPORTED_BUILD", + session, + ) is None: + raise AssertionError("Engine preflight applies the v3 build floor to payloads whose layout has no build gate") + for context, source in games.items(): + body = re.search( + r"bool idRestoreGame::HasOpenQ4PlayerLiquidSaveFieldForBuild\( int firstBuildWithField \) const \{(?P.*?)\n\}", + source, + re.DOTALL, + ) + if body is None: + raise AssertionError(f"{context} has no per-field build gate") + text = body.group("body") + later = re.search( + r"openQ4SaveGameCompatibilityVersion\s*>\s*OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION\s*\)\s*\{\s*return\s+true;", + text, + ) + build = text.find("buildNumber >= firstBuildWithField") + if later is None or build < 0 or later.start() > build: + raise AssertionError(f"{context} consults the writer's build number for payloads newer than v3") + + +def main() -> None: + session = read(ROOT / "src" / "framework" / "Session.cpp") + games = { + "SP GameLib": read(ROOT / "src" / "game" / "gamesys" / "SaveGame.cpp"), + "MP GameLib": read(ROOT / "src" / "mpgame" / "gamesys" / "SaveGame.cpp"), + } + current = constant(session, "SESSION_OPENQ4_SAVEGAME_COMPATIBILITY_VERSION") + previous = constant(session, "SESSION_OPENQ4_SAVEGAME_PREVIOUS_COMPATIBILITY_VERSION") + tables = {context: layout_table(source, context) for context, source in games.items()} + + guis = shipped_guis() + validate_classification(guis) + validate_layout_pins(guis, current, previous, tables) + for context, source in games.items(): + if "SaveGame_FindGuiLayoutForPayload( name, openQ4SaveGameCompatibilityVersion )" not in source: + raise AssertionError(f"{context} does not choose a GUI's saved layout by payload version") + validate_release_provenance(previous) + validate_build_number_scope(session, games) + print("savegame_gui_layout_contract: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/savegame_restore_matrix.py b/tools/tests/savegame_restore_matrix.py new file mode 100644 index 000000000..7b5ea844b --- /dev/null +++ b/tools/tests/savegame_restore_matrix.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Restore existing saves with a staged client and classify each outcome. + +This is an opt-in runtime test: it needs the retail Quake 4 PK4s and real saves. +It answers the question the contract tests cannot: does *this* build restore a +save written by *another* build? For each input save it copies the slot into an +isolated savepath, launches the client with ``+loadGame``, and requires one of: + +* ``restored`` -- the map was rebuilt from the save, an after-load config ran, + and no ``ERROR:`` was printed; or +* ``rejected`` -- with ``--expect-reject``, the payload preflight refused the + save and the map was never torn down for it. + +Each input file is hashed before and after its run; a changed original is a +failure whatever the outcome. ``--restamp-build N`` adds a variant whose only +difference is the payload build number (CRC recomputed). Build numbers are +commit counts, so a source archive stamps 0 and a shallow clone 1; a payload +layout that is not build-dependent must restore identically for any value. + +Saves are player data. Point this at copies or designated fixtures; the harness +never writes to the input paths. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import re +import shutil +import struct +import subprocess +import sys +import time +import zlib +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +LOG_NAME = "savegame-restore-matrix.log" +AFTER_LOAD_CFG = "savegame_restore_matrix/after_load.cfg" +ACTIVE_MARKER = "OPENQ4_RESTORE_MATRIX_ACTIVE" +DONE_MARKER = "OPENQ4_RESTORE_MATRIX_DONE" +PAYLOAD_MAGIC = b"OQ4S" +INTEGRITY_MAGIC = int.from_bytes(b"OQ4I", "little") + +ERROR_PATTERN = re.compile(r"^(?:\^[0-9])?ERROR:.*$", re.MULTILINE) +SIGNAL_PATTERN = re.compile(r"signal caught|segmentation fault|Sys_Error:", re.IGNORECASE) +RESTORE_STARTED = re.compile(r"Game Map Init SaveGame") +PREFLIGHT_REJECTED = re.compile(r"WARNING:.*Savegame '[^']+' (?:has|was|wire|is|failed)[^\r\n]*") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_stamp(data: bytes) -> dict[str, Any]: + """Return the openQ4 payload stamp, or an empty dict for unstamped payloads.""" + offset = data.find(PAYLOAD_MAGIC) + if offset < 0 or offset + 16 > len(data): + return {} + version, build = struct.unpack_from(" bytes: + """Rewrite only the payload build number and refresh the integrity CRC.""" + stamp = read_stamp(data) + if not stamp or stamp["version"] < 3: + raise ValueError("only stamped v3+ payloads carry a build number and integrity trailer") + patched = bytearray(data) + struct.pack_into(" Path: + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + suffix = ".exe" if sys.platform == "win32" else "" + return install_root / f"openQ4-client_{arch}{suffix}" + + +def stage_slot(source: Path, home: Path, slot: str, payload: bytes | None) -> None: + savegames = home / "baseoq4" / "savegames" + savegames.mkdir(parents=True, exist_ok=True) + for extension in (".txt", ".tga"): + companion = source.with_suffix(extension) + if companion.is_file(): + shutil.copyfile(companion, savegames / f"{slot}{extension}") + target = savegames / f"{slot}.save" + if payload is None: + shutil.copyfile(source, target) + else: + target.write_bytes(payload) + + +def write_after_load_cfg(home: Path, settle_frames: int) -> None: + cfg = home / "baseoq4" / AFTER_LOAD_CFG + cfg.parent.mkdir(parents=True, exist_ok=True) + cfg.write_text( + "\n".join( + ( + f"echo {ACTIVE_MARKER}", + f"wait {max(1, settle_frames)}", + f"echo {DONE_MARKER}", + "quit", + "", + ) + ), + encoding="utf-8", + ) + + +def runtime_text(home: Path, stdout_path: Path) -> str: + texts = [] + for candidate in (home / "baseoq4" / "logs" / LOG_NAME, stdout_path): + if candidate.is_file(): + texts.append(candidate.read_text(encoding="utf-8", errors="replace")) + return "\n".join(texts) + + +def run_case(args: argparse.Namespace, client: Path, source: Path, label: str, payload: bytes | None) -> dict[str, Any]: + run_dir = args.output_root / label + if run_dir.exists(): + shutil.rmtree(run_dir) + home = run_dir / "home" + slot = "restore_matrix" + stage_slot(source, home, slot, payload) + write_after_load_cfg(home, args.settle_frames) + staged = home / "baseoq4" / "savegames" / f"{slot}.save" + stamp = read_stamp(staged.read_bytes()) + + command = [str(client)] + for name, value in ( + ("sys_allowMultipleInstances", "1"), + ("logFile", "2"), + ("logFileName", f"logs/{LOG_NAME}"), + ("fs_homepath", home), + ("fs_savepath", home), + ("r_fullscreen", "0"), + ("r_mode", "3"), + ("r_renderApi", args.render_api), + ("com_skipLogoVideos", "1"), + ("com_skipLoadingContinue", "1"), + ("com_loadingContinueAutoAdvance", "1"), + ("g_autoExecAfterMapLoad", AFTER_LOAD_CFG), + ("g_autoExecAfterMapLoadDelayMs", "1000"), + ): + command += ["+set", name, str(value)] + for name, value in args.set: + command += ["+set", name, value] + command += ["+loadGame", slot] + + stdout_path = run_dir / "stdout.txt" + started = time.monotonic() + timed_out = False + with stdout_path.open("w", encoding="utf-8", errors="replace") as stdout: + process = subprocess.Popen( + command, + cwd=client.parent, + stdin=subprocess.DEVNULL, + stdout=stdout, + stderr=subprocess.STDOUT, + ) + deadline = started + args.timeout + while process.poll() is None and time.monotonic() < deadline: + text = runtime_text(home, stdout_path) + # Either outcome is final: an error ends the restore, and a preflight + # rejection leaves the client at the menu with nothing left to load. + if ERROR_PATTERN.search(text) or SIGNAL_PATTERN.search(text) or PREFLIGHT_REJECTED.search(text): + break + time.sleep(0.5) + if process.poll() is None: + timed_out = time.monotonic() >= deadline + process.terminate() + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=15) + + text = runtime_text(home, stdout_path) + errors = ERROR_PATTERN.findall(text) + SIGNAL_PATTERN.findall(text) + rejections = PREFLIGHT_REJECTED.findall(text) + restore_started = RESTORE_STARTED.search(text) is not None + if args.expect_reject: + passed = bool(rejections) and not restore_started and not errors + outcome = "rejected" if passed else ("torn-down" if restore_started else "not-rejected") + else: + passed = restore_started and DONE_MARKER in text and not errors and not rejections + outcome = "restored" if passed else ("timeout" if timed_out else "failed") + return { + "label": label, + "source": str(source), + "stamp": stamp, + "outcome": outcome, + "passed": passed, + "restoreStarted": restore_started, + "errors": errors[:5], + "preflightRejections": rejections[:5], + "seconds": round(time.monotonic() - started, 1), + "runDir": str(run_dir), + } + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("saves", nargs="+", type=Path, help=".save files or directories of them (read only).") + parser.add_argument("--install-root", type=Path, default=ROOT / ".install", help="Staged package root.") + parser.add_argument("--output-root", type=Path, default=ROOT / ".tmp" / "savegame-restore-matrix") + parser.add_argument("--render-api", default="gl", help="r_renderApi for the client (default gl).") + parser.add_argument("--timeout", type=float, default=240.0, help="Seconds allowed per save.") + parser.add_argument("--settle-frames", type=int, default=60, help="Frames to run after the restore.") + parser.add_argument("--restamp-build", type=int, action="append", default=[], + help="Also load a copy whose payload build number is rewritten to N.") + parser.add_argument("--set", nargs=2, action="append", default=[], metavar=("CVAR", "VALUE"), + help="Extra client cvar, for example --set ui_retained 1.") + parser.add_argument("--expect-reject", action="store_true", + help="Pass only when preflight refuses every save before map teardown.") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + args.output_root = args.output_root.resolve() + client = client_path(args.install_root.resolve()) + if not client.is_file(): + raise SystemExit(f"client not found: {client}") + + sources: list[Path] = [] + for entry in args.saves: + sources += sorted(entry.glob("*.save")) if entry.is_dir() else [entry] + if not sources: + raise SystemExit("no .save files given") + + before = {source: sha256_file(source) for source in sources} + results = [] + for source in sources: + variants: list[tuple[str, bytes | None]] = [(source.stem, None)] + for build in args.restamp_build: + variants.append((f"{source.stem}-build{build}", restamp_build(source.read_bytes(), build))) + for label, payload in variants: + result = run_case(args, client, source, label, payload) + results.append(result) + stamp = result["stamp"] + print(f"{'PASS' if result['passed'] else 'FAIL'} {label}: {result['outcome']} " + f"(v{stamp.get('version', '?')} build {stamp.get('build', '?')} {stamp.get('wireAbi', 'unstamped')})" + + (f" -- {result['errors'][0]}" if result["errors"] else "")) + + modified = [str(source) for source in sources if sha256_file(source) != before[source]] + report = {"client": str(client), "results": results, "modifiedInputs": modified} + args.output_root.mkdir(parents=True, exist_ok=True) + (args.output_root / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + if modified: + print("FAIL input saves were modified: " + ", ".join(modified)) + failed = [result for result in results if not result["passed"]] + print(f"savegame_restore_matrix: {len(results) - len(failed)}/{len(results)} passed; inputs unchanged: {not modified}") + return 1 if failed or modified else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/tests/validation_hardening.py b/tools/tests/validation_hardening.py index 018e495da..0a3431b29 100644 --- a/tools/tests/validation_hardening.py +++ b/tools/tests/validation_hardening.py @@ -730,6 +730,8 @@ def validate_validation_wiring() -> None: "linux_dedicated_server_smoke.py", "linux_dedicated_stock_map_smoke.py", "linux_wayland_stock_sp_smoke.py", + # Restores existing saves with a staged client on retail assets. + "savegame_restore_matrix.py", "macos_dedicated_server_smoke.py", "windows_dedicated_server_smoke.py", "renderer_gameplay_benchmark.py", diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index a2771f813..20e5dc956 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -594,6 +594,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "renderer_vulkan_world_interaction_compatibility.py", root / "tools" / "tests" / "rigid_body_recovery_diagnostics.py", root / "tools" / "tests" / "savegame_corruption_contract.py", + root / "tools" / "tests" / "savegame_gui_layout_contract.py", root / "tools" / "tests" / "savegame_pointer_width_safety.py", root / "tools" / "tests" / "savegame_v3_contract.py", root / "tools" / "tests" / "sdl3_input_parity.py", From 2b327b04222e95648fe089609bb266303ac0f318 Mon Sep 17 00:00:00 2001 From: akacross Date: Fri, 16 Oct 2026 06:20:43 -0400 Subject: [PATCH 3/8] Classify main's GUI changes in the saved-layout contract main added guis/arena_pause.gui, a multiplayer pause menu no save writes, and 2126a232 edited guis/weapons/machinegun_scope.gui within format 4. That edit only drops matscaley from two windows. It sets the fixed idWindow::matScaley member, which every window writes whether or not the .gui names it, so the saved window tree is unchanged and v3 and v4 saves still decode with the new file. The contract can now record such layout-neutral revisions, each mapped to the pinned layout it keeps and the evidence for it. Co-Authored-By: Claude Opus 5.5 --- tools/tests/savegame_gui_layout_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tools/tests/savegame_gui_layout_contract.py b/tools/tests/savegame_gui_layout_contract.py index 92056c8dc..8c7fe6cd9 100644 --- a/tools/tests/savegame_gui_layout_contract.py +++ b/tools/tests/savegame_gui_layout_contract.py @@ -94,10 +94,24 @@ }, } +# Shipped revisions proven to keep a pinned window tree. Each maps the shipped +# blob to the pinned blob whose layout it keeps, and the evidence. Add one only +# when the edit cannot add, remove or reorder anything the window tree writes. +LAYOUT_NEUTRAL_EDITS: dict[str, dict[str, tuple[str, str]]] = { + "guis/weapons/machinegun_scope.gui": { + "64d0d8cf9310139e82e2426d4f9ebddc16b6ed91": ( + "d2690deda0fc05b110508071a34051631df8c1c9", + "2126a232 drops matscaley from two windows; it sets the fixed idWindow::matScaley " + "member, which every window writes whether or not the .gui names it", + ), + }, +} + # openQ4 GUIs that single-player saves never contain: menus, loading screens and # multiplayer-only GUIs (idPlayer deliberately skips mphud). NEVER_SERIALIZED_GUIS = { "guis/arena_menu.gui", + "guis/arena_pause.gui", "guis/buymenu.gui", "guis/campaign_menu.gui", "guis/demo_menu.gui", @@ -193,6 +207,9 @@ def validate_layout_pins(guis: dict[str, Path], current: int, previous: int, tab "a payload bump must pin the new version and keep the decoded one" ) blob = git_blob_id(guis[gui].read_bytes()) + neutral = LAYOUT_NEUTRAL_EDITS.get(gui, {}).get(blob) + if neutral is not None and neutral[0] == pins[current]: + continue if blob != pins[current]: raise AssertionError( f"{gui} changed (blob {blob}, pinned {pins[current]} for payload v{current}). Its window tree " From ada436fcc83a158be5a112861baff5cfe6826c40 Mon Sep 17 00:00:00 2001 From: akacross Date: Fri, 16 Oct 2026 06:20:43 -0400 Subject: [PATCH 4/8] Never delete in the restore matrix, and refuse inputs in its output The harness removed / before staging each save, so a save kept inside that directory was deleted before it was read. It now deletes nothing. Each invocation writes into a new run directory it creates, and before any write it refuses linked saves or companions and any input inside the output root. Companion .txt/.tga files are hashed with the save, and a missing input counts as modified. --self-test checks these guards on disposable files without a client and runs in CI and local validation. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/commit-validation.yml | 2 + .github/workflows/push-verification.yml | 2 + tools/tests/savegame_restore_matrix.py | 143 +++++++++++++++++++++--- tools/tests/validation_hardening.py | 2 - tools/validation/openq4_validate.py | 1 + 5 files changed, 131 insertions(+), 19 deletions(-) diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 68e58642d..a73196408 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -492,6 +492,7 @@ jobs: tools/tests/vk_display_color_mapping.py \ tools/tests/vk_display_color_mapping_shader_pin.py \ tools/tests/vk_display_color_mapping_capture.py \ + tools/tests/savegame_restore_matrix.py \ tools/tests/vk_temporal_resolve_shader_pin.py \ tools/tests/vscode_fast_build.py bash -n tools/build/meson_setup.sh @@ -845,6 +846,7 @@ jobs: python tools/tests/vk_display_color_mapping.py python tools/tests/vk_display_color_mapping_shader_pin.py python tools/tests/vk_display_color_mapping_capture.py --self-test + python tools/tests/savegame_restore_matrix.py --self-test python tools/tests/vk_temporal_resolve_shader_pin.py python tools/tests/vscode_fast_build.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index 49256649b..40ab3db40 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -492,6 +492,7 @@ jobs: tools/tests/vk_display_color_mapping.py \ tools/tests/vk_display_color_mapping_shader_pin.py \ tools/tests/vk_display_color_mapping_capture.py \ + tools/tests/savegame_restore_matrix.py \ tools/tests/vk_temporal_resolve_shader_pin.py \ tools/tests/vscode_fast_build.py bash -n tools/build/meson_setup.sh @@ -845,6 +846,7 @@ jobs: python tools/tests/vk_display_color_mapping.py python tools/tests/vk_display_color_mapping_shader_pin.py python tools/tests/vk_display_color_mapping_capture.py --self-test + python tools/tests/savegame_restore_matrix.py --self-test python tools/tests/vk_temporal_resolve_shader_pin.py python tools/tests/vscode_fast_build.py diff --git a/tools/tests/savegame_restore_matrix.py b/tools/tests/savegame_restore_matrix.py index 7b5ea844b..bd2e9de03 100644 --- a/tools/tests/savegame_restore_matrix.py +++ b/tools/tests/savegame_restore_matrix.py @@ -17,8 +17,11 @@ commit counts, so a source archive stamps 0 and a shallow clone 1; a payload layout that is not build-dependent must restore identically for any value. -Saves are player data. Point this at copies or designated fixtures; the harness -never writes to the input paths. +Saves are player data. The harness never deletes anything: every invocation +writes into a new run directory it creates under ``--output-root``. Before it +touches the disk it refuses inputs (saves and their .txt/.tga companions) that +are links or that lie inside the output root, so no run directory can contain +an input. ``--self-test`` checks those guards without launching the game. """ from __future__ import annotations @@ -26,12 +29,14 @@ import argparse import hashlib import json +import os import platform import re import shutil import struct import subprocess import sys +import tempfile import time import zlib from pathlib import Path @@ -105,6 +110,44 @@ def client_path(install_root: Path) -> Path: return install_root / f"openQ4-client_{arch}{suffix}" +def input_files(source: Path) -> list[Path]: + """The save and whichever companions the harness will copy from beside it.""" + return [source] + [ + source.with_suffix(extension) for extension in (".txt", ".tga") if source.with_suffix(extension).exists() + ] + + +def validate_inputs(sources: list[Path], output_root: Path) -> list[Path]: + """Refuse linked inputs and inputs inside the output root before any write.""" + root = output_root.resolve() + files: list[Path] = [] + for source in sources: + for path in input_files(source): + if path.is_symlink(): + raise SystemExit(f"refusing linked input {path}: copy the save to a plain file first") + if not path.is_file(): + raise SystemExit(f"input is not a regular file: {path}") + resolved = path.resolve() + if resolved == root or root in resolved.parents: + raise SystemExit(f"refusing input {path}: it is inside the output root {root}") + files.append(path) + return files + + +def create_run_dir(output_root: Path) -> Path: + """A new directory owned by this invocation; existing content is never reused.""" + output_root.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + for attempt in range(1000): + run_dir = output_root / f"run-{stamp}-{os.getpid()}-{attempt}" + try: + run_dir.mkdir() + return run_dir + except FileExistsError: + continue + raise SystemExit(f"could not allocate a run directory under {output_root}") + + def stage_slot(source: Path, home: Path, slot: str, payload: bytes | None) -> None: savegames = home / "baseoq4" / "savegames" savegames.mkdir(parents=True, exist_ok=True) @@ -144,10 +187,10 @@ def runtime_text(home: Path, stdout_path: Path) -> str: return "\n".join(texts) -def run_case(args: argparse.Namespace, client: Path, source: Path, label: str, payload: bytes | None) -> dict[str, Any]: - run_dir = args.output_root / label - if run_dir.exists(): - shutil.rmtree(run_dir) +def run_case( + args: argparse.Namespace, client: Path, source: Path, label: str, payload: bytes | None, run_dir: Path +) -> dict[str, Any]: + run_dir.mkdir() home = run_dir / "home" slot = "restore_matrix" stage_slot(source, home, slot, payload) @@ -230,7 +273,7 @@ def run_case(args: argparse.Namespace, client: Path, source: Path, label: str, p def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("saves", nargs="+", type=Path, help=".save files or directories of them (read only).") + parser.add_argument("saves", nargs="*", type=Path, help=".save files or directories of them (read only).") parser.add_argument("--install-root", type=Path, default=ROOT / ".install", help="Staged package root.") parser.add_argument("--output-root", type=Path, default=ROOT / ".tmp" / "savegame-restore-matrix") parser.add_argument("--render-api", default="gl", help="r_renderApi for the client (default gl).") @@ -242,40 +285,106 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="Extra client cvar, for example --set ui_retained 1.") parser.add_argument("--expect-reject", action="store_true", help="Pass only when preflight refuses every save before map teardown.") - return parser.parse_args(argv) + parser.add_argument("--self-test", action="store_true", + help="Check the input/output guards on synthetic files, without a client.") + args = parser.parse_args(argv) + if not args.self_test and not args.saves: + parser.error("give at least one save, or --self-test") + return args + + +def self_test() -> int: + """The guards, on disposable files: refusals must leave every input intact.""" + def refused(sources: list[Path], output_root: Path) -> bool: + try: + validate_inputs(sources, output_root) + except SystemExit: + return True + return False + + with tempfile.TemporaryDirectory() as temp: + base = Path(temp) + out = base / "out" + nested = out / "slot" / "slot.save" + nested.parent.mkdir(parents=True) + nested.write_bytes(b"input") + nested.with_suffix(".txt").write_text("input", encoding="utf-8") + outside = base / "saves" / "slot.save" + outside.parent.mkdir() + outside.write_bytes(b"input") + companion_inside = base / "split" / "slot.save" + companion_inside.parent.mkdir() + companion_inside.write_bytes(b"input") + + checks = { + # The reviewed case: an input under //. + "input inside the output root is refused": refused([nested], out), + "input inside a deeper output root is refused": refused([nested], out / "slot"), + "input outside the output root is accepted": not refused([outside], out), + } + try: + (base / "saves" / "linked.save").symlink_to(outside) + (companion_inside.parent / "slot.txt").symlink_to(nested.with_suffix(".txt")) + except (OSError, NotImplementedError): + print("savegame_restore_matrix: symlinks unavailable; skipped the link checks") + else: + checks["linked save is refused"] = refused([base / "saves" / "linked.save"], out) + checks["linked companion is refused"] = refused([companion_inside], out) + + first = create_run_dir(out) + second = create_run_dir(out) + checks["each invocation gets a new run directory"] = first != second and first.is_dir() and second.is_dir() + checks["inputs survive every refusal"] = ( + nested.read_bytes() == b"input" + and nested.with_suffix(".txt").read_text(encoding="utf-8") == "input" + and outside.read_bytes() == b"input" + ) + + failed = [name for name, ok in checks.items() if not ok] + for name, ok in checks.items(): + print(f"{'PASS' if ok else 'FAIL'} {name}") + print(f"savegame_restore_matrix --self-test: {len(checks) - len(failed)}/{len(checks)} passed") + return 1 if failed else 0 def main(argv: list[str]) -> int: args = parse_args(argv) + if args.self_test: + return self_test() args.output_root = args.output_root.resolve() - client = client_path(args.install_root.resolve()) - if not client.is_file(): - raise SystemExit(f"client not found: {client}") sources: list[Path] = [] for entry in args.saves: sources += sorted(entry.glob("*.save")) if entry.is_dir() else [entry] if not sources: raise SystemExit("no .save files given") + # Every guard runs before the first write anywhere. + inputs = validate_inputs(sources, args.output_root) + client = client_path(args.install_root.resolve()) + if not client.is_file(): + raise SystemExit(f"client not found: {client}") - before = {source: sha256_file(source) for source in sources} + before = {path: sha256_file(path) for path in inputs} + run_root = create_run_dir(args.output_root) results = [] + case_index = 0 for source in sources: variants: list[tuple[str, bytes | None]] = [(source.stem, None)] for build in args.restamp_build: variants.append((f"{source.stem}-build{build}", restamp_build(source.read_bytes(), build))) for label, payload in variants: - result = run_case(args, client, source, label, payload) + case_index += 1 + result = run_case(args, client, source, label, payload, run_root / f"{case_index:02d}-{label}") results.append(result) stamp = result["stamp"] print(f"{'PASS' if result['passed'] else 'FAIL'} {label}: {result['outcome']} " f"(v{stamp.get('version', '?')} build {stamp.get('build', '?')} {stamp.get('wireAbi', 'unstamped')})" + (f" -- {result['errors'][0]}" if result["errors"] else "")) - modified = [str(source) for source in sources if sha256_file(source) != before[source]] - report = {"client": str(client), "results": results, "modifiedInputs": modified} - args.output_root.mkdir(parents=True, exist_ok=True) - (args.output_root / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + modified = [str(path) for path in inputs if not path.is_file() or sha256_file(path) != before[path]] + report = {"client": str(client), "runDir": str(run_root), "results": results, "modifiedInputs": modified} + (run_root / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + print(f"report: {run_root / 'report.json'}") if modified: print("FAIL input saves were modified: " + ", ".join(modified)) failed = [result for result in results if not result["passed"]] diff --git a/tools/tests/validation_hardening.py b/tools/tests/validation_hardening.py index fb818c179..8eda6be3d 100644 --- a/tools/tests/validation_hardening.py +++ b/tools/tests/validation_hardening.py @@ -733,8 +733,6 @@ def validate_validation_wiring() -> None: "linux_dedicated_server_smoke.py", "linux_dedicated_stock_map_smoke.py", "linux_wayland_stock_sp_smoke.py", - # Restores existing saves with a staged client on retail assets. - "savegame_restore_matrix.py", "macos_dedicated_server_smoke.py", "windows_dedicated_server_smoke.py", "renderer_gameplay_benchmark.py", diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index 5147d2dde..8ed40475e 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -752,6 +752,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) (root / "tools" / "tests" / 'vk_display_color_mapping.py', []), (root / "tools" / "tests" / 'vk_display_color_mapping_shader_pin.py', []), (root / "tools" / "tests" / 'vk_display_color_mapping_capture.py', ['--self-test']), + (root / "tools" / "tests" / 'savegame_restore_matrix.py', ['--self-test']), ] failures: list[str] = [] for test_script, test_args in [(path, []) for path in tests] + argument_checks: From dcddd682c2b7f2535f134551f649c7416a4bb861 Mon Sep 17 00:00:00 2001 From: akacross Date: Fri, 16 Oct 2026 06:40:00 -0400 Subject: [PATCH 5/8] Keep interpolator layouts compatible with their saved native images f15a41f0 added a bool cache flag to idExtrapolate and idInterpolate. idAnimator saves each jointMod_t, which embeds idInterpolateAccelDecelLinear, and a running GUI transition saves its idInterpolateAccelDecelLinear, both as native images. Each grew by four bytes, so that build could not restore any v3 or v4 save holding a joint modifier, which includes every player: idAnimator::Restore: saved joint count 0 does not match model joint count 65 A NaN cached time now marks the cache stale. NaN never equals a sample time, so negative times, including -1, stay valid and the native interpolation suite passes unchanged (GCC and Clang, -O0 and -O2). Sizes match 0.14.000 again. static_asserts beside both raw writes require these types to hold only their declared members on every ABI; they fail against f15a41f0's headers. The joint-count error now names the model and entity. Co-Authored-By: Claude Opus 5.5 --- docs/dev/core-runtime-refinements.md | 7 +++++-- src/game/anim/Anim_Blend.cpp | 16 +++++++++++++- src/idlib/math/Extrapolate.h | 21 +++++++++++-------- src/idlib/math/Interpolate.h | 31 ++++++++++++++++------------ src/mpgame/anim/Anim_Blend.cpp | 16 +++++++++++++- src/ui/Window.cpp | 9 ++++++++ 6 files changed, 74 insertions(+), 26 deletions(-) diff --git a/docs/dev/core-runtime-refinements.md b/docs/dev/core-runtime-refinements.md index 6b0ca99e5..2399e64ab 100644 --- a/docs/dev/core-runtime-refinements.md +++ b/docs/dev/core-runtime-refinements.md @@ -16,8 +16,11 @@ map-compiler work in the checkout is preserved. prefix at every allocation increment. - Linear interpolation invalidates its cached sample when the start, end, duration or start time changes. Extrapolation caches the correct value when - sampling before its start. Explicit validity flags allow negative sample - times, including `-1`, without colliding with an invalid-cache sentinel. + sampling before its start. A NaN cached time marks the cache stale, so + negative sample times, including `-1`, are valid. A separate validity flag + was tried first, but saves store these interpolators as native images, and + the extra member broke every save holding a joint modifier or a running GUI + transition; see [Savegame Reliability](savegame-reliability.md#remaining-raw-serialization-raw1). - Accelerated linear and sine interpolation recompute velocity after endpoint changes. Reinitializing with a nonpositive duration clears prior phases and velocity; negative acceleration/deceleration durations clamp to zero. diff --git a/src/game/anim/Anim_Blend.cpp b/src/game/anim/Anim_Blend.cpp index 119325851..141728dac 100644 --- a/src/game/anim/Anim_Blend.cpp +++ b/src/game/anim/Anim_Blend.cpp @@ -7,6 +7,19 @@ #include "../Game_local.h" + +// raw1: idAnimator saves each jointMod_t as its native image, so these sizes are +// savegame wire format. Every member is four bytes wide on supported ABIs, so a +// type larger than its declared members has gained one. That moves every later +// byte of every save holding a joint modifier, and needs a payload decision. +static_assert( sizeof( idExtrapolate ) == sizeof( int ) + 3 * sizeof( float ) + 4 * sizeof( idAngles ), + "idExtrapolate changed size; it is saved inside jointMod_t" ); +static_assert( sizeof( idInterpolateAccelDecelLinear ) == + 4 * sizeof( float ) + 2 * sizeof( idAngles ) + sizeof( idExtrapolate ), + "idInterpolateAccelDecelLinear changed size; it is saved inside jointMod_t" ); +static_assert( sizeof( jointMod_t ) == 2 * sizeof( jointHandle_t ) + sizeof( idMat3 ) + sizeof( idVec3 ) + + 2 * sizeof( jointModTransform_t ) + sizeof( idInterpolateAccelDecelLinear ) + sizeof( int ), + "jointMod_t changed size; idAnimator saves its native image" ); #include "../../game/Projectile.h" #include "../ai/AI.h" @@ -3599,7 +3612,8 @@ void idAnimator::Restore( idRestoreGame *savefile ) { savefile->Error( "idAnimator::Restore: invalid joint count %d", numJoints ); } if ( numJoints != expectedJoints ) { - savefile->Error( "idAnimator::Restore: saved joint count %d does not match model joint count %d", numJoints, expectedJoints ); + savefile->Error( "idAnimator::Restore: saved joint count %d does not match model joint count %d for model '%s' on entity '%s'", + numJoints, expectedJoints, modelDef ? modelDef->GetName() : "", entity ? entity->GetName() : "" ); } //RAVEN BEGIN //amccarthy: Added allocation tag diff --git a/src/idlib/math/Extrapolate.h b/src/idlib/math/Extrapolate.h index 5fd3128bf..853626d0a 100644 --- a/src/idlib/math/Extrapolate.h +++ b/src/idlib/math/Extrapolate.h @@ -2,6 +2,8 @@ #ifndef __MATH_EXTRAPOLATE_H__ #define __MATH_EXTRAPOLATE_H__ +#include + /* ============================================================================================== @@ -29,11 +31,11 @@ class idExtrapolate { type GetCurrentValue( float time ) const; type GetCurrentSpeed( float time ) const; bool IsDone( float time ) const { return ( !( extrapolationType & EXTRAPOLATION_NOSTOP ) && time >= startTime + duration ); } - void SetStartTime( float time ) { startTime = time; cacheValid = false; } + void SetStartTime( float time ) { startTime = time; currentTime = CacheInvalidTime(); } float GetStartTime( void ) const { return startTime; } float GetEndTime( void ) const { return ( !( extrapolationType & EXTRAPOLATION_NOSTOP ) && duration > 0 ) ? startTime + duration : 0; } float GetDuration( void ) const { return duration; } - void SetStartValue( const type &value ) { startValue = value; cacheValid = false; } + void SetStartValue( const type &value ) { startValue = value; currentTime = CacheInvalidTime(); } const type & GetStartValue( void ) const { return startValue; } const type & GetBaseSpeed( void ) const { return baseSpeed; } const type & GetSpeed( void ) const { return speed; } @@ -46,9 +48,13 @@ class idExtrapolate { type startValue; type baseSpeed; type speed; + // Saves store this object's native image inside animator joint modifiers, + // so its layout is part of the savegame wire format. A NaN currentTime marks + // the cached sample stale; NaN never equals a sample time, so -1 is valid. mutable float currentTime; mutable type currentValue; - mutable bool cacheValid; + + static float CacheInvalidTime( void ) { return std::numeric_limits::quiet_NaN(); } }; /* @@ -63,8 +69,7 @@ ID_INLINE idExtrapolate::idExtrapolate() { memset( &startValue, 0, sizeof( startValue ) ); memset( &baseSpeed, 0, sizeof( baseSpeed ) ); memset( &speed, 0, sizeof( speed ) ); - currentTime = -1; - cacheValid = false; + currentTime = CacheInvalidTime(); currentValue = startValue; } @@ -81,8 +86,7 @@ ID_INLINE void idExtrapolate::Init( const float startTime, const float dur this->startValue = startValue; this->baseSpeed = baseSpeed; this->speed = speed; - currentTime = -1; - cacheValid = false; + currentTime = CacheInvalidTime(); currentValue = startValue; } @@ -95,12 +99,11 @@ template< class type > ID_INLINE type idExtrapolate::GetCurrentValue( float time ) const { float deltaTime, s; - if ( cacheValid && time == currentTime ) { + if ( time == currentTime ) { return currentValue; } currentTime = time; - cacheValid = true; if ( time < startTime ) { currentValue = startValue; diff --git a/src/idlib/math/Interpolate.h b/src/idlib/math/Interpolate.h index aeaea0f83..61545b416 100644 --- a/src/idlib/math/Interpolate.h +++ b/src/idlib/math/Interpolate.h @@ -2,6 +2,8 @@ #ifndef __MATH_INTERPOLATE_H__ #define __MATH_INTERPOLATE_H__ +#include + /* ============================================================================================== @@ -17,10 +19,10 @@ class idInterpolate { virtual ~idInterpolate() { } void Init( const float startTime, const float duration, const type &startValue, const type &endValue ); - void SetStartTime( float time ) { this->startTime = time; cacheValid = false; } - void SetDuration( float duration ) { this->duration = duration; cacheValid = false; } - void SetStartValue( const type &startValue ) { this->startValue = startValue; cacheValid = false; } - void SetEndValue( const type &endValue ) { this->endValue = endValue; cacheValid = false; } + void SetStartTime( float time ) { this->startTime = time; currentTime = CacheInvalidTime(); } + void SetDuration( float duration ) { this->duration = duration; currentTime = CacheInvalidTime(); } + void SetStartValue( const type &startValue ) { this->startValue = startValue; currentTime = CacheInvalidTime(); } + void SetEndValue( const type &endValue ) { this->endValue = endValue; currentTime = CacheInvalidTime(); } // RAVEN BEGIN // abahr: made virtual @@ -43,9 +45,14 @@ class idInterpolate { float duration; type startValue; type endValue; + // Saves store this object's native image (GUI transitions, animator joint + // modifiers), so its layout is part of the savegame wire format. A NaN + // currentTime marks the cached sample stale instead of a separate flag; + // NaN never equals a sample time, so every time, including -1, is valid. mutable float currentTime; mutable type currentValue; - mutable bool cacheValid; + + static float CacheInvalidTime( void ) { return std::numeric_limits::quiet_NaN(); } }; /* @@ -55,8 +62,8 @@ idInterpolate::idInterpolate */ template< class type > ID_INLINE idInterpolate::idInterpolate() { - currentTime = startTime = duration = 0; - cacheValid = false; + startTime = duration = 0; + currentTime = CacheInvalidTime(); memset( ¤tValue, 0, sizeof( currentValue ) ); startValue = endValue = currentValue; } @@ -72,7 +79,7 @@ ID_INLINE void idInterpolate::Init( const float startTime, const float dur this->duration = duration; this->startValue = startValue; this->endValue = endValue; - this->cacheValid = false; + this->currentTime = CacheInvalidTime(); this->currentValue = startValue; } @@ -86,8 +93,7 @@ ID_INLINE type idInterpolate::GetCurrentValue( float time ) const { float deltaTime; deltaTime = time - startTime; - if ( !cacheValid || time != currentTime ) { - cacheValid = true; + if ( !( time == currentTime ) ) { currentTime = time; if ( deltaTime <= 0 ) { currentValue = startValue; @@ -133,7 +139,7 @@ class rvSphericalInterpolate : public idInterpolate { rvSphericalInterpolate(); virtual idQuat GetCurrentValue( float time ) const; - void SetTimeFunction( TimeManipFunc func ) { timeFunc = func; cacheValid = false; } + void SetTimeFunction( TimeManipFunc func ) { timeFunc = func; currentTime = CacheInvalidTime(); } protected: TimeManipFunc timeFunc; @@ -158,8 +164,7 @@ ID_INLINE idQuat rvSphericalInterpolate::GetCurrentValue( float time ) const { float deltaTime; deltaTime = time - startTime; - if ( !cacheValid || time != currentTime ) { - cacheValid = true; + if ( !( time == currentTime ) ) { currentTime = time; if( duration == 0.0f ) { currentValue = endValue; diff --git a/src/mpgame/anim/Anim_Blend.cpp b/src/mpgame/anim/Anim_Blend.cpp index 9fc4a96e6..42d15dd16 100644 --- a/src/mpgame/anim/Anim_Blend.cpp +++ b/src/mpgame/anim/Anim_Blend.cpp @@ -7,6 +7,19 @@ #pragma hdrstop #include "../Game_local.h" + +// raw1: idAnimator saves each jointMod_t as its native image, so these sizes are +// savegame wire format. Every member is four bytes wide on supported ABIs, so a +// type larger than its declared members has gained one. That moves every later +// byte of every save holding a joint modifier, and needs a payload decision. +static_assert( sizeof( idExtrapolate ) == sizeof( int ) + 3 * sizeof( float ) + 4 * sizeof( idAngles ), + "idExtrapolate changed size; it is saved inside jointMod_t" ); +static_assert( sizeof( idInterpolateAccelDecelLinear ) == + 4 * sizeof( float ) + 2 * sizeof( idAngles ) + sizeof( idExtrapolate ), + "idInterpolateAccelDecelLinear changed size; it is saved inside jointMod_t" ); +static_assert( sizeof( jointMod_t ) == 2 * sizeof( jointHandle_t ) + sizeof( idMat3 ) + sizeof( idVec3 ) + + 2 * sizeof( jointModTransform_t ) + sizeof( idInterpolateAccelDecelLinear ) + sizeof( int ), + "jointMod_t changed size; idAnimator saves its native image" ); #include "../../mpgame/Projectile.h" #include "../ai/AI.h" @@ -3572,7 +3585,8 @@ void idAnimator::Restore( idRestoreGame *savefile ) { savefile->Error( "idAnimator::Restore: invalid joint count %d", numJoints ); } if ( numJoints != expectedJoints ) { - savefile->Error( "idAnimator::Restore: saved joint count %d does not match model joint count %d", numJoints, expectedJoints ); + savefile->Error( "idAnimator::Restore: saved joint count %d does not match model joint count %d for model '%s' on entity '%s'", + numJoints, expectedJoints, modelDef ? modelDef->GetName() : "", entity ? entity->GetName() : "" ); } //RAVEN BEGIN //amccarthy: Added allocation tag diff --git a/src/ui/Window.cpp b/src/ui/Window.cpp index 714de659c..6b644175d 100644 --- a/src/ui/Window.cpp +++ b/src/ui/Window.cpp @@ -55,6 +55,15 @@ If you have questions concerning this license or the applicable additional terms // gui editor is more integrated into the window now #include "../tools/guied/GEWindowWrapper.h" +// raw1: a running transition saves its interpolator as a native image +// (WriteSaveGameTransition), so this size is savegame wire format. A member +// beyond the declared ones moves every later byte of a save taken mid-transition. +static_assert( sizeof( idExtrapolate ) == sizeof( int ) + 3 * sizeof( float ) + 4 * sizeof( idVec4 ), + "idExtrapolate changed size; GUI transitions save it" ); +static_assert( sizeof( idInterpolateAccelDecelLinear ) == + 4 * sizeof( float ) + 2 * sizeof( idVec4 ) + sizeof( idExtrapolate ), + "idInterpolateAccelDecelLinear changed size; GUI transitions save it" ); + namespace { static bool IsFullscreenBackdropRect( const idRectangle &drawRect ) { const float epsilon = 0.01f; From 7185ae209a924e141f0fd1bc367615d21a8a4a8f Mon Sep 17 00:00:00 2001 From: akacross Date: Fri, 16 Oct 2026 06:40:00 -0400 Subject: [PATCH 6/8] Carry changed HUD window values through the format-3 HUD migration Copying only the state dictionary lost window-local values that scripts set. Bossbar::visible is 0 in both HUD definitions and showBossBar sets it to 1, but restoring bossEnemy does not replay StartBossBattle, so a v3 save taken during a boss fight loaded without its boss bar. idUserInterface::ReadFromSaveGameWithLayout, appended after the existing vtable slots, parses the saved definition twice in private: one copy consumes the v3 window tree with every normal check, the other shows what that definition starts with. Besides the dictionary, flags and cursor, every window value the game changed moves to the live GUI where a window and variable of the same name and type exist. GUI-state-bound variables already arrive with the dictionary. Transitions and timeline progress still restart. SP and MP route v3 HUDs through it; the layout contract requires that. A v3 build-1109 save taken in game/waste's boss fight restores Bossbar::visible and the health width, and its screenshot matches a fresh boss fight on this build. Co-Authored-By: Claude Opus 5.5 --- src/game/gamesys/SaveGame.cpp | 57 ++------ src/mpgame/gamesys/SaveGame.cpp | 57 ++------ src/ui/UserInterface.cpp | 145 ++++++++++++++++++++ src/ui/UserInterface.h | 6 + src/ui/UserInterfaceDeferred.cpp | 7 + src/ui/UserInterfaceDeferred.h | 1 + src/ui/UserInterfaceLocal.h | 1 + tools/tests/savegame_gui_layout_contract.py | 4 + 8 files changed, 180 insertions(+), 98 deletions(-) diff --git a/src/game/gamesys/SaveGame.cpp b/src/game/gamesys/SaveGame.cpp index 30ca31494..0aaa0a6b4 100644 --- a/src/game/gamesys/SaveGame.cpp +++ b/src/game/gamesys/SaveGame.cpp @@ -2035,62 +2035,21 @@ void idRestoreGame::ReadUserInterface( idUserInterface *&ui, const idDict *args idRestoreGame::ReadUserInterfaceWithLayout Reads a GUI whose saved window tree follows an earlier definition than the one -this build loads. A private instance of that definition consumes the window tree -with every normal check, then hands its state dictionary and interaction flags to -the live GUI. Window-local animation progress is not carried over: the live -window tree starts from its definition, as it does when a map spawns the GUI. +this build loads. The engine parses that definition privately to consume the +window tree with every normal check, then moves the state dictionary, flags and +each window value the game had changed (a visible boss bar, for example) to the +live GUI. Animation progress is not carried over. ================ */ -class idScopedSavedLayoutGui { -public: - explicit idScopedSavedLayoutGui( idUserInterface *savedGui ) : gui( savedGui ) { - } - - ~idScopedSavedLayoutGui( void ) { - if ( gui != NULL ) { - uiManager->DeAlloc( gui ); - } - } - - idUserInterface *gui; - -private: - idScopedSavedLayoutGui( const idScopedSavedLayoutGui & ); - idScopedSavedLayoutGui &operator=( const idScopedSavedLayoutGui & ); -}; - void idRestoreGame::ReadUserInterfaceWithLayout( idUserInterface *ui, const char *name, const char *layout ) { const int offset = file->Tell(); - // Released on every exit, including the error unwinds a failed read raises. - idScopedSavedLayoutGui scopedSaved( uiManager->FindGui( layout, true, true ) ); - idUserInterface *saved = scopedSaved.gui; - if ( saved == NULL ) { - Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d needs its saved layout '%s', which is missing", - openQ4SaveGameCompatibilityVersion, name, offset, layout ); - } - if ( saved->ReadFromSaveGame( file ) == false ) { + int restoredWindowValues = 0; + if ( !ui->ReadFromSaveGameWithLayout( file, layout, restoredWindowValues ) ) { Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d failed to read with its saved layout '%s'", openQ4SaveGameCompatibilityVersion, name, offset, layout ); } - - const idDict &savedState = saved->State(); - const idDict liveState = ui->State(); - for ( int i = 0; i < liveState.GetNumKeyVals(); i++ ) { - const idKeyValue *kv = liveState.GetKeyVal( i ); - if ( kv != NULL && savedState.FindKey( kv->GetKey() ) == NULL ) { - ui->DeleteStateVar( kv->GetKey() ); - } - } - for ( int i = 0; i < savedState.GetNumKeyVals(); i++ ) { - const idKeyValue *kv = savedState.GetKeyVal( i ); - ui->SetStateString( kv->GetKey(), kv->GetValue() ); - } - ui->SetInteractive( saved->IsInteractive() ); - ui->SetUniqued( saved->IsUniqued() ); - ui->SetCursor( saved->CursorX(), saved->CursorY() ); - - common->Printf( "Restored v%d gui '%s' through its saved layout '%s' (%d state values; window animations restart)\n", - openQ4SaveGameCompatibilityVersion, name, layout, savedState.GetNumKeyVals() ); + common->Printf( "Restored v%d gui '%s' through its saved layout '%s' (%d state values, %d changed window values; window animations restart)\n", + openQ4SaveGameCompatibilityVersion, name, layout, ui->State().GetNumKeyVals(), restoredWindowValues ); } // RAVEN BEGIN diff --git a/src/mpgame/gamesys/SaveGame.cpp b/src/mpgame/gamesys/SaveGame.cpp index 469dfe7dc..78acfc90a 100644 --- a/src/mpgame/gamesys/SaveGame.cpp +++ b/src/mpgame/gamesys/SaveGame.cpp @@ -2048,62 +2048,21 @@ void idRestoreGame::ReadUserInterface( idUserInterface *&ui, const idDict *args idRestoreGame::ReadUserInterfaceWithLayout Reads a GUI whose saved window tree follows an earlier definition than the one -this build loads. A private instance of that definition consumes the window tree -with every normal check, then hands its state dictionary and interaction flags to -the live GUI. Window-local animation progress is not carried over: the live -window tree starts from its definition, as it does when a map spawns the GUI. +this build loads. The engine parses that definition privately to consume the +window tree with every normal check, then moves the state dictionary, flags and +each window value the game had changed (a visible boss bar, for example) to the +live GUI. Animation progress is not carried over. ================ */ -class idScopedSavedLayoutGui { -public: - explicit idScopedSavedLayoutGui( idUserInterface *savedGui ) : gui( savedGui ) { - } - - ~idScopedSavedLayoutGui( void ) { - if ( gui != NULL ) { - uiManager->DeAlloc( gui ); - } - } - - idUserInterface *gui; - -private: - idScopedSavedLayoutGui( const idScopedSavedLayoutGui & ); - idScopedSavedLayoutGui &operator=( const idScopedSavedLayoutGui & ); -}; - void idRestoreGame::ReadUserInterfaceWithLayout( idUserInterface *ui, const char *name, const char *layout ) { const int offset = file->Tell(); - // Released on every exit, including the error unwinds a failed read raises. - idScopedSavedLayoutGui scopedSaved( uiManager->FindGui( layout, true, true ) ); - idUserInterface *saved = scopedSaved.gui; - if ( saved == NULL ) { - Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d needs its saved layout '%s', which is missing", - openQ4SaveGameCompatibilityVersion, name, offset, layout ); - } - if ( saved->ReadFromSaveGame( file ) == false ) { + int restoredWindowValues = 0; + if ( !ui->ReadFromSaveGameWithLayout( file, layout, restoredWindowValues ) ) { Error( "idRestoreGame::ReadUserInterface: v%d gui '%s' at offset %d failed to read with its saved layout '%s'", openQ4SaveGameCompatibilityVersion, name, offset, layout ); } - - const idDict &savedState = saved->State(); - const idDict liveState = ui->State(); - for ( int i = 0; i < liveState.GetNumKeyVals(); i++ ) { - const idKeyValue *kv = liveState.GetKeyVal( i ); - if ( kv != NULL && savedState.FindKey( kv->GetKey() ) == NULL ) { - ui->DeleteStateVar( kv->GetKey() ); - } - } - for ( int i = 0; i < savedState.GetNumKeyVals(); i++ ) { - const idKeyValue *kv = savedState.GetKeyVal( i ); - ui->SetStateString( kv->GetKey(), kv->GetValue() ); - } - ui->SetInteractive( saved->IsInteractive() ); - ui->SetUniqued( saved->IsUniqued() ); - ui->SetCursor( saved->CursorX(), saved->CursorY() ); - - common->Printf( "Restored v%d gui '%s' through its saved layout '%s' (%d state values; window animations restart)\n", - openQ4SaveGameCompatibilityVersion, name, layout, savedState.GetNumKeyVals() ); + common->Printf( "Restored v%d gui '%s' through its saved layout '%s' (%d state values, %d changed window values; window animations restart)\n", + openQ4SaveGameCompatibilityVersion, name, layout, ui->State().GetNumKeyVals(), restoredWindowValues ); } // RAVEN BEGIN diff --git a/src/ui/UserInterface.cpp b/src/ui/UserInterface.cpp index f5514cc7e..3ddf64d05 100644 --- a/src/ui/UserInterface.cpp +++ b/src/ui/UserInterface.cpp @@ -52,6 +52,8 @@ If you have questions concerning this license or the applicable additional terms #include #include #include +#include +#include #include "../idlib/ParserSourceObserver.h" #include "../framework/DeclReferenceObserver.h" @@ -1963,6 +1965,149 @@ bool idUserInterfaceLocal::ReadFromSaveGame( idFile *savefile ) { return true; } +namespace { + +struct uiSavedWindowVar_t { + const char * name; + idWinVar * var; +}; + +} + +bool idUserInterfaceLocal::ReadFromSaveGameWithLayout( idFile *savefile, const char *layoutPath, int &restoredWindowValues ) { + restoredWindowValues = 0; + if ( savefile == NULL || desktop == NULL || layoutPath == NULL || layoutPath[ 0 ] == '\0' ) { + common->Warning( "idUserInterfaceLocal::ReadFromSaveGameWithLayout: gui '%s' has no valid input file, parsed desktop or layout", + source.c_str() ); + return false; + } + + // Two private parses of the saved definition: one consumes the save with every + // normal check, the other shows what that definition starts with. A window + // value that differs between them was changed while the game ran -- a boss bar + // a script made visible, for example -- and is what this GUI must keep. + idUserInterfaceLocal saved( false ); + idUserInterfaceLocal initial( false ); + if ( !saved.InitFromFile( layoutPath, true, false ) || !initial.InitFromFile( layoutPath, true, false ) || + saved.desktop == NULL || initial.desktop == NULL ) { + common->Warning( "idUserInterfaceLocal::ReadFromSaveGameWithLayout: gui '%s' could not parse its saved layout '%s'", + source.c_str(), layoutPath ); + return false; + } + if ( !saved.ReadFromSaveGame( savefile ) ) { + return false; + } + + // The same window variables the window save code writes, by script name. + const auto windowVars = []( const drawWin_t &window, idList &vars ) { + vars.Clear(); + if ( window.win != NULL ) { + idWindow *w = window.win; + const uiSavedWindowVar_t fixed[] = { + { "notime", &w->noTime }, { "visible", &w->visible }, { "rect", &w->rect }, + { "backcolor", &w->backColor }, { "matcolor", &w->matColor }, { "forecolor", &w->foreColor }, + { "hovercolor", &w->hoverColor }, { "bordercolor", &w->borderColor }, { "textscale", &w->textScale }, + { "noevents", &w->noEvents }, { "rotate", &w->rotate }, { "text", &w->text }, + { "background", &w->backGroundName }, { "nocursor", &w->hideCursor }, + }; + for ( int i = 0; i < static_cast( sizeof( fixed ) / sizeof( fixed[ 0 ] ) ); i++ ) { + vars.Append( fixed[ i ] ); + } + for ( int i = 0; i < w->definedVars.Num(); i++ ) { + if ( w->definedVars[ i ] != NULL && w->definedVars[ i ]->GetName()[ 0 ] != '\0' ) { + const uiSavedWindowVar_t defined = { w->definedVars[ i ]->GetName(), w->definedVars[ i ] }; + vars.Append( defined ); + } + } + } else if ( window.simp != NULL ) { + idSimpleWindow *s = window.simp; + const uiSavedWindowVar_t fixed[] = { + { "text", &s->text }, { "visible", &s->visible }, { "rect", &s->rect }, + { "backcolor", &s->backColor }, { "matcolor", &s->matColor }, { "forecolor", &s->foreColor }, + { "bordercolor", &s->borderColor }, { "textscale", &s->textScale }, { "rotate", &s->rotate }, + { "shear", &s->shear }, { "background", &s->backGroundName }, { "nocursor", &s->hideCursor }, + }; + for ( int i = 0; i < static_cast( sizeof( fixed ) / sizeof( fixed[ 0 ] ) ); i++ ) { + vars.Append( fixed[ i ] ); + } + } + }; + const auto findVar = []( const idList &vars, const char *name ) -> idWinVar * { + for ( int i = 0; i < vars.Num(); i++ ) { + if ( idStr::Icmp( vars[ i ].name, name ) == 0 ) { + return vars[ i ].var; + } + } + return NULL; + }; + const auto findWindow = []( idWindow *root, const char *name, drawWin_t &found ) { + drawWin_t *match = root->FindChildByName( name ); + if ( match == NULL ) { + return false; + } + found = *match; // FindChildByName returns a shared static for the root itself + return true; + }; + + int changedValues = 0; + idList savedVars, initialVars, liveVars; + std::function transfer = [&]( const drawWin_t &savedWindow ) { + const char *windowName = savedWindow.win != NULL ? savedWindow.win->name.c_str() : savedWindow.simp->name.c_str(); + drawWin_t initialWindow; + drawWin_t liveWindow; + if ( windowName[ 0 ] != '\0' && findWindow( initial.desktop, windowName, initialWindow ) && + findWindow( desktop, windowName, liveWindow ) && + ( initialWindow.win != NULL ) == ( savedWindow.win != NULL ) ) { + windowVars( savedWindow, savedVars ); + windowVars( initialWindow, initialVars ); + windowVars( liveWindow, liveVars ); + for ( int i = 0; i < savedVars.Num(); i++ ) { + idWinVar *savedVar = savedVars[ i ].var; + idWinVar *initialVar = findVar( initialVars, savedVars[ i ].name ); + idWinVar *liveVar = findVar( liveVars, savedVars[ i ].name ); + // GUI-state-bound variables already arrived with the state dictionary. + if ( initialVar == NULL || liveVar == NULL || liveVar->GetDict() != NULL || + typeid( *savedVar ) != typeid( *initialVar ) || typeid( *savedVar ) != typeid( *liveVar ) || + idStr::Cmp( savedVar->c_str(), initialVar->c_str() ) == 0 ) { + continue; + } + common->DPrintf( "gui '%s': restored saved %s::%s '%s' (layout '%s' starts with '%s')\n", + source.c_str(), windowName, savedVars[ i ].name, savedVar->c_str(), layoutPath, initialVar->c_str() ); + liveVar->Set( savedVar->c_str() ); + changedValues++; + } + } + if ( savedWindow.win != NULL ) { + for ( int i = 0; i < savedWindow.win->drawWindows.Num(); i++ ) { + transfer( savedWindow.win->drawWindows[ i ] ); + } + } + }; + + // The Remastered peer rejoins the restored state at its next draw, as after + // an ordinary restore. + delete remastered; + remastered = NULL; + evaluatedHudValid = false; + state = saved.state; + active = saved.active; + interactive = saved.interactive; + uniqued = saved.uniqued; + time = saved.time; + activateStr = saved.activateStr; + pendingCmd = saved.pendingCmd; + returnCmd = saved.returnCmd; + + drawWin_t savedDesktop; + savedDesktop.simp = NULL; + savedDesktop.win = saved.desktop; + transfer( savedDesktop ); + SetCursor( saved.cursorX, saved.cursorY ); + + restoredWindowValues = changedValues; + return true; +} + size_t idUserInterfaceLocal::Size() { size_t sz = sizeof(*this) + state.Size() + evaluatedHudState.Allocated() + source.Allocated(); if ( levelLoadCache ) sz += levelLoadCache->Size(); diff --git a/src/ui/UserInterface.h b/src/ui/UserInterface.h index 3243270bb..b1248a39c 100644 --- a/src/ui/UserInterface.h +++ b/src/ui/UserInterface.h @@ -123,6 +123,12 @@ class idUserInterface { virtual float CursorY( void ) = 0; virtual bool GetMaxTextIndex( const char *windowName, const char *text, wrapInfo_t& wrapInfo ) const = 0; + // Restores a save written with an earlier definition of this GUI. A private + // parse of layoutPath consumes the saved window tree; the state dictionary, + // interaction flags, cursor, and every window value the save changed from that + // definition move to this GUI where a same-named window and variable exist. + // Appended last so the vtable slots above stay where earlier modules expect. + virtual bool ReadFromSaveGameWithLayout( idFile *savefile, const char *layoutPath, int &restoredWindowValues ) { restoredWindowValues = 0; return false; } }; diff --git a/src/ui/UserInterfaceDeferred.cpp b/src/ui/UserInterfaceDeferred.cpp index cc3bfe555..82e86684b 100644 --- a/src/ui/UserInterfaceDeferred.cpp +++ b/src/ui/UserInterfaceDeferred.cpp @@ -191,6 +191,13 @@ bool idUserInterfaceDeferred::ReadFromSaveGame( idFile *file ) { RefreshThinking(); return result; } +bool idUserInterfaceDeferred::ReadFromSaveGameWithLayout( idFile *file, const char *layoutPath, int &restoredWindowValues ) { + restoredWindowValues = 0; + if ( backend == NULL ) { return false; } + const bool result = backend->ReadFromSaveGameWithLayout( file, layoutPath, restoredWindowValues ); + RefreshThinking(); + return result; +} void idUserInterfaceDeferred::SetKeyBindingNames() { if ( backend != NULL ) { backend->SetKeyBindingNames(); } } void idUserInterfaceDeferred::SetCursor( float x, float y ) { if ( backend != NULL ) { backend->SetCursor( x, y ); return; } diff --git a/src/ui/UserInterfaceDeferred.h b/src/ui/UserInterfaceDeferred.h index e4be6d4cb..88d1a24ca 100644 --- a/src/ui/UserInterfaceDeferred.h +++ b/src/ui/UserInterfaceDeferred.h @@ -39,6 +39,7 @@ class idUserInterfaceDeferred final : public idUserInterfaceManaged { void Trigger( int time ) override; bool WriteToSaveGame( idFile *file ) const override; bool ReadFromSaveGame( idFile *file ) override; + bool ReadFromSaveGameWithLayout( idFile *file, const char *layoutPath, int &restoredWindowValues ) override; void SetKeyBindingNames() override; void SetCursor( float x, float y ) override; float CursorX() override; diff --git a/src/ui/UserInterfaceLocal.h b/src/ui/UserInterfaceLocal.h index e9b640a47..e2e9ceeb1 100644 --- a/src/ui/UserInterfaceLocal.h +++ b/src/ui/UserInterfaceLocal.h @@ -77,6 +77,7 @@ class idUserInterfaceLocal : public idUserInterfaceManaged { virtual void WriteToDemoFile( class idDemoFile *f ); virtual bool WriteToSaveGame( idFile *savefile ) const; virtual bool ReadFromSaveGame( idFile *savefile ); + virtual bool ReadFromSaveGameWithLayout( idFile *savefile, const char *layoutPath, int &restoredWindowValues ); virtual void SetKeyBindingNames( void ); virtual bool IsUniqued() const { return uniqued; }; virtual void SetUniqued( bool b ) { uniqued = b; }; diff --git a/tools/tests/savegame_gui_layout_contract.py b/tools/tests/savegame_gui_layout_contract.py index 8c7fe6cd9..354e22c6c 100644 --- a/tools/tests/savegame_gui_layout_contract.py +++ b/tools/tests/savegame_gui_layout_contract.py @@ -306,6 +306,10 @@ def main() -> None: for context, source in games.items(): if "SaveGame_FindGuiLayoutForPayload( name, openQ4SaveGameCompatibilityVersion )" not in source: raise AssertionError(f"{context} does not choose a GUI's saved layout by payload version") + # Copying only the state dictionary loses window values scripts set, such + # as a visible boss bar; the engine migration carries those as well. + if "ui->ReadFromSaveGameWithLayout( file, layout, restoredWindowValues )" not in source: + raise AssertionError(f"{context} does not restore older GUI layouts through the engine window migration") validate_release_provenance(previous) validate_build_number_scope(session, games) print("savegame_gui_layout_contract: ok") From d2e013718729f46d67641df1ca23b12fff90521b Mon Sep 17 00:00:00 2001 From: akacross Date: Fri, 16 Oct 2026 06:40:00 -0400 Subject: [PATCH 7/8] Record the boss-fight and interpolator evidence; add restore screenshots savegame_restore_matrix.py --screenshot captures the restored scene after the settle frames. The savegame policy, reliability notes and release list describe the HUD window-value migration, the interpolator layout regression and its guards, and the new restore results. Co-Authored-By: Claude Opus 5.5 --- docs/dev/release-completion.md | 8 ++++++++ docs/dev/savegame-compatibility-policy.md | 16 +++++++++------ docs/dev/savegame-reliability.md | 19 ++++++++++++++--- tools/tests/savegame_restore_matrix.py | 25 ++++++++++------------- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index 94d56de5a..c753fa1cc 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -14,6 +14,14 @@ source archive (build 0) or shallow clone (build 1) refused their own saves in preflight, and their payloads were read against the v3 liquid-field thresholds. The build floor and those thresholds now apply to format 3 only. +- [x] Keep interpolator layouts out of the save format's way. `f15a41f0` added a + cache flag to `idExtrapolate`/`idInterpolate`; animator joint modifiers and + running GUI transitions save those types as native images, so no save holding + a joint modifier (every player) restored on that build. A NaN cached time now + marks the cache stale, keeping the original layout and the negative-time fix, + and `static_assert`s beside both raw writes reject any future member. +- [x] Carry HUD window values through the format-3 HUD migration: a boss bar a + script had shown, and its health width, survive a format-3 load. - [x] Add `savegame_restore_matrix.py`, an opt-in runtime check that restores existing saves with a staged client, optionally under rewritten build numbers, and confirms the input files are unchanged. Linux x64 results, including real diff --git a/docs/dev/savegame-compatibility-policy.md b/docs/dev/savegame-compatibility-policy.md index 8334b8fc0..4bda8498f 100644 --- a/docs/dev/savegame-compatibility-policy.md +++ b/docs/dev/savegame-compatibility-policy.md @@ -67,12 +67,16 @@ release note's promise that supported format 3 saves still load was not true. Every v3 writer used the same two earlier definitions (unchanged from June 2026 through v0.13.2), so the payload version identifies them exactly. They ship unchanged as `guis/savegame/v3/hud.gui` and `guis/savegame/v3/hud_strogg.gui`. -For a v3 payload, both game libraries read those HUD window trees into a private -instance of the v3 definition, with every normal restore check, then move its -state dictionary, interaction flags and cursor to the live HUD. Window-local -animation progress is not carried over; the live HUD starts from its current -definition, as when a map spawns it. No other serialized openQ4 GUI changed -between v3 and v4. +For a v3 payload, both game libraries ask the engine to read those HUD window +trees through `idUserInterface::ReadFromSaveGameWithLayout`. The engine parses the +v3 definition twice in private: one copy consumes the saved window tree with every +normal restore check, the other shows what that definition starts with. The state +dictionary, interaction flags and cursor move to the live HUD, and so does every +window value the game had changed from the v3 definition, wherever the current +definition has a window and variable of the same name and type. A boss bar a +script had made visible, and its health width, therefore survive. Running +transitions and timeline progress are not carried over, so mid-flight HUD +animations restart. No other serialized openQ4 GUI changed between v3 and v4. `tools/tests/savegame_gui_layout_contract.py` pins every serialized openQ4 GUI by payload version and requires every openQ4 GUI to be classified as serialized or diff --git a/docs/dev/savegame-reliability.md b/docs/dev/savegame-reliability.md index faa5c51fd..a23f77a21 100644 --- a/docs/dev/savegame-reliability.md +++ b/docs/dev/savegame-reliability.md @@ -248,7 +248,15 @@ each of the SP and MP source trees (22 calls per tree): | `vehicle/VehiclePosition.cpp` | 5 | Engine-owned GUI/window and interpolation aggregates also retain fixed native -layout in parts of the positional payload. The table above is the exact locked +layout in parts of the positional payload. `jointMod_t` embeds +`idInterpolateAccelDecelLinear`, and a running GUI transition writes its +`idInterpolateAccelDecelLinear`, both as native images. Commit `f15a41f0` +(after 0.14.000) added a `bool` to `idExtrapolate` and `idInterpolate`, growing +each of those images by four bytes, so that build could not restore any v3 or v4 +save holding a joint modifier, which includes every player. The cache flag is now a +NaN cached time, restoring the original layout, and `static_assert`s beside both +raw writes require these types to contain only their declared members on every +ABI. Adding a member to them is a payload change. The table above is the exact locked GameLib *direct-call* inventory, not a claim that these are the only native-layout bytes reachable through all helper functions. @@ -399,9 +407,14 @@ build-878 player saves (not committed) and fresh map-start fixtures: | fixed | wrong ABI, payload v5, CRC damage, truncation, empty file, v3 build 1 | Each refused in preflight with its own reason; map not torn down | | v3-only 0.13.2-dev build 1109 | v4 saves | Refused in preflight as unsupported payload version 4 | +| main at `f15a41f0` plus the v3 HUD decoder | v3 builds 878 and 1109, including a Strogg boss fight | Desync in the player's animator (`saved joint count 0 does not match model joint count 65 for model 'model_player_marine' on entity 'player1'`) | +| fixed, after the interpolator layout restore (2026-10-16) | v3 build 878, v3 build 1109 `game/waste` boss fight, v4 build 1574 | All restored; the boss fight's HUD reports `Bossbar::visible` and its health width carried over | + Restores were checked by running at least 60 post-restore frames without an -error, not by inspecting gameplay state. Windows, macOS and ARM64 were not -exercised. +error. The `game/waste` boss-fight restore was also compared by screenshot: the +restored HUD shows the boss bar exactly as a boss fight started fresh on the same +build does. Other gameplay state was not inspected. Windows, macOS and ARM64 were +not exercised. Historical Linux x64/Wayland save/load evidence predates v3 and does not prove the current candidate on physical Linux hardware. ARM64 contract/build results diff --git a/tools/tests/savegame_restore_matrix.py b/tools/tests/savegame_restore_matrix.py index bd2e9de03..a12fc2c3f 100644 --- a/tools/tests/savegame_restore_matrix.py +++ b/tools/tests/savegame_restore_matrix.py @@ -46,6 +46,7 @@ ROOT = Path(__file__).resolve().parents[2] LOG_NAME = "savegame-restore-matrix.log" AFTER_LOAD_CFG = "savegame_restore_matrix/after_load.cfg" +SCREENSHOT_REL = "screenshots/savegame_restore_matrix.tga" ACTIVE_MARKER = "OPENQ4_RESTORE_MATRIX_ACTIVE" DONE_MARKER = "OPENQ4_RESTORE_MATRIX_DONE" PAYLOAD_MAGIC = b"OQ4S" @@ -162,21 +163,14 @@ def stage_slot(source: Path, home: Path, slot: str, payload: bytes | None) -> No target.write_bytes(payload) -def write_after_load_cfg(home: Path, settle_frames: int) -> None: +def write_after_load_cfg(home: Path, settle_frames: int, screenshot: bool) -> None: cfg = home / "baseoq4" / AFTER_LOAD_CFG cfg.parent.mkdir(parents=True, exist_ok=True) - cfg.write_text( - "\n".join( - ( - f"echo {ACTIVE_MARKER}", - f"wait {max(1, settle_frames)}", - f"echo {DONE_MARKER}", - "quit", - "", - ) - ), - encoding="utf-8", - ) + lines = [f"echo {ACTIVE_MARKER}", f"wait {max(1, settle_frames)}"] + if screenshot: + lines += [f'screenshot "{SCREENSHOT_REL}"', "wait 5"] + lines += [f"echo {DONE_MARKER}", "quit", ""] + cfg.write_text("\n".join(lines), encoding="utf-8") def runtime_text(home: Path, stdout_path: Path) -> str: @@ -194,7 +188,7 @@ def run_case( home = run_dir / "home" slot = "restore_matrix" stage_slot(source, home, slot, payload) - write_after_load_cfg(home, args.settle_frames) + write_after_load_cfg(home, args.settle_frames, args.screenshot) staged = home / "baseoq4" / "savegames" / f"{slot}.save" stamp = read_stamp(staged.read_bytes()) @@ -268,6 +262,7 @@ def run_case( "preflightRejections": rejections[:5], "seconds": round(time.monotonic() - started, 1), "runDir": str(run_dir), + "screenshot": str(home / "baseoq4" / SCREENSHOT_REL) if (home / "baseoq4" / SCREENSHOT_REL).is_file() else "", } @@ -283,6 +278,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="Also load a copy whose payload build number is rewritten to N.") parser.add_argument("--set", nargs=2, action="append", default=[], metavar=("CVAR", "VALUE"), help="Extra client cvar, for example --set ui_retained 1.") + parser.add_argument("--screenshot", action="store_true", + help="Capture the restored scene after the settle frames, for visual checks.") parser.add_argument("--expect-reject", action="store_true", help="Pass only when preflight refuses every save before map teardown.") parser.add_argument("--self-test", action="store_true", From 035b340920ad384d71568f473f14093d4768cd80 Mon Sep 17 00:00:00 2001 From: akacross Date: Sun, 18 Oct 2026 17:46:14 -0400 Subject: [PATCH 8/8] Carry each restored window value's evaluation flag through GUI revisions A script `set` stores its value and clears the variable's eval flag, so idRegister::GetFromRegs stops rewriting it from the window's expression; the flag is saved with the value. The revision migration copied only values, and only where they differed from the definition, so the live variable kept its freshly parsed eval and the next GUI update recomputed it. game/mcc_1's script hides the HUD at once (set "p_health::visible" "0" against ("gui::mp" == 0)); restoring a v3 save from there showed both panels again on the first update, p_armor because its saved 0 equals its definition's. The migration now carries a value whenever its value or eval flag differs from the saved definition's, and sets the live flag to the saved one. A variable that was still following its expression keeps doing so. hudGet (SP and MP, cheat) prints the local player's HUD value after evaluation, and savegame_restore_matrix.py gains --after-load commands and --expect-log patterns. With them the v3 mcc_1 save is restored, armor changed and 300 frames run: value-only transfer reads 1 for both panels, this reads 0 throughout. The boss-fight save keeps Bossbar::visible = 1 after 300 frames, and the 0.14.000 and v0.13.2 saves still restore. savegame_gui_layout_contract.py requires the flag transfer. The harness self-test keeps its scratch files under the repository's .tmp/. Co-Authored-By: Claude Opus 5.5 --- docs/dev/release-completion.md | 4 ++- docs/dev/savegame-compatibility-policy.md | 8 ++++-- docs/dev/savegame-reliability.md | 13 ++++++++- src/game/gamesys/SysCmds.cpp | 23 ++++++++++++++++ src/mpgame/gamesys/SysCmds.cpp | 23 ++++++++++++++++ src/ui/UserInterface.cpp | 19 +++++++++++--- src/ui/UserInterface.h | 3 ++- tools/tests/savegame_gui_layout_contract.py | 19 ++++++++++++++ tools/tests/savegame_restore_matrix.py | 29 ++++++++++++++++----- 9 files changed, 126 insertions(+), 15 deletions(-) diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index d040d88e0..c7b12652d 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -15,7 +15,9 @@ completed release material here and publish curated notes in `releases/`. windows: a save taken in a boss fight loaded without its boss bar, because `showBossBar` sets `BossBar::visible` and restoring the boss does not replay it. `idUserInterface::ReadFromSaveGameWithLayout` now carries every window value the - game changed from the saved definition, as well as the state, flags and cursor. + game changed from the saved definition, with whether it still follows its + expression, as well as the state, flags and cursor. A HUD a level script hid + (as `game/mcc_1` does from its start) stays hidden after loading an older save. - [ ] Add `savegame_gui_layout_contract.py`, which pins every serialized openQ4 GUI and the `guis/savecompat/` revisions, and `savegame_restore_matrix.py`, an opt-in runtime restore check whose `--self-test` runs in CI. diff --git a/docs/dev/savegame-compatibility-policy.md b/docs/dev/savegame-compatibility-policy.md index 6ec1d98ba..31dc587a8 100644 --- a/docs/dev/savegame-compatibility-policy.md +++ b/docs/dev/savegame-compatibility-policy.md @@ -212,8 +212,12 @@ record with every normal check, the other shows what that definition starts with The state dictionary, interaction flags and cursor carry over to the current GUI, and so does every window value the game had changed from the saved definition wherever the current GUI has a window and variable of the same name and type: a -boss bar a script made visible, and its health width, survive. Running transitions -and timeline progress restart. Both copies are then freed. The window check matters because a +boss bar a script made visible, and its health width, survive. Each value carries +its evaluation flag too. A script `set` clears that flag so the variable stops +following its expression, and the saved flag decides, even where the value equals +the definition's: a HUD panel a script hid stays hidden through later GUI updates, +while a variable still following its expression keeps doing so. Running +transitions and timeline progress restart. Both copies are then freed. The window check matters because a build of uncommitted work can carry the edit under an earlier build number (the build-1561 saves on hand do). GUI records hold no sync markers, so the next one bounds the search. The saved definitions are copies of `38bdc6872^` and must not diff --git a/docs/dev/savegame-reliability.md b/docs/dev/savegame-reliability.md index ac4ad9ca4..aea7b2aba 100644 --- a/docs/dev/savegame-reliability.md +++ b/docs/dev/savegame-reliability.md @@ -400,7 +400,18 @@ The harness and fixtures live under `.tmp/savecompat/` (`load_saves.py`, client, optionally restamped to other build numbers, never deletes anything and refuses inputs inside its output directory; `--self-test` runs in CI. `tools/tests/savegame_gui_layout_contract.py` pins every serialized openQ4 GUI and -the `guis/savecompat/` revisions, so editing one fails until it is classified. No save written by the +the `guis/savecompat/` revisions, so editing one fails until it is classified. + +GUI-revision window state, Linux x64, 2026-10-18. A v3 save from build 1109 on +`game/mcc_1`, whose map script sends `hideHud` (`set "p_health::visible" "0"` and +likewise for armor, overriding their expressions), was restored, armor was then +changed and 300 frames run, with `hudGet` reading the live HUD before and after +(`savegame_restore_matrix.py --after-load ... --expect-log ...`). Copying values +alone left both panels visible (`= 1`) from the first update, because their +evaluation flags stayed set and `p_armor`'s saved `0` equals its definition's. +Carrying the flags keeps both at `0` throughout. The same build keeps +`Bossbar::visible = 1` after 300 frames on the `game/waste` boss-fight save, and +restores the 0.14.000 build-1574 and v0.13.2 build-878 saves. No save written by the 0.6.95, 0.8.0, 0.10.0, 0.11.0 or 0.13.0 binaries themselves was available, and Linux and macOS old-save restores are untested. diff --git a/src/game/gamesys/SysCmds.cpp b/src/game/gamesys/SysCmds.cpp index dbfb4388d..1b8fd81e0 100644 --- a/src/game/gamesys/SysCmds.cpp +++ b/src/game/gamesys/SysCmds.cpp @@ -1441,6 +1441,28 @@ void Cmd_GetViewpos_f( const idCmdArgs &args ) { gameLocal.Printf( "(%s) %.1f\n", origin.ToString(), angles.yaw ); } +/* +================== +Cmd_HudGet_f + +Prints a window variable of the local player's own HUD, such as +p_armor::visible, after any expression evaluation it is subject to. +================== +*/ +static void Cmd_HudGet_f( const idCmdArgs &args ) { + idPlayer *player = gameLocal.GetLocalPlayer(); + if ( args.Argc() != 2 || player == NULL || player->hud == NULL ) { + gameLocal.Printf( "usage: hudGet with a local player HUD\n" ); + return; + } + idStr value; + if ( !player->hud->GetPresentationValue( args.Argv( 1 ), value ) ) { + gameLocal.Printf( "hudGet: unknown HUD variable '%s'\n", args.Argv( 1 ) ); + return; + } + gameLocal.Printf( "hudGet %s = %s\n", args.Argv( 1 ), value.c_str() ); +} + /* ================== Cmd_Viewpos_f @@ -4359,6 +4381,7 @@ void idGameLocal::InitConsoleCommands( void ) { cmdSystem->AddCommand( "kill", Cmd_Kill_f, CMD_FL_GAME, "kills the player" ); cmdSystem->AddCommand( "where", Cmd_GetViewpos_f, CMD_FL_GAME|CMD_FL_CHEAT, "prints the current view position" ); cmdSystem->AddCommand( "getviewpos", Cmd_GetViewpos_f, CMD_FL_GAME|CMD_FL_CHEAT, "prints the current view position" ); + cmdSystem->AddCommand( "hudGet", Cmd_HudGet_f, CMD_FL_GAME|CMD_FL_CHEAT, "prints a window variable of the local player's HUD" ); cmdSystem->AddCommand( "viewpos", Cmd_Viewpos_f, CMD_FL_GAME|CMD_FL_CHEAT, "prints the current view origin and angles" ); cmdSystem->AddCommand( "setviewpos", Cmd_SetViewpos_f, CMD_FL_GAME|CMD_FL_CHEAT, "sets the current view position" ); cmdSystem->AddCommand( "benchmarkViewSweep", Cmd_BenchmarkViewSweep_f, CMD_FL_GAME|CMD_FL_CHEAT, "pans the view a given number of degrees over a given number of milliseconds" ); diff --git a/src/mpgame/gamesys/SysCmds.cpp b/src/mpgame/gamesys/SysCmds.cpp index 02784ff3b..beb8a852a 100644 --- a/src/mpgame/gamesys/SysCmds.cpp +++ b/src/mpgame/gamesys/SysCmds.cpp @@ -1013,6 +1013,28 @@ static void Cmd_Kick_f( const idCmdArgs &args ) { cmdSystem->BufferCommandText( CMD_EXEC_NOW, va( "kick %d\n", player->entityNumber ) ); } +/* +================== +Cmd_HudGet_f + +Prints a window variable of the local player's own HUD, such as +p_armor::visible, after any expression evaluation it is subject to. +================== +*/ +static void Cmd_HudGet_f( const idCmdArgs &args ) { + idPlayer *player = gameLocal.GetLocalPlayer(); + if ( args.Argc() != 2 || player == NULL || player->hud == NULL ) { + gameLocal.Printf( "usage: hudGet with a local player HUD\n" ); + return; + } + idStr value; + if ( !player->hud->GetPresentationValue( args.Argv( 1 ), value ) ) { + gameLocal.Printf( "hudGet: unknown HUD variable '%s'\n", args.Argv( 1 ) ); + return; + } + gameLocal.Printf( "hudGet %s = %s\n", args.Argv( 1 ), value.c_str() ); +} + /* ================== Cmd_GetViewpos_f @@ -4049,6 +4071,7 @@ void idGameLocal::InitConsoleCommands( void ) { cmdSystem->AddCommand( "kill", Cmd_Kill_f, CMD_FL_GAME, "kills the player" ); cmdSystem->AddCommand( "where", Cmd_GetViewpos_f, CMD_FL_GAME|CMD_FL_CHEAT, "prints the current view position" ); cmdSystem->AddCommand( "getviewpos", Cmd_GetViewpos_f, CMD_FL_GAME|CMD_FL_CHEAT, "prints the current view position" ); + cmdSystem->AddCommand( "hudGet", Cmd_HudGet_f, CMD_FL_GAME|CMD_FL_CHEAT, "prints a window variable of the local player's HUD" ); cmdSystem->AddCommand( "setviewpos", Cmd_SetViewpos_f, CMD_FL_GAME|CMD_FL_CHEAT, "sets the current view position" ); cmdSystem->AddCommand( "gotolevelshot", Cmd_GotoLevelshot_f, CMD_FL_GAME|CMD_FL_CHEAT, "moves the view to the levelshot position" ); cmdSystem->AddCommand( "teleport", Cmd_Teleport_f, CMD_FL_GAME|CMD_FL_CHEAT, "teleports the player to an entity location", idGameLocal::ArgCompletion_EntityName ); diff --git a/src/ui/UserInterface.cpp b/src/ui/UserInterface.cpp index 3ddf64d05..88ff7b637 100644 --- a/src/ui/UserInterface.cpp +++ b/src/ui/UserInterface.cpp @@ -2067,13 +2067,24 @@ bool idUserInterfaceLocal::ReadFromSaveGameWithLayout( idFile *savefile, const c idWinVar *liveVar = findVar( liveVars, savedVars[ i ].name ); // GUI-state-bound variables already arrived with the state dictionary. if ( initialVar == NULL || liveVar == NULL || liveVar->GetDict() != NULL || - typeid( *savedVar ) != typeid( *initialVar ) || typeid( *savedVar ) != typeid( *liveVar ) || - idStr::Cmp( savedVar->c_str(), initialVar->c_str() ) == 0 ) { + typeid( *savedVar ) != typeid( *initialVar ) || typeid( *savedVar ) != typeid( *liveVar ) ) { continue; } - common->DPrintf( "gui '%s': restored saved %s::%s '%s' (layout '%s' starts with '%s')\n", - source.c_str(), windowName, savedVars[ i ].name, savedVar->c_str(), layoutPath, initialVar->c_str() ); + // A script `set` stores its value and clears eval, so the variable no + // longer follows its expression (idRegister::GetFromRegs skips it). That + // override matters even when the value equals the definition's: carry + // the saved eval with the value, or the next evaluation rewrites it. + // A variable still following its expression keeps eval and stays live. + const bool valueChanged = idStr::Cmp( savedVar->c_str(), initialVar->c_str() ) != 0; + const bool evalChanged = savedVar->GetEval() != initialVar->GetEval(); + if ( !valueChanged && !evalChanged ) { + continue; + } + common->DPrintf( "gui '%s': restored saved %s::%s '%s' eval %d (layout '%s' starts with '%s' eval %d)\n", + source.c_str(), windowName, savedVars[ i ].name, savedVar->c_str(), savedVar->GetEval() ? 1 : 0, + layoutPath, initialVar->c_str(), initialVar->GetEval() ? 1 : 0 ); liveVar->Set( savedVar->c_str() ); + liveVar->SetEval( savedVar->GetEval() ); changedValues++; } } diff --git a/src/ui/UserInterface.h b/src/ui/UserInterface.h index b1248a39c..8c1d4a790 100644 --- a/src/ui/UserInterface.h +++ b/src/ui/UserInterface.h @@ -126,7 +126,8 @@ class idUserInterface { // Restores a save written with an earlier definition of this GUI. A private // parse of layoutPath consumes the saved window tree; the state dictionary, // interaction flags, cursor, and every window value the save changed from that - // definition move to this GUI where a same-named window and variable exist. + // definition, together with whether it still follows its expression, move to + // this GUI where a same-named window and variable exist. // Appended last so the vtable slots above stay where earlier modules expect. virtual bool ReadFromSaveGameWithLayout( idFile *savefile, const char *layoutPath, int &restoredWindowValues ) { restoredWindowValues = 0; return false; } }; diff --git a/tools/tests/savegame_gui_layout_contract.py b/tools/tests/savegame_gui_layout_contract.py index 1e112a40f..21e6acf97 100644 --- a/tools/tests/savegame_gui_layout_contract.py +++ b/tools/tests/savegame_gui_layout_contract.py @@ -265,6 +265,24 @@ def validate_engine_migration() -> None: raise AssertionError(f"{context} still copies GUI state by hand beside the engine migration") +def validate_eval_transfer() -> None: + """A carried window value keeps whether it still follows its expression.""" + source = read(ROOT / "src" / "ui" / "UserInterface.cpp") + start = source.find("bool idUserInterfaceLocal::ReadFromSaveGameWithLayout(") + if start < 0: + raise AssertionError("idUserInterfaceLocal::ReadFromSaveGameWithLayout is missing") + body = source[start:source.index("\n}\n", start)] + # A script `set` clears eval so the expression stops rewriting the value; + # without it the next GUI update recomputes a hidden panel back into view. + for token in ( + "savedVar->GetEval() != initialVar->GetEval()", + "if ( !valueChanged && !evalChanged )", + "liveVar->SetEval( savedVar->GetEval() );", + ): + if token not in body: + raise AssertionError(f"GUI revision migration lacks {token!r}; restored overrides would be re-evaluated") + + def main() -> None: guis = shipped_guis() validate_classification(guis) @@ -272,6 +290,7 @@ def main() -> None: validate_revisions(guis) validate_release_provenance() validate_engine_migration() + validate_eval_transfer() print("savegame_gui_layout_contract: ok") diff --git a/tools/tests/savegame_restore_matrix.py b/tools/tests/savegame_restore_matrix.py index a12fc2c3f..0beea161c 100644 --- a/tools/tests/savegame_restore_matrix.py +++ b/tools/tests/savegame_restore_matrix.py @@ -163,12 +163,13 @@ def stage_slot(source: Path, home: Path, slot: str, payload: bytes | None) -> No target.write_bytes(payload) -def write_after_load_cfg(home: Path, settle_frames: int, screenshot: bool) -> None: +def write_after_load_cfg(home: Path, settle_frames: int, screenshot: bool, after_load: list[str]) -> None: cfg = home / "baseoq4" / AFTER_LOAD_CFG cfg.parent.mkdir(parents=True, exist_ok=True) lines = [f"echo {ACTIVE_MARKER}", f"wait {max(1, settle_frames)}"] if screenshot: lines += [f'screenshot "{SCREENSHOT_REL}"', "wait 5"] + lines += after_load lines += [f"echo {DONE_MARKER}", "quit", ""] cfg.write_text("\n".join(lines), encoding="utf-8") @@ -188,7 +189,7 @@ def run_case( home = run_dir / "home" slot = "restore_matrix" stage_slot(source, home, slot, payload) - write_after_load_cfg(home, args.settle_frames, args.screenshot) + write_after_load_cfg(home, args.settle_frames, args.screenshot, args.after_load) staged = home / "baseoq4" / "savegames" / f"{slot}.save" stamp = read_stamp(staged.read_bytes()) @@ -249,8 +250,14 @@ def run_case( passed = bool(rejections) and not restore_started and not errors outcome = "rejected" if passed else ("torn-down" if restore_started else "not-rejected") else: - passed = restore_started and DONE_MARKER in text and not errors and not rejections - outcome = "restored" if passed else ("timeout" if timed_out else "failed") + missing = [pattern for pattern in args.expect_log if not re.search(pattern, text, re.MULTILINE)] + passed = restore_started and DONE_MARKER in text and not errors and not rejections and not missing + if passed: + outcome = "restored" + elif restore_started and DONE_MARKER in text and not errors and not rejections: + outcome = "expectation-missing" + else: + outcome = "timeout" if timed_out else "failed" return { "label": label, "source": str(source), @@ -263,6 +270,7 @@ def run_case( "seconds": round(time.monotonic() - started, 1), "runDir": str(run_dir), "screenshot": str(home / "baseoq4" / SCREENSHOT_REL) if (home / "baseoq4" / SCREENSHOT_REL).is_file() else "", + "missingExpectedLog": [] if args.expect_reject else missing, } @@ -278,6 +286,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="Also load a copy whose payload build number is rewritten to N.") parser.add_argument("--set", nargs=2, action="append", default=[], metavar=("CVAR", "VALUE"), help="Extra client cvar, for example --set ui_retained 1.") + parser.add_argument("--after-load", action="append", default=[], metavar="COMMAND", + help="Console command to run after the settle frames, for example 'wait 300' or " + "'hudGet p_armor::visible'. Repeatable; runs in order before the client quits.") + parser.add_argument("--expect-log", action="append", default=[], metavar="REGEX", + help="A restore passes only if the client log matches this pattern. Repeatable.") parser.add_argument("--screenshot", action="store_true", help="Capture the restored scene after the settle frames, for visual checks.") parser.add_argument("--expect-reject", action="store_true", @@ -299,7 +312,10 @@ def refused(sources: list[Path], output_root: Path) -> bool: return True return False - with tempfile.TemporaryDirectory() as temp: + # Disposable files stay under the repository's .tmp/, as the project's tests do. + scratch_root = ROOT / ".tmp" + scratch_root.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="savegame-restore-matrix-selftest-", dir=scratch_root) as temp: base = Path(temp) out = base / "out" nested = out / "slot" / "slot.save" @@ -376,7 +392,8 @@ def main(argv: list[str]) -> int: stamp = result["stamp"] print(f"{'PASS' if result['passed'] else 'FAIL'} {label}: {result['outcome']} " f"(v{stamp.get('version', '?')} build {stamp.get('build', '?')} {stamp.get('wireAbi', 'unstamped')})" - + (f" -- {result['errors'][0]}" if result["errors"] else "")) + + (f" -- {result['errors'][0]}" if result["errors"] else "") + + (f" -- log lacks {result['missingExpectedLog']}" if result.get("missingExpectedLog") else "")) modified = [str(path) for path in inputs if not path.is_file() or sha256_file(path) != before[path]] report = {"client": str(client), "runDir": str(run_root), "results": results, "modifiedInputs": modified}