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/RetroArchConfigTests.cs b/RomM.Tests/RetroArchConfigTests.cs
new file mode 100644
index 0000000..e4fa8fb
--- /dev/null
+++ b/RomM.Tests/RetroArchConfigTests.cs
@@ -0,0 +1,133 @@
+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);
+ }
+
+ [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, "Game", "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/RomM.Tests.csproj b/RomM.Tests/RomM.Tests.csproj
index d348673..1dfab77 100644
--- a/RomM.Tests/RomM.Tests.csproj
+++ b/RomM.Tests/RomM.Tests.csproj
@@ -51,6 +51,8 @@
+
+
diff --git a/RomM.Tests/SaveFileHashTests.cs b/RomM.Tests/SaveFileHashTests.cs
new file mode 100644
index 0000000..1081b8b
--- /dev/null
+++ b/RomM.Tests/SaveFileHashTests.cs
@@ -0,0 +1,45 @@
+using System.IO;
+using System.Text;
+using RomM.Saves;
+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);
+ }
+ }
+ }
+}
diff --git a/RomM.cs b/RomM.cs
index 81780fb..74b4d17 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)
{
@@ -501,6 +516,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);
@@ -663,5 +704,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/RetroArchConfig.cs b/Saves/RetroArchConfig.cs
new file mode 100644
index 0000000..4d24078
--- /dev/null
+++ b/Saves/RetroArchConfig.cs
@@ -0,0 +1,139 @@
+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):
+ /// base = savefile_directory (empty / "default" -> the content's own directory)
+ /// + per-core subfolder when sort_savefiles_enable = true
+ /// + per-content subfolder when sort_savefiles_by_content_enable = true
+ /// file = <content base name>.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 contentDir = Path.GetDirectoryName(contentFilePath);
+ var contentName = Path.GetFileNameWithoutExtension(contentFilePath);
+
+ var saveDir = ExpandPath(GetValue(cfg, "savefile_directory"), retroArchBaseDir);
+ if (string.IsNullOrEmpty(saveDir))
+ saveDir = contentDir;
+
+ if (GetBool(cfg, "sort_savefiles_enable") && !string.IsNullOrEmpty(coreName))
+ saveDir = Path.Combine(saveDir, coreName);
+
+ if (GetBool(cfg, "sort_savefiles_by_content_enable"))
+ saveDir = Path.Combine(saveDir, contentName);
+
+ return Path.Combine(saveDir, contentName + SaveExtension);
+ }
+
+ ///
+ /// Resolves the configured 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. Falls
+ /// back to the content directory when savefile_directory is unset.
+ ///
+ public static string ResolveSaveBaseDirectory(
+ IDictionary cfg,
+ string contentFilePath,
+ string retroArchBaseDir = null)
+ {
+ var saveDir = ExpandPath(GetValue(cfg, "savefile_directory"), retroArchBaseDir);
+ if (string.IsNullOrEmpty(saveDir) && !string.IsNullOrEmpty(contentFilePath))
+ saveDir = Path.GetDirectoryName(contentFilePath);
+
+ 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/SaveFileHash.cs b/Saves/SaveFileHash.cs
new file mode 100644
index 0000000..45880fe
--- /dev/null
+++ b/Saves/SaveFileHash.cs
@@ -0,0 +1,32 @@
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace RomM.Saves
+{
+ ///
+ /// Computes the content hash RomM uses to compare saves. The server hashes save files with
+ /// MD5 (hex digest of the raw bytes), so the client must match that exactly or negotiate will
+ /// never report "no_op" / will misclassify identical files. See romm assets_handler.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/Saves/SaveSyncService.cs b/Saves/SaveSyncService.cs
new file mode 100644
index 0000000..9d6faad
--- /dev/null
+++ b/Saves/SaveSyncService.cs
@@ -0,0 +1,509 @@
+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 RetroArch battery save (.srm) 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.
+ ///
+ /// Currently scoped to RetroArch, whose SRAM location is derived from retroarch.cfg.
+ ///
+ internal class SaveSyncService
+ {
+ private const string Emulator = "retroarch";
+ private const string DeviceClient = "playnite";
+
+ private readonly IRomM _romM;
+ private readonly object _deviceLock = new object();
+
+ public SaveSyncService(IRomM romM)
+ {
+ _romM = romM;
+ }
+
+ 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 = ResolveRetroArchTarget(game);
+ if (target == null)
+ {
+ outcome.Message = "Save sync currently only supports RetroArch games.";
+ 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, RetroArchTarget target)
+ {
+ var payload = new RomMSyncNegotiatePayload { DeviceId = deviceId };
+
+ if (File.Exists(target.LocalSavePath))
+ {
+ var info = new FileInfo(target.LocalSavePath);
+ payload.Saves.Add(new RomMClientSaveState
+ {
+ RomId = romId,
+ FileName = Path.GetFileName(target.LocalSavePath),
+ Slot = null,
+ Emulator = Emulator,
+ ContentHash = SaveFileHash.Md5HexFile(target.LocalSavePath),
+ UpdatedAt = info.LastWriteTimeUtc,
+ FileSizeBytes = info.Length,
+ });
+ }
+
+ 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, RetroArchTarget 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, RetroArchTarget target, SyncOutcome outcome)
+ {
+ var localTime = File.Exists(target.LocalSavePath)
+ ? (DateTime?)new FileInfo(target.LocalSavePath).LastWriteTimeUtc
+ : 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, RetroArchTarget target)
+ {
+ if (!File.Exists(target.LocalSavePath))
+ {
+ Logger.Warn($"[SaveSync] Asked to upload rom {op.RomId} but no local save at {target.LocalSavePath}.");
+ return false;
+ }
+
+ using (var content = BuildSaveContent(target.LocalSavePath))
+ {
+ 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={Emulator}" +
+ $"&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, RetroArchTarget 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();
+ }
+
+ // Overwrite the file RetroArch already uses when we found one, otherwise write to the
+ // path RetroArch would create for this content.
+ var destination = target.ExistingSavePath ?? target.LocalSavePath;
+ var directory = Path.GetDirectoryName(destination);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ File.WriteAllBytes(destination, bytes);
+
+ // Align the local timestamp with the server so the next negotiate sees them as in-sync.
+ if (op.ServerUpdatedAt.HasValue)
+ {
+ try { File.SetLastWriteTimeUtc(destination, op.ServerUpdatedAt.Value.ToUniversalTime()); }
+ catch (Exception ex) { Logger.Warn($"[SaveSync] Could not set save timestamp: {ex.Message}"); }
+ }
+
+ 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(string filePath)
+ {
+ var fileContent = new ByteArrayContent(File.ReadAllBytes(filePath));
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+
+ var form = new MultipartFormDataContent();
+ form.Add(fileContent, "saveFile", Path.GetFileName(filePath));
+ 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 RetroArch resolution
+
+ /// Where a game's RetroArch save lives locally, plus any already-existing file.
+ private class RetroArchTarget
+ {
+ public string LocalSavePath { get; set; }
+ public string ExistingSavePath { get; set; }
+ }
+
+ private RetroArchTarget ResolveRetroArchTarget(Game game)
+ {
+ try
+ {
+ var contentPath = game.Roms?.FirstOrDefault()?.Path;
+ if (string.IsNullOrEmpty(contentPath))
+ return null;
+
+ contentPath = _romM.Playnite.ExpandGameVariables(game, contentPath);
+
+ var emulator = ResolveEmulator(game);
+ if (emulator == null || !IsRetroArch(emulator))
+ return null;
+
+ var cfgPath = FindRetroArchConfig(emulator);
+ var cfg = cfgPath != null
+ ? RetroArchConfig.Parse(File.ReadAllText(cfgPath))
+ : new Dictionary();
+
+ var baseDir = emulator.InstallDir;
+ var localSavePath = RetroArchConfig.ResolveSaveFilePath(cfg, contentPath, null, baseDir);
+ if (string.IsNullOrEmpty(localSavePath))
+ return null;
+
+ var existing = File.Exists(localSavePath)
+ ? localSavePath
+ : FindExistingSave(RetroArchConfig.ResolveSaveBaseDirectory(cfg, contentPath, baseDir),
+ Path.GetFileNameWithoutExtension(contentPath));
+
+ return new RetroArchTarget
+ {
+ LocalSavePath = existing ?? localSavePath,
+ ExistingSavePath = existing,
+ };
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"[SaveSync] Failed to resolve RetroArch save path for {game?.Name}.");
+ return null;
+ }
+ }
+
+ private Emulator ResolveEmulator(Game game)
+ {
+ var action = game.GameActions?.FirstOrDefault(a => a.IsPlayAction && a.Type == GameActionType.Emulator)
+ ?? game.GameActions?.FirstOrDefault(a => a.Type == GameActionType.Emulator);
+
+ if (action != null && action.EmulatorId != Guid.Empty)
+ return _romM.Playnite.Database.Emulators?.FirstOrDefault(e => e.Id == action.EmulatorId);
+
+ return null;
+ }
+
+ private static bool IsRetroArch(Emulator emulator)
+ {
+ 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;
+ }
+
+ private static string FindRetroArchConfig(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;
+ }
+ }
+
+ #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/Settings/Settings.cs b/Settings/Settings.cs
index 33cfc05..8f61063 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)
@@ -525,6 +532,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 c9e2968..678cc8c 100644
--- a/Settings/SettingsView.xaml
+++ b/Settings/SettingsView.xaml
@@ -307,6 +307,14 @@
+
+
+
+
+
+
+
+