Merge pull request 'feat(fix): Re-structure Pathing Logic for necessary additional configuration for multiple different varieties' (#110) from fix/restructure-save-pathing-logic into main

Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/110
This commit is contained in:
Collecting
2026-01-24 03:01:36 +01:00
4 changed files with 184 additions and 109 deletions

View File

@@ -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...");
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); });
@@ -1132,20 +1177,31 @@ 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_str = Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir);
const QString nand_dir = QString::fromStdString(nand_dir_str);
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);
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));
// 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));
bool mirroring_enabled = false;
QString detected_emu = GetDetectedEmulatorName(new_path, program_id, nand_dir);
// 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 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),
"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) {
@@ -1153,23 +1209,23 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
}
}
QDir citron_dir(citron_nand_save_path);
if (citron_dir.exists() && !citron_dir.isEmpty()) {
QDir internal_dir(internal_save_path);
if (internal_dir.exists() && !internal_dir.isEmpty()) {
if (mirroring_enabled) {
// Non-destructive backup for mirroring
// 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 = citron_nand_save_path + QStringLiteral("_mirror_backup_") + timestamp;
QString backup_path = internal_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());
if (QDir().rename(internal_save_path, backup_path)) {
LOG_INFO(Frontend, "Safety: Existing internal 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?"),
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);
if (reply == QMessageBox::Cancel) return;
@@ -1177,8 +1233,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
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();
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."));
@@ -1188,15 +1244,15 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
}
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)) {
// 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 NAND
// 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 Citron NAND."));
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;
@@ -1211,6 +1267,17 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
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]() {
const auto sdmc_path = Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir);
const auto full_path = sdmc_path / "atmosphere" / "contents" / fmt::format("{:016X}", program_id);

View File

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

View File

@@ -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<int, 3> sig_interrupt_fds;

View File

@@ -422,51 +422,51 @@ std::shared_ptr<FileSys::SaveDataFactory> 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<FileSys::SaveDataFactory>(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<FileSys::SaveDataFactory>(
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<FileSys::SaveDataFactory>(system, program_id,
std::move(nand_directory));
std::move(base_directory));
}
Result FileSystemController::OpenSDMC(FileSys::VirtualDir* out_sdmc) const {