Skip to content

Add RetroArch save sync with the RomM server - #112

Closed
gantoine wants to merge 2 commits into
mainfrom
claude/brave-feynman-d4ivcn
Closed

Add RetroArch save sync with the RomM server#112
gantoine wants to merge 2 commits into
mainfrom
claude/brave-feynman-d4ivcn

Conversation

@gantoine

Copy link
Copy Markdown
Member

Summary

Implements save synchronization between local games and the RomM server, per the save sync RFC, the server-side #3137, and the behaviour change in #3479.

Uses the server's API sync mode:

  1. Register this machine as a RomM device once (POST /api/devices, client playnite, sync_mode: "api"); the server-assigned device_id is persisted in settings.
  2. POST /api/sync/negotiate — report the local save (rom_id, file name, MD5 content_hash, updated_at, size) and let the server decide per save: upload / download / conflict / no_op.
  3. Execute the returned operations — POST/PUT /api/saves (multipart saveFile), GET /api/saves/{id}/content then POST /api/saves/{id}/downloaded.
  4. POST /api/sync/sessions/{id}/complete.

Conflicts are resolved most-recent-wins (the RFC default), with the decision logged.

Scope & behaviour

  • RetroArch battery saves (.srm): the local save path is derived from retroarch.cfg (savefile_directory plus the sort_savefiles_enable / sort_savefiles_by_content_enable options and the : base-dir token), with a recursive-search fallback for sorted layouts.
  • Saves only — save states aren't part of the server's device-sync flow.
  • When: pull the newest save before launch (OnGameStarting), push after play (OnGameStopped), plus a "Sync saves with RomM" game-menu action.
  • Gated behind a new "Enable save sync" setting (off by default).
  • Save content is hashed with MD5 to match the server's comparison exactly.
  • Failures are logged and surfaced via notifications; they never block a game from launching.

Files

  • New: Saves/SaveSyncService.cs (orchestration), Saves/RetroArchConfig.cs + Saves/SaveFileHash.cs (pure, unit-tested), Models/RomM/Save/* (wire models).
  • Changed: RomM.cs (play hooks + menu item), Settings/Settings.cs + SettingsView.xaml (new toggle, persisted device id).
  • Tests: RetroArchConfigTests, SaveFileHashTests.

Testing

  • Added unit tests for the pure logic (retroarch.cfg parsing, save-path resolution, MD5 hashing).
  • Not built/run locally — the plugin is WPF / .NET Framework 4.6.2 (Windows-only) and this environment has no Windows SDK; relying on CI for the build + test run.

Open questions

  • Device registration needs the client token to carry device/asset scopes — worth documenting.
  • extension.yaml version bump and a README section for the new setting can follow if wanted.

🤖 Draft opened for review.


Generated by Claude Code

claude and others added 2 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.
@ScottamDendar

Copy link
Copy Markdown

Will this only work if you're using Retro Arch, or does the title infer where the type of save came from? I could checkout this branch. It'll just take me a hot second to build and figure it how to install it to my playnite.

How much of this MR is ai generated?

@gantoine

Copy link
Copy Markdown
Member Author

Will this only work if you're using Retro Arch

I haven't had the chance to test it yet so couldn't tell you, focused on the 5.1 release for the next couple days

How much of this MR is ai generated?

All of it, as with all one-shot code 80% of the work comes from pulling it locally and manually fixing it lmao

Comment thread Saves/RetroArchConfig.cs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I dont think this config is needed. I dont understand what the config is suppose to look like, but the default behavior of SaveSyncService with a blank cfg works to upload a save to romm.

Comment thread Saves/SaveSyncService.cs
var target = ResolveRetroArchTarget(game);
if (target == null)
{
outcome.Message = "Save sync currently only supports RetroArch games.";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should make this more generic, and get rid of the RetroArch references. Unless I have missunderstood the intent of this.

@ScottamDendar

Copy link
Copy Markdown

In general the upload (for a basic gba) from my playnite to the romm server works! Still looking into downloads.

@LxKnsy

LxKnsy commented Jul 31, 2026

Copy link
Copy Markdown

I've been working on the same problem from the other side — a prototype Playnite extension that syncs Switch (Eden) and PS2 (PCSX2) saves through /api/saves, with cross-device round-trips against Argosy on an Android handheld. Since @ScottamDendar raised making this more generic, here are two things I ran into that are directly relevant, plus an offer to help.

1. The hash scheme changes once saves become archives

SaveFileHash.Md5Hex (MD5 over raw bytes) is correct for a plain .srm. But folder-based platforms — PS2 memory cards, Switch, PSP, GameCube — have to upload a zip, and the server does not hash zips by their raw bytes. _compute_zip_hash hashes the entries:

for each non-directory entry: md5(content) -> hex
collect (entryName, fileHash) pairs, sort by entryName
join as "name:hash", separated by \n
md5(utf8(joined)) -> hex

Argosy implements exactly this in SaveArchiver.calculateZipHash, and pins it with a test vector specifically to catch drift — entries a.sav = 00 01 02 and b.sav = FF FE must produce fe72f8d850245659647bd6b5f3577a7a.

Raw-byte MD5 over a zip can never match that, since zip bytes vary with entry order and timestamps. The failure mode is quiet and unpleasant: negotiate would report permanent conflicts between PC and Android for saves that are actually identical. Probably worth having the two-mode hash in place before the first folder platform lands, rather than retrofitting it afterwards.

Argosy also has calculateFolderAsZipHash, which produces the same digest directly from a folder without writing a temp zip — useful for the "report local saves" step of negotiate.

2. The archive root is a cross-client contract

Argosy's docs/save-id-to-path.md documents that an archive is only matched to a save when its root entry matches the id under one of several tiers (exact / prefix / contains / a platform-specific "unidentified" root). PS2 archives exist in the wild both card-rooted and game-folder-rooted, and Argosy deliberately accepts both shapes because both are already on people's servers.

So whatever this plugin zips for a given platform has to line up with what Argosy has been uploading, or previously synced saves stop resolving. It's the kind of thing that's cheap to get right up front and expensive to change later.

3. Offer

If it's useful, I'd be glad to contribute the generalization on top of this PR:

  • A handler abstraction (PrepareForUpload / ExtractDownload plus path discovery) so that Emulator = "retroarch" and RetroArchTarget become one implementation among several — mirroring Argosy's data/sync/platform/ layout, where adding a platform is a single registry entry.
  • Folder handlers for PS2, Switch, PSP and GameCube. PS2 and Switch I already have working and cross-device verified, including the sharp edges: PS2 region prefixes (BA / BE / BI, derived from the third character of the serial), serial folder names having to match byte-for-byte including the game suffix, keeping _pcsx2_superblock out of the bundle, and Dolphin needing GCI folders enabled before per-game sync is possible at all.

Happy to do that as separate follow-up PRs, one platform at a time, or in whatever shape fits your plans best.

Two questions

  1. Does the API expose save_id / the platform-native serial? Argosy derives it locally from the ROM via sigil. If the server already knows it, the folder platforms get considerably simpler on the client side.
  2. Is sigil intended to become the cross-client standard? There's no .NET binding and no prebuilt binaries today, and CI here builds pure C#, so my default would be managed extractors that mirror sigil's contract (title_id / save_id / usage tiers) and leave a real binding as a separate question. But I'd rather follow whatever direction you have in mind.

Both repos are GPL-3.0, so porting logic from argosy-launcher is fine license-wise; I'd credit it in the PR description and at the ported sites.

@gantoine

Copy link
Copy Markdown
Member Author

I'd be glad to contribute the generalization on top of this PR

If you want to own this and have a clear vision of how it would work in playnite/interact with the server, then I'll happily close this PR and help you get something merged!

Does the API expose save_id / the platform-native serial?

No but it should soon: rommapp/romm#3925

Is sigil intended to become the cross-client standard?

I think that's a question for @tmgast, this is outside my wheelhouse.

@LxKnsy

LxKnsy commented Jul 31, 2026

Copy link
Copy Markdown

If you want to own this and have a clear vision of how it would work in playnite/interact with the server, then I'll happily close this PR and help you get something merged!

Happy to take a run at it; with one caveat up front: this is a hobby project alongside a full-time job. I can't promise sustained ownership of the feature, and I'd rather say that now than quietly stall on you later.

So I'd suggest not closing this PR yet. Instead I'd work in small self-contained pieces, each useful on its own, so nothing is left half-finished if I run out of time:

  1. Zip-aware hashing _compute_zip_hash parity, with Argosy's pinned test vector. Needed before any folder-based platform, and harmless on its own.
  2. Handler abstraction -> so the RetroArch path becomes one implementation among several rather than the only shape.
  3. One folder platform end to end -> PS2 or Switch, both of which I already have working and cross-device verified in my prototype.

If those three land, the rest is largely repetition and someone else can pick it up just as easily. If I stall after step 1 or 2, you still end up with strictly more than today, and this PR is still there as the RetroArch baseline.

On save_id: good to know #3925 is coming. Since it's on hold, I'd have the client read save_id / save_usage from the API when they're present and fall back to deriving them locally when they aren't; that way neither side blocks the other, and the client gets simpler for free once the server PR lands.

@tmgast on sigil: if the server extracts these IDs during scan and exposes them as in rommapp/romm#3925, is there a scenario where a client would still want its own sigil binding? I'm trying to work out whether a .NET binding is worth planning for, or whether reading the API fields is the intended path for clients and local extraction should just be a degraded fallback.

@tmgast

tmgast commented Jul 31, 2026

Copy link
Copy Markdown
Member

on sigil...

The intention is for that to be the standard so it's easier to maintain for any client wanting to handle save sync. Once sigil is added to the scan process in RomM, I don't feel like it'll be so necessary for every client to also use the library, but Argosy probably will for backwards compatibility with older RomM installs or as a fallback when something gets missed on the server.

@LxKnsy

LxKnsy commented Jul 31, 2026

Copy link
Copy Markdown

The intention is for that to be the standard so it's easier to maintain for any client wanting to handle save sync. Once sigil is added to the scan process in RomM, I don't like it'll be so necessary for every client to also use the library, but Argosy probably will for backwards compatibility with older RomM installs or as a fallback when something gets missed on the server.

Perfect, that settles it, thanks.
So for the Playnite plugin: read save_id / save_usage from the API as the primary path, no .NET binding, and treat local derivation as a narrow fallback rather than something to build out.

That also makes the sequencing straightforward on my end: RetroArch needs no id at all (the save is named after the ROM file), so the hashing and handler-abstraction work is independent of all this. The folder-based platforms are the ones that actually want save_id, so those are better off following rommapp/romm#3925 rather than growing their own extraction in the plugin.

I´ll look into Zip-aware hashing and Handler abstraction. I'll give you guys a headsup when i made some progress.

@LxKnsy

LxKnsy commented Aug 2, 2026

Copy link
Copy Markdown

Opened #131, which carries the commits from this branch forward — authorship intact, nothing squashed — and adds the archive-aware hashing plus the handler abstraction @ScottamDendar asked about. Current main is merged in as well.

Also ran it end to end for the first time (RomM 5.1.0, RetroArch + mGBA): uploads work and are byte-identical, downloads never fire. Turns out the main reason is on our side — we upload saves without a slot, and sync/negotiate ignores slot-less saves completely, so nothing this plugin uploads is ever visible to the sync layer. Details and the traces are in #131.

Happy for this one to be closed whenever suits you.

@gantoine gantoine closed this Aug 2, 2026
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.

5 participants