Skip to content

Add save sync with the RomM server - #131

Draft
LxKnsy wants to merge 5 commits into
rommapp:mainfrom
LxKnsy:saves/zip-aware-hashing
Draft

Add save sync with the RomM server#131
LxKnsy wants to merge 5 commits into
rommapp:mainfrom
LxKnsy:saves/zip-aware-hashing

Conversation

@LxKnsy

@LxKnsy LxKnsy commented Aug 2, 2026

Copy link
Copy Markdown

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, the retroarch.cfg save-path resolution, the settings toggle and the game-menu action.

New in this PR — archives are hashed the way the server hashes them.

SaveFileHash previously MD5'd raw bytes, which is right for a plain .srm and wrong for anything packed. The server's _compute_zip_hash digests each entry's content, pairs it with the entry name, sorts by name, joins 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, 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's SaveArchiver.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:

a.sav = 00 01 02, b.sav = FF FE  ->  fe72f8d850245659647bd6b5f3577a7a

RomM.Tests needed a SharpCompress reference 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" and RetroArchTarget become one implementation among several, following the layout argosy-launcher uses. Folder platforms then follow — those want save_id from 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.

claude and others added 3 commits June 16, 2026 01:11
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>
@LxKnsy LxKnsy changed the title Saves/zip aware hashing Add save sync with the RomM server Aug 2, 2026
@LxKnsy
LxKnsy marked this pull request as draft August 2, 2026 14:03
LxKnsy and others added 2 commits August 2, 2026 16:09
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>
@LxKnsy

This comment was marked as outdated.

@ScottamDendar

Copy link
Copy Markdown

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.

@LxKnsy

LxKnsy commented Aug 2, 2026

Copy link
Copy Markdown
Author

Pushed the handler abstraction, and merged current main in — the branch was 13 commits behind, which also meant it still declared 0.7.0.

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 works

Device registration, negotiate, upload and session completion all run clean, no errors in extensions.log:

[SaveSync] Registered device '37b0ed7c-…' with RomM.
[SaveSync] Advance Wars: 0 uploaded, 0 downloaded, 0 conflicts, 0 failed.   (launch)
[SaveSync] Advance Wars: 1 uploaded, 0 downloaded, 0 conflicts, 0 failed.   (exit)

The uploaded save is byte-identical to the local file — 65536 bytes, MD5 2e91143b604d7a17fdc802bc11ad39d3 on both sides.

Path resolution held up in a config that could have broken it: portable RetroArch with savefile_directory = ":\saves" and sort_savefiles_enable = true, so mGBA writes to saves\mGBA\<rom>.srm. ResolveSaveFilePath is called with coreName: null and therefore predicts saves\<rom>.srm, but the recursive fallback finds the real file. Worth keeping that fallback.

Downloads never happen — and the main cause is on our side

I deleted the local .srm and relaunched, expecting the server copy back. The game started a fresh save instead, and on exit that empty save was uploaded over the good one.

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

SaveSyncService sends Slot = null in the negotiate payload and posts to api/saves without a slot parameter. Across my library:

saves WITH a slot : 40  ->  27 offered for download
saves WITHOUT one :  8  ->   0 offered, ever, to any device

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 sync/negotiate at all. Reporting one in the payload comes back as:

action: upload
reason: "Save exists on client but not on server"

…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.

POST /api/saves accepts slot as an optional query parameter, so this looks fixable here. Verified directly: uploading a throwaway save with slot=autosave makes it show up as a download operation immediately, where the same upload without a slot never does. Argosy uses autosave for .srm files, so matching that seems like the way to stay compatible — but I'd rather ask than guess: is autosave the intended slot for a battery save, and should the plugin be setting it on both the upload and the negotiate payload? Happy to push that as a follow-up commit here.

2. A save is never offered back to the device that uploaded it

Separate from the above, and it survives the slot fix. In the same experiment, the slotted save is offered as a download to another device but produces no operation at all for the device it came from. device_syncs is [] on every save I've looked at.

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 rommapp/romm if it is.

Smaller observation

Every 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 Where(o => o.RomId == romId) filter — which the comment there explains, and which is clearly the safe choice. CompleteSession then reports counts that don't account for the rest, and the abandoned sessions accumulate server-side. Not harmful in anything I saw, just noting it.

The abstraction, now in

SaveTarget / ISaveHandler / SaveHandlerRegistry, with the RetroArch logic moved out of SaveSyncService into RetroArchSaveHandler. No behaviour change — the run above is with it in place.

SaveSyncService now talks to a SaveTarget that answers what negotiate actually asks (does a local save exist, its hash, name, time and size, what to upload, what applying a download means) without saying whether that is one file or a directory tree. FileSaveTarget covers the single-file case and uploads in place, matching what argosy-launcher sends for the same platforms. Adding an emulator is a handler plus one line in the registry.

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 save_id from the API, so they're better off following rommapp/romm#3925 than growing their own extraction here.

@LxKnsy

LxKnsy commented Aug 2, 2026

Copy link
Copy Markdown
Author

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.

I'm totally fine with this.

@LxKnsy

LxKnsy commented Aug 2, 2026

Copy link
Copy Markdown
Author

@gantoine — found why downloads never fire, and it's on our side rather than the server's.

SaveSyncService uploads with no slot (posts to api/saves without the parameter, and sends Slot = null in the negotiate payload). sync/negotiate ignores slot-less saves completely. Across my library on 5.1.0:

saves WITH a slot : 40  ->  27 offered for download
saves WITHOUT one :  8  ->   0 offered, ever, to any device

So every save this plugin uploads lands on the server correctly but is invisible to the sync layer. Reporting one back comes in as upload / "Save exists on client but not on server" even when the hash, size and timestamp match what's stored — which means local always wins and an empty save can silently replace a good one. That's how I lost a save while testing.

Verified the cause directly: the same upload with slot=autosave shows up as a download operation straight away; without it, never.

The fix looks like one line each on the upload URL and in RomMClientSaveState. Before I push it — is autosave the slot you'd want for a battery save? Argosy uses it for .srm, so it seemed like the value that keeps the two clients interoperable, but you know the server side better than I do.

Full traces and the rest of the end-to-end run are in the full report above.

@gantoine
gantoine self-requested a review August 2, 2026 19:34
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.

4 participants