core, citra_qt: Implement a save states file format and slot UI

10 slots are offered along with 'Save to Oldest Slot' and 'Load from Newest Slot'.

The savestate format is similar to the movie file format. It is called CST (Citra SavesTate), and is basically a 0x100 byte header (consisting of magic, revision, creation time and title ID) followed by Zstd compressed raw savestate data.

The savestate files are saved to the `states` folder in Citra's user folder. The files are named like `<Title ID>.<Slot ID>.cst`.
This commit is contained in:
zhupengfei 2020-02-18 13:19:52 +08:00
parent 7d880f94db
commit a487016cb4
No known key found for this signature in database
GPG Key ID: DD129E108BD09378
11 changed files with 384 additions and 77 deletions

View File

@ -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<u64>::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<QAction*>(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<QAction*>(sender());
assert(action);
Core::System::GetInstance().SendSignal(Core::System::Signal::Load, action->data().toUInt());
}
void GMainWindow::OnConfigure() {

View File

@ -4,6 +4,7 @@
#pragma once
#include <array>
#include <memory>
#include <QLabel>
#include <QMainWindow>
@ -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<QAction*, Core::SaveStateSlotCount> actions_load_state;
std::array<QAction*, Core::SaveStateSlotCount> actions_save_state;
u32 oldest_slot;
u64 oldest_slot_time;
u32 newest_slot;
u64 newest_slot_time;
QTranslator translator;

View File

@ -79,17 +79,32 @@
<property name="title">
<string>&amp;Emulation</string>
</property>
<widget class="QMenu" name="menu_Save_State">
<property name="title">
<string>Save State</string>
</property>
<addaction name="action_Save_to_Oldest_Slot"/>
<addaction name="separator"/>
</widget>
<widget class="QMenu" name="menu_Load_State">
<property name="title">
<string>Load State</string>
</property>
<addaction name="action_Load_from_Newest_Slot"/>
<addaction name="separator"/>
</widget>
<addaction name="action_Start"/>
<addaction name="action_Pause"/>
<addaction name="action_Stop"/>
<addaction name="action_Restart"/>
<addaction name="separator"/>
<addaction name="menu_Load_State"/>
<addaction name="menu_Save_State"/>
<addaction name="separator"/>
<addaction name="action_Report_Compatibility"/>
<addaction name="separator"/>
<addaction name="action_Configure"/>
<addaction name="action_Cheats"/>
<addaction name="action_Save"/>
<addaction name="action_Load"/>
</widget>
<widget class="QMenu" name="menu_View">
<property name="title">
@ -253,6 +268,16 @@
<string>Single Window Mode</string>
</property>
</action>
<action name="action_Save_to_Oldest_Slot">
<property name="text">
<string>Save to Oldest Slot</string>
</property>
</action>
<action name="action_Load_from_Newest_Slot">
<property name="text">
<string>Load from Newest Slot</string>
</property>
</action>
<action name="action_Configure">
<property name="text">
<string>Configure...</string>

View File

@ -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)

View File

@ -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) {

View File

@ -36,6 +36,7 @@ enum class UserPath {
RootDir,
SDMCDir,
ShaderDir,
StatesDir,
SysDataDir,
UserDir,
};

View File

@ -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

View File

@ -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<Cheats::CheatEngine>(*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<u32>(load_result));
@ -246,8 +259,8 @@ System::ResultStatus System::Init(Frontend::EmuWindow& emu_window, u32 system_mo
timing = std::make_unique<Timing>();
kernel = std::make_unique<Kernel::KernelSystem>(
*memory, *timing, [this] { PrepareReschedule(); }, system_mode);
kernel = std::make_unique<Kernel::KernelSystem>(*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<const u8*>(str.data()), str.size());
stream.write(reinterpret_cast<const char*>(buffer.data()), buffer.size());
}
void System::Load(std::istream& stream, std::size_t size) {
std::vector<u8> decompressed;
{
std::vector<u8> buffer(size);
stream.read(reinterpret_cast<char*>(buffer.data()), size);
decompressed = Common::Compression::DecompressDataZSTD(buffer);
}
std::istringstream sstream{
std::string{reinterpret_cast<char*>(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

View File

@ -5,6 +5,7 @@
#pragma once
#include <memory>
#include <mutex>
#include <string>
#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<Signal> current_signal;
std::mutex signal_mutex;
Signal current_signal;
u32 signal_param;
friend class boost::serialization::access;
template <typename Archive>

174
src/core/savestate.cpp Normal file
View File

@ -0,0 +1,174 @@
// Copyright 2020 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <chrono>
#include <cryptopp/hex.h>
#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<u8, 4> 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<u8, 20> revision; /// Git hash of the revision this savestate was created with
u64_le time; /// The time when this save state was created
std::array<u8, 216> 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<u8, 4> 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<SaveStateInfo> ListSaveStates(u64 program_id) {
std::vector<SaveStateInfo> 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<const u8*>(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::seconds>(
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<u8> decompressed;
{
std::vector<u8> 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<char*>(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

27
src/core/savestate.h Normal file
View File

@ -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 <vector>
#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<SaveStateInfo> ListSaveStates(u64 program_id);
} // namespace Core