Add save sync with the RomM server - #131
Conversation
Implements the API-mode save sync flow from the RomM server PRs (#3137, #3479): register this machine as a RomM device once, POST /sync/negotiate to let the server decide upload/download/conflict/no_op per save, execute the returned operations, then complete the sync session. Conflicts are resolved most-recent-wins. Scope is RetroArch battery saves (.srm): the local save path is derived from retroarch.cfg (savefile_directory plus the sort_savefiles* options), with a recursive search fallback for sorted layouts. Saves are pulled down before launch (OnGameStarting) and pushed back after play (OnGameStopped), plus a "Sync saves with RomM" game-menu action. Gated behind a new "Enable save sync" setting; the server-assigned device id is persisted. Save content is hashed with MD5 to match the server's comparison. Pure parsing/path/hashing logic is unit-tested.
A plain .srm hashes as MD5 over its raw bytes, which is what SaveFileHash did. Archives do not: the server's _compute_zip_hash MD5s each entry's content, pairs that with the entry name, sorts the pairs by name, joins them as "name:hash" with newlines, and MD5s that string. Raw-byte MD5 over a zip can never agree with it, because zip bytes vary with entry order, compression and timestamps while the content does not. Nothing uploads an archive yet, but every folder-based platform will (PS2 memory cards, Switch, PSP, GameCube), and getting this wrong makes negotiate report a conflict on every sync for saves that are identical on both ends - a quiet failure that is far cheaper to prevent than to diagnose later. FolderAsZipHex computes the same digest straight from a folder without writing a temp archive, so reporting local state during negotiate does not cost a file. Entry names are normalised to '/' because a name derived from a Windows path would otherwise diverge from what every other client computes for the same save. Cross-checked against argosy-launcher's SaveArchiver.calculateZipHash (GPL-3.0, same license) so both clients agree on what "unchanged" means; its published vector is pinned as a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SaveSyncService knew that a save is a RetroArch .srm: the emulator tag was a const, the target was a RetroArchTarget of two file paths, and hashing, upload and download all assumed a single file on disk. Every platform beyond RetroArch breaks at least one of those assumptions. The service now talks to a SaveTarget, which answers the questions negotiate actually asks - does a local save exist, what is its hash, name, time and size, what should be uploaded, and what does applying a download mean - without saying whether that is one file or a directory tree. FileSaveTarget implements the single-file case and uploads in place, matching what argosy-launcher sends for the same platforms so a save round-trips between the two clients untouched. Finding the save is an ISaveHandler, picked from SaveHandlerRegistry by the emulator Playnite launches the game with. RetroArchSaveHandler holds what SaveSyncService used to: locating retroarch.cfg, resolving the path from it, and searching by ROM name when the configured path is empty. No behaviour change for RetroArch. Adding an emulator is now a handler plus one line in the registry, rather than an edit to the sync loop - which is what makes the folder-based platforms possible without the service growing a second shape of everything. An unrecognised emulator resolves to no handler and the game is skipped with a log line, rather than falling through to a path that would overwrite an unrelated save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
|
Do we want to feature flag this PR to get it reviewed and merged with the new hashing? To try and keep a short lived PR vs many dependencies? Im more of a Java dev, but c# is close enough, I could leave comments. |
|
Pushed the handler abstraction, and merged current Then deployed the whole thing and ran it end to end against RomM 5.0.0 and again on 5.1.0, RetroArch + mGBA, one GBA game. Behaviour is identical on both. Writing it up in full since I couldn't find another end-to-end result for this yet. What worksDevice registration, negotiate, upload and session completion all run clean, no errors in The uploaded save is byte-identical to the local file — 65536 bytes, MD5 Path resolution held up in a config that could have broken it: portable RetroArch with Downloads never happen — and the main cause is on our sideI deleted the local I first assumed this was server-side. It mostly isn't. Two separate mechanisms, and the first one masked the second: 1. We upload slot-less saves, and negotiate ignores those entirely
The eight slot-less ones are exactly those written by clients that don't set a slot — this plugin, plus a script of mine. None of them is visible to …even when I report the exact stored state, same hash, size and timestamp. So every save this plugin creates is invisible to the sync layer, the local side is always classified as new, local wins unconditionally, and an empty save silently replaces a good one.
2. A save is never offered back to the device that uploaded itSeparate from the above, and it survives the slot fix. In the same experiment, the slotted save is offered as a That's probably deliberate — the origin device is assumed to still have the file. It only bites when the local file is gone, which is exactly the "restore my save on a fresh machine" case. The negotiate payload can only carry saves the client has, so there's no way for a client to say "I'm the origin and I no longer have this." Not sure whether that's worth changing, or whether clients are simply expected to handle that case outside negotiate — happy to open something on Smaller observationEvery launch creates a session carrying operations for the whole library (27 in mine), and the client applies only those matching the current rom via the The abstraction, now in
An emulator no handler recognises now resolves to nothing and the game is skipped with a log line, rather than falling through to a path that could overwrite an unrelated save. Folder-based platforms are the obvious next step but want |
I'm totally fine with this. |
|
@gantoine — found why downloads never fire, and it's on our side rather than the server's.
So every save this plugin uploads lands on the server correctly but is invisible to the sync layer. Reporting one back comes in as Verified the cause directly: the same upload with The fix looks like one line each on the upload URL and in Full traces and the rest of the end-to-end run are in the full report above. |
Supersedes #112, which @gantoine offered to close so this could be picked up. The commits from that branch are kept as they are rather than squashed, so its authorship stays intact — this builds on it rather than replacing it.
What's here
From #112 — device registration,
POST /sync/negotiate, applying the returned operations, session completion, most-recent-wins conflicts, theretroarch.cfgsave-path resolution, the settings toggle and the game-menu action.New in this PR — archives are hashed the way the server hashes them.
SaveFileHashpreviously MD5'd raw bytes, which is right for a plain.srmand wrong for anything packed. The server's_compute_zip_hashdigests each entry's content, pairs it with the entry name, sorts by name, joins asname:hashwith newlines, and MD5s that string. Raw-byte MD5 over a zip can never agree with it, because zip bytes vary with entry order, compression and timestamps while the content does not.Nothing uploads an archive yet, so this changes no current behaviour — but every folder-based platform will (PS2 memory cards, Switch, PSP, GameCube), and the failure mode is quiet: negotiate would report a conflict on every sync for saves that are byte-identical on both ends. Cheaper to get right before the first folder platform lands than to debug afterwards.
Also adds
FolderAsZipHex/FoldersAsZipHex, which produce the same digest straight from a folder without writing a temp archive — useful for the "report local state" half of negotiate, and for saves whose unit spans several sibling folders. Entry names are normalised to/, because a name derived from a Windows path would otherwise diverge from what every other client computes for the same save.Cross-checked against
argosy-launcher'sSaveArchiver.calculateZipHash(GPL-3.0, same licence as this repo) so both clients agree on what "unchanged" means. Its published vector is pinned as a test:RomM.Testsneeded aSharpCompressreference to build a zip in the test; pinned to 0.36.0 to match the main project.What's next on this branch
Per @ScottamDendar's point about this being RetroArch-specific, the next step is a handler abstraction so that
Emulator = "retroarch"andRetroArchTargetbecome one implementation among several, following the layoutargosy-launcheruses. Folder platforms then follow — those wantsave_idfrom the API, so they're better off once rommapp/romm#3925 lands than growing their own extraction here.Marking this a draft until the abstraction is in. Fair warning that I'm doing this as a hobby alongside a full-time job, so I can't promise a fixed pace — the work is deliberately split so each piece stands on its own if I run out of time.