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 @@ + + +