Skip to content

Save data: export and import one game's save as a file - #642

Open
oliverames wants to merge 7 commits into
seedds:mainfrom
oliverames:feat-save-data
Open

oliverames wants to merge 7 commits into
seedds:mainfrom
oliverames:feat-save-data

Conversation

@oliverames

@oliverames oliverames commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What and why

A save can only leave the phone by copying Documents/save in the Files app, and a reinstall that lands in a new data folder loses it unless the player copied it first. Every other emulator and Switch save tool lets a player carry one file; nxnative had no way to.

The launchers now have a Save data section: Export Save Data… writes the game's last committed save to one .dmsave file, and Import Save Data… puts one back. On the iPhone the file goes to Documents/Save Data (the Files app, under the game's name) and opens the share sheet, so it can go to Files, AirDrop or another device; on the Mac both use the normal save and open panels. Both call dm/host/save_archive.c, so a save moves between a Mac app and an iPhone app of the same game.

  • dm_save_export packs $DM_SAVE and, when the game has one, <$DM_SAVE>_device. It packs the committed tree only: Commit is what the game asks to persist, and the working directory beside it is what the next mount discards.
  • dm_save_import validates the whole archive into <save>.import first, then renames it in, keeping the replaced tree as <save>.pre-import and clearing a leftover .work/.new. A failure at any step puts every moved tree back; nothing is written outside the save.
  • The format (documented at the top of save_archive.c): a 32-byte header with a magic, version, the game's base title ID and the entry count, then entries of kind, guest name, size, bytes and CRC-32, sorted and unique. Not a zip, so no compression library or deflate implementation is needed, and the same save always produces the same bytes.
  • Names are the guest's, not the host's: an entry whose physical name is a .dm-case-N alias carries hle_fs.c's org.d2rmobile.guest-name xattr into the archive, and import rebuilds the alias the same way, so a save written on a case-sensitive volume reads back on APFS.
  • Both directions refuse while a save is mounted (dm_fs_save_in_use, a new strong function in hle_fs.c with a weak default for the Mac launcher, which links the archive without the host). The launcher is the place that imports, because the game's mount owns the working directory once it runs.
  • The archive records the base title ID, and another game's file is refused. build_ipa.sh now exports DM_TITLE_ID into the iPhone app's DMTitleID (pack_app.py already wrote it on the Mac).
  • The .dmsave type is declared in both Info.plists so the pickers offer it, and Swift reaches the C through a bridging header on iOS and -import-objc-header on the Mac.

This is 1,048 lines including the 62-check test and both front ends, just over CONTRIBUTING's ~1,000-line line. The commits are separable (host archive; launchers and wiring) if you would rather take it in that order.

The branch is rebased on current main, so it no longer has a merge commit, and the overlap with #617 (cloud saves, now on main) is resolved here: both save sources are linked into the Mac launcher (launcher.o, save_sync.o, save_archive.o), hle_fs.c keeps dm_fs_clone_committed beside dm_fs_save_in_use, and the bridging header imports CloudSaves.h and save_archive.h. The full host test suite passes on that base, including t_save_archive, t_fs_case and t_save_sync, and so do test_nsp_mac_app and test_launcher_storage (45 tests).

Checked

  • make -C dm/host GAME=010015100B514000 builds, and make -C dm/host/tests GAME=010015100B514000 run passes in full, including the new t_save_archive (62 checks). It packs a tree with nested and empty folders, a zero-byte file, a non-ASCII name and a .dm-case-1 alias; exports twice to identical bytes; restores it and exports again to identical bytes; then checks the replacement and its .pre-import backup, cleared .work/.new, a damaged checksum, a cut-short file, trailing bytes, an unknown version, .., a trailing-slash root, an entry before its folder, unsorted and duplicate entries, another game's title, an unknown title, device save data, an empty save and a mounted save. The same test also passes under ASan and UBSan.
  • GAME=010015100B514000 python3 dm/ios/tools/compile_check.py: 181 files, 0 failed.
  • dm/ios/tests: all six test modules pass, including test_launcher_storage.py, which now compiles SaveData.swift and save_archive.c with the real model and window.
  • python3 -m unittest dm.tools.tests.test_nsp_mac_app: 44 tests pass, with pack_app.py's reuse key covering the archive sources and SaveData.swift.
  • dm.tools.tests.test_docs, test_env_switches (no new switch) and test_player_env pass.
  • The Swift sources typecheck against the iOS 26 and macOS SDKs.
  • The only host change on a game's path is one new function that nothing in the host calls (dm_fs_save_in_use, for the launchers), so no game can behave differently and no scripts/regress.py check --games run was made.
  • No new environment switch: the launchers set only the existing DM_SAVE before the Mac window now, as main.m already did on iOS.
  • Not checked: no IPA or Mac app was built, and no game was launched. Nothing was installed on a device. The real-file behaviour — a large save exported while the launcher is up, the share sheet and file picker on iOS, the panels on the Mac, and an imported save loading in a game — is untested on hardware. That is the first thing to run before this merges.

Docs

  • No game files, keys or data extracted from a game in the change or the attachments
  • The finding is in its game note or its docs/reference/<subsystem>.md entry, and python3 dm/tools/docs_symptoms.py was run (the symptom index)

This is a feature, not a finding: the player's side is a new Save data section in docs/playing.md, and the format is described at the top of dm/host/save_archive.c.

@oliverames

Copy link
Copy Markdown
Contributor Author

The ruff/pyright job fails on a finding this PR does not touch: dm/gfx/tools/dmsh_build.py:629,634,637 (reportGeneralTypeIssues: "None" is not iterable, baseline lists 2, the code has 3). A clean origin/main checkout at 880b45d runs the same pinned check to the same result (393 findings, 392 in the baseline, 1 new), so the base branch is red there independently of this change. The PR's own Python changes add no ruff or pyright finding: with the pinned tools (ruff==0.17.2, pyright==1.1.414) the tree reports 392 findings, all in the baseline.

@oliverames

Copy link
Copy Markdown
Contributor Author

The two red jobs noted above are fixed on main now, so this branch is rebased onto 9474a63 and no longer depends on them. The rebase also resolves the overlap with #617 (cloud saves, landed after this branch was opened): the Mac launcher links launcher.o, save_sync.o and save_archive.o; hle_fs.c keeps dm_fs_clone_committed next to dm_fs_save_in_use; the iOS bridging header imports CloudSaves.h and save_archive.h; and the tests Makefile keeps t_save_sync beside t_save_archive. Checked on that base: the full host suite passes (make -C dm/host/tests GAME=010015100B514000 run, including t_save_archive, t_fs_case, t_save_sync), and test_nsp_mac_app with test_launcher_storage pass (45 tests).

seedds and others added 7 commits October 31, 2026 08:18
A save could only leave the device by copying Documents/save in the Files app, and a reinstall that lands in a new data folder lost it unless the player had copied it first. dm_save_export packs the committed saves ($DM_SAVE and, when the game has one, <$DM_SAVE>_device) into one file; dm_save_import validates one whole and swaps it in, keeping the replaced tree as <save>.pre-import.

The format is a 32-byte header and one entry per file or folder (kind, guest name, size, bytes, CRC-32), sorted and reproducible byte for byte. Names are the guest's, so a case-collision alias (.dm-case-N with hle_fs.c's xattr) survives a trip through a case-sensitive volume. An import writes nothing until the whole archive has been checked: a short file, a bad checksum, a '..' entry, an unknown version or another game's archive is refused and leaves the save as it was. Both directions refuse while a save is mounted, because the mount holds the working directory the transaction swaps.

t_save_archive (62 checks) packs a tree with nested and empty folders, a zero-byte file, a non-ASCII name and a case alias; exports it twice to the same bytes; restores it and exports again to the same bytes; and checks the replacement and its backup, the cleared .work/.new, a damaged checksum, a cut-short file, trailing bytes, an unknown version, '..', unsorted and duplicate entries, a missing parent folder, another game's title, device save data and an empty save.
The iPhone settings screen and the Mac settings window gain Export Save Data… and Import Save Data…. The iPhone writes '<game> <date>.dmsave' to Documents/Save Data, visible in the Files app, and opens the share sheet so the file can go to Files, AirDrop or another device; the Mac uses NSSavePanel and NSOpenPanel. Both call the host's archive, so a save moves between the two apps without the Files app's folder copy, and the work runs off the main thread on the iPhone, where a save can be hundreds of megabytes.

The archive's type (org.nxnative.save-archive, extension dmsave) is declared in each app's Info.plist, so the file pickers offer it, and Swift reaches the C through dm/ios/Sources/DmIOS-Bridging-Header.h (iOS, SWIFT_OBJC_BRIDGING_HEADER) and swiftc's -import-objc-header (Mac). The launcher must be the place that imports, because the game's mount owns the working directory once it runs. build_ipa.sh exports the game's base title ID as DMTitleID (pack_app.py already writes it on the Mac), which the archive records so another game's file is refused.

docs/playing.md has the player's side: where the export goes, what import keeps, and that the file is nxnative's own format, not a Switch or other emulator save. The Mac pack reuse key now covers save_archive.c/.h and SaveData.swift, and the launcher tests compile SaveData.swift with the C file.
The comment read as if this definition were the weak one; the Mac launcher's is (save_archive.c), and this one is the strong answer the host links.
The smoke build sets every project.yml placeholder it needs; DMTitleID is new (SaveData.swift), and without DM_TITLE_ID xcodegen leaves the app's title ID empty. No finding changes.
The file importer hands back a security-scoped URL that may point at a temporary copy; the import runs in a detached task so a large save does not freeze the launcher, and the scope must stay open until that task is done. SaveData.importArchive still balances its own start/stop inside the task.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants