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.
|
I'll checkout the branch (hopefully tmrw but you never know) to assure it works on another setup |
|
@ScottamDendar @gantoine And how's it going with #3925 |
|
Oh, I haven't had a chance to checkout the branch cuz of life stuffs. I can hope to do that maybe before next week. what do you think about my comment about the interface? We could still merge this and change it in a follow up. |
Per @ScottamDendar on rommapp#131: the interface and its one implementation sat next to the sync service, which reads fine with a single handler and stops doing so at the second. Saves/Handlers now holds the contract, the registry, and everything RetroArch-specific, leaving Saves/ with the service, the hashing and the target abstraction it talks to. Namespace follows the folder, as elsewhere in the repo. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Agreed, and yes, follow-up rather than growing this one. One thing worth pinning down: a handler isn't meant to be one per emulator, it's one per save shape. Most emulators share the same shape, a single file named after the ROM under a configurable path, and those shouldn't need code at all. That's exactly what your settings hook would drive: a handler that takes the path from the emulator mapping and returns a Config can't reach the cases where the save isn't a path: PS2 bundles every memory-card folder starting with the region-prefixed serial and has to leave
So: settings-driven handler next, slotting in as another |
|
Ah, thank you for better explaining that to me. I should take a look at the argosy implementation for more understanding |
|
@LxKnsy I'm out of coutry for a week for work, i'll get back to this next week! |
|
Looks good, probably better than the implementation I came up with for my playnite 11 fork. Will you add some form of screenshot service that can get screenshots to be uploaded with the saves? |
|
It works nicely. When you create a save with RetroArch, the sync happens after youre done playing (or can be initiated manially with the "Sync Saves with Romm" menu button), and is uploaded. I can then load the save in romm, play further with jsEmulator, and make a new save. I use the menu button to sync playnite again, and then can continue my progress from romm. I think this is gtg |
Thanks! Upload side is easy: Capturing one is the catch. Caveat is that it'd be whichever screenshot the user last took with the hotkey, not the moment of saving. For a battery save that's arguably fine, it's a thumbnail rather than a snapshot of the state. Would that be good enough for what you have in mind? If so I'd add it as an optional lookup on Follow-up rather than in here, if that suits. |
The way I do screenshot capture is 5s after the game starts I start a rolling buffer of jpgs that gets generated by PrintWindow from the windows API, at the same time I start a file/folder watcher looking for writes to any of the tracked files/folders. If a save happens then it will hold onto a single "frame" from the rolling buffer from X seconds ago (min 0s - max 60s) (User selectable) and keeps hold of that. When the game exits if the file watcher class has an image it will upload it after the save has be upload via a PUT request as I think the screenshot needs to be the same file name as the save which when you are using slots RomM appends the datetime to so we need to use that name. But I think a screenshot that the user manual takes is perfectly acceptable, especially for low power devices which I'm not sure how well my way of doing it will perform. |
Nice approach, buffering continuously and picking the frame retroactively is about the only way to get one that actually matches the save moment. Hadn't considered a file watcher for the trigger. I'd like to keep screenshots out of this PR though, and treat them as an additional feature afterwards, same as states. Getting saves themselves correct is the part I want reviewed. |
Fair enough. I may try and add it after this PR is done but I haven't properly touched the codebase in a while as my focus is porting this plugin to Playnite 11 |
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.