diff --git a/Games/RomMGameData.cs b/Games/RomMGameData.cs
new file mode 100644
index 0000000..151873e
--- /dev/null
+++ b/Games/RomMGameData.cs
@@ -0,0 +1,81 @@
+using Newtonsoft.Json;
+using Playnite.SDK;
+using RomM.Models.RomM.Rom;
+using System;
+using System.IO;
+
+namespace RomM.Games
+{
+ ///
+ /// The per-ROM sidecar ("{sha1}.json" under the plugin's data folder): the download
+ /// descriptors for every revision, the emulator mapping the ROM was imported under, and what
+ /// the importer last wrote onto the game's play action.
+ ///
+ /// Install, uninstall, the version menu and save sync all need it, and each used to carry its
+ /// own copy of the path building, the id parsing and the try/catch around a corrupt file.
+ ///
+ internal static class RomMGameData
+ {
+ public static string PathFor(string romDataPath, string sha1) =>
+ Path.Combine(romDataPath ?? string.Empty, $"{sha1}.json");
+
+ ///
+ /// The sidecar for a game id, or null when the id is malformed, the file is missing or its
+ /// contents cannot be read. What a missing sidecar means is the caller's business.
+ ///
+ public static RomMRomLocal Load(string romDataPath, string gameId, ILogger logger, string gameName = null)
+ {
+ if (!RomMGameId.TryParse(gameId, out int _, out string sha1))
+ {
+ logger?.Error($"{gameName ?? gameId} GameID is malformed!");
+ return null;
+ }
+
+ return LoadBySha1(romDataPath, sha1, logger, out string _, gameName);
+ }
+
+ ///
+ /// The file's text exactly as read, or null when there was none. A caller about to rewrite
+ /// the sidecar compares against this to decide whether anything changed, rather than
+ /// re-serialising what it has just parsed once per ROM.
+ ///
+ public static RomMRomLocal LoadBySha1(string romDataPath, string sha1, ILogger logger, out string rawJson, string gameName = null)
+ {
+ rawJson = null;
+
+ var path = PathFor(romDataPath, sha1);
+ if (!File.Exists(path))
+ return null;
+
+ try
+ {
+ var text = File.ReadAllText(path);
+ var data = JsonConvert.DeserializeObject(text);
+ rawJson = text;
+ return data;
+ }
+ catch (IOException ex)
+ {
+ // Save sync reads the sidecar off the UI thread while an import may be rewriting it;
+ // that is a transient sharing violation, not a corrupt file.
+ logger?.Warn(ex, $"{gameName ?? sha1} ROM data file could not be read.");
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger?.Error(ex, $"{gameName ?? sha1} ROM data file is corrupted!");
+ return null;
+ }
+ }
+
+ public static void Save(string romDataPath, string sha1, RomMRomLocal data) =>
+ SaveJson(romDataPath, sha1, JsonConvert.SerializeObject(data));
+
+ ///
+ /// For a caller that has already serialised the sidecar to compare it with what was on
+ /// disk, so it is not serialised a second time just to be written.
+ ///
+ public static void SaveJson(string romDataPath, string sha1, string json) =>
+ File.WriteAllText(PathFor(romDataPath, sha1), json);
+ }
+}
diff --git a/Games/RomMGameInfo.Plugin.cs b/Games/RomMGameInfo.Plugin.cs
index ef135ab..e270d37 100644
--- a/Games/RomMGameInfo.Plugin.cs
+++ b/Games/RomMGameInfo.Plugin.cs
@@ -20,7 +20,7 @@ public EmulatorMapping Mapping
{
get
{
- return Settings.SettingsViewModel.Instance.Mappings.FirstOrDefault(m => m.MappingId == MappingId);
+ return Settings.SettingsViewModel.Instance.MappingById(MappingId);
}
}
diff --git a/Games/RomMImport.cs b/Games/RomMImport.cs
index 195005e..919b3d1 100644
--- a/Games/RomMImport.cs
+++ b/Games/RomMImport.cs
@@ -25,6 +25,12 @@ internal class RomMImport
Dictionary _legacyGames; // unmigrated "!0..." games, keyed by GameId
Dictionary _completionStatusMap;
List _favourites;
+ // Two enabled mappings on one platform both walk the same ROMs, and each pass would repoint
+ // the play actions the other just wrote. Neither is more right than the other, so when that
+ // is the setup the actions are left exactly as they are.
+ bool _platformHasRivalMapping;
+ // The emulator and profile this import writes onto play actions.
+ readonly AppliedPlayAction _mapped;
public RomMImport(RomM plugin, LibraryImportGamesArgs args, EmulatorMapping mapping, List roms, List favourites)
{
@@ -61,6 +67,12 @@ public RomMImport(RomM plugin, LibraryImportGamesArgs args, EmulatorMapping mapp
_completionStatusMap = plugin.Playnite.Database.CompletionStatuses.ToDictionary(cs => cs.Name, cs => cs.Id);
_favourites = favourites;
+
+ // Only mappings the import controller will actually run count: one it skips as
+ // misconfigured writes nothing, so it cannot fight this one over the actions.
+ _platformHasRivalMapping = plugin.Settings?.HasRivalMapping(mapping) == true;
+
+ _mapped = new AppliedPlayAction(mapping.EmulatorId, mapping.EmulatorProfileId);
}
// Builds the per-ROM download descriptor via the shared factory (see RomMRevisionFactory).
@@ -125,13 +137,18 @@ public List ProcessData()
}
}
- // Save Game ROM data to file
- SaveGameData(ROM);
+ // What the sidecar held before this import overwrites it: the record of the
+ // play action the plugin last wrote, which decides whether that action is
+ // still ours to repoint at the mapping's current emulator.
+ var previous = RomMGameData.LoadBySha1(_plugin.ROMDataPath, ROM.SHA1, _plugin.Logger,
+ out string previousJson, ROM.Name);
// Skip full import if ROM has already been imported
Guid statusID = Guid.Empty;
if (_existingGames.TryGetValue(gameID, out var existingGame))
{
+ bool changed = RefreshPlayAction(existingGame, previous, out var applied);
+
// Sync user data
if (_plugin.Settings.KeepRomMSynced)
{
@@ -139,24 +156,35 @@ public List ProcessData()
existingGame.Favorite = _favourites.Exists(f => f == ROM.Id);
if (statusID != Guid.Empty)
existingGame.CompletionStatusId = statusID;
-
- // This is our own write of the server's values; don't let OnItemUpdated echo it back.
- _plugin.SuppressSync(existingGame.Id);
- _plugin.Playnite.Database.Games.Update(existingGame);
+ changed = true;
}
+ if (changed)
+ SaveOwnWrite(existingGame);
+
+ // Save Game ROM data to file
+ SaveGameData(ROM, previous, previousJson, applied);
importedGameIds.Add(gameID);
continue;
}
// If keep deleted games is enabled and a deleted game gets re-added back to the
// server under a new romMId, update the existing playnite entry instead.
- if (_plugin.Settings.KeepDeletedGames && UpdatedDeletedGame(ROM))
+ if (_plugin.Settings.KeepDeletedGames && UpdatedDeletedGame(ROM, out var adoptedGame))
{
+ // The adopted entry is an existing game re-keyed under the new id, so its
+ // action is refreshed on the same terms as any other existing game's.
+ if (RefreshPlayAction(adoptedGame, previous, out var applied))
+ SaveOwnWrite(adoptedGame);
+
+ SaveGameData(ROM, previous, previousJson, applied);
importedGameIds.Add(gameID);
continue;
}
+ // A new game gets the mapping's emulator outright, below.
+ SaveGameData(ROM, previous, previousJson, _mapped);
+
var importedGame = ImportGame(ROM, statusID);
if (importedGame != null)
{
@@ -235,14 +263,7 @@ private Game ImportGame(RomMRom ROM, Guid StatusID)
metadata.InstallSize = ROM.FileSizeBytes;
metadata.GameActions = new List
{
- new GameAction
- {
- Name = $"Play in {_mapping.Emulator.Name}",
- Type = GameActionType.Emulator,
- EmulatorId = _mapping.EmulatorId,
- EmulatorProfileId = _mapping.EmulatorProfileId,
- IsPlayAction = true,
- },
+ RomMPlayAction.Build(_mapping.Emulator.Name, _mapped.EmulatorId, _mapped.ProfileId),
new GameAction
{
Type = GameActionType.URL,
@@ -351,7 +372,7 @@ private bool UpdatedOldGameID(RomMRom ROM)
return false;
}
- private bool UpdatedDeletedGame(RomMRom ROM)
+ private bool UpdatedDeletedGame(RomMRom ROM, out Game adopted)
{
// A game with the same SHA1 but a different romMId means RomM deleted and re-added it; adopt
// the existing entry under the new id.
@@ -364,22 +385,26 @@ private bool UpdatedDeletedGame(RomMRom ROM)
_existingGames.Remove(oldId);
_existingGames[newId] = oldgame;
+ adopted = oldgame;
return true;
}
+ adopted = null;
return false;
}
private MainSibling CheckForMainSibling(RomMRom ROM)
=> RomMSiblings.ClassifyMain(ROM, _romById);
- private void SaveGameData(RomMRom ROM)
+ private void SaveGameData(RomMRom ROM, RomMRomLocal previous, string previousJson, AppliedPlayAction applied)
{
RomMRomLocal toSave = new RomMRomLocal
{
Name = ROM.Name,
SHA1 = ROM.SHA1,
MappingID = _mapping.MappingId,
+ AppliedEmulatorID = applied.EmulatorId,
+ AppliedEmulatorProfileID = applied.ProfileId,
ROMVersions = new List()
};
@@ -413,30 +438,79 @@ private void SaveGameData(RomMRom ROM)
}
// Carry over the user's previously selected version and only rewrite when something changed.
- string sidecarPath = $"{_plugin.ROMDataPath}{ROM.SHA1}.json";
- string existingJson = null;
- if (File.Exists(sidecarPath))
+ foreach (var revision in previous?.ROMVersions ?? new List())
{
- try
- {
- existingJson = File.ReadAllText(sidecarPath);
- var localROM = JsonConvert.DeserializeObject(existingJson);
- foreach (var revision in localROM?.ROMVersions ?? new List())
- {
- var matchedRevision = toSave.ROMVersions.FirstOrDefault(x => x.Id == revision.Id);
- if (matchedRevision != null)
- matchedRevision.IsSelected = revision.IsSelected;
- }
- }
- catch (Exception)
- {
- _plugin.Logger.Error($"{ROM.Name} GameID is malformed or {ROM.SHA1} json file is corrupted!");
- }
+ var matchedRevision = toSave.ROMVersions.FirstOrDefault(x => x.Id == revision.Id);
+ if (matchedRevision != null)
+ matchedRevision.IsSelected = revision.IsSelected;
}
string json = JsonConvert.SerializeObject(toSave);
- if (json != existingJson)
- File.WriteAllText(sidecarPath, json);
+ if (json != previousJson)
+ RomMGameData.SaveJson(_plugin.ROMDataPath, ROM.SHA1, json);
+ }
+
+ // Saves a change the importer made to a game; OnItemUpdated must not echo it back to RomM.
+ private void SaveOwnWrite(Game game)
+ {
+ _plugin.SuppressSync(game.Id);
+ _plugin.Playnite.Database.Games.Update(game);
+ }
+
+ ///
+ /// Brings an already-imported game's play action back in step with its mapping, and reports
+ /// the action the sidecar should now record. Returns whether the action was changed; the
+ /// caller saves the game, so a repoint and a user-data sync share one write.
+ ///
+ /// Existing games are otherwise skipped wholesale by the importer, so a mapping repointed
+ /// at another emulator left every game it had imported launching -- and resolving its saves
+ /// against -- the old one. Only an action the plugin still owns is rewritten; once the user
+ /// has repointed it themselves it is theirs, and the sidecar keeps remembering what we last
+ /// wrote so that stays true across imports.
+ ///
+ private bool RefreshPlayAction(Game game, RomMRomLocal previous, out AppliedPlayAction result)
+ {
+ result = new AppliedPlayAction(previous?.AppliedEmulatorID ?? Guid.Empty,
+ previous?.AppliedEmulatorProfileID);
+ var applied = result;
+
+ // No action to keep in step.
+ var action = RomMPlayAction.Find(game?.GameActions);
+ if (action == null || _platformHasRivalMapping)
+ return false;
+
+ Func actionEmulatorName = () =>
+ _plugin.Playnite.Database.Emulators?.Get(action.EmulatorId)?.Name;
+
+ // Already in step. It only becomes ours to record if it was ours before: a user who set
+ // this emulator themselves and then pointed the mapping at the same one still owns it,
+ // and must not have it repointed when the mapping moves on.
+ if (RomMPlayAction.Matches(action, _mapped))
+ {
+ if (RomMPlayAction.IsUnedited(action, applied, actionEmulatorName))
+ result = _mapped;
+ return false;
+ }
+
+ // A sidecar from before the plugin recorded what it applied leaves only the generated
+ // name to go on, and that name records which emulator the importer chose, not which
+ // profile. On the mapping's own emulator a different profile is as likely a core the
+ // user picked for this one game as a mapping edit, so it is left alone.
+ if (applied.EmulatorId == Guid.Empty && action.EmulatorId == _mapped.EmulatorId)
+ return false;
+
+ if (!RomMPlayAction.IsUnedited(action, applied, actionEmulatorName))
+ {
+ _plugin.Logger.Info($"[Importer] Leaving {game.Name}'s play action pointed at " +
+ $"{actionEmulatorName() ?? ""}: it is no longer the one the plugin wrote.");
+ return false;
+ }
+
+ RomMPlayAction.Apply(action, _mapping.Emulator.Name, _mapped.EmulatorId, _mapped.ProfileId);
+ _plugin.Logger.Info($"[Importer] Repointed {game.Name}'s play action at {_mapping.Emulator.Name} " +
+ $"to follow the {_mapping.MappingName} mapping.");
+ result = _mapped;
+ return true;
}
private Guid DetermineCompletionStatus(RomMRom ROM)
diff --git a/Games/RomMImportController.cs b/Games/RomMImportController.cs
index b35257b..6e6a5f1 100644
--- a/Games/RomMImportController.cs
+++ b/Games/RomMImportController.cs
@@ -59,7 +59,7 @@ public List Import(LibraryImportGamesArgs args)
break;
// A mapping with no emulator/profile is genuinely unconfigured — skip quietly.
- if (mapping.Emulator == null || mapping.EmulatorProfile == null)
+ if (!mapping.HasEmulatorProfile)
{
Logger.Warn($"[Import Controller] Emulator {mapping.MappingId} is misconfigured, skipping.");
continue;
@@ -70,7 +70,7 @@ public List Import(LibraryImportGamesArgs args)
// is carried over. Give the user an actionable message instead of a cryptic
// "-1 not found". The <= 0 check covers both the unset RomMPlatformId (-1) and the
// empty default RomMPlatform (Id 0).
- if (mapping.RomMPlatformId <= 0 || mapping.RomMPlatform == null || mapping.RomMPlatform.Id <= 0)
+ if (!mapping.HasRomMPlatform)
{
var name = !string.IsNullOrWhiteSpace(mapping.MappingName)
? mapping.MappingName
diff --git a/Games/RomMPlayAction.cs b/Games/RomMPlayAction.cs
new file mode 100644
index 0000000..12a1c0b
--- /dev/null
+++ b/Games/RomMPlayAction.cs
@@ -0,0 +1,113 @@
+using Playnite.SDK.Models;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RomM.Games
+{
+ ///
+ /// The emulator and profile the importer last wrote onto a game's play action, as recorded in
+ /// the ROM's sidecar. An empty emulator id is what a sidecar written before the plugin kept
+ /// this record yields.
+ ///
+ internal struct AppliedPlayAction
+ {
+ public readonly Guid EmulatorId;
+ public readonly string ProfileId;
+
+ public AppliedPlayAction(Guid emulatorId, string profileId)
+ {
+ EmulatorId = emulatorId;
+ ProfileId = profileId;
+ }
+ }
+
+ ///
+ /// The emulator play action the importer writes onto every imported game, and the rules for
+ /// deciding whether a later import may repoint it.
+ ///
+ /// The action is a snapshot of the emulator mapping taken at import time. Re-imports skip games
+ /// that already exist, so a mapping later pointed at another emulator used to leave every game
+ /// it had imported launching -- and syncing saves against -- the emulator it no longer uses. Refreshing the action closes that gap, but only while the action
+ /// is still the one the plugin wrote: a user who repoints it themselves keeps their choice.
+ ///
+ internal static class RomMPlayAction
+ {
+ public static string NameFor(string emulatorName) => $"Play in {emulatorName}";
+
+ public static GameAction Build(string emulatorName, Guid emulatorId, string emulatorProfileId) =>
+ Apply(new GameAction(), emulatorName, emulatorId, emulatorProfileId);
+
+ ///
+ /// Writes the importer's play action onto an existing one, for repointing a game already in
+ /// the library. Shares its body with so the fields the importer owns
+ /// cannot drift from a freshly imported action; anything else on the action is left as it
+ /// is, which is why refuses an action carrying argument overrides.
+ ///
+ public static GameAction Apply(GameAction action, string emulatorName, Guid emulatorId, string emulatorProfileId)
+ {
+ action.Name = NameFor(emulatorName);
+ action.Type = GameActionType.Emulator;
+ action.EmulatorId = emulatorId;
+ action.EmulatorProfileId = emulatorProfileId;
+ action.IsPlayAction = true;
+ return action;
+ }
+
+ ///
+ /// The emulator action Playnite launches the game with. The play action comes first; a game
+ /// whose emulator action is not marked as one still tells us which emulator it runs on.
+ ///
+ public static GameAction Find(IEnumerable actions)
+ {
+ if (actions == null)
+ return null;
+
+ var emulatorActions = actions.Where(a => a != null && a.Type == GameActionType.Emulator).ToList();
+ return emulatorActions.FirstOrDefault(a => a.IsPlayAction) ?? emulatorActions.FirstOrDefault();
+ }
+
+ /// Whether the action already launches the given emulator and profile.
+ public static bool Matches(GameAction action, AppliedPlayAction target)
+ {
+ return action != null
+ && action.EmulatorId == target.EmulatorId
+ && SameProfile(action.EmulatorProfileId, target.ProfileId);
+ }
+
+ ///
+ /// Whether the action is still the plugin's to repoint.
+ ///
+ /// is what the plugin last wrote, recorded in the ROM's sidecar;
+ /// if the action still carries it, nobody has touched it. Sidecars written before the plugin recorded that (every install
+ /// that predates this) carry no applied emulator, and then the generated name is the only
+ /// marker left: an action still called "Play in {the emulator it points at}" is one
+ /// the importer wrote and the user has not renamed or repointed. That name costs a lookup
+ /// of the action's emulator, so it is passed as a thunk and only resolved on that path.
+ ///
+ /// Either way the importer only ever writes a play action with no argument overrides, so an
+ /// action the user demoted from being the play action, or gave arguments of its own, is
+ /// theirs: repointing it would re-promote it beside their own play action, or launch the
+ /// new emulator with arguments meant for the old one.
+ ///
+ public static bool IsUnedited(GameAction action, AppliedPlayAction applied, Func actionEmulatorName)
+ {
+ if (action == null
+ || !action.IsPlayAction
+ || action.OverrideDefaultArgs
+ || !string.IsNullOrEmpty(action.AdditionalArguments))
+ return false;
+
+ if (applied.EmulatorId != Guid.Empty)
+ return Matches(action, applied);
+
+ var name = actionEmulatorName?.Invoke();
+ return !string.IsNullOrEmpty(name)
+ && string.Equals(action.Name, NameFor(name), StringComparison.Ordinal);
+ }
+
+ // Playnite writes an unset profile as either null or "", and the two mean the same thing.
+ private static bool SameProfile(string left, string right) =>
+ string.Equals(left ?? "", right ?? "", StringComparison.Ordinal);
+ }
+}
diff --git a/IRomm.cs b/IRomm.cs
index 4b4f4af..c760986 100644
--- a/IRomm.cs
+++ b/IRomm.cs
@@ -17,5 +17,12 @@ internal interface IRomM
string GetPluginUserDataPath();
RomMRom FetchRom(string romId);
+ ///
+ /// The emulator mapping a game was imported under, from its ROM sidecar, or null when the
+ /// sidecar or the mapping is gone. Unlike the game's play action this follows the mapping
+ /// as the user edits it, because every import rewrites the sidecar.
+ ///
+ Settings.EmulatorMapping MappingFor(Game game);
+
}
}
\ No newline at end of file
diff --git a/Models/RomM/Rom/RomMRomLocal.cs b/Models/RomM/Rom/RomMRomLocal.cs
index 683584d..cbbd676 100644
--- a/Models/RomM/Rom/RomMRomLocal.cs
+++ b/Models/RomM/Rom/RomMRomLocal.cs
@@ -57,6 +57,13 @@ public class RomMRomLocal
public string SHA1 { get; set; }
public Guid MappingID { get; set; }
+ // The emulator and profile the importer last wrote onto the game's play action. An action
+ // that still carries them has not been touched since, so a later import may repoint it at
+ // the mapping's current emulator; one that differs is the user's own choice and is left
+ // alone. Empty on sidecars written before the plugin recorded this.
+ public Guid AppliedEmulatorID { get; set; }
+ public string AppliedEmulatorProfileID { get; set; }
+
public List ROMVersions { get; set; }
}
diff --git a/RomM.Tests/RomM.Tests.csproj b/RomM.Tests/RomM.Tests.csproj
index 15717a1..c65994a 100644
--- a/RomM.Tests/RomM.Tests.csproj
+++ b/RomM.Tests/RomM.Tests.csproj
@@ -53,6 +53,8 @@
+
+
diff --git a/RomM.Tests/RomMPlayActionTests.cs b/RomM.Tests/RomMPlayActionTests.cs
new file mode 100644
index 0000000..0cd9a8c
--- /dev/null
+++ b/RomM.Tests/RomMPlayActionTests.cs
@@ -0,0 +1,184 @@
+using Playnite.SDK.Models;
+using RomM.Games;
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace RomM.Tests
+{
+ public class RomMPlayActionTests
+ {
+ private static readonly Guid Mapped = Guid.NewGuid();
+ private static readonly Guid Other = Guid.NewGuid();
+
+ private static AppliedPlayAction Applied(Guid emulatorId, string profileId) =>
+ new AppliedPlayAction(emulatorId, profileId);
+
+ [Fact]
+ public void Build_produces_the_importers_play_action()
+ {
+ var action = RomMPlayAction.Build("RetroArch", Mapped, "profile-1");
+
+ Assert.Equal("Play in RetroArch", action.Name);
+ Assert.Equal(GameActionType.Emulator, action.Type);
+ Assert.Equal(Mapped, action.EmulatorId);
+ Assert.Equal("profile-1", action.EmulatorProfileId);
+ Assert.True(action.IsPlayAction);
+ }
+
+ // Repointing an existing action has to leave it identical to a freshly imported one, which
+ // is why both go through the same writer.
+ [Fact]
+ public void Apply_rewrites_an_existing_action_into_the_importers_shape()
+ {
+ var action = new GameAction { Name = "Play in Dolphin", Type = GameActionType.URL, EmulatorId = Other };
+
+ RomMPlayAction.Apply(action, "RetroArch", Mapped, "profile-1");
+
+ Assert.Equal("Play in RetroArch", action.Name);
+ Assert.Equal(GameActionType.Emulator, action.Type);
+ Assert.Equal(Mapped, action.EmulatorId);
+ Assert.Equal("profile-1", action.EmulatorProfileId);
+ Assert.True(action.IsPlayAction);
+ }
+
+ [Fact]
+ public void Find_prefers_the_emulator_action_marked_as_the_play_action()
+ {
+ var play = new GameAction { Type = GameActionType.Emulator, IsPlayAction = true, Name = "play" };
+
+ var found = RomMPlayAction.Find(new List
+ {
+ new GameAction { Type = GameActionType.URL, Name = "View in RomM" },
+ new GameAction { Type = GameActionType.Emulator, Name = "secondary" },
+ play,
+ });
+
+ Assert.Same(play, found);
+ }
+
+ // An emulator action that isn't flagged as the play action still tells us which emulator
+ // the game runs on, which is all save sync needs.
+ [Fact]
+ public void Find_falls_back_to_any_emulator_action()
+ {
+ var action = new GameAction { Type = GameActionType.Emulator, Name = "secondary" };
+
+ Assert.Same(action, RomMPlayAction.Find(new[]
+ {
+ new GameAction { Type = GameActionType.URL, IsPlayAction = true },
+ action,
+ }));
+ }
+
+ [Fact]
+ public void Find_returns_null_without_an_emulator_action()
+ {
+ Assert.Null(RomMPlayAction.Find(new[] { new GameAction { Type = GameActionType.URL } }));
+ Assert.Null(RomMPlayAction.Find(null));
+ }
+
+ // Playnite writes an unset profile as null or "" depending on where it came from.
+ [Theory]
+ [InlineData(null, "")]
+ [InlineData("", null)]
+ [InlineData("p", "p")]
+ public void Matches_treats_an_unset_profile_the_same_either_way(string actionProfile, string mappedProfile)
+ {
+ var action = new GameAction { EmulatorId = Mapped, EmulatorProfileId = actionProfile };
+
+ Assert.True(RomMPlayAction.Matches(action, Applied(Mapped, mappedProfile)));
+ }
+
+ [Fact]
+ public void Matches_is_false_for_another_emulator_or_profile()
+ {
+ var action = new GameAction { EmulatorId = Mapped, EmulatorProfileId = "p" };
+
+ Assert.False(RomMPlayAction.Matches(action, Applied(Other, "p")));
+ Assert.False(RomMPlayAction.Matches(action, Applied(Mapped, "q")));
+ Assert.False(RomMPlayAction.Matches(null, Applied(Mapped, "p")));
+ }
+
+ [Fact]
+ public void An_action_still_carrying_what_the_plugin_applied_is_unedited()
+ {
+ var action = new GameAction { Name = "anything", IsPlayAction = true, EmulatorId = Mapped, EmulatorProfileId = "p" };
+
+ Assert.True(RomMPlayAction.IsUnedited(action, Applied(Mapped, "p"), () => "Dolphin"));
+ }
+
+ // Once the user has repointed the action, the recorded applied emulator no longer matches
+ // and the action is theirs -- a later mapping change must not take it back.
+ [Fact]
+ public void An_action_repointed_by_the_user_is_not_unedited()
+ {
+ var action = new GameAction { Name = "Play in Dolphin", IsPlayAction = true, EmulatorId = Other, EmulatorProfileId = "q" };
+
+ Assert.False(RomMPlayAction.IsUnedited(action, Applied(Mapped, "p"), () => "Dolphin"));
+ }
+
+ // Sidecars from before the plugin recorded what it applied: the generated name is the only
+ // marker that the importer wrote the action and nobody has touched it since.
+ [Fact]
+ public void Without_a_recorded_emulator_the_generated_name_marks_the_action_as_the_plugins()
+ {
+ var action = new GameAction { Name = "Play in Dolphin", IsPlayAction = true, EmulatorId = Other };
+
+ Assert.True(RomMPlayAction.IsUnedited(action, Applied(Guid.Empty, null), () => "Dolphin"));
+ }
+
+ [Theory]
+ [InlineData("Play with Dolphin", "Dolphin")]
+ [InlineData("Play in Dolphin", "RetroArch")]
+ [InlineData("Play in Dolphin", null)]
+ public void Without_a_recorded_emulator_anything_but_the_generated_name_is_left_alone(string name, string emulatorName)
+ {
+ var action = new GameAction { Name = name, IsPlayAction = true, EmulatorId = Other };
+
+ Assert.False(RomMPlayAction.IsUnedited(action, Applied(Guid.Empty, null), () => emulatorName));
+ }
+
+ // The emulator name costs a database lookup, so it is only resolved on the legacy path that
+ // actually needs it.
+ [Fact]
+ public void The_emulator_name_is_not_resolved_when_the_sidecar_records_what_we_applied()
+ {
+ var action = new GameAction { Name = "Play in Dolphin", IsPlayAction = true, EmulatorId = Mapped, EmulatorProfileId = "p" };
+ var resolved = false;
+
+ RomMPlayAction.IsUnedited(action, Applied(Mapped, "p"), () => { resolved = true; return "Dolphin"; });
+
+ Assert.False(resolved);
+ }
+
+ // The importer only writes a play action. One the user demoted is theirs: repointing it
+ // would mark it as a play action again beside the one they chose.
+ [Fact]
+ public void An_action_no_longer_marked_as_the_play_action_is_not_unedited()
+ {
+ var action = new GameAction { Name = "Play in Dolphin", IsPlayAction = false, EmulatorId = Mapped, EmulatorProfileId = "p" };
+
+ Assert.False(RomMPlayAction.IsUnedited(action, Applied(Mapped, "p"), () => "Dolphin"));
+ Assert.False(RomMPlayAction.IsUnedited(action, Applied(Guid.Empty, null), () => "Dolphin"));
+ }
+
+ // Repointing keeps every field the importer does not own, so arguments the user added for
+ // one emulator would be passed to the next.
+ [Fact]
+ public void An_action_with_argument_overrides_is_not_unedited()
+ {
+ var additional = new GameAction { IsPlayAction = true, EmulatorId = Mapped, EmulatorProfileId = "p", AdditionalArguments = "--fullscreen" };
+ var overridden = new GameAction { IsPlayAction = true, EmulatorId = Mapped, EmulatorProfileId = "p", OverrideDefaultArgs = true };
+
+ Assert.False(RomMPlayAction.IsUnedited(additional, Applied(Mapped, "p"), () => "Dolphin"));
+ Assert.False(RomMPlayAction.IsUnedited(overridden, Applied(Mapped, "p"), () => "Dolphin"));
+ }
+
+ [Fact]
+ public void A_missing_action_is_never_unedited()
+ {
+ Assert.False(RomMPlayAction.IsUnedited(null, Applied(Mapped, "p"), () => "RetroArch"));
+ }
+ }
+}
diff --git a/RomM.Tests/SaveEmulatorResolverTests.cs b/RomM.Tests/SaveEmulatorResolverTests.cs
new file mode 100644
index 0000000..2123a02
--- /dev/null
+++ b/RomM.Tests/SaveEmulatorResolverTests.cs
@@ -0,0 +1,181 @@
+using Playnite.SDK.Models;
+using RomM.Saves;
+using RomM.Saves.Handlers;
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace RomM.Tests
+{
+ public class SaveEmulatorResolverTests
+ {
+ private static readonly SaveHandlerRegistry Handlers = new SaveHandlerRegistry();
+
+ private static Emulator RetroArch(Guid? id = null) =>
+ new Emulator { Id = id ?? Guid.NewGuid(), Name = "RetroArch" };
+
+ private static Emulator Dolphin() =>
+ new Emulator { Id = Guid.NewGuid(), Name = "Dolphin" };
+
+ private static SaveEmulatorCandidate FromAction(Emulator emulator, EmulatorProfile profile = null) =>
+ new SaveEmulatorCandidate { Emulator = emulator, Profile = profile };
+
+ private static SaveEmulatorCandidate FromMapping(Emulator emulator, EmulatorProfile profile = null) =>
+ new SaveEmulatorCandidate { FromMapping = true, Emulator = emulator, Profile = profile };
+
+ // The action a user repointed at another supported emulator is their choice, and outranks
+ // whatever the platform mapping says.
+ [Fact]
+ public void The_play_action_wins_when_its_emulator_is_supported()
+ {
+ var fromAction = RetroArch();
+
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(fromAction),
+ FromMapping(RetroArch()),
+ });
+
+ Assert.Same(fromAction, resolution.Emulator);
+ Assert.False(resolution.FromMapping);
+ Assert.NotNull(resolution.Handler);
+ }
+
+ // The action is what launches, so syncing the mapping's emulator in its place would download
+ // where the launched emulator never reads. It is reported as unsupported, naming the
+ // mapping's emulator so the user knows what to point the action at.
+ [Fact]
+ public void The_mapping_does_not_replace_an_unsupported_play_action_emulator()
+ {
+ var fromMapping = RetroArch();
+
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(Dolphin()),
+ FromMapping(fromMapping),
+ });
+
+ Assert.Equal("Dolphin", resolution.Emulator?.Name);
+ Assert.Null(resolution.Handler);
+ Assert.Same(fromMapping, resolution.PassedOverMappingEmulator);
+ }
+
+ // Nor does it replace a supported one the user chose.
+ [Fact]
+ public void The_mapping_is_not_passed_over_when_the_action_names_the_same_emulator()
+ {
+ var id = Guid.NewGuid();
+
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(RetroArch(id)),
+ FromMapping(RetroArch(id)),
+ });
+
+ Assert.False(resolution.FromMapping);
+ Assert.Null(resolution.PassedOverMappingEmulator);
+ }
+
+ // With no emulator on the action there is nothing launched to contradict, so the mapping
+ // is the best answer there is.
+ [Fact]
+ public void The_mapping_is_used_when_the_play_action_names_no_emulator()
+ {
+ var fromMapping = RetroArch();
+
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(null),
+ FromMapping(fromMapping),
+ });
+
+ Assert.Same(fromMapping, resolution.Emulator);
+ Assert.True(resolution.FromMapping);
+ Assert.NotNull(resolution.Handler);
+ }
+
+ // Reported as unsupported rather than as "no emulator": the two need different advice, and
+ // the emulator named is the one the user would go looking for.
+ [Fact]
+ public void An_unsupported_play_action_emulator_is_reported_as_unsupported()
+ {
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(Dolphin()),
+ FromMapping(new Emulator { Id = Guid.NewGuid(), Name = "PCSX2" }),
+ });
+
+ Assert.Equal("Dolphin", resolution.Emulator?.Name);
+ Assert.Null(resolution.Handler);
+ Assert.Null(resolution.PassedOverMappingEmulator);
+ }
+
+ [Fact]
+ public void Candidates_without_an_emulator_are_reported_as_none_set()
+ {
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(null),
+ FromMapping(null),
+ null,
+ });
+
+ Assert.Null(resolution.Emulator);
+ Assert.Null(resolution.Handler);
+ }
+
+ [Fact]
+ public void No_candidates_at_all_are_reported_as_none_set()
+ {
+ Assert.Null(SaveEmulatorResolver.Resolve(Handlers, null).Emulator);
+ }
+
+ // An action can name an emulator without naming a profile. For RetroArch the profile is the
+ // core, and the core is a folder in the save path, so the mapping's profile for that same
+ // emulator is better than none.
+ [Fact]
+ public void A_profile_is_borrowed_from_another_candidate_for_the_same_emulator()
+ {
+ var id = Guid.NewGuid();
+ var profile = new BuiltInEmulatorProfile { Name = "mGBA" };
+
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(RetroArch(id)),
+ FromMapping(RetroArch(id), profile),
+ });
+
+ Assert.False(resolution.FromMapping);
+ Assert.Same(profile, resolution.Profile);
+ }
+
+ // A profile belongs to the emulator it was defined on; borrowing across emulators would
+ // name a core that emulator never runs.
+ [Fact]
+ public void A_profile_is_not_borrowed_from_a_different_emulator()
+ {
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new[]
+ {
+ FromAction(RetroArch()),
+ FromMapping(RetroArch(), new BuiltInEmulatorProfile { Name = "mGBA" }),
+ });
+
+ Assert.Null(resolution.Profile);
+ }
+
+ [Fact]
+ public void The_candidates_own_profile_is_kept()
+ {
+ var id = Guid.NewGuid();
+ var own = new BuiltInEmulatorProfile { Name = "Dolphin - GC/Wii" };
+
+ var resolution = SaveEmulatorResolver.Resolve(Handlers, new List
+ {
+ FromAction(RetroArch(id), own),
+ FromMapping(RetroArch(id), new BuiltInEmulatorProfile { Name = "mGBA" }),
+ });
+
+ Assert.Same(own, resolution.Profile);
+ }
+ }
+}
diff --git a/RomM.cs b/RomM.cs
index 7e6d2d0..5cb092b 100644
--- a/RomM.cs
+++ b/RomM.cs
@@ -116,6 +116,13 @@ public RomM(IPlayniteAPI api) : base(api)
#region Helper functions
public string CombineUrl(string baseUrl, string relativePath) => RomMUrl.Combine(baseUrl, relativePath);
+ /// The ROM sidecar for a game, or null when it is missing or unreadable.
+ internal RomMRomLocal LoadGameData(Game game) =>
+ RomMGameData.Load(ROMDataPath, game?.GameId, Logger, game?.Name);
+
+ public EmulatorMapping MappingFor(Game game) =>
+ Settings?.MappingById(LoadGameData(game)?.MappingID ?? Guid.Empty);
+
public RomMRom FetchRom(string romId)
{
string romUrl = CombineUrl(Settings.RomMHost, $"api/roms/{romId}");
@@ -253,7 +260,7 @@ public override void OnApplicationStarted(OnApplicationStartedEventArgs args)
{
if (RomMGameId.TryParse(item.GameId, out int _, out var sha1))
{
- var romDataFile = $"{ROMDataPath}{sha1}.json";
+ var romDataFile = RomMGameData.PathFor(ROMDataPath, sha1);
if (File.Exists(romDataFile))
{
File.Delete(romDataFile);
@@ -355,30 +362,21 @@ public override IEnumerable GetGameMenuItems(GetGameMenuItemsArgs
});
}
- string romDataFile = $"{ROMDataPath}{sha1}.json";
- if (Settings.MergeRevisions && File.Exists(romDataFile) && game.IsInstalled)
+ if (Settings.MergeRevisions && game.IsInstalled)
{
- try
+ var gameData = RomMGameData.LoadBySha1(ROMDataPath, sha1, Logger, out _, game.Name);
+ if (gameData?.ROMVersions?.Count > 1)
{
- string json = File.ReadAllText(romDataFile);
- var gameData = JsonConvert.DeserializeObject(json);
- if(gameData.ROMVersions.Count > 1)
+ gameMenuItems.Add(new GameMenuItem
{
- gameMenuItems.Add(new GameMenuItem
+ //MenuSection = "@",
+ Description = "Switch ROM Version!",
+ Action = (gameMenuItem) =>
{
- //MenuSection = "@",
- Description = "Switch ROM Version!",
- Action = (gameMenuItem) =>
- {
- Playnite.InstallGame(args.Games.First().Id);
- }
- });
- }
+ Playnite.InstallGame(args.Games.First().Id);
+ }
+ });
}
- catch (Exception)
- {
- Logger.Error($"{args.Games.First().Name} GameID is malformed or json file is corrupted!");
- }
}
}
return gameMenuItems;
@@ -402,7 +400,7 @@ public override IEnumerable GetInstallActions(GetInstallActio
else
{
// Pull game file from RomM data directory
- if (!RomMGameId.TryParse(gameID, out int _, out string romMSHA1) || !File.Exists($"{ROMDataPath}{romMSHA1}.json"))
+ if (!RomMGameId.TryParse(gameID, out int _, out string romMSHA1))
{
Logger.Error($"{args.Game.Name} GameID is malformed!");
romData.Id = (int)InstallStatus.Cancelled;
@@ -410,19 +408,10 @@ public override IEnumerable GetInstallActions(GetInstallActio
yield break;
}
- try
- {
- string json = File.ReadAllText($"{ROMDataPath}{romMSHA1}.json");
- gameData = JsonConvert.DeserializeObject(json);
- }
- catch (Exception)
- {
- Logger.Error($"{args.Game.Name} GameID is malformed or {romMSHA1} json file is corrupted!");
- romData.Id = (int)InstallStatus.Cancelled;
- }
-
- if (romData.Id == (int)InstallStatus.Cancelled || gameData?.ROMVersions == null || gameData.ROMVersions.Count == 0)
+ gameData = RomMGameData.LoadBySha1(ROMDataPath, romMSHA1, Logger, out _, args.Game.Name);
+ if (gameData?.ROMVersions == null || gameData.ROMVersions.Count == 0)
{
+ Logger.Error($"{args.Game.Name} has no usable ROM data file; run update game library before installing!");
romData.Id = (int)InstallStatus.Cancelled;
yield return new RomMInstallController(args.Game, this, romData);
yield break;
@@ -431,7 +420,7 @@ public override IEnumerable GetInstallActions(GetInstallActio
// Set ROM data to base ROM
romData = GameInstallInfo.From(
gameData.ROMVersions[0],
- Settings.Mappings.FirstOrDefault(x => x.MappingId == gameData.MappingID));
+ Settings.MappingById(gameData.MappingID));
// If Siblings are available prompt user with version selection
if (Settings.MergeRevisions && gameData.ROMVersions?.Count > 1)
@@ -485,7 +474,7 @@ public override IEnumerable GetInstallActions(GetInstallActio
gameData.ROMVersions[0].IsSelected = true;
}
- File.WriteAllText($"{ROMDataPath}{romMSHA1}.json", JsonConvert.SerializeObject(gameData));
+ RomMGameData.Save(ROMDataPath, romMSHA1, gameData);
}
yield return new RomMInstallController(args.Game, this, romData);
@@ -495,23 +484,7 @@ public override IEnumerable GetUninstallActions(GetUninstal
{
if (args.Game.PluginId == Id)
{
- EmulatorMapping mapping = null;
-
- try
- {
- var splitID = args.Game.GameId.Split(':');
- string sidecarPath = $"{ROMDataPath}{splitID[1]}.json";
- if (File.Exists(sidecarPath))
- {
- var existingJson = File.ReadAllText(sidecarPath);
- var localROM = JsonConvert.DeserializeObject(existingJson);
- mapping = Settings.Mappings.FirstOrDefault(x => x.MappingId == localROM.MappingID);
- }
- }
- catch (Exception)
- {
- Logger.Error($"{args.Game.Name} GameID is malformed or json file is corrupted!");
- }
+ EmulatorMapping mapping = MappingFor(args.Game);
if (mapping == null)
yield return null;
@@ -664,14 +637,15 @@ private void OnItemUpdated(object sender, ItemUpdatedEventArgs e)
DownloadQueueController?.Cancel(newGame.Id);
}
- if (Settings.KeepRomMSynced == true)
+ // The importer wrote this change itself; don't push it back. Consumed whether or not
+ // sync is on, so a suppression set while it is off cannot swallow a later real edit.
+ if (ignoredGameIds.TryRemove(newGame.Id, out byte _))
{
- // The importer wrote the server's own values into this game; don't push them back.
- if (ignoredGameIds.TryRemove(newGame.Id, out byte _))
- {
- continue;
- }
+ continue;
+ }
+ if (Settings.KeepRomMSynced == true)
+ {
if(!RomMGameId.TryParse(newGame.GameId, out int romMId, out string _))
{
Logger.Error($"{newGame.Name} GameID is malformed!");
diff --git a/Saves/SaveEmulatorResolver.cs b/Saves/SaveEmulatorResolver.cs
new file mode 100644
index 0000000..98dc782
--- /dev/null
+++ b/Saves/SaveEmulatorResolver.cs
@@ -0,0 +1,103 @@
+using Playnite.SDK.Models;
+using RomM.Saves.Handlers;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RomM.Saves
+{
+ internal class SaveEmulatorCandidate
+ {
+ /// Whether this candidate came from the platform mapping rather than the play action.
+ public bool FromMapping { get; set; }
+
+ public Emulator Emulator { get; set; }
+ public EmulatorProfile Profile { get; set; }
+ }
+
+ ///
+ /// The outcome of the pick. A null means no candidate named one at all;
+ /// an emulator with a null means one is set but no handler knows where it
+ /// keeps its saves. The two need different advice, which is why they are distinguishable.
+ ///
+ internal class SaveEmulatorResolution
+ {
+ public Emulator Emulator { get; set; }
+ public EmulatorProfile Profile { get; set; }
+ public ISaveHandler Handler { get; set; }
+ public bool FromMapping { get; set; }
+
+ ///
+ /// The mapping's emulator when it is supported but was passed over because the game launches
+ /// another one. Set so the "unsupported" advice can point at it; never a sync target.
+ ///
+ public Emulator PassedOverMappingEmulator { get; set; }
+ }
+
+ ///
+ /// Picks the emulator a game's saves belong to, from the candidates in preference order.
+ ///
+ /// The play action decides: it is what Playnite launches, so its emulator is the one that reads
+ /// and writes the saves. Syncing another emulator's saves in its place would download where the
+ /// launched emulator never reads and upload a save it never wrote, so a mapping naming a
+ /// different emulator is never a fallback -- a stale action is fixed by the importer repointing
+ /// it instead. The mapping only answers when the action names no emulator at all, or lends its
+ /// profile to that same emulator. Kept free of Playnite lookups so the rules can be tested on
+ /// their own.
+ ///
+ internal static class SaveEmulatorResolver
+ {
+ public static SaveEmulatorResolution Resolve(SaveHandlerRegistry handlers, IEnumerable candidates)
+ {
+ var known = (candidates ?? Enumerable.Empty())
+ .Where(c => c != null && c.Emulator != null)
+ .ToList();
+
+ var launched = known.FirstOrDefault(c => !c.FromMapping)?.Emulator;
+ Emulator passedOver = null;
+ if (launched != null)
+ {
+ passedOver = known.FirstOrDefault(c => c.FromMapping && c.Emulator.Id != launched.Id
+ && handlers?.Find(c.Emulator) != null)?.Emulator;
+ known = known.Where(c => c.Emulator.Id == launched.Id).ToList();
+ }
+
+ foreach (var candidate in known)
+ {
+ var handler = handlers?.Find(candidate.Emulator);
+ if (handler == null)
+ continue;
+
+ return new SaveEmulatorResolution
+ {
+ Emulator = candidate.Emulator,
+ Profile = candidate.Profile ?? BorrowProfile(known, candidate),
+ Handler = handler,
+ FromMapping = candidate.FromMapping,
+ };
+ }
+
+ // An emulator is set but unsupported. The first candidate is the one the user would go
+ // looking for, so it is the one the message names.
+ return new SaveEmulatorResolution
+ {
+ Emulator = known.FirstOrDefault()?.Emulator,
+ PassedOverMappingEmulator = passedOver,
+ };
+ }
+
+ ///
+ /// A play action can name an emulator without naming a profile, and for RetroArch the
+ /// profile is what identifies the core -- which is a folder in the save path when
+ /// sort_savefiles_enable is on. Rather than resolve a path with the core folder missing,
+ /// where a download would land somewhere RetroArch never reads, take the profile from
+ /// another candidate for the same emulator.
+ ///
+ private static EmulatorProfile BorrowProfile(IEnumerable candidates, SaveEmulatorCandidate chosen)
+ {
+ return candidates
+ .Where(c => c != chosen && c.Profile != null && c.Emulator.Id == chosen.Emulator.Id)
+ .Select(c => c.Profile)
+ .FirstOrDefault();
+ }
+ }
+}
diff --git a/Saves/SaveSyncService.cs b/Saves/SaveSyncService.cs
index 81f607c..4aebebf 100644
--- a/Saves/SaveSyncService.cs
+++ b/Saves/SaveSyncService.cs
@@ -97,10 +97,10 @@ public class SyncOutcome
lock (_romLocks.GetOrAdd(romId, _ => new object()))
{
- var target = ResolveTarget(game);
+ var target = ResolveTarget(game, out string reason);
if (target == null)
{
- outcome.Message = "Save sync does not know where this game's emulator keeps its saves.";
+ outcome.Message = reason;
return outcome;
}
@@ -431,66 +431,118 @@ private bool HasDeviceFor(string host)
#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.
+ /// Finds the emulator this game's saves belong to, hands it to whichever handler recognises
+ /// it, and lets that handler locate the save. Null when no emulator is set, none is
+ /// supported, or the handler cannot work out a path -- says
+ /// which, because "somewhere in these three" is not something a user can act on.
///
- private SaveTarget ResolveTarget(Game game)
+ private SaveTarget ResolveTarget(Game game, out string reason)
{
+ reason = null;
+
var contentPath = game.Roms?.FirstOrDefault()?.Path;
if (string.IsNullOrEmpty(contentPath))
+ {
+ reason = $"{game.Name} has no ROM file for save sync to work from.";
return null;
+ }
- var action = EmulatorAction(game);
- var emulator = ResolveEmulator(action);
- if (emulator == null)
+ var resolution = ResolveEmulator(game);
+ if (resolution.Emulator == null)
+ {
+ reason = $"{game.Name} has no emulator set. Choose one in the game's Actions, " +
+ "or map its platform under RomM settings.";
return null;
+ }
- var handler = _handlers.Find(emulator);
- if (handler == null)
+ if (resolution.Handler == null)
{
- Logger.Info($"[SaveSync] No save handler for emulator '{emulator.Name}', skipping {game.Name}.");
+ Logger.Info($"[SaveSync] No save handler for emulator '{resolution.Emulator.Name}', skipping {game.Name}.");
+ reason = $"Save sync does not support {resolution.Emulator.Name} yet. " +
+ $"Supported: {SupportedEmulators}.";
+ if (resolution.PassedOverMappingEmulator != null)
+ {
+ reason += $" Its RomM platform mapping uses {resolution.PassedOverMappingEmulator.Name}: " +
+ "point the game's play action at it to sync its saves.";
+ }
return null;
}
- return handler.ResolveTarget(new SaveTargetRequest
+ if (resolution.FromMapping)
+ {
+ Logger.Info($"[SaveSync] {game.Name}'s play action names no emulator; " +
+ $"using {resolution.Emulator.Name} from its RomM platform mapping instead.");
+ }
+
+ var target = resolution.Handler.ResolveTarget(new SaveTargetRequest
{
Game = game,
- EmulatorInstallDir = PlaynitePath.Resolve(_romM.Playnite, emulator.InstallDir),
- Profile = ResolveProfile(action, emulator),
+ EmulatorInstallDir = PlaynitePath.Resolve(_romM.Playnite, resolution.Emulator.InstallDir),
+ Profile = resolution.Profile,
ContentPath = _romM.Playnite.ExpandGameVariables(game, contentPath),
Logger = Logger,
});
+
+ if (target == null)
+ {
+ reason = $"Could not work out where {resolution.Emulator.Name} keeps this game's saves.";
+ }
+
+ return target;
}
///
- /// The emulator comes from the action Playnite actually launches with, not from the
- /// the game was imported under: a user who repoints the play
- /// action at another emulator should have their saves follow it.
+ /// The play action decides, since it is what launches; the mapping fills in when the action
+ /// names no emulator or no profile. See for the rules;
+ /// this half is only the Playnite lookups.
///
- private Emulator ResolveEmulator(GameAction action)
+ private SaveEmulatorResolution ResolveEmulator(Game game)
{
+ var candidates = new List();
+
+ var action = RomMPlayAction.Find(game.GameActions);
+ SaveEmulatorCandidate fromAction = null;
if (action != null && action.EmulatorId != Guid.Empty)
- return _romM.Playnite.Database.Emulators?.FirstOrDefault(e => e.Id == action.EmulatorId);
+ {
+ var emulator = _romM.Playnite.Database.Emulators?.Get(action.EmulatorId);
+ fromAction = new SaveEmulatorCandidate
+ {
+ Emulator = emulator,
+ Profile = ProfileOf(emulator, action.EmulatorProfileId),
+ };
+ candidates.Add(fromAction);
+ }
- return null;
+ // Reading the mapping means reading the ROM's sidecar off disk, and this runs on the
+ // pre-launch path, so it is skipped when the action can answer on its own -- which it
+ // cannot if its emulator is unsupported, or if it names no profile for the handler to
+ // take the core from. A platform two mappings cover records whichever import ran last,
+ // which says nothing about this game, so its mapping is not consulted at all.
+ if (fromAction?.Profile == null || _handlers.Find(fromAction.Emulator) == null)
+ {
+ var mapping = _romM.MappingFor(game);
+ if (mapping != null && !Settings.HasRivalMapping(mapping))
+ {
+ candidates.Add(new SaveEmulatorCandidate
+ {
+ FromMapping = true,
+ Emulator = mapping.Emulator,
+ Profile = mapping.EmulatorProfile,
+ });
+ }
+ }
+
+ return SaveEmulatorResolver.Resolve(_handlers, candidates);
}
- private static EmulatorProfile ResolveProfile(GameAction action, Emulator emulator)
+ private static EmulatorProfile ProfileOf(Emulator emulator, string profileId)
{
- var profileId = action?.EmulatorProfileId;
- if (string.IsNullOrEmpty(profileId))
+ if (emulator == null || 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
diff --git a/Settings/EmulatorMapping.cs b/Settings/EmulatorMapping.cs
index 32d2662..7bbadf1 100644
--- a/Settings/EmulatorMapping.cs
+++ b/Settings/EmulatorMapping.cs
@@ -108,6 +108,14 @@ public Emulator Emulator
_emulator = value;
_emulatorId = value.Id;
AvailableProfiles = Emulator?.SelectableProfiles;
+ // A profile belongs to one emulator. Keeping the previous emulator's would pair it
+ // with this one and leave every game imported through the mapping unable to launch.
+ if (_emulatorProfileId != null && value.SelectableProfiles?.Any(p => p.Id == _emulatorProfileId) != true)
+ {
+ _emulatorProfile = null;
+ _emulatorProfileId = null;
+ OnPropertyChanged(nameof(EmulatorProfile));
+ }
RomMPlatform = new RomMPlatform();
MappingName = value.Name;
OnPropertyChanged();
@@ -161,6 +169,18 @@ public string EmulatorProfileId
}
}
+ /// Whether an emulator and one of its profiles are picked.
+ [JsonIgnore]
+ public bool HasEmulatorProfile => Emulator != null && EmulatorProfile != null;
+
+ /// Whether a RomM platform is picked; unset reads as -1 or an empty platform (Id 0).
+ [JsonIgnore]
+ public bool HasRomMPlatform => RomMPlatformId > 0 && RomMPlatform != null && RomMPlatform.Id > 0;
+
+ /// Whether the import controller will run this mapping, given it is enabled.
+ [JsonIgnore]
+ public bool IsImportable => HasEmulatorProfile && HasRomMPlatform;
+
// (Deprecated) DON'T USE
[JsonIgnore]
public Platform Platform
diff --git a/Settings/Settings.cs b/Settings/Settings.cs
index 0417861..b2983d2 100644
--- a/Settings/Settings.cs
+++ b/Settings/Settings.cs
@@ -276,6 +276,17 @@ public string ExcludeGenres
public ObservableCollection Mappings { get; set; }
+ /// The mapping with this id, or null. The link a game keeps to its mapping.
+ internal EmulatorMapping MappingById(Guid id) => Mappings?.FirstOrDefault(x => x.MappingId == id);
+
+ ///
+ /// Whether another mapping the importer will actually run covers the same RomM platform.
+ /// Both walk the same ROMs and each records itself as the ROM's mapping, so for such a
+ /// platform the sidecar's mapping is only whichever pass ran last.
+ ///
+ internal bool HasRivalMapping(EmulatorMapping mapping) =>
+ mapping != null && Mappings?.Count(m => m.Enabled && m.IsImportable && m.RomMPlatformId == mapping.RomMPlatformId) > 1;
+
public List RomMPlatforms
{
get => _romMPlatforms;