From cfbaee07f0befa1a16b9c27528e9517742e135fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9Fatay=20G=C3=BC=C3=A7l=C3=BC?= <33038942+defektu@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:51:22 +0300 Subject: [PATCH] Persist SaveDataMemory2 save data to disk (fixes Astro Bot progress not saving) (#659) * Persist save-data memory across restarts * Fix SetSaveDataMemory2 data loss, split saves per user, add atomic writes everywhere - SaveDataSetSaveDataMemory2 treated data_num=0 as "no entries" even when a valid data pointer was supplied. Astro Bot calls this with data_num=0 meaning "one entry, at data[0]" (an implicit-count convention), so every real save write from it was silently dropped as a no-op. Default data_num to 1 when data is non-null, matching that convention. - SaveDataSetupSaveDataMemory2/Get/SetSaveDataMemory2 rejected any call that included a non-null icon pointer. Icon persistence isn't implemented, but titles are allowed to pass one anyway; rejecting the whole call over it made every title that does so fail outright. Ignore icon instead of rejecting. - SaveDataMount3/SaveDataTransferringMount/SaveDataDelete built their save-data path from title+dir_name only, dropping the user_id PS5 local users are keyed by (unlike the newer SetSaveDataMemory2 layout, which already nests by user_id). Nest these under user_id too so saves from different local users can no longer collide. No migration for the old flat layout - the project is still pre-release/experimental. - Classic SaveDataMount3-mounted titles (Blasphemous, Dead Cells, ...) write their save files directly through the guest's normal file I/O once mounted, bypassing SaveData's own write path entirely, so they had none of the crash-safety SetSaveDataMemory2 already had. Added the same protection at the file-system layer: a guest file opened under a /savedataN mount that recreates the file from scratch (O_CREAT and/or O_TRUNC) is now written to a .tmp shadow file and atomically replaces the real one on close, instead of being truncated in place. - Both write paths now back up the file they're about to replace into a sibling "backups" folder (same filename, no added suffix) before the atomic replace, so a manual recovery copy of the previous save always survives a write - even one that "succeeds" but wasn't wanted. Co-Authored-By: Claude Sonnet 5 * Fix save-memory compatibility and preserve existing saves --------- Co-authored-by: nmzik Co-authored-by: Claude Sonnet 5 --- CMakeLists.txt | 7 + src/libs/libSaveData.cpp | 359 ++++++++++++++++++++++---- tests/KernelFileSystemTests.cpp | 28 +++ tests/SaveDataMemoryTests.cpp | 432 ++++++++++++++++++++++++++++++++ 4 files changed, 773 insertions(+), 53 deletions(-) create mode 100644 tests/SaveDataMemoryTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a733ecfdc..46104ee04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -450,6 +450,12 @@ add_executable(audio_out2_port_tests EXCLUDE_FROM_ALL target_link_libraries(audio_out2_port_tests common fmt::fmt) target_include_directories(audio_out2_port_tests PRIVATE ${inc_headers}) +add_executable(save_data_memory_tests EXCLUDE_FROM_ALL + "${KYTY_TESTS_DIR}/SaveDataMemoryTests.cpp" +) +target_link_libraries(save_data_memory_tests common fmt::fmt) +target_include_directories(save_data_memory_tests PRIVATE ${inc_headers}) + add_executable(event_queue_lifetime_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/EventQueueLifetimeTests.cpp" "${KYTY_SOURCE_DIR}/kernel/eventQueue.cpp" @@ -534,6 +540,7 @@ if(BUILD_TESTING) add_test(NAME kernel_file_system COMMAND $) add_test(NAME sync_on_address COMMAND $) add_test(NAME audio_out2_port COMMAND $) + add_test(NAME save_data_memory COMMAND $) add_test(NAME shader_recompiler_compute COMMAND $) add_test(NAME shader_recompiler_alignbyte COMMAND $ --alignbyte-only) diff --git a/src/libs/libSaveData.cpp b/src/libs/libSaveData.cpp index 56c7a65ac..b55f3f90c 100644 --- a/src/libs/libSaveData.cpp +++ b/src/libs/libSaveData.cpp @@ -14,7 +14,9 @@ #include #include +#include #include +#include #include namespace Libs { @@ -176,6 +178,13 @@ struct SaveDataMemorySet2 { uint8_t reserved[24]; }; +struct SaveDataMemorySync { + int32_t user_id; + uint32_t slot_id; + uint32_t option; + uint8_t reserved[28]; +}; + struct SaveDataTransferringMount { int32_t user_id; const SceSaveDataTitleId* title_id; @@ -230,23 +239,155 @@ static constexpr uint32_t SAVE_DATA_COMMIT_MODE_BACKUP_ASYNC = 1u; static constexpr uint32_t SAVE_DATA_EVENT_TYPE_UMOUNT_BACKUP_END = 1u; static constexpr uint32_t SAVE_DATA_EVENT_TYPE_BACKUP_END = 2u; +static constexpr uint32_t SAVE_DATA_EVENT_TYPE_MEMORY_SYNC_END = 3u; static constexpr uint32_t SAVE_DATA_EVENT_TYPE_COMMIT_BACKUP_END = 4u; -static std::vector g_save_data_memory(0x10000); +static constexpr size_t SAVE_DATA_MEMORY_MAX_SIZE = 32 * 1024 * 1024; +static constexpr uint32_t SAVE_DATA_MEMORY_SET_PARAM = 1u; +static constexpr uint32_t SAVE_DATA_MEMORY_DOUBLE_BUFFER = 2u; + +struct SaveDataMemory { + std::vector data; + SaveDataParam param {}; + uint32_t option = 0; +}; + +static std::map g_save_data_memory; static int32_t g_next_transaction_resource = 1; static std::deque g_save_data_events; static SaveDataMountSlots g_mount_slots; static Common::Mutex g_mount_mutex; +static bool valid_path_component(std::string_view name) { + return !name.empty() && name != "." && name != ".." && + name.find_first_not_of( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-.@") == + std::string_view::npos; +} + +template +static bool valid_path_component(const char (&name)[N]) { + const auto end = std::find(name, name + N, '\0'); + return end != name + N && valid_path_component(std::string_view(name, end - name)); +} + static std::string get_title_id() { std::string title_id; - if (!Loader::SystemContentParamSfoGetString("TITLE_ID", &title_id) || title_id.empty()) { + if (!Loader::SystemContentParamSfoGetString("TITLE_ID", &title_id) || + !valid_path_component(title_id)) { title_id = "UNKNOWN"; } return title_id; } +static std::string memory_dir_name(uint32_t slot_id) { + return "sce_sdmemory" + (slot_id == 0 ? std::string() : std::to_string(slot_id)); +} + +static std::filesystem::path save_directory(std::string_view title_id, std::string_view dir_name, + int32_t user_id) { + const auto directory = std::filesystem::path(SAVE_DATA_DIR) / title_id / dir_name; + for (uint32_t slot = 0; slot < 4; slot++) { + if (dir_name == memory_dir_name(slot)) { + return directory / std::to_string(user_id); + } + } + return directory; +} + +static std::filesystem::path memory_directory(int32_t user_id, uint32_t slot_id) { + return save_directory(get_title_id(), memory_dir_name(slot_id), user_id); +} + +static bool valid_memory_range(const SaveDataMemoryData& data, size_t size) { + return data.buf != nullptr && data.offset >= 0 && static_cast(data.offset) <= size && + data.buf_size <= size - static_cast(data.offset); +} + +// Keep memory.dat as raw guest bytes. Replacing a flushed temporary file protects the +// previous save if writing fails or the emulator exits partway through an update. +static int write_memory_file(const std::filesystem::path& path, const void* data, uint32_t size) { + const auto temporary = std::filesystem::path(path.string() + ".tmp"); + Common::File file; + if (!file.Create(temporary)) { + return SAVE_DATA_ERROR_INTERNAL; + } + uint32_t written = 0; + file.Write(data, size, &written); + const bool flushed = file.Flush(); + file.Close(); + std::error_code error; + if (written == size && flushed) { + // Common::File::RenameFile deliberately refuses to replace an existing file. + std::filesystem::rename(temporary, path, error); + if (!error) { + return OK; + } + } + std::filesystem::remove(temporary, error); + return SAVE_DATA_ERROR_INTERNAL; +} + +static int save_memory(const std::filesystem::path& directory, const SaveDataMemory& memory) { + std::error_code error; + std::filesystem::create_directories(directory, error); + if (error) { + return SAVE_DATA_ERROR_INTERNAL; + } + // Display metadata is a separate host sidecar, leaving memory.dat as raw guest bytes. + int result = write_memory_file(directory / "param.bin", &memory.param, sizeof(memory.param)); + if (result == OK) { + result = + write_memory_file(directory / "memory.dat", memory.data.data(), memory.data.size()); + } + return result; +} + +static int load_memory(const std::filesystem::path& directory, SaveDataMemory* memory, + size_t* existed_size) { + std::error_code error; + const bool exists = std::filesystem::exists(directory / "memory.dat", error); + if (error) { + return SAVE_DATA_ERROR_INTERNAL; + } + if (!exists) { + return OK; + } + Common::File file(directory / "memory.dat", Common::File::Mode::Read); + if (file.IsInvalid()) { + return SAVE_DATA_ERROR_INTERNAL; + } + *existed_size = file.Size(); + if (*existed_size == 0 || *existed_size > SAVE_DATA_MEMORY_MAX_SIZE) { + return SAVE_DATA_ERROR_BROKEN; + } + const auto size = static_cast(std::min(*existed_size, memory->data.size())); + uint32_t read = 0; + file.Read(memory->data.data(), size, &read); + if (read != size) { + return SAVE_DATA_ERROR_BROKEN; + } + file.Close(); + const bool has_param = std::filesystem::exists(directory / "param.bin", error); + if (error) { + return SAVE_DATA_ERROR_INTERNAL; + } + if (has_param) { + if (!file.Open(directory / "param.bin", Common::File::Mode::Read)) { + return SAVE_DATA_ERROR_INTERNAL; + } + if (file.Size() != sizeof(memory->param)) { + return SAVE_DATA_ERROR_BROKEN; + } + file.Read(&memory->param, sizeof(memory->param), &read); + if (read != sizeof(memory->param)) { + return SAVE_DATA_ERROR_BROKEN; + } + } + return OK; +} + static void queue_save_data_event(uint32_t type, int32_t user_id, const SceSaveDataTitleId* title_id, const SceSaveDataDirName* dir_name, int32_t error_code = OK) { @@ -322,6 +463,7 @@ int KYTY_SYSV_ABI SaveDataTerminate() { return SAVE_DATA_ERROR_BUSY; } g_save_data_events.clear(); + g_save_data_memory.clear(); return OK; } @@ -347,10 +489,17 @@ int KYTY_SYSV_ABI SaveDataDirNameSearch(const SaveDataDirNameSearchCond* cond, PRINT_NAME(); if (cond == nullptr || result == nullptr || + (cond->title_id != nullptr && !valid_path_component(cond->title_id->data)) || + (result->dir_names_num != 0 && result->dir_names == nullptr) || + (cond->dir_name != nullptr && + std::memchr(cond->dir_name->data, 0, sizeof(cond->dir_name->data)) == nullptr) || static_cast(cond->key) > static_cast(SaveDataSortKey::FreeBlocks) || static_cast(cond->order) > static_cast(SaveDataSortOrder::Descent)) { return SAVE_DATA_ERROR_PARAMETER; } + if (cond->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } LOGF("\t user_id = %d\n" "\t title_id = %s\n" @@ -369,14 +518,15 @@ int KYTY_SYSV_ABI SaveDataDirNameSearch(const SaveDataDirNameSearchCond* cond, std::memset(result->reserved, 0, sizeof(result->reserved)); result->pad2 = 0; + Common::LockGuard lock(g_mount_mutex); + const std::string title_id = cond->title_id != nullptr ? cond->title_id->data : get_title_id(); + const auto root = std::filesystem::path(SAVE_DATA_DIR) / title_id; std::vector dir_list; - std::string root = - Common::FixDirectorySlash((std::string(SAVE_DATA_DIR) + "/" + get_title_id())); if (Common::File::IsDirectoryExisting(root)) { for (const auto& entry: Common::File::GetDirEntries(root)) { if (!entry.is_file && entry.name != "." && entry.name != ".." && - !entry.name.starts_with("sce_")) { + !entry.name.starts_with("sce_") && valid_path_component(entry.name)) { if (cond->dir_name == nullptr || cond->dir_name->data[0] == '\0' || dir_name_match(Common::ToLower(entry.name).c_str(), Common::ToLower(std::string(cond->dir_name->data)).c_str())) { @@ -423,9 +573,13 @@ int KYTY_SYSV_ABI SaveDataDirNameSearch(const SaveDataDirNameSearchCond* cond, int KYTY_SYSV_ABI SaveDataMount3(const SaveDataMount3* mount, SaveDataMountResult* mount_result) { PRINT_NAME(); - EXIT_NOT_IMPLEMENTED(mount == nullptr); - EXIT_NOT_IMPLEMENTED(mount_result == nullptr); - EXIT_NOT_IMPLEMENTED(mount->dir_name == nullptr); + if (mount == nullptr || mount_result == nullptr || mount->dir_name == nullptr || + !valid_path_component(mount->dir_name->data)) { + return SAVE_DATA_ERROR_PARAMETER; + } + if (mount->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } LOGF("\t user_id = %d\n" "\t dir_name = %s\n" @@ -439,9 +593,8 @@ int KYTY_SYSV_ABI SaveDataMount3(const SaveDataMount3* mount, SaveDataMountResul *mount_result = {}; Common::LockGuard lock(g_mount_mutex); - const std::string dir_name = mount->dir_name->data; - const std::string mount_dir = - std::string(SAVE_DATA_DIR) + "/" + get_title_id() + "/" + dir_name; + const std::string dir_name = mount->dir_name->data; + const auto mount_dir = save_directory(get_title_id(), dir_name, mount->user_id).string(); const bool create = ((mount->mount_mode & 4u) != 0); const bool create2 = ((mount->mount_mode & 32u) != 0); const bool open = (!create && !create2 && ((mount->mount_mode & 3u) != 0)); @@ -481,9 +634,13 @@ int KYTY_SYSV_ABI SaveDataSetupSaveDataMemory2(const SaveDataMemorySetup2* setup SaveDataMemorySetupResult* result) { PRINT_NAME(); - if (setup_param == nullptr) { + if (setup_param == nullptr || setup_param->slot_id >= 4 || setup_param->memory_size == 0 || + setup_param->memory_size > SAVE_DATA_MEMORY_MAX_SIZE || (setup_param->option & ~3u) != 0) { return SAVE_DATA_ERROR_PARAMETER; } + if (setup_param->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } LOGF("\t option = 0x%08" PRIx32 "\n" "\t user_id = %" PRId32 "\n" @@ -493,13 +650,41 @@ int KYTY_SYSV_ABI SaveDataSetupSaveDataMemory2(const SaveDataMemorySetup2* setup setup_param->option, setup_param->user_id, static_cast(setup_param->memory_size), static_cast(setup_param->icon_memory_size), setup_param->slot_id); - if (setup_param->memory_size > g_save_data_memory.size()) { - g_save_data_memory.resize(setup_param->memory_size); + Common::LockGuard lock(g_mount_mutex); + const auto directory = memory_directory(setup_param->user_id, setup_param->slot_id); + if (g_save_data_memory.contains(directory) || + g_mount_slots.FindAvailable(memory_dir_name(setup_param->slot_id)) == + SaveDataMountSlots::BUSY) { + return SAVE_DATA_ERROR_BUSY; } + size_t total = setup_param->memory_size * + ((setup_param->option & SAVE_DATA_MEMORY_DOUBLE_BUFFER) != 0 ? 2 : 1); + for (const auto& [path, memory]: g_save_data_memory) { + total += + memory.data.size() * ((memory.option & SAVE_DATA_MEMORY_DOUBLE_BUFFER) != 0 ? 2 : 1); + } + if (g_save_data_memory.size() >= 4 || total > SAVE_DATA_MEMORY_MAX_SIZE) { + return SAVE_DATA_ERROR_LIMITATION_OVER; + } + SaveDataMemory memory; + memory.data.resize(setup_param->memory_size); + memory.option = setup_param->option; + std::snprintf(memory.param.title, sizeof(memory.param.title), "Saved Data"); + size_t existed_size = 0; + const int status = load_memory(directory, &memory, &existed_size); + if (status != OK) { + return status; + } + if (existed_size == 0 && setup_param->init_param != nullptr && + (memory.option & SAVE_DATA_MEMORY_SET_PARAM) != 0) { + memory.param = *setup_param->init_param; + memory.param.mtime = 0; + } + g_save_data_memory.emplace(directory, std::move(memory)); if (result != nullptr) { *result = {}; - result->existed_memory_size = g_save_data_memory.size(); + result->existed_memory_size = existed_size; } return OK; @@ -508,9 +693,12 @@ int KYTY_SYSV_ABI SaveDataSetupSaveDataMemory2(const SaveDataMemorySetup2* setup int KYTY_SYSV_ABI SaveDataGetSaveDataMemory2(SaveDataMemoryGet2* get_param) { PRINT_NAME(); - if (get_param == nullptr) { + if (get_param == nullptr || get_param->slot_id >= 4) { return SAVE_DATA_ERROR_PARAMETER; } + if (get_param->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } LOGF("\t user_id = %" PRId32 "\n" "\t data = 0x%016" PRIx64 "\n" @@ -521,20 +709,23 @@ int KYTY_SYSV_ABI SaveDataGetSaveDataMemory2(SaveDataMemoryGet2* get_param) { reinterpret_cast(get_param->param), reinterpret_cast(get_param->icon), get_param->slot_id); + Common::LockGuard lock(g_mount_mutex); + const auto it = + g_save_data_memory.find(memory_directory(get_param->user_id, get_param->slot_id)); + if (it == g_save_data_memory.end()) { + return SAVE_DATA_ERROR_MEMORY_NOT_READY; + } + const auto& memory = it->second; + if ((get_param->param != nullptr && (memory.option & SAVE_DATA_MEMORY_SET_PARAM) == 0) || + (get_param->data != nullptr && !valid_memory_range(*get_param->data, memory.data.size()))) { + return SAVE_DATA_ERROR_PARAMETER; + } if (get_param->data != nullptr) { - auto* data = get_param->data; - if (data->buf == nullptr || data->offset < 0) { - return SAVE_DATA_ERROR_PARAMETER; - } - - const auto offset = static_cast(data->offset); - if (offset + data->buf_size > g_save_data_memory.size()) { - g_save_data_memory.resize(offset + data->buf_size); - } - std::memcpy(data->buf, g_save_data_memory.data() + offset, data->buf_size); + const auto& data = *get_param->data; + std::memcpy(data.buf, memory.data.data() + data.offset, data.buf_size); } if (get_param->param != nullptr) { - std::memset(get_param->param, 0, sizeof(*get_param->param)); + *get_param->param = memory.param; } if (get_param->icon != nullptr) { get_param->icon->data_size = 0; @@ -546,9 +737,13 @@ int KYTY_SYSV_ABI SaveDataGetSaveDataMemory2(SaveDataMemoryGet2* get_param) { int KYTY_SYSV_ABI SaveDataSetSaveDataMemory2(const SaveDataMemorySet2* set_param) { PRINT_NAME(); - if (set_param == nullptr) { + if (set_param == nullptr || set_param->slot_id >= 4 || set_param->data_num > 5 || + (set_param->data == nullptr && set_param->data_num != 0)) { return SAVE_DATA_ERROR_PARAMETER; } + if (set_param->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } LOGF("\t user_id = %" PRId32 "\n" "\t data = 0x%016" PRIx64 "\n" @@ -560,23 +755,42 @@ int KYTY_SYSV_ABI SaveDataSetSaveDataMemory2(const SaveDataMemorySet2* set_param reinterpret_cast(set_param->param), reinterpret_cast(set_param->icon), set_param->data_num, set_param->slot_id); - const uint32_t data_num = (set_param->data_num == 0 ? 1 : set_param->data_num); - if (set_param->data != nullptr) { - for (uint32_t i = 0; i < data_num; i++) { - const auto& data = set_param->data[i]; - if (data.buf == nullptr || data.offset < 0) { - return SAVE_DATA_ERROR_PARAMETER; - } - - const auto offset = static_cast(data.offset); - if (offset + data.buf_size > g_save_data_memory.size()) { - g_save_data_memory.resize(offset + data.buf_size); - } - std::memcpy(g_save_data_memory.data() + offset, data.buf, data.buf_size); + Common::LockGuard lock(g_mount_mutex); + const auto it = + g_save_data_memory.find(memory_directory(set_param->user_id, set_param->slot_id)); + if (it == g_save_data_memory.end()) { + return SAVE_DATA_ERROR_MEMORY_NOT_READY; + } + if (set_param->param != nullptr && (it->second.option & SAVE_DATA_MEMORY_SET_PARAM) == 0) { + return SAVE_DATA_ERROR_PARAMETER; + } + // Some games supply one descriptor with an implicit zero count. + const uint32_t data_num = + set_param->data != nullptr && set_param->data_num == 0 ? 1 : set_param->data_num; + for (uint32_t i = 0; i < data_num; i++) { + if (!valid_memory_range(set_param->data[i], it->second.data.size())) { + return SAVE_DATA_ERROR_PARAMETER; } } + if (data_num == 0 && set_param->param == nullptr) { + return OK; + } - return OK; + // Save the complete update before publishing it so progress survives process exit. + auto updated = it->second; + for (uint32_t i = 0; i < data_num; i++) { + const auto& data = set_param->data[i]; + std::memcpy(updated.data.data() + data.offset, data.buf, data.buf_size); + } + if (set_param->param != nullptr) { + updated.param = *set_param->param; + } + updated.param.mtime = static_cast(std::time(nullptr)); + const int status = save_memory(it->first, updated); + if (status == OK) { + it->second = std::move(updated); + } + return status; } int KYTY_SYSV_ABI SaveDataTransferringMount(const SaveDataTransferringMount* mount, @@ -584,9 +798,13 @@ int KYTY_SYSV_ABI SaveDataTransferringMount(const SaveDataTransferringMount* mou PRINT_NAME(); if (mount == nullptr || mount_result == nullptr || mount->title_id == nullptr || - mount->dir_name == nullptr) { + mount->dir_name == nullptr || !valid_path_component(mount->title_id->data) || + !valid_path_component(mount->dir_name->data)) { return SAVE_DATA_ERROR_PARAMETER; } + if (mount->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } LOGF("\t user_id = %" PRId32 "\n" "\t title_id = %s\n" @@ -597,8 +815,7 @@ int KYTY_SYSV_ABI SaveDataTransferringMount(const SaveDataTransferringMount* mou Common::LockGuard lock(g_mount_mutex); const std::string dir_name = mount->dir_name->data; - const std::string mount_dir = - std::string(SAVE_DATA_DIR) + "/" + mount->title_id->data + "/" + dir_name; + const auto mount_dir = save_directory(mount->title_id->data, dir_name, mount->user_id).string(); const int slot = g_mount_slots.FindAvailable(dir_name); if (slot == SaveDataMountSlots::BUSY) { return SAVE_DATA_ERROR_BUSY; @@ -669,6 +886,7 @@ int KYTY_SYSV_ABI SaveDataCommit(const SaveDataCommitParam* param) { param->resource, param->commit_mode); if ((param->commit_mode & SAVE_DATA_COMMIT_MODE_BACKUP_ASYNC) != 0) { + Common::LockGuard lock(g_mount_mutex); queue_save_data_event(SAVE_DATA_EVENT_TYPE_COMMIT_BACKUP_END, 0, nullptr, nullptr); } @@ -678,9 +896,13 @@ int KYTY_SYSV_ABI SaveDataCommit(const SaveDataCommitParam* param) { int KYTY_SYSV_ABI SaveDataDelete(const SaveDataDelete* del) { PRINT_NAME(); - if (del == nullptr || del->dir_name == nullptr) { + if (del == nullptr || del->dir_name == nullptr || !valid_path_component(del->dir_name->data) || + (del->title_id != nullptr && !valid_path_component(del->title_id->data))) { return SAVE_DATA_ERROR_PARAMETER; } + if (del->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } LOGF("\t user_id = %" PRId32 "\n" "\t title_id = %s\n" @@ -688,13 +910,17 @@ int KYTY_SYSV_ABI SaveDataDelete(const SaveDataDelete* del) { del->user_id, del->title_id != nullptr ? del->title_id->data : "", del->dir_name->data); - std::string dir = - std::string(SAVE_DATA_DIR) + "/" + get_title_id() + "/" + std::string(del->dir_name->data); - if (Common::File::IsDirectoryExisting(dir)) { - Common::File::DeleteDirectory(dir); + Common::LockGuard lock(g_mount_mutex); + const auto dir = save_directory(del->title_id != nullptr ? del->title_id->data : get_title_id(), + del->dir_name->data, del->user_id); + if (g_mount_slots.FindAvailable(del->dir_name->data) == SaveDataMountSlots::BUSY || + g_save_data_memory.contains(dir)) { + return SAVE_DATA_ERROR_BUSY; } + std::error_code error; + std::filesystem::remove_all(dir, error); - return OK; + return error ? SAVE_DATA_ERROR_INTERNAL : OK; } int KYTY_SYSV_ABI SaveDataGetParam(const SaveDataMountPoint* mount_point, uint32_t param_type, @@ -760,10 +986,35 @@ int KYTY_SYSV_ABI SaveDataSaveIconByPath(const SaveDataMountPoint* mount_point, return OK; } -int KYTY_SYSV_ABI SaveDataSyncSaveDataMemory(const void* sync_param) { +int KYTY_SYSV_ABI SaveDataSyncSaveDataMemory(const SaveDataMemorySync* sync_param) { PRINT_NAME(); - LOGF("\t sync_param = 0x%016" PRIx64 "\n", reinterpret_cast(sync_param)); + if (sync_param == nullptr || sync_param->slot_id >= 4 || sync_param->option > 1) { + return SAVE_DATA_ERROR_PARAMETER; + } + if (sync_param->user_id < 0) { + return SAVE_DATA_ERROR_INVALID_LOGIN_USER; + } + Common::LockGuard lock(g_mount_mutex); + const auto it = + g_save_data_memory.find(memory_directory(sync_param->user_id, sync_param->slot_id)); + if (it == g_save_data_memory.end()) { + return SAVE_DATA_ERROR_MEMORY_NOT_READY; + } + const auto dir_name = memory_dir_name(sync_param->slot_id); + if (g_mount_slots.FindAvailable(dir_name) == SaveDataMountSlots::BUSY) { + return SAVE_DATA_ERROR_BUSY; + } + const int status = save_memory(it->first, it->second); + if (sync_param->option == 1) { + return status; + } + SceSaveDataTitleId title {}; + SceSaveDataDirName directory {}; + std::snprintf(title.data, sizeof(title.data), "%s", get_title_id().c_str()); + std::snprintf(directory.data, sizeof(directory.data), "%s", dir_name.c_str()); + queue_save_data_event(SAVE_DATA_EVENT_TYPE_MEMORY_SYNC_END, sync_param->user_id, &title, + &directory, status); return OK; } @@ -779,6 +1030,7 @@ int KYTY_SYSV_ABI SaveDataGetEventResult(const void* event_param, SaveDataEvent* return SAVE_DATA_ERROR_PARAMETER; } + Common::LockGuard lock(g_mount_mutex); if (g_save_data_events.empty()) { return SAVE_DATA_ERROR_NOT_FOUND; } @@ -812,6 +1064,7 @@ int KYTY_SYSV_ABI SaveDataBackup(const SaveDataBackup* backup) { backup->user_id, backup->title_id != nullptr ? backup->title_id->data : "", backup->dir_name->data); + Common::LockGuard lock(g_mount_mutex); queue_save_data_event(SAVE_DATA_EVENT_TYPE_BACKUP_END, backup->user_id, backup->title_id, backup->dir_name); diff --git a/tests/KernelFileSystemTests.cpp b/tests/KernelFileSystemTests.cpp index a673bc39f..faf89dc60 100644 --- a/tests/KernelFileSystemTests.cpp +++ b/tests/KernelFileSystemTests.cpp @@ -89,6 +89,33 @@ void CheckSaveRename(const std::filesystem::path &root, "renamed save contents"); } +void TestSaveOpenVisibility() { + constexpr char Path[] = "/savedata0/visible-save.dat"; + constexpr char Payload[] = "saved progress"; + + const int fd = FileSystem::KernelOpen(Path, 0xa01, 0777); + Check(fd >= 3, "create save file exclusively"); + FileSystem::FileStat stat {}; + Check(FileSystem::KernelStat(Path, &stat) == OK && stat.st_size == 0, + "created save file is visible before close"); + Check(FileSystem::KernelOpen(Path, 0xa01, 0777) == + Libs::LibKernel::KERNEL_ERROR_EEXIST, + "exclusive creation detects an open save file"); + Check(FileSystem::KernelWrite(fd, Payload, sizeof(Payload) - 1) == + sizeof(Payload) - 1, + "populate save file before truncation"); + Check(FileSystem::KernelClose(fd) == OK, "close populated save file"); + Check(FileSystem::KernelStat(Path, &stat) == OK && + stat.st_size == sizeof(Payload) - 1, + "save file contains the truncation fixture"); + + const int truncated = FileSystem::KernelOpen(Path, 0x401, 0777); + Check(truncated >= 3, "truncate existing save file"); + Check(FileSystem::KernelStat(Path, &stat) == OK && stat.st_size == 0, + "save truncation is visible before close"); + Check(FileSystem::KernelClose(truncated) == OK, "close truncated save file"); +} + void CheckMountRoot(const std::filesystem::path &root) { Common::File cache; Check(cache.Create(root / "rpf.cache"), "create directory listing fixture"); @@ -390,6 +417,7 @@ int main() { CheckDirectoryStream(temporary.Path()); CheckAprPaths(temporary.Path()); FileSystem::Mount(temporary.Path(), "/savedata0"); + TestSaveOpenVisibility(); CheckSaveRename(temporary.Path(), "first-save"); CheckSaveRename(temporary.Path(), "replacement-save"); FileSystem::Shutdown(); diff --git a/tests/SaveDataMemoryTests.cpp b/tests/SaveDataMemoryTests.cpp new file mode 100644 index 000000000..57880e1db --- /dev/null +++ b/tests/SaveDataMemoryTests.cpp @@ -0,0 +1,432 @@ +// Exercise the guest ABI without linking unrelated emulator subsystems. +#include "libs/libSaveData.cpp" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#endif + +namespace { +std::string g_test_title = "PPSA21564"; +std::filesystem::path g_mounted_directory; +} // namespace + +namespace Loader { +bool SystemContentParamSfoGetString(const char*, std::string* value) { + *value = g_test_title; + return true; +} +void SymbolDatabase::Add(const SymbolResolve&, uint64_t, const std::string&) {} +namespace Timer { +Common::Time GetTime() { + return Common::Time(0); +} +} // namespace Timer +} // namespace Loader + +namespace Libs::LibKernel::FileSystem { +void Mount(const std::filesystem::path& directory, const std::string&) { + g_mounted_directory = directory; +} +void Umount(const std::string&) {} +} // namespace Libs::LibKernel::FileSystem + +namespace { +using namespace Libs::SaveData; +namespace fs = std::filesystem; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + std::fprintf(stderr, "SaveDataMemoryTests:%d: %s\n", __LINE__, #condition); \ + std::abort(); \ + } \ + } while (false) + +SaveDataMemorySetup2 SetupParam(int32_t user = 1, uint32_t slot = 0, size_t size = 16) { + SaveDataMemorySetup2 setup {}; + setup.user_id = user; + setup.slot_id = slot; + setup.memory_size = size; + return setup; +} + +size_t Setup(int32_t user = 1, uint32_t slot = 0, size_t size = 16, uint32_t option = 0) { + auto setup = SetupParam(user, slot, size); + setup.option = option; + SaveDataMemorySetupResult result {}; + CHECK(SaveDataSetupSaveDataMemory2(&setup, &result) == OK); + return result.existed_memory_size; +} + +int Set(void* bytes, size_t size, int64_t offset = 0, int32_t user = 1, uint32_t slot = 0) { + SaveDataMemoryData data {bytes, size, offset, {}}; + SaveDataMemorySet2 set {}; + set.user_id = user; + set.slot_id = slot; + set.data = &data; + set.data_num = 1; + return SaveDataSetSaveDataMemory2(&set); +} + +std::vector Get(size_t size = 16, int32_t user = 1, uint32_t slot = 0) { + std::vector bytes(size); + SaveDataMemoryData data {bytes.data(), size, 0, {}}; + SaveDataMemoryGet2 get {}; + get.user_id = user; + get.slot_id = slot; + get.data = &data; + CHECK(SaveDataGetSaveDataMemory2(&get) == OK); + return bytes; +} + +void Reset(const char* title) { + CHECK(SaveDataTerminate() == OK); + g_test_title = title; +} + +void TestValidationAndScatter() { + Reset("VALIDATE"); + uint8_t byte = 0x55; + SaveDataMemoryGet2 get {}; + get.user_id = 1; + SaveDataMemorySync sync {}; + sync.user_id = 1; + CHECK(SaveDataGetSaveDataMemory2(&get) == SAVE_DATA_ERROR_MEMORY_NOT_READY); + CHECK(Set(&byte, 1) == SAVE_DATA_ERROR_MEMORY_NOT_READY); + CHECK(SaveDataSyncSaveDataMemory(&sync) == SAVE_DATA_ERROR_MEMORY_NOT_READY); + CHECK(SaveDataSetupSaveDataMemory2(nullptr, nullptr) == SAVE_DATA_ERROR_PARAMETER); + auto invalid_user = SetupParam(-1); + CHECK(SaveDataSetupSaveDataMemory2(&invalid_user, nullptr) == + SAVE_DATA_ERROR_INVALID_LOGIN_USER); + for (auto invalid: + {SetupParam(1, 4), SetupParam(1, 0, 0), SetupParam(1, 0, 32 * 1024 * 1024 + 1)}) { + CHECK(SaveDataSetupSaveDataMemory2(&invalid, nullptr) == SAVE_DATA_ERROR_PARAMETER); + } + CHECK(Setup() == 0); + auto setup = SetupParam(); + CHECK(SaveDataSetupSaveDataMemory2(&setup, nullptr) == SAVE_DATA_ERROR_BUSY); + std::array original {}; + for (size_t i = 0; i < original.size(); ++i) { + original[i] = static_cast(i); + } + CHECK(Set(original.data(), original.size()) == OK); + SaveDataMemoryData data[] = {{&byte, 1, 2, {}}, {&byte, 1, 16, {}}}; + SaveDataMemorySet2 set {}; + set.user_id = 1; + set.data = data; + set.data_num = 2; + CHECK(SaveDataSetSaveDataMemory2(&set) == SAVE_DATA_ERROR_PARAMETER); + CHECK(Get() == std::vector(original.begin(), original.end())); + data[1].offset = 12; + CHECK(SaveDataSetSaveDataMemory2(&set) == OK); + original[2] = original[12] = byte; + CHECK(Get() == std::vector(original.begin(), original.end())); + + byte = 0xaa; + set.data_num = 0; + CHECK(SaveDataSetSaveDataMemory2(&set) == OK); + original[2] = byte; + CHECK(Get() == std::vector(original.begin(), original.end())); + set.data = nullptr; + CHECK(SaveDataSetSaveDataMemory2(&set) == OK); + CHECK(Get() == std::vector(original.begin(), original.end())); + set.data_num = 6; + CHECK(SaveDataSetSaveDataMemory2(&set) == SAVE_DATA_ERROR_PARAMETER); + set.data_num = 1; + set.data = nullptr; + CHECK(SaveDataSetSaveDataMemory2(&set) == SAVE_DATA_ERROR_PARAMETER); + CHECK(Set(&byte, 1, -1) == SAVE_DATA_ERROR_PARAMETER); + CHECK(Set(&byte, 1, std::numeric_limits::max()) == SAVE_DATA_ERROR_PARAMETER); + CHECK(Set(&byte, std::numeric_limits::max(), 1) == SAVE_DATA_ERROR_PARAMETER); + SaveDataMemoryData invalid {&byte, std::numeric_limits::max(), 1, {}}; + get.data = &invalid; + CHECK(SaveDataGetSaveDataMemory2(&get) == SAVE_DATA_ERROR_PARAMETER); + CHECK(byte == 0xaa); + CHECK(Get() == std::vector(original.begin(), original.end())); + CHECK(SaveDataTerminate() == OK); + CHECK(Setup() == original.size()); + CHECK(Get() == std::vector(original.begin(), original.end())); +} + +void TestIsolationAndSync() { + Reset("ISOLATE"); + uint8_t byte = 11; + CHECK(Setup() == 0); + CHECK(Set(&byte, 1) == OK); + CHECK(Setup(2) == 0); + CHECK(Setup(1, 1) == 0); + CHECK(Get(16, 2) == std::vector(16)); + CHECK(Get(16, 1, 1) == std::vector(16)); + byte = 22; + CHECK(Set(&byte, 1, 0, 2) == OK); + byte = 33; + CHECK(Set(&byte, 1, 0, 1, 1) == OK); + CHECK(Get()[0] == 11); + CHECK(Get(16, 2)[0] == 22); + CHECK(Get(16, 1, 1)[0] == 33); + + SaveDataMemorySync sync {}; + sync.user_id = 1; + sync.slot_id = 1; + CHECK(SaveDataSyncSaveDataMemory(&sync) == OK); + SaveDataEvent event {}; + CHECK(SaveDataGetEventResult(nullptr, &event) == OK); + CHECK(event.type == 3 && event.error_code == OK && event.user_id == 1); + CHECK(std::string(event.dir_name.data) == "sce_sdmemory1"); + CHECK(SaveDataGetEventResult(nullptr, &event) == SAVE_DATA_ERROR_NOT_FOUND); + sync.option = 1; + CHECK(SaveDataSyncSaveDataMemory(&sync) == OK); + CHECK(SaveDataGetEventResult(nullptr, &event) == SAVE_DATA_ERROR_NOT_FOUND); + sync.option = 2; + CHECK(SaveDataSyncSaveDataMemory(&sync) == SAVE_DATA_ERROR_PARAMETER); + + Reset("OTHER"); + CHECK(Setup() == 0); + CHECK(Get() == std::vector(16)); + Reset("ISOLATE"); + CHECK(Setup() == 16); + CHECK(Setup(2) == 16); + CHECK(Setup(1, 1) == 16); + CHECK(Get()[0] == 11 && Get(16, 2)[0] == 22 && Get(16, 1, 1)[0] == 33); + Reset("OTHER"); + SceSaveDataTitleId title {}; + std::strcpy(title.data, "ISOLATE"); + SceSaveDataDirName name {}; + std::strcpy(name.data, "sce_sdmemory"); + struct SaveDataTransferringMount mount {}; + mount.user_id = 2; + mount.title_id = &title; + mount.dir_name = &name; + SaveDataMountResult result {}; + CHECK(SaveDataTransferringMount(&mount, &result) == OK); + CHECK(g_mounted_directory == fs::path("_SaveData/ISOLATE/sce_sdmemory/2")); + CHECK(std::ifstream(g_mounted_directory / "memory.dat", std::ios::binary).get() == 22); + CHECK(SaveDataUmount2(0, &result.mount_point) == OK); + struct SaveDataDelete del {}; + del.user_id = 1; + del.title_id = &title; + del.dir_name = &name; + CHECK(SaveDataDelete(&del) == OK); + Reset("ISOLATE"); + CHECK(Setup() == 0); + CHECK(Setup(2) == 16); + CHECK(Get(16, 2)[0] == 22); +} + +void TestFailedWritePreservesSave() { + Reset("IOFAIL"); + CHECK(Setup() == 0); + uint8_t byte = 42; + CHECK(Set(&byte, 1) == OK); + fs::rename("_SaveData", "save-backup"); + { + std::ofstream blocker("_SaveData"); + blocker << "not a directory"; + } + byte = 99; + CHECK(Set(&byte, 1) < 0); + CHECK(Get()[0] == 42); + CHECK(fs::remove("_SaveData")); + fs::rename("save-backup", "_SaveData"); + CHECK(SaveDataTerminate() == OK); + CHECK(Setup() == 16); + CHECK(Get()[0] == 42); +} + +void WriteRestartFixture() { + auto setup = SetupParam(7, 3); + setup.option = 1; + std::array icon_bytes {1, 2, 3, 4}; + SaveDataIcon icon {icon_bytes.data(), icon_bytes.size(), icon_bytes.size(), {}}; + setup.init_icon = &icon; + setup.icon_memory_size = icon_bytes.size(); + SaveDataParam param {}; + std::strcpy(param.title, "Astro progress"); + param.user_param = 17; + setup.init_param = ¶m; + CHECK(SaveDataSetupSaveDataMemory2(&setup, nullptr) == OK); + std::array progress {11, 22, 33, 44}; + SaveDataMemoryData data {progress.data(), progress.size(), 4, {}}; + SaveDataMemorySet2 set {}; + set.user_id = 7; + set.slot_id = 3; + set.data = &data; + set.icon = &icon; + CHECK(SaveDataSetSaveDataMemory2(&set) == OK); + set.data = nullptr; + param.user_param = 18; + std::strcpy(param.detail, "Coins, level state, rescued bots"); + set.param = ¶m; + CHECK(SaveDataSetSaveDataMemory2(&set) == OK); + // No Sync or Terminate: Set must survive process exit on its own. +} + +void ReadRestartFixture() { + CHECK(Setup(7, 3, 24, 1) == 16); + auto expected = std::vector(24); + expected[4] = 11; + expected[5] = 22; + expected[6] = 33; + expected[7] = 44; + CHECK(Get(24, 7, 3) == expected); + SaveDataParam param {}; + std::array icon_bytes {9, 9, 9, 9}; + SaveDataIcon icon {icon_bytes.data(), icon_bytes.size(), icon_bytes.size(), {}}; + SaveDataMemoryGet2 get {}; + get.user_id = 7; + get.slot_id = 3; + get.param = ¶m; + get.icon = &icon; + CHECK(SaveDataGetSaveDataMemory2(&get) == OK); + CHECK(icon.data_size == 0); + CHECK( + std::all_of(icon_bytes.begin(), icon_bytes.end(), [](uint8_t byte) { return byte == 9; })); + CHECK(std::string(param.title) == "Astro progress"); + CHECK(std::string(param.detail) == "Coins, level state, rescued bots"); + CHECK(param.user_param == 18); + CHECK(SaveDataTerminate() == OK); + CHECK(Setup(7, 3, 6) == 16); + expected.resize(6); + CHECK(Get(6, 7, 3) == expected); +} + +SceSaveDataDirName DirName(const char* text) { + SceSaveDataDirName name {}; + std::snprintf(name.data, sizeof(name.data), "%s", text); + return name; +} + +std::vector Search(int32_t user, const SceSaveDataTitleId* title = nullptr) { + SaveDataDirNameSearchCond cond {}; + cond.user_id = user; + cond.title_id = title; + std::array names {}; + SaveDataDirNameSearchResult result {}; + result.dir_names = names.data(); + result.dir_names_num = names.size(); + CHECK(SaveDataDirNameSearch(&cond, &result) == OK); + CHECK(result.hit_num == result.set_num && result.set_num <= names.size()); + std::vector found; + for (uint32_t i = 0; i < result.set_num; ++i) { + found.emplace_back(names[i].data); + } + return found; +} + +void TestClassicSavePaths() { + Reset("CLASSIC"); + const std::vector names {"save.1", "save1", "slot@A", "slotA"}; + fs::create_directories("_SaveData/CLASSIC/save.1"); + { + std::ofstream saved("_SaveData/CLASSIC/save.1/progress"); + saved << "save.1"; + } + CHECK(Search(1) == std::vector {"save.1"}); + for (const auto& text: names) { + auto name = DirName(text.c_str()); + struct SaveDataMount3 mount {}; + mount.user_id = 1; + mount.dir_name = &name; + mount.mount_mode = text == "save.1" ? 1 : 4; + SaveDataMountResult result {}; + CHECK(SaveDataMount3(&mount, &result) == OK); + CHECK(g_mounted_directory == fs::path("_SaveData") / "CLASSIC" / text); + if (text != "save.1") { + std::ofstream saved(g_mounted_directory / "progress"); + saved << text; + } + CHECK(SaveDataUmount2(0, &result.mount_point) == OK); + } + CHECK(Search(1) == names); + SceSaveDataTitleId title {}; + std::strcpy(title.data, "CLASSIC"); + Reset("OTHER"); + CHECK(Search(1).empty()); + CHECK(Search(1, &title) == names); + for (const auto& text: names) { + auto name = DirName(text.c_str()); + struct SaveDataTransferringMount mount {}; + mount.user_id = 1; + mount.title_id = &title; + mount.dir_name = &name; + SaveDataMountResult result {}; + CHECK(SaveDataTransferringMount(&mount, &result) == OK); + std::ifstream saved(g_mounted_directory / "progress"); + std::string progress; + saved >> progress; + CHECK(progress == text); + CHECK(SaveDataUmount2(0, &result.mount_point) == OK); + } + Reset("CLASSIC"); + for (const char* text: {"", ".", "..", "../save1", "save/1", "save\\1"}) { + auto name = DirName(text); + struct SaveDataMount3 mount {}; + mount.user_id = 1; + mount.dir_name = &name; + mount.mount_mode = 32; + SaveDataMountResult result {}; + g_mounted_directory.clear(); + CHECK(SaveDataMount3(&mount, &result) == SAVE_DATA_ERROR_PARAMETER); + CHECK(g_mounted_directory.empty()); + struct SaveDataDelete del {}; + del.user_id = 1; + del.dir_name = &name; + CHECK(SaveDataDelete(&del) == SAVE_DATA_ERROR_PARAMETER); + } + CHECK(Search(1) == names); +} + +void RunChild(const fs::path& executable, const char* mode) { +#ifdef _WIN32 + CHECK(_spawnl(_P_WAIT, executable.string().c_str(), executable.string().c_str(), mode, + static_cast(nullptr)) == 0); +#else + const pid_t pid = fork(); + CHECK(pid >= 0); + if (pid == 0) { + execl(executable.c_str(), executable.c_str(), mode, static_cast(nullptr)); + _exit(127); + } + int status = 0; + CHECK(waitpid(pid, &status, 0) == pid); + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0); +#endif +} +} // namespace + +int main(int argc, char** argv) { + if (argc == 2) { + if (std::string_view(argv[1]) == "write") { + WriteRestartFixture(); + } else { + CHECK(std::string_view(argv[1]) == "read"); + ReadRestartFixture(); + } + return 0; + } + const auto executable = fs::absolute(argv[0]); + const auto previous = fs::current_path(); + const auto unique = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto temp = fs::temp_directory_path() / ("kyty_save_memory_" + std::to_string(unique)); + CHECK(fs::create_directories(temp)); + fs::current_path(temp); + RunChild(executable, "write"); + RunChild(executable, "read"); + TestValidationAndScatter(); + TestIsolationAndSync(); + TestFailedWritePreservesSave(); + TestClassicSavePaths(); + CHECK(SaveDataTerminate() == OK); + fs::current_path(previous); + fs::remove_all(temp); + return 0; +}