Skip to content
Draft
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; }
}
}
133 changes: 133 additions & 0 deletions RomM.Tests/RetroArchConfigTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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<string, string> { ["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<string, string> { ["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<string, string>
{
["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<string, string>
{
["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<string, string> { ["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<string, string>(), 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<string, string>
{
["savefile_directory"] = saveDir,
["sort_savefiles_by_content_enable"] = "true",
};

Assert.Equal(saveDir, RetroArchConfig.ResolveSaveBaseDirectory(cfg, content));
}
}
}
2 changes: 2 additions & 0 deletions RomM.Tests/RomM.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
<Compile Include="..\Games\RomMCompletionStatus.cs" Link="Linked\Games\RomMCompletionStatus.cs" />
<Compile Include="..\Games\RomMHash.cs" Link="Linked\Games\RomMHash.cs" />
<Compile Include="..\Games\RomMSiblings.cs" Link="Linked\Games\RomMSiblings.cs" />
<Compile Include="..\Saves\RetroArchConfig.cs" Link="Linked\Saves\RetroArchConfig.cs" />
<Compile Include="..\Saves\SaveFileHash.cs" Link="Linked\Saves\SaveFileHash.cs" />
</ItemGroup>

</Project>
Loading
Loading