Skip to content

feat(libSceSaveData): implement sceSaveDataCheckBackupData, sceSaveDataRestoreBackupData and sceSaveDataUmountWithBackup - #12748

Merged
14 commits merged into
boykopovar:mainfrom
EagleBearerYS:feat/savedata-backup-progress-sourced
Nov 2, 2026
Merged

14 commits merged into
boykopovar:mainfrom
EagleBearerYS:feat/savedata-backup-progress-sourced

Conversation

@EagleBearerYS

@EagleBearerYS EagleBearerYS commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What

The three were parameterless declarations that threw through NotImplemented_nid_no_patch. They now take shadPS4's signatures and do what its PS4 binding does, on this library's save store.

  • Sources (shadPS4 at ce74386283, fpPS4 at 04cefd43e6):
  • The check:
    • SAVE_DATA_ERROR_NOT_INITIALIZED before initialization;
    • PARAMETER for a null block or directory name;
    • BUSY while the save is mounted;
    • NOT_FOUND when there is no backup;
    • otherwise 0, with param filled from the backup.
  • The restore: the same checks, then the save's files, param and size are replaced with the backup's. INTERNAL when the save directory is missing, the backup's metadata is incomplete or the wrong size, or a file call fails.
    • The backup is first copied to _sd_restore_tmp/<dir name>, checked complete and flushed. The live save then moves to _sd_restore_old/<dir name> and the copy into place, and a failed move puts back what had moved, so a failed restore leaves the save as it was.
  • The unmount with backup:
    • NOT_INITIALIZED before initialization, and PARAMETER for a null mount point;
    • otherwise it unmounts, copies the save to its backup, and queues a type-1 event with the mount's user, the title and the directory for sceSaveDataGetEventResult;
    • a failed copy sets the event's error to INTERNAL, as shadPS4's backup thread does, and the call still returns 0.
  • Where it differs from shadPS4, each stated in libSceSaveData.native.md's debt entries:
    • an unknown mount point returns NOT_MOUNTED, as sceSaveDataUmount2 does here, where shadPS4 returns NOT_FOUND;
    • the copy finishes before the call returns, so BACKUP_BUSY never occurs and the event is queued at once, as in fpPS4;
    • another title's titleId returns NOT_FOUND, because only the running title's saves are kept;
    • saves keep no icons, so the check never fills icon. shadPS4 does the same when there's no icon0.png.
  • Where backups live: _sd_backup/<dir name>, outside the save, so the title's mount and its block count don't see them.
    • Each backup is built in _sd_backup_tmp, flushed, and swapped in through _sd_backup_old, like shadPS4's sce_backup_tmp and sce_backup_old. A failed swap puts the previous backup back.
    • Every root and entry a call copies or deletes must be a plain file or directory under its root: a symlink or a Windows reparse point gives INTERNAL with nothing changed.
    • sceSaveDataDelete removes a save's backup along with the save. In shadPS4 the backup lives inside the save directory that Delete removes.
  • Shared code:
    • mount3, deleteSave and convert each had their own copy of the directory-name check. They and the two new functions now call one helper, with the same rules.
    • MountSlot now keeps the mounting user, for the event.
  • Not measured on a PS5: the layouts and the error checks follow shadPS4's PS4 binding, and the tests check that binding, not the console. The debt entry says so. A PS4 Pro on firmware 10.71 agrees with every error code and result it was run for (see Tested). The one difference it found, PARAMETER for a param buffer that isn't zeroed, is a debt entry, since which bytes the console checks isn't known.
  • Not covered: sceSaveDataBackup doesn't copy yet, so the check and the restore see only backups that sceSaveDataUmountWithBackup made (also in the debt entry). Making it use the same copy is a follow-up.

Tested

  • guest_savedata_mount_status, 37 new checks:
    • before sceSaveDataInitialize3, all three calls return NOT_INITIALIZED;
    • PARAMETER for a null block or directory name, and for a null mount point;
    • NOT_FOUND for a save with no backup, and BUSY for a mounted one, from both the check and the restore;
    • sceSaveDataUmountWithBackup returns 0 and unmounts. sceSaveDataGetEventResult then gives type 1, error 0, user 7, title PPSA00000 and directory kept;
    • after that backup, the check fills param with the backed-up title, also when it is given the running title's ID;
    • after data.bin is changed, added.bin is added and the title is changed, the restore puts back data.bin, removes added.bin and puts back the title;
    • deleting the save makes the check return NOT_FOUND.
  • Red/green on Linux (Ubuntu 24.04, GCC 13.3, Release):
    • Red, main's library code with only the three signatures changed so it builds: the test aborts with sceSaveDataCheckBackupData not implemented.
    • Red, stubs that return 0: 24 of the 37 new checks fail. The 13 that pass are setup steps, calls that only need to return 0, and the event's zero error.
    • Red, without the backup removal in sceSaveDataDelete: only "deleting a save deletes its backup" fails.
    • Green: the 13 save-data tests (guest_savedata_*, savedata_*) ran 10 times: 130 passed, 0 skipped, 0 failed.
  • GitHub, ×10 on both runners (run): the 13 save-data tests pass 10 of 10 on Linux and on Windows, 0 skipped. The libc and libkernel suites run 171 and 291 tests on Linux and 199 and 314 on Windows, with 0 failed.
    • On the runners, main's code doesn't build the new cases, because its stubs take no parameters.
  • Checks on Linux: check_test_registration.py passes, and no comment lines are added.
    • check_conventions.py: one warning, the debt-length check on the pending list in libSceSaveData.native.md. That list is a single line and was already over the limit on main. This change removes three names from it, so it gets shorter, from 6,140 to 5,870 characters.
  • MinGW-w64 -fsyntax-only: Export.cpp and the test show no new diagnostics.
  • b17c475, six commits after oneandonlydean's review:
    • 26a50dd validates the backup's metadata before the check and the restore read it;
    • 637b601 restores through a staged copy and rolls back a failed swap;
    • 1744dbf puts the old backup back when the backup swap fails, and flushes before removing it;
    • 9429d83 keeps save and backup paths inside their roots;
    • eff8683 adds the coverage cases;
    • b17c475 says in the debt entries how a failed restore or a crash leaves the save.
    • On Linux, each fix commit's new cases fail on its parent and pass with it. eff8683's cases already passed on the first head.
    • GitHub, ×10 on both runners (run): Linux 13 run, 13 pass, 0 skip, 0 fail; Windows 13 run, 13 pass, 0 skip, 0 fail. The libc and libkernel suites run 171 and 291 tests on Linux and 199 and 314 on Windows, with 0 failed. They ran with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.
    • The link cases use symlinks on Linux, and on Windows a directory junction and a symbolic link. A verbose run (run, ctest -V, 2 runs) shows no case skipped on either runner, so the junction and reparse-point cases ran on Windows and the symlink cases on Linux.
  • A PS4 console run, by ntamas94 (comment): a PS4 Pro on firmware 10.71 with the system libSceSaveData.sprx (SDK 0x05050001), the shadPS4 structures and a zeroed 1,328-byte param. It agrees with this PR on every row it covers:
    • NOT_FOUND with no save or backup, for another title's id or another directory, and after sceSaveDataDelete, so the delete takes the backup with it;
    • BUSY while the save is mounted, before NOT_FOUND;
    • NOT_MOUNTED from sceSaveDataUmountWithBackup for a mount point that is no longer mounted;
    • the check sees the backup right after the unmount returns, and a restore brings the backed-up title back.
    • Not modelled: the check returns PARAMETER for a param filled with 0xAA, also with a backup present, and 0 for a zeroed or null one. Which bytes it checks wasn't measured, so bb346f7 records it as a debt entry, with the delete and other-title rows, and the code is unchanged. The events weren't measured, and this is a PS4, not a PS5.
  • 22291cd, a merge of main 91a6995 (run). Four of the main changes it brings touch this PR's files: slopgobblercobbler's fix(libSceSaveData.native): validate directory name in sceSaveDataBackup #12644, ionut27001's fix(libSceSaveData.native): answer sceSaveDataCancel before initialization as measured in a PS5 payload #11720, al3w0f205's test(libSceSaveData.native): cover unmounted mount point in icon operations #12220 and xXPBSXx's docs(debt): count how local titles pair sceSaveDataInitialize3 and Terminate #12600.
  • 46c8ff2, a merge of main 8c8b998 (run), which brings SnekPolylepis's feat(libSceSaveData): add provisional abort and clear progress stubs #12699 (sceSaveDataAbort, sceSaveDataClearProgress and the mounted-save count).
  • 842f75d, a merge of main 7fcab08, which brings JJenkx's fix(savedata): keep failed host mounts unpublished #13324 (run).
    • The build: fix(savedata): keep failed host mounts unpublished #13324 gives isMountedDirectory an std::error_code parameter, and this PR's two new calls, in checkBackupData and restoreBackupData, no longer compiled on main. The merge passes one, as main's callers do, so Check and Restore now return SAVE_DATA_ERROR_INTERNAL on a host filesystem error instead of throwing, consistent with fix(savedata): keep failed host mounts unpublished #13324.
    • The test: CheckBackups now writes param.json at the resolved /app0 path, since fix(savedata): keep failed host mounts unpublished #13324's test fixes the guest root in a subdirectory first.
    • This PR's own diff changes only at those two calls and those three test lines. There was no textual conflict.
    • ×10: ^guest_savedata_ and ^savedata_, guest_savedata_mount_status among them.
      • Linux: 13 run, 13 pass, 0 skip, 0 fail.
      • Windows: 14 run, 14 pass, 0 skip, 0 fail.
      • The libc and libkernel suites run 199 and 307 tests on Linux and 226 and 329 on Windows, with 0 failed. The suites ran with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.
  • 9b9e850, oneandonlydean's merge of main d8b60b4, which brings DealPotato's feat(savedata): add sceSaveDataMount2 #8285 (sceSaveDataMount2) and the host free-space checks in mount3.
    • The conflict: only libSceSaveData.native/Export.cpp's include block, where feat(savedata): add sceSaveDataMount2 #8285 adds <string_view> beside this PR's includes. Both are kept.
    • mount3, which both sides change, merged on its own and composes: main reads the free-space mode first and refuses a new save the host volume can't hold, and this PR's parse_dir_name and the slot's user_id stay. feat(savedata): add sceSaveDataMount2 #8285's mount2 passes user_id through to it.
    • ×10 on the same tree (run): the eight guest_savedata_* tests, main's and this PR's. Linux: 8 run, 8 pass, 0 skip, 0 fail. Windows: 8 run, 8 pass, 0 skip, 0 fail. The libc and libkernel suites run 208 and 309 tests on Linux and 235 and 330 on Windows, with 0 failed. The suites ran with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.
  • 23493d9, oneandonlydean's merge of main 24238c4. The one conflict was the pending-declarations list in docs/dev/debt/libSceSaveData.native.md: MarcZX10's feat(libSceSaveData): declare sceSaveDataGetProgress with its known signature #10674 moves sceSaveDataGetProgress out of it into its own entry, and the merge keeps that with this PR's removal of its three calls. This PR's diff over main is unchanged. On GitHub's runners (run, on 5a7a8f3, a merge of main 3c3b3cd with the same resolution; main's later commits change only a cmake line and two debt files outside this PR), the ^guest_save tests with guest_savedata_mount_status ran 10 times with no failure. Linux: 8 run, 8 pass, 0 skip, 0 fail. Windows: 8 run, 8 pass, 0 skip, 0 fail. Suites: libc and libkernel pass on Linux (214 and 316) and on Windows (241 and 337). The suites ran with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.
  • 02af078, oneandonlydean's merge of main b28bb77. The one conflicting file was docs/dev/debt/libSceSaveData.native.md. The merge keeps main's sceSaveDataGetProgress entry, which xecedemon's docs(libSceSaveData): survey the pending exports across citable reimplementations #15077 extends, and the unterminated-mount-point sentence that slopgobblercobbler's fix(libSceSaveData.native): validate mount point termination in mount operations #12853 adds to the null-pointer entry, with this PR's removal of its three calls and its new entries. fix(libSceSaveData.native): validate mount point termination in mount operations #12853 and slopgobblercobbler's test(libSceSaveData.native): cover offset boundaries, null buffers, and uninitialized access in save data memory #12846 change libSceSaveData.native/Export.cpp too, and those merged without conflicts.
    • This PR's changed lines over main are identical to those in 3547c39, our merge of main f06d7fd, which ran ×10 on GitHub's runners (run): the ^guest_save tests with guest_savedata_mount_status. Linux: 8 run, 8 pass, 0 skip, 0 fail. Windows: 8 run, 8 pass, 0 skip, 0 fail. Suites: libc and libkernel pass on Linux (217 and 318) and on Windows (244 and 339). The suites ran with APS5_SKIP_TIMING_TESTS=1, which relaxes precise_wait's median bounds on GitHub's hosted runners.
    • On 02af078 itself, feat(libSceSaveData): deliver backup completions to a registered event callback #13551, which is stacked on this PR, ran its nine guest_savedata_* tests 10 times with no failure on both runners (run).

Checklist

…taRestoreBackupData and sceSaveDataUmountWithBackup
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Progress report (b28bb77 → 02af078)

📈 System libraries: 26.76% (+0.01%, +3 implemented functions)

✅ 3 implemented
Library Name
libSceSaveData.native sceSaveDataCheckBackupData
libSceSaveData.native sceSaveDataRestoreBackupData
libSceSaveData.native sceSaveDataUmountWithBackup

Changed libraries (implemented exports)

Library Before After Change
libSceSaveData.native 45/119 (37.82%) 48/119 (40.34%) +2.52%

@oneandonlydean oneandonlydean self-assigned this Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 14908 docs/dev/debt/libSceSaveData.native.md L25
PR 15097 docs/dev/debt/libSceSaveData.native.md L24-25

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 8630 core/libs/prx/libSceSaveData.native/Export.cpp
core/libs/tests/GuestSaveDataMountStatus.cpp
docs/dev/debt/libSceSaveData.native.md
PR 8649 core/libs/SceTypes.hpp
PR 12625 core/libs/prx/libSceSaveData.native/Export.cpp
core/libs/prx/libSceSaveData/include/Api.hpp
docs/dev/debt/libSceSaveData.native.md
PR 13486 core/libs/prx/libSceSaveData.native/Export.cpp
core/libs/prx/libSceSaveData/include/Api.hpp
core/libs/tests/GuestSaveDataMountStatus.cpp
docs/dev/debt/libSceSaveData.native.md
PR 14150 core/libs/prx/libSceSaveData.native/Export.cpp
core/libs/prx/libSceSaveData/include/Api.hpp
core/libs/tests/GuestSaveDataMountStatus.cpp
docs/dev/debt/libSceSaveData.native.md
PR 14212 docs/dev/debt/libSceSaveData.native.md
PR 15042 core/libs/prx/libSceSaveData.native/Export.cpp
core/libs/tests/GuestSaveDataMountStatus.cpp

@EagleBearerYS
EagleBearerYS marked this pull request as ready for review October 29, 2026 07:52
@JJenkx

JJenkx commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Windows run of this head (5fed277cf2), merged onto main edf6804cf5 (clean merge, local only). Windows 11 Enterprise 10.0.22621, Ryzen 9 5950X, WinLibs GCC 15.2.0 UCRT r7, Ninja, Release, BUILD_TESTING=ON, no Vulkan driver visible.

  • This PR: guest_savedata_mount_status passes 3 of 3 (ctest --repeat until-fail:3).
  • Negative control: not possible. With this PR's source files (core/libs/SceTypes.hpp, core/libs/prx/libSceSaveData.native/Export.cpp, core/libs/prx/libSceSaveData.native/SaveData.hpp, core/libs/prx/libSceSaveData/include/Api.hpp) put back to main's version, the build fails against the rest of the PR, so there is no source-only control.

AI-assisted: yes (Claude Code).

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@EagleBearerYS The three functions follow shadPS4's binding closely and the struct layouts match it field for field. Because they write the player's save on disk, I went through the failure paths. Two independent reviews looked at this; the points below are from both. Five things I would like changed, and some tests to match:

  1. restoreSave removes everything in the live save first and then copies the backup in. If a copy fails (full disk, I/O error, a backup with no .param file), the call returns SAVE_DATA_ERROR_INTERNAL with the live save half-restored and nothing to roll back to, and restoreBackupData never checks that the backup is readable before it deletes the live data. backupSave already builds in a staging directory and swaps; could the restore do the same? For example copy the backup (and its .param and blocks file) next to the save, check that it is complete, then rename the live save aside, rename the staged copy into place and remove the old one, putting the old one back if a rename fails. At minimum, validate the backup (.param readable and the right size, files copyable) before the first delete. shadPS4's Restore also deletes first, but a save is the one thing the emulator must not lose.

  2. backupSave does rename(entry, old), then rename(staged, entry), then removes old. If the second rename throws, entry no longer exists and nothing puts old back, and the next backup starts with RemoveTree(old). Please restore old into entry on failure. Also, nothing is flushed to disk before the old backup is removed, so after a power loss the new backup can be partly empty with the old one already gone; a flush of the staged files and directory before the swap would cover that (or say in the debt entry that the backup is not crash-safe).

  3. The debt entries say the layouts and checks follow shadPS4, but they do not say how a failed restore or a crash leaves the save. Please add that to the entry once 1 and 2 are settled.

  4. Validate backup metadata even when the caller doesn't ask for param. An empty backup directory currently passes sceSaveDataCheckBackupData, wrong-sized param data throws out of the guest API instead of returning an error, and Restore can install truncated metadata. Please return a consistent guest error before anything in the live save changes.

  5. Contain symlinks / reparse points. The directory-name checks are lexical, so they don't stop a link inside the store: e.g. a symlinked _sd/kept makes restore enumerate and delete children outside the save store. Please confine every save/backup/staging/old root and every copied entry (don't follow links, or check the resolved path stays under the root).

Tests I could not find in CheckBackups, none of which needs a console: a corrupt backup (missing .param, wrong size) for the check (SAVE_DATA_ERROR_INTERNAL) and the restore (and that the live save is left as it was); a second sceSaveDataUmountWithBackup replacing the first backup; a failed copy giving event error SAVE_DATA_ERROR_INTERNAL while the call returns 0; another title's titleId giving NOT_FOUND; .. and a/b as directory names giving PARAMETER for the three new calls; and the restore of a missing save directory giving INTERNAL. Also: an empty backup directory and wrong-sized param data (point 4), a rename failure at each step of the backup swap, repeated backup rotation, an outside-sentinel symlink fixture (point 5), and static_assert size/offset checks for the two new argument blocks (the PS4-source / PS5-unverified note can stay).

Checked and fine: directory names are validated by one shared helper (no /, \, :, . or ..), the backup roots are fixed constants beside _sd, an unmounted save is backed up after the mount is removed, and a failed backup still unmounts and returns 0 like shadPS4's backup thread.

The existing merge order after #10674 still applies.

…e read it

sceSaveDataCheckBackupData and sceSaveDataRestoreBackupData now read a
backup through one check that runs before anything in the live save
changes. A backup directory without a save inside, a missing .param, a
.param that is not exactly one SaveDataParam, or a blocks entry that is
not a file returns SAVE_DATA_ERROR_INTERNAL, whether or not the caller
asked for param. Before, the check threw on a wrong-sized .param, and
the restore removed the live save's files before finding the .param
missing.
…back a failed swap

sceSaveDataRestoreBackupData used to remove the live save's files and
copy the backup over them, so a copy that failed half way left neither.
It now copies the backup, its .param and its blocks file to
_sd_restore_tmp/<dir>, checks that the copy has every entry at its size,
and flushes it. Then it moves the live save, .param and blocks file to
_sd_restore_old/<dir>, moves the staged copy into place and removes the
old one. A failed move puts back everything already moved, so the live
save stays as it was. The staging roots sit next to _sd, not in it,
where sceSaveDataDirNameSearch would list them.

SaveDataSetFileStepHook_nid_no_patch sets a function that is called
before each copy and rename of the backup and restore paths with a step
name and the path written; when it returns false that step fails. It
does nothing unless a test sets it.
…ls, and flush before removing it

backupSave moved the previous backup to _sd_backup_old and then renamed
the new copy into place; when that rename failed, the save had no
backup until the next one. A failed rename now moves the previous
backup back into _sd_backup and removes the staged copy. Every staged
file is flushed before the swap (FlushFileBuffers on Windows, fsync on
POSIX, as libc does), and on POSIX the directories on both sides of
each rename are flushed too, all before the previous backup is removed.
Before any copy or delete, the backup and the restore check the roots
they use (_sd, _sd_backup and their staging and old directories) and
every entry under them for the save: each must be a plain directory or
file, not a symbolic link, or on Windows a reparse point such as a
junction, and must stay under its root after weakly_canonical. Anything
else makes the restore return SAVE_DATA_ERROR_INTERNAL, and the backup
report it in its event, with nothing changed. sceSaveDataCheckBackupData
checks the backup the same way. The copies walk the tree themselves and
refuse a link instead of following it.
Checks behaviour 5fed277 already has: a second
sceSaveDataUmountWithBackup replaces the first backup; a backup copy
that fails still unmounts and returns 0, with SAVE_DATA_ERROR_INTERNAL
in its event and the previous backup kept; another title's titleId is
SAVE_DATA_ERROR_NOT_FOUND for the check and the restore; ".." and "a/b"
are SAVE_DATA_ERROR_PARAMETER for the check, the restore and the mount a
backup needs, and as mount points sceSaveDataUmountWithBackup reports
them SAVE_DATA_ERROR_NOT_MOUNTED, as it does for any unknown mount
point. The sizes and field offsets of SaveDataCheckBackupData and
SaveDataRestoreBackupData are checked with static_assert.
…save

Three entries in the libSceSaveData.native debt file: a failed restore
leaves the live save as it was, and where a crash during a restore or a
backup leaves the save and its backup, since neither is recovered at the
next start; and that on Windows directory entries cannot be flushed, so
crash safety there rests on NTFS journaling and is not shown.
@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@oneandonlydean thanks for going through the failure paths. All five points are addressed in follow-up commits on this PR, now at b17c475, with the tests you listed.

  1. The restore goes through a staged copy (637b601). The backup, its .param and its blocks file are copied to _sd_restore_tmp/<dir name>, checked complete (every entry present at its size) and flushed before anything in the live save moves. Then the live save moves to _sd_restore_old/<dir name>, the copy moves into place, and the old save is removed. A failed move puts back what was already moved. The staging directories sit next to _sd, not inside it, so sceSaveDataDirNameSearch never lists them. A test hook, SaveDataSetFileStepHook_nid_no_patch, fails a chosen copy or rename. The tests check that each failing step returns SAVE_DATA_ERROR_INTERNAL and leaves the save byte for byte as it was.

  2. The backup swap rolls back and flushes (1744dbf). If moving the staged backup into place fails, the old backup goes back and the staged copy is removed. Every staged file is flushed before the swap (FlushFileBuffers on Windows, fsync on POSIX, as libc's flush does). On POSIX the directories on both sides of each rename are also flushed after it. All of this happens before the old backup is removed. The tests fail each step of the swap and check that the previous backup is kept and still restores. They also check that three backups in a row rotate.

  3. The debt entries (b17c475) cover three things:

    • a failed restore leaves the save as it was;
    • what a crash during a restore or a backup leaves, and where, since neither is recovered at the next start;
    • on Windows, directory entries can't be flushed, so crash safety there rests on NTFS journaling and isn't shown.
  4. The metadata is validated first (26a50dd). One check runs before the check or the restore reads anything, and before the live save changes. Each of these gives SAVE_DATA_ERROR_INTERNAL, with or without param:

    • a backup directory without save/ or save.param;
    • a save.param that isn't exactly 1,328 bytes;
    • a save.blocks that isn't a file.

    The check no longer throws on a wrong-sized .param, and fills param only on success.

  5. Paths stay inside their roots (9429d83). Before copying or deleting, each call checks the roots it uses and every entry under the save and the backup. A symlink, a Windows reparse point (FILE_ATTRIBUTE_REPARSE_POINT) or anything that isn't a plain file or directory gives SAVE_DATA_ERROR_INTERNAL with nothing changed. Each entry's resolved path must also lie under its root's. The copy walk checks each entry itself and never follows a link.

Tests. eff8683 adds the coverage cases; the others come with their fixes.

  • A corrupt backup (missing .param, short .param, empty directory), for the check and the restore, with the live save unchanged.
  • The backup calls' outcomes:
    • a second sceSaveDataUmountWithBackup replaces the first;
    • a failed copy gives the event error SAVE_DATA_ERROR_INTERNAL while the call returns 0;
    • another title's titleId gives NOT_FOUND;
    • .. and a/b give PARAMETER for the check and the restore;
    • restoring a missing save gives INTERNAL.
  • Failures and rotation: a failure at each step of the backup swap and of the restore, and backup rotation.
  • Links that point outside the store: a linked save directory, a linked file in a save, and linked _sd_backup_old and _sd_restore_old roots, each with an outside sentinel that must survive.
  • static_assert size and offset checks for the two argument blocks.

sceSaveDataUmountWithBackup takes a mount point rather than a directory name. So for it, .. and a/b are checked as names that can't be mounted (PARAMETER from sceSaveDataMount3) and as unknown mount points (NOT_MOUNTED).

On Linux, each fix commit's new cases fail on its parent and pass with it. eff8683's cases already pass on this PR's original head, so that commit adds coverage only.

Runs on GitHub's runners at b17c475:

  • ×10 (run), the ^guest_savedata|^savedata tests.
    • Linux: 13 run, 13 pass, 0 skip, 0 fail. Windows: 13 run, 13 pass, 0 skip, 0 fail.
    • The libc and libkernel suites, run with APS5_SKIP_TIMING_TESTS=1, run 171 and 291 tests on Linux and 199 and 314 on Windows, with 0 failed.
  • The link cases make real links: symlinks on Linux; on Windows, a directory junction for each linked directory and a symbolic link for the linked file. A case prints a "skipped" line when its link can't be created, and ctest doesn't show a passing test's output. So a verbose run of guest_savedata_mount_status (run, ctest -V, 2 runs) checks for it: it passed with no "skipped" line on either runner. The junction and reparse-point cases ran on Windows only, and the symlink cases on Linux.

Not covered: a real crash or power loss during a backup or restore (the debt entries say what it would leave). Nothing here is measured on a console, and the existing PS4-source note stays. Could you take another look?

@ntamas94

Copy link
Copy Markdown
Contributor

@EagleBearerYS A console run of the three exports: PS4 Pro, firmware 10.71, the system libSceSaveData.sprx from a homebrew probe (SDK version 0x05050001), on its own save SURVEYBKP, with the shadPS4 structures this PR uses; the check's param is a zeroed 1328-byte buffer (see the last point):

sceSaveDataDelete[leftover] ret=0x00000000
CheckBackupData[no-save] ret=0x809f0008 param.title="(untouched)"
RestoreBackupData[no-save] ret=0x809f0008
Mount2[create] ret=0x00000000 point=/savedata0 status=0
SetParam[title BACKUP-ONE] ret=0x00000000
CheckBackupData[mounted-no-backup] ret=0x809f0003 param.title="(untouched)"
RestoreBackupData[mounted-no-backup] ret=0x809f0003
UmountWithBackup ret=0x00000000
CheckBackupData[after-backup try 0] ret=0x00000000 param.title="BACKUP-ONE"
CheckBackupData[other-title] ret=0x809f0008 param.title="(untouched)"
CheckBackupData[other-dir] ret=0x809f0008 param.title="(untouched)"
UmountWithBackup[again-not-mounted] ret=0x809f0004
Mount2[rdwr] ret=0x00000000 point=/savedata0 status=0
SetParam[title CHANGED-TWO] ret=0x00000000
CheckBackupData[mounted-with-backup] ret=0x809f0003 param.title="(untouched)"
RestoreBackupData[mounted-with-backup] ret=0x809f0003
Umount ret=0x00000000
RestoreBackupData ret=0x00000000
Mount2[rdonly] ret=0x00000000 point=/savedata0 status=0
GetParam[title after restore] ret=0x00000000 title="BACKUP-ONE"
Umount ret=0x00000000
sceSaveDataDelete ret=0x00000000
CheckBackupData[after-delete] ret=0x809f0008 param.title="(untouched)"
RestoreBackupData[after-delete] ret=0x809f0008

So the console agrees with this PR on every row it covers: NOT_FOUND (0x809f0008) without a save or a backup, also for another title's id and after sceSaveDataDelete (so the delete takes the backup with it), BUSY (0x809f0003) while mounted ahead of NOT_FOUND, NOT_MOUNTED (0x809f0004) from sceSaveDataUmountWithBackup for a mount point that is no longer mounted, a backup the check sees right after the unmount returns, and a restore that brings the backed-up title back.

One thing the PR does not model: sceSaveDataCheckBackupData returns PARAMETER (0x809f0000) when the param buffer it is given is not zeroed. With the buffer filled with 0xAA every call returned 0x809f0000, also with a backup present; zeroed (at any of the offsets tried) it returned 0 and wrote the title; with param null it returned 0. Which bytes it checks I have not narrowed down. The events from sceSaveDataGetEventResult were not measured.

AI-assisted: yes (Claude Code)

ntamas94 ran sceSaveDataCheckBackupData, sceSaveDataRestoreBackupData
and sceSaveDataUmountWithBackup on a PS4 Pro with firmware 10.71's
system libSceSaveData.sprx. The console agrees with this library on
every case it covers. Two of its rows go in the debt file: another
title's titleId returns NOT_FOUND, and sceSaveDataDelete drops the
save's backup as this library does, which the entry called unknown.

The run also shows the check returning PARAMETER for a param buffer
filled with 0xAA, and 0 for a zeroed or null one. Which bytes the
console checks was not measured, so the check is not changed; a new
entry states the measured cases and that the byte set is unknown.
@EagleBearerYS

Copy link
Copy Markdown
Contributor Author

@ntamas94 thank you, this is the first console data for these three calls. Every row you covered matches what the PR does. Two of them settle points the debt file had left open: another title's titleId returns NOT_FOUND, and sceSaveDataDelete takes the backup with the save, which the entry called unknown. bb346f7 records both in libSceSaveData.native.md, naming the PS4 Pro, firmware 10.71 and the system libSceSaveData.sprx (SDK 0x05050001), and the description's Tested section lists your run.

The non-zeroed param finding goes into the same file as a debt entry, and isn't in the code yet. Your run shows a buffer filled with 0xAA giving PARAMETER on every call, and a zeroed or null one giving 0. It doesn't show which bytes the check looks at. Any rule here, such as any non-zero byte or only the reserved tail, would refuse or accept buffers nobody has run. So the check still fills param whatever it held, and the entry says so. If you ever narrow down the bytes, we'll take it into the code.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 30, 2026
# Conflicts:
#	core/libs/tests/GuestSaveDataMountStatus.cpp
#	docs/dev/debt/libSceSaveData.native.md
@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed and removed needs rebase Conflicts with main, rebase needed labels Oct 30, 2026
# Conflicts:
#	core/libs/tests/GuestSaveDataMountStatus.cpp
#	docs/dev/debt/libSceSaveData.native.md
JJenkx's boykopovar#13324 gives isMountedDirectory an std::error_code parameter.
The merge passes one in checkBackupData and restoreBackupData and
returns SAVE_DATA_ERROR_INTERNAL on a host error, as main's callers do.

GuestSaveDataMountStatus.cpp: CheckBackups writes param.json where
ResolvePath_nid_no_patch puts /app0/sce_sys/param.json, not under the
working directory. boykopovar#13324's CheckHostMountFailures now resolves the
first guest path from its mount-host-failures subdirectory, so /app0
maps there, and checkBackupData didn't find the title's param.json.
# Conflicts:
#	core/libs/prx/libSceSaveData.native/Export.cpp
# Conflicts:
#	docs/dev/debt/libSceSaveData.native.md

The pending declarations line keeps main's sceSaveDataGetProgress split and drops the three names this branch implements (sceSaveDataCheckBackupData, sceSaveDataRestoreBackupData, sceSaveDataUmountWithBackup).
@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed and removed needs rebase Conflicts with main, rebase needed labels Nov 2, 2026
# Conflicts:
#	docs/dev/debt/libSceSaveData.native.md
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Nov 2, 2026
@oneandonlydean oneandonlydean closed this pull request by merging all changes into boykopovar:main in ea57a10 Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants