From 35fb409e85ef07b8e1c3a582cdc6615e6da71429 Mon Sep 17 00:00:00 2001 From: Isaac Marovitz <42140194+IsaacMarovitz@users.noreply.github.com> Date: Fri, 26 Jan 2024 01:02:28 +0000 Subject: [PATCH] Ava UI: Mod Manager (#4390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Let’s start again * Read folders and such * Remove Open Mod Folder menu items * Fix folder opening, Selecting/deselecting * She works * Fix GTK * AddMod * Delete * Fix duplicate entries * Fix file check * Avalonia 11 * Style fixes * Final style fixes * Might be too general * Remove unnecessary using * Enable new mods by default * More cleanup * Fix saving metadata * Dont deseralise ModMetadata several times * Avalonia I hate you * Confirmation dialgoues * Allow selecting multiple folders * Add back secondary folder * Search both paths * Fix formatting * Apply suggestions from code review Co-authored-by: Ac_K * Rename Title to Application * Generic locale key * Apply suggestions from code review Co-authored-by: Ac_K * Locale Updates * GDK Feedback * Fix --------- Co-authored-by: Ac_K --- src/Ryujinx.Ava/Assets/Locales/en_US.json | 14 +- .../UI/Controls/ApplicationContextMenu.axaml | 10 +- .../Controls/ApplicationContextMenu.axaml.cs | 20 +- src/Ryujinx.Ava/UI/Models/ModModel.cs | 30 ++ .../DownloadableContentManagerViewModel.cs | 9 +- .../UI/ViewModels/ModManagerViewModel.cs | 260 ++++++++++++++++++ .../UI/ViewModels/TitleUpdateViewModel.cs | 2 +- .../UI/Windows/CheatWindow.axaml.cs | 2 +- .../UI/Windows/ModManagerWindow.axaml | 179 ++++++++++++ .../UI/Windows/ModManagerWindow.axaml.cs | 139 ++++++++++ src/Ryujinx.Common/Configuration/Mod.cs | 9 + .../Configuration/ModMetadata.cs | 14 + .../ModMetadataJsonSerializerContext.cs | 10 + src/Ryujinx.HLE/HOS/ModLoader.cs | 178 ++++++++---- .../Extensions/LocalFileSystemExtensions.cs | 5 +- .../Ui/Widgets/GameTableContextMenu.cs | 4 +- src/Ryujinx/Ui/Windows/CheatWindow.cs | 2 +- 17 files changed, 790 insertions(+), 97 deletions(-) create mode 100644 src/Ryujinx.Ava/UI/Models/ModModel.cs create mode 100644 src/Ryujinx.Ava/UI/ViewModels/ModManagerViewModel.cs create mode 100644 src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml create mode 100644 src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml.cs create mode 100644 src/Ryujinx.Common/Configuration/Mod.cs create mode 100644 src/Ryujinx.Common/Configuration/ModMetadata.cs create mode 100644 src/Ryujinx.Common/Configuration/ModMetadataJsonSerializerContext.cs diff --git a/src/Ryujinx.Ava/Assets/Locales/en_US.json b/src/Ryujinx.Ava/Assets/Locales/en_US.json index dcb7421fc2..79692a08c8 100644 --- a/src/Ryujinx.Ava/Assets/Locales/en_US.json +++ b/src/Ryujinx.Ava/Assets/Locales/en_US.json @@ -54,8 +54,6 @@ "GameListContextMenuManageTitleUpdatesToolTip": "Opens the Title Update management window", "GameListContextMenuManageDlc": "Manage DLC", "GameListContextMenuManageDlcToolTip": "Opens the DLC management window", - "GameListContextMenuOpenModsDirectory": "Open Mods Directory", - "GameListContextMenuOpenModsDirectoryToolTip": "Opens the directory which contains Application's Mods", "GameListContextMenuCacheManagement": "Cache Management", "GameListContextMenuCacheManagementPurgePptc": "Queue PPTC Rebuild", "GameListContextMenuCacheManagementPurgePptcToolTip": "Trigger PPTC to rebuild at boot time on the next game launch", @@ -383,7 +381,8 @@ "DialogUserProfileUnsavedChangesSubMessage": "Do you want to discard your changes?", "DialogControllerSettingsModifiedConfirmMessage": "The current controller settings has been updated.", "DialogControllerSettingsModifiedConfirmSubMessage": "Do you want to save?", - "DialogLoadNcaErrorMessage": "{0}. Errored File: {1}", + "DialogLoadFileErrorMessage": "{0}. Errored File: {1}", + "DialogModAlreadyExistsMessage": "Mod already exists", "DialogDlcNoDlcErrorMessage": "The specified file does not contain a DLC for the selected title!", "DialogPerformanceCheckLoggingEnabledMessage": "You have trace logging enabled, which is designed to be used by developers only.", "DialogPerformanceCheckLoggingEnabledConfirmMessage": "For optimal performance, it's recommended to disable trace logging. Would you like to disable trace logging now?", @@ -394,6 +393,8 @@ "DialogUpdateAddUpdateErrorMessage": "The specified file does not contain an update for the selected title!", "DialogSettingsBackendThreadingWarningTitle": "Warning - Backend Threading", "DialogSettingsBackendThreadingWarningMessage": "Ryujinx must be restarted after changing this option for it to apply fully. Depending on your platform, you may need to manually disable your driver's own multithreading when using Ryujinx's.", + "DialogModManagerDeletionWarningMessage": "You are about to delete the mod: {0}\n\nAre you sure you want to proceed?", + "DialogModManagerDeletionAllWarningMessage": "You are about to delete all mods for this title.\n\nAre you sure you want to proceed?", "SettingsTabGraphicsFeaturesOptions": "Features", "SettingsTabGraphicsBackendMultithreading": "Graphics Backend Multithreading:", "CommonAuto": "Auto", @@ -428,6 +429,7 @@ "DlcManagerRemoveAllButton": "Remove All", "DlcManagerEnableAllButton": "Enable All", "DlcManagerDisableAllButton": "Disable All", + "ModManagerDeleteAllButton": "Delete All", "MenuBarOptionsChangeLanguage": "Change Language", "MenuBarShowFileTypes": "Show File Types", "CommonSort": "Sort", @@ -502,6 +504,8 @@ "EnableInternetAccessTooltip": "Allows the emulated application to connect to the Internet.\n\nGames with a LAN mode can connect to each other when this is enabled and the systems are connected to the same access point. This includes real consoles as well.\n\nDoes NOT allow connecting to Nintendo servers. May cause crashing in certain games that try to connect to the Internet.\n\nLeave OFF if unsure.", "GameListContextMenuManageCheatToolTip": "Manage Cheats", "GameListContextMenuManageCheat": "Manage Cheats", + "GameListContextMenuManageModToolTip": "Manage Mods", + "GameListContextMenuManageMod": "Manage Mods", "ControllerSettingsStickRange": "Range:", "DialogStopEmulationTitle": "Ryujinx - Stop Emulation", "DialogStopEmulationMessage": "Are you sure you want to stop emulation?", @@ -513,8 +517,6 @@ "SettingsTabCpuMemory": "CPU Mode", "DialogUpdaterFlatpakNotSupportedMessage": "Please update Ryujinx via FlatHub.", "UpdaterDisabledWarningTitle": "Updater Disabled!", - "GameListContextMenuOpenSdModsDirectory": "Open Atmosphere Mods Directory", - "GameListContextMenuOpenSdModsDirectoryToolTip": "Opens the alternative SD card Atmosphere directory which contains Application's Mods. Useful for mods that are packaged for real hardware.", "ControllerSettingsRotate90": "Rotate 90° Clockwise", "IconSize": "Icon Size", "IconSizeTooltip": "Change the size of game icons", @@ -586,6 +588,7 @@ "Writable": "Writable", "SelectDlcDialogTitle": "Select DLC files", "SelectUpdateDialogTitle": "Select update files", + "SelectModDialogTitle": "Select mod directory", "UserProfileWindowTitle": "User Profiles Manager", "CheatWindowTitle": "Cheats Manager", "DlcWindowTitle": "Manage Downloadable Content for {0} ({1})", @@ -593,6 +596,7 @@ "CheatWindowHeading": "Cheats Available for {0} [{1}]", "BuildId": "BuildId:", "DlcWindowHeading": "{0} Downloadable Content(s)", + "ModWindowHeading": "{0} Mod(s)", "UserProfilesEditProfile": "Edit Selected", "Cancel": "Cancel", "Save": "Save", diff --git a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml index b8fe7e76f8..7f786cf383 100644 --- a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml +++ b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml @@ -47,13 +47,9 @@ Header="{locale:Locale GameListContextMenuManageCheat}" ToolTip.Tip="{locale:Locale GameListContextMenuManageCheatToolTip}" /> - + Click="OpenModManager_Click" + Header="{locale:Locale GameListContextMenuManageMod}" + ToolTip.Tip="{locale:Locale GameListContextMenuManageModToolTip}" /> _enabled; + set + { + _enabled = value; + OnPropertyChanged(); + } + } + + public string Path { get; } + public string Name { get; } + + public ModModel(string path, string name, bool enabled) + { + Path = path; + Name = name; + Enabled = enabled; + } + } +} diff --git a/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs index cdecae77dd..2cd714f447 100644 --- a/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs +++ b/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs @@ -39,6 +39,7 @@ namespace Ryujinx.Ava.UI.ViewModels private string _search; private readonly ulong _titleId; + private readonly IStorageProvider _storageProvider; private static readonly DownloadableContentJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); @@ -90,8 +91,6 @@ namespace Ryujinx.Ava.UI.ViewModels get => string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowHeading], DownloadableContents.Count); } - public IStorageProvider StorageProvider; - public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ulong titleId) { _virtualFileSystem = virtualFileSystem; @@ -100,7 +99,7 @@ namespace Ryujinx.Ava.UI.ViewModels if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - StorageProvider = desktop.MainWindow.StorageProvider; + _storageProvider = desktop.MainWindow.StorageProvider; } _downloadableContentJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "dlc.json"); @@ -194,7 +193,7 @@ namespace Ryujinx.Ava.UI.ViewModels { Dispatcher.UIThread.InvokeAsync(async () => { - await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogLoadNcaErrorMessage], ex.Message, containerPath)); + await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogLoadFileErrorMessage], ex.Message, containerPath)); }); } @@ -203,7 +202,7 @@ namespace Ryujinx.Ava.UI.ViewModels public async void Add() { - var result = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + var result = await _storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = LocaleManager.Instance[LocaleKeys.SelectDlcDialogTitle], AllowMultiple = true, diff --git a/src/Ryujinx.Ava/UI/ViewModels/ModManagerViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/ModManagerViewModel.cs new file mode 100644 index 0000000000..7340873aef --- /dev/null +++ b/src/Ryujinx.Ava/UI/ViewModels/ModManagerViewModel.cs @@ -0,0 +1,260 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using DynamicData; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Helpers; +using Ryujinx.Ava.UI.Models; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.HOS; +using System.IO; +using System.Linq; + +namespace Ryujinx.Ava.UI.ViewModels +{ + public class ModManagerViewModel : BaseModel + { + private readonly string _modJsonPath; + + private AvaloniaList _mods = new(); + private AvaloniaList _views = new(); + private AvaloniaList _selectedMods = new(); + + private string _search; + private readonly ulong _applicationId; + private readonly IStorageProvider _storageProvider; + + private static readonly ModMetadataJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + + public AvaloniaList Mods + { + get => _mods; + set + { + _mods = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ModCount)); + Sort(); + } + } + + public AvaloniaList Views + { + get => _views; + set + { + _views = value; + OnPropertyChanged(); + } + } + + public AvaloniaList SelectedMods + { + get => _selectedMods; + set + { + _selectedMods = value; + OnPropertyChanged(); + } + } + + public string Search + { + get => _search; + set + { + _search = value; + OnPropertyChanged(); + Sort(); + } + } + + public string ModCount + { + get => string.Format(LocaleManager.Instance[LocaleKeys.ModWindowHeading], Mods.Count); + } + + public ModManagerViewModel(ulong applicationId) + { + _applicationId = applicationId; + + _modJsonPath = Path.Combine(AppDataManager.GamesDirPath, applicationId.ToString("x16"), "mods.json"); + + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + _storageProvider = desktop.MainWindow.StorageProvider; + } + + LoadMods(applicationId); + } + + private void LoadMods(ulong applicationId) + { + Mods.Clear(); + SelectedMods.Clear(); + + string[] modsBasePaths = [ModLoader.GetSdModsBasePath(), ModLoader.GetModsBasePath()]; + + foreach (var path in modsBasePaths) + { + var modCache = new ModLoader.ModCache(); + + ModLoader.QueryContentsDir(modCache, new DirectoryInfo(Path.Combine(path, "contents")), applicationId); + + foreach (var mod in modCache.RomfsDirs) + { + var modModel = new ModModel(mod.Path.Parent.FullName, mod.Name, mod.Enabled); + if (Mods.All(x => x.Path != mod.Path.Parent.FullName)) + { + Mods.Add(modModel); + } + } + + foreach (var mod in modCache.RomfsContainers) + { + Mods.Add(new ModModel(mod.Path.FullName, mod.Name, mod.Enabled)); + } + + foreach (var mod in modCache.ExefsDirs) + { + var modModel = new ModModel(mod.Path.Parent.FullName, mod.Name, mod.Enabled); + if (Mods.All(x => x.Path != mod.Path.Parent.FullName)) + { + Mods.Add(modModel); + } + } + + foreach (var mod in modCache.ExefsContainers) + { + Mods.Add(new ModModel(mod.Path.FullName, mod.Name, mod.Enabled)); + } + } + + Sort(); + } + + public void Sort() + { + Mods.AsObservableChangeSet() + .Filter(Filter) + .Bind(out var view).AsObservableList(); + + _views.Clear(); + _views.AddRange(view); + + SelectedMods = new(Views.Where(x => x.Enabled)); + + OnPropertyChanged(nameof(ModCount)); + OnPropertyChanged(nameof(Views)); + OnPropertyChanged(nameof(SelectedMods)); + } + + private bool Filter(object arg) + { + if (arg is ModModel content) + { + return string.IsNullOrWhiteSpace(_search) || content.Name.ToLower().Contains(_search.ToLower()); + } + + return false; + } + + public void Save() + { + ModMetadata modData = new(); + + foreach (ModModel mod in Mods) + { + modData.Mods.Add(new Mod + { + Name = mod.Name, + Path = mod.Path, + Enabled = SelectedMods.Contains(mod), + }); + } + + JsonHelper.SerializeToFile(_modJsonPath, modData, _serializerContext.ModMetadata); + } + + public void Delete(ModModel model) + { + Directory.Delete(model.Path, true); + + Mods.Remove(model); + OnPropertyChanged(nameof(ModCount)); + Sort(); + } + + private void AddMod(DirectoryInfo directory) + { + var directories = Directory.GetDirectories(directory.ToString(), "*", SearchOption.AllDirectories); + var destinationDir = ModLoader.GetApplicationDir(ModLoader.GetSdModsBasePath(), _applicationId.ToString("x16")); + + foreach (var dir in directories) + { + string dirToCreate = dir.Replace(directory.Parent.ToString(), destinationDir); + + // Mod already exists + if (Directory.Exists(dirToCreate)) + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadFileErrorMessage, LocaleKeys.DialogModAlreadyExistsMessage, dirToCreate)); + }); + + return; + } + + Directory.CreateDirectory(dirToCreate); + } + + var files = Directory.GetFiles(directory.ToString(), "*", SearchOption.AllDirectories); + + foreach (var file in files) + { + File.Copy(file, file.Replace(directory.Parent.ToString(), destinationDir), true); + } + + LoadMods(_applicationId); + } + + public async void Add() + { + var result = await _storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = LocaleManager.Instance[LocaleKeys.SelectModDialogTitle], + AllowMultiple = true, + }); + + foreach (var folder in result) + { + AddMod(new DirectoryInfo(folder.Path.LocalPath)); + } + } + + public void DeleteAll() + { + foreach (var mod in Mods) + { + Directory.Delete(mod.Path, true); + } + + Mods.Clear(); + OnPropertyChanged(nameof(ModCount)); + Sort(); + } + + public void EnableAll() + { + SelectedMods = new(Mods); + } + + public void DisableAll() + { + SelectedMods.Clear(); + } + } +} diff --git a/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs index 5090a8c70a..8a287ecac4 100644 --- a/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs +++ b/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs @@ -192,7 +192,7 @@ namespace Ryujinx.Ava.UI.ViewModels } catch (Exception ex) { - Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadNcaErrorMessage, ex.Message, path))); + Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadFileErrorMessage, ex.Message, path))); } } } diff --git a/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs index c2de67ab21..46441faa8b 100644 --- a/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs +++ b/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs @@ -41,7 +41,7 @@ namespace Ryujinx.Ava.UI.Windows InitializeComponent(); string modsBasePath = ModLoader.GetModsBasePath(); - string titleModsPath = ModLoader.GetTitleDir(modsBasePath, titleId); + string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, titleId); ulong titleIdValue = ulong.Parse(titleId, NumberStyles.HexNumber); _enabledCheatsPath = Path.Combine(titleModsPath, "cheats", "enabled.txt"); diff --git a/src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml b/src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml new file mode 100644 index 0000000000..d9f5864083 --- /dev/null +++ b/src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml.cs new file mode 100644 index 0000000000..5de09ba0b4 --- /dev/null +++ b/src/Ryujinx.Ava/UI/Windows/ModManagerWindow.axaml.cs @@ -0,0 +1,139 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Styling; +using FluentAvalonia.UI.Controls; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Helpers; +using Ryujinx.Ava.UI.Models; +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.Ui.Common.Helper; +using System.Threading.Tasks; +using Button = Avalonia.Controls.Button; + +namespace Ryujinx.Ava.UI.Windows +{ + public partial class ModManagerWindow : UserControl + { + public ModManagerViewModel ViewModel; + + public ModManagerWindow() + { + DataContext = this; + + InitializeComponent(); + } + + public ModManagerWindow(ulong titleId) + { + DataContext = ViewModel = new ModManagerViewModel(titleId); + + InitializeComponent(); + } + + public static async Task Show(ulong titleId, string titleName) + { + ContentDialog contentDialog = new() + { + PrimaryButtonText = "", + SecondaryButtonText = "", + CloseButtonText = "", + Content = new ModManagerWindow(titleId), + Title = string.Format(LocaleManager.Instance[LocaleKeys.ModWindowHeading], titleName, titleId.ToString("X16")), + }; + + Style bottomBorder = new(x => x.OfType().Name("DialogSpace").Child().OfType()); + bottomBorder.Setters.Add(new Setter(IsVisibleProperty, false)); + + contentDialog.Styles.Add(bottomBorder); + + await contentDialog.ShowAsync(); + } + + private void SaveAndClose(object sender, RoutedEventArgs e) + { + ViewModel.Save(); + ((ContentDialog)Parent).Hide(); + } + + private void Close(object sender, RoutedEventArgs e) + { + ((ContentDialog)Parent).Hide(); + } + + private async void DeleteMod(object sender, RoutedEventArgs e) + { + if (sender is Button button) + { + if (button.DataContext is ModModel model) + { + var result = await ContentDialogHelper.CreateConfirmationDialog( + LocaleManager.Instance[LocaleKeys.DialogWarning], + LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogModManagerDeletionWarningMessage, model.Name), + LocaleManager.Instance[LocaleKeys.InputDialogYes], + LocaleManager.Instance[LocaleKeys.InputDialogNo], + LocaleManager.Instance[LocaleKeys.RyujinxConfirm]); + + if (result == UserResult.Yes) + { + ViewModel.Delete(model); + } + } + } + } + + private async void DeleteAll(object sender, RoutedEventArgs e) + { + var result = await ContentDialogHelper.CreateConfirmationDialog( + LocaleManager.Instance[LocaleKeys.DialogWarning], + LocaleManager.Instance[LocaleKeys.DialogModManagerDeletionAllWarningMessage], + LocaleManager.Instance[LocaleKeys.InputDialogYes], + LocaleManager.Instance[LocaleKeys.InputDialogNo], + LocaleManager.Instance[LocaleKeys.RyujinxConfirm]); + + if (result == UserResult.Yes) + { + ViewModel.DeleteAll(); + } + } + + private void OpenLocation(object sender, RoutedEventArgs e) + { + if (sender is Button button) + { + if (button.DataContext is ModModel model) + { + OpenHelper.OpenFolder(model.Path); + } + } + } + + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + foreach (var content in e.AddedItems) + { + if (content is ModModel model) + { + var index = ViewModel.Mods.IndexOf(model); + + if (index != -1) + { + ViewModel.Mods[index].Enabled = true; + } + } + } + + foreach (var content in e.RemovedItems) + { + if (content is ModModel model) + { + var index = ViewModel.Mods.IndexOf(model); + + if (index != -1) + { + ViewModel.Mods[index].Enabled = false; + } + } + } + } + } +} diff --git a/src/Ryujinx.Common/Configuration/Mod.cs b/src/Ryujinx.Common/Configuration/Mod.cs new file mode 100644 index 0000000000..052c7c8d12 --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Mod.cs @@ -0,0 +1,9 @@ +namespace Ryujinx.Common.Configuration +{ + public class Mod + { + public string Name { get; set; } + public string Path { get; set; } + public bool Enabled { get; set; } + } +} diff --git a/src/Ryujinx.Common/Configuration/ModMetadata.cs b/src/Ryujinx.Common/Configuration/ModMetadata.cs new file mode 100644 index 0000000000..174320d0a5 --- /dev/null +++ b/src/Ryujinx.Common/Configuration/ModMetadata.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Ryujinx.Common.Configuration +{ + public struct ModMetadata + { + public List Mods { get; set; } + + public ModMetadata() + { + Mods = new List(); + } + } +} diff --git a/src/Ryujinx.Common/Configuration/ModMetadataJsonSerializerContext.cs b/src/Ryujinx.Common/Configuration/ModMetadataJsonSerializerContext.cs new file mode 100644 index 0000000000..8c1e242ad8 --- /dev/null +++ b/src/Ryujinx.Common/Configuration/ModMetadataJsonSerializerContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Ryujinx.Common.Configuration +{ + [JsonSourceGenerationOptions(WriteIndented = true)] + [JsonSerializable(typeof(ModMetadata))] + public partial class ModMetadataJsonSerializerContext : JsonSerializerContext + { + } +} diff --git a/src/Ryujinx.HLE/HOS/ModLoader.cs b/src/Ryujinx.HLE/HOS/ModLoader.cs index 834bc05951..669c775b76 100644 --- a/src/Ryujinx.HLE/HOS/ModLoader.cs +++ b/src/Ryujinx.HLE/HOS/ModLoader.cs @@ -7,6 +7,7 @@ using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.RomFs; using Ryujinx.Common.Configuration; using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; using Ryujinx.HLE.HOS.Kernel.Process; using Ryujinx.HLE.Loaders.Executables; using Ryujinx.HLE.Loaders.Mods; @@ -37,15 +38,19 @@ namespace Ryujinx.HLE.HOS private const string AmsNroPatchDir = "nro_patches"; private const string AmsKipPatchDir = "kip_patches"; + private static readonly ModMetadataJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + public readonly struct Mod where T : FileSystemInfo { public readonly string Name; public readonly T Path; + public readonly bool Enabled; - public Mod(string name, T path) + public Mod(string name, T path, bool enabled) { Name = name; Path = path; + Enabled = enabled; } } @@ -67,7 +72,7 @@ namespace Ryujinx.HLE.HOS } } - // Title dependent mods + // Application dependent mods public class ModCache { public List> RomfsContainers { get; } @@ -88,7 +93,7 @@ namespace Ryujinx.HLE.HOS } } - // Title independent mods + // Application independent mods private class PatchCache { public List> NsoPatches { get; } @@ -107,7 +112,7 @@ namespace Ryujinx.HLE.HOS } } - private readonly Dictionary _appMods; // key is TitleId + private readonly Dictionary _appMods; // key is ApplicationId private PatchCache _patches; private static readonly EnumerationOptions _dirEnumOptions; @@ -153,26 +158,52 @@ namespace Ryujinx.HLE.HOS return modsDir.FullName; } - private static DirectoryInfo FindTitleDir(DirectoryInfo contentsDir, string titleId) - => contentsDir.EnumerateDirectories(titleId, _dirEnumOptions).FirstOrDefault(); + private static DirectoryInfo FindApplicationDir(DirectoryInfo contentsDir, string applicationId) + => contentsDir.EnumerateDirectories(applicationId, _dirEnumOptions).FirstOrDefault(); - private static void AddModsFromDirectory(ModCache mods, DirectoryInfo dir, string titleId) + private static void AddModsFromDirectory(ModCache mods, DirectoryInfo dir, ModMetadata modMetadata) { System.Text.StringBuilder types = new(); foreach (var modDir in dir.EnumerateDirectories()) { types.Clear(); - Mod mod = new("", null); + Mod mod = new("", null, true); if (StrEquals(RomfsDir, modDir.Name)) { - mods.RomfsDirs.Add(mod = new Mod(dir.Name, modDir)); + bool enabled; + + try + { + var modData = modMetadata.Mods.Find(x => modDir.FullName.Contains(x.Path)); + enabled = modData.Enabled; + } + catch + { + // Mod is not in the list yet. New mods should be enabled by default. + enabled = true; + } + + mods.RomfsDirs.Add(mod = new Mod(dir.Name, modDir, enabled)); types.Append('R'); } else if (StrEquals(ExefsDir, modDir.Name)) { - mods.ExefsDirs.Add(mod = new Mod(dir.Name, modDir)); + bool enabled; + + try + { + var modData = modMetadata.Mods.Find(x => modDir.FullName.Contains(x.Path)); + enabled = modData.Enabled; + } + catch + { + // Mod is not in the list yet. New mods should be enabled by default. + enabled = true; + } + + mods.ExefsDirs.Add(mod = new Mod(dir.Name, modDir, enabled)); types.Append('E'); } else if (StrEquals(CheatDir, modDir.Name)) @@ -181,7 +212,7 @@ namespace Ryujinx.HLE.HOS } else { - AddModsFromDirectory(mods, modDir, titleId); + AddModsFromDirectory(mods, modDir, modMetadata); } if (types.Length > 0) @@ -191,18 +222,18 @@ namespace Ryujinx.HLE.HOS } } - public static string GetTitleDir(string modsBasePath, string titleId) + public static string GetApplicationDir(string modsBasePath, string applicationId) { var contentsDir = new DirectoryInfo(Path.Combine(modsBasePath, AmsContentsDir)); - var titleModsPath = FindTitleDir(contentsDir, titleId); + var applicationModsPath = FindApplicationDir(contentsDir, applicationId); - if (titleModsPath == null) + if (applicationModsPath == null) { - Logger.Info?.Print(LogClass.ModLoader, $"Creating mods directory for Title {titleId.ToUpper()}"); - titleModsPath = contentsDir.CreateSubdirectory(titleId); + Logger.Info?.Print(LogClass.ModLoader, $"Creating mods directory for Application {applicationId.ToUpper()}"); + applicationModsPath = contentsDir.CreateSubdirectory(applicationId); } - return titleModsPath.FullName; + return applicationModsPath.FullName; } // Static Query Methods @@ -238,47 +269,68 @@ namespace Ryujinx.HLE.HOS foreach (var modDir in patchDir.EnumerateDirectories()) { - patches.Add(new Mod(modDir.Name, modDir)); + patches.Add(new Mod(modDir.Name, modDir, true)); Logger.Info?.Print(LogClass.ModLoader, $"Found {type} patch '{modDir.Name}'"); } } - private static void QueryTitleDir(ModCache mods, DirectoryInfo titleDir) + private static void QueryApplicationDir(ModCache mods, DirectoryInfo applicationDir, ulong applicationId) { - if (!titleDir.Exists) + if (!applicationDir.Exists) { return; } - var fsFile = new FileInfo(Path.Combine(titleDir.FullName, RomfsContainer)); - if (fsFile.Exists) + string modJsonPath = Path.Combine(AppDataManager.GamesDirPath, applicationId.ToString("x16"), "mods.json"); + ModMetadata modMetadata = new(); + + if (File.Exists(modJsonPath)) { - mods.RomfsContainers.Add(new Mod($"<{titleDir.Name} RomFs>", fsFile)); + try + { + modMetadata = JsonHelper.DeserializeFromFile(modJsonPath, _serializerContext.ModMetadata); + } + catch + { + Logger.Warning?.Print(LogClass.ModLoader, $"Failed to deserialize mod data for {applicationId:X16} at {modJsonPath}"); + } } - fsFile = new FileInfo(Path.Combine(titleDir.FullName, ExefsContainer)); + var fsFile = new FileInfo(Path.Combine(applicationDir.FullName, RomfsContainer)); if (fsFile.Exists) { - mods.ExefsContainers.Add(new Mod($"<{titleDir.Name} ExeFs>", fsFile)); + var modData = modMetadata.Mods.Find(x => fsFile.FullName.Contains(x.Path)); + var enabled = modData == null || modData.Enabled; + + mods.RomfsContainers.Add(new Mod($"<{applicationDir.Name} RomFs>", fsFile, enabled)); } - AddModsFromDirectory(mods, titleDir, titleDir.Name); + fsFile = new FileInfo(Path.Combine(applicationDir.FullName, ExefsContainer)); + if (fsFile.Exists) + { + var modData = modMetadata.Mods.Find(x => fsFile.FullName.Contains(x.Path)); + var enabled = modData == null || modData.Enabled; + + mods.ExefsContainers.Add(new Mod($"<{applicationDir.Name} ExeFs>", fsFile, enabled)); + } + + AddModsFromDirectory(mods, applicationDir, modMetadata); } - public static void QueryContentsDir(ModCache mods, DirectoryInfo contentsDir, ulong titleId) + public static void QueryContentsDir(ModCache mods, DirectoryInfo contentsDir, ulong applicationId) { if (!contentsDir.Exists) { return; } - Logger.Info?.Print(LogClass.ModLoader, $"Searching mods for {((titleId & 0x1000) != 0 ? "DLC" : "Title")} {titleId:X16}"); + Logger.Info?.Print(LogClass.ModLoader, $"Searching mods for {((applicationId & 0x1000) != 0 ? "DLC" : "Application")} {applicationId:X16}"); - var titleDir = FindTitleDir(contentsDir, $"{titleId:x16}"); + var applicationDir = FindApplicationDir(contentsDir, $"{applicationId:x16}"); - if (titleDir != null) + if (applicationDir != null) { - QueryTitleDir(mods, titleDir); + QueryApplicationDir(mods, applicationDir, applicationId); } } @@ -387,9 +439,9 @@ namespace Ryujinx.HLE.HOS { if (IsContentsDir(searchDir.Name)) { - foreach ((ulong titleId, ModCache cache) in modCaches) + foreach ((ulong applicationId, ModCache cache) in modCaches) { - QueryContentsDir(cache, searchDir, titleId); + QueryContentsDir(cache, searchDir, applicationId); } return true; @@ -410,7 +462,7 @@ namespace Ryujinx.HLE.HOS if (!searchDir.Exists) { Logger.Warning?.Print(LogClass.ModLoader, $"Mod Search Dir '{searchDir.FullName}' doesn't exist"); - continue; + return; } if (!TryQuery(searchDir, patches, modCaches)) @@ -425,21 +477,21 @@ namespace Ryujinx.HLE.HOS patches.Initialized = true; } - public void CollectMods(IEnumerable titles, params string[] searchDirPaths) + public void CollectMods(IEnumerable applications, params string[] searchDirPaths) { Clear(); - foreach (ulong titleId in titles) + foreach (ulong applicationId in applications) { - _appMods[titleId] = new ModCache(); + _appMods[applicationId] = new ModCache(); } CollectMods(_appMods, _patches, searchDirPaths); } - internal IStorage ApplyRomFsMods(ulong titleId, IStorage baseStorage) + internal IStorage ApplyRomFsMods(ulong applicationId, IStorage baseStorage) { - if (!_appMods.TryGetValue(titleId, out ModCache mods) || mods.RomfsDirs.Count + mods.RomfsContainers.Count == 0) + if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.RomfsDirs.Count + mods.RomfsContainers.Count == 0) { return baseStorage; } @@ -448,11 +500,16 @@ namespace Ryujinx.HLE.HOS var builder = new RomFsBuilder(); int count = 0; - Logger.Info?.Print(LogClass.ModLoader, $"Applying RomFS mods for Title {titleId:X16}"); + Logger.Info?.Print(LogClass.ModLoader, $"Applying RomFS mods for Application {applicationId:X16}"); // Prioritize loose files first foreach (var mod in mods.RomfsDirs) { + if (!mod.Enabled) + { + continue; + } + using (IFileSystem fs = new LocalFileSystem(mod.Path.FullName)) { AddFiles(fs, mod.Name, fileSet, builder); @@ -463,7 +520,12 @@ namespace Ryujinx.HLE.HOS // Then files inside images foreach (var mod in mods.RomfsContainers) { - Logger.Info?.Print(LogClass.ModLoader, $"Found 'romfs.bin' for Title {titleId:X16}"); + if (!mod.Enabled) + { + continue; + } + + Logger.Info?.Print(LogClass.ModLoader, $"Found 'romfs.bin' for Application {applicationId:X16}"); using (IFileSystem fs = new RomFsFileSystem(mod.Path.OpenRead().AsStorage())) { AddFiles(fs, mod.Name, fileSet, builder); @@ -519,9 +581,9 @@ namespace Ryujinx.HLE.HOS } } - internal bool ReplaceExefsPartition(ulong titleId, ref IFileSystem exefs) + internal bool ReplaceExefsPartition(ulong applicationId, ref IFileSystem exefs) { - if (!_appMods.TryGetValue(titleId, out ModCache mods) || mods.ExefsContainers.Count == 0) + if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.ExefsContainers.Count == 0) { return false; } @@ -549,7 +611,7 @@ namespace Ryujinx.HLE.HOS public bool Modified => (Stubs.Data | Replaces.Data) != 0; } - internal ModLoadResult ApplyExefsMods(ulong titleId, NsoExecutable[] nsos) + internal ModLoadResult ApplyExefsMods(ulong applicationId, NsoExecutable[] nsos) { ModLoadResult modLoadResult = new() { @@ -557,7 +619,7 @@ namespace Ryujinx.HLE.HOS Replaces = new BitVector32(), }; - if (!_appMods.TryGetValue(titleId, out ModCache mods) || mods.ExefsDirs.Count == 0) + if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.ExefsDirs.Count == 0) { return modLoadResult; } @@ -571,6 +633,11 @@ namespace Ryujinx.HLE.HOS foreach (var mod in exeMods) { + if (!mod.Enabled) + { + continue; + } + for (int i = 0; i < ProcessConst.ExeFsPrefixes.Length; ++i) { var nsoName = ProcessConst.ExeFsPrefixes[i]; @@ -637,11 +704,11 @@ namespace Ryujinx.HLE.HOS ApplyProgramPatches(nroPatches, 0, nro); } - internal bool ApplyNsoPatches(ulong titleId, params IExecutable[] programs) + internal bool ApplyNsoPatches(ulong applicationId, params IExecutable[] programs) { IEnumerable> nsoMods = _patches.NsoPatches; - if (_appMods.TryGetValue(titleId, out ModCache mods)) + if (_appMods.TryGetValue(applicationId, out ModCache mods)) { nsoMods = nsoMods.Concat(mods.ExefsDirs); } @@ -651,7 +718,7 @@ namespace Ryujinx.HLE.HOS return ApplyProgramPatches(nsoMods, 0x100, programs); } - internal void LoadCheats(ulong titleId, ProcessTamperInfo tamperInfo, TamperMachine tamperMachine) + internal void LoadCheats(ulong applicationId, ProcessTamperInfo tamperInfo, TamperMachine tamperMachine) { if (tamperInfo?.BuildIds == null || tamperInfo.CodeAddresses == null) { @@ -660,9 +727,9 @@ namespace Ryujinx.HLE.HOS return; } - Logger.Info?.Print(LogClass.ModLoader, $"Build ids found for title {titleId:X16}:\n {String.Join("\n ", tamperInfo.BuildIds)}"); + Logger.Info?.Print(LogClass.ModLoader, $"Build ids found for application {applicationId:X16}:\n {String.Join("\n ", tamperInfo.BuildIds)}"); - if (!_appMods.TryGetValue(titleId, out ModCache mods) || mods.Cheats.Count == 0) + if (!_appMods.TryGetValue(applicationId, out ModCache mods) || mods.Cheats.Count == 0) { return; } @@ -687,12 +754,12 @@ namespace Ryujinx.HLE.HOS tamperMachine.InstallAtmosphereCheat(cheat.Name, cheatId, cheat.Instructions, tamperInfo, exeAddress); } - EnableCheats(titleId, tamperMachine); + EnableCheats(applicationId, tamperMachine); } - internal static void EnableCheats(ulong titleId, TamperMachine tamperMachine) + internal static void EnableCheats(ulong applicationId, TamperMachine tamperMachine) { - var contentDirectory = FindTitleDir(new DirectoryInfo(Path.Combine(GetModsBasePath(), AmsContentsDir)), $"{titleId:x16}"); + var contentDirectory = FindApplicationDir(new DirectoryInfo(Path.Combine(GetModsBasePath(), AmsContentsDir)), $"{applicationId:x16}"); string enabledCheatsPath = Path.Combine(contentDirectory.FullName, CheatDir, "enabled.txt"); if (File.Exists(enabledCheatsPath)) @@ -724,6 +791,11 @@ namespace Ryujinx.HLE.HOS // Collect patches foreach (var mod in mods) { + if (!mod.Enabled) + { + continue; + } + var patchDir = mod.Path; foreach (var patchFile in patchDir.EnumerateFiles()) { diff --git a/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs b/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs index 39139ecca6..6c2415e466 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs @@ -16,10 +16,7 @@ namespace Ryujinx.HLE.Loaders.Processes var nacpData = new BlitStruct(1); ulong programId = metaLoader.GetProgramId(); - device.Configuration.VirtualFileSystem.ModLoader.CollectMods( - new[] { programId }, - ModLoader.GetModsBasePath(), - ModLoader.GetSdModsBasePath()); + device.Configuration.VirtualFileSystem.ModLoader.CollectMods(new[] { programId }); if (programId != 0) { diff --git a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs index eb048b00d2..6bf43842c6 100644 --- a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs +++ b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs @@ -497,7 +497,7 @@ namespace Ryujinx.Ui.Widgets private void OpenTitleModDir_Clicked(object sender, EventArgs args) { string modsBasePath = ModLoader.GetModsBasePath(); - string titleModsPath = ModLoader.GetTitleDir(modsBasePath, _titleIdText); + string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, _titleIdText); OpenHelper.OpenFolder(titleModsPath); } @@ -505,7 +505,7 @@ namespace Ryujinx.Ui.Widgets private void OpenTitleSdModDir_Clicked(object sender, EventArgs args) { string sdModsBasePath = ModLoader.GetSdModsBasePath(); - string titleModsPath = ModLoader.GetTitleDir(sdModsBasePath, _titleIdText); + string titleModsPath = ModLoader.GetApplicationDir(sdModsBasePath, _titleIdText); OpenHelper.OpenFolder(titleModsPath); } diff --git a/src/Ryujinx/Ui/Windows/CheatWindow.cs b/src/Ryujinx/Ui/Windows/CheatWindow.cs index 1eca732b2a..afccec53c3 100644 --- a/src/Ryujinx/Ui/Windows/CheatWindow.cs +++ b/src/Ryujinx/Ui/Windows/CheatWindow.cs @@ -31,7 +31,7 @@ namespace Ryujinx.Ui.Windows _buildIdTextView.Buffer.Text = $"BuildId: {ApplicationData.GetApplicationBuildId(virtualFileSystem, titlePath)}"; string modsBasePath = ModLoader.GetModsBasePath(); - string titleModsPath = ModLoader.GetTitleDir(modsBasePath, titleId.ToString("X16")); + string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, titleId.ToString("X16")); _enabledCheatsPath = System.IO.Path.Combine(titleModsPath, "cheats", "enabled.txt");