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;