From cc51cc6a12c4ab34690d07b5fafd147641ce02a8 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:45:23 +0000 Subject: [PATCH 01/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/citron/main.cpp | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/citron/main.cpp b/src/citron/main.cpp index b7d90338b..3b5d2a259 100644 --- a/src/citron/main.cpp +++ b/src/citron/main.cpp @@ -445,6 +445,12 @@ GMainWindow::GMainWindow(std::unique_ptr config_, bool has_broken_vulk system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, provider.get()); system->GetFileSystemController().CreateFactories(*vfs); + system->SetContentProvider(std::make_unique()); + system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, provider.get()); + + // 1. First, create the factories + system->GetFileSystemController().CreateFactories(*vfs); + autoloader_provider = std::make_unique(); 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(); From 8fbd4661a21e563b3935c726f158ab9be3b79bb2 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:49:14 +0000 Subject: [PATCH 02/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/citron/game_list.cpp | 141 ++++++++++++++++++++++++++++----------- 1 file changed, 102 insertions(+), 39 deletions(-) diff --git a/src/citron/game_list.cpp b/src/citron/game_list.cpp index ca7789c8d..a9ff04698 100644 --- a/src/citron/game_list.cpp +++ b/src/citron/game_list.cpp @@ -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(); }); From 1820ed7815677e37b548eb44a579fd94284cec0f Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:50:58 +0000 Subject: [PATCH 03/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/core/file_sys/directory_save_data_filesystem.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/file_sys/directory_save_data_filesystem.h b/src/core/file_sys/directory_save_data_filesystem.h index 954841cdd..9a1f1a101 100644 --- a/src/core/file_sys/directory_save_data_filesystem.h +++ b/src/core/file_sys/directory_save_data_filesystem.h @@ -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 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; From 4f4b3a9360464139c2dd299dd7fd143d47db9ac4 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:51:44 +0000 Subject: [PATCH 04/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- .../directory_save_data_filesystem.cpp | 83 +++++++++++-------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/src/core/file_sys/directory_save_data_filesystem.cpp b/src/core/file_sys/directory_save_data_filesystem.cpp index cdd7ce72e..cbcc75c3b 100644 --- a/src/core/file_sys/directory_save_data_filesystem.cpp +++ b/src/core/file_sys/directory_save_data_filesystem.cpp @@ -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 operation) { +Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(std::function 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 From 70926d388de0cf8272b748e2183ec4413b79e5d9 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:52:44 +0000 Subject: [PATCH 05/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/common/settings.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/common/settings.h b/src/common/settings.h index a2d37f10b..e6c0f5500 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -686,6 +686,9 @@ struct Values { // Custom Save Paths (with backups) std::map custom_save_paths; + // This stores the external path used for Intelligent Mirroring sync + std::map mirrored_save_paths; + Setting global_custom_save_path_enabled{linkage, false, "global_custom_save_path_enabled", Category::DataStorage}; Setting global_custom_save_path{linkage, std::string(), "global_custom_save_path", Category::DataStorage}; Setting backup_saves_to_nand{linkage, false, "backup_saves_to_nand", Category::DataStorage}; From c377c0c40798aca5457f65bf64f8262d2d678dd7 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:53:38 +0000 Subject: [PATCH 06/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/common/settings.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/common/settings.cpp b/src/common/settings.cpp index f34ee2709..a17e13bb9 100644 --- a/src/common/settings.cpp +++ b/src/common/settings.cpp @@ -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 @@ -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() { From dee84bd8a3db85456884214644baaa40d693820e Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:55:03 +0000 Subject: [PATCH 07/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/core/file_sys/savedata_factory.cpp | 345 +++++++++++++------------ 1 file changed, 181 insertions(+), 164 deletions(-) diff --git a/src/core/file_sys/savedata_factory.cpp b/src/core/file_sys/savedata_factory.cpp index 3ca2e0a6d..8b689e9fc 100644 --- a/src/core/file_sys/savedata_factory.cpp +++ b/src/core/file_sys/savedata_factory.cpp @@ -3,7 +3,9 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include +#include #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 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(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(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(type)); return fmt::format("{}save/unknown_{:X}/{:016X}", out, static_cast(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(&extra_data); const u8* mask_bytes = reinterpret_cast(&mask); u8* current_data_bytes = reinterpret_cast(¤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(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!"); } } From 4a81f4ff8391f4b0e23487c2c966678eb61c9385 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:56:04 +0000 Subject: [PATCH 08/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/core/file_sys/savedata_factory.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/file_sys/savedata_factory.h b/src/core/file_sys/savedata_factory.h index 54250515d..2d079b81c 100644 --- a/src/core/file_sys/savedata_factory.h +++ b/src/core/file_sys/savedata_factory.h @@ -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}; }; From 63e48d91918b6de08e6f7771a0effe7c8bccf593 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:57:15 +0000 Subject: [PATCH 09/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/core/hle/service/filesystem/fsp/fsp_srv.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/core/hle/service/filesystem/fsp/fsp_srv.cpp b/src/core/hle/service/filesystem/fsp/fsp_srv.cpp index 32c79af63..b34ba658a 100644 --- a/src/core/hle/service/filesystem/fsp/fsp_srv.cpp +++ b/src/core/hle/service/filesystem/fsp/fsp_srv.cpp @@ -252,9 +252,11 @@ Result FSP_SRV::CreateSaveDataFileSystemBySystemSaveDataId( Result FSP_SRV::OpenSaveDataFileSystem(OutInterface 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(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 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( system, std::move(dir), SizeGetter::FromStorageId(fsc, id), save_data_controller->GetFactory(), space_id, attribute); From bbd74891fd3bb8ce36daa734dacc8871c6153511 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:58:00 +0000 Subject: [PATCH 10/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- .../filesystem/fsp/fs_i_filesystem.cpp | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/core/hle/service/filesystem/fsp/fs_i_filesystem.cpp b/src/core/hle/service/filesystem/fsp/fs_i_filesystem.cpp index c21285a96..6aa6c60b4 100644 --- a/src/core/hle/service/filesystem/fsp/fs_i_filesystem.cpp +++ b/src/core/hle/service/filesystem/fsp/fs_i_filesystem.cpp @@ -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); From 1ca62a0302c7af114e17cab3d008691cd5c7c3e6 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:58:49 +0000 Subject: [PATCH 11/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/core/hle/service/filesystem/filesystem.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 84aa313f9..056ca8d70 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -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() { From 678d6c095f60d5d00759f96953dd5c38a3e55c86 Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 03:59:31 +0000 Subject: [PATCH 12/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/core/hle/service/filesystem/filesystem.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/hle/service/filesystem/filesystem.h b/src/core/hle/service/filesystem/filesystem.h index 718500385..6dc60254f 100644 --- a/src/core/hle/service/filesystem/filesystem.h +++ b/src/core/hle/service/filesystem/filesystem.h @@ -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 gamecard_registered; std::unique_ptr gamecard_placeholder; + // Global factory for startup tasks and mirroring + std::shared_ptr global_save_data_factory; + Core::System& system; }; From 43319bde2dab4ea2962c34a1c1a404879871f68a Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 04:00:32 +0000 Subject: [PATCH 13/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/frontend_common/config.cpp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/frontend_common/config.cpp b/src/frontend_common/config.cpp index 8b9b85ba3..d53e022e2 100644 --- a/src/frontend_common/config.cpp +++ b/src/frontend_common/config.cpp @@ -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(0))); + WriteStringSetting(std::string("path"), elem.second, std::make_optional(std::string(""))); + ++i; + } + EndArray(); + EndGroup(); +} From 8aba08322eb814cd4bc27ea040f728640042d2df Mon Sep 17 00:00:00 2001 From: Collecting Date: Wed, 31 Dec 2025 04:01:30 +0000 Subject: [PATCH 14/14] feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths Signed-off-by: Collecting --- src/frontend_common/config.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/frontend_common/config.h b/src/frontend_common/config.h index 35c3273e2..1a0a1fe64 100644 --- a/src/frontend_common/config.h +++ b/src/frontend_common/config.h @@ -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.