From 2a13c53f16114cb9ac086bb22364c6e77517f7e2 Mon Sep 17 00:00:00 2001 From: Davide Garberi Date: Mon, 7 Nov 2022 21:39:50 +0100 Subject: [PATCH] src: Add save file import and export * This is very useful when playing the same game on different devices * Use zip archives with the help of libzip for this * The archive structure is program_id/save_file Signed-off-by: Davide Garberi --- src/yuzu/game_list.cpp | 10 +++ src/yuzu/game_list.h | 6 ++ src/yuzu/main.cpp | 196 +++++++++++++++++++++++++++++++++++++++++ src/yuzu/main.h | 3 + 4 files changed, 215 insertions(+) diff --git a/src/yuzu/game_list.cpp b/src/yuzu/game_list.cpp index 5c33c1b0f3..ef6fa040a1 100644 --- a/src/yuzu/game_list.cpp +++ b/src/yuzu/game_list.cpp @@ -539,6 +539,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri QAction* open_mod_location = context_menu.addAction(tr("Open Mod Data Location")); QAction* open_transferable_shader_cache = context_menu.addAction(tr("Open Transferable Pipeline Cache")); + QAction* export_save_file = context_menu.addAction(tr("Export Game Save File")); + QAction* import_save_file = context_menu.addAction(tr("Import Game Save File")); context_menu.addSeparator(); QMenu* remove_menu = context_menu.addMenu(tr("Remove")); QAction* remove_update = remove_menu->addAction(tr("Remove Installed Update")); @@ -563,6 +565,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri open_save_location->setVisible(program_id != 0); open_mod_location->setVisible(program_id != 0); open_transferable_shader_cache->setVisible(program_id != 0); + export_save_file->setVisible(program_id != 0); + import_save_file->setVisible(program_id != 0); remove_update->setVisible(program_id != 0); remove_dlc->setVisible(program_id != 0); remove_gl_shader_cache->setVisible(program_id != 0); @@ -587,6 +591,12 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri }); connect(open_transferable_shader_cache, &QAction::triggered, [this, program_id]() { emit OpenTransferableShaderCacheRequested(program_id); }); + connect(export_save_file, &QAction::triggered, [this, program_id, path]() { + emit HandleSaveFile(SaveFileOperation::Import, program_id, path); + }); + connect(import_save_file, &QAction::triggered, [this, program_id, path]() { + emit HandleSaveFile(SaveFileOperation::Export, program_id, path); + }); connect(remove_all_content, &QAction::triggered, [this, program_id]() { emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::Game); }); diff --git a/src/yuzu/game_list.h b/src/yuzu/game_list.h index cdf085019c..39e1ac4810 100644 --- a/src/yuzu/game_list.h +++ b/src/yuzu/game_list.h @@ -58,6 +58,11 @@ enum class InstalledEntryType { AddOnContent, }; +enum class SaveFileOperation { + Export, + Import, +}; + class GameList : public QWidget { Q_OBJECT @@ -103,6 +108,7 @@ signals: void OpenFolderRequested(u64 program_id, GameListOpenTarget target, const std::string& game_path); void OpenTransferableShaderCacheRequested(u64 program_id); + void HandleSaveFile(SaveFileOperation operation, u64 program_id, const std::string& game_path); void RemoveInstalledEntryRequested(u64 program_id, InstalledEntryType type); void RemoveFileRequested(u64 program_id, GameListRemoveTarget target, const std::string& game_path); diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp index 59e56633ad..4e4b752ce6 100644 --- a/src/yuzu/main.cpp +++ b/src/yuzu/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #ifdef __APPLE__ #include // for chdir #endif @@ -1226,6 +1227,7 @@ void GMainWindow::ConnectWidgetEvents() { connect(game_list, &GameList::OpenFolderRequested, this, &GMainWindow::OnGameListOpenFolder); connect(game_list, &GameList::OpenTransferableShaderCacheRequested, this, &GMainWindow::OnTransferableShaderCacheOpenFile); + connect(game_list, &GameList::HandleSaveFile, this, &GMainWindow::OnHandleSaveFile); connect(game_list, &GameList::RemoveInstalledEntryRequested, this, &GMainWindow::OnGameListRemoveInstalledEntry); connect(game_list, &GameList::RemoveFileRequested, this, &GMainWindow::OnGameListRemoveFile); @@ -1965,6 +1967,200 @@ void GMainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target QDesktopServices::openUrl(QUrl::fromLocalFile(qpath)); } +void GMainWindow::OnHandleSaveFile(SaveFileOperation operation, u64 program_id, + const std::string& game_path) { + std::filesystem::path path; + QString open_target; + + const auto [user_save_size, device_save_size] = [this, &game_path, &program_id] { + const FileSys::PatchManager pm{program_id, system->GetFileSystemController(), + system->GetContentProvider()}; + const auto control = pm.GetControlMetadata().first; + if (control != nullptr) { + return std::make_pair(control->GetDefaultNormalSaveSize(), + control->GetDeviceSaveDataSize()); + } else { + const auto file = Core::GetGameFileFromPath(vfs, game_path); + const auto loader = Loader::GetLoader(*system, file); + + FileSys::NACP nacp{}; + loader->ReadControlData(nacp); + return std::make_pair(nacp.GetDefaultNormalSaveSize(), nacp.GetDeviceSaveDataSize()); + } + }(); + + const bool has_user_save{user_save_size > 0}; + const bool has_device_save{device_save_size > 0}; + + ASSERT_MSG(has_user_save != has_device_save, "Game uses both user and device savedata?"); + + open_target = tr("Save Data"); + const auto nand_dir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir); + auto vfs_nand_dir = + vfs->OpenDirectory(Common::FS::PathToUTF8String(nand_dir), FileSys::Mode::Read); + + if (has_user_save) { + // User save data + const auto select_profile = [this] { + QtProfileSelectionDialog dialog(system->HIDCore(), this); + dialog.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | + Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint); + dialog.setWindowModality(Qt::WindowModal); + + if (dialog.exec() == QDialog::Rejected) { + return -1; + } + + return dialog.GetIndex(); + }; + + const auto index = select_profile(); + if (index == -1) { + return; + } + + Service::Account::ProfileManager manager; + const auto user_id = manager.GetUser(static_cast(index)); + ASSERT(user_id); + + const auto user_save_data_path = FileSys::SaveDataFactory::GetFullPath( + *system, vfs_nand_dir, FileSys::SaveDataSpaceId::NandUser, + FileSys::SaveDataType::SaveData, program_id, user_id->AsU128(), 0); + + path = Common::FS::ConcatPathSafe(nand_dir, user_save_data_path); + } else { + // Device save data + const auto device_save_data_path = FileSys::SaveDataFactory::GetFullPath( + *system, vfs_nand_dir, FileSys::SaveDataSpaceId::NandUser, + FileSys::SaveDataType::SaveData, program_id, {}, 0); + + path = Common::FS::ConcatPathSafe(nand_dir, device_save_data_path); + } + + const QString qpath = + QString::fromStdString(Common::FS::PathToUTF8String(path)) + QString::fromUtf8("/../"); + + if (operation == SaveFileOperation::Import) { + const QDir dir(qpath); + if (!dir.exists()) { + QMessageBox::warning(this, tr("Error Opening %1 Folder").arg(open_target), + tr("Folder does not exist!")); + return; + } + + QString destination_file = + QFileDialog::getSaveFileName(this, tr("Save Game Files"), + QStringLiteral("%1").arg(QStandardPaths::writableLocation( + QStandardPaths::DocumentsLocation)), + tr("ZIP Archive (*.zip)")); + + if (destination_file.isEmpty()) { + return; + } + + zip_t* zip = zip_open(destination_file.toStdString().c_str(), ZIP_CREATE, nullptr); + if (zip == nullptr) { + QMessageBox::warning(this, tr("Error Opening %1 Folder").arg(open_target), + tr("Failed to open ZIP archive!")); + return; + } + + QDirIterator file(qpath, QDirIterator::Subdirectories); + while (file.hasNext()) { + const auto file_path = file.next(); + const auto file_name = file.fileName(); + const auto file_info = file.fileInfo(); + QString relative_path = file_path; + relative_path.remove(qpath); + + if (file_info.isDir() && + relative_path.contains(QString::fromStdString(fmt::format("{:016x}", program_id)), + Qt::CaseInsensitive)) { + if (!relative_path.endsWith(QString::fromUtf8(".."))) { + zip_dir_add(zip, file_name.toStdString().c_str(), ZIP_FL_ENC_UTF_8); + } + continue; + } + + if (!relative_path.contains(QString::fromStdString(fmt::format("{:016x}", program_id)), + Qt::CaseInsensitive)) { + continue; + } + + zip_source_t* source = zip_source_file(zip, file_path.toStdString().c_str(), 0, -1); + zip_add(zip, relative_path.toStdString().c_str(), source); + } + + const auto error = zip_close(zip); + if (error != 0) { + QMessageBox::warning(this, tr("Error Opening %1 Folder").arg(open_target), + tr("Failed to close ZIP archive!")); + return; + } + } else if (operation == SaveFileOperation::Export) { + QString source_file = + QFileDialog::getOpenFileName(this, tr("Choose Game Save File"), + QStringLiteral("%1").arg(QStandardPaths::writableLocation( + QStandardPaths::DocumentsLocation)), + tr("ZIP archive (*.zip)")); + + if (source_file.isEmpty()) { + return; + } + + zip_t* zip = zip_open(source_file.toStdString().c_str(), ZIP_RDONLY, nullptr); + if (zip == nullptr) { + QMessageBox::warning(this, tr("Error Opening %1").arg(source_file), + tr("Failed to open ZIP archive!")); + return; + } + + for (int i = 0; i < zip_get_num_files(zip); i++) { + const auto file_name = zip_get_name(zip, i, ZIP_FL_ENC_UTF_8); + QString file_path = qpath + QString::fromStdString(file_name); + + if (!file_path.contains(QString::fromStdString(fmt::format("{:016x}", program_id)), + Qt::CaseInsensitive)) { + continue; + } + + if (file_name[strlen(file_name) - 1] == '/') { + QDir dir(file_path); + if (!dir.exists()) { + dir.mkpath(file_path); + } + continue; + } + + zip_file_t* file = zip_fopen_index(zip, i, ZIP_FL_ENC_UTF_8); + if (file == nullptr) { + QMessageBox::warning(this, + tr("Error Opening %1").arg(QString::fromStdString(file_name)), + tr("Failed to open file!")); + return; + } + + FILE* out = fopen(file_path.toStdString().c_str(), "wb"); + if (out == nullptr) { + QMessageBox::warning(this, tr("Error Opening %1").arg(file_path), + tr("Failed to open file!")); + return; + } + + char buffer[4096]; + int len = 0; + while ((len = zip_fread(file, buffer, sizeof(buffer))) > 0) { + fwrite(buffer, 1, len, out); + } + + fclose(out); + zip_fclose(file); + } + + zip_close(zip); + } +} + void GMainWindow::OnTransferableShaderCacheOpenFile(u64 program_id) { const auto shader_cache_dir = Common::FS::GetYuzuPath(Common::FS::YuzuPath::ShaderDir); const auto shader_cache_folder_path{shader_cache_dir / fmt::format("{:016x}", program_id)}; diff --git a/src/yuzu/main.h b/src/yuzu/main.h index 150ada84c0..e63e3a5787 100644 --- a/src/yuzu/main.h +++ b/src/yuzu/main.h @@ -40,6 +40,7 @@ enum class GameListOpenTarget; enum class GameListRemoveTarget; enum class DumpRomFSTarget; enum class InstalledEntryType; +enum class SaveFileOperation; class GameListPlaceholder; class QtSoftwareKeyboardDialog; @@ -288,6 +289,8 @@ private slots: void OnGameListAddDirectory(); void OnGameListShowList(bool show); void OnGameListOpenPerGameProperties(const std::string& file); + void OnHandleSaveFile(SaveFileOperation operation, u64 program_id, + const std::string& game_path); void OnMenuLoadFile(); void OnMenuLoadFolder(); void IncrementInstallProgress();