diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 89a29ada..15f93dde 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -328,6 +328,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 \ @@ -498,6 +499,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 @@ -715,6 +717,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 @@ -856,6 +859,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 7e3bc51b..879f805f 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -328,6 +328,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 \ @@ -498,6 +499,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 @@ -715,6 +717,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 @@ -856,6 +859,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/docs/dev/release-completion.md b/docs/dev/release-completion.md index 07a0c5ff..c7b12652 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -10,6 +10,21 @@ completed release material here and publish curated notes in `releases/`. ## Post-0.14.000 corrections +- [ ] Keep HUD window state when an older save's HUD is read through its earlier + definition. Copying only the GUI state dictionary lost values scripts set on + 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, 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. + +Candidate save release wording: Older saves taken during a boss fight now load +with the boss's health bar on screen. + - [x] Complete the stage-one baseline implementation and audit: use a shared high-resolution presentation phase for each SP/MP draw and precise OpenXR smooth-turn deltas; restore imported game regression coverage; reconcile diff --git a/docs/dev/savegame-compatibility-policy.md b/docs/dev/savegame-compatibility-policy.md index 3da2a4d8..31dc587a 100644 --- a/docs/dev/savegame-compatibility-policy.md +++ b/docs/dev/savegame-compatibility-policy.md @@ -206,9 +206,18 @@ only the current revision has: | `guis/hud_strogg.gui` | 1562 | `guis/savecompat/hud_strogg_r1.gui` | `hit_indicator_reduced` | A record from a payload whose layout build predates the revision, and whose blob -does not name that window, is read into a copy built from the saved definition. -Its state dictionary carries over to the current GUI, whose windows start from -their definition; the copy is then freed. The window check matters because a +does not name that window, is read through `idUserInterface::ReadFromSaveGameWithLayout`. +The engine parses the saved definition twice in private: one copy consumes the +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. 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 76c414cd..aea7b2ab 100644 --- a/docs/dev/savegame-reliability.md +++ b/docs/dev/savegame-reliability.md @@ -395,7 +395,23 @@ comes from an incompatible version.", and loaded the 0.12.0 checkpoint into Air Defense Bunker with the HUD restored through its earlier definition. The harness and fixtures live under `.tmp/savecompat/` (`load_saves.py`, -`menu_check.py`, `restamp_save.py`, `make_save.py`). No save written by the +`menu_check.py`, `restamp_save.py`, `make_save.py`). The in-repo +`tools/tests/savegame_restore_matrix.py` restores a set of saves with a staged +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. + +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/SaveGame.cpp b/src/game/gamesys/SaveGame.cpp index 5f225e17..51571f13 100644 --- a/src/game/gamesys/SaveGame.cpp +++ b/src/game/gamesys/SaveGame.cpp @@ -2076,26 +2076,21 @@ idRestoreGame::ReadUserInterfaceRevision A GUI record written for an earlier revision of its file (SaveGameReleases.h) follows that revision's structure, so it is read into a copy built from that -revision. Its state carries over to the current GUI, whose windows start from -their definition. +revision. Its state carries over to the current GUI, and so does every window +value the game had changed from that revision -- a boss bar a script made +visible, for example -- where the current GUI has the same window and variable. +Animation progress is not carried over. ================ */ void idRestoreGame::ReadUserInterfaceRevision( idUserInterface *ui, const openQ4SaveGameGuiRevision_t &revision ) { - idUserInterface *saved = uiManager->FindGui( revision.savedDefinition, true, true, true ); - if ( saved == NULL ) { - Error( "idRestoreGame::ReadUserInterface: '%s' needs its earlier definition '%s'", revision.name, revision.savedDefinition ); - } - if ( saved->ReadFromSaveGame( file ) == false ) { - uiManager->DeAlloc( saved ); - Error( "idSaveGame::ReadUserInterface: ui failed to read properly\n" ); - } - const idDict &state = saved->State(); - for ( int i = 0; i < state.GetNumKeyVals(); i++ ) { - const idKeyValue *kv = state.GetKeyVal( i ); - ui->SetStateString( kv->GetKey(), kv->GetValue() ); + const int offset = file->Tell(); + int restoredWindowValues = 0; + if ( !ui->ReadFromSaveGameWithLayout( file, revision.savedDefinition, restoredWindowValues ) ) { + Error( "idRestoreGame::ReadUserInterface: '%s' at offset %d failed to read with its earlier definition '%s'", + revision.name, offset, revision.savedDefinition ); } - uiManager->DeAlloc( saved ); - common->DPrintf( "Restored '%s' from a save written for its earlier definition\n", revision.name ); + common->DPrintf( "Restored '%s' from a save written for its earlier definition (%d changed window values)\n", + revision.name, restoredWindowValues ); } // RAVEN BEGIN diff --git a/src/game/gamesys/SysCmds.cpp b/src/game/gamesys/SysCmds.cpp index dbfb4388..1b8fd81e 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/SaveGame.cpp b/src/mpgame/gamesys/SaveGame.cpp index 1937fbb0..d0a128e8 100644 --- a/src/mpgame/gamesys/SaveGame.cpp +++ b/src/mpgame/gamesys/SaveGame.cpp @@ -2089,26 +2089,21 @@ idRestoreGame::ReadUserInterfaceRevision A GUI record written for an earlier revision of its file (SaveGameReleases.h) follows that revision's structure, so it is read into a copy built from that -revision. Its state carries over to the current GUI, whose windows start from -their definition. +revision. Its state carries over to the current GUI, and so does every window +value the game had changed from that revision -- a boss bar a script made +visible, for example -- where the current GUI has the same window and variable. +Animation progress is not carried over. ================ */ void idRestoreGame::ReadUserInterfaceRevision( idUserInterface *ui, const openQ4SaveGameGuiRevision_t &revision ) { - idUserInterface *saved = uiManager->FindGui( revision.savedDefinition, true, true, true ); - if ( saved == NULL ) { - Error( "idRestoreGame::ReadUserInterface: '%s' needs its earlier definition '%s'", revision.name, revision.savedDefinition ); - } - if ( saved->ReadFromSaveGame( file ) == false ) { - uiManager->DeAlloc( saved ); - Error( "idSaveGame::ReadUserInterface: ui failed to read properly\n" ); - } - const idDict &state = saved->State(); - for ( int i = 0; i < state.GetNumKeyVals(); i++ ) { - const idKeyValue *kv = state.GetKeyVal( i ); - ui->SetStateString( kv->GetKey(), kv->GetValue() ); + const int offset = file->Tell(); + int restoredWindowValues = 0; + if ( !ui->ReadFromSaveGameWithLayout( file, revision.savedDefinition, restoredWindowValues ) ) { + Error( "idRestoreGame::ReadUserInterface: '%s' at offset %d failed to read with its earlier definition '%s'", + revision.name, offset, revision.savedDefinition ); } - uiManager->DeAlloc( saved ); - common->DPrintf( "Restored '%s' from a save written for its earlier definition\n", revision.name ); + common->DPrintf( "Restored '%s' from a save written for its earlier definition (%d changed window values)\n", + revision.name, restoredWindowValues ); } // RAVEN BEGIN diff --git a/src/mpgame/gamesys/SysCmds.cpp b/src/mpgame/gamesys/SysCmds.cpp index 02784ff3..beb8a852 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 f5514cc7..88ff7b63 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,160 @@ 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 ) ) { + continue; + } + // 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++; + } + } + 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 3243270b..8c1d4a79 100644 --- a/src/ui/UserInterface.h +++ b/src/ui/UserInterface.h @@ -123,6 +123,13 @@ 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, 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/src/ui/UserInterfaceDeferred.cpp b/src/ui/UserInterfaceDeferred.cpp index cc3bfe55..82e86684 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 e4be6d4c..88d1a24c 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 e9b640a4..e2e9ceeb 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/src/ui/retained/Motion.h b/src/ui/retained/Motion.h index 3c45d113..cd094bea 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; diff --git a/tools/tests/savegame_gui_layout_contract.py b/tools/tests/savegame_gui_layout_contract.py new file mode 100644 index 00000000..21e6acf9 --- /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 source hash notices. 0.14.000 edited +both player HUDs and broke every earlier save's restore after map teardown. + +src/framework/SaveGameReleases.h keeps such a GUI's previous revision under +guis/savecompat/ and reads older records through it. This contract makes that +the only way a serialized GUI can change: + +* every openQ4 GUI is classified as serialized or never serialized; +* each serialized GUI's current definition matches its pin, or a recorded + layout-neutral revision of it; +* each revision in OPENQ4_SAVEGAME_GUI_REVISIONS keeps exactly the definition + the older builds shipped (its explanatory header aside), and its marker window + exists only in the current definition, which is how the reader tells the two + records apart; and +* both game libraries read such records through the engine migration, which + carries changed window values (a visible boss bar) as well as GUI state. + +Changing a pinned GUI fails here until it is classified: prove the window tree +is unchanged and record it as layout-neutral, or keep the previous definition as +a new revision and pin the new one. +""" + +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" +REVISION_ROOT = "guis/savecompat" + +# Git blob ids (LF-normalized) of every openQ4 GUI a save writes: player HUDs, +# objective and cinematic GUIs, weapon and vehicle zoom GUIs and vehicle HUDs. +SERIALIZED_GUIS: dict[str, str] = { + "guis/cinematic.gui": "63d5f187afe545a3533f14780605a7e614463f5c", + "guis/hud.gui": "51c029611142acc27f7e0e03c2f09791d108994e", + "guis/hud_strogg.gui": "98430b36f89e9b2887eff70d49980178cca8a0f8", + "guis/vehicles/airdef_cannon.gui": "e92ae101f581b82a2885ac970b8755a92e1cbcae", + "guis/vehicles/hud.gui": "fa96e0421d400e4e880236fe27b623bdfd2e7e2e", + "guis/vehicles/strogg_noise.gui": "d0777427b93ec45c90c6dfdf3ba6352aec981dc6", + "guis/weapons/machinegun_scope.gui": "d2690deda0fc05b110508071a34051631df8c1c9", + "guis/weapons/nailgun_scope.gui": "ae6876c4199d6d252ea11d80ce284bc6d145d34a", + "guis/weapons/railgun_scope.gui": "baaa7a75e8daac09597f201c6836a54dafd19f9b", + "guis/wristcomm.gui": "1f4d8e60cfcd56a46c86b1226240f7b097f30f37", + "guis/wristcomm_strogg.gui": "226aeadf3791732aaf07b8793edb387ade4be03b", +} + +# 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_SAVEGAME_GUI_REVISIONS as it must read, with the blob each saved +# definition keeps and the commit whose parent shipped it. +GUI_REVISIONS: dict[str, dict[str, object]] = { + "guis/hud.gui": { + "firstBuild": 1562, + "savedDefinition": "guis/savecompat/hud_r1.gui", + "marker": "hit_indicator_reduced", + "savedBlob": "d403389d3b651fb9dd536bae1cbfe41e950c6f3c", + "shippedBefore": "38bdc687", + }, + "guis/hud_strogg.gui": { + "firstBuild": 1562, + "savedDefinition": "guis/savecompat/hud_strogg_r1.gui", + "marker": "hit_indicator_reduced", + "savedBlob": "cfea8778e2824f7b04348c3bc1f63e6d2fa530bc", + "shippedBefore": "38bdc687", + }, +} + +# 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", + "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", +} + +REVISION_ENTRY = re.compile( + r'\{\s*"(guis/[^"]+\.gui)"\s*,\s*(\d+)\s*,\s*"(guis/savecompat/[^"]+\.gui)"\s*,\s*"([^"]+)"\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 strip_revision_header(data: bytes) -> bytes: + """Drop the leading // lines that explain why a saved revision exists.""" + lines = data.replace(b"\r\n", b"\n").split(b"\n") + index = 0 + while index < len(lines) and lines[index].startswith(b"//"): + index += 1 + if index and index < len(lines) and lines[index] == b"": + index += 1 + return b"\n".join(lines[index:]) + + +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 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(REVISION_ROOT + "/"): + continue + if relative in guis: + raise AssertionError(f"{relative} ships in more than one pak") + guis[relative] = path + return guis + + +def window_names(text: str) -> set[str]: + return {name.lower() for name in re.findall(r"windowDef\s+([A-Za-z0-9_]+)", text)} + + +def validate_classification(guis: dict[str, Path]) -> None: + serialized = set(SERIALIZED_GUIS) + 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_GUIS 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_current_pins(guis: dict[str, Path]) -> None: + for gui, pin in sorted(SERIALIZED_GUIS.items()): + blob = git_blob_id(guis[gui].read_bytes()) + neutral = LAYOUT_NEUTRAL_EDITS.get(gui, {}).get(blob) + if blob == pin or (neutral is not None and neutral[0] == pin): + continue + raise AssertionError( + f"{gui} changed (blob {blob}, pinned {pin}). Its window tree is serialized positionally, " + "so this edit can move every later byte of a save written before it. If it adds, removes " + "or reorders windows, defined vars, scripts, script statements with owned values, timeline " + f"or named events, keep the pinned definition under {REVISION_ROOT}/ as a new revision in " + "OPENQ4_SAVEGAME_GUI_REVISIONS and pin the new one. Record it as layout-neutral only after " + "proving the window tree is unchanged." + ) + + +def validate_revisions(guis: dict[str, Path]) -> None: + releases = read(ROOT / "src" / "framework" / "SaveGameReleases.h") + match = re.search(r"OPENQ4_SAVEGAME_GUI_REVISIONS\[\]\s*=\s*\{(?P.*?)\n\};", releases, re.DOTALL) + if match is None: + raise AssertionError("SaveGameReleases.h has no OPENQ4_SAVEGAME_GUI_REVISIONS table") + table = { + gui: {"firstBuild": int(build), "savedDefinition": saved, "marker": marker} + for gui, build, saved, marker in REVISION_ENTRY.findall(match.group("body")) + } + expected = { + gui: {key: entry[key] for key in ("firstBuild", "savedDefinition", "marker")} + for gui, entry in GUI_REVISIONS.items() + } + if table != expected: + raise AssertionError(f"OPENQ4_SAVEGAME_GUI_REVISIONS {table} does not match the contract {expected}") + + for gui, entry in sorted(GUI_REVISIONS.items()): + if gui not in SERIALIZED_GUIS: + raise AssertionError(f"{gui} has a saved revision but is not classified as serialized") + saved_path = PAK0 / str(entry["savedDefinition"]) + if not saved_path.is_file(): + raise AssertionError(f"{entry['savedDefinition']} is missing; saves written before build " + f"{entry['firstBuild']} could not restore {gui}") + saved_blob = git_blob_id(strip_revision_header(saved_path.read_bytes())) + if saved_blob != entry["savedBlob"]: + raise AssertionError(f"{entry['savedDefinition']} is {saved_blob} without its header, not the " + f"definition earlier builds shipped ({entry['savedBlob']})") + marker = str(entry["marker"]).lower() + if marker not in window_names(guis[gui].read_text(encoding="latin-1")): + raise AssertionError(f"{gui} no longer has marker window '{entry['marker']}'; the reader could " + "not tell its records from the earlier revision's") + if marker in window_names(saved_path.read_text(encoding="latin-1")): + raise AssertionError(f"{entry['savedDefinition']} also has marker window '{entry['marker']}'") + + shipped = {path.relative_to(PAK0).as_posix() for path in (PAK0 / REVISION_ROOT).glob("**/*.gui")} + orphaned = shipped - {str(entry["savedDefinition"]) for entry in GUI_REVISIONS.values()} + if orphaned: + raise AssertionError(f"Saved GUI revisions no reader uses: {sorted(orphaned)}") + + +def validate_release_provenance() -> None: + """Optional: each saved revision is what the commit before its change shipped.""" + for gui, entry in sorted(GUI_REVISIONS.items()): + result = subprocess.run( + ["git", "-C", str(ROOT), "rev-parse", "--verify", "--quiet", + f"{entry['shippedBefore']}^:content/baseoq4/pak0/{gui}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print("savegame_gui_layout_contract: history not available; skipped revision provenance") + return + if result.stdout.strip() != entry["savedBlob"]: + raise AssertionError(f"{gui} revision pin {entry['savedBlob']} is not what " + f"{entry['shippedBefore']}^ shipped ({result.stdout.strip()})") + + +def validate_engine_migration() -> None: + for tree, context in (("game", "SP GameLib"), ("mpgame", "MP GameLib")): + source = read(ROOT / "src" / tree / "gamesys" / "SaveGame.cpp") + start = source.find("void idRestoreGame::ReadUserInterfaceRevision(") + if start < 0: + raise AssertionError(f"{context} has no GUI revision reader") + body = source[start:source.index("\n}\n", start)] + # Copying only the state dictionary loses window values scripts set, + # such as a visible boss bar; the engine migration carries those too. + if "ui->ReadFromSaveGameWithLayout( file, revision.savedDefinition, restoredWindowValues )" not in body: + raise AssertionError(f"{context} does not read earlier GUI revisions through the engine window migration") + if "SetStateString" in body: + 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) + validate_current_pins(guis) + validate_revisions(guis) + validate_release_provenance() + validate_engine_migration() + validate_eval_transfer() + 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 00000000..0beea161 --- /dev/null +++ b/tools/tests/savegame_restore_matrix.py @@ -0,0 +1,410 @@ +#!/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. 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 + +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 +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" +SCREENSHOT_REL = "screenshots/savegame_restore_matrix.tga" +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 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) + 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, 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") + + +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, run_dir: Path +) -> dict[str, Any]: + run_dir.mkdir() + home = run_dir / "home" + slot = "restore_matrix" + stage_slot(source, home, slot, payload) + 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()) + + 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: + 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), + "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), + "screenshot": str(home / "baseoq4" / SCREENSHOT_REL) if (home / "baseoq4" / SCREENSHOT_REL).is_file() else "", + "missingExpectedLog": [] if args.expect_reject else missing, + } + + +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("--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", + help="Pass only when preflight refuses every save before map teardown.") + 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 + + # 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" + 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() + + 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 = {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: + 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 "") + + (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} + (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"]] + 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/validation/openq4_validate.py b/tools/validation/openq4_validate.py index b7b756da..c95f8c4f 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -600,6 +600,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", @@ -757,6 +758,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: