From b18a0830f3be7b9a7368344c5e212b00dcf8a0a0 Mon Sep 17 00:00:00 2001 From: Elad <18193363+elad335@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:34:49 +0200 Subject: [PATCH] Multi-Slot Savestates --- .../HomeMenu/overlay_home_menu_savestate.cpp | 6 +- rpcs3/Emu/System.cpp | 13 +- rpcs3/Emu/savestate_utils.cpp | 215 ++++++++++++++---- rpcs3/Emu/savestate_utils.hpp | 4 +- rpcs3/Emu/system_config.h | 2 + rpcs3/rpcs3qt/game_list_frame.cpp | 13 +- rpcs3/rpcs3qt/game_list_frame.h | 1 + rpcs3/rpcs3qt/gs_frame.cpp | 19 +- rpcs3/rpcs3qt/main_window.cpp | 11 + rpcs3/rpcs3qt/shortcut_settings.cpp | 8 + rpcs3/rpcs3qt/shortcut_settings.h | 4 + 11 files changed, 246 insertions(+), 50 deletions(-) diff --git a/rpcs3/Emu/RSX/Overlays/HomeMenu/overlay_home_menu_savestate.cpp b/rpcs3/Emu/RSX/Overlays/HomeMenu/overlay_home_menu_savestate.cpp index e224bc5727..c97373d8a0 100644 --- a/rpcs3/Emu/RSX/Overlays/HomeMenu/overlay_home_menu_savestate.cpp +++ b/rpcs3/Emu/RSX/Overlays/HomeMenu/overlay_home_menu_savestate.cpp @@ -2,8 +2,8 @@ #include "overlay_home_menu_savestate.h" #include "overlay_home_menu_components.h" #include "Emu/system_config.h" +#include "Emu/savestate_utils.hpp" -extern bool boot_last_savestate(bool testing); namespace rsx { @@ -35,7 +35,7 @@ namespace rsx return page_navigation::exit; }); - if (!suspend_mode && boot_last_savestate(true)) { + if (!suspend_mode && boot_current_game_savestate(true, 1)) { std::unique_ptr reload_state = std::make_unique( get_localized_string(localized_string_id::HOME_MENU_RELOAD_SAVESTATE)); @@ -43,7 +43,7 @@ namespace rsx { if (btn != pad_button::cross) return page_navigation::stay; rsx_log.notice("User selected reload savestate in home menu"); - Emu.CallFromMainThread([]() { boot_last_savestate(false); }); + Emu.CallFromMainThread([]() { boot_current_game_savestate(true, 1); }); return page_navigation::exit; }); } diff --git a/rpcs3/Emu/System.cpp b/rpcs3/Emu/System.cpp index 233a6bb0ff..2721cff539 100644 --- a/rpcs3/Emu/System.cpp +++ b/rpcs3/Emu/System.cpp @@ -2529,7 +2529,7 @@ void Emulator::FixGuestTime() // Mark a known savestate location and the one we try to boot (in case we boot a moved/copied savestate) if (g_cfg.savestate.suspend_emu) { - for (std::string old_path : std::initializer_list{m_ar ? m_path_old : "", m_title_id.empty() ? "" : get_savestate_file(m_title_id, m_path_old, 0, 0)}) + for (std::string old_path : std::initializer_list{m_ar ? m_path_old : "", m_title_id.empty() ? "" : get_savestate_file(m_title_id, m_path_old, -1)}) { if (old_path.empty()) { @@ -3390,7 +3390,7 @@ void Emulator::Kill(bool allow_autoexit, bool savestate, savestate_stage* save_s { set_progress_message("Creating File"); - path = get_savestate_file(m_title_id, m_path, 0, 0); + path = get_savestate_file(m_title_id, m_path, 0, umax); // The function is meant for reading files, so if there is no ZST file it would not return compressed file path // So this is the only place where the result is edited if need to be @@ -3630,13 +3630,20 @@ void Emulator::Kill(bool allow_autoexit, bool savestate, savestate_stage* save_s sys_log.success("Old savestate has been removed: path='%s'", old_path2); } - sys_log.success("Saved savestate! path='%s' (file_size=0x%x, time_to_save=%gs)", path, file_stat.size, (get_system_time() - start_time) / 1000000.); + sys_log.success("Saved savestate! path='%s' (file_size=0x%x (%d MiB), time_to_save=%gs)", path, file_stat.size, utils::aligned_div(file_stat.size, 1u << 20), (get_system_time() - start_time) / 1000000.); if (!g_cfg.savestate.suspend_emu) { // Allow to reboot from GUI m_path = path; } + + // Clean savestates + // Cap by number and aggregate file size + const u64 max_files = g_cfg.savestate.max_files; + const u64 max_files_size_mb = g_cfg.savestate.max_files_size; + + clean_savestates(m_title_id, m_path, max_files, max_files_size_mb << 20); } } diff --git a/rpcs3/Emu/savestate_utils.cpp b/rpcs3/Emu/savestate_utils.cpp index 31b24eba5f..5add3e8c6b 100644 --- a/rpcs3/Emu/savestate_utils.cpp +++ b/rpcs3/Emu/savestate_utils.cpp @@ -270,17 +270,146 @@ bool is_savestate_version_compatible(const std::vector& data, boo return ok; } -std::string get_savestate_file(std::string_view title_id, std::string_view boot_path, s64 abs_id, s64 rel_id) +std::string get_savestate_file(std::string_view title_id, std::string_view boot_path, s64 rel_id, u64 aggregate_file_size) { const std::string title = std::string{title_id.empty() ? boot_path.substr(boot_path.find_last_of(fs::delim) + 1) : title_id}; - if (abs_id == -1 && rel_id == -1) + // Internal functionality ATM + constexpr s64 abs_id = 0; + + if (aggregate_file_size == umax && rel_id == -1) { // Return directory return fs::get_config_dir() + "savestates/" + title + "/"; } - ensure(rel_id < 0 || abs_id >= 0, "Unimplemented!"); + if (rel_id >= 0) + { + std::string dir_path = fs::get_config_dir() + "savestates/" + title + "/"; + + fs::dir dir_view{dir_path}; + + std::map> save_files; + + for (auto&& dir_entry : dir_view) + { + if (dir_entry.is_directory || dir_entry.size <= 1024) + { + continue; + } + + const std::string& entry = dir_entry.name; + + if (!title_id.empty() && !entry.starts_with(title + "_")) + { + // Check prefix only for certified applications + // Because ELF file names can be long and unhelpful + continue; + } + + if (entry.ends_with(".SAVESTAT.zst") || entry.ends_with(".SAVESTAT.gz") || entry.ends_with(".SAVESTAT")) + { + if (usz dot_idx = entry.rfind(".SAVESTAT"); dot_idx && dot_idx != umax) + { + if (usz uc_pos = entry.rfind("_", dot_idx - 1); uc_pos != umax && uc_pos + 1 < dot_idx) + { + if (std::all_of(entry.begin() + uc_pos + 1, entry.begin() + dot_idx, [](char c) { return c >= '0' && c <= '9'; })) + { + save_files.emplace(entry, dir_entry.size); + } + } + } + } + } + + std::string rel_path; + std::string size_based_path; + + auto prepare_return_value = [](std::string& dir_path, std::string& filename) -> std::string&& + { + dir_path.append(filename); + filename = std::move(dir_path); + return std::move(filename); + }; + + if (rel_id > 0) + { + if (static_cast(rel_id - 1) < save_files.size()) + { + rel_path = std::next(save_files.begin(), rel_id - 1)->first; + } + } + + if (aggregate_file_size != umax) + { + usz size_sum = 0; + + for (auto&& [path, size] : save_files) + { + if (size_sum >= aggregate_file_size) + { + size_based_path = path; + break; + } + + size_sum += size; + } + } + + if (!rel_path.empty() || !size_based_path.empty()) + { + if (rel_path > size_based_path) + { + return prepare_return_value(dir_path, rel_path); + } + else + { + return prepare_return_value(dir_path, size_based_path); + } + } + + if (rel_id > 0 || aggregate_file_size != umax) + { + return {}; + } + + // Increment number in string in reverse + // Return index of new character if appended a character, umax otherwise + auto increment_string = [](std::string& out, usz pos) -> usz + { + while (pos != umax && out[pos] == '9') + { + out[pos] = '0'; + pos--; + } + + if (pos == umax || (out[pos] < '0' || out[pos] > '9')) + { + out.insert(out.begin() + (pos + 1), '1'); + return pos + 1; + } + + out[pos]++; + return umax; + }; + + if (!save_files.empty()) + { + std::string last_entry = save_files.begin()->first; + + // Increment entry ID + if (usz inc_pos = increment_string(last_entry, last_entry.rfind(".SAVESTAT") - 1); inc_pos != umax) + { + // Increment entry suffix - ID has become wider in length (keeps the string in alphbetic ordering) + ensure(inc_pos >= 2); + ensure(last_entry[inc_pos - 2]++ != 'z'); + } + + return prepare_return_value(dir_path, last_entry); + } + + // Fallback - create new file + } const std::string save_id = fmt::format("%d", abs_id); @@ -335,48 +464,22 @@ std::vector read_used_savestate_versions() return used_serial; } -bool boot_last_savestate(bool testing) +bool boot_current_game_savestate(bool testing, u32 index) { + if (index == 0 || index > g_cfg.savestate.max_files.get()) + { + return false; + } + if (!g_cfg.savestate.suspend_emu && !Emu.GetTitleID().empty() && (Emu.IsRunning() || Emu.GetStatus() == system_state::paused)) { - const std::string save_dir = get_savestate_file(Emu.GetTitleID(), Emu.GetBoot(), -1, -1); - - std::string savestate_path; - s64 mtime = smin; - - for (auto&& entry : fs::dir(save_dir)) - { - if (entry.is_directory) - { - continue; - } - - // Find the latest savestate file compatible with the game (TODO: Check app version and anything more) - if (entry.name.find(Emu.GetTitleID()) != umax && mtime <= entry.mtime) - { - if (std::string path = save_dir + entry.name + ".zst"; is_savestate_compatible(fs::file(path), path)) - { - savestate_path = std::move(path); - mtime = entry.mtime; - } - else if (std::string path = save_dir + entry.name + ".gz"; is_savestate_compatible(fs::file(path), path)) - { - savestate_path = std::move(path); - mtime = entry.mtime; - } - else if (std::string path = save_dir + entry.name; is_savestate_compatible(fs::file(path), path)) - { - savestate_path = std::move(path); - mtime = entry.mtime; - } - } - } + const std::string savestate_path = get_savestate_file(Emu.GetTitleID(), Emu.GetBoot(), index); const bool result = fs::is_file(savestate_path); if (testing) { - sys_log.trace("boot_last_savestate(true) returned %s.", result); + sys_log.trace("boot_current_game_savestate(true, %d) returned %s.", index, result); return result; } @@ -398,12 +501,48 @@ bool boot_last_savestate(bool testing) } } - sys_log.error("No compatible savestate file found in \'%s\''", save_dir); + sys_log.error("No compatible savestate file found in \'%s\''", get_savestate_file(Emu.GetTitleID(), Emu.GetBoot(), -1)); } return false; } +void clean_savestates(std::string_view title_id, std::string_view boot_path, usz max_files, usz max_files_size) +{ + ensure(max_files && max_files != umax); + + bool logged_limits = false; + + while (true) + { + const std::string to_remove = get_savestate_file(title_id, boot_path, max_files + 1, max_files_size == 0 ? u64{umax} : max_files_size); + + if (to_remove.empty()) + { + break; + } + + if (!fs::remove_file(to_remove)) + { + sys_log.error("Failed to remove savestate file at '%s'! (error: %s)", to_remove, fs::g_tls_error); + break; + } + else + { + if (!logged_limits) + { + sys_log.success("Maximum save state files set: %d.\nMaximum save state disk space set: %d (MiB).\nRemoved old savestate file at '%s'.\n" + , max_files, max_files_size, to_remove); + logged_limits = true; + } + else + { + sys_log.success("Removed old savestate file at '%s'.", to_remove); + } + } + } +} + bool load_and_check_reserved(utils::serial& ar, usz size) { u8 bytes[4096]; diff --git a/rpcs3/Emu/savestate_utils.hpp b/rpcs3/Emu/savestate_utils.hpp index 18028ae9ec..2a43861766 100644 --- a/rpcs3/Emu/savestate_utils.hpp +++ b/rpcs3/Emu/savestate_utils.hpp @@ -44,4 +44,6 @@ std::vector get_savestate_versioning_data(fs::file&& file, std::s bool is_savestate_compatible(fs::file&& file, std::string_view filepath); bool is_savestate_compatible(const std::string& filepath); std::vector read_used_savestate_versions(); -std::string get_savestate_file(std::string_view title_id, std::string_view boot_path, s64 abs_id, s64 rel_id); +std::string get_savestate_file(std::string_view title_id, std::string_view boot_path, s64 rel_id, u64 aggregate_file_size = umax); +bool boot_current_game_savestate(bool testing, u32 index); +void clean_savestates(std::string_view title_id, std::string_view boot_path, usz max_files, usz max_files_size); diff --git a/rpcs3/Emu/system_config.h b/rpcs3/Emu/system_config.h index f5122335e4..8a4978229b 100644 --- a/rpcs3/Emu/system_config.h +++ b/rpcs3/Emu/system_config.h @@ -338,6 +338,8 @@ struct cfg_root : cfg::node cfg::_bool compatible_mode{ this, "Compatible Savestate Mode", false }; // SPU emulation optimized for savestate compatibility (off by default for performance reasons) cfg::_bool state_inspection_mode{ this, "Inspection Mode Savestates" }; // Save memory stored in executable files, thus allowing to view state without any files (for debugging) cfg::_bool save_disc_game_data{ this, "Save Disc Game Data", false }; + cfg::uint<0, 64> max_files{ this, "Maximum SaveState Files", 4 }; + cfg::uint<0, 1024 * 512> max_files_size{ this, "Maximum SaveState Files Space (MiB)", 4096 }; } savestate{this}; struct node_misc : cfg::node diff --git a/rpcs3/rpcs3qt/game_list_frame.cpp b/rpcs3/rpcs3qt/game_list_frame.cpp index 3da34c832a..ac9f58e329 100644 --- a/rpcs3/rpcs3qt/game_list_frame.cpp +++ b/rpcs3/rpcs3qt/game_list_frame.cpp @@ -46,7 +46,7 @@ LOG_CHANNEL(sys_log, "SYS"); extern atomic_t g_system_progress_canceled; -std::string get_savestate_file(std::string_view title_id, std::string_view boot_pat, s64 abs_id, s64 rel_id); +std::string get_savestate_file(std::string_view title_id, std::string_view boot_pat, s64 rel_id, u64 aggregate_file_size = umax); game_list_frame::game_list_frame(std::shared_ptr gui_settings, std::shared_ptr emu_settings, std::shared_ptr persistent_settings, QWidget* parent) : custom_dock_widget(tr("Game List"), parent) @@ -1221,13 +1221,20 @@ void game_list_frame::ShowContextMenu(const QPoint &pos) extern bool is_savestate_compatible(const std::string& filepath); - if (const std::string sstate = get_savestate_file(current_game.serial, current_game.path, 0, 0); is_savestate_compatible(sstate)) + if (const std::string sstate = get_savestate_file(current_game.serial, current_game.path, 1); is_savestate_compatible(sstate)) { QAction* boot_state = menu.addAction(is_current_running_game ? tr("&Reboot with savestate") : tr("&Boot with savestate")); - connect(boot_state, &QAction::triggered, [this, gameinfo, sstate] + connect(boot_state, &QAction::triggered, [this, gameinfo, sstate, current_game] { + if (!get_savestate_file(current_game.serial, current_game.path, 2).empty()) + { + // If there is any ambiguity, launch the savestate manager + Q_EMIT RequestSaveStateManager(gameinfo); + return; + } + sys_log.notice("Booting savestate from gamelist per context menu..."); Q_EMIT RequestBoot(gameinfo, cfg_mode::custom, "", sstate); }); diff --git a/rpcs3/rpcs3qt/game_list_frame.h b/rpcs3/rpcs3qt/game_list_frame.h index 5e4156f09d..0252cbf84f 100644 --- a/rpcs3/rpcs3qt/game_list_frame.h +++ b/rpcs3/rpcs3qt/game_list_frame.h @@ -95,6 +95,7 @@ Q_SIGNALS: void NotifyEmuSettingsChange(); void FocusToSearchBar(); void Refreshed(); + void RequestSaveStateManager(const game_info& game); public: template diff --git a/rpcs3/rpcs3qt/gs_frame.cpp b/rpcs3/rpcs3qt/gs_frame.cpp index 2c9e573331..18e304e393 100644 --- a/rpcs3/rpcs3qt/gs_frame.cpp +++ b/rpcs3/rpcs3qt/gs_frame.cpp @@ -9,6 +9,7 @@ #include "Emu/System.h" #include "Emu/system_config.h" #include "Emu/system_progress.hpp" +#include "Emu/savestate_utils.hpp" #include "Emu/IdManager.h" #include "Emu/Audio/audio_utils.h" #include "Emu/Cell/Modules/cellScreenshot.h" @@ -315,6 +316,10 @@ void gs_frame::handle_shortcut(gui::shortcuts::shortcut shortcut_key, const QKey break; } case gui::shortcuts::shortcut::gw_restart: + case gui::shortcuts::shortcut::gw_savestate_1: + case gui::shortcuts::shortcut::gw_savestate_2: + case gui::shortcuts::shortcut::gw_savestate_3: + case gui::shortcuts::shortcut::gw_savestate_4: { if (Emu.IsStopped()) { @@ -322,8 +327,18 @@ void gs_frame::handle_shortcut(gui::shortcuts::shortcut shortcut_key, const QKey return; } - extern bool boot_last_savestate(bool testing); - boot_last_savestate(false); + u32 index = 1; + + switch (shortcut_key) + { + case gui::shortcuts::shortcut::gw_restart: index = 1; break; + case gui::shortcuts::shortcut::gw_savestate_1: index = 1; break; + case gui::shortcuts::shortcut::gw_savestate_2: index = 2; break; + case gui::shortcuts::shortcut::gw_savestate_3: index = 3; break; + case gui::shortcuts::shortcut::gw_savestate_4: index = 4; break; + } + + boot_current_game_savestate(false, index); break; } case gui::shortcuts::shortcut::gw_savestate: diff --git a/rpcs3/rpcs3qt/main_window.cpp b/rpcs3/rpcs3qt/main_window.cpp index 547acd97d6..2e7533cf83 100644 --- a/rpcs3/rpcs3qt/main_window.cpp +++ b/rpcs3/rpcs3qt/main_window.cpp @@ -3320,6 +3320,17 @@ void main_window::CreateConnects() ResizeIcons(idx); }); + connect(m_game_list_frame, &game_list_frame::RequestSaveStateManager, this, [this](const game_info& gameinfo) + { + savestate_manager_dialog* manager = new savestate_manager_dialog(m_gui_settings, std::vector{gameinfo}); + connect(this, &main_window::RequestDialogRepaint, manager, &savestate_manager_dialog::HandleRepaintUiRequest); + connect(manager, &savestate_manager_dialog::RequestBoot, this, [this, gameinfo](const std::string& path) + { + Boot(path, gameinfo->info.serial, false, false, cfg_mode::custom, ""); + }); + manager->show(); + }); + connect(m_list_mode_act_group, &QActionGroup::triggered, this, [this](QAction* act) { const bool is_list_act = act == ui->setlistModeListAct; diff --git a/rpcs3/rpcs3qt/shortcut_settings.cpp b/rpcs3/rpcs3qt/shortcut_settings.cpp index fad59342a1..a3473c7db5 100644 --- a/rpcs3/rpcs3qt/shortcut_settings.cpp +++ b/rpcs3/rpcs3qt/shortcut_settings.cpp @@ -24,6 +24,10 @@ void fmt_class_string::format(std::string& out, u64 arg) case shortcut::gw_toggle_recording: return "gw_toggle_recording"; case shortcut::gw_pause_play: return "gw_pause_play"; case shortcut::gw_savestate: return "gw_savestate"; + case shortcut::gw_savestate_1: return "gw_savestate1"; + case shortcut::gw_savestate_2: return "gw_savestate2"; + case shortcut::gw_savestate_3: return "gw_savestate3"; + case shortcut::gw_savestate_4: return "gw_savestate4"; case shortcut::gw_restart: return "gw_restart"; case shortcut::gw_rsx_capture: return "gw_rsx_capture"; case shortcut::gw_frame_limit: return "gw_frame_limit"; @@ -71,6 +75,10 @@ shortcut_settings::shortcut_settings() { shortcut::gw_screenshot, shortcut_info{ "game_window_screenshot", tr("Screenshot"), "F12", shortcut_handler_id::game_window, false } }, { shortcut::gw_pause_play, shortcut_info{ "game_window_pause_play", tr("Pause/Play"), "Ctrl+P", shortcut_handler_id::game_window, false } }, { shortcut::gw_savestate, shortcut_info{ "game_window_savestate", tr("Savestate"), "Ctrl+S", shortcut_handler_id::game_window, false } }, + { shortcut::gw_savestate_1, shortcut_info{ "game_window_savestate_1", tr("Savestate"), "Alt+Ctrl+1", shortcut_handler_id::game_window, false } }, + { shortcut::gw_savestate_2, shortcut_info{ "game_window_savestate_2", tr("Savestate"), "Alt+Ctrl+2", shortcut_handler_id::game_window, false } }, + { shortcut::gw_savestate_3, shortcut_info{ "game_window_savestate_3", tr("Savestate"), "Alt+Ctrl+3", shortcut_handler_id::game_window, false } }, + { shortcut::gw_savestate_4, shortcut_info{ "game_window_savestate_4", tr("Savestate"), "Alt+Ctrl+4", shortcut_handler_id::game_window, false } }, { shortcut::gw_restart, shortcut_info{ "game_window_restart", tr("Restart"), "Ctrl+R", shortcut_handler_id::game_window, false } }, { shortcut::gw_rsx_capture, shortcut_info{ "game_window_rsx_capture", tr("RSX Capture"), "Alt+C", shortcut_handler_id::game_window, false } }, { shortcut::gw_frame_limit, shortcut_info{ "game_window_frame_limit", tr("Toggle Framelimit"), "Ctrl+F10", shortcut_handler_id::game_window, false } }, diff --git a/rpcs3/rpcs3qt/shortcut_settings.h b/rpcs3/rpcs3qt/shortcut_settings.h index 4e119c4f21..bc4e95925e 100644 --- a/rpcs3/rpcs3qt/shortcut_settings.h +++ b/rpcs3/rpcs3qt/shortcut_settings.h @@ -32,6 +32,10 @@ namespace gui gw_toggle_recording, gw_pause_play, gw_savestate, + gw_savestate_1, + gw_savestate_2, + gw_savestate_3, + gw_savestate_4, gw_restart, gw_rsx_capture, gw_frame_limit,