From 6fbf279faca30ffd2ef33394463b98809ccaf127 Mon Sep 17 00:00:00 2001 From: TSRBerry <20988865+TSRBerry@users.noreply.github.com> Date: Tue, 16 Jul 2024 23:17:32 +0200 Subject: [PATCH] Add support for multi game XCIs (second try) (#6515) * Add default values to ApplicationData directly * Refactor application loading It should now be possible to load multi game XCIs. Included updates won't be detected for now. Opening a game from the command line currently only opens the first one. * Only include program NCAs where at least one tuple item is not null * Get application data by title id and add programIndex check back * Refactor application loading again and remove duplicate code * Actually use patch ncas for updates * Fix number of applications found with multi game xcis * Don't load bundled updates from multi game xcis * Change ApplicationData.TitleId type to ulong & Add TitleIdString property * Use cnmt files and ContentCollection to load programs * Ava: Add updates and DLCs from gamecarts * Get the cnmt file from its NCA * Ava: Identify bundled updates in updater window * Fix the (hopefully) last few bugs * Add idOffset parameter to GetNcaByType * Handle missing file for dlc.json * Ava: Shorten error message for invalid files * Gtk: Add additional string for bundled updates in TitleUpdateWindow * Hopefully fix DLC issues * Apply formatting * Finally fix DLC issues * Adjust property names and fileSize field * Read the correct update file * Fix wrong casing for application id strings * Rename TitleId to ApplicationId * Address review comments * Apply suggestions from code review Co-authored-by: gdkchan * Gracefully fail when loading pfs for update and dlc window * Fix applications with multiple programs * Fix DLCWindow crash on GTK * Fix some GUI issues * Remove IsXci again * Don't add duplicates to update/dlc windows * Avoid double lookup * Preserve DLC enabled state for bundled DLCs * Fix DLCWindow not opening using GTK * Fix missing information when loading applications from file * Address review feedback Rename ContentCollection to ContentMetaData Fix casing issues in log messages Use null as the default value for updatePath * Fix re-adding bundled DLCs every time * Fix bundled DLCs disappearing * Abstract common code to open application pfs * Remove unused imports * Fix file exists check when loading DLCs * Load bundled DLCs only using dlc.json * Load AoC items correctly * Add all DLCs from a PFS * Add argument to launch a specific application id * Use application-id argument for shortcuts if necessary * Return the application id from the control NCA if possible * GetApplicationInformation: Don't overwrite application ids Move SaveDataOwnerId check to the top, since it seems to be more reliable. * Get application ids from CNMT again This commit reverts some parts of 61615b8f0d6f90ae86778958ddc38eaf6dc280ab. Since the issue wasn't actually related to the application id in CMNTs, we can remove the wrong assumptions. * Revert erroneous axaml change from adca8900 * Rename title to application * Wrap nsp/pfs0 case with curly braces * Check if _applicationData.ControlHolder.ByteSpan is zeros only once * Catch exceptions while loading applications from nsps --------- Co-authored-by: gdkchan --- src/Ryujinx.Gtk3/Program.cs | 31 +- src/Ryujinx.Gtk3/UI/MainWindow.cs | 107 +- .../UI/Widgets/GameTableContextMenu.cs | 108 +- src/Ryujinx.Gtk3/UI/Windows/CheatWindow.cs | 9 +- src/Ryujinx.Gtk3/UI/Windows/DlcWindow.cs | 128 +-- .../UI/Windows/TitleUpdateWindow.cs | 98 +- src/Ryujinx.HLE/FileSystem/ContentManager.cs | 42 +- src/Ryujinx.HLE/FileSystem/ContentMetaData.cs | 61 ++ .../Extensions/LocalFileSystemExtensions.cs | 1 - .../Processes/Extensions/NcaExtensions.cs | 86 +- .../PartitionFileSystemExtensions.cs | 133 +-- .../Loaders/Processes/ProcessLoader.cs | 8 +- .../Loaders/Processes/ProcessLoaderHelper.cs | 9 +- src/Ryujinx.HLE/Switch.cs | 8 +- .../Utilities/PartitionFileSystemUtils.cs | 45 + src/Ryujinx.UI.Common/App/ApplicationData.cs | 18 +- .../App/ApplicationLibrary.cs | 954 +++++++++--------- .../Helper/CommandLineState.cs | 5 + .../Helper/ShortcutHelper.cs | 26 +- src/Ryujinx/AppHost.cs | 7 +- src/Ryujinx/Assets/Locales/en_US.json | 3 + src/Ryujinx/Common/ApplicationHelper.cs | 9 +- src/Ryujinx/Program.cs | 2 +- .../Controls/ApplicationContextMenu.axaml.cs | 56 +- .../UI/Controls/ApplicationGridView.axaml | 2 +- .../UI/Controls/ApplicationListView.axaml | 4 +- .../UI/Models/DownloadableContentModel.cs | 4 + src/Ryujinx/UI/Models/SaveModel.cs | 4 +- src/Ryujinx/UI/Models/TitleUpdateModel.cs | 5 +- .../DownloadableContentManagerViewModel.cs | 66 +- .../UI/ViewModels/MainWindowViewModel.cs | 50 +- .../UI/ViewModels/TitleUpdateViewModel.cs | 77 +- .../UI/Views/Main/MainMenuBarView.axaml.cs | 10 +- src/Ryujinx/UI/Windows/CheatWindow.axaml.cs | 7 +- .../DownloadableContentManagerWindow.axaml | 2 +- .../DownloadableContentManagerWindow.axaml.cs | 13 +- src/Ryujinx/UI/Windows/MainWindow.axaml.cs | 47 +- .../UI/Windows/TitleUpdateWindow.axaml.cs | 15 +- 38 files changed, 1299 insertions(+), 961 deletions(-) create mode 100644 src/Ryujinx.HLE/FileSystem/ContentMetaData.cs create mode 100644 src/Ryujinx.HLE/Utilities/PartitionFileSystemUtils.cs diff --git a/src/Ryujinx.Gtk3/Program.cs b/src/Ryujinx.Gtk3/Program.cs index 749cb697..0fb71288 100644 --- a/src/Ryujinx.Gtk3/Program.cs +++ b/src/Ryujinx.Gtk3/Program.cs @@ -7,6 +7,7 @@ using Ryujinx.Common.SystemInterop; using Ryujinx.Modules; using Ryujinx.SDL2.Common; using Ryujinx.UI; +using Ryujinx.UI.App.Common; using Ryujinx.UI.Common; using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Common.Helper; @@ -322,7 +323,35 @@ namespace Ryujinx if (CommandLineState.LaunchPathArg != null) { - mainWindow.RunApplication(CommandLineState.LaunchPathArg, CommandLineState.StartFullscreenArg); + if (mainWindow.ApplicationLibrary.TryGetApplicationsFromFile(CommandLineState.LaunchPathArg, out List applications)) + { + ApplicationData applicationData; + + if (CommandLineState.LaunchApplicationId != null) + { + applicationData = applications.Find(application => application.IdString == CommandLineState.LaunchApplicationId); + + if (applicationData != null) + { + mainWindow.RunApplication(applicationData, CommandLineState.StartFullscreenArg); + } + else + { + Logger.Error?.Print(LogClass.Application, $"Couldn't find requested application id '{CommandLineState.LaunchApplicationId}' in '{CommandLineState.LaunchPathArg}'."); + UserErrorDialog.CreateUserErrorDialog(UserError.ApplicationNotFound); + } + } + else + { + applicationData = applications[0]; + mainWindow.RunApplication(applicationData, CommandLineState.StartFullscreenArg); + } + } + else + { + Logger.Error?.Print(LogClass.Application, $"Couldn't find any application in '{CommandLineState.LaunchPathArg}'."); + UserErrorDialog.CreateUserErrorDialog(UserError.ApplicationNotFound); + } } if (ConfigurationState.Instance.CheckUpdatesOnStart.Value && Updater.CanUpdate(false)) diff --git a/src/Ryujinx.Gtk3/UI/MainWindow.cs b/src/Ryujinx.Gtk3/UI/MainWindow.cs index d1ca6ce6..7f9eceb3 100644 --- a/src/Ryujinx.Gtk3/UI/MainWindow.cs +++ b/src/Ryujinx.Gtk3/UI/MainWindow.cs @@ -37,7 +37,9 @@ using Ryujinx.UI.Windows; using Silk.NET.Vulkan; using SPB.Graphics.Vulkan; using System; +using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Reflection; using System.Threading; @@ -60,7 +62,6 @@ namespace Ryujinx.UI private WindowsMultimediaTimerResolution _windowsMultimediaTimerResolution; - private readonly ApplicationLibrary _applicationLibrary; private readonly GtkHostUIHandler _uiHandler; private readonly AutoResetEvent _deviceExitStatus; private readonly ListStore _tableStore; @@ -69,11 +70,12 @@ namespace Ryujinx.UI private bool _gameLoaded; private bool _ending; - private string _currentEmulatedGamePath = null; + private ApplicationData _currentApplicationData = null; private string _lastScannedAmiiboId = ""; private bool _lastScannedAmiiboShowAll = false; + public readonly ApplicationLibrary ApplicationLibrary; public RendererWidgetBase RendererWidget; public InputManager InputManager; @@ -180,8 +182,12 @@ namespace Ryujinx.UI _accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, CommandLineState.Profile); _userChannelPersistence = new UserChannelPersistence(); + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + // Instantiate GUI objects. - _applicationLibrary = new ApplicationLibrary(_virtualFileSystem); + ApplicationLibrary = new ApplicationLibrary(_virtualFileSystem, checkLevel); _uiHandler = new GtkHostUIHandler(this); _deviceExitStatus = new AutoResetEvent(false); @@ -190,8 +196,8 @@ namespace Ryujinx.UI FocusInEvent += MainWindow_FocusInEvent; FocusOutEvent += MainWindow_FocusOutEvent; - _applicationLibrary.ApplicationAdded += Application_Added; - _applicationLibrary.ApplicationCountUpdated += ApplicationCount_Updated; + ApplicationLibrary.ApplicationAdded += Application_Added; + ApplicationLibrary.ApplicationCountUpdated += ApplicationCount_Updated; _fileMenu.StateChanged += FileMenu_StateChanged; _actionMenu.StateChanged += ActionMenu_StateChanged; @@ -732,7 +738,7 @@ namespace Ryujinx.UI Thread applicationLibraryThread = new(() => { - _applicationLibrary.LoadApplications(ConfigurationState.Instance.UI.GameDirs, ConfigurationState.Instance.System.Language); + ApplicationLibrary.LoadApplications(ConfigurationState.Instance.UI.GameDirs, ConfigurationState.Instance.System.Language); _updatingGameTable = false; }) @@ -783,7 +789,7 @@ namespace Ryujinx.UI } } - private bool LoadApplication(string path, bool isFirmwareTitle) + private bool LoadApplication(string path, ulong applicationId, bool isFirmwareTitle) { SystemVersion firmwareVersion = _contentManager.GetCurrentFirmwareVersion(); @@ -857,7 +863,7 @@ namespace Ryujinx.UI case ".xci": Logger.Info?.Print(LogClass.Application, "Loading as XCI."); - return _emulationContext.LoadXci(path); + return _emulationContext.LoadXci(path, applicationId); case ".nca": Logger.Info?.Print(LogClass.Application, "Loading as NCA."); @@ -866,7 +872,7 @@ namespace Ryujinx.UI case ".pfs0": Logger.Info?.Print(LogClass.Application, "Loading as NSP."); - return _emulationContext.LoadNsp(path); + return _emulationContext.LoadNsp(path, applicationId); default: Logger.Info?.Print(LogClass.Application, "Loading as Homebrew."); try @@ -887,7 +893,7 @@ namespace Ryujinx.UI return false; } - public void RunApplication(string path, bool startFullscreen = false) + public void RunApplication(ApplicationData application, bool startFullscreen = false) { if (_gameLoaded) { @@ -909,14 +915,14 @@ namespace Ryujinx.UI bool isFirmwareTitle = false; - if (path.StartsWith("@SystemContent")) + if (application.Path.StartsWith("@SystemContent")) { - path = VirtualFileSystem.SwitchPathToSystemPath(path); + application.Path = VirtualFileSystem.SwitchPathToSystemPath(application.Path); isFirmwareTitle = true; } - if (!LoadApplication(path, isFirmwareTitle)) + if (!LoadApplication(application.Path, application.Id, isFirmwareTitle)) { _emulationContext.Dispose(); SwitchToGameTable(); @@ -926,7 +932,7 @@ namespace Ryujinx.UI SetupProgressUIHandlers(); - _currentEmulatedGamePath = path; + _currentApplicationData = application; _deviceExitStatus.Reset(); @@ -1165,7 +1171,7 @@ namespace Ryujinx.UI _tableStore.AppendValues( args.AppData.Favorite, new Gdk.Pixbuf(args.AppData.Icon, 75, 75), - $"{args.AppData.TitleName}\n{args.AppData.TitleId.ToUpper()}", + $"{args.AppData.Name}\n{args.AppData.IdString.ToUpper()}", args.AppData.Developer, args.AppData.Version, args.AppData.TimePlayedString, @@ -1253,9 +1259,22 @@ namespace Ryujinx.UI { _gameTableSelection.GetSelected(out TreeIter treeIter); - string path = (string)_tableStore.GetValue(treeIter, 9); + ApplicationData application = new() + { + Favorite = (bool)_tableStore.GetValue(treeIter, 0), + Name = ((string)_tableStore.GetValue(treeIter, 2)).Split('\n')[0], + Id = ulong.Parse(((string)_tableStore.GetValue(treeIter, 2)).Split('\n')[1], NumberStyles.HexNumber), + Developer = (string)_tableStore.GetValue(treeIter, 3), + Version = (string)_tableStore.GetValue(treeIter, 4), + TimePlayed = ValueFormatUtils.ParseTimeSpan((string)_tableStore.GetValue(treeIter, 5)), + LastPlayed = ValueFormatUtils.ParseDateTime((string)_tableStore.GetValue(treeIter, 6)), + FileExtension = (string)_tableStore.GetValue(treeIter, 7), + FileSize = ValueFormatUtils.ParseFileSize((string)_tableStore.GetValue(treeIter, 8)), + Path = (string)_tableStore.GetValue(treeIter, 9), + ControlHolder = (BlitStruct)_tableStore.GetValue(treeIter, 10), + }; - RunApplication(path); + RunApplication(application); } private void VSyncStatus_Clicked(object sender, ButtonReleaseEventArgs args) @@ -1313,13 +1332,22 @@ namespace Ryujinx.UI return; } - string titleFilePath = _tableStore.GetValue(treeIter, 9).ToString(); - string titleName = _tableStore.GetValue(treeIter, 2).ToString().Split("\n")[0]; - string titleId = _tableStore.GetValue(treeIter, 2).ToString().Split("\n")[1].ToLower(); + ApplicationData application = new() + { + Favorite = (bool)_tableStore.GetValue(treeIter, 0), + Name = ((string)_tableStore.GetValue(treeIter, 2)).Split('\n')[0], + Id = ulong.Parse(((string)_tableStore.GetValue(treeIter, 2)).Split('\n')[1], NumberStyles.HexNumber), + Developer = (string)_tableStore.GetValue(treeIter, 3), + Version = (string)_tableStore.GetValue(treeIter, 4), + TimePlayed = ValueFormatUtils.ParseTimeSpan((string)_tableStore.GetValue(treeIter, 5)), + LastPlayed = ValueFormatUtils.ParseDateTime((string)_tableStore.GetValue(treeIter, 6)), + FileExtension = (string)_tableStore.GetValue(treeIter, 7), + FileSize = ValueFormatUtils.ParseFileSize((string)_tableStore.GetValue(treeIter, 8)), + Path = (string)_tableStore.GetValue(treeIter, 9), + ControlHolder = (BlitStruct)_tableStore.GetValue(treeIter, 10), + }; - BlitStruct controlData = (BlitStruct)_tableStore.GetValue(treeIter, 10); - - _ = new GameTableContextMenu(this, _virtualFileSystem, _accountManager, _libHacHorizonManager.RyujinxClient, titleFilePath, titleName, titleId, controlData); + _ = new GameTableContextMenu(this, _virtualFileSystem, _accountManager, _libHacHorizonManager.RyujinxClient, application); } private void Load_Application_File(object sender, EventArgs args) @@ -1341,7 +1369,15 @@ namespace Ryujinx.UI if (fileChooser.Run() == (int)ResponseType.Accept) { - RunApplication(fileChooser.Filename); + if (ApplicationLibrary.TryGetApplicationsFromFile(fileChooser.Filename, + out List applications)) + { + RunApplication(applications[0]); + } + else + { + GtkDialog.CreateErrorDialog("No applications found in selected file."); + } } } @@ -1351,7 +1387,13 @@ namespace Ryujinx.UI if (fileChooser.Run() == (int)ResponseType.Accept) { - RunApplication(fileChooser.Filename); + ApplicationData applicationData = new() + { + Name = System.IO.Path.GetFileNameWithoutExtension(fileChooser.Filename), + Path = fileChooser.Filename, + }; + + RunApplication(applicationData); } } @@ -1366,7 +1408,14 @@ namespace Ryujinx.UI { string contentPath = _contentManager.GetInstalledContentPath(0x0100000000001009, StorageId.BuiltInSystem, NcaContentType.Program); - RunApplication(contentPath); + ApplicationData applicationData = new() + { + Name = "miiEdit", + Id = 0x0100000000001009ul, + Path = contentPath, + }; + + RunApplication(applicationData); } private void Open_Ryu_Folder(object sender, EventArgs args) @@ -1646,13 +1695,13 @@ namespace Ryujinx.UI { _userChannelPersistence.ShouldRestart = false; - RunApplication(_currentEmulatedGamePath); + RunApplication(_currentApplicationData); } else { // otherwise, clear state. _userChannelPersistence = new UserChannelPersistence(); - _currentEmulatedGamePath = null; + _currentApplicationData = null; _actionMenu.Sensitive = false; _firmwareInstallFile.Sensitive = true; _firmwareInstallDirectory.Sensitive = true; @@ -1714,7 +1763,7 @@ namespace Ryujinx.UI _emulationContext.Processes.ActiveApplication.ProgramId, _emulationContext.Processes.ActiveApplication.ApplicationControlProperties .Title[(int)_emulationContext.System.State.DesiredTitleLanguage].NameString.ToString(), - _currentEmulatedGamePath); + _currentApplicationData.Path); window.Destroyed += CheatWindow_Destroyed; window.Show(); diff --git a/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs b/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs index c8236223..e37906d5 100644 --- a/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs +++ b/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs @@ -16,6 +16,8 @@ using Ryujinx.Common.Logging; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Account.Acc; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; using Ryujinx.UI.App.Common; using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Common.Helper; @@ -23,7 +25,6 @@ using Ryujinx.UI.Windows; using System; using System.Buffers; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Reflection; using System.Threading; @@ -36,17 +37,13 @@ namespace Ryujinx.UI.Widgets private readonly VirtualFileSystem _virtualFileSystem; private readonly AccountManager _accountManager; private readonly HorizonClient _horizonClient; - private readonly BlitStruct _controlData; - private readonly string _titleFilePath; - private readonly string _titleName; - private readonly string _titleIdText; - private readonly ulong _titleId; + private readonly ApplicationData _applicationData; private MessageDialog _dialog; private bool _cancel; - public GameTableContextMenu(MainWindow parent, VirtualFileSystem virtualFileSystem, AccountManager accountManager, HorizonClient horizonClient, string titleFilePath, string titleName, string titleId, BlitStruct controlData) + public GameTableContextMenu(MainWindow parent, VirtualFileSystem virtualFileSystem, AccountManager accountManager, HorizonClient horizonClient, ApplicationData applicationData) { _parent = parent; @@ -55,23 +52,22 @@ namespace Ryujinx.UI.Widgets _virtualFileSystem = virtualFileSystem; _accountManager = accountManager; _horizonClient = horizonClient; - _titleFilePath = titleFilePath; - _titleName = titleName; - _titleIdText = titleId; - _controlData = controlData; + _applicationData = applicationData; - if (!ulong.TryParse(_titleIdText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _titleId)) + if (!_applicationData.ControlHolder.ByteSpan.IsZeros()) { - GtkDialog.CreateErrorDialog("The selected game did not have a valid Title Id"); - - return; + _openSaveUserDirMenuItem.Sensitive = _applicationData.ControlHolder.Value.UserAccountSaveDataSize > 0; + _openSaveDeviceDirMenuItem.Sensitive = _applicationData.ControlHolder.Value.DeviceSaveDataSize > 0; + _openSaveBcatDirMenuItem.Sensitive = _applicationData.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0; + } + else + { + _openSaveUserDirMenuItem.Sensitive = false; + _openSaveDeviceDirMenuItem.Sensitive = false; + _openSaveBcatDirMenuItem.Sensitive = false; } - _openSaveUserDirMenuItem.Sensitive = !Utilities.IsZeros(controlData.ByteSpan) && controlData.Value.UserAccountSaveDataSize > 0; - _openSaveDeviceDirMenuItem.Sensitive = !Utilities.IsZeros(controlData.ByteSpan) && controlData.Value.DeviceSaveDataSize > 0; - _openSaveBcatDirMenuItem.Sensitive = !Utilities.IsZeros(controlData.ByteSpan) && controlData.Value.BcatDeliveryCacheStorageSize > 0; - - string fileExt = System.IO.Path.GetExtension(_titleFilePath).ToLower(); + string fileExt = System.IO.Path.GetExtension(_applicationData.Path).ToLower(); bool hasNca = fileExt == ".nca" || fileExt == ".nsp" || fileExt == ".pfs0" || fileExt == ".xci"; _extractRomFsMenuItem.Sensitive = hasNca; @@ -137,7 +133,7 @@ namespace Ryujinx.UI.Widgets private void OpenSaveDir(in SaveDataFilter saveDataFilter) { - if (!TryFindSaveData(_titleName, _titleId, _controlData, in saveDataFilter, out ulong saveDataId)) + if (!TryFindSaveData(_applicationData.Name, _applicationData.Id, _applicationData.ControlHolder, in saveDataFilter, out ulong saveDataId)) { return; } @@ -190,7 +186,7 @@ namespace Ryujinx.UI.Widgets { Title = "Ryujinx - NCA Section Extractor", Icon = new Gdk.Pixbuf(Assembly.GetAssembly(typeof(ConfigurationState)), "Ryujinx.Gtk3.UI.Common.Resources.Logo_Ryujinx.png"), - SecondaryText = $"Extracting {ncaSectionType} section from {System.IO.Path.GetFileName(_titleFilePath)}...", + SecondaryText = $"Extracting {ncaSectionType} section from {System.IO.Path.GetFileName(_applicationData.Path)}...", WindowPosition = WindowPosition.Center, }; @@ -202,29 +198,16 @@ namespace Ryujinx.UI.Widgets } }); - using FileStream file = new(_titleFilePath, FileMode.Open, FileAccess.Read); + using FileStream file = new(_applicationData.Path, FileMode.Open, FileAccess.Read); Nca mainNca = null; Nca patchNca = null; - if ((System.IO.Path.GetExtension(_titleFilePath).ToLower() == ".nsp") || - (System.IO.Path.GetExtension(_titleFilePath).ToLower() == ".pfs0") || - (System.IO.Path.GetExtension(_titleFilePath).ToLower() == ".xci")) + if ((System.IO.Path.GetExtension(_applicationData.Path).ToLower() == ".nsp") || + (System.IO.Path.GetExtension(_applicationData.Path).ToLower() == ".pfs0") || + (System.IO.Path.GetExtension(_applicationData.Path).ToLower() == ".xci")) { - IFileSystem pfs; - - if (System.IO.Path.GetExtension(_titleFilePath) == ".xci") - { - Xci xci = new(_virtualFileSystem.KeySet, file.AsStorage()); - - pfs = xci.OpenPartition(XciPartitionType.Secure); - } - else - { - var pfsTemp = new PartitionFileSystem(); - pfsTemp.Initialize(file.AsStorage()).ThrowIfFailure(); - pfs = pfsTemp; - } + IFileSystem pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(_applicationData.Path, _virtualFileSystem); foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) { @@ -249,7 +232,7 @@ namespace Ryujinx.UI.Widgets } } } - else if (System.IO.Path.GetExtension(_titleFilePath).ToLower() == ".nca") + else if (System.IO.Path.GetExtension(_applicationData.Path).ToLower() == ".nca") { mainNca = new Nca(_virtualFileSystem.KeySet, file.AsStorage()); } @@ -266,7 +249,11 @@ namespace Ryujinx.UI.Widgets return; } - (Nca updatePatchNca, _) = ApplicationLibrary.GetGameUpdateData(_virtualFileSystem, mainNca.Header.TitleId.ToString("x16"), programIndex, out _); + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + (Nca updatePatchNca, _) = mainNca.GetUpdateData(_virtualFileSystem, checkLevel, programIndex, out _); if (updatePatchNca != null) { @@ -460,44 +447,44 @@ namespace Ryujinx.UI.Widgets private void OpenSaveUserDir_Clicked(object sender, EventArgs args) { var userId = new LibHac.Fs.UserId((ulong)_accountManager.LastOpenedUser.UserId.High, (ulong)_accountManager.LastOpenedUser.UserId.Low); - var saveDataFilter = SaveDataFilter.Make(_titleId, saveType: default, userId, saveDataId: default, index: default); + var saveDataFilter = SaveDataFilter.Make(_applicationData.Id, saveType: default, userId, saveDataId: default, index: default); OpenSaveDir(in saveDataFilter); } private void OpenSaveDeviceDir_Clicked(object sender, EventArgs args) { - var saveDataFilter = SaveDataFilter.Make(_titleId, SaveDataType.Device, userId: default, saveDataId: default, index: default); + var saveDataFilter = SaveDataFilter.Make(_applicationData.Id, SaveDataType.Device, userId: default, saveDataId: default, index: default); OpenSaveDir(in saveDataFilter); } private void OpenSaveBcatDir_Clicked(object sender, EventArgs args) { - var saveDataFilter = SaveDataFilter.Make(_titleId, SaveDataType.Bcat, userId: default, saveDataId: default, index: default); + var saveDataFilter = SaveDataFilter.Make(_applicationData.Id, SaveDataType.Bcat, userId: default, saveDataId: default, index: default); OpenSaveDir(in saveDataFilter); } private void ManageTitleUpdates_Clicked(object sender, EventArgs args) { - new TitleUpdateWindow(_parent, _virtualFileSystem, _titleIdText, _titleName).Show(); + new TitleUpdateWindow(_parent, _virtualFileSystem, _applicationData).Show(); } private void ManageDlc_Clicked(object sender, EventArgs args) { - new DlcWindow(_virtualFileSystem, _titleIdText, _titleName).Show(); + new DlcWindow(_virtualFileSystem, _applicationData.IdString, _applicationData).Show(); } private void ManageCheats_Clicked(object sender, EventArgs args) { - new CheatWindow(_virtualFileSystem, _titleId, _titleName, _titleFilePath).Show(); + new CheatWindow(_virtualFileSystem, _applicationData.Id, _applicationData.Name, _applicationData.Path).Show(); } private void OpenTitleModDir_Clicked(object sender, EventArgs args) { string modsBasePath = ModLoader.GetModsBasePath(); - string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, _titleIdText); + string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, _applicationData.IdString); OpenHelper.OpenFolder(titleModsPath); } @@ -505,7 +492,7 @@ namespace Ryujinx.UI.Widgets private void OpenTitleSdModDir_Clicked(object sender, EventArgs args) { string sdModsBasePath = ModLoader.GetSdModsBasePath(); - string titleModsPath = ModLoader.GetApplicationDir(sdModsBasePath, _titleIdText); + string titleModsPath = ModLoader.GetApplicationDir(sdModsBasePath, _applicationData.IdString); OpenHelper.OpenFolder(titleModsPath); } @@ -527,7 +514,7 @@ namespace Ryujinx.UI.Widgets private void OpenPtcDir_Clicked(object sender, EventArgs args) { - string ptcDir = System.IO.Path.Combine(AppDataManager.GamesDirPath, _titleIdText, "cache", "cpu"); + string ptcDir = System.IO.Path.Combine(AppDataManager.GamesDirPath, _applicationData.IdString, "cache", "cpu"); string mainPath = System.IO.Path.Combine(ptcDir, "0"); string backupPath = System.IO.Path.Combine(ptcDir, "1"); @@ -544,7 +531,7 @@ namespace Ryujinx.UI.Widgets private void OpenShaderCacheDir_Clicked(object sender, EventArgs args) { - string shaderCacheDir = System.IO.Path.Combine(AppDataManager.GamesDirPath, _titleIdText, "cache", "shader"); + string shaderCacheDir = System.IO.Path.Combine(AppDataManager.GamesDirPath, _applicationData.IdString, "cache", "shader"); if (!Directory.Exists(shaderCacheDir)) { @@ -556,10 +543,10 @@ namespace Ryujinx.UI.Widgets private void PurgePtcCache_Clicked(object sender, EventArgs args) { - DirectoryInfo mainDir = new(System.IO.Path.Combine(AppDataManager.GamesDirPath, _titleIdText, "cache", "cpu", "0")); - DirectoryInfo backupDir = new(System.IO.Path.Combine(AppDataManager.GamesDirPath, _titleIdText, "cache", "cpu", "1")); + DirectoryInfo mainDir = new(System.IO.Path.Combine(AppDataManager.GamesDirPath, _applicationData.IdString, "cache", "cpu", "0")); + DirectoryInfo backupDir = new(System.IO.Path.Combine(AppDataManager.GamesDirPath, _applicationData.IdString, "cache", "cpu", "1")); - MessageDialog warningDialog = GtkDialog.CreateConfirmationDialog("Warning", $"You are about to queue a PPTC rebuild on the next boot of:\n\n{_titleName}\n\nAre you sure you want to proceed?"); + MessageDialog warningDialog = GtkDialog.CreateConfirmationDialog("Warning", $"You are about to queue a PPTC rebuild on the next boot of:\n\n{_applicationData.Name}\n\nAre you sure you want to proceed?"); List cacheFiles = new(); @@ -593,9 +580,9 @@ namespace Ryujinx.UI.Widgets private void PurgeShaderCache_Clicked(object sender, EventArgs args) { - DirectoryInfo shaderCacheDir = new(System.IO.Path.Combine(AppDataManager.GamesDirPath, _titleIdText, "cache", "shader")); + DirectoryInfo shaderCacheDir = new(System.IO.Path.Combine(AppDataManager.GamesDirPath, _applicationData.IdString, "cache", "shader")); - using MessageDialog warningDialog = GtkDialog.CreateConfirmationDialog("Warning", $"You are about to delete the shader cache for :\n\n{_titleName}\n\nAre you sure you want to proceed?"); + using MessageDialog warningDialog = GtkDialog.CreateConfirmationDialog("Warning", $"You are about to delete the shader cache for :\n\n{_applicationData.Name}\n\nAre you sure you want to proceed?"); List oldCacheDirectories = new(); List newCacheFiles = new(); @@ -637,8 +624,11 @@ namespace Ryujinx.UI.Widgets private void CreateShortcut_Clicked(object sender, EventArgs args) { - byte[] appIcon = new ApplicationLibrary(_virtualFileSystem).GetApplicationIcon(_titleFilePath, ConfigurationState.Instance.System.Language); - ShortcutHelper.CreateAppShortcut(_titleFilePath, _titleName, _titleIdText, appIcon); + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + byte[] appIcon = new ApplicationLibrary(_virtualFileSystem, checkLevel).GetApplicationIcon(_applicationData.Path, ConfigurationState.Instance.System.Language, _applicationData.Id); + ShortcutHelper.CreateAppShortcut(_applicationData.Path, _applicationData.Name, _applicationData.IdString, appIcon); } } } diff --git a/src/Ryujinx.Gtk3/UI/Windows/CheatWindow.cs b/src/Ryujinx.Gtk3/UI/Windows/CheatWindow.cs index 96ed0723..d9f01918 100644 --- a/src/Ryujinx.Gtk3/UI/Windows/CheatWindow.cs +++ b/src/Ryujinx.Gtk3/UI/Windows/CheatWindow.cs @@ -1,7 +1,9 @@ using Gtk; +using LibHac.Tools.FsSystem; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; using Ryujinx.UI.App.Common; +using Ryujinx.UI.Common.Configuration; using System; using System.Collections.Generic; using System.IO; @@ -27,8 +29,13 @@ namespace Ryujinx.UI.Windows private CheatWindow(Builder builder, VirtualFileSystem virtualFileSystem, ulong titleId, string titleName, string titlePath) : base(builder.GetRawOwnedObject("_cheatWindow")) { builder.Autoconnect(this); + + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + _baseTitleInfoLabel.Text = $"Cheats Available for {titleName} [{titleId:X16}]"; - _buildIdTextView.Buffer.Text = $"BuildId: {ApplicationData.GetApplicationBuildId(virtualFileSystem, titlePath)}"; + _buildIdTextView.Buffer.Text = $"BuildId: {ApplicationData.GetBuildId(virtualFileSystem, checkLevel, titlePath)}"; string modsBasePath = ModLoader.GetModsBasePath(); string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, titleId.ToString("X16")); diff --git a/src/Ryujinx.Gtk3/UI/Windows/DlcWindow.cs b/src/Ryujinx.Gtk3/UI/Windows/DlcWindow.cs index 388f1108..b69cc003 100644 --- a/src/Ryujinx.Gtk3/UI/Windows/DlcWindow.cs +++ b/src/Ryujinx.Gtk3/UI/Windows/DlcWindow.cs @@ -2,17 +2,21 @@ using Gtk; using LibHac.Common; using LibHac.Fs; using LibHac.Fs.Fsa; -using LibHac.FsSystem; using LibHac.Tools.Fs; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; using Ryujinx.Common.Configuration; using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; +using Ryujinx.UI.App.Common; using Ryujinx.UI.Widgets; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Linq; using GUI = Gtk.Builder.ObjectAttribute; namespace Ryujinx.UI.Windows @@ -20,7 +24,7 @@ namespace Ryujinx.UI.Windows public class DlcWindow : Window { private readonly VirtualFileSystem _virtualFileSystem; - private readonly string _titleId; + private readonly string _applicationId; private readonly string _dlcJsonPath; private readonly List _dlcContainerList; @@ -32,16 +36,16 @@ namespace Ryujinx.UI.Windows [GUI] TreeSelection _dlcTreeSelection; #pragma warning restore CS0649, IDE0044 - public DlcWindow(VirtualFileSystem virtualFileSystem, string titleId, string titleName) : this(new Builder("Ryujinx.Gtk3.UI.Windows.DlcWindow.glade"), virtualFileSystem, titleId, titleName) { } + public DlcWindow(VirtualFileSystem virtualFileSystem, string titleId, ApplicationData applicationData) : this(new Builder("Ryujinx.Gtk3.UI.Windows.DlcWindow.glade"), virtualFileSystem, titleId, applicationData) { } - private DlcWindow(Builder builder, VirtualFileSystem virtualFileSystem, string titleId, string titleName) : base(builder.GetRawOwnedObject("_dlcWindow")) + private DlcWindow(Builder builder, VirtualFileSystem virtualFileSystem, string applicationId, ApplicationData applicationData) : base(builder.GetRawOwnedObject("_dlcWindow")) { builder.Autoconnect(this); - _titleId = titleId; + _applicationId = applicationId; _virtualFileSystem = virtualFileSystem; - _dlcJsonPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, _titleId, "dlc.json"); - _baseTitleInfoLabel.Text = $"DLC Available for {titleName} [{titleId.ToUpper()}]"; + _dlcJsonPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, _applicationId, "dlc.json"); + _baseTitleInfoLabel.Text = $"DLC Available for {applicationData.Name} [{applicationId.ToUpper()}]"; try { @@ -72,7 +76,7 @@ namespace Ryujinx.UI.Windows }; _dlcTreeView.AppendColumn("Enabled", enableToggle, "active", 0); - _dlcTreeView.AppendColumn("TitleId", new CellRendererText(), "text", 1); + _dlcTreeView.AppendColumn("ApplicationId", new CellRendererText(), "text", 1); _dlcTreeView.AppendColumn("Path", new CellRendererText(), "text", 2); foreach (DownloadableContentContainer dlcContainer in _dlcContainerList) @@ -86,18 +90,18 @@ namespace Ryujinx.UI.Windows bool areAllContentPacksEnabled = dlcContainer.DownloadableContentNcaList.TrueForAll((nca) => nca.Enabled); TreeIter parentIter = ((TreeStore)_dlcTreeView.Model).AppendValues(areAllContentPacksEnabled, "", dlcContainer.ContainerPath); - using FileStream containerFile = File.OpenRead(dlcContainer.ContainerPath); + using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(dlcContainer.ContainerPath, _virtualFileSystem, false); - PartitionFileSystem pfs = new(); - pfs.Initialize(containerFile.AsStorage()).ThrowIfFailure(); - - _virtualFileSystem.ImportTickets(pfs); + if (partitionFileSystem == null) + { + continue; + } foreach (DownloadableContentNca dlcNca in dlcContainer.DownloadableContentNcaList) { using var ncaFile = new UniqueRef(); - pfs.OpenFile(ref ncaFile.Ref, dlcNca.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + partitionFileSystem.OpenFile(ref ncaFile.Ref, dlcNca.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), dlcContainer.ContainerPath); if (nca != null) @@ -112,6 +116,9 @@ namespace Ryujinx.UI.Windows TreeIter parentIter = ((TreeStore)_dlcTreeView.Model).AppendValues(false, "", $"(MISSING) {dlcContainer.ContainerPath}"); } } + + // NOTE: Try to load downloadable contents from PFS last to preserve enabled state. + AddDlc(applicationData.Path, true); } private Nca TryCreateNca(IStorage ncaStorage, string containerPath) @@ -128,6 +135,52 @@ namespace Ryujinx.UI.Windows return null; } + private void AddDlc(string path, bool ignoreNotFound = false) + { + if (!File.Exists(path) || _dlcContainerList.Any(x => x.ContainerPath == path)) + { + return; + } + + using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(path, _virtualFileSystem); + + bool containsDlc = false; + + TreeIter? parentIter = null; + + foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca")) + { + using var ncaFile = new UniqueRef(); + + partitionFileSystem.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), path); + + if (nca == null) + { + continue; + } + + if (nca.Header.ContentType == NcaContentType.PublicData) + { + if (nca.GetProgramIdBase() != (ulong.Parse(_applicationId, NumberStyles.HexNumber) & ~0x1FFFUL)) + { + continue; + } + + parentIter ??= ((TreeStore)_dlcTreeView.Model).AppendValues(true, "", path); + + ((TreeStore)_dlcTreeView.Model).AppendValues(parentIter.Value, true, nca.Header.TitleId.ToString("X16"), fileEntry.FullPath); + containsDlc = true; + } + } + + if (!containsDlc && !ignoreNotFound) + { + GtkDialog.CreateErrorDialog("The specified file does not contain DLC for the selected title!"); + } + } + private void AddButton_Clicked(object sender, EventArgs args) { FileChooserNative fileChooser = new("Select DLC files", this, FileChooserAction.Open, "Add", "Cancel") @@ -147,52 +200,7 @@ namespace Ryujinx.UI.Windows { foreach (string containerPath in fileChooser.Filenames) { - if (!File.Exists(containerPath)) - { - return; - } - - using FileStream containerFile = File.OpenRead(containerPath); - - PartitionFileSystem pfs = new(); - pfs.Initialize(containerFile.AsStorage()).ThrowIfFailure(); - bool containsDlc = false; - - _virtualFileSystem.ImportTickets(pfs); - - TreeIter? parentIter = null; - - foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) - { - using var ncaFile = new UniqueRef(); - - pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), containerPath); - - if (nca == null) - { - continue; - } - - if (nca.Header.ContentType == NcaContentType.PublicData) - { - if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000).ToString("x16") != _titleId) - { - break; - } - - parentIter ??= ((TreeStore)_dlcTreeView.Model).AppendValues(true, "", containerPath); - - ((TreeStore)_dlcTreeView.Model).AppendValues(parentIter.Value, true, nca.Header.TitleId.ToString("X16"), fileEntry.FullPath); - containsDlc = true; - } - } - - if (!containsDlc) - { - GtkDialog.CreateErrorDialog("The specified file does not contain DLC for the selected title!"); - } + AddDlc(containerPath); } } diff --git a/src/Ryujinx.Gtk3/UI/Windows/TitleUpdateWindow.cs b/src/Ryujinx.Gtk3/UI/Windows/TitleUpdateWindow.cs index 74b2330e..3ac972ea 100644 --- a/src/Ryujinx.Gtk3/UI/Windows/TitleUpdateWindow.cs +++ b/src/Ryujinx.Gtk3/UI/Windows/TitleUpdateWindow.cs @@ -2,14 +2,17 @@ using Gtk; using LibHac.Common; using LibHac.Fs; using LibHac.Fs.Fsa; -using LibHac.FsSystem; +using LibHac.Ncm; using LibHac.Ns; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; using Ryujinx.Common.Configuration; using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; using Ryujinx.UI.App.Common; +using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Widgets; using System; using System.Collections.Generic; @@ -24,7 +27,7 @@ namespace Ryujinx.UI.Windows { private readonly MainWindow _parent; private readonly VirtualFileSystem _virtualFileSystem; - private readonly string _titleId; + private readonly ApplicationData _applicationData; private readonly string _updateJsonPath; private TitleUpdateMetadata _titleUpdateWindowData; @@ -38,17 +41,17 @@ namespace Ryujinx.UI.Windows [GUI] RadioButton _noUpdateRadioButton; #pragma warning restore CS0649, IDE0044 - public TitleUpdateWindow(MainWindow parent, VirtualFileSystem virtualFileSystem, string titleId, string titleName) : this(new Builder("Ryujinx.Gtk3.UI.Windows.TitleUpdateWindow.glade"), parent, virtualFileSystem, titleId, titleName) { } + public TitleUpdateWindow(MainWindow parent, VirtualFileSystem virtualFileSystem, ApplicationData applicationData) : this(new Builder("Ryujinx.Gtk3.UI.Windows.TitleUpdateWindow.glade"), parent, virtualFileSystem, applicationData) { } - private TitleUpdateWindow(Builder builder, MainWindow parent, VirtualFileSystem virtualFileSystem, string titleId, string titleName) : base(builder.GetRawOwnedObject("_titleUpdateWindow")) + private TitleUpdateWindow(Builder builder, MainWindow parent, VirtualFileSystem virtualFileSystem, ApplicationData applicationData) : base(builder.GetRawOwnedObject("_titleUpdateWindow")) { _parent = parent; builder.Autoconnect(this); - _titleId = titleId; + _applicationData = applicationData; _virtualFileSystem = virtualFileSystem; - _updateJsonPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, _titleId, "updates.json"); + _updateJsonPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, applicationData.IdString, "updates.json"); _radioButtonToPathDictionary = new Dictionary(); try @@ -64,7 +67,10 @@ namespace Ryujinx.UI.Windows }; } - _baseTitleInfoLabel.Text = $"Updates Available for {titleName} [{titleId.ToUpper()}]"; + _baseTitleInfoLabel.Text = $"Updates Available for {applicationData.Name} [{applicationData.IdString}]"; + + // Try to get updates from PFS first + AddUpdate(_applicationData.Path, true); foreach (string path in _titleUpdateWindowData.Paths) { @@ -84,46 +90,68 @@ namespace Ryujinx.UI.Windows } } - private void AddUpdate(string path) + private void AddUpdate(string path, bool ignoreNotFound = false) { - if (File.Exists(path)) + if (!File.Exists(path) || _radioButtonToPathDictionary.ContainsValue(path)) { - using FileStream file = new(path, FileMode.Open, FileAccess.Read); + return; + } - PartitionFileSystem nsp = new(); - nsp.Initialize(file.AsStorage()).ThrowIfFailure(); + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; - try + try + { + using IFileSystem pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(path, _virtualFileSystem); + + Dictionary updates = pfs.GetContentData(ContentMetaType.Patch, _virtualFileSystem, checkLevel); + + Nca patchNca = null; + Nca controlNca = null; + + if (updates.TryGetValue(_applicationData.Id, out ContentMetaData update)) { - (Nca patchNca, Nca controlNca) = ApplicationLibrary.GetGameUpdateDataFromPartition(_virtualFileSystem, nsp, _titleId, 0); + patchNca = update.GetNcaByType(_virtualFileSystem.KeySet, LibHac.Ncm.ContentType.Program); + controlNca = update.GetNcaByType(_virtualFileSystem.KeySet, LibHac.Ncm.ContentType.Control); + } - if (controlNca != null && patchNca != null) + if (controlNca != null && patchNca != null) + { + ApplicationControlProperty controlData = new(); + + using var nacpFile = new UniqueRef(); + + controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); + nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure(); + + string radioLabel = $"Version {controlData.DisplayVersionString.ToString()} - {path}"; + + if (System.IO.Path.GetExtension(path).ToLower() == ".xci") { - ApplicationControlProperty controlData = new(); - - using var nacpFile = new UniqueRef(); - - controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); - nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure(); - - RadioButton radioButton = new($"Version {controlData.DisplayVersionString.ToString()} - {path}"); - radioButton.JoinGroup(_noUpdateRadioButton); - - _availableUpdatesBox.Add(radioButton); - _radioButtonToPathDictionary.Add(radioButton, path); - - radioButton.Show(); - radioButton.Active = true; + radioLabel = "Bundled: " + radioLabel; } - else + + RadioButton radioButton = new(radioLabel); + radioButton.JoinGroup(_noUpdateRadioButton); + + _availableUpdatesBox.Add(radioButton); + _radioButtonToPathDictionary.Add(radioButton, path); + + radioButton.Show(); + radioButton.Active = true; + } + else + { + if (!ignoreNotFound) { GtkDialog.CreateErrorDialog("The specified file does not contain an update for the selected title!"); } } - catch (Exception exception) - { - GtkDialog.CreateErrorDialog($"{exception.Message}. Errored File: {path}"); - } + } + catch (Exception exception) + { + GtkDialog.CreateErrorDialog($"{exception.Message}. Errored File: {path}"); } } diff --git a/src/Ryujinx.HLE/FileSystem/ContentManager.cs b/src/Ryujinx.HLE/FileSystem/ContentManager.cs index 3c34a886..e6c0fce0 100644 --- a/src/Ryujinx.HLE/FileSystem/ContentManager.cs +++ b/src/Ryujinx.HLE/FileSystem/ContentManager.cs @@ -14,6 +14,7 @@ using Ryujinx.Common.Utilities; using Ryujinx.HLE.Exceptions; using Ryujinx.HLE.HOS.Services.Ssl; using Ryujinx.HLE.HOS.Services.Time; +using Ryujinx.HLE.Utilities; using System; using System.Collections.Generic; using System.IO; @@ -184,41 +185,6 @@ namespace Ryujinx.HLE.FileSystem } } - // fs must contain AOC nca files in its root - public void AddAocData(IFileSystem fs, string containerPath, ulong aocBaseId, IntegrityCheckLevel integrityCheckLevel) - { - _virtualFileSystem.ImportTickets(fs); - - foreach (var ncaPath in fs.EnumerateEntries("*.cnmt.nca", SearchOptions.Default)) - { - using var ncaFile = new UniqueRef(); - - fs.OpenFile(ref ncaFile.Ref, ncaPath.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - var nca = new Nca(_virtualFileSystem.KeySet, ncaFile.Get.AsStorage()); - if (nca.Header.ContentType != NcaContentType.Meta) - { - Logger.Warning?.Print(LogClass.Application, $"{ncaPath} is not a valid metadata file"); - - continue; - } - - using var pfs0 = nca.OpenFileSystem(0, integrityCheckLevel); - using var cnmtFile = new UniqueRef(); - - pfs0.OpenFile(ref cnmtFile.Ref, pfs0.EnumerateEntries().Single().FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - var cnmt = new Cnmt(cnmtFile.Get.AsStream()); - if (cnmt.Type != ContentMetaType.AddOnContent || (cnmt.TitleId & 0xFFFFFFFFFFFFE000) != aocBaseId) - { - continue; - } - - string ncaId = Convert.ToHexString(cnmt.ContentEntries[0].NcaId).ToLower(); - - AddAocItem(cnmt.TitleId, containerPath, $"/{ncaId}.nca", true); - } - } - public void AddAocItem(ulong titleId, string containerPath, string ncaPath, bool mergedToContainer = false) { // TODO: Check Aoc version. @@ -232,11 +198,7 @@ namespace Ryujinx.HLE.FileSystem if (!mergedToContainer) { - using FileStream fileStream = File.OpenRead(containerPath); - using PartitionFileSystem partitionFileSystem = new(); - partitionFileSystem.Initialize(fileStream.AsStorage()).ThrowIfFailure(); - - _virtualFileSystem.ImportTickets(partitionFileSystem); + using var pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(containerPath, _virtualFileSystem); } } } diff --git a/src/Ryujinx.HLE/FileSystem/ContentMetaData.cs b/src/Ryujinx.HLE/FileSystem/ContentMetaData.cs new file mode 100644 index 00000000..aebcf798 --- /dev/null +++ b/src/Ryujinx.HLE/FileSystem/ContentMetaData.cs @@ -0,0 +1,61 @@ +using LibHac.Common.Keys; +using LibHac.Fs.Fsa; +using LibHac.Ncm; +using LibHac.Tools.FsSystem.NcaUtils; +using LibHac.Tools.Ncm; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using System; + +namespace Ryujinx.HLE.FileSystem +{ + /// + /// Thin wrapper around + /// + public class ContentMetaData + { + private readonly IFileSystem _pfs; + private readonly Cnmt _cnmt; + + public ulong Id => _cnmt.TitleId; + public TitleVersion Version => _cnmt.TitleVersion; + public ContentMetaType Type => _cnmt.Type; + public ulong ApplicationId => _cnmt.ApplicationTitleId; + public ulong PatchId => _cnmt.PatchTitleId; + public TitleVersion RequiredSystemVersion => _cnmt.MinimumSystemVersion; + public TitleVersion RequiredApplicationVersion => _cnmt.MinimumApplicationVersion; + public byte[] Digest => _cnmt.Hash; + + public ulong ProgramBaseId => Id & ~0x1FFFUL; + public bool IsSystemTitle => _cnmt.Type < ContentMetaType.Application; + + public ContentMetaData(IFileSystem pfs, Cnmt cnmt) + { + _pfs = pfs; + _cnmt = cnmt; + } + + public Nca GetNcaByType(KeySet keySet, ContentType type, int programIndex = 0) + { + // TODO: Replace this with a check for IdOffset as soon as LibHac supports it: + // && entry.IdOffset == programIndex + + foreach (var entry in _cnmt.ContentEntries) + { + if (entry.Type != type) + { + continue; + } + + string ncaId = BitConverter.ToString(entry.NcaId).Replace("-", null).ToLower(); + Nca nca = _pfs.GetNca(keySet, $"/{ncaId}.nca"); + + if (nca.GetProgramIndex() == programIndex) + { + return nca; + } + } + + return null; + } + } +} diff --git a/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs b/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs index 6c2415e4..6c2a1989 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/Extensions/LocalFileSystemExtensions.cs @@ -3,7 +3,6 @@ using LibHac.FsSystem; using LibHac.Loader; using LibHac.Ncm; using LibHac.Ns; -using Ryujinx.HLE.HOS; using Ryujinx.HLE.Loaders.Processes.Extensions; namespace Ryujinx.HLE.Loaders.Processes diff --git a/src/Ryujinx.HLE/Loaders/Processes/Extensions/NcaExtensions.cs b/src/Ryujinx.HLE/Loaders/Processes/Extensions/NcaExtensions.cs index e70fcb6f..da563720 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/Extensions/NcaExtensions.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/Extensions/NcaExtensions.cs @@ -7,16 +7,25 @@ using LibHac.Ncm; using LibHac.Ns; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; +using LibHac.Tools.Ncm; +using Ryujinx.Common.Configuration; using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; +using Ryujinx.HLE.Utilities; using System.IO; using System.Linq; using ApplicationId = LibHac.Ncm.ApplicationId; +using ContentType = LibHac.Ncm.ContentType; +using Path = System.IO.Path; namespace Ryujinx.HLE.Loaders.Processes.Extensions { - static class NcaExtensions + public static class NcaExtensions { + private static readonly TitleUpdateMetadataJsonSerializerContext _applicationSerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + public static ProcessResult Load(this Nca nca, Switch device, Nca patchNca, Nca controlNca) { // Extract RomFs and ExeFs from NCA. @@ -47,7 +56,7 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions nacpData = controlNca.GetNacp(device); } - /* TODO: Rework this since it's wrong and doesn't work as it takes the DisplayVersion from a "potential" inexistant update. + /* TODO: Rework this since it's wrong and doesn't work as it takes the DisplayVersion from a "potential" non-existent update. // Load program 0 control NCA as we are going to need it for display version. (_, Nca updateProgram0ControlNca) = GetGameUpdateData(_device.Configuration.VirtualFileSystem, mainNca.Header.TitleId.ToString("x16"), 0, out _); @@ -86,6 +95,11 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions return processResult; } + public static ulong GetProgramIdBase(this Nca nca) + { + return nca.Header.TitleId & ~0x1FFFUL; + } + public static int GetProgramIndex(this Nca nca) { return (int)(nca.Header.TitleId & 0xF); @@ -96,6 +110,11 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions return nca.Header.ContentType == NcaContentType.Program; } + public static bool IsMain(this Nca nca) + { + return nca.IsProgram() && !nca.IsPatch(); + } + public static bool IsPatch(this Nca nca) { int dataIndex = Nca.GetSectionIndexFromType(NcaSectionType.Data, NcaContentType.Program); @@ -108,6 +127,43 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions return nca.Header.ContentType == NcaContentType.Control; } + public static (Nca, Nca) GetUpdateData(this Nca mainNca, VirtualFileSystem fileSystem, IntegrityCheckLevel checkLevel, int programIndex, out string updatePath) + { + updatePath = null; + + // Load Update NCAs. + Nca updatePatchNca = null; + Nca updateControlNca = null; + + // Clear the program index part. + ulong titleIdBase = mainNca.GetProgramIdBase(); + + // Load update information if exists. + string titleUpdateMetadataPath = Path.Combine(AppDataManager.GamesDirPath, mainNca.Header.TitleId.ToString("x16"), "updates.json"); + if (File.Exists(titleUpdateMetadataPath)) + { + updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _applicationSerializerContext.TitleUpdateMetadata).Selected; + if (File.Exists(updatePath)) + { + IFileSystem updatePartitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(updatePath, fileSystem); + + foreach ((ulong applicationTitleId, ContentMetaData content) in updatePartitionFileSystem.GetContentData(ContentMetaType.Patch, fileSystem, checkLevel)) + { + if ((applicationTitleId & ~0x1FFFUL) != titleIdBase) + { + continue; + } + + updatePatchNca = content.GetNcaByType(fileSystem.KeySet, ContentType.Program, programIndex); + updateControlNca = content.GetNcaByType(fileSystem.KeySet, ContentType.Control, programIndex); + break; + } + } + } + + return (updatePatchNca, updateControlNca); + } + public static IFileSystem GetExeFs(this Nca nca, Switch device, Nca patchNca = null) { IFileSystem exeFs = null; @@ -172,5 +228,31 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions return nacpData; } + + public static Cnmt GetCnmt(this Nca cnmtNca, IntegrityCheckLevel checkLevel, ContentMetaType metaType) + { + string path = $"/{metaType}_{cnmtNca.Header.TitleId:x16}.cnmt"; + using var cnmtFile = new UniqueRef(); + + try + { + Result result = cnmtNca.OpenFileSystem(0, checkLevel) + .OpenFile(ref cnmtFile.Ref, path.ToU8Span(), OpenMode.Read); + + if (result.IsSuccess()) + { + return new Cnmt(cnmtFile.Release().AsStream()); + } + } + catch (HorizonResultException ex) + { + if (!ResultFs.PathNotFound.Includes(ex.ResultValue)) + { + Logger.Warning?.Print(LogClass.Application, $"Failed get CNMT for '{cnmtNca.Header.TitleId:x16}' from NCA: {ex.Message}"); + } + } + + return null; + } } } diff --git a/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs b/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs index e95b1b05..bee2572a 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs @@ -1,26 +1,58 @@ using LibHac.Common; +using LibHac.Common.Keys; using LibHac.Fs; using LibHac.Fs.Fsa; using LibHac.FsSystem; +using LibHac.Ncm; using LibHac.Tools.Fs; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; +using LibHac.Tools.Ncm; using Ryujinx.Common.Configuration; using Ryujinx.Common.Logging; using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; using System; using System.Collections.Generic; -using System.Globalization; using System.IO; +using ContentType = LibHac.Ncm.ContentType; namespace Ryujinx.HLE.Loaders.Processes.Extensions { public static class PartitionFileSystemExtensions { private static readonly DownloadableContentJsonSerializerContext _contentSerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); - private static readonly TitleUpdateMetadataJsonSerializerContext _titleSerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); - internal static (bool, ProcessResult) TryLoad(this PartitionFileSystemCore partitionFileSystem, Switch device, string path, out string errorMessage) + public static Dictionary GetContentData(this IFileSystem partitionFileSystem, + ContentMetaType contentType, VirtualFileSystem fileSystem, IntegrityCheckLevel checkLevel) + { + fileSystem.ImportTickets(partitionFileSystem); + + var programs = new Dictionary(); + + foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.cnmt.nca")) + { + Cnmt cnmt = partitionFileSystem.GetNca(fileSystem.KeySet, fileEntry.FullPath).GetCnmt(checkLevel, contentType); + + if (cnmt == null) + { + continue; + } + + ContentMetaData content = new(partitionFileSystem, cnmt); + + if (content.Type != contentType) + { + continue; + } + + programs.TryAdd(content.ApplicationId, content); + } + + return programs; + } + + internal static (bool, ProcessResult) TryLoad(this PartitionFileSystemCore partitionFileSystem, Switch device, string path, ulong applicationId, out string errorMessage) where TMetaData : PartitionFileSystemMetaCore, new() where TFormat : IPartitionFileSystemFormat where THeader : unmanaged, IPartitionFileSystemHeader @@ -35,31 +67,22 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions try { - device.Configuration.VirtualFileSystem.ImportTickets(partitionFileSystem); + Dictionary applications = partitionFileSystem.GetContentData(ContentMetaType.Application, device.FileSystem, device.System.FsIntegrityCheckLevel); - // TODO: To support multi-games container, this should use CNMT NCA instead. - foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca")) + if (applicationId == 0) { - Nca nca = partitionFileSystem.GetNca(device, fileEntry.FullPath); - - if (nca.GetProgramIndex() != device.Configuration.UserChannelPersistence.Index) + foreach ((ulong _, ContentMetaData content) in applications) { - continue; - } - - if (nca.IsPatch()) - { - patchNca = nca; - } - else if (nca.IsProgram()) - { - mainNca = nca; - } - else if (nca.IsControl()) - { - controlNca = nca; + mainNca = content.GetNcaByType(device.FileSystem.KeySet, ContentType.Program, device.Configuration.UserChannelPersistence.Index); + controlNca = content.GetNcaByType(device.FileSystem.KeySet, ContentType.Control, device.Configuration.UserChannelPersistence.Index); + break; } } + else if (applications.TryGetValue(applicationId, out ContentMetaData content)) + { + mainNca = content.GetNcaByType(device.FileSystem.KeySet, ContentType.Program, device.Configuration.UserChannelPersistence.Index); + controlNca = content.GetNcaByType(device.FileSystem.KeySet, ContentType.Control, device.Configuration.UserChannelPersistence.Index); + } ProcessLoaderHelper.RegisterProgramMapInfo(device, partitionFileSystem).ThrowIfFailure(); } @@ -79,54 +102,7 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions return (false, ProcessResult.Failed); } - // Load Update NCAs. - Nca updatePatchNca = null; - Nca updateControlNca = null; - - if (ulong.TryParse(mainNca.Header.TitleId.ToString("x16"), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdBase)) - { - // Clear the program index part. - titleIdBase &= ~0xFUL; - - // Load update information if exists. - string titleUpdateMetadataPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, titleIdBase.ToString("x16"), "updates.json"); - if (File.Exists(titleUpdateMetadataPath)) - { - string updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected; - if (File.Exists(updatePath)) - { - PartitionFileSystem updatePartitionFileSystem = new(); - updatePartitionFileSystem.Initialize(new FileStream(updatePath, FileMode.Open, FileAccess.Read).AsStorage()).ThrowIfFailure(); - - device.Configuration.VirtualFileSystem.ImportTickets(updatePartitionFileSystem); - - // TODO: This should use CNMT NCA instead. - foreach (DirectoryEntryEx fileEntry in updatePartitionFileSystem.EnumerateEntries("/", "*.nca")) - { - Nca nca = updatePartitionFileSystem.GetNca(device, fileEntry.FullPath); - - if (nca.GetProgramIndex() != device.Configuration.UserChannelPersistence.Index) - { - continue; - } - - if ($"{nca.Header.TitleId.ToString("x16")[..^3]}000" != titleIdBase.ToString("x16")) - { - break; - } - - if (nca.IsProgram()) - { - updatePatchNca = nca; - } - else if (nca.IsControl()) - { - updateControlNca = nca; - } - } - } - } - } + (Nca updatePatchNca, Nca updateControlNca) = mainNca.GetUpdateData(device.FileSystem, device.System.FsIntegrityCheckLevel, device.Configuration.UserChannelPersistence.Index, out string _); if (updatePatchNca != null) { @@ -138,10 +114,8 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions controlNca = updateControlNca; } - // Load contained DownloadableContents. // TODO: If we want to support multi-processes in future, we shouldn't clear AddOnContent data here. device.Configuration.ContentManager.ClearAocData(); - device.Configuration.ContentManager.AddAocData(partitionFileSystem, path, mainNca.Header.TitleId, device.Configuration.FsIntegrityCheckLevel); // Load DownloadableContents. string addOnContentMetadataPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, mainNca.Header.TitleId.ToString("x16"), "dlc.json"); @@ -153,9 +127,12 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions { foreach (DownloadableContentNca downloadableContentNca in downloadableContentContainer.DownloadableContentNcaList) { - if (File.Exists(downloadableContentContainer.ContainerPath) && downloadableContentNca.Enabled) + if (File.Exists(downloadableContentContainer.ContainerPath)) { - device.Configuration.ContentManager.AddAocItem(downloadableContentNca.TitleId, downloadableContentContainer.ContainerPath, downloadableContentNca.FullPath); + if (downloadableContentNca.Enabled) + { + device.Configuration.ContentManager.AddAocItem(downloadableContentNca.TitleId, downloadableContentContainer.ContainerPath, downloadableContentNca.FullPath); + } } else { @@ -168,18 +145,18 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions return (true, mainNca.Load(device, patchNca, controlNca)); } - errorMessage = "Unable to load: Could not find Main NCA"; + errorMessage = $"Unable to load: Could not find Main NCA for title \"{applicationId:X16}\""; return (false, ProcessResult.Failed); } - public static Nca GetNca(this IFileSystem fileSystem, Switch device, string path) + public static Nca GetNca(this IFileSystem fileSystem, KeySet keySet, string path) { using var ncaFile = new UniqueRef(); fileSystem.OpenFile(ref ncaFile.Ref, path.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - return new Nca(device.Configuration.VirtualFileSystem.KeySet, ncaFile.Release().AsStorage()); + return new Nca(keySet, ncaFile.Release().AsStorage()); } } } diff --git a/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs b/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs index e5056c89..12d9c8bd 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs @@ -32,7 +32,7 @@ namespace Ryujinx.HLE.Loaders.Processes _processesByPid = new ConcurrentDictionary(); } - public bool LoadXci(string path) + public bool LoadXci(string path, ulong applicationId) { FileStream stream = new(path, FileMode.Open, FileAccess.Read); Xci xci = new(_device.Configuration.VirtualFileSystem.KeySet, stream.AsStorage()); @@ -44,7 +44,7 @@ namespace Ryujinx.HLE.Loaders.Processes return false; } - (bool success, ProcessResult processResult) = xci.OpenPartition(XciPartitionType.Secure).TryLoad(_device, path, out string errorMessage); + (bool success, ProcessResult processResult) = xci.OpenPartition(XciPartitionType.Secure).TryLoad(_device, path, applicationId, out string errorMessage); if (!success) { @@ -66,13 +66,13 @@ namespace Ryujinx.HLE.Loaders.Processes return false; } - public bool LoadNsp(string path) + public bool LoadNsp(string path, ulong applicationId) { FileStream file = new(path, FileMode.Open, FileAccess.Read); PartitionFileSystem partitionFileSystem = new(); partitionFileSystem.Initialize(file.AsStorage()).ThrowIfFailure(); - (bool success, ProcessResult processResult) = partitionFileSystem.TryLoad(_device, path, out string errorMessage); + (bool success, ProcessResult processResult) = partitionFileSystem.TryLoad(_device, path, applicationId, out string errorMessage); if (processResult.ProcessId == 0) { diff --git a/src/Ryujinx.HLE/Loaders/Processes/ProcessLoaderHelper.cs b/src/Ryujinx.HLE/Loaders/Processes/ProcessLoaderHelper.cs index fe2aaac6..cf4eb416 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/ProcessLoaderHelper.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/ProcessLoaderHelper.cs @@ -43,15 +43,14 @@ namespace Ryujinx.HLE.Loaders.Processes foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca")) { - Nca nca = partitionFileSystem.GetNca(device, fileEntry.FullPath); + Nca nca = partitionFileSystem.GetNca(device.FileSystem.KeySet, fileEntry.FullPath); - if (!nca.IsProgram() && nca.IsPatch()) + if (!nca.IsProgram()) { continue; } - ulong currentProgramId = nca.Header.TitleId; - ulong currentMainProgramId = currentProgramId & ~0xFFFul; + ulong currentMainProgramId = nca.GetProgramIdBase(); if (applicationId == 0 && currentMainProgramId != 0) { @@ -68,7 +67,7 @@ namespace Ryujinx.HLE.Loaders.Processes break; } - hasIndex[(int)(currentProgramId & 0xF)] = true; + hasIndex[nca.GetProgramIndex()] = true; } if (programCount == 0) diff --git a/src/Ryujinx.HLE/Switch.cs b/src/Ryujinx.HLE/Switch.cs index 81c3ab47..9dfc6989 100644 --- a/src/Ryujinx.HLE/Switch.cs +++ b/src/Ryujinx.HLE/Switch.cs @@ -73,9 +73,9 @@ namespace Ryujinx.HLE return Processes.LoadUnpackedNca(exeFsDir, romFsFile); } - public bool LoadXci(string xciFile) + public bool LoadXci(string xciFile, ulong applicationId = 0) { - return Processes.LoadXci(xciFile); + return Processes.LoadXci(xciFile, applicationId); } public bool LoadNca(string ncaFile) @@ -83,9 +83,9 @@ namespace Ryujinx.HLE return Processes.LoadNca(ncaFile); } - public bool LoadNsp(string nspFile) + public bool LoadNsp(string nspFile, ulong applicationId = 0) { - return Processes.LoadNsp(nspFile); + return Processes.LoadNsp(nspFile, applicationId); } public bool LoadProgram(string fileName) diff --git a/src/Ryujinx.HLE/Utilities/PartitionFileSystemUtils.cs b/src/Ryujinx.HLE/Utilities/PartitionFileSystemUtils.cs new file mode 100644 index 00000000..3c4ce085 --- /dev/null +++ b/src/Ryujinx.HLE/Utilities/PartitionFileSystemUtils.cs @@ -0,0 +1,45 @@ +using LibHac; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Tools.Fs; +using LibHac.Tools.FsSystem; +using Ryujinx.HLE.FileSystem; +using System.IO; + +namespace Ryujinx.HLE.Utilities +{ + public static class PartitionFileSystemUtils + { + public static IFileSystem OpenApplicationFileSystem(string path, VirtualFileSystem fileSystem, bool throwOnFailure = true) + { + FileStream file = File.OpenRead(path); + + IFileSystem partitionFileSystem; + + if (Path.GetExtension(path).ToLower() == ".xci") + { + partitionFileSystem = new Xci(fileSystem.KeySet, file.AsStorage()).OpenPartition(XciPartitionType.Secure); + } + else + { + var pfsTemp = new PartitionFileSystem(); + Result initResult = pfsTemp.Initialize(file.AsStorage()); + + if (throwOnFailure) + { + initResult.ThrowIfFailure(); + } + else if (initResult.IsFailure()) + { + return null; + } + + partitionFileSystem = pfsTemp; + } + + fileSystem.ImportTickets(partitionFileSystem); + + return partitionFileSystem; + } + } +} diff --git a/src/Ryujinx.UI.Common/App/ApplicationData.cs b/src/Ryujinx.UI.Common/App/ApplicationData.cs index 13c05655..7108defc 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationData.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationData.cs @@ -9,9 +9,11 @@ using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; using Ryujinx.Common.Logging; using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Loaders.Processes.Extensions; using Ryujinx.UI.Common.Helper; using System; using System.IO; +using System.Text.Json.Serialization; namespace Ryujinx.UI.App.Common { @@ -19,10 +21,10 @@ namespace Ryujinx.UI.App.Common { public bool Favorite { get; set; } public byte[] Icon { get; set; } - public string TitleName { get; set; } - public string TitleId { get; set; } - public string Developer { get; set; } - public string Version { get; set; } + public string Name { get; set; } = "Unknown"; + public ulong Id { get; set; } + public string Developer { get; set; } = "Unknown"; + public string Version { get; set; } = "0"; public TimeSpan TimePlayed { get; set; } public DateTime? LastPlayed { get; set; } public string FileExtension { get; set; } @@ -36,7 +38,11 @@ namespace Ryujinx.UI.App.Common public string FileSizeString => ValueFormatUtils.FormatFileSize(FileSize); - public static string GetApplicationBuildId(VirtualFileSystem virtualFileSystem, string titleFilePath) + [JsonIgnore] public string IdString => Id.ToString("x16"); + + [JsonIgnore] public ulong IdBase => Id & ~0x1FFFUL; + + public static string GetBuildId(VirtualFileSystem virtualFileSystem, IntegrityCheckLevel checkLevel, string titleFilePath) { using FileStream file = new(titleFilePath, FileMode.Open, FileAccess.Read); @@ -105,7 +111,7 @@ namespace Ryujinx.UI.App.Common return string.Empty; } - (Nca updatePatchNca, _) = ApplicationLibrary.GetGameUpdateData(virtualFileSystem, mainNca.Header.TitleId.ToString("x16"), 0, out _); + (Nca updatePatchNca, _) = mainNca.GetUpdateData(virtualFileSystem, checkLevel, 0, out string _); if (updatePatchNca != null) { diff --git a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs index 176011dd..ef3826cf 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs @@ -4,28 +4,29 @@ using LibHac.Common.Keys; using LibHac.Fs; using LibHac.Fs.Fsa; using LibHac.FsSystem; +using LibHac.Ncm; using LibHac.Ns; using LibHac.Tools.Fs; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; -using Ryujinx.Common; using Ryujinx.Common.Configuration; using Ryujinx.Common.Logging; using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS.SystemState; using Ryujinx.HLE.Loaders.Npdm; +using Ryujinx.HLE.Loaders.Processes.Extensions; using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Common.Configuration.System; using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.Json; using System.Threading; +using ContentType = LibHac.Ncm.ContentType; using Path = System.IO.Path; using TimeSpan = System.TimeSpan; @@ -43,15 +44,16 @@ namespace Ryujinx.UI.App.Common private readonly byte[] _nsoIcon; private readonly VirtualFileSystem _virtualFileSystem; + private readonly IntegrityCheckLevel _checkLevel; private Language _desiredTitleLanguage; private CancellationTokenSource _cancellationToken; private static readonly ApplicationJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); - private static readonly TitleUpdateMetadataJsonSerializerContext _titleSerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); - public ApplicationLibrary(VirtualFileSystem virtualFileSystem) + public ApplicationLibrary(VirtualFileSystem virtualFileSystem, IntegrityCheckLevel checkLevel) { _virtualFileSystem = virtualFileSystem; + _checkLevel = checkLevel; _nspIcon = GetResourceBytes("Ryujinx.UI.Common.Resources.Icon_NSP.png"); _xciIcon = GetResourceBytes("Ryujinx.UI.Common.Resources.Icon_XCI.png"); @@ -70,6 +72,400 @@ namespace Ryujinx.UI.App.Common return resourceByteArray; } + private ApplicationData GetApplicationFromExeFs(PartitionFileSystem pfs, string filePath) + { + ApplicationData data = new() + { + Icon = _nspIcon, + }; + + using UniqueRef npdmFile = new(); + + try + { + Result result = pfs.OpenFile(ref npdmFile.Ref, "/main.npdm".ToU8Span(), OpenMode.Read); + + if (ResultFs.PathNotFound.Includes(result)) + { + Npdm npdm = new(npdmFile.Get.AsStream()); + + data.Name = npdm.TitleName; + data.Id = npdm.Aci0.TitleId; + } + + return data; + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. File: '{filePath}' Error: {exception.Message}"); + + return null; + } + } + + private ApplicationData GetApplicationFromNsp(PartitionFileSystem pfs, string filePath) + { + bool isExeFs = false; + + // If the NSP doesn't have a main NCA, decrement the number of applications found and then continue to the next application. + bool hasMainNca = false; + + foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*")) + { + if (Path.GetExtension(fileEntry.FullPath)?.ToLower() == ".nca") + { + using UniqueRef ncaFile = new(); + + try + { + pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = new(_virtualFileSystem.KeySet, ncaFile.Get.AsStorage()); + int dataIndex = Nca.GetSectionIndexFromType(NcaSectionType.Data, NcaContentType.Program); + + // Some main NCAs don't have a data partition, so check if the partition exists before opening it + if (nca.Header.ContentType == NcaContentType.Program && + !(nca.SectionExists(NcaSectionType.Data) && + nca.Header.GetFsHeader(dataIndex).IsPatchSection())) + { + hasMainNca = true; + + break; + } + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, $"Encountered an error while trying to load applications from file '{filePath}': {exception}"); + + return null; + } + } + else if (Path.GetFileNameWithoutExtension(fileEntry.FullPath) == "main") + { + isExeFs = true; + } + } + + if (hasMainNca) + { + List applications = GetApplicationsFromPfs(pfs, filePath); + + switch (applications.Count) + { + case 1: + return applications[0]; + case >= 1: + Logger.Warning?.Print(LogClass.Application, $"File '{filePath}' contains more applications than expected: {applications.Count}"); + return applications[0]; + default: + return null; + } + } + + if (isExeFs) + { + return GetApplicationFromExeFs(pfs, filePath); + } + + return null; + } + + private List GetApplicationsFromPfs(IFileSystem pfs, string filePath) + { + var applications = new List(); + string extension = Path.GetExtension(filePath).ToLower(); + + foreach ((ulong titleId, ContentMetaData content) in pfs.GetContentData(ContentMetaType.Application, _virtualFileSystem, _checkLevel)) + { + ApplicationData applicationData = new() + { + Id = titleId, + Path = filePath, + }; + + try + { + Nca mainNca = content.GetNcaByType(_virtualFileSystem.KeySet, ContentType.Program); + Nca controlNca = content.GetNcaByType(_virtualFileSystem.KeySet, ContentType.Control); + + BlitStruct controlHolder = new(1); + + IFileSystem controlFs = controlNca?.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None); + + // Check if there is an update available. + if (IsUpdateApplied(mainNca, out IFileSystem updatedControlFs)) + { + // Replace the original ControlFs by the updated one. + controlFs = updatedControlFs; + } + + ReadControlData(controlFs, controlHolder.ByteSpan); + + GetApplicationInformation(ref controlHolder.Value, ref applicationData); + + // Read the icon from the ControlFS and store it as a byte array + try + { + using UniqueRef icon = new(); + + controlFs.OpenFile(ref icon.Ref, $"/icon_{_desiredTitleLanguage}.dat".ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + using MemoryStream stream = new(); + + icon.Get.AsStream().CopyTo(stream); + applicationData.Icon = stream.ToArray(); + } + catch (HorizonResultException) + { + foreach (DirectoryEntryEx entry in controlFs.EnumerateEntries("/", "*")) + { + if (entry.Name == "control.nacp") + { + continue; + } + + using var icon = new UniqueRef(); + + controlFs.OpenFile(ref icon.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + using MemoryStream stream = new(); + + icon.Get.AsStream().CopyTo(stream); + applicationData.Icon = stream.ToArray(); + + if (applicationData.Icon != null) + { + break; + } + } + + applicationData.Icon ??= extension == ".xci" ? _xciIcon : _nspIcon; + } + + applicationData.ControlHolder = controlHolder; + + applications.Add(applicationData); + } + catch (MissingKeyException exception) + { + applicationData.Icon = extension == ".xci" ? _xciIcon : _nspIcon; + + Logger.Warning?.Print(LogClass.Application, $"Your key set is missing a key with the name: {exception.Name}"); + } + catch (InvalidDataException) + { + applicationData.Icon = extension == ".xci" ? _xciIcon : _nspIcon; + + Logger.Warning?.Print(LogClass.Application, $"The header key is incorrect or missing and therefore the NCA header content type check has failed. Errored File: {filePath}"); + } + catch (Exception exception) + { + Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. File: '{filePath}' Error: {exception}"); + } + } + + return applications; + } + + public bool TryGetApplicationsFromFile(string applicationPath, out List applications) + { + applications = []; + + long fileSize = new FileInfo(applicationPath).Length; + + BlitStruct controlHolder = new(1); + + try + { + string extension = Path.GetExtension(applicationPath).ToLower(); + + using FileStream file = new(applicationPath, FileMode.Open, FileAccess.Read); + + switch (extension) + { + case ".xci": + { + Xci xci = new(_virtualFileSystem.KeySet, file.AsStorage()); + + applications = GetApplicationsFromPfs(xci.OpenPartition(XciPartitionType.Secure), applicationPath); + + if (applications.Count == 0) + { + return false; + } + + break; + } + case ".nsp": + case ".pfs0": + { + var pfs = new PartitionFileSystem(); + pfs.Initialize(file.AsStorage()).ThrowIfFailure(); + + ApplicationData result = GetApplicationFromNsp(pfs, applicationPath); + + if (result == null) + { + return false; + } + + applications.Add(result); + + break; + } + case ".nro": + { + BinaryReader reader = new(file); + ApplicationData application = new(); + + try + { + file.Seek(24, SeekOrigin.Begin); + + int assetOffset = reader.ReadInt32(); + + if (Encoding.ASCII.GetString(Read(assetOffset, 4)) == "ASET") + { + byte[] iconSectionInfo = Read(assetOffset + 8, 0x10); + + long iconOffset = BitConverter.ToInt64(iconSectionInfo, 0); + long iconSize = BitConverter.ToInt64(iconSectionInfo, 8); + + ulong nacpOffset = reader.ReadUInt64(); + ulong nacpSize = reader.ReadUInt64(); + + // Reads and stores game icon as byte array + if (iconSize > 0) + { + application.Icon = Read(assetOffset + iconOffset, (int)iconSize); + } + else + { + application.Icon = _nroIcon; + } + + // Read the NACP data + Read(assetOffset + (int)nacpOffset, (int)nacpSize).AsSpan().CopyTo(controlHolder.ByteSpan); + + GetApplicationInformation(ref controlHolder.Value, ref application); + } + else + { + application.Icon = _nroIcon; + application.Name = Path.GetFileNameWithoutExtension(applicationPath); + } + + application.ControlHolder = controlHolder; + applications.Add(application); + } + catch + { + Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. Errored File: {applicationPath}"); + + return false; + } + + break; + + byte[] Read(long position, int size) + { + file.Seek(position, SeekOrigin.Begin); + + return reader.ReadBytes(size); + } + } + case ".nca": + { + try + { + ApplicationData application = new(); + + Nca nca = new(_virtualFileSystem.KeySet, new FileStream(applicationPath, FileMode.Open, FileAccess.Read).AsStorage()); + + if (!nca.IsProgram() || nca.IsPatch()) + { + return false; + } + + application.Icon = _ncaIcon; + application.Name = Path.GetFileNameWithoutExtension(applicationPath); + application.ControlHolder = controlHolder; + + applications.Add(application); + } + catch (InvalidDataException) + { + Logger.Warning?.Print(LogClass.Application, $"The NCA header content type check has failed. This is usually because the header key is incorrect or missing. Errored File: {applicationPath}"); + } + catch + { + Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. Errored File: {applicationPath}"); + + return false; + } + + break; + } + // If its an NSO we just set defaults + case ".nso": + { + ApplicationData application = new() + { + Icon = _nsoIcon, + Name = Path.GetFileNameWithoutExtension(applicationPath), + }; + + applications.Add(application); + break; + } + } + } + catch (IOException exception) + { + Logger.Warning?.Print(LogClass.Application, exception.Message); + + return false; + } + + foreach (var data in applications) + { + ApplicationMetadata appMetadata = LoadAndSaveMetaData(data.IdString, appMetadata => + { + appMetadata.Title = data.Name; + + // Only do the migration if time_played has a value and timespan_played hasn't been updated yet. + if (appMetadata.TimePlayedOld != default && appMetadata.TimePlayed == TimeSpan.Zero) + { + appMetadata.TimePlayed = TimeSpan.FromSeconds(appMetadata.TimePlayedOld); + appMetadata.TimePlayedOld = default; + } + + // Only do the migration if last_played has a value and last_played_utc doesn't exist yet. + if (appMetadata.LastPlayedOld != default && !appMetadata.LastPlayed.HasValue) + { + // Migrate from string-based last_played to DateTime-based last_played_utc. + if (DateTime.TryParse(appMetadata.LastPlayedOld, out DateTime lastPlayedOldParsed)) + { + appMetadata.LastPlayed = lastPlayedOldParsed; + + // Migration successful: deleting last_played from the metadata file. + appMetadata.LastPlayedOld = default; + } + + } + }); + + data.Favorite = appMetadata.Favorite; + data.TimePlayed = appMetadata.TimePlayed; + data.LastPlayed = appMetadata.LastPlayed; + data.FileExtension = Path.GetExtension(applicationPath).TrimStart('.').ToUpper(); + data.FileSize = fileSize; + data.Path = applicationPath; + } + + return true; + } + public void CancelLoading() { _cancellationToken?.Cancel(); @@ -93,7 +489,7 @@ namespace Ryujinx.UI.App.Common _cancellationToken = new CancellationTokenSource(); // Builds the applications list with paths to found applications - List applications = new(); + List applicationPaths = new(); try { @@ -137,14 +533,7 @@ namespace Ryujinx.UI.App.Common if (!fileInfo.Attributes.HasFlag(FileAttributes.Hidden) && extension is ".nsp" or ".pfs0" or ".xci" or ".nca" or ".nro" or ".nso") { var fullPath = fileInfo.ResolveLinkTarget(true)?.FullName ?? fileInfo.FullName; - - if (!File.Exists(fullPath)) - { - Logger.Warning?.Print(LogClass.Application, $"Skipping invalid symlink: {fileInfo.FullName}"); - continue; - } - - applications.Add(fullPath); + applicationPaths.Add(fullPath); numApplicationsFound++; } } @@ -156,328 +545,35 @@ namespace Ryujinx.UI.App.Common } // Loops through applications list, creating a struct and then firing an event containing the struct for each application - foreach (string applicationPath in applications) + foreach (string applicationPath in applicationPaths) { if (_cancellationToken.Token.IsCancellationRequested) { return; } - long fileSize = new FileInfo(applicationPath).Length; - string titleName = "Unknown"; - string titleId = "0000000000000000"; - string developer = "Unknown"; - string version = "0"; - byte[] applicationIcon = null; - - BlitStruct controlHolder = new(1); - - try + if (TryGetApplicationsFromFile(applicationPath, out List applications)) { - string extension = Path.GetExtension(applicationPath).ToLower(); - - using FileStream file = new(applicationPath, FileMode.Open, FileAccess.Read); - - if (extension == ".nsp" || extension == ".pfs0" || extension == ".xci") + foreach (var application in applications) { - try + OnApplicationAdded(new ApplicationAddedEventArgs { - IFileSystem pfs; - - bool isExeFs = false; - - if (extension == ".xci") - { - Xci xci = new(_virtualFileSystem.KeySet, file.AsStorage()); - - pfs = xci.OpenPartition(XciPartitionType.Secure); - } - else - { - var pfsTemp = new PartitionFileSystem(); - pfsTemp.Initialize(file.AsStorage()).ThrowIfFailure(); - pfs = pfsTemp; - - // If the NSP doesn't have a main NCA, decrement the number of applications found and then continue to the next application. - bool hasMainNca = false; - - foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*")) - { - if (Path.GetExtension(fileEntry.FullPath).ToLower() == ".nca") - { - using UniqueRef ncaFile = new(); - - pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - Nca nca = new(_virtualFileSystem.KeySet, ncaFile.Get.AsStorage()); - int dataIndex = Nca.GetSectionIndexFromType(NcaSectionType.Data, NcaContentType.Program); - - // Some main NCAs don't have a data partition, so check if the partition exists before opening it - if (nca.Header.ContentType == NcaContentType.Program && !(nca.SectionExists(NcaSectionType.Data) && nca.Header.GetFsHeader(dataIndex).IsPatchSection())) - { - hasMainNca = true; - - break; - } - } - else if (Path.GetFileNameWithoutExtension(fileEntry.FullPath) == "main") - { - isExeFs = true; - } - } - - if (!hasMainNca && !isExeFs) - { - numApplicationsFound--; - - continue; - } - } - - if (isExeFs) - { - applicationIcon = _nspIcon; - - using UniqueRef npdmFile = new(); - - Result result = pfs.OpenFile(ref npdmFile.Ref, "/main.npdm".ToU8Span(), OpenMode.Read); - - if (ResultFs.PathNotFound.Includes(result)) - { - Npdm npdm = new(npdmFile.Get.AsStream()); - - titleName = npdm.TitleName; - titleId = npdm.Aci0.TitleId.ToString("x16"); - } - } - else - { - GetControlFsAndTitleId(pfs, out IFileSystem controlFs, out titleId); - - // Check if there is an update available. - if (IsUpdateApplied(titleId, out IFileSystem updatedControlFs)) - { - // Replace the original ControlFs by the updated one. - controlFs = updatedControlFs; - } - - ReadControlData(controlFs, controlHolder.ByteSpan); - - GetGameInformation(ref controlHolder.Value, out titleName, out _, out developer, out version); - - // Read the icon from the ControlFS and store it as a byte array - try - { - using UniqueRef icon = new(); - - controlFs.OpenFile(ref icon.Ref, $"/icon_{_desiredTitleLanguage}.dat".ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - using MemoryStream stream = new(); - - icon.Get.AsStream().CopyTo(stream); - applicationIcon = stream.ToArray(); - } - catch (HorizonResultException) - { - foreach (DirectoryEntryEx entry in controlFs.EnumerateEntries("/", "*")) - { - if (entry.Name == "control.nacp") - { - continue; - } - - using var icon = new UniqueRef(); - - controlFs.OpenFile(ref icon.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - using MemoryStream stream = new(); - - icon.Get.AsStream().CopyTo(stream); - applicationIcon = stream.ToArray(); - - if (applicationIcon != null) - { - break; - } - } - - applicationIcon ??= extension == ".xci" ? _xciIcon : _nspIcon; - } - } - } - catch (MissingKeyException exception) - { - applicationIcon = extension == ".xci" ? _xciIcon : _nspIcon; - - Logger.Warning?.Print(LogClass.Application, $"Your key set is missing a key with the name: {exception.Name}"); - } - catch (InvalidDataException) - { - applicationIcon = extension == ".xci" ? _xciIcon : _nspIcon; - - Logger.Warning?.Print(LogClass.Application, $"The header key is incorrect or missing and therefore the NCA header content type check has failed. Errored File: {applicationPath}"); - } - catch (Exception exception) - { - Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. File: '{applicationPath}' Error: {exception}"); - - numApplicationsFound--; - - continue; - } + AppData = application, + }); } - else if (extension == ".nro") + + if (applications.Count > 1) { - BinaryReader reader = new(file); - - byte[] Read(long position, int size) - { - file.Seek(position, SeekOrigin.Begin); - - return reader.ReadBytes(size); - } - - try - { - file.Seek(24, SeekOrigin.Begin); - - int assetOffset = reader.ReadInt32(); - - if (Encoding.ASCII.GetString(Read(assetOffset, 4)) == "ASET") - { - byte[] iconSectionInfo = Read(assetOffset + 8, 0x10); - - long iconOffset = BitConverter.ToInt64(iconSectionInfo, 0); - long iconSize = BitConverter.ToInt64(iconSectionInfo, 8); - - ulong nacpOffset = reader.ReadUInt64(); - ulong nacpSize = reader.ReadUInt64(); - - // Reads and stores game icon as byte array - if (iconSize > 0) - { - applicationIcon = Read(assetOffset + iconOffset, (int)iconSize); - } - else - { - applicationIcon = _nroIcon; - } - - // Read the NACP data - Read(assetOffset + (int)nacpOffset, (int)nacpSize).AsSpan().CopyTo(controlHolder.ByteSpan); - - GetGameInformation(ref controlHolder.Value, out titleName, out titleId, out developer, out version); - } - else - { - applicationIcon = _nroIcon; - titleName = Path.GetFileNameWithoutExtension(applicationPath); - } - } - catch - { - Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. Errored File: {applicationPath}"); - - numApplicationsFound--; - - continue; - } + numApplicationsFound += applications.Count - 1; } - else if (extension == ".nca") - { - try - { - Nca nca = new(_virtualFileSystem.KeySet, new FileStream(applicationPath, FileMode.Open, FileAccess.Read).AsStorage()); - int dataIndex = Nca.GetSectionIndexFromType(NcaSectionType.Data, NcaContentType.Program); - if (nca.Header.ContentType != NcaContentType.Program || (nca.SectionExists(NcaSectionType.Data) && nca.Header.GetFsHeader(dataIndex).IsPatchSection())) - { - numApplicationsFound--; - - continue; - } - } - catch (InvalidDataException) - { - Logger.Warning?.Print(LogClass.Application, $"The NCA header content type check has failed. This is usually because the header key is incorrect or missing. Errored File: {applicationPath}"); - } - catch - { - Logger.Warning?.Print(LogClass.Application, $"The file encountered was not of a valid type. Errored File: {applicationPath}"); - - numApplicationsFound--; - - continue; - } - - applicationIcon = _ncaIcon; - titleName = Path.GetFileNameWithoutExtension(applicationPath); - } - // If its an NSO we just set defaults - else if (extension == ".nso") - { - applicationIcon = _nsoIcon; - titleName = Path.GetFileNameWithoutExtension(applicationPath); - } + numApplicationsLoaded += applications.Count; } - catch (IOException exception) + else { - Logger.Warning?.Print(LogClass.Application, exception.Message); - numApplicationsFound--; - - continue; } - ApplicationMetadata appMetadata = LoadAndSaveMetaData(titleId, appMetadata => - { - appMetadata.Title = titleName; - - // Only do the migration if time_played has a value and timespan_played hasn't been updated yet. - if (appMetadata.TimePlayedOld != default && appMetadata.TimePlayed == TimeSpan.Zero) - { - appMetadata.TimePlayed = TimeSpan.FromSeconds(appMetadata.TimePlayedOld); - appMetadata.TimePlayedOld = default; - } - - // Only do the migration if last_played has a value and last_played_utc doesn't exist yet. - if (appMetadata.LastPlayedOld != default && !appMetadata.LastPlayed.HasValue) - { - // Migrate from string-based last_played to DateTime-based last_played_utc. - if (DateTime.TryParse(appMetadata.LastPlayedOld, out DateTime lastPlayedOldParsed)) - { - appMetadata.LastPlayed = lastPlayedOldParsed; - - // Migration successful: deleting last_played from the metadata file. - appMetadata.LastPlayedOld = default; - } - - } - }); - - ApplicationData data = new() - { - Favorite = appMetadata.Favorite, - Icon = applicationIcon, - TitleName = titleName, - TitleId = titleId, - Developer = developer, - Version = version, - TimePlayed = appMetadata.TimePlayed, - LastPlayed = appMetadata.LastPlayed, - FileExtension = Path.GetExtension(applicationPath).TrimStart('.').ToUpper(), - FileSize = fileSize, - Path = applicationPath, - ControlHolder = controlHolder, - }; - - numApplicationsLoaded++; - - OnApplicationAdded(new ApplicationAddedEventArgs - { - AppData = data, - }); - OnApplicationCountUpdated(new ApplicationCountUpdatedEventArgs { NumAppsFound = numApplicationsFound, @@ -508,15 +604,6 @@ namespace Ryujinx.UI.App.Common ApplicationCountUpdated?.Invoke(null, e); } - private void GetControlFsAndTitleId(IFileSystem pfs, out IFileSystem controlFs, out string titleId) - { - (_, _, Nca controlNca) = GetGameData(_virtualFileSystem, pfs, 0); - - // Return the ControlFS - controlFs = controlNca?.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None); - titleId = controlNca?.Header.TitleId.ToString("x16"); - } - public static ApplicationMetadata LoadAndSaveMetaData(string titleId, Action modifyFunction = null) { string metadataFolder = Path.Combine(AppDataManager.GamesDirPath, titleId, "gui"); @@ -554,10 +641,29 @@ namespace Ryujinx.UI.App.Common return appMetadata; } - public byte[] GetApplicationIcon(string applicationPath, Language desiredTitleLanguage) + public byte[] GetApplicationIcon(string applicationPath, Language desiredTitleLanguage, ulong applicationId) { byte[] applicationIcon = null; + if (applicationId == 0) + { + if (Directory.Exists(applicationPath)) + { + return _ncaIcon; + } + + return Path.GetExtension(applicationPath).ToLower() switch + { + ".nsp" => _nspIcon, + ".pfs0" => _nspIcon, + ".xci" => _xciIcon, + ".nso" => _nsoIcon, + ".nro" => _nroIcon, + ".nca" => _ncaIcon, + _ => _ncaIcon, + }; + } + try { // Look for icon only if applicationPath is not a directory @@ -603,7 +709,16 @@ namespace Ryujinx.UI.App.Common else { // Store the ControlFS in variable called controlFs - GetControlFsAndTitleId(pfs, out IFileSystem controlFs, out _); + Dictionary programs = pfs.GetContentData(ContentMetaType.Application, _virtualFileSystem, _checkLevel); + IFileSystem controlFs = null; + + if (programs.TryGetValue(applicationId, out ContentMetaData value)) + { + if (value.GetNcaByType(_virtualFileSystem.KeySet, ContentType.Control) is { } controlNca) + { + controlFs = controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None); + } + } // Read the icon from the ControlFS and store it as a byte array try @@ -630,16 +745,11 @@ namespace Ryujinx.UI.App.Common controlFs.OpenFile(ref icon.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - using (MemoryStream stream = new()) - { - icon.Get.AsStream().CopyTo(stream); - applicationIcon = stream.ToArray(); - } + using MemoryStream stream = new(); + icon.Get.AsStream().CopyTo(stream); + applicationIcon = stream.ToArray(); - if (applicationIcon != null) - { - break; - } + break; } applicationIcon ??= extension == ".xci" ? _xciIcon : _nspIcon; @@ -722,80 +832,79 @@ namespace Ryujinx.UI.App.Common return applicationIcon ?? _ncaIcon; } - private void GetGameInformation(ref ApplicationControlProperty controlData, out string titleName, out string titleId, out string publisher, out string version) + private void GetApplicationInformation(ref ApplicationControlProperty controlData, ref ApplicationData data) { _ = Enum.TryParse(_desiredTitleLanguage.ToString(), out TitleLanguage desiredTitleLanguage); if (controlData.Title.ItemsRo.Length > (int)desiredTitleLanguage) { - titleName = controlData.Title[(int)desiredTitleLanguage].NameString.ToString(); - publisher = controlData.Title[(int)desiredTitleLanguage].PublisherString.ToString(); + data.Name = controlData.Title[(int)desiredTitleLanguage].NameString.ToString(); + data.Developer = controlData.Title[(int)desiredTitleLanguage].PublisherString.ToString(); } else { - titleName = null; - publisher = null; + data.Name = null; + data.Developer = null; } - if (string.IsNullOrWhiteSpace(titleName)) + if (string.IsNullOrWhiteSpace(data.Name)) { foreach (ref readonly var controlTitle in controlData.Title.ItemsRo) { if (!controlTitle.NameString.IsEmpty()) { - titleName = controlTitle.NameString.ToString(); + data.Name = controlTitle.NameString.ToString(); break; } } } - if (string.IsNullOrWhiteSpace(publisher)) + if (string.IsNullOrWhiteSpace(data.Developer)) { foreach (ref readonly var controlTitle in controlData.Title.ItemsRo) { if (!controlTitle.PublisherString.IsEmpty()) { - publisher = controlTitle.PublisherString.ToString(); + data.Developer = controlTitle.PublisherString.ToString(); break; } } } - if (controlData.PresenceGroupId != 0) + if (data.Id == 0) { - titleId = controlData.PresenceGroupId.ToString("x16"); - } - else if (controlData.SaveDataOwnerId != 0) - { - titleId = controlData.SaveDataOwnerId.ToString(); - } - else if (controlData.AddOnContentBaseId != 0) - { - titleId = (controlData.AddOnContentBaseId - 0x1000).ToString("x16"); - } - else - { - titleId = "0000000000000000"; + if (controlData.SaveDataOwnerId != 0) + { + data.Id = controlData.SaveDataOwnerId; + } + else if (controlData.PresenceGroupId != 0) + { + data.Id = controlData.PresenceGroupId; + } + else if (controlData.AddOnContentBaseId != 0) + { + data.Id = (controlData.AddOnContentBaseId - 0x1000); + } } - version = controlData.DisplayVersionString.ToString(); + data.Version = controlData.DisplayVersionString.ToString(); } - private bool IsUpdateApplied(string titleId, out IFileSystem updatedControlFs) + private bool IsUpdateApplied(Nca mainNca, out IFileSystem updatedControlFs) { updatedControlFs = null; - string updatePath = "(unknown)"; + string updatePath = null; try { - (Nca patchNca, Nca controlNca) = GetGameUpdateData(_virtualFileSystem, titleId, 0, out updatePath); + (Nca patchNca, Nca controlNca) = mainNca.GetUpdateData(_virtualFileSystem, _checkLevel, 0, out updatePath); if (patchNca != null && controlNca != null) { - updatedControlFs = controlNca?.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None); + updatedControlFs = controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None); return true; } @@ -811,120 +920,5 @@ namespace Ryujinx.UI.App.Common return false; } - - public static (Nca main, Nca patch, Nca control) GetGameData(VirtualFileSystem fileSystem, IFileSystem pfs, int programIndex) - { - Nca mainNca = null; - Nca patchNca = null; - Nca controlNca = null; - - fileSystem.ImportTickets(pfs); - - foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) - { - using var ncaFile = new UniqueRef(); - - pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - Nca nca = new(fileSystem.KeySet, ncaFile.Release().AsStorage()); - - int ncaProgramIndex = (int)(nca.Header.TitleId & 0xF); - - if (ncaProgramIndex != programIndex) - { - continue; - } - - if (nca.Header.ContentType == NcaContentType.Program) - { - int dataIndex = Nca.GetSectionIndexFromType(NcaSectionType.Data, NcaContentType.Program); - - if (nca.SectionExists(NcaSectionType.Data) && nca.Header.GetFsHeader(dataIndex).IsPatchSection()) - { - patchNca = nca; - } - else - { - mainNca = nca; - } - } - else if (nca.Header.ContentType == NcaContentType.Control) - { - controlNca = nca; - } - } - - return (mainNca, patchNca, controlNca); - } - - public static (Nca patch, Nca control) GetGameUpdateDataFromPartition(VirtualFileSystem fileSystem, PartitionFileSystem pfs, string titleId, int programIndex) - { - Nca patchNca = null; - Nca controlNca = null; - - fileSystem.ImportTickets(pfs); - - foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) - { - using var ncaFile = new UniqueRef(); - - pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - Nca nca = new(fileSystem.KeySet, ncaFile.Release().AsStorage()); - - int ncaProgramIndex = (int)(nca.Header.TitleId & 0xF); - - if (ncaProgramIndex != programIndex) - { - continue; - } - - if ($"{nca.Header.TitleId.ToString("x16")[..^3]}000" != titleId) - { - break; - } - - if (nca.Header.ContentType == NcaContentType.Program) - { - patchNca = nca; - } - else if (nca.Header.ContentType == NcaContentType.Control) - { - controlNca = nca; - } - } - - return (patchNca, controlNca); - } - - public static (Nca patch, Nca control) GetGameUpdateData(VirtualFileSystem fileSystem, string titleId, int programIndex, out string updatePath) - { - updatePath = null; - - if (ulong.TryParse(titleId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdBase)) - { - // Clear the program index part. - titleIdBase &= ~0xFUL; - - // Load update information if exists. - string titleUpdateMetadataPath = Path.Combine(AppDataManager.GamesDirPath, titleIdBase.ToString("x16"), "updates.json"); - - if (File.Exists(titleUpdateMetadataPath)) - { - updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected; - - if (File.Exists(updatePath)) - { - FileStream file = new(updatePath, FileMode.Open, FileAccess.Read); - PartitionFileSystem nsp = new(); - nsp.Initialize(file.AsStorage()).ThrowIfFailure(); - - return GetGameUpdateDataFromPartition(fileSystem, nsp, titleIdBase.ToString("x16"), programIndex); - } - } - } - - return (null, null); - } } } diff --git a/src/Ryujinx.UI.Common/Helper/CommandLineState.cs b/src/Ryujinx.UI.Common/Helper/CommandLineState.cs index bbacd5fe..ae0e4d90 100644 --- a/src/Ryujinx.UI.Common/Helper/CommandLineState.cs +++ b/src/Ryujinx.UI.Common/Helper/CommandLineState.cs @@ -14,6 +14,7 @@ namespace Ryujinx.UI.Common.Helper public static string BaseDirPathArg { get; private set; } public static string Profile { get; private set; } public static string LaunchPathArg { get; private set; } + public static string LaunchApplicationId { get; private set; } public static bool StartFullscreenArg { get; private set; } public static void ParseArguments(string[] args) @@ -72,6 +73,10 @@ namespace Ryujinx.UI.Common.Helper OverrideGraphicsBackend = args[++i]; break; + case "-i": + case "--application-id": + LaunchApplicationId = args[++i]; + break; case "--docked-mode": OverrideDockedMode = true; break; diff --git a/src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs b/src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs index c2085b28..58bdc90e 100644 --- a/src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs +++ b/src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs @@ -15,7 +15,7 @@ namespace Ryujinx.UI.Common.Helper public static class ShortcutHelper { [SupportedOSPlatform("windows")] - private static void CreateShortcutWindows(string applicationFilePath, byte[] iconData, string iconPath, string cleanedAppName, string desktopPath) + private static void CreateShortcutWindows(string applicationFilePath, string applicationId, byte[] iconData, string iconPath, string cleanedAppName, string desktopPath) { string basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, AppDomain.CurrentDomain.FriendlyName + ".exe"); iconPath += ".ico"; @@ -25,13 +25,13 @@ namespace Ryujinx.UI.Common.Helper image.Mutate(x => x.Resize(128, 128)); SaveBitmapAsIcon(image, iconPath); - var shortcut = Shortcut.CreateShortcut(basePath, GetArgsString(applicationFilePath), iconPath, 0); + var shortcut = Shortcut.CreateShortcut(basePath, GetArgsString(applicationFilePath, applicationId), iconPath, 0); shortcut.StringData.NameString = cleanedAppName; shortcut.WriteToFile(Path.Combine(desktopPath, cleanedAppName + ".lnk")); } [SupportedOSPlatform("linux")] - private static void CreateShortcutLinux(string applicationFilePath, byte[] iconData, string iconPath, string desktopPath, string cleanedAppName) + private static void CreateShortcutLinux(string applicationFilePath, string applicationId, byte[] iconData, string iconPath, string desktopPath, string cleanedAppName) { string basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Ryujinx.sh"); var desktopFile = EmbeddedResources.ReadAllText("Ryujinx.UI.Common/shortcut-template.desktop"); @@ -41,11 +41,11 @@ namespace Ryujinx.UI.Common.Helper image.SaveAsPng(iconPath); using StreamWriter outputFile = new(Path.Combine(desktopPath, cleanedAppName + ".desktop")); - outputFile.Write(desktopFile, cleanedAppName, iconPath, $"{basePath} {GetArgsString(applicationFilePath)}"); + outputFile.Write(desktopFile, cleanedAppName, iconPath, $"{basePath} {GetArgsString(applicationFilePath, applicationId)}"); } [SupportedOSPlatform("macos")] - private static void CreateShortcutMacos(string appFilePath, byte[] iconData, string desktopPath, string cleanedAppName) + private static void CreateShortcutMacos(string appFilePath, string applicationId, byte[] iconData, string desktopPath, string cleanedAppName) { string basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Ryujinx"); var plistFile = EmbeddedResources.ReadAllText("Ryujinx.UI.Common/shortcut-template.plist"); @@ -64,7 +64,7 @@ namespace Ryujinx.UI.Common.Helper string scriptPath = Path.Combine(scriptFolderPath, ScriptName); using StreamWriter scriptFile = new(scriptPath); - scriptFile.Write(shortcutScript, basePath, GetArgsString(appFilePath)); + scriptFile.Write(shortcutScript, basePath, GetArgsString(appFilePath, applicationId)); // Set execute permission FileInfo fileInfo = new(scriptPath); @@ -95,7 +95,7 @@ namespace Ryujinx.UI.Common.Helper { string iconPath = Path.Combine(AppDataManager.BaseDirPath, "games", applicationId, "app"); - CreateShortcutWindows(applicationFilePath, iconData, iconPath, cleanedAppName, desktopPath); + CreateShortcutWindows(applicationFilePath, applicationId, iconData, iconPath, cleanedAppName, desktopPath); return; } @@ -105,14 +105,14 @@ namespace Ryujinx.UI.Common.Helper string iconPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "icons", "Ryujinx"); Directory.CreateDirectory(iconPath); - CreateShortcutLinux(applicationFilePath, iconData, Path.Combine(iconPath, applicationId), desktopPath, cleanedAppName); + CreateShortcutLinux(applicationFilePath, applicationId, iconData, Path.Combine(iconPath, applicationId), desktopPath, cleanedAppName); return; } if (OperatingSystem.IsMacOS()) { - CreateShortcutMacos(applicationFilePath, iconData, desktopPath, cleanedAppName); + CreateShortcutMacos(applicationFilePath, applicationId, iconData, desktopPath, cleanedAppName); return; } @@ -120,7 +120,7 @@ namespace Ryujinx.UI.Common.Helper throw new NotImplementedException("Shortcut support has not been implemented yet for this OS."); } - private static string GetArgsString(string appFilePath) + private static string GetArgsString(string appFilePath, string applicationId) { // args are first defined as a list, for easier adjustments in the future var argsList = new List(); @@ -131,6 +131,12 @@ namespace Ryujinx.UI.Common.Helper argsList.Add($"\"{CommandLineState.BaseDirPathArg}\""); } + if (appFilePath.ToLower().EndsWith(".xci")) + { + argsList.Add("--application-id"); + argsList.Add($"\"{applicationId}\""); + } + argsList.Add($"\"{appFilePath}\""); return String.Join(" ", argsList); diff --git a/src/Ryujinx/AppHost.cs b/src/Ryujinx/AppHost.cs index 7004908a..8c643f34 100644 --- a/src/Ryujinx/AppHost.cs +++ b/src/Ryujinx/AppHost.cs @@ -132,12 +132,14 @@ namespace Ryujinx.Ava public int Width { get; private set; } public int Height { get; private set; } public string ApplicationPath { get; private set; } + public ulong ApplicationId { get; private set; } public bool ScreenshotRequested { get; set; } public AppHost( RendererHost renderer, InputManager inputManager, string applicationPath, + ulong applicationId, VirtualFileSystem virtualFileSystem, ContentManager contentManager, AccountManager accountManager, @@ -161,6 +163,7 @@ namespace Ryujinx.Ava NpadManager = _inputManager.CreateNpadManager(); TouchScreenManager = _inputManager.CreateTouchScreenManager(); ApplicationPath = applicationPath; + ApplicationId = applicationId; VirtualFileSystem = virtualFileSystem; ContentManager = contentManager; @@ -719,7 +722,7 @@ namespace Ryujinx.Ava { Logger.Info?.Print(LogClass.Application, "Loading as XCI."); - if (!Device.LoadXci(ApplicationPath)) + if (!Device.LoadXci(ApplicationPath, ApplicationId)) { Device.Dispose(); @@ -746,7 +749,7 @@ namespace Ryujinx.Ava { Logger.Info?.Print(LogClass.Application, "Loading as NSP."); - if (!Device.LoadNsp(ApplicationPath)) + if (!Device.LoadNsp(ApplicationPath, ApplicationId)) { Device.Dispose(); diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json index 8df0f96a..74e18056 100644 --- a/src/Ryujinx/Assets/Locales/en_US.json +++ b/src/Ryujinx/Assets/Locales/en_US.json @@ -10,6 +10,7 @@ "SettingsTabSystemUseHypervisor": "Use Hypervisor", "MenuBarFile": "_File", "MenuBarFileOpenFromFile": "_Load Application From File", + "MenuBarFileOpenFromFileError": "No applications found in selected file.", "MenuBarFileOpenUnpacked": "Load _Unpacked Game", "MenuBarFileOpenEmuFolder": "Open Ryujinx Folder", "MenuBarFileOpenLogsFolder": "Open Logs Folder", @@ -649,6 +650,8 @@ "OpenSetupGuideMessage": "Open the Setup Guide", "NoUpdate": "No Update", "TitleUpdateVersionLabel": "Version {0}", + "TitleBundledUpdateVersionLabel": "Bundled: Version {0}", + "TitleBundledDlcLabel": "Bundled:", "RyujinxInfo": "Ryujinx - Info", "RyujinxConfirm": "Ryujinx - Confirmation", "FileDialogAllTypes": "All types", diff --git a/src/Ryujinx/Common/ApplicationHelper.cs b/src/Ryujinx/Common/ApplicationHelper.cs index 622a6a02..14773114 100644 --- a/src/Ryujinx/Common/ApplicationHelper.cs +++ b/src/Ryujinx/Common/ApplicationHelper.cs @@ -18,7 +18,8 @@ using Ryujinx.Ava.UI.Helpers; using Ryujinx.Common.Logging; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS.Services.Account.Acc; -using Ryujinx.UI.App.Common; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Common.Helper; using System; using System.Buffers; @@ -226,7 +227,11 @@ namespace Ryujinx.Ava.Common return; } - (Nca updatePatchNca, _) = ApplicationLibrary.GetGameUpdateData(_virtualFileSystem, mainNca.Header.TitleId.ToString("x16"), programIndex, out _); + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + (Nca updatePatchNca, _) = mainNca.GetUpdateData(_virtualFileSystem, checkLevel, programIndex, out _); if (updatePatchNca != null) { patchNca = updatePatchNca; diff --git a/src/Ryujinx/Program.cs b/src/Ryujinx/Program.cs index 4f68ca24..97696342 100644 --- a/src/Ryujinx/Program.cs +++ b/src/Ryujinx/Program.cs @@ -125,7 +125,7 @@ namespace Ryujinx.Ava if (CommandLineState.LaunchPathArg != null) { - MainWindow.DeferLoadApplication(CommandLineState.LaunchPathArg, CommandLineState.StartFullscreenArg); + MainWindow.DeferLoadApplication(CommandLineState.LaunchPathArg, CommandLineState.LaunchApplicationId, CommandLineState.StartFullscreenArg); } } diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs index 894ac6c1..5edd0230 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs @@ -1,7 +1,6 @@ using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; -using Avalonia.Threading; using LibHac.Fs; using LibHac.Tools.FsSystem.NcaUtils; using Ryujinx.Ava.Common; @@ -15,7 +14,6 @@ using Ryujinx.UI.App.Common; using Ryujinx.UI.Common.Helper; using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using Path = System.IO.Path; @@ -41,7 +39,7 @@ namespace Ryujinx.Ava.UI.Controls { viewModel.SelectedApplication.Favorite = !viewModel.SelectedApplication.Favorite; - ApplicationLibrary.LoadAndSaveMetaData(viewModel.SelectedApplication.TitleId, appMetadata => + ApplicationLibrary.LoadAndSaveMetaData(viewModel.SelectedApplication.IdString, appMetadata => { appMetadata.Favorite = viewModel.SelectedApplication.Favorite; }); @@ -76,19 +74,9 @@ namespace Ryujinx.Ava.UI.Controls { if (viewModel?.SelectedApplication != null) { - if (!ulong.TryParse(viewModel.SelectedApplication.TitleId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdNumber)) - { - Dispatcher.UIThread.InvokeAsync(async () => - { - await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogRyujinxErrorMessage], LocaleManager.Instance[LocaleKeys.DialogInvalidTitleIdErrorMessage]); - }); + var saveDataFilter = SaveDataFilter.Make(viewModel.SelectedApplication.Id, saveDataType, userId, saveDataId: default, index: default); - return; - } - - var saveDataFilter = SaveDataFilter.Make(titleIdNumber, saveDataType, userId, saveDataId: default, index: default); - - ApplicationHelper.OpenSaveDir(in saveDataFilter, titleIdNumber, viewModel.SelectedApplication.ControlHolder, viewModel.SelectedApplication.TitleName); + ApplicationHelper.OpenSaveDir(in saveDataFilter, viewModel.SelectedApplication.Id, viewModel.SelectedApplication.ControlHolder, viewModel.SelectedApplication.Name); } } @@ -98,7 +86,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - await TitleUpdateWindow.Show(viewModel.VirtualFileSystem, ulong.Parse(viewModel.SelectedApplication.TitleId, NumberStyles.HexNumber), viewModel.SelectedApplication.TitleName); + await TitleUpdateWindow.Show(viewModel.VirtualFileSystem, viewModel.SelectedApplication); } } @@ -108,7 +96,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - await DownloadableContentManagerWindow.Show(viewModel.VirtualFileSystem, ulong.Parse(viewModel.SelectedApplication.TitleId, NumberStyles.HexNumber), viewModel.SelectedApplication.TitleName); + await DownloadableContentManagerWindow.Show(viewModel.VirtualFileSystem, viewModel.SelectedApplication); } } @@ -120,8 +108,8 @@ namespace Ryujinx.Ava.UI.Controls { await new CheatWindow( viewModel.VirtualFileSystem, - viewModel.SelectedApplication.TitleId, - viewModel.SelectedApplication.TitleName, + viewModel.SelectedApplication.IdString, + viewModel.SelectedApplication.Name, viewModel.SelectedApplication.Path).ShowDialog(viewModel.TopLevel as Window); } } @@ -133,7 +121,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { string modsBasePath = ModLoader.GetModsBasePath(); - string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, viewModel.SelectedApplication.TitleId); + string titleModsPath = ModLoader.GetApplicationDir(modsBasePath, viewModel.SelectedApplication.IdString); OpenHelper.OpenFolder(titleModsPath); } @@ -146,7 +134,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { string sdModsBasePath = ModLoader.GetSdModsBasePath(); - string titleModsPath = ModLoader.GetApplicationDir(sdModsBasePath, viewModel.SelectedApplication.TitleId); + string titleModsPath = ModLoader.GetApplicationDir(sdModsBasePath, viewModel.SelectedApplication.IdString); OpenHelper.OpenFolder(titleModsPath); } @@ -158,7 +146,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - await ModManagerWindow.Show(ulong.Parse(viewModel.SelectedApplication.TitleId, NumberStyles.HexNumber), viewModel.SelectedApplication.TitleName); + await ModManagerWindow.Show(viewModel.SelectedApplication.Id, viewModel.SelectedApplication.Name); } } @@ -170,15 +158,15 @@ namespace Ryujinx.Ava.UI.Controls { UserResult result = await ContentDialogHelper.CreateConfirmationDialog( LocaleManager.Instance[LocaleKeys.DialogWarning], - LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionMessage, viewModel.SelectedApplication.TitleName), + LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionMessage, viewModel.SelectedApplication.Name), LocaleManager.Instance[LocaleKeys.InputDialogYes], LocaleManager.Instance[LocaleKeys.InputDialogNo], LocaleManager.Instance[LocaleKeys.RyujinxConfirm]); if (result == UserResult.Yes) { - DirectoryInfo mainDir = new(Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.TitleId, "cache", "cpu", "0")); - DirectoryInfo backupDir = new(Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.TitleId, "cache", "cpu", "1")); + DirectoryInfo mainDir = new(Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.IdString, "cache", "cpu", "0")); + DirectoryInfo backupDir = new(Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.IdString, "cache", "cpu", "1")); List cacheFiles = new(); @@ -218,14 +206,14 @@ namespace Ryujinx.Ava.UI.Controls { UserResult result = await ContentDialogHelper.CreateConfirmationDialog( LocaleManager.Instance[LocaleKeys.DialogWarning], - LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogShaderDeletionMessage, viewModel.SelectedApplication.TitleName), + LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogShaderDeletionMessage, viewModel.SelectedApplication.Name), LocaleManager.Instance[LocaleKeys.InputDialogYes], LocaleManager.Instance[LocaleKeys.InputDialogNo], LocaleManager.Instance[LocaleKeys.RyujinxConfirm]); if (result == UserResult.Yes) { - DirectoryInfo shaderCacheDir = new(Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.TitleId, "cache", "shader")); + DirectoryInfo shaderCacheDir = new(Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.IdString, "cache", "shader")); List oldCacheDirectories = new(); List newCacheFiles = new(); @@ -273,7 +261,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - string ptcDir = Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.TitleId, "cache", "cpu"); + string ptcDir = Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.IdString, "cache", "cpu"); string mainDir = Path.Combine(ptcDir, "0"); string backupDir = Path.Combine(ptcDir, "1"); @@ -294,7 +282,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - string shaderCacheDir = Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.TitleId, "cache", "shader"); + string shaderCacheDir = Path.Combine(AppDataManager.GamesDirPath, viewModel.SelectedApplication.IdString, "cache", "shader"); if (!Directory.Exists(shaderCacheDir)) { @@ -315,7 +303,7 @@ namespace Ryujinx.Ava.UI.Controls viewModel.StorageProvider, NcaSectionType.Code, viewModel.SelectedApplication.Path, - viewModel.SelectedApplication.TitleName); + viewModel.SelectedApplication.Name); } } @@ -329,7 +317,7 @@ namespace Ryujinx.Ava.UI.Controls viewModel.StorageProvider, NcaSectionType.Data, viewModel.SelectedApplication.Path, - viewModel.SelectedApplication.TitleName); + viewModel.SelectedApplication.Name); } } @@ -343,7 +331,7 @@ namespace Ryujinx.Ava.UI.Controls viewModel.StorageProvider, NcaSectionType.Logo, viewModel.SelectedApplication.Path, - viewModel.SelectedApplication.TitleName); + viewModel.SelectedApplication.Name); } } @@ -354,7 +342,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { ApplicationData selectedApplication = viewModel.SelectedApplication; - ShortcutHelper.CreateAppShortcut(selectedApplication.Path, selectedApplication.TitleName, selectedApplication.TitleId, selectedApplication.Icon); + ShortcutHelper.CreateAppShortcut(selectedApplication.Path, selectedApplication.Name, selectedApplication.IdString, selectedApplication.Icon); } } @@ -364,7 +352,7 @@ namespace Ryujinx.Ava.UI.Controls if (viewModel?.SelectedApplication != null) { - await viewModel.LoadApplication(viewModel.SelectedApplication.Path); + await viewModel.LoadApplication(viewModel.SelectedApplication); } } } diff --git a/src/Ryujinx/UI/Controls/ApplicationGridView.axaml b/src/Ryujinx/UI/Controls/ApplicationGridView.axaml index 2dc95662..98a1c004 100644 --- a/src/Ryujinx/UI/Controls/ApplicationGridView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationGridView.axaml @@ -80,7 +80,7 @@ diff --git a/src/Ryujinx/UI/Controls/ApplicationListView.axaml b/src/Ryujinx/UI/Controls/ApplicationListView.axaml index fecf0888..f99cf316 100644 --- a/src/Ryujinx/UI/Controls/ApplicationListView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationListView.axaml @@ -85,7 +85,7 @@ Path.GetFileName(ContainerPath); + public string Label => + Path.GetExtension(FileName)?.ToLower() == ".xci" ? $"{LocaleManager.Instance[LocaleKeys.TitleBundledDlcLabel]} {FileName}" : FileName; + public DownloadableContentModel(string titleId, string containerPath, string fullPath, bool enabled) { TitleId = titleId; diff --git a/src/Ryujinx/UI/Models/SaveModel.cs b/src/Ryujinx/UI/Models/SaveModel.cs index d6dea2f6..181295b0 100644 --- a/src/Ryujinx/UI/Models/SaveModel.cs +++ b/src/Ryujinx/UI/Models/SaveModel.cs @@ -46,14 +46,14 @@ namespace Ryujinx.Ava.UI.Models TitleId = info.ProgramId; UserId = info.UserId; - var appData = MainWindow.MainWindowViewModel.Applications.FirstOrDefault(x => x.TitleId.ToUpper() == TitleIdString); + var appData = MainWindow.MainWindowViewModel.Applications.FirstOrDefault(x => x.IdString.ToUpper() == TitleIdString); InGameList = appData != null; if (InGameList) { Icon = appData.Icon; - Title = appData.TitleName; + Title = appData.Name; } else { diff --git a/src/Ryujinx/UI/Models/TitleUpdateModel.cs b/src/Ryujinx/UI/Models/TitleUpdateModel.cs index c270c9ed..cde37bf9 100644 --- a/src/Ryujinx/UI/Models/TitleUpdateModel.cs +++ b/src/Ryujinx/UI/Models/TitleUpdateModel.cs @@ -8,7 +8,10 @@ namespace Ryujinx.Ava.UI.Models public ApplicationControlProperty Control { get; } public string Path { get; } - public string Label => LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleUpdateVersionLabel, Control.DisplayVersionString.ToString()); + public string Label => LocaleManager.Instance.UpdateAndGetDynamicValue( + System.IO.Path.GetExtension(Path)?.ToLower() == ".xci" ? LocaleKeys.TitleBundledUpdateVersionLabel : LocaleKeys.TitleUpdateVersionLabel, + Control.DisplayVersionString.ToString() + ); public TitleUpdateModel(ApplicationControlProperty control, string path) { diff --git a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs index 2cd714f4..0f500513 100644 --- a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs @@ -6,7 +6,6 @@ using DynamicData; using LibHac.Common; using LibHac.Fs; using LibHac.Fs.Fsa; -using LibHac.FsSystem; using LibHac.Tools.Fs; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; @@ -17,11 +16,13 @@ using Ryujinx.Common.Configuration; using Ryujinx.Common.Logging; using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; +using Ryujinx.UI.App.Common; using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Threading.Tasks; using Application = Avalonia.Application; using Path = System.IO.Path; @@ -38,7 +39,7 @@ namespace Ryujinx.Ava.UI.ViewModels private AvaloniaList _selectedDownloadableContents = new(); private string _search; - private readonly ulong _titleId; + private readonly ApplicationData _applicationData; private readonly IStorageProvider _storageProvider; private static readonly DownloadableContentJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); @@ -91,18 +92,25 @@ namespace Ryujinx.Ava.UI.ViewModels get => string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowHeading], DownloadableContents.Count); } - public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ulong titleId) + public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ApplicationData applicationData) { _virtualFileSystem = virtualFileSystem; - _titleId = titleId; + _applicationData = applicationData; if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { _storageProvider = desktop.MainWindow.StorageProvider; } - _downloadableContentJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "dlc.json"); + _downloadableContentJsonPath = Path.Combine(AppDataManager.GamesDirPath, applicationData.IdString, "dlc.json"); + + if (!File.Exists(_downloadableContentJsonPath)) + { + _downloadableContentContainerList = new List(); + + Save(); + } try { @@ -123,12 +131,7 @@ namespace Ryujinx.Ava.UI.ViewModels { if (File.Exists(downloadableContentContainer.ContainerPath)) { - using FileStream containerFile = File.OpenRead(downloadableContentContainer.ContainerPath); - - PartitionFileSystem partitionFileSystem = new(); - partitionFileSystem.Initialize(containerFile.AsStorage()).ThrowIfFailure(); - - _virtualFileSystem.ImportTickets(partitionFileSystem); + using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(downloadableContentContainer.ContainerPath, _virtualFileSystem); foreach (DownloadableContentNca downloadableContentNca in downloadableContentContainer.DownloadableContentNcaList) { @@ -157,6 +160,9 @@ namespace Ryujinx.Ava.UI.ViewModels } } + // NOTE: Try to load downloadable contents from PFS last to preserve enabled state. + AddDownloadableContent(_applicationData.Path); + // NOTE: Save the list again to remove leftovers. Save(); Sort(); @@ -219,25 +225,23 @@ namespace Ryujinx.Ava.UI.ViewModels foreach (var file in result) { - await AddDownloadableContent(file.Path.LocalPath); + if (!AddDownloadableContent(file.Path.LocalPath)) + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogDlcNoDlcErrorMessage]); + } } } - private async Task AddDownloadableContent(string path) + private bool AddDownloadableContent(string path) { - if (!File.Exists(path) || DownloadableContents.FirstOrDefault(x => x.ContainerPath == path) != null) + if (!File.Exists(path) || _downloadableContentContainerList.Any(x => x.ContainerPath == path)) { - return; + return true; } - using FileStream containerFile = File.OpenRead(path); - - PartitionFileSystem partitionFileSystem = new(); - partitionFileSystem.Initialize(containerFile.AsStorage()).ThrowIfFailure(); - bool containsDownloadableContent = false; - - _virtualFileSystem.ImportTickets(partitionFileSystem); + using IFileSystem partitionFileSystem = PartitionFileSystemUtils.OpenApplicationFileSystem(path, _virtualFileSystem); + bool success = false; foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca")) { using var ncaFile = new UniqueRef(); @@ -252,26 +256,26 @@ namespace Ryujinx.Ava.UI.ViewModels if (nca.Header.ContentType == NcaContentType.PublicData) { - if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000) != _titleId) + if (nca.GetProgramIdBase() != _applicationData.IdBase) { - break; + continue; } var content = new DownloadableContentModel(nca.Header.TitleId.ToString("X16"), path, fileEntry.FullPath, true); DownloadableContents.Add(content); SelectedDownloadableContents.Add(content); - OnPropertyChanged(nameof(UpdateCount)); - Sort(); - - containsDownloadableContent = true; + success = true; } } - if (!containsDownloadableContent) + if (success) { - await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogDlcNoDlcErrorMessage]); + OnPropertyChanged(nameof(UpdateCount)); + Sort(); } + + return success; } public void Remove(DownloadableContentModel model) diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index b47cc4b7..134e9030 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -96,7 +96,7 @@ namespace Ryujinx.Ava.UI.ViewModels private bool _canUpdate = true; private Cursor _cursor; private string _title; - private string _currentEmulatedGamePath; + private ApplicationData _currentApplicationData; private readonly AutoResetEvent _rendererWaitEvent; private WindowState _windowState; private double _windowWidth; @@ -108,7 +108,6 @@ namespace Ryujinx.Ava.UI.ViewModels public ApplicationData ListSelectedApplication; public ApplicationData GridSelectedApplication; - private string TitleName { get; set; } internal AppHost AppHost { get; set; } public MainWindowViewModel() @@ -954,8 +953,8 @@ namespace Ryujinx.Ava.UI.ViewModels return SortMode switch { #pragma warning disable IDE0055 // Disable formatting - ApplicationSort.Title => IsAscending ? SortExpressionComparer.Ascending(app => app.TitleName) - : SortExpressionComparer.Descending(app => app.TitleName), + ApplicationSort.Title => IsAscending ? SortExpressionComparer.Ascending(app => app.Name) + : SortExpressionComparer.Descending(app => app.Name), ApplicationSort.Developer => IsAscending ? SortExpressionComparer.Ascending(app => app.Developer) : SortExpressionComparer.Descending(app => app.Developer), ApplicationSort.LastPlayed => new LastPlayedSortComparer(IsAscending), @@ -999,7 +998,7 @@ namespace Ryujinx.Ava.UI.ViewModels CompareInfo compareInfo = CultureInfo.CurrentCulture.CompareInfo; - return compareInfo.IndexOf(app.TitleName, _searchText, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) >= 0; + return compareInfo.IndexOf(app.Name, _searchText, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) >= 0; } return false; @@ -1128,7 +1127,7 @@ namespace Ryujinx.Ava.UI.ViewModels IsLoadingIndeterminate = false; break; case LoadState.Loaded: - LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, TitleName); + LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, _currentApplicationData.Name); IsLoadingIndeterminate = true; CacheLoadStatus = ""; break; @@ -1148,7 +1147,7 @@ namespace Ryujinx.Ava.UI.ViewModels IsLoadingIndeterminate = false; break; case ShaderCacheLoadingState.Loaded: - LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, TitleName); + LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, _currentApplicationData.Name); IsLoadingIndeterminate = true; CacheLoadStatus = ""; break; @@ -1200,13 +1199,13 @@ namespace Ryujinx.Ava.UI.ViewModels { UserChannelPersistence.ShouldRestart = false; - await LoadApplication(_currentEmulatedGamePath); + await LoadApplication(_currentApplicationData); } else { // Otherwise, clear state. UserChannelPersistence = new UserChannelPersistence(); - _currentEmulatedGamePath = null; + _currentApplicationData = null; } } @@ -1493,7 +1492,15 @@ namespace Ryujinx.Ava.UI.ViewModels if (result.Count > 0) { - await LoadApplication(result[0].Path.LocalPath); + if (ApplicationLibrary.TryGetApplicationsFromFile(result[0].Path.LocalPath, + out List applications)) + { + await LoadApplication(applications[0]); + } + else + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.MenuBarFileOpenFromFileError]); + } } } @@ -1507,11 +1514,17 @@ namespace Ryujinx.Ava.UI.ViewModels if (result.Count > 0) { - await LoadApplication(result[0].Path.LocalPath); + ApplicationData applicationData = new() + { + Name = Path.GetFileNameWithoutExtension(result[0].Path.LocalPath), + Path = result[0].Path.LocalPath, + }; + + await LoadApplication(applicationData); } } - public async Task LoadApplication(string path, bool startFullscreen = false, string titleName = "") + public async Task LoadApplication(ApplicationData application, bool startFullscreen = false) { if (AppHost != null) { @@ -1531,7 +1544,7 @@ namespace Ryujinx.Ava.UI.ViewModels Logger.RestartTime(); - SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(path, ConfigurationState.Instance.System.Language); + SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(application.Path, ConfigurationState.Instance.System.Language, application.Id); PrepareLoadScreen(); @@ -1540,7 +1553,8 @@ namespace Ryujinx.Ava.UI.ViewModels AppHost = new AppHost( RendererHostControl, InputManager, - path, + application.Path, + application.Id, VirtualFileSystem, ContentManager, AccountManager, @@ -1558,17 +1572,17 @@ namespace Ryujinx.Ava.UI.ViewModels CanUpdate = false; - LoadHeading = TitleName = titleName; + LoadHeading = application.Name; - if (string.IsNullOrWhiteSpace(titleName)) + if (string.IsNullOrWhiteSpace(application.Name)) { LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, AppHost.Device.Processes.ActiveApplication.Name); - TitleName = AppHost.Device.Processes.ActiveApplication.Name; + application.Name = AppHost.Device.Processes.ActiveApplication.Name; } SwitchToRenderer(startFullscreen); - _currentEmulatedGamePath = path; + _currentApplicationData = application; Thread gameThread = new(InitializeGame) { Name = "GUI.WindowThread" }; gameThread.Start(); diff --git a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs index 5989ce09..6c38edb3 100644 --- a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs @@ -1,4 +1,3 @@ -using Avalonia; using Avalonia.Collections; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform.Storage; @@ -6,7 +5,7 @@ using Avalonia.Threading; using LibHac.Common; using LibHac.Fs; using LibHac.Fs.Fsa; -using LibHac.FsSystem; +using LibHac.Ncm; using LibHac.Ns; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; @@ -17,12 +16,17 @@ using Ryujinx.Common.Configuration; using Ryujinx.Common.Logging; using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.Loaders.Processes.Extensions; +using Ryujinx.HLE.Utilities; using Ryujinx.UI.App.Common; +using Ryujinx.UI.Common.Configuration; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using Application = Avalonia.Application; +using ContentType = LibHac.Ncm.ContentType; using Path = System.IO.Path; using SpanHelpers = LibHac.Common.SpanHelpers; @@ -33,7 +37,7 @@ namespace Ryujinx.Ava.UI.ViewModels public TitleUpdateMetadata TitleUpdateWindowData; public readonly string TitleUpdateJsonPath; private VirtualFileSystem VirtualFileSystem { get; } - private ulong TitleId { get; } + private ApplicationData ApplicationData { get; } private AvaloniaList _titleUpdates = new(); private AvaloniaList _views = new(); @@ -73,18 +77,18 @@ namespace Ryujinx.Ava.UI.ViewModels public IStorageProvider StorageProvider; - public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ulong titleId) + public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ApplicationData applicationData) { VirtualFileSystem = virtualFileSystem; - TitleId = titleId; + ApplicationData = applicationData; if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { StorageProvider = desktop.MainWindow.StorageProvider; } - TitleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "updates.json"); + TitleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, ApplicationData.IdString, "updates.json"); try { @@ -92,7 +96,7 @@ namespace Ryujinx.Ava.UI.ViewModels } catch { - Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {TitleId} at {TitleUpdateJsonPath}"); + Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {ApplicationData.IdString} at {TitleUpdateJsonPath}"); TitleUpdateWindowData = new TitleUpdateMetadata { @@ -108,6 +112,9 @@ namespace Ryujinx.Ava.UI.ViewModels private void LoadUpdates() { + // Try to load updates from PFS first + AddUpdate(ApplicationData.Path, true); + foreach (string path in TitleUpdateWindowData.Paths) { AddUpdate(path); @@ -162,38 +169,54 @@ namespace Ryujinx.Ava.UI.ViewModels } } - private void AddUpdate(string path) + private void AddUpdate(string path, bool ignoreNotFound = false) { - if (File.Exists(path) && TitleUpdates.All(x => x.Path != path)) + if (!File.Exists(path) || TitleUpdates.Any(x => x.Path == path)) { - using FileStream file = new(path, FileMode.Open, FileAccess.Read); + return; + } - try + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + try + { + using IFileSystem pfs = PartitionFileSystemUtils.OpenApplicationFileSystem(path, VirtualFileSystem); + + Dictionary updates = pfs.GetContentData(ContentMetaType.Patch, VirtualFileSystem, checkLevel); + + Nca patchNca = null; + Nca controlNca = null; + + if (updates.TryGetValue(ApplicationData.Id, out ContentMetaData content)) { - var pfs = new PartitionFileSystem(); - pfs.Initialize(file.AsStorage()).ThrowIfFailure(); - (Nca patchNca, Nca controlNca) = ApplicationLibrary.GetGameUpdateDataFromPartition(VirtualFileSystem, pfs, TitleId.ToString("x16"), 0); + patchNca = content.GetNcaByType(VirtualFileSystem.KeySet, ContentType.Program); + controlNca = content.GetNcaByType(VirtualFileSystem.KeySet, ContentType.Control); + } - if (controlNca != null && patchNca != null) - { - ApplicationControlProperty controlData = new(); + if (controlNca != null && patchNca != null) + { + ApplicationControlProperty controlData = new(); - using UniqueRef nacpFile = new(); + using UniqueRef nacpFile = new(); - controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); - nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure(); + controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure(); + nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure(); - TitleUpdates.Add(new TitleUpdateModel(controlData, path)); - } - else + TitleUpdates.Add(new TitleUpdateModel(controlData, path)); + } + else + { + if (!ignoreNotFound) { Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage])); } } - catch (Exception ex) - { - Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadFileErrorMessage, ex.Message, path))); - } + } + catch (Exception ex) + { + Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadFileErrorMessage, ex.Message, path))); } } diff --git a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs index 522ac19b..73ae0df1 100644 --- a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs +++ b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs @@ -11,6 +11,7 @@ using Ryujinx.Ava.UI.Windows; using Ryujinx.Common; using Ryujinx.Common.Utilities; using Ryujinx.Modules; +using Ryujinx.UI.App.Common; using Ryujinx.UI.Common; using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Common.Helper; @@ -134,7 +135,14 @@ namespace Ryujinx.Ava.UI.Views.Main if (!string.IsNullOrEmpty(contentPath)) { - await ViewModel.LoadApplication(contentPath, false, "Mii Applet"); + ApplicationData applicationData = new() + { + Name = "miiEdit", + Id = 0x0100000000001009, + Path = contentPath, + }; + + await ViewModel.LoadApplication(applicationData, ViewModel.IsFullScreen || ViewModel.StartGamesInFullscreen); } } diff --git a/src/Ryujinx/UI/Windows/CheatWindow.axaml.cs b/src/Ryujinx/UI/Windows/CheatWindow.axaml.cs index d78e48a4..8f4c3ceb 100644 --- a/src/Ryujinx/UI/Windows/CheatWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/CheatWindow.axaml.cs @@ -1,9 +1,11 @@ using Avalonia.Collections; +using LibHac.Tools.FsSystem; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.UI.Models; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; using Ryujinx.UI.App.Common; +using Ryujinx.UI.Common.Configuration; using System; using System.Collections.Generic; using System.Globalization; @@ -34,9 +36,12 @@ namespace Ryujinx.Ava.UI.Windows public CheatWindow(VirtualFileSystem virtualFileSystem, string titleId, string titleName, string titlePath) { LoadedCheats = new AvaloniaList(); + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; Heading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.CheatWindowHeading, titleName, titleId.ToUpper()); - BuildId = ApplicationData.GetApplicationBuildId(virtualFileSystem, titlePath); + BuildId = ApplicationData.GetBuildId(virtualFileSystem, checkLevel, titlePath); InitializeComponent(); diff --git a/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml b/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml index 99cf28e7..98aac09c 100644 --- a/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml +++ b/src/Ryujinx/UI/Windows/DownloadableContentManagerWindow.axaml @@ -97,7 +97,7 @@ MaxLines="2" TextWrapping="Wrap" TextTrimming="CharacterEllipsis" - Text="{Binding FileName}" /> + Text="{Binding Label}" /> x.OfType().Name("DialogSpace").Child().OfType()); diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs index 7de8a49a..dc5336ab 100644 --- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Interactivity; using Avalonia.Platform; using Avalonia.Threading; using FluentAvalonia.UI.Controls; +using LibHac.Tools.FsSystem; using Ryujinx.Ava.Common; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Input; @@ -24,7 +25,7 @@ using Ryujinx.UI.Common; using Ryujinx.UI.Common.Configuration; using Ryujinx.UI.Common.Helper; using System; -using System.IO; +using System.Collections.Generic; using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; @@ -40,6 +41,7 @@ namespace Ryujinx.Ava.UI.Windows private UserChannelPersistence _userChannelPersistence; private static bool _deferLoad; private static string _launchPath; + private static string _launchApplicationId; private static bool _startFullscreen; internal readonly AvaHostUIHandler UiHandler; @@ -168,18 +170,17 @@ namespace Ryujinx.Ava.UI.Windows { ViewModel.SelectedIcon = args.Application.Icon; - string path = new FileInfo(args.Application.Path).FullName; - - ViewModel.LoadApplication(path).Wait(); + ViewModel.LoadApplication(args.Application).Wait(); } args.Handled = true; } - internal static void DeferLoadApplication(string launchPathArg, bool startFullscreenArg) + internal static void DeferLoadApplication(string launchPathArg, string launchApplicationId, bool startFullscreenArg) { _deferLoad = true; _launchPath = launchPathArg; + _launchApplicationId = launchApplicationId; _startFullscreen = startFullscreenArg; } @@ -219,7 +220,11 @@ namespace Ryujinx.Ava.UI.Windows LibHacHorizonManager.InitializeBcatServer(); LibHacHorizonManager.InitializeSystemClients(); - ApplicationLibrary = new ApplicationLibrary(VirtualFileSystem); + IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks + ? IntegrityCheckLevel.ErrorOnInvalid + : IntegrityCheckLevel.None; + + ApplicationLibrary = new ApplicationLibrary(VirtualFileSystem, checkLevel); // Save data created before we supported extra data in directory save data will not work properly if // given empty extra data. Luckily some of that extra data can be created using the data from the @@ -314,7 +319,35 @@ namespace Ryujinx.Ava.UI.Windows { _deferLoad = false; - await ViewModel.LoadApplication(_launchPath, _startFullscreen); + if (ApplicationLibrary.TryGetApplicationsFromFile(_launchPath, out List applications)) + { + ApplicationData applicationData; + + if (_launchApplicationId != null) + { + applicationData = applications.Find(application => application.IdString == _launchApplicationId); + + if (applicationData != null) + { + await ViewModel.LoadApplication(applicationData, _startFullscreen); + } + else + { + Logger.Error?.Print(LogClass.Application, $"Couldn't find requested application id '{_launchApplicationId}' in '{_launchPath}'."); + await Dispatcher.UIThread.InvokeAsync(async () => await UserErrorDialog.ShowUserErrorDialog(UserError.ApplicationNotFound)); + } + } + else + { + applicationData = applications[0]; + await ViewModel.LoadApplication(applicationData, _startFullscreen); + } + } + else + { + Logger.Error?.Print(LogClass.Application, $"Couldn't find any application in '{_launchPath}'."); + await Dispatcher.UIThread.InvokeAsync(async () => await UserErrorDialog.ShowUserErrorDialog(UserError.ApplicationNotFound)); + } } } else diff --git a/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml.cs b/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml.cs index f5e25032..8de5cb14 100644 --- a/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/TitleUpdateWindow.axaml.cs @@ -5,19 +5,18 @@ 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.HLE.FileSystem; +using Ryujinx.UI.App.Common; using Ryujinx.UI.Common.Helper; using System.Threading.Tasks; -using Button = Avalonia.Controls.Button; namespace Ryujinx.Ava.UI.Windows { public partial class TitleUpdateWindow : UserControl { - public TitleUpdateViewModel ViewModel; + public readonly TitleUpdateViewModel ViewModel; public TitleUpdateWindow() { @@ -26,22 +25,22 @@ namespace Ryujinx.Ava.UI.Windows InitializeComponent(); } - public TitleUpdateWindow(VirtualFileSystem virtualFileSystem, ulong titleId) + public TitleUpdateWindow(VirtualFileSystem virtualFileSystem, ApplicationData applicationData) { - DataContext = ViewModel = new TitleUpdateViewModel(virtualFileSystem, titleId); + DataContext = ViewModel = new TitleUpdateViewModel(virtualFileSystem, applicationData); InitializeComponent(); } - public static async Task Show(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName) + public static async Task Show(VirtualFileSystem virtualFileSystem, ApplicationData applicationData) { ContentDialog contentDialog = new() { PrimaryButtonText = "", SecondaryButtonText = "", CloseButtonText = "", - Content = new TitleUpdateWindow(virtualFileSystem, titleId), - Title = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.GameUpdateWindowHeading, titleName, titleId.ToString("X16")), + Content = new TitleUpdateWindow(virtualFileSystem, applicationData), + Title = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.GameUpdateWindowHeading, applicationData.Name, applicationData.IdString), }; Style bottomBorder = new(x => x.OfType().Name("DialogSpace").Child().OfType());