From d7814f6f768885fa941b8d7e75ff6139abe68ae2 Mon Sep 17 00:00:00 2001 From: Collecting Date: Sat, 24 Jan 2026 02:57:14 +0100 Subject: [PATCH 1/4] feat(add): Initial Sync to fix overwriting of NAND when Configuring Settings outside of emulation Signed-off-by: Collecting --- src/citron/main.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/citron/main.h b/src/citron/main.h index 35625e70a..661ab49f2 100644 --- a/src/citron/main.h +++ b/src/citron/main.h @@ -118,6 +118,8 @@ public: void RefreshGameList(); GRenderWindow* GetRenderWindow() const { return render_window; } bool ExtractZipToDirectoryPublic(const std::filesystem::path& zip_path, const std::filesystem::path& extract_path); + [[nodiscard]] bool HasPerformedInitialSync() const { return has_performed_initial_sync; } + void SetPerformedInitialSync(bool synced) { has_performed_initial_sync = synced; } signals: void EmulationStarting(EmuThread* emu_thread); void EmulationStopping(); @@ -403,6 +405,7 @@ private: bool is_tas_recording_dialog_active{}; bool m_is_updating_theme = false; bool m_is_configuring = false; + bool has_performed_initial_sync = false; #ifdef __unix__ QSocketNotifier* sig_interrupt_notifier; static std::array sig_interrupt_fds; From c0914868f6dc0f9efb4af8d60c340355affaa7f6 Mon Sep 17 00:00:00 2001 From: Collecting Date: Sat, 24 Jan 2026 02:58:20 +0100 Subject: [PATCH 2/4] feat(add): Boolean for Initial Sync to ensure re-arm for proper syncing Signed-off-by: Collecting --- src/citron/main.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/citron/main.cpp b/src/citron/main.cpp index 86b7e9251..534bb97eb 100644 --- a/src/citron/main.cpp +++ b/src/citron/main.cpp @@ -2308,6 +2308,13 @@ void GMainWindow::OnEmulationStopped() { emulation_running = false; + // Reset the startup sync flag for the next session. + has_performed_initial_sync = false; + LOG_INFO(Frontend, "Mirroring: Emulation stopped. Re-arming startup sync for next game list refresh."); + + // This is necessary to reset the in-memory state for the next launch. + system->GetFileSystemController().CreateFactories(*vfs, true); + discord_rpc->Update(); #ifdef __unix__ @@ -2426,30 +2433,28 @@ void GMainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target case GameListOpenTarget::SaveData: { open_target = tr("Save Data"); - // 1. Check for Mirrored Path FIRST (opens the external directory) + // 1. Priority 1: Mirrored Path (opens the external directory) if (Settings::values.mirrored_save_paths.count(program_id)) { const std::string& mirrored_path_str = Settings::values.mirrored_save_paths.at(program_id); if (!mirrored_path_str.empty() && Common::FS::IsDir(mirrored_path_str)) { - LOG_INFO(Frontend, "Opening mirrored save data path for program_id={:016x}", + LOG_INFO(Frontend, "Opening external mirrored save data path for program_id={:016x}", program_id); QDesktopServices::openUrl( QUrl::fromLocalFile(QString::fromStdString(mirrored_path_str))); return; } } - // 2. Check for Per-Game Custom Path + // 2. Priority 2: Per-Game Custom Path else if (Settings::values.custom_save_paths.count(program_id)) { const std::string& custom_path_str = Settings::values.custom_save_paths.at(program_id); if (!custom_path_str.empty() && Common::FS::IsDir(custom_path_str)) { - LOG_INFO(Frontend, "Opening custom save data path for program_id={:016x}", - program_id); - QDesktopServices::openUrl( - QUrl::fromLocalFile(QString::fromStdString(custom_path_str))); + LOG_INFO(Frontend, "Opening per-game custom save data path for program_id={:016x}", program_id); + QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(custom_path_str))); return; } } - // 3. Check for Global Custom Save Path + // 3. Priority 3: Global Custom Path else if (Settings::values.global_custom_save_path_enabled.GetValue()) { const std::string& global_path_str = Settings::values.global_custom_save_path.GetValue(); From 7dee5834bfdcde56a7924e5bafd681019ef2f938 Mon Sep 17 00:00:00 2001 From: Collecting Date: Sat, 24 Jan 2026 02:59:14 +0100 Subject: [PATCH 3/4] feat(add): More options including Dynamic understanding of Global & Default NAND Locations Signed-off-by: Collecting --- src/citron/game_list.cpp | 217 +++++++++++++++++++++++++-------------- 1 file changed, 142 insertions(+), 75 deletions(-) diff --git a/src/citron/game_list.cpp b/src/citron/game_list.cpp index d6113559b..57d0a7a1e 100644 --- a/src/citron/game_list.cpp +++ b/src/citron/game_list.cpp @@ -959,8 +959,13 @@ void GameList::DonePopulating(const QStringList& watch_list) { // Only sync if we aren't rebuilding the UI and the game isn't running. if (main_window && !main_window->IsConfiguring() && !system.IsPoweredOn()) { - LOG_INFO(Frontend, "Game List populated. Triggering Mirror Sync..."); - system.GetFileSystemController().GetSaveDataFactory().PerformStartupMirrorSync(); + if (!main_window->HasPerformedInitialSync()) { + LOG_INFO(Frontend, "Mirroring: Performing one-time startup sync..."); + system.GetFileSystemController().GetSaveDataFactory().PerformStartupMirrorSync(); + main_window->SetPerformedInitialSync(true); + } else { + LOG_INFO(Frontend, "Mirroring: Startup sync already performed this session. Skipping."); + } } else { LOG_INFO(Frontend, "Mirroring: Startup sync skipped (Reason: UI Busy or Game is Emulating)."); } @@ -1014,14 +1019,20 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { } void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path, const QString& game_name) { + const bool is_mirrored = Settings::values.mirrored_save_paths.count(program_id); + const bool has_custom_path = Settings::values.custom_save_paths.count(program_id); + QString mirror_base_path; + QAction* favorite = context_menu.addAction(tr("Favorite")); context_menu.addSeparator(); QAction* start_game = context_menu.addAction(tr("Start Game")); QAction* start_game_global = context_menu.addAction(tr("Start Game without Custom Configuration")); context_menu.addSeparator(); QAction* open_save_location = context_menu.addAction(tr("Open Save Data Location")); + QAction* open_nand_location = context_menu.addAction(tr("Open NAND Location")); QAction* set_custom_save_path = context_menu.addAction(tr("Set Custom Save Path")); QAction* remove_custom_save_path = context_menu.addAction(tr("Revert to NAND Save Path")); + QAction* disable_mirroring = context_menu.addAction(tr("Disable Mirroring")); QAction* open_mod_location = context_menu.addAction(tr("Open Mod Data Location")); QMenu* open_sdmc_mod_menu = context_menu.addMenu(tr("Open SDMC Mod Data Location")); QAction* open_current_game_sdmc = open_sdmc_mod_menu->addAction(tr("Open Current Game Location")); @@ -1053,14 +1064,15 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri context_menu.addSeparator(); QAction* properties = context_menu.addAction(tr("Properties")); - const bool has_custom_path = Settings::values.custom_save_paths.count(program_id); - favorite->setVisible(program_id != 0); favorite->setCheckable(true); favorite->setChecked(UISettings::values.favorited_ids.contains(program_id)); open_save_location->setVisible(program_id != 0); - set_custom_save_path->setVisible(program_id != 0); + open_nand_location->setVisible(is_mirrored); + open_nand_location->setToolTip(tr("Citron uses your NAND while syncing. If you need to make save data modifications, do so in here.")); + set_custom_save_path->setVisible(program_id != 0 && !is_mirrored); remove_custom_save_path->setVisible(program_id != 0 && has_custom_path); + disable_mirroring->setVisible(is_mirrored); open_mod_location->setVisible(program_id != 0); open_sdmc_mod_menu->menuAction()->setVisible(program_id != 0); open_transferable_shader_cache->setVisible(program_id != 0); @@ -1071,6 +1083,39 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri remove_shader_cache->setVisible(program_id != 0); remove_all_content->setVisible(program_id != 0); + if (is_mirrored) { + const bool has_global_path = Settings::values.global_custom_save_path_enabled.GetValue() && + !Settings::values.global_custom_save_path.GetValue().empty(); + + if (has_global_path) { + open_nand_location->setText(tr("Open Global Save Path Location")); + open_nand_location->setToolTip(tr("The global save path is being used as the base for save data mirroring.")); + mirror_base_path = QString::fromStdString(Settings::values.global_custom_save_path.GetValue()); + } else { + // Text is already "Open NAND Location", so we just set the correct path and a more descriptive tooltip. + open_nand_location->setToolTip(tr("Citron's default NAND is being used as the base for save data mirroring.")); + mirror_base_path = QString::fromStdString(Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir)); + } + + connect(open_nand_location, &QAction::triggered, [this, program_id, mirror_base_path]() { + const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128(); + // This constructs the relative path to the specific game's save folder + const std::string relative_save_path = fmt::format("user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id); + + // Combine the determined base path (Global or default NAND) with the relative game path + const auto full_save_path = std::filesystem::path(mirror_base_path.toStdString()) / relative_save_path; + + // Ensure the parent directory exists before trying to open it + if (!std::filesystem::exists(full_save_path.parent_path())) { + std::filesystem::create_directories(full_save_path.parent_path()); + } + + QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(full_save_path.string()))); + }); + } + + submit_compat_report->setToolTip(tr("Requires GitHub account.")); + connect(favorite, &QAction::triggered, [this, program_id]() { ToggleFavorite(program_id); }); connect(open_save_location, &QAction::triggered, [this, program_id, path]() { emit OpenFolderRequested(program_id, GameListOpenTarget::SaveData, path); }); @@ -1128,87 +1173,109 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri return true; }; - connect(set_custom_save_path, &QAction::triggered, [this, program_id, copyWithProgress]() { - const QString new_path = QFileDialog::getExistingDirectory(this, tr("Select Custom Save Data Location")); - if (new_path.isEmpty()) return; +connect(set_custom_save_path, &QAction::triggered, [this, program_id, copyWithProgress]() { + const QString new_path = QFileDialog::getExistingDirectory(this, tr("Select Custom Save Data Location")); + if (new_path.isEmpty()) return; - 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 citron_nand_save_path = QDir(nand_dir).filePath(QString::fromStdString(relative_save_path)); + std::string base_save_path_str; + if (Settings::values.global_custom_save_path_enabled.GetValue() && + !Settings::values.global_custom_save_path.GetValue().empty()) { + base_save_path_str = Settings::values.global_custom_save_path.GetValue(); + } else { + base_save_path_str = Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir); + } + const QString base_dir = QString::fromStdString(base_save_path_str); - bool mirroring_enabled = false; - QString detected_emu = GetDetectedEmulatorName(new_path, program_id, nand_dir); + 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); - 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); + // This path points to the save data within either the Global Path or the NAND. + const QString internal_save_path = QDir(base_dir).filePath(QString::fromStdString(relative_save_path)); - 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.")); - } - } - } + bool mirroring_enabled = false; + // The check for other emulators uses the determined base directory. + QString detected_emu = GetDetectedEmulatorName(new_path, program_id, base_dir); + + 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 internal save directory " + "(currently set to '%2') and keep both locations synced whenever you play. A backup of your existing Citron data " + "will be created. BE WARNED: Please do not have both emulators open during this process.").arg(detected_emu, base_dir), + QMessageBox::Yes | QMessageBox::No); + + if (mirror_reply == QMessageBox::Yes) { + mirroring_enabled = true; } + } + QDir internal_dir(internal_save_path); + if (internal_dir.exists() && !internal_dir.isEmpty()) { 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); + // Non-destructive backup for mirroring, now created in the base directory. + QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd_hh-mm-ss")); + QString backup_path = internal_save_path + QStringLiteral("_mirror_backup_") + timestamp; - 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 pull data from the mirror source.")); - return; + // Ensure parent directory exists before renaming + QDir().mkpath(QFileInfo(backup_path).absolutePath()); + + if (QDir().rename(internal_save_path, backup_path)) { + LOG_INFO(Frontend, "Safety: Existing internal data moved to backup: {}", backup_path.toStdString()); } } 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); - } + // 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 your internal save directory. Would you like to move it to the new custom save path?"), + QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); - emit SaveConfig(); + 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(internal_save_path, full_dest_path, this)) { + QDir(internal_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 (mirroring_enabled) { + // Initial Pull (External -> Internal) + // We copy FROM the selected folder TO the correct internal save location. + if (copyWithProgress(new_path, internal_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 the internal directory + Settings::values.custom_save_paths.erase(program_id); + + QMessageBox::information(this, tr("Success"), tr("Mirroring established. Your data has been pulled into the internal Citron save directory.")); + } else { + 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); + } + + emit SaveConfig(); +}); + + connect(disable_mirroring, &QAction::triggered, [this, program_id]() { + if (QMessageBox::question(this, tr("Disable Mirroring"), + tr("Are you sure you want to disable mirroring for this game?\n\nThe directories will no longer be synced."), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + Settings::values.mirrored_save_paths.erase(program_id); + emit SaveConfig(); + QMessageBox::information(this, tr("Mirroring Disabled"), + tr("Mirroring has been disabled for this game. It will now use the save data from the NAND.")); + } }); connect(open_current_game_sdmc, &QAction::triggered, [program_id]() { From a3a8e6f916157d926ddb1a841608e289124a4c11 Mon Sep 17 00:00:00 2001 From: Collecting Date: Sat, 24 Jan 2026 03:00:58 +0100 Subject: [PATCH 4/4] feat(add): Logic to determine base location when using Global or Default Paths Signed-off-by: Collecting --- .../hle/service/filesystem/filesystem.cpp | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 36fe52216..fb50a2698 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -422,51 +422,51 @@ std::shared_ptr FileSystemController::CreateSaveDataFa const auto rw_mode = FileSys::OpenMode::ReadWrite; auto vfs = system.GetFilesystem(); - // 1. Priority 1: Mirrored Path Override (Forces NAND) + // 1. Determine the correct BASE directory FIRST. + // The base directory is either the Global Custom Save Path or the default NAND. + std::string base_save_path_str; + if (Settings::values.global_custom_save_path_enabled.GetValue() && + !Settings::values.global_custom_save_path.GetValue().empty()) { + + base_save_path_str = Settings::values.global_custom_save_path.GetValue(); + LOG_INFO(Service_FS, "Save Path: Using Global Custom Save Path as the base: {}", base_save_path_str); + } else { + base_save_path_str = Common::FS::GetCitronPathString(CitronPath::NANDDir); + LOG_INFO(Service_FS, "Save Path: Using default NAND as the base."); + } + + auto base_directory = vfs->OpenDirectory(base_save_path_str, rw_mode); + + // 2. Check for Mirroring. if (Settings::values.mirrored_save_paths.count(program_id)) { LOG_INFO(Service_FS, - "Save Path: Mirroring detected for Program ID {:016X}. Forcing use of NAND " - "directory for syncing.", + "Save Path: Mirroring detected for Program ID {:016X}. Syncing against the determined base directory.", program_id); - const auto nand_directory = - vfs->OpenDirectory(Common::FS::GetCitronPathString(CitronPath::NANDDir), rw_mode); return std::make_shared(system, program_id, - std::move(nand_directory)); + std::move(base_directory)); } - std::string custom_path_str; - - // 2. Priority 2: Individual Game Override + // 3. Check for Per-Game Custom Path override. if (Settings::values.custom_save_paths.count(program_id)) { - custom_path_str = Settings::values.custom_save_paths.at(program_id); + const std::string custom_path_str = Settings::values.custom_save_paths.at(program_id); LOG_INFO(Service_FS, "Save Path: Using Per-Game Custom Path for Program ID {:016X}: {}", program_id, custom_path_str); - } - // 3. Priority 3: Global Override - else if (Settings::values.global_custom_save_path_enabled.GetValue()) { - custom_path_str = Settings::values.global_custom_save_path.GetValue(); - LOG_INFO(Service_FS, "Save Path: Using Global Custom Save Path: {}", custom_path_str); - } - // If any custom logic is hit, use that path but KEEP NAND as backup target - if (!custom_path_str.empty()) { const std::filesystem::path custom_path = custom_path_str; if (Common::FS::IsDir(custom_path)) { auto custom_save_directory = vfs->OpenDirectory(custom_path_str, rw_mode); - auto nand_directory = - vfs->OpenDirectory(Common::FS::GetCitronPathString(CitronPath::NANDDir), rw_mode); + // The base_directory (Global Path or NAND) is now correctly passed as the backup. return std::make_shared( - system, program_id, std::move(custom_save_directory), std::move(nand_directory)); + system, program_id, std::move(custom_save_directory), std::move(base_directory)); } } - // 4. Fallback: Standard NAND - LOG_INFO(Service_FS, "Save Path: No custom paths found. Falling back to default NAND."); - const auto nand_directory = - vfs->OpenDirectory(Common::FS::GetCitronPathString(CitronPath::NANDDir), rw_mode); + // 4. Fallback: If no mirroring and no per-game path, use the determined base directory. + LOG_INFO(Service_FS, "Save Path: No overrides found. Using the determined base directory."); return std::make_shared(system, program_id, - std::move(nand_directory)); + std::move(base_directory)); + } Result FileSystemController::OpenSDMC(FileSys::VirtualDir* out_sdmc) const {