mirror of
https://git.eden-emu.dev/archive/citron
synced 2026-03-23 01:56:08 -04:00
Merge pull request 'feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths' (#79) from feat/cross-compatible-saves into main
Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/79
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
#include "core/core.h"
|
||||
#include "core/file_sys/patch_manager.h"
|
||||
#include "core/file_sys/registered_cache.h"
|
||||
#include "core/file_sys/savedata_factory.h"
|
||||
#include "citron/compatibility_list.h"
|
||||
#include "common/fs/path_util.h"
|
||||
#include "core/hle/service/acc/profile_manager.h"
|
||||
@@ -45,6 +46,41 @@
|
||||
#include "citron/uisettings.h"
|
||||
#include "citron/util/controller_navigation.h"
|
||||
|
||||
// Static helper for Save Detection
|
||||
static QString GetDetectedEmulatorName(const QString& path, u64 program_id, const QString& citron_nand_base) {
|
||||
QString abs_path = QDir(path).absolutePath();
|
||||
QString citron_abs_base = QDir(citron_nand_base).absolutePath();
|
||||
QString tid_str = QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char('0'));
|
||||
|
||||
// SELF-EXCLUSION
|
||||
if (abs_path.startsWith(citron_abs_base, Qt::CaseInsensitive)) {
|
||||
return QString{};
|
||||
}
|
||||
|
||||
// Ryujinx
|
||||
if (abs_path.contains(QStringLiteral("bis/user/save"), Qt::CaseInsensitive)) {
|
||||
if (abs_path.contains(QStringLiteral("ryubing"), Qt::CaseInsensitive)) return QStringLiteral("Ryubing");
|
||||
if (abs_path.contains(QStringLiteral("ryujinx"), Qt::CaseInsensitive)) return QStringLiteral("Ryujinx");
|
||||
|
||||
// Fallback if it's a generic Ryujinx-structure folder
|
||||
return abs_path.contains(tid_str, Qt::CaseInsensitive) ? QStringLiteral("Ryujinx/Ryubing") : QStringLiteral("Ryujinx/Ryubing (Manual Slot)");
|
||||
}
|
||||
|
||||
// Fork
|
||||
if (abs_path.contains(QStringLiteral("nand/user/save"), Qt::CaseInsensitive) ||
|
||||
abs_path.contains(QStringLiteral("nand/system/Containers"), Qt::CaseInsensitive)) {
|
||||
|
||||
if (abs_path.contains(QStringLiteral("eden"), Qt::CaseInsensitive)) return QStringLiteral("Eden");
|
||||
if (abs_path.contains(QStringLiteral("suyu"), Qt::CaseInsensitive)) return QStringLiteral("Suyu");
|
||||
if (abs_path.contains(QStringLiteral("sudachi"), Qt::CaseInsensitive)) return QStringLiteral("Sudachi");
|
||||
if (abs_path.contains(QStringLiteral("yuzu"), Qt::CaseInsensitive)) return QStringLiteral("Yuzu");
|
||||
|
||||
return QStringLiteral("another emulator");
|
||||
}
|
||||
|
||||
return QString{};
|
||||
}
|
||||
|
||||
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent)
|
||||
: QObject(parent), gamelist{gamelist_} {}
|
||||
|
||||
@@ -917,6 +953,10 @@ void GameList::DonePopulating(const QStringList& watch_list) {
|
||||
PopulateGridView();
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INFO(Frontend, "Game List populated. Triggering Mirror Sync...");
|
||||
system.GetFileSystemController().GetSaveDataFactory().PerformStartupMirrorSync();
|
||||
|
||||
emit PopulatingCompleted();
|
||||
}
|
||||
|
||||
@@ -1077,59 +1117,82 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
|
||||
const QString new_path = QFileDialog::getExistingDirectory(this, tr("Select Custom Save Data Location"));
|
||||
if (new_path.isEmpty()) return;
|
||||
|
||||
const auto nand_dir = QString::fromStdString(Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir));
|
||||
const auto nand_dir_str = Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir);
|
||||
const QString nand_dir = QString::fromStdString(nand_dir_str);
|
||||
const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128();
|
||||
const std::string relative_save_path = fmt::format("user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id);
|
||||
const QString old_save_path = QDir(nand_dir).filePath(QString::fromStdString(relative_save_path));
|
||||
const QString citron_nand_save_path = QDir(nand_dir).filePath(QString::fromStdString(relative_save_path));
|
||||
|
||||
QDir old_dir(old_save_path);
|
||||
if (old_dir.exists() && !old_dir.isEmpty()) {
|
||||
QMessageBox::StandardButton reply = QMessageBox::question(this, tr("Move Save Data"),
|
||||
tr("You have existing save data in the NAND. Would you like to move it to the new custom save path? Also for reference, if you'd like to use a Global Custom Save Path for all of your titles instead of setting them manually, you can do so within Emulation -> Configure -> Filesystem."),
|
||||
QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel);
|
||||
bool mirroring_enabled = false;
|
||||
QString detected_emu = GetDetectedEmulatorName(new_path, program_id, nand_dir);
|
||||
|
||||
if (reply == QMessageBox::Cancel) return;
|
||||
if (!detected_emu.isEmpty()) {
|
||||
QMessageBox::StandardButton mirror_reply = QMessageBox::question(this, tr("Enable Save Mirroring?"),
|
||||
tr("Citron has detected a %1 save structure.\n\n"
|
||||
"Would you like to enable 'Intelligent Mirroring'? This will pull the data into Citron's NAND "
|
||||
"and keep both locations synced whenever you play. A backup of what is inside of your NAND for Citron will be backed up for you with a corresponding folder name, so if you'd prefer to use Citron's data, please go to that folder & copy the contents and paste it back into the regular Title ID directory. BE WARNED: Please do not A. Have both emulators open during this process, and B. Ensure you do not fully 'delete' your backup that was provided to you incase something goes wrong.").arg(detected_emu),
|
||||
QMessageBox::Yes | QMessageBox::No);
|
||||
|
||||
if (reply == QMessageBox::Yes) {
|
||||
const QString full_dest_path = QDir(new_path).filePath(QString::fromStdString(relative_save_path));
|
||||
if (copyWithProgress(old_save_path, full_dest_path, this)) {
|
||||
QDir(old_save_path).removeRecursively();
|
||||
QMessageBox::information(this, tr("Success"), tr("Successfully moved save data to the new location."));
|
||||
} else {
|
||||
QMessageBox::warning(this, tr("Error"), tr("Failed to move save data. Please see the log for more details."));
|
||||
if (mirror_reply == QMessageBox::Yes) {
|
||||
mirroring_enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
QDir citron_dir(citron_nand_save_path);
|
||||
if (citron_dir.exists() && !citron_dir.isEmpty()) {
|
||||
if (mirroring_enabled) {
|
||||
// Non-destructive backup for mirroring
|
||||
QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd_hh-mm-ss"));
|
||||
QString backup_path = citron_nand_save_path + QStringLiteral("_mirror_backup_") + timestamp;
|
||||
|
||||
// Ensure parent directory exists before renaming
|
||||
QDir().mkpath(QFileInfo(backup_path).absolutePath());
|
||||
|
||||
if (QDir().rename(citron_nand_save_path, backup_path)) {
|
||||
LOG_INFO(Frontend, "Safety: Existing NAND data moved to backup: {}", backup_path.toStdString());
|
||||
}
|
||||
} else {
|
||||
// Standard Citron behavior for manual paths (Override mode)
|
||||
QMessageBox::StandardButton reply = QMessageBox::question(this, tr("Move Save Data"),
|
||||
tr("You have existing save data in the NAND. Would you like to move it to the new custom save path?"),
|
||||
QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel);
|
||||
|
||||
if (reply == QMessageBox::Cancel) return;
|
||||
|
||||
if (reply == QMessageBox::Yes) {
|
||||
// In override mode, we move files TO the new path
|
||||
const QString full_dest_path = QDir(new_path).filePath(QString::fromStdString(relative_save_path));
|
||||
if (copyWithProgress(citron_nand_save_path, full_dest_path, this)) {
|
||||
QDir(citron_nand_save_path).removeRecursively();
|
||||
QMessageBox::information(this, tr("Success"), tr("Successfully moved save data to the new location."));
|
||||
} else {
|
||||
QMessageBox::warning(this, tr("Error"), tr("Failed to move save data. Please see the log for more details."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Settings::values.custom_save_paths.insert_or_assign(program_id, new_path.toStdString());
|
||||
emit SaveConfig();
|
||||
});
|
||||
if (mirroring_enabled) {
|
||||
// Initial Pull (External -> Citron NAND)
|
||||
// We copy FROM the selected folder TO the Citron NAND location
|
||||
if (copyWithProgress(new_path, citron_nand_save_path, this)) {
|
||||
// IMPORTANT: Save to the NEW mirror map
|
||||
Settings::values.mirrored_save_paths.insert_or_assign(program_id, new_path.toStdString());
|
||||
// CLEAR the standard custom path so the emulator boots from NAND
|
||||
Settings::values.custom_save_paths.erase(program_id);
|
||||
|
||||
connect(remove_custom_save_path, &QAction::triggered, [this, program_id, copyWithProgress]() {
|
||||
const QString custom_path_root = QString::fromStdString(Settings::values.custom_save_paths.at(program_id));
|
||||
const auto nand_dir = QString::fromStdString(Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir));
|
||||
const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128();
|
||||
const std::string relative_save_path = fmt::format("user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id);
|
||||
|
||||
const QString custom_game_save_path = QDir(custom_path_root).filePath(QString::fromStdString(relative_save_path));
|
||||
const QString nand_save_path = QDir(nand_dir).filePath(QString::fromStdString(relative_save_path));
|
||||
|
||||
QMessageBox::StandardButton reply = QMessageBox::question(this, tr("Move Save Data"),
|
||||
tr("Would you like to move the save data from the custom path back to the NAND?"),
|
||||
QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel);
|
||||
|
||||
if (reply == QMessageBox::Cancel) return;
|
||||
|
||||
if (reply == QMessageBox::Yes) {
|
||||
if (copyWithProgress(custom_game_save_path, nand_save_path, this)) {
|
||||
QDir(custom_game_save_path).removeRecursively();
|
||||
QMessageBox::information(this, tr("Success"), tr("Successfully moved save data back to the NAND."));
|
||||
QMessageBox::information(this, tr("Success"), tr("Mirroring established. Your data has been pulled into the Citron NAND."));
|
||||
} else {
|
||||
QMessageBox::warning(this, tr("Error"), tr("Failed to move save data. Please see the log for more details."));
|
||||
QMessageBox::warning(this, tr("Error"), tr("Failed to pull data from the mirror source."));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Standard Path Override
|
||||
Settings::values.custom_save_paths.insert_or_assign(program_id, new_path.toStdString());
|
||||
// Remove from mirror map if it was there before
|
||||
Settings::values.mirrored_save_paths.erase(program_id);
|
||||
}
|
||||
|
||||
Settings::values.custom_save_paths.erase(program_id);
|
||||
emit SaveConfig();
|
||||
});
|
||||
|
||||
|
||||
@@ -445,6 +445,12 @@ GMainWindow::GMainWindow(std::unique_ptr<QtConfig> config_, bool has_broken_vulk
|
||||
system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, provider.get());
|
||||
system->GetFileSystemController().CreateFactories(*vfs);
|
||||
|
||||
system->SetContentProvider(std::make_unique<FileSys::ContentProviderUnion>());
|
||||
system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, provider.get());
|
||||
|
||||
// 1. First, create the factories
|
||||
system->GetFileSystemController().CreateFactories(*vfs);
|
||||
|
||||
autoloader_provider = std::make_unique<FileSys::ManualContentProvider>();
|
||||
system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::Autoloader,
|
||||
autoloader_provider.get());
|
||||
@@ -5714,20 +5720,23 @@ void GMainWindow::closeEvent(QCloseEvent* event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This stops mirroring threads before we start saving configs.
|
||||
if (emu_thread != nullptr) {
|
||||
ShutdownGame();
|
||||
}
|
||||
|
||||
// Now save settings
|
||||
UpdateUISettings();
|
||||
config->SaveAllValues();
|
||||
|
||||
game_list->SaveInterfaceLayout();
|
||||
UISettings::SaveWindowState();
|
||||
hotkey_registry.SaveHotkeys();
|
||||
|
||||
// Unload controllers early
|
||||
// Unload controllers
|
||||
controller_dialog->UnloadController();
|
||||
game_list->UnloadController();
|
||||
|
||||
// Shutdown session if the emu thread is active...
|
||||
if (emu_thread != nullptr) {
|
||||
ShutdownGame();
|
||||
}
|
||||
|
||||
render_window->close();
|
||||
multiplayer_state->Close();
|
||||
system->HIDCore().UnloadInputDevices();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project
|
||||
// SPDX-FileCopyrightText: 2025 citron Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <version>
|
||||
@@ -136,6 +137,14 @@ void LogSettings() {
|
||||
log_path("DataStorage_LoadDir", Common::FS::GetCitronPath(Common::FS::CitronPath::LoadDir));
|
||||
log_path("DataStorage_NANDDir", Common::FS::GetCitronPath(Common::FS::CitronPath::NANDDir));
|
||||
log_path("DataStorage_SDMCDir", Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir));
|
||||
|
||||
// Log Custom Save Paths and Mirrored Save Paths for debugging
|
||||
for (const auto& [id, path] : values.custom_save_paths) {
|
||||
log_setting(fmt::format("DataStorage_CustomSavePath_{:016X}", id), path);
|
||||
}
|
||||
for (const auto& [id, path] : values.mirrored_save_paths) {
|
||||
log_setting(fmt::format("DataStorage_MirrorSavePath_{:016X}", id), path);
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateGPUAccuracy() {
|
||||
|
||||
@@ -686,6 +686,9 @@ struct Values {
|
||||
|
||||
// Custom Save Paths (with backups)
|
||||
std::map<u64, std::string> custom_save_paths;
|
||||
// This stores the external path used for Intelligent Mirroring sync
|
||||
std::map<u64, std::string> mirrored_save_paths;
|
||||
|
||||
Setting<bool> global_custom_save_path_enabled{linkage, false, "global_custom_save_path_enabled", Category::DataStorage};
|
||||
Setting<std::string> global_custom_save_path{linkage, std::string(), "global_custom_save_path", Category::DataStorage};
|
||||
Setting<bool> backup_saves_to_nand{linkage, false, "backup_saves_to_nand", Category::DataStorage};
|
||||
|
||||
@@ -17,9 +17,15 @@ constexpr int RetryWaitTimeMs = 100;
|
||||
|
||||
} // Anonymous namespace
|
||||
|
||||
DirectorySaveDataFileSystem::DirectorySaveDataFileSystem(VirtualDir base_filesystem, VirtualDir backup_filesystem)
|
||||
: base_fs(std::move(base_filesystem)), backup_fs(std::move(backup_filesystem)),
|
||||
extra_data_accessor(base_fs), journaling_enabled(true),
|
||||
// Updated constructor to accept mirror_filesystem
|
||||
DirectorySaveDataFileSystem::DirectorySaveDataFileSystem(VirtualDir base_filesystem,
|
||||
VirtualDir backup_filesystem,
|
||||
VirtualDir mirror_filesystem)
|
||||
: base_fs(std::move(base_filesystem)),
|
||||
backup_fs(std::move(backup_filesystem)),
|
||||
mirror_fs(std::move(mirror_filesystem)),
|
||||
extra_data_accessor(base_fs),
|
||||
journaling_enabled(true),
|
||||
open_writable_files(0) {}
|
||||
|
||||
DirectorySaveDataFileSystem::~DirectorySaveDataFileSystem() = default;
|
||||
@@ -88,19 +94,15 @@ Result DirectorySaveDataFileSystem::Commit() {
|
||||
std::scoped_lock lk{mutex};
|
||||
|
||||
if (!journaling_enabled) {
|
||||
// Non-journaling: just commit extra data
|
||||
return extra_data_accessor.CommitExtraDataWithTimeStamp(
|
||||
std::chrono::system_clock::now().time_since_epoch().count());
|
||||
}
|
||||
|
||||
// Check that all writable files are closed
|
||||
if (open_writable_files > 0) {
|
||||
LOG_ERROR(Service_FS, "Cannot commit: {} writable files still open", open_writable_files);
|
||||
return ResultWriteModeFileNotClosed;
|
||||
}
|
||||
|
||||
// Atomic commit process (based on LibHac lines 572-622)
|
||||
// 1. Rename committed → synchronizing (backup old version)
|
||||
auto committed = base_fs->GetSubdirectory(CommittedDirectoryName);
|
||||
if (committed != nullptr) {
|
||||
if (!committed->Rename(SynchronizingDirectoryName)) {
|
||||
@@ -108,41 +110,34 @@ Result DirectorySaveDataFileSystem::Commit() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Copy working → synchronizing (prepare new commit)
|
||||
R_TRY(SynchronizeDirectory(SynchronizingDirectoryName, ModifiedDirectoryName));
|
||||
|
||||
// 3. Commit extra data with updated timestamp
|
||||
R_TRY(extra_data_accessor.CommitExtraDataWithTimeStamp(
|
||||
std::chrono::system_clock::now().time_since_epoch().count()));
|
||||
|
||||
// 4. Rename synchronizing → committed (make it permanent)
|
||||
auto sync_dir = base_fs->GetSubdirectory(SynchronizingDirectoryName);
|
||||
if (sync_dir == nullptr) {
|
||||
return ResultPathNotFound;
|
||||
}
|
||||
|
||||
if (!sync_dir->Rename(CommittedDirectoryName)) {
|
||||
if (sync_dir == nullptr || !sync_dir->Rename(CommittedDirectoryName)) {
|
||||
return ResultPermissionDenied;
|
||||
}
|
||||
|
||||
// Update cached committed_dir reference
|
||||
committed_dir = base_fs->GetSubdirectory(CommittedDirectoryName);
|
||||
|
||||
if (Settings::values.backup_saves_to_nand.GetValue() && backup_fs != nullptr) {
|
||||
LOG_INFO(Service_FS, "Dual-Save: Backing up custom save to NAND...");
|
||||
// Now that the NAND is safely updated, we push the changes back to Ryujinx/Eden
|
||||
if (mirror_fs != nullptr) {
|
||||
LOG_INFO(Service_FS, "Mirroring: Pushing changes back to external source...");
|
||||
|
||||
// 1. Find or Create the '0' (Committed) folder in the NAND
|
||||
auto nand_committed = backup_fs->GetSubdirectory(CommittedDirectoryName);
|
||||
if (nand_committed == nullptr) {
|
||||
nand_committed = backup_fs->CreateSubdirectory(CommittedDirectoryName);
|
||||
}
|
||||
// Use SmartSyncToMirror instead of CleanSubdirectoryRecursive
|
||||
// working_dir contains the data that was just successfully committed
|
||||
SmartSyncToMirror(mirror_fs, working_dir);
|
||||
|
||||
if (nand_committed != nullptr) {
|
||||
// 2. Wipe whatever old backup was there
|
||||
backup_fs->DeleteSubdirectoryRecursive(CommittedDirectoryName);
|
||||
nand_committed = backup_fs->CreateSubdirectory(CommittedDirectoryName);
|
||||
|
||||
// 3. Copy the fresh data from our 'working' area to the NAND '0' folder
|
||||
LOG_INFO(Service_FS, "Mirroring: External sync successful.");
|
||||
}
|
||||
// Standard backup only if Mirroring is NOT active
|
||||
else if (Settings::values.backup_saves_to_nand.GetValue() && backup_fs != nullptr) {
|
||||
LOG_INFO(Service_FS, "Dual-Save: Backing up to NAND...");
|
||||
backup_fs->DeleteSubdirectoryRecursive(CommittedDirectoryName);
|
||||
auto nand_committed = backup_fs->CreateSubdirectory(CommittedDirectoryName);
|
||||
if (nand_committed) {
|
||||
CopyDirectoryRecursively(nand_committed, working_dir);
|
||||
}
|
||||
}
|
||||
@@ -167,8 +162,6 @@ Result DirectorySaveDataFileSystem::Rollback() {
|
||||
}
|
||||
|
||||
bool DirectorySaveDataFileSystem::HasUncommittedChanges() const {
|
||||
// For now, assume any write means uncommitted changes
|
||||
// A full implementation would compare directory contents
|
||||
return open_writable_files > 0;
|
||||
}
|
||||
|
||||
@@ -224,8 +217,7 @@ Result DirectorySaveDataFileSystem::CopyDirectoryRecursively(VirtualDir dest, Vi
|
||||
return ResultSuccess;
|
||||
}
|
||||
|
||||
Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(
|
||||
std::function<Result()> operation) {
|
||||
Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(std::function<Result()> operation) {
|
||||
int remaining_retries = MaxRetryCount;
|
||||
|
||||
while (true) {
|
||||
@@ -235,7 +227,6 @@ Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(
|
||||
return ResultSuccess;
|
||||
}
|
||||
|
||||
// Only retry on TargetLocked error
|
||||
if (result != ResultTargetLocked) {
|
||||
return result;
|
||||
}
|
||||
@@ -249,4 +240,28 @@ Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(
|
||||
}
|
||||
}
|
||||
|
||||
void DirectorySaveDataFileSystem::SmartSyncToMirror(VirtualDir mirror_dest, VirtualDir citron_source) {
|
||||
// Citron: Extra safety check for valid pointers and writable permissions
|
||||
if (mirror_dest == nullptr || citron_source == nullptr || !mirror_dest->IsWritable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync files from Citron back to the Mirror
|
||||
for (const auto& c_file : citron_source->GetFiles()) {
|
||||
auto m_file = mirror_dest->CreateFile(c_file->GetName());
|
||||
if (m_file) {
|
||||
m_file->WriteBytes(c_file->ReadAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively handle subfolders (like 'private', 'extra', etc)
|
||||
for (const auto& c_subdir : citron_source->GetSubdirectories()) {
|
||||
auto m_subdir = mirror_dest->GetDirectoryRelative(c_subdir->GetName());
|
||||
if (m_subdir == nullptr) {
|
||||
m_subdir = mirror_dest->CreateSubdirectory(c_subdir->GetName());
|
||||
}
|
||||
SmartSyncToMirror(m_subdir, c_subdir);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace FileSys
|
||||
|
||||
@@ -18,8 +18,10 @@ namespace FileSys {
|
||||
/// Uses /0 (committed) and /1 (working) directories for journaling
|
||||
class DirectorySaveDataFileSystem {
|
||||
public:
|
||||
// optional directory here for backup
|
||||
explicit DirectorySaveDataFileSystem(VirtualDir base_filesystem, VirtualDir backup_filesystem = nullptr);
|
||||
// Updated constructor to include mirror_filesystem for external sync (Ryujinx/Eden/etc)
|
||||
explicit DirectorySaveDataFileSystem(VirtualDir base_filesystem,
|
||||
VirtualDir backup_filesystem = nullptr,
|
||||
VirtualDir mirror_filesystem = nullptr);
|
||||
~DirectorySaveDataFileSystem();
|
||||
|
||||
/// Initialize the journaling filesystem
|
||||
@@ -53,9 +55,11 @@ private:
|
||||
Result SynchronizeDirectory(const char* dest_name, const char* source_name);
|
||||
Result CopyDirectoryRecursively(VirtualDir dest, VirtualDir source);
|
||||
Result RetryFinitelyForTargetLocked(std::function<Result()> operation);
|
||||
void SmartSyncToMirror(VirtualDir mirror_dest, VirtualDir citron_source);
|
||||
|
||||
VirtualDir base_fs;
|
||||
VirtualDir backup_fs; // This will store the NAND path
|
||||
VirtualDir backup_fs; // This stores the secondary NAND path
|
||||
VirtualDir mirror_fs; // This stores the External Mirror path (Ryujinx/Eden/etc)
|
||||
VirtualDir working_dir;
|
||||
VirtualDir committed_dir;
|
||||
SaveDataExtraDataAccessor extra_data_accessor;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include "common/assert.h"
|
||||
#include "common/common_types.h"
|
||||
#include "common/logging/log.h"
|
||||
@@ -15,56 +17,70 @@
|
||||
#include "core/file_sys/savedata_extra_data_accessor.h"
|
||||
#include "core/file_sys/savedata_factory.h"
|
||||
#include "core/file_sys/vfs/vfs.h"
|
||||
#include "core/file_sys/vfs/vfs_real.h"
|
||||
#include "core/hle/service/acc/profile_manager.h"
|
||||
|
||||
namespace FileSys {
|
||||
|
||||
namespace {
|
||||
|
||||
// Using a leaked raw pointer for the RealVfsFilesystem singleton.
|
||||
// This prevents SIGSEGV during shutdown by ensuring the VFS bridge
|
||||
// outlives all threads that might still be flushing save data.
|
||||
RealVfsFilesystem* GetPersistentVfs() {
|
||||
static RealVfsFilesystem* instance = new RealVfsFilesystem();
|
||||
return instance;
|
||||
}
|
||||
|
||||
bool ShouldSaveDataBeAutomaticallyCreated(SaveDataSpaceId space, const SaveDataAttribute& attr) {
|
||||
return attr.type == SaveDataType::Cache || attr.type == SaveDataType::Temporary ||
|
||||
(space == SaveDataSpaceId::User && ///< Normal Save Data -- Current Title & User
|
||||
(space == SaveDataSpaceId::User &&
|
||||
(attr.type == SaveDataType::Account || attr.type == SaveDataType::Device) &&
|
||||
attr.program_id == 0 && attr.system_save_data_id == 0);
|
||||
}
|
||||
|
||||
std::string GetFutureSaveDataPath(SaveDataSpaceId space_id, SaveDataType type, u64 title_id,
|
||||
u128 user_id) {
|
||||
// Only detect nand user saves.
|
||||
const auto space_id_path = [space_id]() -> std::string_view {
|
||||
switch (space_id) {
|
||||
case SaveDataSpaceId::User:
|
||||
return "/user/save";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}();
|
||||
|
||||
if (space_id_path.empty()) {
|
||||
if (space_id != SaveDataSpaceId::User) {
|
||||
return "";
|
||||
}
|
||||
|
||||
Common::UUID uuid;
|
||||
std::memcpy(uuid.uuid.data(), user_id.data(), sizeof(Common::UUID));
|
||||
|
||||
// Only detect account/device saves from the future location.
|
||||
switch (type) {
|
||||
case SaveDataType::Account:
|
||||
return fmt::format("{}/account/{}/{:016X}/0", space_id_path, uuid.RawString(), title_id);
|
||||
return fmt::format("/user/save/account/{}/{:016X}/0", uuid.RawString(), title_id);
|
||||
case SaveDataType::Device:
|
||||
return fmt::format("{}/device/{:016X}/0", space_id_path, title_id);
|
||||
return fmt::format("/user/save/device/{:016X}/0", title_id);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
void BufferedVfsCopy(VirtualFile source, VirtualFile dest) {
|
||||
if (!source || !dest) return;
|
||||
try {
|
||||
std::vector<u8> buffer(0x100000); // 1MB buffer
|
||||
dest->Resize(0);
|
||||
size_t offset = 0;
|
||||
while (offset < source->GetSize()) {
|
||||
const size_t to_read = std::min(buffer.size(), source->GetSize() - offset);
|
||||
source->Read(buffer.data(), to_read, offset);
|
||||
dest->Write(buffer.data(), to_read, offset);
|
||||
offset += to_read;
|
||||
}
|
||||
} catch (...) {
|
||||
LOG_ERROR(Service_FS, "Critical error during VFS mirror operation.");
|
||||
}
|
||||
}
|
||||
|
||||
} // Anonymous namespace
|
||||
|
||||
SaveDataFactory::SaveDataFactory(Core::System& system_, ProgramId program_id_,
|
||||
VirtualDir save_directory_, VirtualDir backup_directory_)
|
||||
: system{system_}, program_id{program_id_}, dir{std::move(save_directory_)},
|
||||
backup_dir{std::move(backup_directory_)} {
|
||||
// Delete all temporary storages
|
||||
// On hardware, it is expected that temporary storage be empty at first use.
|
||||
dir->DeleteSubdirectoryRecursive("temp");
|
||||
}
|
||||
|
||||
@@ -79,19 +95,14 @@ VirtualDir SaveDataFactory::Create(SaveDataSpaceId space, const SaveDataAttribut
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Initialize ExtraData for new save
|
||||
SaveDataExtraDataAccessor accessor(save_dir);
|
||||
if (accessor.Initialize(true) != ResultSuccess) {
|
||||
LOG_WARNING(Service_FS, "Failed to initialize ExtraData for new save at {}", save_directory);
|
||||
// Continue anyway - save is still usable
|
||||
} else {
|
||||
// Write initial extra data
|
||||
if (accessor.Initialize(true) == ResultSuccess) {
|
||||
SaveDataExtraData initial_data{};
|
||||
initial_data.attr = meta;
|
||||
initial_data.owner_id = meta.program_id;
|
||||
initial_data.timestamp = std::chrono::system_clock::now().time_since_epoch().count();
|
||||
initial_data.flags = static_cast<u32>(SaveDataFlags::None);
|
||||
initial_data.available_size = 0; // Will be updated on commit
|
||||
initial_data.available_size = 0;
|
||||
initial_data.journal_size = 0;
|
||||
initial_data.commit_id = 1;
|
||||
|
||||
@@ -122,6 +133,8 @@ VirtualDir SaveDataFactory::GetSaveDataSpaceDirectory(SaveDataSpaceId space) con
|
||||
std::string SaveDataFactory::GetSaveDataSpaceIdPath(SaveDataSpaceId space) {
|
||||
switch (space) {
|
||||
case SaveDataSpaceId::System:
|
||||
case SaveDataSpaceId::ProperSystem:
|
||||
case SaveDataSpaceId::SafeMode:
|
||||
return "/system/";
|
||||
case SaveDataSpaceId::User:
|
||||
return "/user/";
|
||||
@@ -130,54 +143,37 @@ std::string SaveDataFactory::GetSaveDataSpaceIdPath(SaveDataSpaceId space) {
|
||||
case SaveDataSpaceId::SdSystem:
|
||||
case SaveDataSpaceId::SdUser:
|
||||
return "/sd/";
|
||||
case SaveDataSpaceId::ProperSystem:
|
||||
return "/system/";
|
||||
case SaveDataSpaceId::SafeMode:
|
||||
return "/system/";
|
||||
default:
|
||||
ASSERT_MSG(false, "Unrecognized SaveDataSpaceId: {:02X}", static_cast<u8>(space));
|
||||
return "/unrecognized/"; ///< To prevent corruption when ignoring asserts.
|
||||
return "/unrecognized/";
|
||||
}
|
||||
}
|
||||
|
||||
std::string SaveDataFactory::GetFullPath(ProgramId program_id, VirtualDir dir,
|
||||
SaveDataSpaceId space, SaveDataType type, u64 title_id,
|
||||
u128 user_id, u64 save_id) {
|
||||
// According to switchbrew, if a save is of type SaveData and the title id field is 0, it should
|
||||
// be interpreted as the title id of the current process.
|
||||
if (type == SaveDataType::Account || type == SaveDataType::Device) {
|
||||
if (title_id == 0) {
|
||||
title_id = program_id;
|
||||
}
|
||||
if ((type == SaveDataType::Account || type == SaveDataType::Device) && title_id == 0) {
|
||||
title_id = program_id;
|
||||
}
|
||||
|
||||
// For compat with a future impl.
|
||||
if (std::string future_path =
|
||||
GetFutureSaveDataPath(space, type, title_id & ~(0xFFULL), user_id);
|
||||
if (std::string future_path = GetFutureSaveDataPath(space, type, title_id & ~(0xFFULL), user_id);
|
||||
!future_path.empty()) {
|
||||
// Check if this location exists, and prefer it over the old.
|
||||
if (const auto future_dir = dir->GetDirectoryRelative(future_path); future_dir != nullptr) {
|
||||
LOG_INFO(Service_FS, "Using save at new location: {}", future_path);
|
||||
if (dir->GetDirectoryRelative(future_path) != nullptr) {
|
||||
return future_path;
|
||||
}
|
||||
}
|
||||
|
||||
std::string out = GetSaveDataSpaceIdPath(space);
|
||||
|
||||
switch (type) {
|
||||
case SaveDataType::System:
|
||||
return fmt::format("{}save/{:016X}/{:016X}{:016X}", out, save_id, user_id[1], user_id[0]);
|
||||
case SaveDataType::Account:
|
||||
case SaveDataType::Device:
|
||||
return fmt::format("{}save/{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0],
|
||||
title_id);
|
||||
return fmt::format("{}save/{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0], title_id);
|
||||
case SaveDataType::Temporary:
|
||||
return fmt::format("{}{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0],
|
||||
title_id);
|
||||
return fmt::format("{}{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0], title_id);
|
||||
case SaveDataType::Cache:
|
||||
return fmt::format("{}save/cache/{:016X}", out, title_id);
|
||||
default:
|
||||
ASSERT_MSG(false, "Unrecognized SaveDataType: {:02X}", static_cast<u8>(type));
|
||||
return fmt::format("{}save/unknown_{:X}/{:016X}", out, static_cast<u8>(type), title_id);
|
||||
}
|
||||
}
|
||||
@@ -191,36 +187,21 @@ std::string SaveDataFactory::GetUserGameSaveDataRoot(u128 user_id, bool future)
|
||||
return fmt::format("/user/save/{:016X}/{:016X}{:016X}", 0, user_id[1], user_id[0]);
|
||||
}
|
||||
|
||||
SaveDataSize SaveDataFactory::ReadSaveDataSize(SaveDataType type, u64 title_id,
|
||||
u128 user_id) const {
|
||||
const auto path =
|
||||
GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
|
||||
SaveDataSize SaveDataFactory::ReadSaveDataSize(SaveDataType type, u64 title_id, u128 user_id) const {
|
||||
const auto path = GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
|
||||
const auto relative_dir = GetOrCreateDirectoryRelative(dir, path);
|
||||
|
||||
const auto size_file = relative_dir->GetFile(GetSaveDataSizeFileName());
|
||||
if (size_file == nullptr || size_file->GetSize() < sizeof(SaveDataSize)) {
|
||||
return {0, 0};
|
||||
}
|
||||
|
||||
if (size_file == nullptr || size_file->GetSize() < sizeof(SaveDataSize)) return {0, 0};
|
||||
SaveDataSize out;
|
||||
if (size_file->ReadObject(&out) != sizeof(SaveDataSize)) {
|
||||
return {0, 0};
|
||||
}
|
||||
|
||||
if (size_file->ReadObject(&out) != sizeof(SaveDataSize)) return {0, 0};
|
||||
return out;
|
||||
}
|
||||
|
||||
void SaveDataFactory::WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id,
|
||||
SaveDataSize new_value) const {
|
||||
const auto path =
|
||||
GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
|
||||
void SaveDataFactory::WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id, SaveDataSize new_value) const {
|
||||
const auto path = GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
|
||||
const auto relative_dir = GetOrCreateDirectoryRelative(dir, path);
|
||||
|
||||
const auto size_file = relative_dir->CreateFile(GetSaveDataSizeFileName());
|
||||
if (size_file == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (size_file == nullptr) return;
|
||||
size_file->Resize(sizeof(SaveDataSize));
|
||||
size_file->WriteObject(new_value);
|
||||
}
|
||||
@@ -229,137 +210,173 @@ void SaveDataFactory::SetAutoCreate(bool state) {
|
||||
auto_create = state;
|
||||
}
|
||||
|
||||
Result SaveDataFactory::ReadSaveDataExtraData(SaveDataExtraData* out_extra_data,
|
||||
SaveDataSpaceId space,
|
||||
const SaveDataAttribute& attribute) const {
|
||||
const auto save_directory =
|
||||
GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id,
|
||||
attribute.system_save_data_id);
|
||||
|
||||
Result SaveDataFactory::ReadSaveDataExtraData(SaveDataExtraData* out_extra_data, SaveDataSpaceId space, const SaveDataAttribute& attribute) const {
|
||||
const auto save_directory = GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id, attribute.system_save_data_id);
|
||||
auto save_dir = dir->GetDirectoryRelative(save_directory);
|
||||
if (save_dir == nullptr) {
|
||||
return ResultPathNotFound;
|
||||
}
|
||||
|
||||
if (save_dir == nullptr) return ResultPathNotFound;
|
||||
SaveDataExtraDataAccessor accessor(save_dir);
|
||||
|
||||
// Try to initialize (but don't create if missing)
|
||||
if (Result result = accessor.Initialize(false); result != ResultSuccess) {
|
||||
// ExtraData doesn't exist - return default values
|
||||
LOG_DEBUG(Service_FS, "ExtraData not found for save at {}, returning defaults",
|
||||
save_directory);
|
||||
|
||||
// Return zeroed data
|
||||
*out_extra_data = {}; // Or: *out_extra_data = SaveDataExtraData{};
|
||||
if (accessor.Initialize(false) != ResultSuccess) {
|
||||
*out_extra_data = {};
|
||||
out_extra_data->attr = attribute;
|
||||
return ResultSuccess;
|
||||
}
|
||||
|
||||
return accessor.ReadExtraData(out_extra_data);
|
||||
}
|
||||
|
||||
Result SaveDataFactory::WriteSaveDataExtraData(const SaveDataExtraData& extra_data,
|
||||
SaveDataSpaceId space,
|
||||
const SaveDataAttribute& attribute) const {
|
||||
const auto save_directory =
|
||||
GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id,
|
||||
attribute.system_save_data_id);
|
||||
|
||||
Result SaveDataFactory::WriteSaveDataExtraData(const SaveDataExtraData& extra_data, SaveDataSpaceId space, const SaveDataAttribute& attribute) const {
|
||||
const auto save_directory = GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id, attribute.system_save_data_id);
|
||||
auto save_dir = dir->GetDirectoryRelative(save_directory);
|
||||
if (save_dir == nullptr) {
|
||||
return ResultPathNotFound;
|
||||
}
|
||||
|
||||
if (save_dir == nullptr) return ResultPathNotFound;
|
||||
SaveDataExtraDataAccessor accessor(save_dir);
|
||||
|
||||
// Initialize and create if missing
|
||||
R_TRY(accessor.Initialize(true));
|
||||
|
||||
// Write the data
|
||||
R_TRY(accessor.WriteExtraData(extra_data));
|
||||
|
||||
// Commit immediately for transactional writes
|
||||
R_TRY(accessor.CommitExtraData());
|
||||
|
||||
return ResultSuccess;
|
||||
return accessor.CommitExtraData();
|
||||
}
|
||||
|
||||
Result SaveDataFactory::WriteSaveDataExtraDataWithMask(const SaveDataExtraData& extra_data,
|
||||
const SaveDataExtraData& mask,
|
||||
SaveDataSpaceId space,
|
||||
const SaveDataAttribute& attribute) const {
|
||||
const auto save_directory =
|
||||
GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id,
|
||||
attribute.system_save_data_id);
|
||||
|
||||
Result SaveDataFactory::WriteSaveDataExtraDataWithMask(const SaveDataExtraData& extra_data, const SaveDataExtraData& mask, SaveDataSpaceId space, const SaveDataAttribute& attribute) const {
|
||||
const auto save_directory = GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id, attribute.system_save_data_id);
|
||||
auto save_dir = dir->GetDirectoryRelative(save_directory);
|
||||
if (save_dir == nullptr) {
|
||||
return ResultPathNotFound;
|
||||
}
|
||||
|
||||
if (save_dir == nullptr) return ResultPathNotFound;
|
||||
SaveDataExtraDataAccessor accessor(save_dir);
|
||||
|
||||
// Initialize and create if missing
|
||||
R_TRY(accessor.Initialize(true));
|
||||
|
||||
// Read existing data
|
||||
SaveDataExtraData current_data{};
|
||||
R_TRY(accessor.ReadExtraData(¤t_data));
|
||||
|
||||
// Apply mask: copy only the bytes where mask is non-zero
|
||||
const u8* extra_data_bytes = reinterpret_cast<const u8*>(&extra_data);
|
||||
const u8* mask_bytes = reinterpret_cast<const u8*>(&mask);
|
||||
u8* current_data_bytes = reinterpret_cast<u8*>(¤t_data);
|
||||
|
||||
for (size_t i = 0; i < sizeof(SaveDataExtraData); ++i) {
|
||||
if (mask_bytes[i] != 0) {
|
||||
current_data_bytes[i] = extra_data_bytes[i];
|
||||
if (mask_bytes[i] != 0) current_data_bytes[i] = extra_data_bytes[i];
|
||||
}
|
||||
R_TRY(accessor.WriteExtraData(current_data));
|
||||
return accessor.CommitExtraData();
|
||||
}
|
||||
|
||||
// --- MIRRORING TOOLS ---
|
||||
|
||||
VirtualDir SaveDataFactory::GetMirrorDirectory(u64 title_id) const {
|
||||
auto it = Settings::values.mirrored_save_paths.find(title_id);
|
||||
if (it == Settings::values.mirrored_save_paths.end() || it->second.empty()) return nullptr;
|
||||
|
||||
std::filesystem::path host_path(it->second);
|
||||
if (!std::filesystem::exists(host_path)) return nullptr;
|
||||
|
||||
// Get the persistent VFS bridge
|
||||
auto* vfs = GetPersistentVfs();
|
||||
return vfs->OpenDirectory(it->second, OpenMode::ReadWrite);
|
||||
}
|
||||
|
||||
void SaveDataFactory::SmartSyncFromSource(VirtualDir source, VirtualDir dest) const {
|
||||
// Citron: Shutdown and null safety
|
||||
if (!source || !dest || system.IsShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync files from Source to Destination
|
||||
for (const auto& s_file : source->GetFiles()) {
|
||||
if (!s_file) continue;
|
||||
std::string name = s_file->GetName();
|
||||
|
||||
// Skip metadata and lock files
|
||||
if (name == ".lock" || name == ".citron_save_size" || name.find("mirror_backup") != std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto d_file = dest->CreateFile(name);
|
||||
if (d_file) {
|
||||
BufferedVfsCopy(s_file, d_file);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back the masked data
|
||||
R_TRY(accessor.WriteExtraData(current_data));
|
||||
// Recurse into subdirectories
|
||||
for (const auto& s_subdir : source->GetSubdirectories()) {
|
||||
if (!s_subdir) continue;
|
||||
|
||||
// Commit the changes
|
||||
R_TRY(accessor.CommitExtraData());
|
||||
// Prevent recursion into title-id-named folders to avoid infinite loops
|
||||
if (s_subdir->GetName().find("0100") != std::string::npos) continue;
|
||||
|
||||
return ResultSuccess;
|
||||
auto d_subdir = dest->GetDirectoryRelative(s_subdir->GetName());
|
||||
if (!d_subdir) {
|
||||
d_subdir = dest->CreateDirectoryRelative(s_subdir->GetName());
|
||||
}
|
||||
|
||||
if (d_subdir) {
|
||||
SmartSyncFromSource(s_subdir, d_subdir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SaveDataFactory::PerformStartupMirrorSync() const {
|
||||
// If settings are empty or system is shutting down/uninitialized
|
||||
if (Settings::values.mirrored_save_paths.empty() || system.IsShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure our NAND directory is actually valid
|
||||
if (!dir) {
|
||||
LOG_ERROR(Service_FS, "Mirroring: Startup Sync aborted. NAND directory is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to locate the save root with null checks at every step
|
||||
VirtualDir user_save_root = nullptr;
|
||||
try {
|
||||
user_save_root = dir->GetDirectoryRelative("user/save/0000000000000000");
|
||||
if (!user_save_root) {
|
||||
user_save_root = dir->GetDirectoryRelative("user/save");
|
||||
}
|
||||
} catch (...) {
|
||||
LOG_ERROR(Service_FS, "Mirroring: Critical failure accessing VFS. Filesystem may be stale.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user_save_root) {
|
||||
LOG_WARNING(Service_FS, "Mirroring: Could not find user save root in NAND.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO(Service_FS, "Mirroring: Startup Sync initiated.");
|
||||
|
||||
for (const auto& [title_id, host_path] : Settings::values.mirrored_save_paths) {
|
||||
if (host_path.empty()) continue;
|
||||
|
||||
auto mirror_source = GetMirrorDirectory(title_id);
|
||||
if (!mirror_source) continue;
|
||||
|
||||
std::string title_id_str = fmt::format("{:016X}", title_id);
|
||||
|
||||
for (const auto& profile_dir : user_save_root->GetSubdirectories()) {
|
||||
if (!profile_dir) continue;
|
||||
|
||||
auto nand_dest = profile_dir->GetDirectoryRelative(title_id_str);
|
||||
|
||||
if (!nand_dest) {
|
||||
for (const auto& sub : profile_dir->GetSubdirectories()) {
|
||||
if (!sub) continue;
|
||||
nand_dest = sub->GetDirectoryRelative(title_id_str);
|
||||
if (nand_dest) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nand_dest) {
|
||||
LOG_INFO(Service_FS, "Mirroring: Pulling external data for {}", title_id_str);
|
||||
SmartSyncFromSource(mirror_source, nand_dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SaveDataFactory::DoNandBackup(SaveDataSpaceId space, const SaveDataAttribute& meta, VirtualDir custom_dir) const {
|
||||
LOG_INFO(Common, "Dual-Save: Backup process initiated for Program ID: {:016X}", program_id);
|
||||
u64 title_id = (meta.program_id != 0 ? meta.program_id : static_cast<u64>(program_id));
|
||||
if (Settings::values.mirrored_save_paths.count(title_id)) return;
|
||||
|
||||
if (!Settings::values.backup_saves_to_nand.GetValue()) {
|
||||
LOG_INFO(Common, "Dual-Save: Backup skipped (Setting is OFF)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (backup_dir == nullptr) {
|
||||
LOG_ERROR(Common, "Dual-Save: Backup failed (NAND directory is NULL)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (custom_dir == nullptr) {
|
||||
LOG_ERROR(Common, "Dual-Save: Backup failed (Source Custom directory is NULL)");
|
||||
return;
|
||||
}
|
||||
|
||||
const auto nand_path = GetFullPath(program_id, backup_dir, space, meta.type, meta.program_id,
|
||||
meta.user_id, meta.system_save_data_id);
|
||||
if (!Settings::values.backup_saves_to_nand.GetValue() || backup_dir == nullptr || custom_dir == nullptr) return;
|
||||
|
||||
const auto nand_path = GetFullPath(program_id, backup_dir, space, meta.type, meta.program_id, meta.user_id, meta.system_save_data_id);
|
||||
auto nand_out = backup_dir->CreateDirectoryRelative(nand_path);
|
||||
if (nand_out != nullptr) {
|
||||
LOG_INFO(Common, "Dual-Save: Mirroring files to NAND: {}", nand_path);
|
||||
|
||||
// Clear the old backup
|
||||
if (nand_out) {
|
||||
nand_out->CleanSubdirectoryRecursive(".");
|
||||
|
||||
// Perform the copy
|
||||
VfsRawCopyD(custom_dir, nand_out);
|
||||
|
||||
LOG_INFO(Common, "Dual-Save: NAND Backup successful.");
|
||||
} else {
|
||||
LOG_ERROR(Common, "Dual-Save: Could not create/access NAND backup path!");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ constexpr const char* GetSaveDataSizeFileName() {
|
||||
|
||||
using ProgramId = u64;
|
||||
|
||||
/// File system interface to the SaveData archive
|
||||
class SaveDataFactory {
|
||||
public:
|
||||
explicit SaveDataFactory(Core::System& system_, ProgramId program_id_,
|
||||
@@ -45,7 +44,6 @@ public:
|
||||
void WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id,
|
||||
SaveDataSize new_value) const;
|
||||
|
||||
// ExtraData operations
|
||||
Result ReadSaveDataExtraData(SaveDataExtraData* out_extra_data, SaveDataSpaceId space,
|
||||
const SaveDataAttribute& attribute) const;
|
||||
Result WriteSaveDataExtraData(const SaveDataExtraData& extra_data, SaveDataSpaceId space,
|
||||
@@ -57,11 +55,16 @@ public:
|
||||
void SetAutoCreate(bool state);
|
||||
void DoNandBackup(SaveDataSpaceId space, const SaveDataAttribute& meta, VirtualDir custom_dir) const;
|
||||
|
||||
// --- MIRRORING TOOLS ---
|
||||
VirtualDir GetMirrorDirectory(u64 title_id) const;
|
||||
void SmartSyncFromSource(VirtualDir source, VirtualDir dest) const;
|
||||
void PerformStartupMirrorSync() const;
|
||||
|
||||
private:
|
||||
Core::System& system;
|
||||
ProgramId program_id;
|
||||
VirtualDir dir;
|
||||
VirtualDir backup_dir; // This will hold the NAND path
|
||||
VirtualDir backup_dir;
|
||||
bool auto_create{true};
|
||||
};
|
||||
|
||||
|
||||
@@ -807,6 +807,11 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove
|
||||
system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC,
|
||||
sdmc_factory->GetSDMCContents());
|
||||
}
|
||||
|
||||
// factory that handles sync tasks before a game is even selected
|
||||
if (global_save_data_factory == nullptr || overwrite) {
|
||||
global_save_data_factory = CreateSaveDataFactory(ProgramId{});
|
||||
}
|
||||
}
|
||||
|
||||
void FileSystemController::Reset() {
|
||||
|
||||
@@ -121,6 +121,9 @@ public:
|
||||
// above is called.
|
||||
void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true);
|
||||
|
||||
// getter for main.cpp to trigger the sync between custom game paths for separate emulators
|
||||
FileSys::SaveDataFactory& GetSaveDataFactory() { return *global_save_data_factory; }
|
||||
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
@@ -142,6 +145,9 @@ private:
|
||||
std::unique_ptr<FileSys::RegisteredCache> gamecard_registered;
|
||||
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;
|
||||
|
||||
// Global factory for startup tasks and mirroring
|
||||
std::shared_ptr<FileSys::SaveDataFactory> global_save_data_factory;
|
||||
|
||||
Core::System& system;
|
||||
};
|
||||
|
||||
|
||||
@@ -132,12 +132,32 @@ Result IFileSystem::GetEntryType(
|
||||
}
|
||||
|
||||
Result IFileSystem::Commit() {
|
||||
// 1. Standard Commit
|
||||
Result res = backend->Commit();
|
||||
if (res != ResultSuccess) return res;
|
||||
|
||||
if (save_factory && Settings::values.backup_saves_to_nand.GetValue()) {
|
||||
LOG_INFO(Common, "IFileSystem: Commit detected, triggering NAND backup...");
|
||||
save_factory->DoNandBackup(save_space, save_attr, content_dir);
|
||||
// Citron: Shutdown Safety Check
|
||||
// If the emulator is stopping, the VFS might be invalid. Skip mirroring to prevent SEGV.
|
||||
if (system.IsShuttingDown()) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// 2. (Citron NAND -> External)
|
||||
if (save_factory) {
|
||||
u64 title_id = save_attr.program_id != 0 ? save_attr.program_id : system.GetApplicationProcessProgramID();
|
||||
auto mirror_dir = save_factory->GetMirrorDirectory(title_id);
|
||||
|
||||
if (mirror_dir != nullptr) {
|
||||
LOG_INFO(Service_FS, "Mirroring: Pushing Citron NAND data back to external source...");
|
||||
|
||||
// SYNC: Citron NAND Title ID folder -> Selected External Folder Contents
|
||||
save_factory->SmartSyncFromSource(content_dir, mirror_dir);
|
||||
|
||||
LOG_INFO(Service_FS, "Mirroring: Push complete.");
|
||||
}
|
||||
else if (Settings::values.backup_saves_to_nand.GetValue()) {
|
||||
save_factory->DoNandBackup(save_space, save_attr, content_dir);
|
||||
}
|
||||
}
|
||||
|
||||
R_RETURN(res);
|
||||
|
||||
@@ -252,9 +252,11 @@ Result FSP_SRV::CreateSaveDataFileSystemBySystemSaveDataId(
|
||||
Result FSP_SRV::OpenSaveDataFileSystem(OutInterface<IFileSystem> out_interface,
|
||||
FileSys::SaveDataSpaceId space_id,
|
||||
FileSys::SaveDataAttribute attribute) {
|
||||
LOG_INFO(Service_FS, "called.");
|
||||
LOG_INFO(Service_FS, "called, space_id={:02X}, program_id={:016X}",
|
||||
static_cast<u8>(space_id), attribute.program_id);
|
||||
|
||||
FileSys::VirtualDir dir{};
|
||||
// This triggers the 'Smart Pull' (Ryujinx -> Citron) in savedata_factory.cpp
|
||||
R_TRY(save_data_controller->OpenSaveData(&dir, space_id, attribute));
|
||||
|
||||
FileSys::StorageId id{};
|
||||
@@ -267,19 +269,15 @@ Result FSP_SRV::OpenSaveDataFileSystem(OutInterface<IFileSystem> out_interface,
|
||||
id = FileSys::StorageId::SdCard;
|
||||
break;
|
||||
case FileSys::SaveDataSpaceId::System:
|
||||
id = FileSys::StorageId::NandSystem;
|
||||
break;
|
||||
case FileSys::SaveDataSpaceId::Temporary:
|
||||
id = FileSys::StorageId::NandSystem;
|
||||
break;
|
||||
case FileSys::SaveDataSpaceId::ProperSystem:
|
||||
id = FileSys::StorageId::NandSystem;
|
||||
break;
|
||||
case FileSys::SaveDataSpaceId::SafeMode:
|
||||
id = FileSys::StorageId::NandSystem;
|
||||
break;
|
||||
}
|
||||
|
||||
// Wrap the directory in the IFileSystem interface.
|
||||
// We pass 'save_data_controller->GetFactory()' so the Commit function can find the Mirror.
|
||||
*out_interface = std::make_shared<IFileSystem>(
|
||||
system, std::move(dir), SizeGetter::FromStorageId(fsc, id),
|
||||
save_data_controller->GetFactory(), space_id, attribute);
|
||||
|
||||
@@ -459,6 +459,7 @@ void Config::ReadValues() {
|
||||
ReadDataStorageValues();
|
||||
ReadDebuggingValues();
|
||||
ReadCustomSavePathValues();
|
||||
ReadMirrorValues();
|
||||
ReadDisabledAddOnValues();
|
||||
ReadDisabledCheatValues();
|
||||
ReadNetworkValues();
|
||||
@@ -564,6 +565,7 @@ void Config::SaveValues() {
|
||||
SaveDataStorageValues();
|
||||
SaveDebuggingValues();
|
||||
SaveCustomSavePathValues();
|
||||
SaveMirrorValues();
|
||||
SaveDisabledAddOnValues();
|
||||
SaveDisabledCheatValues();
|
||||
SaveNetworkValues();
|
||||
@@ -1181,3 +1183,33 @@ void Config::SetArrayIndex(const int index) {
|
||||
array_stack.back().size = array_index;
|
||||
array_stack.back().index = array_index;
|
||||
}
|
||||
|
||||
void Config::ReadMirrorValues() {
|
||||
BeginGroup(std::string("MirroredSaves"));
|
||||
const int size = BeginArray(std::string("")); // Keyless array style
|
||||
for (int i = 0; i < size; ++i) {
|
||||
SetArrayIndex(i);
|
||||
const auto title_id = ReadUnsignedIntegerSetting(std::string("title_id"), 0);
|
||||
const auto path = ReadStringSetting(std::string("path"), std::string(""));
|
||||
|
||||
if (title_id != 0 && !path.empty()) {
|
||||
Settings::values.mirrored_save_paths.insert_or_assign(title_id, path);
|
||||
}
|
||||
}
|
||||
EndArray();
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void Config::SaveMirrorValues() {
|
||||
BeginGroup(std::string("MirroredSaves"));
|
||||
int i = 0;
|
||||
BeginArray(std::string("")); // Keyless array style
|
||||
for (const auto& elem : Settings::values.mirrored_save_paths) {
|
||||
SetArrayIndex(i);
|
||||
WriteIntegerSetting(std::string("title_id"), elem.first, std::make_optional(static_cast<u64>(0)));
|
||||
WriteStringSetting(std::string("path"), elem.second, std::make_optional(std::string("")));
|
||||
++i;
|
||||
}
|
||||
EndArray();
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ protected:
|
||||
|
||||
void Reload();
|
||||
|
||||
void ReadMirrorValues();
|
||||
void SaveMirrorValues();
|
||||
|
||||
/**
|
||||
* Derived config classes must implement this so they can reload all platform-specific
|
||||
* values and global ones.
|
||||
|
||||
Reference in New Issue
Block a user