diff --git a/rpcs3/Emu/System.cpp b/rpcs3/Emu/System.cpp index 21785a32be..a1d6f4b930 100644 --- a/rpcs3/Emu/System.cpp +++ b/rpcs3/Emu/System.cpp @@ -2557,7 +2557,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, 0)}) { if (old_path.empty()) { @@ -3360,7 +3360,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 @@ -3600,13 +3600,49 @@ 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 = g_cfg.savestate.max_files_size; + + bool logged_limits = false; + + while (true) + { + std::string to_remove = get_savestate_file(m_title_id, m_path, max_files + 1, max_files_size == 0 ? u64{umax} : (max_files_size << 20)); + + 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); + } + } + } } } diff --git a/rpcs3/Emu/savestate_utils.cpp b/rpcs3/Emu/savestate_utils.cpp index c1b5c953fb..1621fcc022 100644 --- a/rpcs3/Emu/savestate_utils.cpp +++ b/rpcs3/Emu/savestate_utils.cpp @@ -272,17 +272,131 @@ 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) + { + const 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 (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("_", 0, dot_idx); 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; + + if (rel_id > 0) + { + if (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 std::move(rel_path); + } + else + { + return std::move(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 last_entry; + } + + // Fallback - create new file + } const std::string save_id = fmt::format("%d", abs_id); diff --git a/rpcs3/Emu/savestate_utils.hpp b/rpcs3/Emu/savestate_utils.hpp index 18028ae9ec..51cf735e2e 100644 --- a/rpcs3/Emu/savestate_utils.hpp +++ b/rpcs3/Emu/savestate_utils.hpp @@ -44,4 +44,4 @@ 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); diff --git a/rpcs3/Emu/system_config.h b/rpcs3/Emu/system_config.h index d3dfab4ce8..361b1278a2 100644 --- a/rpcs3/Emu/system_config.h +++ b/rpcs3/Emu/system_config.h @@ -332,6 +332,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 150724deea..81e9ecf986 100644 --- a/rpcs3/rpcs3qt/game_list_frame.cpp +++ b/rpcs3/rpcs3qt/game_list_frame.cpp @@ -50,7 +50,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) @@ -1199,13 +1199,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 60c4e0daee..8c59477f18 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/main_window.cpp b/rpcs3/rpcs3qt/main_window.cpp index a67af2cc6e..e26f64b54b 100644 --- a/rpcs3/rpcs3qt/main_window.cpp +++ b/rpcs3/rpcs3qt/main_window.cpp @@ -3412,6 +3412,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;