diff --git a/Models/RomM/Save/RomMDevice.cs b/Models/RomM/Save/RomMDevice.cs
new file mode 100644
index 0000000..ee1a3ee
--- /dev/null
+++ b/Models/RomM/Save/RomMDevice.cs
@@ -0,0 +1,45 @@
+using System;
+using Newtonsoft.Json;
+
+namespace RomM.Models.RomM.Save
+{
+ ///
+ /// Payload for POST /api/devices. The server stores an arbitrary client string and a sync mode
+ /// (see KNOWN_DEVICES / SyncMode in romm). The Playnite plugin registers itself as the "playnite"
+ /// client using API sync mode. allow_existing lets us re-register idempotently per RomM account.
+ ///
+ public class RomMDeviceCreate
+ {
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ [JsonProperty("platform")]
+ public string Platform { get; set; }
+
+ [JsonProperty("client")]
+ public string Client { get; set; }
+
+ [JsonProperty("client_version")]
+ public string ClientVersion { get; set; }
+
+ // SyncMode is a StrEnum on the server: "api" | "file_transfer" | "push_pull".
+ [JsonProperty("sync_mode")]
+ public string SyncMode { get; set; } = "api";
+
+ [JsonProperty("allow_existing")]
+ public bool AllowExisting { get; set; } = true;
+ }
+
+ /// Response from POST /api/devices. We only persist .
+ public class RomMDeviceCreateResponse
+ {
+ [JsonProperty("device_id")]
+ public string DeviceId { get; set; }
+
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ [JsonProperty("created_at")]
+ public DateTime CreatedAt { get; set; }
+ }
+}
diff --git a/Models/RomM/Save/RomMSave.cs b/Models/RomM/Save/RomMSave.cs
new file mode 100644
index 0000000..8c531e0
--- /dev/null
+++ b/Models/RomM/Save/RomMSave.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace RomM.Models.RomM.Save
+{
+ ///
+ /// Per-device sync state attached to a save. Returned by the server (see PR #3479) so the
+ /// client can tell which devices already hold a copy and which one is current.
+ ///
+ public class RomMDeviceSync
+ {
+ [JsonProperty("device_id")]
+ public string DeviceId { get; set; }
+
+ [JsonProperty("device_name")]
+ public string DeviceName { get; set; }
+
+ [JsonProperty("last_synced_at")]
+ public DateTime? LastSyncedAt { get; set; }
+
+ [JsonProperty("is_untracked")]
+ public bool IsUntracked { get; set; }
+
+ [JsonProperty("is_current")]
+ public bool IsCurrent { get; set; }
+ }
+
+ ///
+ /// Subset of the server's SaveSchema that the plugin needs. The server returns many more
+ /// fields (file paths, screenshot, tags) which we deliberately ignore.
+ ///
+ public class RomMSave
+ {
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ [JsonProperty("rom_id")]
+ public int RomId { get; set; }
+
+ [JsonProperty("file_name")]
+ public string FileName { get; set; }
+
+ [JsonProperty("file_size_bytes")]
+ public long FileSizeBytes { get; set; }
+
+ [JsonProperty("download_path")]
+ public string DownloadPath { get; set; }
+
+ [JsonProperty("emulator")]
+ public string Emulator { get; set; }
+
+ [JsonProperty("slot")]
+ public string Slot { get; set; }
+
+ [JsonProperty("content_hash")]
+ public string ContentHash { get; set; }
+
+ [JsonProperty("origin_device_id")]
+ public string OriginDeviceId { get; set; }
+
+ [JsonProperty("created_at")]
+ public DateTime CreatedAt { get; set; }
+
+ [JsonProperty("updated_at")]
+ public DateTime UpdatedAt { get; set; }
+
+ [JsonProperty("device_syncs")]
+ public List DeviceSyncs { get; set; } = new List();
+ }
+}
diff --git a/Models/RomM/Save/RomMSyncModels.cs b/Models/RomM/Save/RomMSyncModels.cs
new file mode 100644
index 0000000..7f2e501
--- /dev/null
+++ b/Models/RomM/Save/RomMSyncModels.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace RomM.Models.RomM.Save
+{
+ ///
+ /// A single locally-known save reported to POST /sync/negotiate. The server keys saves by
+ /// (rom_id, slot) and compares (MD5 hex) then
+ /// against its own copy to decide the sync action.
+ ///
+ public class RomMClientSaveState
+ {
+ [JsonProperty("rom_id")]
+ public int RomId { get; set; }
+
+ [JsonProperty("file_name")]
+ public string FileName { get; set; }
+
+ [JsonProperty("slot")]
+ public string Slot { get; set; }
+
+ [JsonProperty("emulator")]
+ public string Emulator { get; set; }
+
+ [JsonProperty("content_hash")]
+ public string ContentHash { get; set; }
+
+ [JsonProperty("updated_at")]
+ public DateTime UpdatedAt { get; set; }
+
+ [JsonProperty("file_size_bytes")]
+ public long FileSizeBytes { get; set; }
+ }
+
+ public class RomMSyncNegotiatePayload
+ {
+ [JsonProperty("device_id")]
+ public string DeviceId { get; set; }
+
+ [JsonProperty("saves")]
+ public List Saves { get; set; } = new List();
+ }
+
+ /// The action the server wants the client to perform for a given save.
+ public static class RomMSyncAction
+ {
+ public const string Upload = "upload";
+ public const string Download = "download";
+ public const string Conflict = "conflict";
+ public const string NoOp = "no_op";
+ }
+
+ public class RomMSyncOperation
+ {
+ [JsonProperty("action")]
+ public string Action { get; set; }
+
+ [JsonProperty("rom_id")]
+ public int RomId { get; set; }
+
+ [JsonProperty("save_id")]
+ public int? SaveId { get; set; }
+
+ [JsonProperty("file_name")]
+ public string FileName { get; set; }
+
+ [JsonProperty("slot")]
+ public string Slot { get; set; }
+
+ [JsonProperty("emulator")]
+ public string Emulator { get; set; }
+
+ [JsonProperty("reason")]
+ public string Reason { get; set; }
+
+ [JsonProperty("server_updated_at")]
+ public DateTime? ServerUpdatedAt { get; set; }
+
+ [JsonProperty("server_content_hash")]
+ public string ServerContentHash { get; set; }
+ }
+
+ public class RomMSyncNegotiateResponse
+ {
+ [JsonProperty("session_id")]
+ public int SessionId { get; set; }
+
+ [JsonProperty("operations")]
+ public List Operations { get; set; } = new List();
+
+ [JsonProperty("total_upload")]
+ public int TotalUpload { get; set; }
+
+ [JsonProperty("total_download")]
+ public int TotalDownload { get; set; }
+
+ [JsonProperty("total_conflict")]
+ public int TotalConflict { get; set; }
+
+ [JsonProperty("total_no_op")]
+ public int TotalNoOp { get; set; }
+ }
+
+ /// Payload for POST /sync/sessions/{id}/complete. play_sessions is omitted (null).
+ public class RomMSyncCompletePayload
+ {
+ [JsonProperty("operations_completed")]
+ public int OperationsCompleted { get; set; }
+
+ [JsonProperty("operations_failed")]
+ public int OperationsFailed { get; set; }
+ }
+}
diff --git a/RomM.Tests/FileSaveTargetTests.cs b/RomM.Tests/FileSaveTargetTests.cs
new file mode 100644
index 0000000..3a11e0d
--- /dev/null
+++ b/RomM.Tests/FileSaveTargetTests.cs
@@ -0,0 +1,116 @@
+using System;
+using System.IO;
+using System.Text;
+using RomM.Saves;
+using Xunit;
+
+namespace RomM.Tests
+{
+ public class FileSaveTargetTests : IDisposable
+ {
+ private readonly string _dir;
+
+ public FileSaveTargetTests()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(_dir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, true); } catch { }
+ }
+
+ private string Path_(string name) => Path.Combine(_dir, name);
+
+ [Fact]
+ public void Reports_absent_when_no_save_has_been_written_yet()
+ {
+ var target = new FileSaveTarget("retroarch", Path_("game.srm"), null);
+
+ Assert.False(target.Exists);
+ }
+
+ // sync/negotiate skips saves without a slot entirely: they upload fine and are then
+ // invisible to every device, including the one that wrote them. "autosave" is what the
+ // other RomM clients use for a game's live save.
+ [Fact]
+ public void Carries_a_slot_so_the_save_is_visible_to_negotiate()
+ {
+ Assert.Equal("autosave", new FileSaveTarget("retroarch", Path_("game.srm"), null).Slot);
+ }
+
+ [Fact]
+ public void Reports_the_file_it_found_rather_than_the_configured_path()
+ {
+ var existing = Path_("sorted-by-core.srm");
+ File.WriteAllText(existing, "abc");
+
+ var target = new FileSaveTarget("retroarch", Path_("game.srm"), existing);
+
+ Assert.True(target.Exists);
+ Assert.Equal("sorted-by-core.srm", target.FileName);
+ Assert.Equal("900150983cd24fb0d6963f7d28e17f72", target.ContentHash());
+ }
+
+ // A single file goes to the server untouched -- no archive, nothing to clean up afterwards.
+ [Fact]
+ public void Uploads_the_file_in_place_without_a_temporary_copy()
+ {
+ var path = Path_("game.srm");
+ File.WriteAllText(path, "abc");
+
+ var target = new FileSaveTarget("retroarch", path, null);
+ using (var prepared = target.PrepareUpload())
+ {
+ Assert.False(prepared.IsTemporary);
+ Assert.Equal(path, prepared.FilePath);
+ Assert.Equal("game.srm", prepared.FileName);
+ }
+
+ Assert.True(File.Exists(path));
+ }
+
+ [Fact]
+ public void Creates_the_directory_when_downloading_a_first_save()
+ {
+ var path = Path.Combine(_dir, "saves", "nested", "game.srm");
+ var target = new FileSaveTarget("retroarch", path, null);
+
+ target.ApplyDownload(Encoding.ASCII.GetBytes("abc"), null);
+
+ Assert.Equal("abc", File.ReadAllText(path));
+ }
+
+ // The emulator may keep its save somewhere the configured layout would not predict. A
+ // download has to land on the file it actually reads, or the game silently keeps loading
+ // the old data.
+ [Fact]
+ public void Overwrites_the_discovered_file_rather_than_the_configured_path()
+ {
+ var configured = Path_("game.srm");
+ var existing = Path_("sorted-by-core.srm");
+ File.WriteAllText(existing, "old");
+
+ var target = new FileSaveTarget("retroarch", configured, existing);
+ target.ApplyDownload(Encoding.ASCII.GetBytes("new"), null);
+
+ Assert.Equal("new", File.ReadAllText(existing));
+ Assert.False(File.Exists(configured));
+ }
+
+ // Negotiate compares timestamps, so a download that leaves "now" on the file would look
+ // like a local edit on the next sync and bounce straight back up.
+ [Fact]
+ public void Stamps_the_downloaded_file_with_the_server_time()
+ {
+ var path = Path_("game.srm");
+ var serverTime = new DateTime(2024, 5, 17, 9, 30, 0, DateTimeKind.Utc);
+
+ var target = new FileSaveTarget("retroarch", path, null);
+ target.ApplyDownload(Encoding.ASCII.GetBytes("abc"), serverTime);
+
+ Assert.Equal(serverTime, target.UpdatedAtUtc);
+ }
+ }
+}
diff --git a/RomM.Tests/RetroArchConfigLayoutTests.cs b/RomM.Tests/RetroArchConfigLayoutTests.cs
new file mode 100644
index 0000000..8dfa776
--- /dev/null
+++ b/RomM.Tests/RetroArchConfigLayoutTests.cs
@@ -0,0 +1,91 @@
+using System.Collections.Generic;
+using RomM.Saves;
+using Xunit;
+
+namespace RomM.Tests
+{
+ ///
+ /// Save layouts checked against RetroArch itself rather than against the option names, which
+ /// are misleading in two places: sorting "by content" keys on the ROM's folder rather than the
+ /// ROM, and it wraps around the per-core folder instead of nesting inside it.
+ ///
+ public class RetroArchConfigLayoutTests
+ {
+ private const string Rom = @"D:\Retrogames\gba\Advance Wars.gba";
+
+ private static Dictionary Cfg(params string[] pairs)
+ {
+ var cfg = new Dictionary { { "savefile_directory", @"D:\RetroArch\saves" } };
+ for (int i = 0; i < pairs.Length; i += 2)
+ cfg[pairs[i]] = pairs[i + 1];
+ return cfg;
+ }
+
+ [Fact]
+ public void Plain_layout_puts_the_save_in_the_configured_directory()
+ {
+ Assert.Equal(@"D:\RetroArch\saves\Advance Wars.srm",
+ RetroArchConfig.ResolveSaveFilePath(Cfg(), Rom));
+ }
+
+ [Fact]
+ public void Sorting_by_core_adds_the_core_folder()
+ {
+ Assert.Equal(@"D:\RetroArch\saves\mGBA\Advance Wars.srm",
+ RetroArchConfig.ResolveSaveFilePath(Cfg("sort_savefiles_enable", "true"), Rom, "mGBA"));
+ }
+
+ // Verified against RetroArch: the folder is named after the ROM's parent directory ("gba"),
+ // not after the ROM.
+ [Fact]
+ public void Sorting_by_content_uses_the_roms_parent_folder_name()
+ {
+ Assert.Equal(@"D:\RetroArch\saves\gba\Advance Wars.srm",
+ RetroArchConfig.ResolveSaveFilePath(Cfg("sort_savefiles_by_content_enable", "true"), Rom));
+ }
+
+ // …and the core folder sits inside the content folder, not the other way round.
+ [Fact]
+ public void Content_sorting_wraps_around_the_core_folder()
+ {
+ var cfg = Cfg("sort_savefiles_by_content_enable", "true", "sort_savefiles_enable", "true");
+
+ Assert.Equal(@"D:\RetroArch\saves\gba\mGBA\Advance Wars.srm",
+ RetroArchConfig.ResolveSaveFilePath(cfg, Rom, "mGBA"));
+ }
+
+ // savefiles_in_content_dir wins over a configured savefile_directory rather than only
+ // filling in when it is empty.
+ [Fact]
+ public void Saves_in_content_dir_override_the_configured_directory()
+ {
+ Assert.Equal(@"D:\Retrogames\gba\Advance Wars.srm",
+ RetroArchConfig.ResolveSaveFilePath(Cfg("savefiles_in_content_dir", "true"), Rom));
+ }
+
+ [Fact]
+ public void Saves_in_content_dir_still_take_the_core_folder()
+ {
+ var cfg = Cfg("savefiles_in_content_dir", "true", "sort_savefiles_enable", "true");
+
+ Assert.Equal(@"D:\Retrogames\gba\mGBA\Advance Wars.srm",
+ RetroArchConfig.ResolveSaveFilePath(cfg, Rom, "mGBA"));
+ }
+
+ [Fact]
+ public void Base_directory_follows_saves_in_content_dir_for_the_recursive_search()
+ {
+ Assert.Equal(@"D:\Retrogames\gba",
+ RetroArchConfig.ResolveSaveBaseDirectory(Cfg("savefiles_in_content_dir", "true"), Rom));
+ }
+
+ [Fact]
+ public void An_empty_savefile_directory_falls_back_to_the_content_directory()
+ {
+ var cfg = new Dictionary { { "savefile_directory", "default" } };
+
+ Assert.Equal(@"D:\Retrogames\gba\Advance Wars.srm",
+ RetroArchConfig.ResolveSaveFilePath(cfg, Rom));
+ }
+ }
+}
diff --git a/RomM.Tests/RetroArchConfigTests.cs b/RomM.Tests/RetroArchConfigTests.cs
new file mode 100644
index 0000000..3ac59bd
--- /dev/null
+++ b/RomM.Tests/RetroArchConfigTests.cs
@@ -0,0 +1,136 @@
+using System.Collections.Generic;
+using System.IO;
+using RomM.Saves;
+using Xunit;
+
+namespace RomM.Tests
+{
+ public class RetroArchConfigTests
+ {
+ [Fact]
+ public void Parse_strips_quotes_comments_and_whitespace()
+ {
+ var cfg = RetroArchConfig.Parse(string.Join("\n", new[]
+ {
+ "# a comment",
+ "savefile_directory = \"C:\\saves\"",
+ "sort_savefiles_enable = \"true\"",
+ " sort_savefiles_by_content_enable = false ",
+ "",
+ "garbage line without separator",
+ }));
+
+ Assert.Equal("C:\\saves", cfg["savefile_directory"]);
+ Assert.Equal("true", cfg["sort_savefiles_enable"]);
+ Assert.Equal("false", cfg["sort_savefiles_by_content_enable"]);
+ Assert.False(cfg.ContainsKey("garbage line without separator"));
+ }
+
+ [Fact]
+ public void ResolveSaveFilePath_falls_back_to_content_directory_when_unset()
+ {
+ var content = Path.Combine("roms", "gba", "Game.gba");
+ var cfg = new Dictionary();
+
+ var path = RetroArchConfig.ResolveSaveFilePath(cfg, content);
+
+ Assert.Equal(Path.Combine("roms", "gba", "Game.srm"), path);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("default")]
+ public void ResolveSaveFilePath_empty_or_default_uses_content_directory(string value)
+ {
+ var content = Path.Combine("roms", "Game.gba");
+ var cfg = new Dictionary { ["savefile_directory"] = value };
+
+ var path = RetroArchConfig.ResolveSaveFilePath(cfg, content);
+
+ Assert.Equal(Path.Combine("roms", "Game.srm"), path);
+ }
+
+ [Fact]
+ public void ResolveSaveFilePath_uses_configured_directory()
+ {
+ var content = Path.Combine("roms", "Game.gba");
+ var saveDir = Path.Combine("data", "saves");
+ var cfg = new Dictionary { ["savefile_directory"] = saveDir };
+
+ var path = RetroArchConfig.ResolveSaveFilePath(cfg, content);
+
+ Assert.Equal(Path.Combine(saveDir, "Game.srm"), path);
+ }
+
+ // "Content" here is the directory the ROM sits in, not the ROM itself — checked against
+ // RetroArch, which puts a rom in roms\ under \roms\. See RetroArchConfigLayoutTests
+ // for the full set of layouts.
+ [Fact]
+ public void ResolveSaveFilePath_sorts_by_content_when_enabled()
+ {
+ var content = Path.Combine("roms", "Game.gba");
+ var saveDir = Path.Combine("data", "saves");
+ var cfg = new Dictionary
+ {
+ ["savefile_directory"] = saveDir,
+ ["sort_savefiles_by_content_enable"] = "true",
+ };
+
+ var path = RetroArchConfig.ResolveSaveFilePath(cfg, content);
+
+ Assert.Equal(Path.Combine(saveDir, "roms", "Game.srm"), path);
+ }
+
+ [Fact]
+ public void ResolveSaveFilePath_sorts_by_core_only_when_core_known()
+ {
+ var content = Path.Combine("roms", "Game.gba");
+ var saveDir = Path.Combine("data", "saves");
+ var cfg = new Dictionary
+ {
+ ["savefile_directory"] = saveDir,
+ ["sort_savefiles_enable"] = "true",
+ };
+
+ // Unknown core -> no per-core subfolder (runtime search handles it instead).
+ Assert.Equal(Path.Combine(saveDir, "Game.srm"),
+ RetroArchConfig.ResolveSaveFilePath(cfg, content, coreName: null));
+
+ // Known core -> per-core subfolder.
+ Assert.Equal(Path.Combine(saveDir, "mgba_libretro", "Game.srm"),
+ RetroArchConfig.ResolveSaveFilePath(cfg, content, coreName: "mgba_libretro"));
+ }
+
+ [Fact]
+ public void ResolveSaveFilePath_expands_leading_colon_base_token()
+ {
+ var content = Path.Combine("roms", "Game.gba");
+ var baseDir = Path.Combine("opt", "retroarch");
+ var cfg = new Dictionary { ["savefile_directory"] = ":\\saves" };
+
+ var path = RetroArchConfig.ResolveSaveFilePath(cfg, content, retroArchBaseDir: baseDir);
+
+ Assert.Equal(Path.Combine(baseDir, "saves", "Game.srm"), path);
+ }
+
+ [Fact]
+ public void ResolveSaveFilePath_returns_null_without_content()
+ {
+ Assert.Null(RetroArchConfig.ResolveSaveFilePath(new Dictionary(), null));
+ }
+
+ [Fact]
+ public void ResolveSaveBaseDirectory_ignores_sorting_subfolders()
+ {
+ var content = Path.Combine("roms", "Game.gba");
+ var saveDir = Path.Combine("data", "saves");
+ var cfg = new Dictionary
+ {
+ ["savefile_directory"] = saveDir,
+ ["sort_savefiles_by_content_enable"] = "true",
+ };
+
+ Assert.Equal(saveDir, RetroArchConfig.ResolveSaveBaseDirectory(cfg, content));
+ }
+ }
+}
diff --git a/RomM.Tests/RetroArchSaveHandlerTests.cs b/RomM.Tests/RetroArchSaveHandlerTests.cs
new file mode 100644
index 0000000..5399232
--- /dev/null
+++ b/RomM.Tests/RetroArchSaveHandlerTests.cs
@@ -0,0 +1,54 @@
+using Playnite.SDK.Models;
+using RomM.Saves;
+using Xunit;
+
+namespace RomM.Tests
+{
+ public class RetroArchSaveHandlerTests
+ {
+ // Playnite names its built-in RetroArch profiles after the core, and RetroArch names the
+ // per-core save folder the same way, so the profile name is the value we want verbatim.
+ [Fact]
+ public void Core_name_of_a_builtin_profile_is_its_name()
+ {
+ var profile = new BuiltInEmulatorProfile { Name = "mGBA" };
+
+ Assert.Equal("mGBA", RetroArchSaveHandler.ResolveCoreName(profile));
+ }
+
+ // A custom profile only carries the core in its libretro argument, as a dll path.
+ [Theory]
+ [InlineData("-L \"cores\\mgba_libretro.dll\" \"{ImagePath}\"", "mgba")]
+ [InlineData("-L cores\\snes9x_libretro.dll \"{ImagePath}\"", "snes9x")]
+ [InlineData("-f -L \"D:\\RetroArch\\cores\\gambatte_libretro.dll\"", "gambatte")]
+ public void Core_name_of_a_custom_profile_comes_from_the_libretro_argument(string args, string expected)
+ {
+ var profile = new CustomEmulatorProfile { Arguments = args };
+
+ Assert.Equal(expected, RetroArchSaveHandler.ResolveCoreName(profile));
+ }
+
+ // No core name means the per-core folder is simply left out of the path, which is the
+ // behaviour that existed before — not a reason to fail resolution.
+ [Theory]
+ [InlineData("\"{ImagePath}\"")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void Core_name_is_null_when_the_arguments_carry_none(string args)
+ {
+ Assert.Null(RetroArchSaveHandler.ResolveCoreName(new CustomEmulatorProfile { Arguments = args }));
+ }
+
+ [Fact]
+ public void Core_name_is_null_without_a_profile()
+ {
+ Assert.Null(RetroArchSaveHandler.ResolveCoreName(null));
+ }
+
+ [Fact]
+ public void Builtin_profile_without_a_name_yields_null()
+ {
+ Assert.Null(RetroArchSaveHandler.ResolveCoreName(new BuiltInEmulatorProfile { Name = " " }));
+ }
+ }
+}
diff --git a/RomM.Tests/RomM.Tests.csproj b/RomM.Tests/RomM.Tests.csproj
index d348673..a722db7 100644
--- a/RomM.Tests/RomM.Tests.csproj
+++ b/RomM.Tests/RomM.Tests.csproj
@@ -13,6 +13,7 @@
+ all
@@ -51,6 +52,13 @@
+
+
+
+
+
+
+
diff --git a/RomM.Tests/SaveFileHashTests.cs b/RomM.Tests/SaveFileHashTests.cs
new file mode 100644
index 0000000..f6ab102
--- /dev/null
+++ b/RomM.Tests/SaveFileHashTests.cs
@@ -0,0 +1,146 @@
+using System.IO;
+using System.Text;
+using RomM.Saves;
+using SharpCompress.Archives.Zip;
+using SharpCompress.Common;
+using SharpCompress.Writers;
+using Xunit;
+
+namespace RomM.Tests
+{
+ public class SaveFileHashTests
+ {
+ // The server hashes saves with MD5 (hex). These are the canonical MD5 digests; if the client
+ // produced anything else, negotiate could never detect identical files.
+ [Fact]
+ public void Md5Hex_of_empty_stream_matches_known_digest()
+ {
+ using (var stream = new MemoryStream())
+ {
+ Assert.Equal("d41d8cd98f00b204e9800998ecf8427e", SaveFileHash.Md5Hex(stream));
+ }
+ }
+
+ [Fact]
+ public void Md5Hex_of_abc_matches_known_digest()
+ {
+ using (var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")))
+ {
+ Assert.Equal("900150983cd24fb0d6963f7d28e17f72", SaveFileHash.Md5Hex(stream));
+ }
+ }
+
+ [Fact]
+ public void Md5HexFile_reads_and_hashes_file()
+ {
+ var path = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(path, "abc");
+ Assert.Equal("900150983cd24fb0d6963f7d28e17f72", SaveFileHash.Md5HexFile(path));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ // Archives are hashed by their entries, not their bytes. This vector is the one
+ // argosy-launcher pins in SaveArchiverHashParityTest against the server's
+ // _compute_zip_hash; drift here means Playnite and Argosy would disagree about whether a
+ // save changed, and negotiate would report conflicts for identical data.
+ [Fact]
+ public void ZipHexFile_matches_server_compute_zip_hash_vector()
+ {
+ var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".zip");
+ try
+ {
+ WriteZip(path,
+ new ZipEntrySpec("a.sav", new byte[] { 0x00, 0x01, 0x02 }),
+ new ZipEntrySpec("b.sav", new byte[] { 0xFF, 0xFE }));
+
+ Assert.Equal("fe72f8d850245659647bd6b5f3577a7a", SaveFileHash.ZipHexFile(path));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ // The digest keys on entry names, so the order they were written in must not matter --
+ // otherwise two clients zipping the same save in a different order would disagree.
+ [Fact]
+ public void ZipHexFile_is_independent_of_entry_order()
+ {
+ var forward = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".zip");
+ var reverse = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".zip");
+ try
+ {
+ WriteZip(forward,
+ new ZipEntrySpec("a.sav", new byte[] { 0x00, 0x01, 0x02 }),
+ new ZipEntrySpec("b.sav", new byte[] { 0xFF, 0xFE }));
+ WriteZip(reverse,
+ new ZipEntrySpec("b.sav", new byte[] { 0xFF, 0xFE }),
+ new ZipEntrySpec("a.sav", new byte[] { 0x00, 0x01, 0x02 }));
+
+ Assert.Equal(SaveFileHash.ZipHexFile(forward), SaveFileHash.ZipHexFile(reverse));
+ }
+ finally
+ {
+ File.Delete(forward);
+ File.Delete(reverse);
+ }
+ }
+
+ // Reporting local state during negotiate hashes the folder directly rather than building a
+ // throwaway archive, so the two paths have to agree -- including the '/' separator, which
+ // a Windows path would otherwise contribute as '\'.
+ [Fact]
+ public void FolderAsZipHex_matches_the_archive_it_would_produce()
+ {
+ var work = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ var folder = Path.Combine(work, "BESCES-53326nico");
+ var zipPath = Path.Combine(work, "bundle.zip");
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(folder, "nested"));
+ File.WriteAllBytes(Path.Combine(folder, "icon.sys"), new byte[] { 0x01, 0x02 });
+ File.WriteAllBytes(Path.Combine(folder, "nested", "data.bin"), new byte[] { 0x03 });
+
+ WriteZip(zipPath,
+ new ZipEntrySpec("BESCES-53326nico/icon.sys", new byte[] { 0x01, 0x02 }),
+ new ZipEntrySpec("BESCES-53326nico/nested/data.bin", new byte[] { 0x03 }));
+
+ Assert.Equal(SaveFileHash.ZipHexFile(zipPath), SaveFileHash.FolderAsZipHex(folder));
+ }
+ finally
+ {
+ Directory.Delete(work, true);
+ }
+ }
+
+ private class ZipEntrySpec
+ {
+ public ZipEntrySpec(string name, byte[] content)
+ {
+ Name = name;
+ Content = content;
+ }
+
+ public string Name { get; }
+ public byte[] Content { get; }
+ }
+
+ private static void WriteZip(string path, params ZipEntrySpec[] entries)
+ {
+ using (var archive = ZipArchive.Create())
+ {
+ foreach (var entry in entries)
+ archive.AddEntry(entry.Name, new MemoryStream(entry.Content), closeStream: true);
+
+ using (var stream = File.OpenWrite(path))
+ archive.SaveTo(stream, new WriterOptions(CompressionType.Deflate));
+ }
+ }
+ }
+}
diff --git a/RomM.Tests/SaveHandlerRegistryTests.cs b/RomM.Tests/SaveHandlerRegistryTests.cs
new file mode 100644
index 0000000..5c1fc89
--- /dev/null
+++ b/RomM.Tests/SaveHandlerRegistryTests.cs
@@ -0,0 +1,63 @@
+using Playnite.SDK.Models;
+using RomM.Saves;
+using Xunit;
+
+namespace RomM.Tests
+{
+ public class SaveHandlerRegistryTests
+ {
+ [Fact]
+ public void Find_returns_the_retroarch_handler_for_a_builtin_retroarch_emulator()
+ {
+ var registry = new SaveHandlerRegistry();
+
+ var handler = registry.Find(new Emulator { BuiltInConfigId = "retroarch" });
+
+ Assert.NotNull(handler);
+ Assert.Equal("retroarch", handler.EmulatorTag);
+ }
+
+ // Manually configured emulators carry no BuiltInConfigId, so the name is the next signal.
+ [Theory]
+ [InlineData("RetroArch")]
+ [InlineData("retroarch (64-bit)")]
+ [InlineData("My RETROARCH build")]
+ public void Find_recognises_retroarch_by_name(string name)
+ {
+ var registry = new SaveHandlerRegistry();
+
+ Assert.NotNull(registry.Find(new Emulator { Name = name }));
+ }
+
+ // An unknown emulator has to come back empty rather than fall through to a handler that
+ // would resolve the wrong path and overwrite an unrelated save.
+ [Fact]
+ public void Find_returns_null_for_an_emulator_no_handler_knows()
+ {
+ var registry = new SaveHandlerRegistry();
+
+ Assert.Null(registry.Find(new Emulator { Name = "PCSX2", BuiltInConfigId = "pcsx2" }));
+ }
+
+ [Fact]
+ public void Find_returns_null_for_a_missing_emulator()
+ {
+ Assert.Null(new SaveHandlerRegistry().Find(null));
+ }
+
+ [Fact]
+ public void Find_uses_the_handlers_it_was_given()
+ {
+ var registry = new SaveHandlerRegistry(new ISaveHandler[] { new StubHandler() });
+
+ Assert.Equal("stub", registry.Find(new Emulator { Name = "anything" })?.EmulatorTag);
+ }
+
+ private class StubHandler : ISaveHandler
+ {
+ public string EmulatorTag => "stub";
+ public bool CanHandle(Emulator emulator) => true;
+ public SaveTarget ResolveTarget(SaveTargetRequest request) => null;
+ }
+ }
+}
diff --git a/RomM.cs b/RomM.cs
index 429555d..d60aab1 100644
--- a/RomM.cs
+++ b/RomM.cs
@@ -5,6 +5,7 @@
using Playnite.SDK.Plugins;
using RomM.Games;
using RomM.Downloads;
+using RomM.Saves;
using RomM.VersionSelector;
using RomM.Models.RomM.Collection;
using RomM.Models.RomM.Rom;
@@ -75,6 +76,8 @@ public class RomM : LibraryPlugin, IRomM
internal RomMDownloadsSidebarItem DownloadsSidebar { get; private set; }
private readonly DownloadQueueViewModel downloadsVm;
+ internal SaveSyncService SaveSync { get; private set; }
+
// Game ids whose next ItemUpdated was caused by the importer itself, so OnItemUpdated must
// not echo the change back to the RomM server.
private readonly ConcurrentDictionary ignoredGameIds = new ConcurrentDictionary();
@@ -93,6 +96,9 @@ public RomM(IPlayniteAPI api) : base(api)
};
ROMDataPath = $"{Playnite.Paths.ExtensionsDataPath}\\{Id}\\Games\\";
+ // Save sync (RetroArch <-> RomM). Reads Settings lazily, so constructing here is safe.
+ SaveSync = new SaveSyncService(this);
+
// Initialise the download queue
downloadsVm = new DownloadQueueViewModel();
@@ -340,6 +346,15 @@ public override IEnumerable GetGameMenuItems(GetGameMenuItemsArgs
var game = args.Games.First();
if (game.PluginId == PluginId && RomMGameId.TryParse(game.GameId, out int _, out var sha1))
{
+ if (Settings.EnableSaveSync)
+ {
+ gameMenuItems.Add(new GameMenuItem
+ {
+ Description = "Sync saves with RomM",
+ Action = (_) => SyncSavesWithNotification(args.Games.Where(g => g.PluginId == PluginId).ToList())
+ });
+ }
+
string romDataFile = $"{ROMDataPath}{sha1}.json";
if (Settings.MergeRevisions && File.Exists(romDataFile) && game.IsInstalled)
{
@@ -524,6 +539,32 @@ public override void OnGameInstalled(OnGameInstalledEventArgs args)
}
}
+ // Pull the newest save down before the emulator launches so the player continues from the
+ // latest device. Playnite blocks the launch until this returns, so any failure is swallowed
+ // (logged inside Sync) rather than preventing the game from starting.
+ public override void OnGameStarting(OnGameStartingEventArgs args)
+ {
+ base.OnGameStarting(args);
+
+ if (Settings.EnableSaveSync && args.Game.PluginId == PluginId)
+ {
+ SaveSync.Sync(args.Game);
+ }
+ }
+
+ // Push the save the player just produced back to RomM. Runs in the background so it never
+ // delays returning to the library.
+ public override void OnGameStopped(OnGameStoppedEventArgs args)
+ {
+ base.OnGameStopped(args);
+
+ if (Settings.EnableSaveSync && args.Game.PluginId == PluginId)
+ {
+ var game = args.Game;
+ Task.Run(() => SaveSync.Sync(game));
+ }
+ }
+
public override LibraryMetadataProvider GetMetadataDownloader()
{
return new RomMMetadataProvider(this);
@@ -686,5 +727,56 @@ private void OnItemUpdated(object sender, ItemUpdatedEventArgs e)
});
}
#endregion
+
+ #region RomM Save Syncing
+ // Manual "Sync saves with RomM" menu action: sync each selected game on a background thread
+ // and surface a single summary notification so the user gets feedback.
+ private void SyncSavesWithNotification(IList games)
+ {
+ if (games == null || games.Count == 0)
+ {
+ return;
+ }
+
+ Task.Run(() =>
+ {
+ int uploaded = 0, downloaded = 0, conflicts = 0, failed = 0, applicable = 0;
+ string lastMessage = null;
+
+ foreach (var game in games)
+ {
+ var outcome = SaveSync.Sync(game);
+ if (outcome.Applicable)
+ {
+ applicable++;
+ uploaded += outcome.Uploaded;
+ downloaded += outcome.Downloaded;
+ conflicts += outcome.Conflicts;
+ failed += outcome.Failed;
+ }
+
+ if (!string.IsNullOrEmpty(outcome.Message))
+ {
+ lastMessage = outcome.Message;
+ }
+ }
+
+ if (applicable == 0)
+ {
+ Playnite.Notifications.Add("RomMPlugin.SaveSync",
+ lastMessage ?? "Save sync currently only supports RetroArch games.",
+ NotificationType.Info);
+ return;
+ }
+
+ var summary = $"Save sync complete: {downloaded} downloaded, {uploaded} uploaded" +
+ (conflicts > 0 ? $", {conflicts} conflict(s) resolved" : "") +
+ (failed > 0 ? $", {failed} failed" : "") + ".";
+
+ Playnite.Notifications.Add("RomMPlugin.SaveSync", summary,
+ failed > 0 ? NotificationType.Error : NotificationType.Info);
+ });
+ }
+ #endregion
}
}
\ No newline at end of file
diff --git a/Saves/FileSaveTarget.cs b/Saves/FileSaveTarget.cs
new file mode 100644
index 0000000..3f05528
--- /dev/null
+++ b/Saves/FileSaveTarget.cs
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+
+namespace RomM.Saves
+{
+ ///
+ /// A save that is a single file on disk -- RetroArch's .srm and anything else that keeps one
+ /// blob per game. Uploaded as-is rather than packed, matching what argosy-launcher sends for
+ /// the same platforms, so a save round-trips between the two clients untouched.
+ ///
+ internal sealed class FileSaveTarget : SaveTarget
+ {
+ private readonly string _writePath;
+ private readonly string _readPath;
+
+ /// Where a downloaded save is written when none exists locally.
+ ///
+ /// The file the emulator is actually using, when one was found. Downloads overwrite this in
+ /// preference to : the emulator may keep its save somewhere the
+ /// configured layout would not predict, and writing the "correct" path would leave the file
+ /// it really reads untouched.
+ ///
+ public FileSaveTarget(string emulatorTag, string writePath, string existingPath)
+ {
+ EmulatorTag = emulatorTag;
+ _writePath = writePath;
+ _readPath = existingPath ?? writePath;
+ }
+
+ public override string EmulatorTag { get; }
+
+ public override string Slot => "autosave";
+
+ public override bool Exists => File.Exists(_readPath);
+
+ public override string FileName => Path.GetFileName(_readPath);
+
+ public override DateTime UpdatedAtUtc => new FileInfo(_readPath).LastWriteTimeUtc;
+
+ public override long SizeBytes => new FileInfo(_readPath).Length;
+
+ public override string ContentHash() => SaveFileHash.Md5HexFile(_readPath);
+
+ public override PreparedUpload PrepareUpload()
+ {
+ return new PreparedUpload(_readPath, Path.GetFileName(_readPath), isTemporary: false);
+ }
+
+ public override void ApplyDownload(byte[] payload, DateTime? serverUpdatedAtUtc)
+ {
+ var destination = File.Exists(_readPath) ? _readPath : _writePath;
+
+ var directory = Path.GetDirectoryName(destination);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ File.WriteAllBytes(destination, payload);
+
+ if (serverUpdatedAtUtc.HasValue)
+ File.SetLastWriteTimeUtc(destination, serverUpdatedAtUtc.Value.ToUniversalTime());
+ }
+ }
+}
diff --git a/Saves/ISaveHandler.cs b/Saves/ISaveHandler.cs
new file mode 100644
index 0000000..f6b2b12
--- /dev/null
+++ b/Saves/ISaveHandler.cs
@@ -0,0 +1,51 @@
+using Playnite.SDK;
+using Playnite.SDK.Models;
+
+namespace RomM.Saves
+{
+ ///
+ /// Knows where one emulator keeps its saves. Handlers own everything platform-specific --
+ /// reading the emulator's own configuration, working out the path, deciding whether the save
+ /// is a file or a directory -- so can stay about negotiate,
+ /// upload and download.
+ ///
+ /// Adding an emulator is a new handler plus one line in .
+ ///
+ internal interface ISaveHandler
+ {
+ /// The tag saves are filed under on the server, e.g. "retroarch".
+ string EmulatorTag { get; }
+
+ /// Whether this handler is the one that knows the given emulator.
+ bool CanHandle(Emulator emulator);
+
+ ///
+ /// Locates the game's save, or null when this handler cannot work out a path -- an
+ /// unreadable configuration, a layout it does not recognise. Returning null skips the game
+ /// rather than failing the sync.
+ ///
+ SaveTarget ResolveTarget(SaveTargetRequest request);
+ }
+
+ ///
+ /// Everything a handler needs to locate a save, gathered by the service so handlers stay free
+ /// of Playnite lookups and stay unit-testable.
+ ///
+ internal class SaveTargetRequest
+ {
+ public Game Game { get; set; }
+
+ public Emulator Emulator { get; set; }
+
+ ///
+ /// The profile the game launches with, when one is set. Carries the detail that decides
+ /// where some emulators file a save — for RetroArch, which core is running.
+ ///
+ public EmulatorProfile Profile { get; set; }
+
+ /// The ROM's path with Playnite's variables already expanded.
+ public string ContentPath { get; set; }
+
+ public ILogger Logger { get; set; }
+ }
+}
diff --git a/Saves/RetroArchConfig.cs b/Saves/RetroArchConfig.cs
new file mode 100644
index 0000000..289d105
--- /dev/null
+++ b/Saves/RetroArchConfig.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace RomM.Saves
+{
+ ///
+ /// Parses a retroarch.cfg and resolves where RetroArch writes a game's battery save (.srm).
+ /// Pure, Playnite-free logic so it can be unit-tested; the filesystem/emulator lookups live in
+ /// .
+ ///
+ /// RetroArch save path rules (battery / SRAM), as observed against RetroArch itself:
+ /// base = savefile_directory (empty / "default" -> the content's own directory),
+ /// overridden entirely by savefiles_in_content_dir
+ /// + subfolder named after the content's *parent directory*, when
+ /// sort_savefiles_by_content_enable = true
+ /// + per-core subfolder, when sort_savefiles_enable = true
+ /// file = <content base name>.srm
+ ///
+ /// The order matters and is not the one the option names suggest: content-directory sorting
+ /// is applied first and the core folder nests inside it, giving
+ /// <base>/<rom's folder>/<core>/<rom>.srm.
+ ///
+ public static class RetroArchConfig
+ {
+ /// RetroArch battery-save extension. RetroArch always writes SRAM here regardless of core.
+ public const string SaveExtension = ".srm";
+
+ ///
+ /// Parses retroarch.cfg text into a key/value map. Lines are "key = value"; values may be
+ /// wrapped in double quotes and lines starting with '#' are comments.
+ ///
+ public static Dictionary Parse(string cfgText)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrEmpty(cfgText))
+ return result;
+
+ foreach (var rawLine in cfgText.Split('\n'))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0 || line[0] == '#')
+ continue;
+
+ int eq = line.IndexOf('=');
+ if (eq <= 0)
+ continue;
+
+ var key = line.Substring(0, eq).Trim();
+ var value = line.Substring(eq + 1).Trim();
+
+ // Strip a single pair of surrounding double quotes.
+ if (value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"')
+ value = value.Substring(1, value.Length - 2);
+
+ if (key.Length > 0)
+ result[key] = value;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Resolves the expected .srm path for given the parsed
+ /// config. may be null when unknown (the per-core subfolder is
+ /// then skipped; falls back to a recursive search at runtime).
+ /// expands RetroArch's leading ':' base-directory token.
+ /// Returns null when no content path is available.
+ ///
+ public static string ResolveSaveFilePath(
+ IDictionary cfg,
+ string contentFilePath,
+ string coreName = null,
+ string retroArchBaseDir = null)
+ {
+ if (string.IsNullOrEmpty(contentFilePath))
+ return null;
+
+ var contentName = Path.GetFileNameWithoutExtension(contentFilePath);
+ var saveDir = ResolveSaveBaseDirectory(cfg, contentFilePath, retroArchBaseDir);
+
+ // Sorting by content uses the name of the folder the ROM sits in, not the ROM's own
+ // name, and it wraps around the per-core folder rather than nesting inside it.
+ if (GetBool(cfg, "sort_savefiles_by_content_enable"))
+ {
+ var contentDirName = Path.GetFileName(Path.GetDirectoryName(contentFilePath) ?? string.Empty);
+ if (!string.IsNullOrEmpty(contentDirName))
+ saveDir = Path.Combine(saveDir, contentDirName);
+ }
+
+ if (GetBool(cfg, "sort_savefiles_enable") && !string.IsNullOrEmpty(coreName))
+ saveDir = Path.Combine(saveDir, coreName);
+
+ return Path.Combine(saveDir, contentName + SaveExtension);
+ }
+
+ ///
+ /// Resolves the save base directory (without any per-core / per-content sorting), used as
+ /// the root for a recursive ".srm" search when the exact path doesn't exist.
+ ///
+ /// savefiles_in_content_dir puts saves beside the ROM and takes precedence over
+ /// savefile_directory rather than merely filling in for an empty one — searching the
+ /// configured directory in that case looks in a tree the saves were never written to.
+ ///
+ public static string ResolveSaveBaseDirectory(
+ IDictionary cfg,
+ string contentFilePath,
+ string retroArchBaseDir = null)
+ {
+ var contentDir = string.IsNullOrEmpty(contentFilePath)
+ ? null
+ : Path.GetDirectoryName(contentFilePath);
+
+ if (GetBool(cfg, "savefiles_in_content_dir"))
+ return contentDir;
+
+ var saveDir = ExpandPath(GetValue(cfg, "savefile_directory"), retroArchBaseDir);
+ if (string.IsNullOrEmpty(saveDir))
+ saveDir = contentDir;
+
+ return saveDir;
+ }
+
+ private static string GetValue(IDictionary cfg, string key)
+ {
+ return cfg != null && cfg.TryGetValue(key, out var v) ? v : null;
+ }
+
+ private static bool GetBool(IDictionary cfg, string key)
+ {
+ var v = GetValue(cfg, key);
+ return string.Equals(v, "true", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Normalises a RetroArch directory value: empty / "default" -> null (use content dir),
+ /// a leading ':' is RetroArch's base-directory token, and environment variables are expanded.
+ ///
+ private static string ExpandPath(string raw, string retroArchBaseDir)
+ {
+ if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, "default", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ raw = Environment.ExpandEnvironmentVariables(raw);
+
+ if (raw.Length > 0 && raw[0] == ':')
+ {
+ var rest = raw.Substring(1).TrimStart('\\', '/');
+ if (!string.IsNullOrEmpty(retroArchBaseDir))
+ return Path.Combine(retroArchBaseDir, rest);
+ return rest;
+ }
+
+ return raw;
+ }
+ }
+}
diff --git a/Saves/RetroArchSaveHandler.cs b/Saves/RetroArchSaveHandler.cs
new file mode 100644
index 0000000..4b1ce55
--- /dev/null
+++ b/Saves/RetroArchSaveHandler.cs
@@ -0,0 +1,180 @@
+using Playnite.SDK.Models;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace RomM.Saves
+{
+ ///
+ /// RetroArch's battery saves. The location comes out of retroarch.cfg rather than being fixed,
+ /// so the file is found by reading the emulator's own configuration and then, if that path is
+ /// empty, by looking for the save the emulator already wrote.
+ ///
+ /// The save is named after the ROM, not after any platform identifier, which is why this
+ /// handler needs no title id and works for every core.
+ ///
+ internal sealed class RetroArchSaveHandler : ISaveHandler
+ {
+ public string EmulatorTag => "retroarch";
+
+ public bool CanHandle(Emulator emulator)
+ {
+ if (emulator == null)
+ return false;
+
+ if (string.Equals(emulator.BuiltInConfigId, "retroarch", StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ if (!string.IsNullOrEmpty(emulator.Name) &&
+ emulator.Name.IndexOf("retroarch", StringComparison.OrdinalIgnoreCase) >= 0)
+ return true;
+
+ if (!string.IsNullOrEmpty(emulator.InstallDir) &&
+ File.Exists(Path.Combine(emulator.InstallDir, "retroarch.exe")))
+ return true;
+
+ return false;
+ }
+
+ public SaveTarget ResolveTarget(SaveTargetRequest request)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(request.ContentPath))
+ return null;
+
+ var cfgPath = FindConfig(request.Emulator);
+ var cfg = cfgPath != null
+ ? RetroArchConfig.Parse(File.ReadAllText(cfgPath))
+ : new Dictionary();
+
+ var baseDir = request.Emulator.InstallDir;
+ var saveRoot = RetroArchConfig.ResolveSaveBaseDirectory(cfg, request.ContentPath, baseDir);
+
+ // With sort_savefiles_enable the save sits in a folder named after the running core.
+ // Resolving without that name only matters once a download has to create the file:
+ // it would land beside the core folders instead of inside the right one, where
+ // RetroArch never looks, and the game would start over on a save that is present.
+ var coreName = MatchExistingCoreFolder(saveRoot, ResolveCoreName(request.Profile));
+
+ var expectedPath = RetroArchConfig.ResolveSaveFilePath(cfg, request.ContentPath, coreName, baseDir);
+ if (string.IsNullOrEmpty(expectedPath))
+ return null;
+
+ // The configured path is where RetroArch *would* write. When nothing is there, the
+ // save may still exist under a subfolder we did not model, so fall back to
+ // searching for it by ROM name before assuming there is none.
+ var existing = File.Exists(expectedPath)
+ ? expectedPath
+ : FindExistingSave(saveRoot, Path.GetFileNameWithoutExtension(request.ContentPath));
+
+ return new FileSaveTarget(EmulatorTag, expectedPath, existing);
+ }
+ catch (Exception ex)
+ {
+ request.Logger?.Error(ex, $"[SaveSync] Failed to resolve RetroArch save path for {request.Game?.Name}.");
+ return null;
+ }
+ }
+
+ ///
+ /// The core a profile runs, as far as it can be told from Playnite. Built-in RetroArch
+ /// profiles are named after the core ("mGBA"); a custom profile carries it in the libretro
+ /// argument (`-L "cores\mgba_libretro.dll"`). Null when neither yields anything, which
+ /// leaves the per-core folder out of the path exactly as before.
+ ///
+ internal static string ResolveCoreName(EmulatorProfile profile)
+ {
+ var builtIn = profile as BuiltInEmulatorProfile;
+ if (builtIn != null)
+ return string.IsNullOrWhiteSpace(builtIn.Name) ? null : builtIn.Name.Trim();
+
+ var custom = profile as CustomEmulatorProfile;
+ if (custom != null)
+ return CoreFromArguments(custom.Arguments);
+
+ return null;
+ }
+
+ private static readonly Regex LibretroArgument =
+ new Regex(@"-L\s+""?(?[^""\s]+)""?", RegexOptions.IgnoreCase);
+
+ private static string CoreFromArguments(string arguments)
+ {
+ if (string.IsNullOrEmpty(arguments))
+ return null;
+
+ var match = LibretroArgument.Match(arguments);
+ if (!match.Success)
+ return null;
+
+ var name = Path.GetFileNameWithoutExtension(match.Groups["path"].Value);
+ if (string.IsNullOrEmpty(name))
+ return null;
+
+ if (name.EndsWith("_libretro", StringComparison.OrdinalIgnoreCase))
+ name = name.Substring(0, name.Length - "_libretro".Length);
+
+ return name.Length == 0 ? null : name;
+ }
+
+ ///
+ /// RetroArch names the folder after the core's own display name, which is not always how
+ /// Playnite spells it — a profile can yield "mgba" where the folder on disk is "mGBA".
+ /// Where a matching folder already exists its spelling wins, so a download joins the saves
+ /// RetroArch is already writing instead of creating a near-duplicate beside them.
+ ///
+ private static string MatchExistingCoreFolder(string saveRoot, string coreName)
+ {
+ if (string.IsNullOrEmpty(coreName) || string.IsNullOrEmpty(saveRoot) || !Directory.Exists(saveRoot))
+ return coreName;
+
+ try
+ {
+ var match = Directory.EnumerateDirectories(saveRoot)
+ .Select(Path.GetFileName)
+ .FirstOrDefault(n => string.Equals(n, coreName, StringComparison.OrdinalIgnoreCase));
+
+ return match ?? coreName;
+ }
+ catch
+ {
+ return coreName;
+ }
+ }
+
+ private static string FindConfig(Emulator emulator)
+ {
+ if (!string.IsNullOrEmpty(emulator.InstallDir))
+ {
+ var inInstall = Path.Combine(emulator.InstallDir, "retroarch.cfg");
+ if (File.Exists(inInstall))
+ return inInstall;
+ }
+
+ var appData = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "RetroArch", "retroarch.cfg");
+ return File.Exists(appData) ? appData : null;
+ }
+
+ private static string FindExistingSave(string baseDir, string contentName)
+ {
+ if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir) || string.IsNullOrEmpty(contentName))
+ return null;
+
+ try
+ {
+ return Directory
+ .EnumerateFiles(baseDir, contentName + RetroArchConfig.SaveExtension, SearchOption.AllDirectories)
+ .FirstOrDefault();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/Saves/SaveFileHash.cs b/Saves/SaveFileHash.cs
new file mode 100644
index 0000000..c4d5ba4
--- /dev/null
+++ b/Saves/SaveFileHash.cs
@@ -0,0 +1,140 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using SharpCompress.Archives;
+using SharpCompress.Archives.Zip;
+
+namespace RomM.Saves
+{
+ ///
+ /// Content hashes RomM uses to compare saves during negotiate. There are two schemes, and
+ /// picking the wrong one makes the server misclassify a save:
+ ///
+ /// * A plain save file (RetroArch's .srm) hashes as MD5 over its raw bytes.
+ /// See .
+ /// * An archive hashes over its *entries*, not its bytes. The server's _compute_zip_hash
+ /// MD5s each entry's content, pairs that digest with the entry name, sorts the pairs by
+ /// name, joins them as "name:hash" separated by '\n', and MD5s that string.
+ /// See .
+ ///
+ /// Raw-byte MD5 over an archive can never agree with the server, because zip bytes vary with
+ /// entry order, compression settings and timestamps while the content does not. Folder-based
+ /// platforms (PS2 memory cards, Switch, PSP, GameCube) upload archives, so they need the
+ /// second scheme; using the first would report a conflict on every sync for saves that are in
+ /// fact identical.
+ ///
+ /// Cross-checked against argosy-launcher's SaveArchiver.calculateZipHash so both clients agree
+ /// on what "unchanged" means. SaveFileHashTests pins its published vector.
+ ///
+ public static class SaveFileHash
+ {
+ public static string Md5Hex(Stream stream)
+ {
+ using (var md5 = MD5.Create())
+ {
+ var hash = md5.ComputeHash(stream);
+ var sb = new StringBuilder(hash.Length * 2);
+ foreach (var b in hash)
+ sb.Append(b.ToString("x2"));
+ return sb.ToString();
+ }
+ }
+
+ public static string Md5HexFile(string path)
+ {
+ using (var fs = File.OpenRead(path))
+ return Md5Hex(fs);
+ }
+
+ ///
+ /// Hashes an archive the way the server does: per-entry content digests keyed by entry
+ /// name, independent of how the archive happens to be laid out. Directory entries carry no
+ /// content and are skipped.
+ ///
+ public static string ZipHexFile(string path)
+ {
+ var entries = new List>();
+
+ using (var archive = ZipArchive.Open(path))
+ {
+ foreach (var entry in archive.Entries)
+ {
+ if (entry.IsDirectory)
+ continue;
+
+ using (var stream = entry.OpenEntryStream())
+ entries.Add(new KeyValuePair(NormalizeEntryName(entry.Key), Md5Hex(stream)));
+ }
+ }
+
+ return Combine(entries);
+ }
+
+ ///
+ /// The digest would produce for this folder, without building the
+ /// archive first. Entry names are rooted at the folder's own name so they match what an
+ /// upload writes, which lets negotiate report local state without a temp file.
+ ///
+ public static string FolderAsZipHex(string folder)
+ {
+ return FoldersAsZipHex(new[] { folder });
+ }
+
+ ///
+ /// Same as for a save whose unit spans several sibling folders
+ /// (a PS2 game owning multiple card entries, a PSP game's profile and system data).
+ ///
+ public static string FoldersAsZipHex(IEnumerable folders)
+ {
+ var entries = new List>();
+
+ foreach (var folder in folders)
+ {
+ var root = new DirectoryInfo(folder);
+ if (!root.Exists)
+ continue;
+
+ foreach (var file in root.GetFiles("*", SearchOption.AllDirectories))
+ {
+ var relative = file.FullName.Substring(root.FullName.Length)
+ .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ entries.Add(new KeyValuePair(
+ NormalizeEntryName(root.Name + "/" + relative),
+ Md5HexFile(file.FullName)));
+ }
+ }
+
+ return Combine(entries);
+ }
+
+ ///
+ /// Zip entry names are '/'-separated by specification. Windows paths are not, so a name
+ /// derived from the filesystem has to be normalised here or the digest silently diverges
+ /// from what every other client computes for the very same save.
+ ///
+ private static string NormalizeEntryName(string name)
+ {
+ return string.IsNullOrEmpty(name) ? name : name.Replace('\\', '/');
+ }
+
+ private static string Combine(List> entries)
+ {
+ // Ordinal, not culture-aware: the server and the other clients order by raw code unit,
+ // and a culture-sensitive comparison would reorder names under some locales.
+ entries.Sort((a, b) => string.CompareOrdinal(a.Key, b.Key));
+
+ var sb = new StringBuilder();
+ for (int i = 0; i < entries.Count; i++)
+ {
+ if (i > 0)
+ sb.Append('\n');
+ sb.Append(entries[i].Key).Append(':').Append(entries[i].Value);
+ }
+
+ using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())))
+ return Md5Hex(ms);
+ }
+ }
+}
diff --git a/Saves/SaveHandlerRegistry.cs b/Saves/SaveHandlerRegistry.cs
new file mode 100644
index 0000000..636ce4e
--- /dev/null
+++ b/Saves/SaveHandlerRegistry.cs
@@ -0,0 +1,37 @@
+using Playnite.SDK.Models;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RomM.Saves
+{
+ ///
+ /// Picks the handler that knows a given emulator. The single place that has to change when a
+ /// platform is added, so no call site grows an emulator check.
+ ///
+ internal class SaveHandlerRegistry
+ {
+ private readonly IList _handlers;
+
+ public SaveHandlerRegistry()
+ : this(new ISaveHandler[]
+ {
+ new RetroArchSaveHandler(),
+ })
+ {
+ }
+
+ public SaveHandlerRegistry(IEnumerable handlers)
+ {
+ _handlers = handlers.ToList();
+ }
+
+ /// The handler for this emulator, or null when none of them recognises it.
+ public ISaveHandler Find(Emulator emulator)
+ {
+ return emulator == null ? null : _handlers.FirstOrDefault(h => h.CanHandle(emulator));
+ }
+
+ /// Names of the emulators supported today, for user-facing messages.
+ public IEnumerable SupportedEmulatorTags => _handlers.Select(h => h.EmulatorTag);
+ }
+}
diff --git a/Saves/SaveSyncService.cs b/Saves/SaveSyncService.cs
new file mode 100644
index 0000000..1e3034d
--- /dev/null
+++ b/Saves/SaveSyncService.cs
@@ -0,0 +1,462 @@
+using Newtonsoft.Json;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using RomM.Games;
+using RomM.Models.RomM.Save;
+using RomM.Settings;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Reflection;
+using System.Text;
+
+namespace RomM.Saves
+{
+ ///
+ /// Synchronises a game's local save with the RomM server using the API sync mode (see romm PRs
+ /// #3137 / #3479): register a device once, POST /sync/negotiate to let the server decide
+ /// upload / download / conflict / no_op per save, execute the returned operations, then POST
+ /// the session complete. Conflicts are resolved most-recent-wins.
+ ///
+ /// Where a save lives, and whether it is one file or a packed directory, is the business of
+ /// an ; this class only moves bytes. RetroArch is the handler that
+ /// exists today.
+ ///
+ internal class SaveSyncService
+ {
+ private const string DeviceClient = "playnite";
+
+ private readonly IRomM _romM;
+ private readonly SaveHandlerRegistry _handlers;
+ private readonly object _deviceLock = new object();
+
+ public SaveSyncService(IRomM romM)
+ : this(romM, new SaveHandlerRegistry())
+ {
+ }
+
+ public SaveSyncService(IRomM romM, SaveHandlerRegistry handlers)
+ {
+ _romM = romM;
+ _handlers = handlers;
+ }
+
+ private ILogger Logger => _romM.Logger;
+ private SettingsViewModel Settings => _romM.Settings;
+
+ public class SyncOutcome
+ {
+ public bool Applicable { get; set; }
+ public int Uploaded { get; set; }
+ public int Downloaded { get; set; }
+ public int Conflicts { get; set; }
+ public int Failed { get; set; }
+ public string Message { get; set; }
+ }
+
+ ///
+ /// Runs a full negotiate + apply cycle for a single game. Safe to call off the UI thread.
+ /// Never throws; failures are logged and reflected in the returned .
+ ///
+ public SyncOutcome Sync(Game game)
+ {
+ var outcome = new SyncOutcome();
+ try
+ {
+ if (game == null || game.PluginId != _romM.Id)
+ {
+ return outcome;
+ }
+
+ if (!RomMGameId.TryParse(game.GameId, out int romId, out string _))
+ {
+ Logger.Warn($"[SaveSync] {game?.Name} has a malformed GameId, skipping.");
+ return outcome;
+ }
+
+ var target = ResolveTarget(game);
+ if (target == null)
+ {
+ outcome.Message = "Save sync does not know where this game's emulator keeps its saves.";
+ return outcome;
+ }
+
+ outcome.Applicable = true;
+
+ var deviceId = EnsureDeviceRegistered();
+ if (string.IsNullOrEmpty(deviceId))
+ {
+ outcome.Message = "Could not register this device with RomM (check token scopes).";
+ outcome.Failed++;
+ return outcome;
+ }
+
+ var negotiation = Negotiate(deviceId, romId, target);
+ if (negotiation == null)
+ {
+ outcome.Message = "Save sync negotiation with RomM failed.";
+ outcome.Failed++;
+ return outcome;
+ }
+
+ // Negotiate may surface operations for saves we didn't report (e.g. created on another
+ // device). We only resolved a local path for THIS game, so apply only its operations;
+ // other ROMs are handled when their own games sync.
+ foreach (var op in negotiation.Operations.Where(o => o.RomId == romId))
+ {
+ ApplyOperation(op, deviceId, negotiation.SessionId, target, outcome);
+ }
+
+ CompleteSession(negotiation.SessionId,
+ outcome.Uploaded + outcome.Downloaded,
+ outcome.Failed);
+
+ Logger.Info($"[SaveSync] {game.Name}: {outcome.Uploaded} uploaded, {outcome.Downloaded} downloaded, " +
+ $"{outcome.Conflicts} conflicts, {outcome.Failed} failed.");
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"[SaveSync] Unexpected failure syncing {game?.Name}.");
+ outcome.Failed++;
+ outcome.Message = ex.Message;
+ }
+
+ return outcome;
+ }
+
+ #region Negotiate / session
+
+ private RomMSyncNegotiateResponse Negotiate(string deviceId, int romId, SaveTarget target)
+ {
+ var payload = new RomMSyncNegotiatePayload { DeviceId = deviceId };
+
+ if (target.Exists)
+ {
+ payload.Saves.Add(new RomMClientSaveState
+ {
+ RomId = romId,
+ FileName = target.FileName,
+ Slot = target.Slot,
+ Emulator = target.EmulatorTag,
+ ContentHash = target.ContentHash(),
+ UpdatedAt = target.UpdatedAtUtc,
+ FileSizeBytes = target.SizeBytes,
+ });
+ }
+
+ var url = RomMUrl.Combine(Settings.RomMHost, "api/sync/negotiate");
+ var body = PostJson(url, payload);
+ return body == null ? null : JsonConvert.DeserializeObject(body);
+ }
+
+ private void CompleteSession(int sessionId, int completed, int failed)
+ {
+ try
+ {
+ var url = RomMUrl.Combine(Settings.RomMHost, $"api/sync/sessions/{sessionId}/complete");
+ PostJson(url, new RomMSyncCompletePayload
+ {
+ OperationsCompleted = completed,
+ OperationsFailed = failed,
+ });
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"[SaveSync] Failed to complete sync session {sessionId}.");
+ }
+ }
+
+ #endregion
+
+ #region Operation handling
+
+ private void ApplyOperation(RomMSyncOperation op, string deviceId, int sessionId, SaveTarget target, SyncOutcome outcome)
+ {
+ try
+ {
+ switch (op.Action)
+ {
+ case RomMSyncAction.Upload:
+ if (Upload(op, deviceId, sessionId, target))
+ outcome.Uploaded++;
+ else
+ outcome.Failed++;
+ break;
+
+ case RomMSyncAction.Download:
+ if (Download(op, deviceId, sessionId, target))
+ outcome.Downloaded++;
+ else
+ outcome.Failed++;
+ break;
+
+ case RomMSyncAction.Conflict:
+ outcome.Conflicts++;
+ ResolveConflict(op, deviceId, sessionId, target, outcome);
+ break;
+
+ case RomMSyncAction.NoOp:
+ default:
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"[SaveSync] Operation '{op.Action}' failed for save {op.SaveId} (rom {op.RomId}).");
+ outcome.Failed++;
+ }
+ }
+
+ /// Most-recent-wins: whichever side was modified later overwrites the other.
+ private void ResolveConflict(RomMSyncOperation op, string deviceId, int sessionId, SaveTarget target, SyncOutcome outcome)
+ {
+ var localTime = target.Exists ? (DateTime?)target.UpdatedAtUtc : null;
+ var serverTime = op.ServerUpdatedAt?.ToUniversalTime();
+
+ bool serverWins = serverTime.HasValue && (!localTime.HasValue || serverTime.Value > localTime.Value);
+
+ Logger.Warn($"[SaveSync] Conflict for rom {op.RomId} ({op.Reason}); " +
+ $"resolving most-recent-wins -> {(serverWins ? "download" : "upload")}.");
+
+ if (serverWins)
+ {
+ if (Download(op, deviceId, sessionId, target)) outcome.Downloaded++; else outcome.Failed++;
+ }
+ else
+ {
+ if (Upload(op, deviceId, sessionId, target)) outcome.Uploaded++; else outcome.Failed++;
+ }
+ }
+
+ private bool Upload(RomMSyncOperation op, string deviceId, int sessionId, SaveTarget target)
+ {
+ if (!target.Exists)
+ {
+ Logger.Warn($"[SaveSync] Asked to upload rom {op.RomId} but there is no local save for it.");
+ return false;
+ }
+
+ using (var prepared = target.PrepareUpload())
+ using (var content = BuildSaveContent(prepared))
+ {
+ HttpResponseMessage response;
+ if (op.SaveId.HasValue)
+ {
+ var url = RomMUrl.Combine(Settings.RomMHost,
+ $"api/saves/{op.SaveId.Value}?device_id={WebUtility.UrlEncode(deviceId)}");
+ response = HttpClientSingleton.Instance.PutAsync(url, content).GetAwaiter().GetResult();
+ }
+ else
+ {
+ var url = RomMUrl.Combine(Settings.RomMHost,
+ $"api/saves?rom_id={op.RomId}&emulator={target.EmulatorTag}" +
+ $"&slot={WebUtility.UrlEncode(target.Slot)}" +
+ $"&device_id={WebUtility.UrlEncode(deviceId)}&session_id={sessionId}");
+ response = HttpClientSingleton.Instance.PostAsync(url, content).GetAwaiter().GetResult();
+ }
+
+ using (response)
+ {
+ response.EnsureSuccessStatusCode();
+ }
+ }
+
+ return true;
+ }
+
+ private bool Download(RomMSyncOperation op, string deviceId, int sessionId, SaveTarget target)
+ {
+ if (!op.SaveId.HasValue)
+ {
+ Logger.Warn($"[SaveSync] Download requested for rom {op.RomId} without a save id.");
+ return false;
+ }
+
+ var url = RomMUrl.Combine(Settings.RomMHost,
+ $"api/saves/{op.SaveId.Value}/content?device_id={WebUtility.UrlEncode(deviceId)}&session_id={sessionId}");
+
+ byte[] bytes;
+ using (var response = HttpClientSingleton.Instance.GetAsync(url).GetAwaiter().GetResult())
+ {
+ response.EnsureSuccessStatusCode();
+ bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
+ }
+
+ // The handler decides what "apply" means -- overwrite a file, or unpack an archive over
+ // the save directory. It also aligns the local timestamp with the server's so the next
+ // negotiate sees the two sides as in sync rather than as a fresh local edit.
+ try
+ {
+ target.ApplyDownload(bytes, op.ServerUpdatedAt);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"[SaveSync] Could not write the downloaded save for rom {op.RomId}.");
+ return false;
+ }
+
+ ConfirmDownloaded(op.SaveId.Value, deviceId);
+ return true;
+ }
+
+ private void ConfirmDownloaded(int saveId, string deviceId)
+ {
+ try
+ {
+ var url = RomMUrl.Combine(Settings.RomMHost, $"api/saves/{saveId}/downloaded");
+ PostJson(url, new { device_id = deviceId });
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"[SaveSync] Could not confirm download of save {saveId}: {ex.Message}");
+ }
+ }
+
+ private static HttpContent BuildSaveContent(PreparedUpload upload)
+ {
+ var fileContent = new ByteArrayContent(File.ReadAllBytes(upload.FilePath));
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+
+ var form = new MultipartFormDataContent();
+ form.Add(fileContent, "saveFile", upload.FileName);
+ return form;
+ }
+
+ #endregion
+
+ #region Device registration
+
+ /// Registers this machine as a RomM device once, persisting the returned id in settings.
+ private string EnsureDeviceRegistered()
+ {
+ if (!string.IsNullOrEmpty(Settings.SaveSyncDeviceId))
+ return Settings.SaveSyncDeviceId;
+
+ lock (_deviceLock)
+ {
+ if (!string.IsNullOrEmpty(Settings.SaveSyncDeviceId))
+ return Settings.SaveSyncDeviceId;
+
+ try
+ {
+ var payload = new RomMDeviceCreate
+ {
+ Name = Environment.MachineName,
+ Platform = "Windows",
+ Client = DeviceClient,
+ ClientVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(),
+ SyncMode = "api",
+ AllowExisting = true,
+ };
+
+ var url = RomMUrl.Combine(Settings.RomMHost, "api/devices");
+ var body = PostJson(url, payload);
+ if (body == null)
+ return null;
+
+ var created = JsonConvert.DeserializeObject(body);
+ if (created == null || string.IsNullOrEmpty(created.DeviceId))
+ return null;
+
+ Settings.SaveSyncDeviceId = created.DeviceId;
+ Settings.Persist();
+ Logger.Info($"[SaveSync] Registered device '{created.DeviceId}' with RomM.");
+ return created.DeviceId;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "[SaveSync] Device registration failed.");
+ return null;
+ }
+ }
+ }
+
+ #endregion
+
+ #region Save location
+
+ ///
+ /// Finds the emulator Playnite launches this game with, hands it to whichever handler
+ /// recognises it, and lets that handler locate the save. Null when the game has no
+ /// emulator, no ROM path, or runs on an emulator no handler covers yet.
+ ///
+ private SaveTarget ResolveTarget(Game game)
+ {
+ var contentPath = game.Roms?.FirstOrDefault()?.Path;
+ if (string.IsNullOrEmpty(contentPath))
+ return null;
+
+ var emulator = ResolveEmulator(game);
+ if (emulator == null)
+ return null;
+
+ var handler = _handlers.Find(emulator);
+ if (handler == null)
+ {
+ Logger.Info($"[SaveSync] No save handler for emulator '{emulator.Name}', skipping {game.Name}.");
+ return null;
+ }
+
+ return handler.ResolveTarget(new SaveTargetRequest
+ {
+ Game = game,
+ Emulator = emulator,
+ Profile = ResolveProfile(game, emulator),
+ ContentPath = _romM.Playnite.ExpandGameVariables(game, contentPath),
+ Logger = Logger,
+ });
+ }
+
+ private Emulator ResolveEmulator(Game game)
+ {
+ var action = EmulatorAction(game);
+
+ if (action != null && action.EmulatorId != Guid.Empty)
+ return _romM.Playnite.Database.Emulators?.FirstOrDefault(e => e.Id == action.EmulatorId);
+
+ return null;
+ }
+
+ private static EmulatorProfile ResolveProfile(Game game, Emulator emulator)
+ {
+ var profileId = EmulatorAction(game)?.EmulatorProfileId;
+ if (string.IsNullOrEmpty(profileId))
+ return null;
+
+ return emulator.SelectableProfiles?.FirstOrDefault(p => p.Id == profileId);
+ }
+
+ private static GameAction EmulatorAction(Game game)
+ {
+ return game.GameActions?.FirstOrDefault(a => a.IsPlayAction && a.Type == GameActionType.Emulator)
+ ?? game.GameActions?.FirstOrDefault(a => a.Type == GameActionType.Emulator);
+ }
+
+ #endregion
+
+ #region HTTP helper
+
+ private string PostJson(string url, object payload)
+ {
+ var json = JsonConvert.SerializeObject(payload);
+ using (var content = new StringContent(json, Encoding.UTF8, "application/json"))
+ using (var response = HttpClientSingleton.Instance.PostAsync(url, content).GetAwaiter().GetResult())
+ {
+ if (!response.IsSuccessStatusCode)
+ {
+ var error = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+ Logger.Error($"[SaveSync] POST {url} -> {(int)response.StatusCode}: {error}");
+ return null;
+ }
+
+ return response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Saves/SaveTarget.cs b/Saves/SaveTarget.cs
new file mode 100644
index 0000000..0d91a7f
--- /dev/null
+++ b/Saves/SaveTarget.cs
@@ -0,0 +1,98 @@
+using System;
+using System.IO;
+
+namespace RomM.Saves
+{
+ ///
+ /// A game's local save, as the sync service needs to see it: something that may or may not
+ /// exist, reports a content hash and a modification time, can be packaged for upload, and can
+ /// be replaced from a downloaded blob.
+ ///
+ /// Deliberately says nothing about whether that is one file or a directory tree. RetroArch's
+ /// SRAM is a single .srm; a PS2 memory card entry, a Switch title's save folder and a
+ /// GameCube game's .gci set are several files that travel as one archive. Both shapes have to
+ /// answer the same questions for negotiate to work, and only the implementation differs --
+ /// including which of the two hashing schemes in applies.
+ ///
+ internal abstract class SaveTarget
+ {
+ ///
+ /// The emulator tag the server files this save under. Metadata rather than a filter --
+ /// RomM keys saves by rom id, so a save uploaded under one tag is still offered to a
+ /// client using another.
+ ///
+ public abstract string EmulatorTag { get; }
+
+ ///
+ /// The slot the save is filed under. Not optional in practice: `sync/negotiate` only
+ /// considers saves that carry one, so a slot-less upload lands on the server correctly and
+ /// is then invisible to every device, including the one that wrote it. Other RomM clients
+ /// use "autosave" for a game's live save regardless of platform, and matching that is what
+ /// keeps the same save reconcilable across them.
+ ///
+ public abstract string Slot { get; }
+
+ /// Whether there is anything locally to report or upload yet.
+ public abstract bool Exists { get; }
+
+ /// Name reported to the server, and the name the upload is filed under.
+ public abstract string FileName { get; }
+
+ public abstract DateTime UpdatedAtUtc { get; }
+
+ public abstract long SizeBytes { get; }
+
+ ///
+ /// The digest the server compares against. Implementations must pick the scheme that
+ /// matches what they upload: raw bytes for a single file, per-entry for an archive.
+ ///
+ public abstract string ContentHash();
+
+ ///
+ /// Produces the bytes to send. Callers dispose the result, which cleans up any temporary
+ /// archive built along the way.
+ ///
+ public abstract PreparedUpload PrepareUpload();
+
+ ///
+ /// Replaces the local save with a downloaded payload.
+ /// is applied to the result where possible so the next negotiate sees the two sides as
+ /// in sync rather than as a fresh local edit.
+ ///
+ public abstract void ApplyDownload(byte[] payload, DateTime? serverUpdatedAtUtc);
+ }
+
+ ///
+ /// A file ready to be uploaded. marks archives built on the fly, so
+ /// a single-file save is sent straight from disk without being copied.
+ ///
+ internal sealed class PreparedUpload : IDisposable
+ {
+ public PreparedUpload(string filePath, string fileName, bool isTemporary)
+ {
+ FilePath = filePath;
+ FileName = fileName;
+ IsTemporary = isTemporary;
+ }
+
+ public string FilePath { get; }
+ public string FileName { get; }
+ public bool IsTemporary { get; }
+
+ public void Dispose()
+ {
+ if (!IsTemporary)
+ return;
+
+ try
+ {
+ if (File.Exists(FilePath))
+ File.Delete(FilePath);
+ }
+ catch
+ {
+ // A leftover temp file is not worth failing a sync over.
+ }
+ }
+ }
+}
diff --git a/Settings/Settings.cs b/Settings/Settings.cs
index d5a4ae9..ba01259 100644
--- a/Settings/Settings.cs
+++ b/Settings/Settings.cs
@@ -242,6 +242,11 @@ public string RomMProfileType
public bool ScanGamesInFullScreen { get; set; } = false;
public bool NotifyOnInstallComplete { get; set; } = false;
public bool KeepRomMSynced { get; set; } = false;
+
+ // Save sync (RetroArch saves <-> RomM server). DeviceId is assigned by the server on first
+ // registration and persisted so this machine keeps the same RomM device across sessions.
+ public bool EnableSaveSync { get; set; } = false;
+ public string SaveSyncDeviceId { get; set; } = "";
public bool Use7z { get; set; } = false;
public string PathTo7z
{
@@ -325,7 +330,9 @@ internal SettingsViewModel(Plugin plugin, IRomM romM)
PathTo7z = savedSettings.PathTo7z;
MergeRevisions = savedSettings.MergeRevisions;
KeepDeletedGames = savedSettings.KeepDeletedGames;
- ExcludeGenres = savedSettings.ExcludeGenres;
+ ExcludeGenres = savedSettings.ExcludeGenres;
+ EnableSaveSync = savedSettings.EnableSaveSync;
+ SaveSyncDeviceId = savedSettings.SaveSyncDeviceId;
}
if (Mappings == null)
@@ -553,6 +560,10 @@ public void EndEdit()
}
+ // Persists the current settings to disk. Used by background features (e.g. save sync device
+ // registration) that need to store a value without going through the settings dialog edit cycle.
+ internal void Persist() => SavePluginSettings(this);
+
private void SavePluginSettings(SettingsViewModel settings)
{
var setDir = _plugin.GetPluginUserDataPath();
diff --git a/Settings/SettingsView.xaml b/Settings/SettingsView.xaml
index 481a9ff..50fa7cf 100644
--- a/Settings/SettingsView.xaml
+++ b/Settings/SettingsView.xaml
@@ -316,6 +316,14 @@
+
+
+
+
+
+
+
+