diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp index 7fe4936f5..de66e14ac 100644 --- a/src/citra_qt/main.cpp +++ b/src/citra_qt/main.cpp @@ -79,6 +79,7 @@ #include "core/hle/service/nfc/nfc.h" #include "core/loader/loader.h" #include "core/movie.h" +#include "core/savestate.h" #include "core/settings.h" #include "game_list_p.h" #include "video_core/renderer_base.h" @@ -166,6 +167,7 @@ GMainWindow::GMainWindow() : config(new Config()), emu_thread(nullptr) { InitializeWidgets(); InitializeDebugWidgets(); InitializeRecentFileMenuActions(); + InitializeSaveStateMenuActions(); InitializeHotkeys(); ShowUpdaterWidgets(); @@ -383,6 +385,32 @@ void GMainWindow::InitializeRecentFileMenuActions() { UpdateRecentFiles(); } +void GMainWindow::InitializeSaveStateMenuActions() { + for (u32 i = 0; i < Core::SaveStateSlotCount; ++i) { + actions_load_state[i] = new QAction(this); + actions_load_state[i]->setData(i + 1); + connect(actions_load_state[i], &QAction::triggered, this, &GMainWindow::OnLoadState); + ui.menu_Load_State->addAction(actions_load_state[i]); + + actions_save_state[i] = new QAction(this); + actions_save_state[i]->setData(i + 1); + connect(actions_save_state[i], &QAction::triggered, this, &GMainWindow::OnSaveState); + ui.menu_Save_State->addAction(actions_save_state[i]); + } + + connect(ui.action_Load_from_Newest_Slot, &QAction::triggered, + [this] { actions_load_state[newest_slot - 1]->trigger(); }); + connect(ui.action_Save_to_Oldest_Slot, &QAction::triggered, + [this] { actions_save_state[oldest_slot - 1]->trigger(); }); + + connect(ui.menu_Load_State->menuAction(), &QAction::hovered, this, + &GMainWindow::UpdateSaveStates); + connect(ui.menu_Save_State->menuAction(), &QAction::hovered, this, + &GMainWindow::UpdateSaveStates); + + UpdateSaveStates(); +} + void GMainWindow::InitializeHotkeys() { hotkey_registry.LoadHotkeys(); @@ -607,8 +635,6 @@ void GMainWindow::ConnectMenuEvents() { &GMainWindow::OnMenuReportCompatibility); connect(ui.action_Configure, &QAction::triggered, this, &GMainWindow::OnConfigure); connect(ui.action_Cheats, &QAction::triggered, this, &GMainWindow::OnCheats); - connect(ui.action_Save, &QAction::triggered, this, &GMainWindow::OnSave); - connect(ui.action_Load, &QAction::triggered, this, &GMainWindow::OnLoad); // View connect(ui.action_Single_Window_Mode, &QAction::triggered, this, @@ -1036,8 +1062,6 @@ void GMainWindow::ShutdownGame() { ui.action_Stop->setEnabled(false); ui.action_Restart->setEnabled(false); ui.action_Cheats->setEnabled(false); - ui.action_Save->setEnabled(false); - ui.action_Load->setEnabled(false); ui.action_Load_Amiibo->setEnabled(false); ui.action_Remove_Amiibo->setEnabled(false); ui.action_Report_Compatibility->setEnabled(false); @@ -1061,6 +1085,8 @@ void GMainWindow::ShutdownGame() { game_fps_label->setVisible(false); emu_frametime_label->setVisible(false); + UpdateSaveStates(); + emulation_running = false; if (defer_update_prompt) { @@ -1107,6 +1133,62 @@ void GMainWindow::UpdateRecentFiles() { ui.menu_recent_files->setEnabled(num_recent_files != 0); } +void GMainWindow::UpdateSaveStates() { + if (!Core::System::GetInstance().IsPoweredOn()) { + ui.menu_Load_State->setEnabled(false); + ui.menu_Save_State->setEnabled(false); + return; + } + + ui.menu_Load_State->setEnabled(true); + ui.menu_Save_State->setEnabled(true); + ui.action_Load_from_Newest_Slot->setEnabled(false); + + oldest_slot = newest_slot = 0; + oldest_slot_time = std::numeric_limits::max(); + newest_slot_time = 0; + + u64 title_id; + if (Core::System::GetInstance().GetAppLoader().ReadProgramId(title_id) != + Loader::ResultStatus::Success) { + return; + } + auto savestates = Core::ListSaveStates(title_id); + for (u32 i = 0; i < Core::SaveStateSlotCount; ++i) { + actions_load_state[i]->setEnabled(false); + actions_load_state[i]->setText(tr("Slot %1").arg(i + 1)); + actions_save_state[i]->setText(tr("Slot %1").arg(i + 1)); + } + for (const auto& savestate : savestates) { + const auto text = tr("Slot %1 - %2") + .arg(savestate.slot) + .arg(QDateTime::fromSecsSinceEpoch(savestate.time) + .toString(QStringLiteral("yyyy-MM-dd hh:mm:ss"))); + actions_load_state[savestate.slot - 1]->setEnabled(true); + actions_load_state[savestate.slot - 1]->setText(text); + actions_save_state[savestate.slot - 1]->setText(text); + + ui.action_Load_from_Newest_Slot->setEnabled(true); + + if (savestate.time > newest_slot_time) { + newest_slot = savestate.slot; + newest_slot_time = savestate.time; + } + if (savestate.time < oldest_slot_time) { + oldest_slot = savestate.slot; + oldest_slot_time = savestate.time; + } + } + for (u32 i = 0; i < Core::SaveStateSlotCount; ++i) { + if (!actions_load_state[i]->isEnabled()) { + // Prefer empty slot + oldest_slot = i + 1; + oldest_slot_time = 0; + break; + } + } +} + void GMainWindow::OnGameListLoadFile(QString game_path) { BootGame(game_path); } @@ -1348,14 +1430,14 @@ void GMainWindow::OnStartGame() { ui.action_Stop->setEnabled(true); ui.action_Restart->setEnabled(true); ui.action_Cheats->setEnabled(true); - ui.action_Save->setEnabled(true); - ui.action_Load->setEnabled(true); ui.action_Load_Amiibo->setEnabled(true); ui.action_Report_Compatibility->setEnabled(true); ui.action_Enable_Frame_Advancing->setEnabled(true); ui.action_Capture_Screenshot->setEnabled(true); discord_rpc->Update(); + + UpdateSaveStates(); } void GMainWindow::OnPauseGame() { @@ -1503,14 +1585,19 @@ void GMainWindow::OnCheats() { cheat_dialog.exec(); } -void GMainWindow::OnSave() { - Core::System::GetInstance().SendSignal(Core::System::Signal::Save); +void GMainWindow::OnSaveState() { + QAction* action = qobject_cast(sender()); + assert(action); + + Core::System::GetInstance().SendSignal(Core::System::Signal::Save, action->data().toUInt()); + UpdateSaveStates(); } -void GMainWindow::OnLoad() { - if (QFileInfo("save0.citrasave").exists()) { - Core::System::GetInstance().SendSignal(Core::System::Signal::Load); - } +void GMainWindow::OnLoadState() { + QAction* action = qobject_cast(sender()); + assert(action); + + Core::System::GetInstance().SendSignal(Core::System::Signal::Load, action->data().toUInt()); } void GMainWindow::OnConfigure() { diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index 1858d5988..ebe1a013a 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include @@ -14,6 +15,7 @@ #include "common/announce_multiplayer_room.h" #include "core/core.h" #include "core/hle/service/am/am.h" +#include "core/savestate.h" #include "ui_main.h" class AboutDialog; @@ -106,6 +108,7 @@ private: void InitializeWidgets(); void InitializeDebugWidgets(); void InitializeRecentFileMenuActions(); + void InitializeSaveStateMenuActions(); void SetDefaultUIGeometry(); void SyncMenuUISettings(); @@ -149,6 +152,8 @@ private: */ void UpdateRecentFiles(); + void UpdateSaveStates(); + /** * If the emulation is running, * asks the user if he really want to close the emulator @@ -163,8 +168,8 @@ private slots: void OnStartGame(); void OnPauseGame(); void OnStopGame(); - void OnSave(); - void OnLoad(); + void OnSaveState(); + void OnLoadState(); void OnMenuReportCompatibility(); /// Called whenever a user selects a game in the game list widget. void OnGameListLoadFile(QString game_path); @@ -276,6 +281,13 @@ private: bool defer_update_prompt = false; QAction* actions_recent_files[max_recent_files_item]; + std::array actions_load_state; + std::array actions_save_state; + + u32 oldest_slot; + u64 oldest_slot_time; + u32 newest_slot; + u64 newest_slot_time; QTranslator translator; diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui index c0c38e4c8..2eff98083 100644 --- a/src/citra_qt/main.ui +++ b/src/citra_qt/main.ui @@ -79,17 +79,32 @@ &Emulation + + + Save State + + + + + + + Load State + + + + + + + - - @@ -253,6 +268,16 @@ Single Window Mode + + + Save to Oldest Slot + + + + + Load from Newest Slot + + Configure... diff --git a/src/common/common_paths.h b/src/common/common_paths.h index 13e71615e..eec4dde9c 100644 --- a/src/common/common_paths.h +++ b/src/common/common_paths.h @@ -47,6 +47,7 @@ #define DUMP_DIR "dump" #define LOAD_DIR "load" #define SHADER_DIR "shaders" +#define STATES_DIR "states" // Filenames // Files in the directory returned by GetUserPath(UserPath::LogDir) diff --git a/src/common/file_util.cpp b/src/common/file_util.cpp index c5da82973..cd3f4e102 100644 --- a/src/common/file_util.cpp +++ b/src/common/file_util.cpp @@ -725,6 +725,7 @@ void SetUserPath(const std::string& path) { g_paths.emplace(UserPath::ShaderDir, user_path + SHADER_DIR DIR_SEP); g_paths.emplace(UserPath::DumpDir, user_path + DUMP_DIR DIR_SEP); g_paths.emplace(UserPath::LoadDir, user_path + LOAD_DIR DIR_SEP); + g_paths.emplace(UserPath::StatesDir, user_path + STATES_DIR DIR_SEP); } const std::string& GetUserPath(UserPath path) { diff --git a/src/common/file_util.h b/src/common/file_util.h index 0368d3665..8af5a2a61 100644 --- a/src/common/file_util.h +++ b/src/common/file_util.h @@ -36,6 +36,7 @@ enum class UserPath { RootDir, SDMCDir, ShaderDir, + StatesDir, SysDataDir, UserDir, }; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index c6908c59a..2b02ffc41 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -447,6 +447,8 @@ add_library(core STATIC rpc/server.h rpc/udp_server.cpp rpc/udp_server.h + savestate.cpp + savestate.h settings.cpp settings.h telemetry_session.cpp diff --git a/src/core/core.cpp b/src/core/core.cpp index 0bbb79aea..f17474a85 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -10,10 +10,8 @@ #include "audio_core/dsp_interface.h" #include "audio_core/hle/hle.h" #include "audio_core/lle/lle.h" -#include "common/archives.h" #include "common/logging/log.h" #include "common/texture.h" -#include "common/zstd_compression.h" #include "core/arm/arm_interface.h" #ifdef ARCHITECTURE_x86_64 #include "core/arm/dynarmic/arm_dynarmic.h" @@ -63,6 +61,8 @@ Kernel::KernelSystem& Global() { return System::GetInstance().Kernel(); } +System::~System() = default; + System::ResultStatus System::RunLoop(bool tight_loop) { status = ResultStatus::Success; if (!cpu_core) { @@ -106,7 +106,16 @@ System::ResultStatus System::RunLoop(bool tight_loop) { HW::Update(); Reschedule(); - auto signal = current_signal.exchange(Signal::None); + Signal signal{Signal::None}; + u32 param{}; + { + std::lock_guard lock{signal_mutex}; + if (current_signal != Signal::None) { + signal = current_signal; + param = signal_param; + current_signal = Signal::None; + } + } switch (signal) { case Signal::Reset: Reset(); @@ -116,14 +125,16 @@ System::ResultStatus System::RunLoop(bool tight_loop) { break; case Signal::Load: { LOG_INFO(Core, "Begin load"); - auto stream = std::ifstream("save0.citrasave", std::fstream::binary); - System::Load(stream, FileUtil::GetSize("save0.citrasave")); + System::LoadState(param); + // auto stream = std::ifstream("save0.citrasave", std::fstream::binary); + // System::Load(stream, FileUtil::GetSize("save0.citrasave")); LOG_INFO(Core, "Load completed"); } break; case Signal::Save: { LOG_INFO(Core, "Begin save"); - auto stream = std::ofstream("save0.citrasave", std::fstream::binary); - System::Save(stream); + System::SaveState(param); + // auto stream = std::ofstream("save0.citrasave", std::fstream::binary); + // System::Save(stream); LOG_INFO(Core, "Save completed"); } break; default: @@ -133,12 +144,14 @@ System::ResultStatus System::RunLoop(bool tight_loop) { return status; } -bool System::SendSignal(System::Signal signal) { - auto prev = System::Signal::None; - if (!current_signal.compare_exchange_strong(prev, signal)) { - LOG_ERROR(Core, "Unable to {} as {} is ongoing", signal, prev); +bool System::SendSignal(System::Signal signal, u32 param) { + std::lock_guard lock{signal_mutex}; + if (current_signal != signal && current_signal != Signal::None) { + LOG_ERROR(Core, "Unable to {} as {} is ongoing", signal, current_signal); return false; } + current_signal = signal; + signal_param = param; return true; } @@ -196,7 +209,7 @@ System::ResultStatus System::Load(Frontend::EmuWindow& emu_window, const std::st } } cheat_engine = std::make_unique(*this); - u64 title_id{0}; + title_id = 0; if (app_loader->ReadProgramId(title_id) != Loader::ResultStatus::Success) { LOG_ERROR(Core, "Failed to find title id for ROM (Error {})", static_cast(load_result)); @@ -246,8 +259,8 @@ System::ResultStatus System::Init(Frontend::EmuWindow& emu_window, u32 system_mo timing = std::make_unique(); - kernel = std::make_unique( - *memory, *timing, [this] { PrepareReschedule(); }, system_mode); + kernel = std::make_unique(*memory, *timing, + [this] { PrepareReschedule(); }, system_mode); if (Settings::values.use_cpu_jit) { #ifdef ARCHITECTURE_x86_64 @@ -464,48 +477,6 @@ void System::serialize(Archive& ar, const unsigned int file_version) { } } -void System::Save(std::ostream& stream) const { - std::ostringstream sstream{std::ios_base::binary}; - try { - - { - oarchive oa{sstream}; - oa&* this; - } - VideoCore::Save(sstream); - - } catch (const std::exception& e) { - LOG_ERROR(Core, "Error saving: {}", e.what()); - } - const std::string& str{sstream.str()}; - auto buffer = Common::Compression::CompressDataZSTDDefault( - reinterpret_cast(str.data()), str.size()); - stream.write(reinterpret_cast(buffer.data()), buffer.size()); -} - -void System::Load(std::istream& stream, std::size_t size) { - std::vector decompressed; - { - std::vector buffer(size); - stream.read(reinterpret_cast(buffer.data()), size); - decompressed = Common::Compression::DecompressDataZSTD(buffer); - } - std::istringstream sstream{ - std::string{reinterpret_cast(decompressed.data()), decompressed.size()}, - std::ios_base::binary}; - decompressed.clear(); - - try { - - { - iarchive ia{sstream}; - ia&* this; - } - VideoCore::Load(sstream); - - } catch (const std::exception& e) { - LOG_ERROR(Core, "Error loading: {}", e.what()); - } -} +SERIALIZE_IMPL(System) } // namespace Core diff --git a/src/core/core.h b/src/core/core.h index 80c1505b5..0ce6924cd 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include "boost/serialization/access.hpp" #include "common/common_types.h" @@ -92,6 +93,8 @@ public: ErrorUnknown ///< Any other error }; + ~System(); + /** * Run the core CPU loop * This function runs the core for the specified number of CPU instructions before trying to @@ -118,7 +121,7 @@ public: enum class Signal : u32 { None, Shutdown, Reset, Save, Load }; - bool SendSignal(Signal signal); + bool SendSignal(Signal signal, u32 param = 0); /// Request reset of the system void RequestReset() { @@ -276,9 +279,9 @@ public: return registered_image_interface; } - void Save(std::ostream& stream) const; + void SaveState(u32 slot) const; - void Load(std::istream& stream, std::size_t size); + void LoadState(u32 slot); private: /** @@ -344,8 +347,11 @@ private: /// Saved variables for reset Frontend::EmuWindow* m_emu_window; std::string m_filepath; + u64 title_id; - std::atomic current_signal; + std::mutex signal_mutex; + Signal current_signal; + u32 signal_param; friend class boost::serialization::access; template diff --git a/src/core/savestate.cpp b/src/core/savestate.cpp new file mode 100644 index 000000000..d52789b2c --- /dev/null +++ b/src/core/savestate.cpp @@ -0,0 +1,174 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include "common/archives.h" +#include "common/logging/log.h" +#include "common/scm_rev.h" +#include "common/zstd_compression.h" +#include "core/core.h" +#include "core/savestate.h" +#include "video_core/video_core.h" + +namespace Core { + +#pragma pack(push, 1) +struct CSTHeader { + std::array filetype; /// Unique Identifier to check the file type (always "CST"0x1B) + u64_le program_id; /// ID of the ROM being executed. Also called title_id + std::array revision; /// Git hash of the revision this savestate was created with + u64_le time; /// The time when this save state was created + + std::array reserved; /// Make heading 256 bytes so it has consistent size +}; +static_assert(sizeof(CSTHeader) == 256, "CSTHeader should be 256 bytes"); +#pragma pack(pop) + +constexpr std::array header_magic_bytes{{'C', 'S', 'T', 0x1B}}; + +std::string GetSaveStatePath(u64 program_id, u32 slot) { + return fmt::format("{}{:016X}.{:02d}.cst", FileUtil::GetUserPath(FileUtil::UserPath::StatesDir), + program_id, slot); +} + +std::vector ListSaveStates(u64 program_id) { + std::vector result; + for (u32 slot = 1; slot <= SaveStateSlotCount; ++slot) { + const auto path = GetSaveStatePath(program_id, slot); + if (!FileUtil::Exists(path)) { + continue; + } + + SaveStateInfo info; + info.slot = slot; + + FileUtil::IOFile file(path, "rb"); + if (!file) { + LOG_ERROR(Core, "Could not open file {}", path); + continue; + } + CSTHeader header; + if (file.GetSize() < sizeof(header)) { + LOG_ERROR(Core, "File too small {}", path); + continue; + } + if (file.ReadBytes(&header, sizeof(header)) != sizeof(header)) { + LOG_ERROR(Core, "Could not read from file {}", path); + continue; + } + if (header.filetype != header_magic_bytes) { + LOG_WARNING(Core, "Invalid save state file {}", path); + continue; + } + info.time = header.time; + + if (header.program_id != program_id) { + LOG_WARNING(Core, "Save state file isn't for the current game {}", path); + continue; + } + std::string revision = fmt::format("{:02x}", fmt::join(header.revision, "")); + if (revision == Common::g_scm_rev) { + info.status = SaveStateInfo::ValidationStatus::OK; + } else { + LOG_WARNING(Core, "Save state file created from a different revision {}", path); + info.status = SaveStateInfo::ValidationStatus::RevisionDismatch; + } + result.emplace_back(std::move(info)); + } + return result; +} + +void System::SaveState(u32 slot) const { + std::ostringstream sstream{std::ios_base::binary}; + try { + + { + oarchive oa{sstream}; + oa&* this; + } + VideoCore::Save(sstream); + + } catch (const std::exception& e) { + LOG_ERROR(Core, "Error saving: {}", e.what()); + } + const std::string& str{sstream.str()}; + auto buffer = Common::Compression::CompressDataZSTDDefault( + reinterpret_cast(str.data()), str.size()); + + const auto path = GetSaveStatePath(title_id, slot); + if (!FileUtil::CreateFullPath(path)) { + LOG_ERROR(Core, "Could not create path {}", path); + return; + } + + FileUtil::IOFile file(path, "wb"); + if (!file) { + LOG_ERROR(Core, "Could not open file {}", path); + return; + } + + CSTHeader header{}; + header.filetype = header_magic_bytes; + header.program_id = title_id; + std::string rev_bytes; + CryptoPP::StringSource(Common::g_scm_rev, true, + new CryptoPP::HexDecoder(new CryptoPP::StringSink(rev_bytes))); + std::memcpy(header.revision.data(), rev_bytes.data(), sizeof(header.revision)); + header.time = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + if (file.WriteBytes(&header, sizeof(header)) != sizeof(header)) { + LOG_ERROR(Core, "Could not write to file {}", path); + return; + } + if (file.WriteBytes(buffer.data(), buffer.size()) != buffer.size()) { + LOG_ERROR(Core, "Could not write to file {}", path); + return; + } +} + +void System::LoadState(u32 slot) { + const auto path = GetSaveStatePath(title_id, slot); + if (!FileUtil::Exists(path)) { + LOG_ERROR(Core, "File not exist {}", path); + return; + } + + std::vector decompressed; + { + std::vector buffer(FileUtil::GetSize(path) - sizeof(CSTHeader)); + + FileUtil::IOFile file(path, "rb"); + if (!file) { + LOG_ERROR(Core, "Could not open file {}", path); + return; + } + file.Seek(sizeof(CSTHeader), SEEK_SET); // Skip header + if (file.ReadBytes(buffer.data(), buffer.size()) != buffer.size()) { + LOG_ERROR(Core, "Could not read from file {}", path); + return; + } + decompressed = Common::Compression::DecompressDataZSTD(buffer); + } + std::istringstream sstream{ + std::string{reinterpret_cast(decompressed.data()), decompressed.size()}, + std::ios_base::binary}; + decompressed.clear(); + + try { + + { + iarchive ia{sstream}; + ia&* this; + } + VideoCore::Load(sstream); + + } catch (const std::exception& e) { + LOG_ERROR(Core, "Error loading: {}", e.what()); + } +} + +} // namespace Core diff --git a/src/core/savestate.h b/src/core/savestate.h new file mode 100644 index 000000000..f67bee22f --- /dev/null +++ b/src/core/savestate.h @@ -0,0 +1,27 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include "common/common_types.h" + +namespace Core { + +struct CSTHeader; + +struct SaveStateInfo { + u32 slot; + u64 time; + enum class ValidationStatus { + OK, + RevisionDismatch, + } status; +}; + +constexpr u32 SaveStateSlotCount = 10; // Maximum count of savestate slots + +std::vector ListSaveStates(u64 program_id); + +} // namespace Core