Merge pull request 'feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths' (#79) from feat/cross-compatible-saves into main

Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/79
This commit is contained in:
Zephyron
2025-12-31 04:36:39 +00:00
14 changed files with 446 additions and 259 deletions

View File

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

View File

@@ -445,6 +445,12 @@ GMainWindow::GMainWindow(std::unique_ptr<QtConfig> config_, bool has_broken_vulk
system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, provider.get());
system->GetFileSystemController().CreateFactories(*vfs);
system->SetContentProvider(std::make_unique<FileSys::ContentProviderUnion>());
system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::FrontendManual, provider.get());
// 1. First, create the factories
system->GetFileSystemController().CreateFactories(*vfs);
autoloader_provider = std::make_unique<FileSys::ManualContentProvider>();
system->RegisterContentProvider(FileSys::ContentProviderUnionSlot::Autoloader,
autoloader_provider.get());
@@ -5714,20 +5720,23 @@ void GMainWindow::closeEvent(QCloseEvent* event) {
return;
}
// This stops mirroring threads before we start saving configs.
if (emu_thread != nullptr) {
ShutdownGame();
}
// Now save settings
UpdateUISettings();
config->SaveAllValues();
game_list->SaveInterfaceLayout();
UISettings::SaveWindowState();
hotkey_registry.SaveHotkeys();
// Unload controllers early
// Unload controllers
controller_dialog->UnloadController();
game_list->UnloadController();
// Shutdown session if the emu thread is active...
if (emu_thread != nullptr) {
ShutdownGame();
}
render_window->close();
multiplayer_state->Close();
system->HIDCore().UnloadInputDevices();

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project
// SPDX-FileCopyrightText: 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <version>
@@ -136,6 +137,14 @@ void LogSettings() {
log_path("DataStorage_LoadDir", Common::FS::GetCitronPath(Common::FS::CitronPath::LoadDir));
log_path("DataStorage_NANDDir", Common::FS::GetCitronPath(Common::FS::CitronPath::NANDDir));
log_path("DataStorage_SDMCDir", Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir));
// Log Custom Save Paths and Mirrored Save Paths for debugging
for (const auto& [id, path] : values.custom_save_paths) {
log_setting(fmt::format("DataStorage_CustomSavePath_{:016X}", id), path);
}
for (const auto& [id, path] : values.mirrored_save_paths) {
log_setting(fmt::format("DataStorage_MirrorSavePath_{:016X}", id), path);
}
}
void UpdateGPUAccuracy() {

View File

@@ -686,6 +686,9 @@ struct Values {
// Custom Save Paths (with backups)
std::map<u64, std::string> custom_save_paths;
// This stores the external path used for Intelligent Mirroring sync
std::map<u64, std::string> mirrored_save_paths;
Setting<bool> global_custom_save_path_enabled{linkage, false, "global_custom_save_path_enabled", Category::DataStorage};
Setting<std::string> global_custom_save_path{linkage, std::string(), "global_custom_save_path", Category::DataStorage};
Setting<bool> backup_saves_to_nand{linkage, false, "backup_saves_to_nand", Category::DataStorage};

View File

@@ -17,9 +17,15 @@ constexpr int RetryWaitTimeMs = 100;
} // Anonymous namespace
DirectorySaveDataFileSystem::DirectorySaveDataFileSystem(VirtualDir base_filesystem, VirtualDir backup_filesystem)
: base_fs(std::move(base_filesystem)), backup_fs(std::move(backup_filesystem)),
extra_data_accessor(base_fs), journaling_enabled(true),
// Updated constructor to accept mirror_filesystem
DirectorySaveDataFileSystem::DirectorySaveDataFileSystem(VirtualDir base_filesystem,
VirtualDir backup_filesystem,
VirtualDir mirror_filesystem)
: base_fs(std::move(base_filesystem)),
backup_fs(std::move(backup_filesystem)),
mirror_fs(std::move(mirror_filesystem)),
extra_data_accessor(base_fs),
journaling_enabled(true),
open_writable_files(0) {}
DirectorySaveDataFileSystem::~DirectorySaveDataFileSystem() = default;
@@ -88,19 +94,15 @@ Result DirectorySaveDataFileSystem::Commit() {
std::scoped_lock lk{mutex};
if (!journaling_enabled) {
// Non-journaling: just commit extra data
return extra_data_accessor.CommitExtraDataWithTimeStamp(
std::chrono::system_clock::now().time_since_epoch().count());
}
// Check that all writable files are closed
if (open_writable_files > 0) {
LOG_ERROR(Service_FS, "Cannot commit: {} writable files still open", open_writable_files);
return ResultWriteModeFileNotClosed;
}
// Atomic commit process (based on LibHac lines 572-622)
// 1. Rename committed → synchronizing (backup old version)
auto committed = base_fs->GetSubdirectory(CommittedDirectoryName);
if (committed != nullptr) {
if (!committed->Rename(SynchronizingDirectoryName)) {
@@ -108,41 +110,34 @@ Result DirectorySaveDataFileSystem::Commit() {
}
}
// 2. Copy working → synchronizing (prepare new commit)
R_TRY(SynchronizeDirectory(SynchronizingDirectoryName, ModifiedDirectoryName));
// 3. Commit extra data with updated timestamp
R_TRY(extra_data_accessor.CommitExtraDataWithTimeStamp(
std::chrono::system_clock::now().time_since_epoch().count()));
// 4. Rename synchronizing → committed (make it permanent)
auto sync_dir = base_fs->GetSubdirectory(SynchronizingDirectoryName);
if (sync_dir == nullptr) {
return ResultPathNotFound;
}
if (!sync_dir->Rename(CommittedDirectoryName)) {
if (sync_dir == nullptr || !sync_dir->Rename(CommittedDirectoryName)) {
return ResultPermissionDenied;
}
// Update cached committed_dir reference
committed_dir = base_fs->GetSubdirectory(CommittedDirectoryName);
if (Settings::values.backup_saves_to_nand.GetValue() && backup_fs != nullptr) {
LOG_INFO(Service_FS, "Dual-Save: Backing up custom save to NAND...");
// Now that the NAND is safely updated, we push the changes back to Ryujinx/Eden
if (mirror_fs != nullptr) {
LOG_INFO(Service_FS, "Mirroring: Pushing changes back to external source...");
// 1. Find or Create the '0' (Committed) folder in the NAND
auto nand_committed = backup_fs->GetSubdirectory(CommittedDirectoryName);
if (nand_committed == nullptr) {
nand_committed = backup_fs->CreateSubdirectory(CommittedDirectoryName);
}
// Use SmartSyncToMirror instead of CleanSubdirectoryRecursive
// working_dir contains the data that was just successfully committed
SmartSyncToMirror(mirror_fs, working_dir);
if (nand_committed != nullptr) {
// 2. Wipe whatever old backup was there
backup_fs->DeleteSubdirectoryRecursive(CommittedDirectoryName);
nand_committed = backup_fs->CreateSubdirectory(CommittedDirectoryName);
// 3. Copy the fresh data from our 'working' area to the NAND '0' folder
LOG_INFO(Service_FS, "Mirroring: External sync successful.");
}
// Standard backup only if Mirroring is NOT active
else if (Settings::values.backup_saves_to_nand.GetValue() && backup_fs != nullptr) {
LOG_INFO(Service_FS, "Dual-Save: Backing up to NAND...");
backup_fs->DeleteSubdirectoryRecursive(CommittedDirectoryName);
auto nand_committed = backup_fs->CreateSubdirectory(CommittedDirectoryName);
if (nand_committed) {
CopyDirectoryRecursively(nand_committed, working_dir);
}
}
@@ -167,8 +162,6 @@ Result DirectorySaveDataFileSystem::Rollback() {
}
bool DirectorySaveDataFileSystem::HasUncommittedChanges() const {
// For now, assume any write means uncommitted changes
// A full implementation would compare directory contents
return open_writable_files > 0;
}
@@ -224,8 +217,7 @@ Result DirectorySaveDataFileSystem::CopyDirectoryRecursively(VirtualDir dest, Vi
return ResultSuccess;
}
Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(
std::function<Result()> operation) {
Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(std::function<Result()> operation) {
int remaining_retries = MaxRetryCount;
while (true) {
@@ -235,7 +227,6 @@ Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(
return ResultSuccess;
}
// Only retry on TargetLocked error
if (result != ResultTargetLocked) {
return result;
}
@@ -249,4 +240,28 @@ Result DirectorySaveDataFileSystem::RetryFinitelyForTargetLocked(
}
}
void DirectorySaveDataFileSystem::SmartSyncToMirror(VirtualDir mirror_dest, VirtualDir citron_source) {
// Citron: Extra safety check for valid pointers and writable permissions
if (mirror_dest == nullptr || citron_source == nullptr || !mirror_dest->IsWritable()) {
return;
}
// Sync files from Citron back to the Mirror
for (const auto& c_file : citron_source->GetFiles()) {
auto m_file = mirror_dest->CreateFile(c_file->GetName());
if (m_file) {
m_file->WriteBytes(c_file->ReadAllBytes());
}
}
// Recursively handle subfolders (like 'private', 'extra', etc)
for (const auto& c_subdir : citron_source->GetSubdirectories()) {
auto m_subdir = mirror_dest->GetDirectoryRelative(c_subdir->GetName());
if (m_subdir == nullptr) {
m_subdir = mirror_dest->CreateSubdirectory(c_subdir->GetName());
}
SmartSyncToMirror(m_subdir, c_subdir);
}
}
} // namespace FileSys

View File

@@ -18,8 +18,10 @@ namespace FileSys {
/// Uses /0 (committed) and /1 (working) directories for journaling
class DirectorySaveDataFileSystem {
public:
// optional directory here for backup
explicit DirectorySaveDataFileSystem(VirtualDir base_filesystem, VirtualDir backup_filesystem = nullptr);
// Updated constructor to include mirror_filesystem for external sync (Ryujinx/Eden/etc)
explicit DirectorySaveDataFileSystem(VirtualDir base_filesystem,
VirtualDir backup_filesystem = nullptr,
VirtualDir mirror_filesystem = nullptr);
~DirectorySaveDataFileSystem();
/// Initialize the journaling filesystem
@@ -53,9 +55,11 @@ private:
Result SynchronizeDirectory(const char* dest_name, const char* source_name);
Result CopyDirectoryRecursively(VirtualDir dest, VirtualDir source);
Result RetryFinitelyForTargetLocked(std::function<Result()> operation);
void SmartSyncToMirror(VirtualDir mirror_dest, VirtualDir citron_source);
VirtualDir base_fs;
VirtualDir backup_fs; // This will store the NAND path
VirtualDir backup_fs; // This stores the secondary NAND path
VirtualDir mirror_fs; // This stores the External Mirror path (Ryujinx/Eden/etc)
VirtualDir working_dir;
VirtualDir committed_dir;
SaveDataExtraDataAccessor extra_data_accessor;

View File

@@ -3,7 +3,9 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <chrono>
#include <filesystem>
#include <memory>
#include <vector>
#include "common/assert.h"
#include "common/common_types.h"
#include "common/logging/log.h"
@@ -15,56 +17,70 @@
#include "core/file_sys/savedata_extra_data_accessor.h"
#include "core/file_sys/savedata_factory.h"
#include "core/file_sys/vfs/vfs.h"
#include "core/file_sys/vfs/vfs_real.h"
#include "core/hle/service/acc/profile_manager.h"
namespace FileSys {
namespace {
// Using a leaked raw pointer for the RealVfsFilesystem singleton.
// This prevents SIGSEGV during shutdown by ensuring the VFS bridge
// outlives all threads that might still be flushing save data.
RealVfsFilesystem* GetPersistentVfs() {
static RealVfsFilesystem* instance = new RealVfsFilesystem();
return instance;
}
bool ShouldSaveDataBeAutomaticallyCreated(SaveDataSpaceId space, const SaveDataAttribute& attr) {
return attr.type == SaveDataType::Cache || attr.type == SaveDataType::Temporary ||
(space == SaveDataSpaceId::User && ///< Normal Save Data -- Current Title & User
(space == SaveDataSpaceId::User &&
(attr.type == SaveDataType::Account || attr.type == SaveDataType::Device) &&
attr.program_id == 0 && attr.system_save_data_id == 0);
}
std::string GetFutureSaveDataPath(SaveDataSpaceId space_id, SaveDataType type, u64 title_id,
u128 user_id) {
// Only detect nand user saves.
const auto space_id_path = [space_id]() -> std::string_view {
switch (space_id) {
case SaveDataSpaceId::User:
return "/user/save";
default:
return "";
}
}();
if (space_id_path.empty()) {
if (space_id != SaveDataSpaceId::User) {
return "";
}
Common::UUID uuid;
std::memcpy(uuid.uuid.data(), user_id.data(), sizeof(Common::UUID));
// Only detect account/device saves from the future location.
switch (type) {
case SaveDataType::Account:
return fmt::format("{}/account/{}/{:016X}/0", space_id_path, uuid.RawString(), title_id);
return fmt::format("/user/save/account/{}/{:016X}/0", uuid.RawString(), title_id);
case SaveDataType::Device:
return fmt::format("{}/device/{:016X}/0", space_id_path, title_id);
return fmt::format("/user/save/device/{:016X}/0", title_id);
default:
return "";
}
}
void BufferedVfsCopy(VirtualFile source, VirtualFile dest) {
if (!source || !dest) return;
try {
std::vector<u8> buffer(0x100000); // 1MB buffer
dest->Resize(0);
size_t offset = 0;
while (offset < source->GetSize()) {
const size_t to_read = std::min(buffer.size(), source->GetSize() - offset);
source->Read(buffer.data(), to_read, offset);
dest->Write(buffer.data(), to_read, offset);
offset += to_read;
}
} catch (...) {
LOG_ERROR(Service_FS, "Critical error during VFS mirror operation.");
}
}
} // Anonymous namespace
SaveDataFactory::SaveDataFactory(Core::System& system_, ProgramId program_id_,
VirtualDir save_directory_, VirtualDir backup_directory_)
: system{system_}, program_id{program_id_}, dir{std::move(save_directory_)},
backup_dir{std::move(backup_directory_)} {
// Delete all temporary storages
// On hardware, it is expected that temporary storage be empty at first use.
dir->DeleteSubdirectoryRecursive("temp");
}
@@ -79,19 +95,14 @@ VirtualDir SaveDataFactory::Create(SaveDataSpaceId space, const SaveDataAttribut
return nullptr;
}
// Initialize ExtraData for new save
SaveDataExtraDataAccessor accessor(save_dir);
if (accessor.Initialize(true) != ResultSuccess) {
LOG_WARNING(Service_FS, "Failed to initialize ExtraData for new save at {}", save_directory);
// Continue anyway - save is still usable
} else {
// Write initial extra data
if (accessor.Initialize(true) == ResultSuccess) {
SaveDataExtraData initial_data{};
initial_data.attr = meta;
initial_data.owner_id = meta.program_id;
initial_data.timestamp = std::chrono::system_clock::now().time_since_epoch().count();
initial_data.flags = static_cast<u32>(SaveDataFlags::None);
initial_data.available_size = 0; // Will be updated on commit
initial_data.available_size = 0;
initial_data.journal_size = 0;
initial_data.commit_id = 1;
@@ -122,6 +133,8 @@ VirtualDir SaveDataFactory::GetSaveDataSpaceDirectory(SaveDataSpaceId space) con
std::string SaveDataFactory::GetSaveDataSpaceIdPath(SaveDataSpaceId space) {
switch (space) {
case SaveDataSpaceId::System:
case SaveDataSpaceId::ProperSystem:
case SaveDataSpaceId::SafeMode:
return "/system/";
case SaveDataSpaceId::User:
return "/user/";
@@ -130,54 +143,37 @@ std::string SaveDataFactory::GetSaveDataSpaceIdPath(SaveDataSpaceId space) {
case SaveDataSpaceId::SdSystem:
case SaveDataSpaceId::SdUser:
return "/sd/";
case SaveDataSpaceId::ProperSystem:
return "/system/";
case SaveDataSpaceId::SafeMode:
return "/system/";
default:
ASSERT_MSG(false, "Unrecognized SaveDataSpaceId: {:02X}", static_cast<u8>(space));
return "/unrecognized/"; ///< To prevent corruption when ignoring asserts.
return "/unrecognized/";
}
}
std::string SaveDataFactory::GetFullPath(ProgramId program_id, VirtualDir dir,
SaveDataSpaceId space, SaveDataType type, u64 title_id,
u128 user_id, u64 save_id) {
// According to switchbrew, if a save is of type SaveData and the title id field is 0, it should
// be interpreted as the title id of the current process.
if (type == SaveDataType::Account || type == SaveDataType::Device) {
if (title_id == 0) {
title_id = program_id;
}
if ((type == SaveDataType::Account || type == SaveDataType::Device) && title_id == 0) {
title_id = program_id;
}
// For compat with a future impl.
if (std::string future_path =
GetFutureSaveDataPath(space, type, title_id & ~(0xFFULL), user_id);
if (std::string future_path = GetFutureSaveDataPath(space, type, title_id & ~(0xFFULL), user_id);
!future_path.empty()) {
// Check if this location exists, and prefer it over the old.
if (const auto future_dir = dir->GetDirectoryRelative(future_path); future_dir != nullptr) {
LOG_INFO(Service_FS, "Using save at new location: {}", future_path);
if (dir->GetDirectoryRelative(future_path) != nullptr) {
return future_path;
}
}
std::string out = GetSaveDataSpaceIdPath(space);
switch (type) {
case SaveDataType::System:
return fmt::format("{}save/{:016X}/{:016X}{:016X}", out, save_id, user_id[1], user_id[0]);
case SaveDataType::Account:
case SaveDataType::Device:
return fmt::format("{}save/{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0],
title_id);
return fmt::format("{}save/{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0], title_id);
case SaveDataType::Temporary:
return fmt::format("{}{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0],
title_id);
return fmt::format("{}{:016X}/{:016X}{:016X}/{:016X}", out, 0, user_id[1], user_id[0], title_id);
case SaveDataType::Cache:
return fmt::format("{}save/cache/{:016X}", out, title_id);
default:
ASSERT_MSG(false, "Unrecognized SaveDataType: {:02X}", static_cast<u8>(type));
return fmt::format("{}save/unknown_{:X}/{:016X}", out, static_cast<u8>(type), title_id);
}
}
@@ -191,36 +187,21 @@ std::string SaveDataFactory::GetUserGameSaveDataRoot(u128 user_id, bool future)
return fmt::format("/user/save/{:016X}/{:016X}{:016X}", 0, user_id[1], user_id[0]);
}
SaveDataSize SaveDataFactory::ReadSaveDataSize(SaveDataType type, u64 title_id,
u128 user_id) const {
const auto path =
GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
SaveDataSize SaveDataFactory::ReadSaveDataSize(SaveDataType type, u64 title_id, u128 user_id) const {
const auto path = GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
const auto relative_dir = GetOrCreateDirectoryRelative(dir, path);
const auto size_file = relative_dir->GetFile(GetSaveDataSizeFileName());
if (size_file == nullptr || size_file->GetSize() < sizeof(SaveDataSize)) {
return {0, 0};
}
if (size_file == nullptr || size_file->GetSize() < sizeof(SaveDataSize)) return {0, 0};
SaveDataSize out;
if (size_file->ReadObject(&out) != sizeof(SaveDataSize)) {
return {0, 0};
}
if (size_file->ReadObject(&out) != sizeof(SaveDataSize)) return {0, 0};
return out;
}
void SaveDataFactory::WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id,
SaveDataSize new_value) const {
const auto path =
GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
void SaveDataFactory::WriteSaveDataSize(SaveDataType type, u64 title_id, u128 user_id, SaveDataSize new_value) const {
const auto path = GetFullPath(program_id, dir, SaveDataSpaceId::User, type, title_id, user_id, 0);
const auto relative_dir = GetOrCreateDirectoryRelative(dir, path);
const auto size_file = relative_dir->CreateFile(GetSaveDataSizeFileName());
if (size_file == nullptr) {
return;
}
if (size_file == nullptr) return;
size_file->Resize(sizeof(SaveDataSize));
size_file->WriteObject(new_value);
}
@@ -229,137 +210,173 @@ void SaveDataFactory::SetAutoCreate(bool state) {
auto_create = state;
}
Result SaveDataFactory::ReadSaveDataExtraData(SaveDataExtraData* out_extra_data,
SaveDataSpaceId space,
const SaveDataAttribute& attribute) const {
const auto save_directory =
GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id,
attribute.system_save_data_id);
Result SaveDataFactory::ReadSaveDataExtraData(SaveDataExtraData* out_extra_data, SaveDataSpaceId space, const SaveDataAttribute& attribute) const {
const auto save_directory = GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id, attribute.system_save_data_id);
auto save_dir = dir->GetDirectoryRelative(save_directory);
if (save_dir == nullptr) {
return ResultPathNotFound;
}
if (save_dir == nullptr) return ResultPathNotFound;
SaveDataExtraDataAccessor accessor(save_dir);
// Try to initialize (but don't create if missing)
if (Result result = accessor.Initialize(false); result != ResultSuccess) {
// ExtraData doesn't exist - return default values
LOG_DEBUG(Service_FS, "ExtraData not found for save at {}, returning defaults",
save_directory);
// Return zeroed data
*out_extra_data = {}; // Or: *out_extra_data = SaveDataExtraData{};
if (accessor.Initialize(false) != ResultSuccess) {
*out_extra_data = {};
out_extra_data->attr = attribute;
return ResultSuccess;
}
return accessor.ReadExtraData(out_extra_data);
}
Result SaveDataFactory::WriteSaveDataExtraData(const SaveDataExtraData& extra_data,
SaveDataSpaceId space,
const SaveDataAttribute& attribute) const {
const auto save_directory =
GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id,
attribute.system_save_data_id);
Result SaveDataFactory::WriteSaveDataExtraData(const SaveDataExtraData& extra_data, SaveDataSpaceId space, const SaveDataAttribute& attribute) const {
const auto save_directory = GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id, attribute.system_save_data_id);
auto save_dir = dir->GetDirectoryRelative(save_directory);
if (save_dir == nullptr) {
return ResultPathNotFound;
}
if (save_dir == nullptr) return ResultPathNotFound;
SaveDataExtraDataAccessor accessor(save_dir);
// Initialize and create if missing
R_TRY(accessor.Initialize(true));
// Write the data
R_TRY(accessor.WriteExtraData(extra_data));
// Commit immediately for transactional writes
R_TRY(accessor.CommitExtraData());
return ResultSuccess;
return accessor.CommitExtraData();
}
Result SaveDataFactory::WriteSaveDataExtraDataWithMask(const SaveDataExtraData& extra_data,
const SaveDataExtraData& mask,
SaveDataSpaceId space,
const SaveDataAttribute& attribute) const {
const auto save_directory =
GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id,
attribute.system_save_data_id);
Result SaveDataFactory::WriteSaveDataExtraDataWithMask(const SaveDataExtraData& extra_data, const SaveDataExtraData& mask, SaveDataSpaceId space, const SaveDataAttribute& attribute) const {
const auto save_directory = GetFullPath(program_id, dir, space, attribute.type, attribute.program_id, attribute.user_id, attribute.system_save_data_id);
auto save_dir = dir->GetDirectoryRelative(save_directory);
if (save_dir == nullptr) {
return ResultPathNotFound;
}
if (save_dir == nullptr) return ResultPathNotFound;
SaveDataExtraDataAccessor accessor(save_dir);
// Initialize and create if missing
R_TRY(accessor.Initialize(true));
// Read existing data
SaveDataExtraData current_data{};
R_TRY(accessor.ReadExtraData(&current_data));
// Apply mask: copy only the bytes where mask is non-zero
const u8* extra_data_bytes = reinterpret_cast<const u8*>(&extra_data);
const u8* mask_bytes = reinterpret_cast<const u8*>(&mask);
u8* current_data_bytes = reinterpret_cast<u8*>(&current_data);
for (size_t i = 0; i < sizeof(SaveDataExtraData); ++i) {
if (mask_bytes[i] != 0) {
current_data_bytes[i] = extra_data_bytes[i];
if (mask_bytes[i] != 0) current_data_bytes[i] = extra_data_bytes[i];
}
R_TRY(accessor.WriteExtraData(current_data));
return accessor.CommitExtraData();
}
// --- MIRRORING TOOLS ---
VirtualDir SaveDataFactory::GetMirrorDirectory(u64 title_id) const {
auto it = Settings::values.mirrored_save_paths.find(title_id);
if (it == Settings::values.mirrored_save_paths.end() || it->second.empty()) return nullptr;
std::filesystem::path host_path(it->second);
if (!std::filesystem::exists(host_path)) return nullptr;
// Get the persistent VFS bridge
auto* vfs = GetPersistentVfs();
return vfs->OpenDirectory(it->second, OpenMode::ReadWrite);
}
void SaveDataFactory::SmartSyncFromSource(VirtualDir source, VirtualDir dest) const {
// Citron: Shutdown and null safety
if (!source || !dest || system.IsShuttingDown()) {
return;
}
// Sync files from Source to Destination
for (const auto& s_file : source->GetFiles()) {
if (!s_file) continue;
std::string name = s_file->GetName();
// Skip metadata and lock files
if (name == ".lock" || name == ".citron_save_size" || name.find("mirror_backup") != std::string::npos) {
continue;
}
auto d_file = dest->CreateFile(name);
if (d_file) {
BufferedVfsCopy(s_file, d_file);
}
}
// Write back the masked data
R_TRY(accessor.WriteExtraData(current_data));
// Recurse into subdirectories
for (const auto& s_subdir : source->GetSubdirectories()) {
if (!s_subdir) continue;
// Commit the changes
R_TRY(accessor.CommitExtraData());
// Prevent recursion into title-id-named folders to avoid infinite loops
if (s_subdir->GetName().find("0100") != std::string::npos) continue;
return ResultSuccess;
auto d_subdir = dest->GetDirectoryRelative(s_subdir->GetName());
if (!d_subdir) {
d_subdir = dest->CreateDirectoryRelative(s_subdir->GetName());
}
if (d_subdir) {
SmartSyncFromSource(s_subdir, d_subdir);
}
}
}
void SaveDataFactory::PerformStartupMirrorSync() const {
// If settings are empty or system is shutting down/uninitialized
if (Settings::values.mirrored_save_paths.empty() || system.IsShuttingDown()) {
return;
}
// Ensure our NAND directory is actually valid
if (!dir) {
LOG_ERROR(Service_FS, "Mirroring: Startup Sync aborted. NAND directory is null.");
return;
}
// Attempt to locate the save root with null checks at every step
VirtualDir user_save_root = nullptr;
try {
user_save_root = dir->GetDirectoryRelative("user/save/0000000000000000");
if (!user_save_root) {
user_save_root = dir->GetDirectoryRelative("user/save");
}
} catch (...) {
LOG_ERROR(Service_FS, "Mirroring: Critical failure accessing VFS. Filesystem may be stale.");
return;
}
if (!user_save_root) {
LOG_WARNING(Service_FS, "Mirroring: Could not find user save root in NAND.");
return;
}
LOG_INFO(Service_FS, "Mirroring: Startup Sync initiated.");
for (const auto& [title_id, host_path] : Settings::values.mirrored_save_paths) {
if (host_path.empty()) continue;
auto mirror_source = GetMirrorDirectory(title_id);
if (!mirror_source) continue;
std::string title_id_str = fmt::format("{:016X}", title_id);
for (const auto& profile_dir : user_save_root->GetSubdirectories()) {
if (!profile_dir) continue;
auto nand_dest = profile_dir->GetDirectoryRelative(title_id_str);
if (!nand_dest) {
for (const auto& sub : profile_dir->GetSubdirectories()) {
if (!sub) continue;
nand_dest = sub->GetDirectoryRelative(title_id_str);
if (nand_dest) break;
}
}
if (nand_dest) {
LOG_INFO(Service_FS, "Mirroring: Pulling external data for {}", title_id_str);
SmartSyncFromSource(mirror_source, nand_dest);
}
}
}
}
void SaveDataFactory::DoNandBackup(SaveDataSpaceId space, const SaveDataAttribute& meta, VirtualDir custom_dir) const {
LOG_INFO(Common, "Dual-Save: Backup process initiated for Program ID: {:016X}", program_id);
u64 title_id = (meta.program_id != 0 ? meta.program_id : static_cast<u64>(program_id));
if (Settings::values.mirrored_save_paths.count(title_id)) return;
if (!Settings::values.backup_saves_to_nand.GetValue()) {
LOG_INFO(Common, "Dual-Save: Backup skipped (Setting is OFF)");
return;
}
if (backup_dir == nullptr) {
LOG_ERROR(Common, "Dual-Save: Backup failed (NAND directory is NULL)");
return;
}
if (custom_dir == nullptr) {
LOG_ERROR(Common, "Dual-Save: Backup failed (Source Custom directory is NULL)");
return;
}
const auto nand_path = GetFullPath(program_id, backup_dir, space, meta.type, meta.program_id,
meta.user_id, meta.system_save_data_id);
if (!Settings::values.backup_saves_to_nand.GetValue() || backup_dir == nullptr || custom_dir == nullptr) return;
const auto nand_path = GetFullPath(program_id, backup_dir, space, meta.type, meta.program_id, meta.user_id, meta.system_save_data_id);
auto nand_out = backup_dir->CreateDirectoryRelative(nand_path);
if (nand_out != nullptr) {
LOG_INFO(Common, "Dual-Save: Mirroring files to NAND: {}", nand_path);
// Clear the old backup
if (nand_out) {
nand_out->CleanSubdirectoryRecursive(".");
// Perform the copy
VfsRawCopyD(custom_dir, nand_out);
LOG_INFO(Common, "Dual-Save: NAND Backup successful.");
} else {
LOG_ERROR(Common, "Dual-Save: Could not create/access NAND backup path!");
}
}

View File

@@ -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};
};

View File

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

View File

@@ -121,6 +121,9 @@ public:
// above is called.
void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true);
// getter for main.cpp to trigger the sync between custom game paths for separate emulators
FileSys::SaveDataFactory& GetSaveDataFactory() { return *global_save_data_factory; }
void Reset();
private:
@@ -142,6 +145,9 @@ private:
std::unique_ptr<FileSys::RegisteredCache> gamecard_registered;
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;
// Global factory for startup tasks and mirroring
std::shared_ptr<FileSys::SaveDataFactory> global_save_data_factory;
Core::System& system;
};

View File

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

View File

@@ -252,9 +252,11 @@ Result FSP_SRV::CreateSaveDataFileSystemBySystemSaveDataId(
Result FSP_SRV::OpenSaveDataFileSystem(OutInterface<IFileSystem> out_interface,
FileSys::SaveDataSpaceId space_id,
FileSys::SaveDataAttribute attribute) {
LOG_INFO(Service_FS, "called.");
LOG_INFO(Service_FS, "called, space_id={:02X}, program_id={:016X}",
static_cast<u8>(space_id), attribute.program_id);
FileSys::VirtualDir dir{};
// This triggers the 'Smart Pull' (Ryujinx -> Citron) in savedata_factory.cpp
R_TRY(save_data_controller->OpenSaveData(&dir, space_id, attribute));
FileSys::StorageId id{};
@@ -267,19 +269,15 @@ Result FSP_SRV::OpenSaveDataFileSystem(OutInterface<IFileSystem> out_interface,
id = FileSys::StorageId::SdCard;
break;
case FileSys::SaveDataSpaceId::System:
id = FileSys::StorageId::NandSystem;
break;
case FileSys::SaveDataSpaceId::Temporary:
id = FileSys::StorageId::NandSystem;
break;
case FileSys::SaveDataSpaceId::ProperSystem:
id = FileSys::StorageId::NandSystem;
break;
case FileSys::SaveDataSpaceId::SafeMode:
id = FileSys::StorageId::NandSystem;
break;
}
// Wrap the directory in the IFileSystem interface.
// We pass 'save_data_controller->GetFactory()' so the Commit function can find the Mirror.
*out_interface = std::make_shared<IFileSystem>(
system, std::move(dir), SizeGetter::FromStorageId(fsc, id),
save_data_controller->GetFactory(), space_id, attribute);

View File

@@ -459,6 +459,7 @@ void Config::ReadValues() {
ReadDataStorageValues();
ReadDebuggingValues();
ReadCustomSavePathValues();
ReadMirrorValues();
ReadDisabledAddOnValues();
ReadDisabledCheatValues();
ReadNetworkValues();
@@ -564,6 +565,7 @@ void Config::SaveValues() {
SaveDataStorageValues();
SaveDebuggingValues();
SaveCustomSavePathValues();
SaveMirrorValues();
SaveDisabledAddOnValues();
SaveDisabledCheatValues();
SaveNetworkValues();
@@ -1181,3 +1183,33 @@ void Config::SetArrayIndex(const int index) {
array_stack.back().size = array_index;
array_stack.back().index = array_index;
}
void Config::ReadMirrorValues() {
BeginGroup(std::string("MirroredSaves"));
const int size = BeginArray(std::string("")); // Keyless array style
for (int i = 0; i < size; ++i) {
SetArrayIndex(i);
const auto title_id = ReadUnsignedIntegerSetting(std::string("title_id"), 0);
const auto path = ReadStringSetting(std::string("path"), std::string(""));
if (title_id != 0 && !path.empty()) {
Settings::values.mirrored_save_paths.insert_or_assign(title_id, path);
}
}
EndArray();
EndGroup();
}
void Config::SaveMirrorValues() {
BeginGroup(std::string("MirroredSaves"));
int i = 0;
BeginArray(std::string("")); // Keyless array style
for (const auto& elem : Settings::values.mirrored_save_paths) {
SetArrayIndex(i);
WriteIntegerSetting(std::string("title_id"), elem.first, std::make_optional(static_cast<u64>(0)));
WriteStringSetting(std::string("path"), elem.second, std::make_optional(std::string("")));
++i;
}
EndArray();
EndGroup();
}

View File

@@ -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.