feat(fs): Cross-Compatible Emulator Save Pathing w/ Custom Save Paths

Signed-off-by: Collecting <collecting@noreply.localhost>
This commit is contained in:
Collecting
2025-12-31 03:55:03 +00:00
parent c377c0c407
commit dee84bd8a3

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!");
}
}