Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Models/RomM/Save/RomMDevice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using Newtonsoft.Json;

namespace RomM.Models.RomM.Save
{
/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>Response from POST /api/devices. We only persist <see cref="DeviceId"/>.</summary>
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; }
}
}
71 changes: 71 additions & 0 deletions Models/RomM/Save/RomMSave.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;

namespace RomM.Models.RomM.Save
{
/// <summary>
/// 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.
/// </summary>
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; }
}

/// <summary>
/// Subset of the server's SaveSchema that the plugin needs. The server returns many more
/// fields (file paths, screenshot, tags) which we deliberately ignore.
/// </summary>
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<RomMDeviceSync> DeviceSyncs { get; set; } = new List<RomMDeviceSync>();
}
}
114 changes: 114 additions & 0 deletions Models/RomM/Save/RomMSyncModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;

namespace RomM.Models.RomM.Save
{
/// <summary>
/// A single locally-known save reported to POST /sync/negotiate. The server keys saves by
/// (rom_id, slot) and compares <see cref="ContentHash"/> (MD5 hex) then <see cref="UpdatedAt"/>
/// against its own copy to decide the sync action.
/// </summary>
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<RomMClientSaveState> Saves { get; set; } = new List<RomMClientSaveState>();
}

/// <summary>The action the server wants the client to perform for a given save.</summary>
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<RomMSyncOperation> Operations { get; set; } = new List<RomMSyncOperation>();

[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; }
}

/// <summary>Payload for POST /sync/sessions/{id}/complete. play_sessions is omitted (null).</summary>
public class RomMSyncCompletePayload
{
[JsonProperty("operations_completed")]
public int OperationsCompleted { get; set; }

[JsonProperty("operations_failed")]
public int OperationsFailed { get; set; }
}
}
116 changes: 116 additions & 0 deletions RomM.Tests/FileSaveTargetTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading