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. |
Two things that between them made save sync a one-way trip.
sync/negotiate only considers saves that carry a slot. We uploaded
without one, so every save this plugin wrote landed on the server
correctly and was then invisible to the sync layer - not offered to any
device, not matched when reported back. Negotiate answered "Save exists
on client but not on server" even for a byte-identical copy of what it
was storing, so the local side always counted as new and won
unconditionally. An empty save could quietly replace a good one; that is
how I lost one while testing this.
Measured against a server holding both kinds: 40 saves with a slot, 27
of them offered for download; 8 without one, none ever offered, to any
device. The slot-less ones were exactly those written by clients that
omit it. Uploading the same file with slot=autosave makes it show up as
a download operation immediately. Other RomM clients use "autosave" for
a game's live save whatever the platform, and the server keys saves by
(rom_id, slot), so a different value here would split one game's save
into two entries that never reconcile.
The second one only surfaces once downloads happen at all. With
sort_savefiles_enable, RetroArch keeps saves in a folder named after the
running core. We resolved the path without a core name, which is
harmless while a local save exists - the recursive search finds it - but
wrong the moment a download has to create the file: it landed beside the
core folders rather than inside one, where RetroArch never looks. The
game then started fresh on a save that was sitting right there, and that
fresh save went back up on exit.
The core comes from the profile Playnite launches with: built-in
RetroArch profiles are named after it, custom ones carry it in the
libretro argument. Where a matching folder already exists its spelling
wins, since RetroArch's own name for a core ("mGBA") is not always how
Playnite spells it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checked the three sorting options against RetroArch itself rather than against what they sound like, and two of them were modelled wrong. sort_savefiles_by_content_enable keys on the directory the ROM sits in, not on the ROM. Launching a rom from D:\Retrogames\ZZTESTORDNER\ with the option on made RetroArch write to <saves>\ZZTESTORDNER\..., where we predicted <saves>\<rom name>\... . The same run showed the nesting is the other way round from what we built: content sorting wraps the per-core folder, giving <saves>\<rom's folder>\<core>\<rom>.srm, and we appended the core first. savefiles_in_content_dir was not read at all. It overrides savefile_directory rather than filling in for an empty one, so with both set RetroArch writes beside the ROM while we looked in the configured tree - and the recursive fallback searched that same wrong tree, so the save was neither found nor, on a download, written anywhere the emulator reads. Verified the same way: with the option on, the save appeared as <rom folder>\mGBA\<rom>.srm and the configured directory stayed empty. Neither shows up with the defaults, which is presumably why they went unnoticed; both are one checkbox away in RetroArch's own settings. RetroArchConfigLayoutTests pins all six combinations. The existing by-content test asserted the ROM's name and has been corrected to the folder's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Three fixes pushed ✅ round trip works end to end now (5.1.0, RetroArch + mGBA, real save, including the fresh-machine case):
@gantoine Two things from you whenever it suits:
|
possibly? depends how necessary it is to get this working/polished. |
|
Wow you've been churning on this. Sorry I haven't had time to help despite being the one that brought it up in the first place. I hope to have time to help review this tmrw |
@gantoine Not blocking for this PR; saves work without it. But if states are meant to sync at all, the server is the cheaper place to put it, and here's why. Argosy already syncs states, and for 16 emulators, not just RetroArch. It does it entirely over plain That's not a knock on Argosy. State records have none of So: no urgency from my side. Just that doing it in negotiate is one implementation instead of N, and it would let Argosy delete a chunk rather than have Playnite grow its own version of it. |
ScottamDendar
left a comment
There was a problem hiding this comment.
The only thing Im concerned with is the ISaveHandler needing to be implemented per emulator. That feels clunky considering how many different emulators there are out there. Maybe this could be refined in a follow-up since this PR is working, but get the SaveHandling to go along with the "Emulator path mapping" settings in the "Configure Integration" window. So when you configure your emulator paths, you can also set anything necessary to get the saveHandling to work.
There was a problem hiding this comment.
Nitpick (as in take it or leave it): Maybe move the ISaveHandler and the RetroHandler into its own Saves/Handler dir? If we expect multiple handlers to exist at some point.
|
I'll checkout the branch (hopefully tmrw but you never know) to assure it works on another setup |
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. Current
mainis merged in.Save sync works end to end now. It didn't before: uploads landed on the server but were invisible to the sync layer, and downloads never fired.
What's here
From #112 — device registration,
POST /sync/negotiate, applying the returned operations, session completion, most-recent-wins conflicts,retroarch.cfgsave-path resolution, the settings toggle and the game-menu action.Archives are hashed the way the server hashes them
SaveFileHashMD5'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, since 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 the failure mode is quiet — a conflict reported on every sync for saves that are byte-identical. Also adds
FolderAsZipHex/FoldersAsZipHex, producing the same digest straight from a folder without a temp archive. Entry names are normalised to/, because a name derived from a Windows path would otherwise diverge from what every other client computes.Cross-checked against
argosy-launcher'sSaveArchiver.calculateZipHash(GPL-3.0, same licence) and its published vector is pinned as a test:Save location behind a handler abstraction
Per @ScottamDendar's point about this being RetroArch-specific.
SaveSyncServicenow talks to aSaveTargetthat answers what negotiate asks — does a local save exist, its hash, name, slot, time and size, what to upload, what applying a download means — without saying whether that is one file or a directory tree.FileSaveTargetcovers the single-file case and uploads in place, matching whatargosy-launchersends for the same platforms.Where the save lives is an
ISaveHandler, picked fromSaveHandlerRegistryby the emulator Playnite launches the game with.RetroArchSaveHandlerholds whatSaveSyncServiceused to. Adding an emulator is a handler plus one line in the registry. An emulator no handler recognises is skipped with a log line rather than falling through to a path that could overwrite an unrelated save.Saves now carry a slot
sync/negotiateonly considers saves that have one. We uploaded without, so everything this plugin wrote was invisible to the sync layer — never offered to any device, and not matched when reported back. Measured against a server holding both kinds: 40 saves with a slot, 27 offered for download; 8 without, none ever offered to anyone. Same save, same server, only the slot differing:So the local side always counted as new and won unconditionally — an empty save could quietly replace a good one.
autosavematches what the other clients use for a game's live save; since the server keys on(rom_id, slot), a different value would split one game's save into two entries that never reconcile.Downloads land where RetroArch actually reads
With
sort_savefiles_enable, RetroArch keeps saves in a folder named after the running core. The path was resolved without a core name — harmless while a local save exists, because the recursive search finds it, but wrong the moment a download has to create the file. It landed beside the core folders rather than inside one, so the game started fresh on a save that was right there, and that fresh save went back up on exit.The core now comes from the profile Playnite launches with: built-in RetroArch profiles are named after it, custom ones carry it in the libretro argument. Where a matching folder exists its spelling wins, since RetroArch's own name for a core (
mGBA) isn't always how Playnite spells it.The sorting options, checked against RetroArch itself
Two were modelled on what they sound like rather than what they do:
sort_savefiles_by_content_enablekeys on the directory the ROM sits in, not the ROM — and it wraps around the per-core folder, giving<saves>\<rom's folder>\<core>\<rom>.srm. We appended the ROM's name, and the core first.savefiles_in_content_dirwasn't read at all. It overridessavefile_directoryrather than filling in for an empty one, so with both set RetroArch writes beside the ROM while we searched the configured tree — and the recursive fallback searched that same wrong tree.Neither shows up with the defaults; both are one checkbox away.
RetroArchConfigLayoutTestspins all six combinations.Verified end to end
RomM 5.1.0, portable RetroArch + mGBA, a real GBA save:
slot=autosave; exit is a cleanno_opinstead of a redundant re-upload1 downloaded, lands insaves\mGBA\, progress is there in-gamedotnet test RomM.Tests/RomM.Tests.csproj— 153 passing.Not in scope here
Save states.
SyncNegotiatePayloadonly carriessaves, and state records have none ofcontent_hash,slot,origin_device_id,device_syncs— so there's nothing for a client to reconcile against. Happy to do the Playnite side if negotiate grows to cover them.Folder-based platforms (PS2, Switch, PSP, GameCube). The abstraction is there for them, but they want
save_idfrom the API — better once rommapp/romm#3925 lands than growing their own extraction here.A save is never offered back to the device that uploaded it, including with a slot — verified: offered as a
downloadto another device, no operation for its origin. Probably deliberate, since the origin is assumed to still have the file; it only bites when the local file is gone, which is the restore-on-a-fresh-machine case. Not something a client can express through negotiate.Happy to feature-flag this as @ScottamDendar suggested if you'd rather land it in smaller pieces. 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 split so each commit stands on its own.