Add support for multi game XCIs (#5638)

* 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

* Fix formatting issues

* Apply suggestions from code review

Co-authored-by: gdkchan <gab.dark.100@gmail.com>

* 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

---------

Co-authored-by: gdkchan <gab.dark.100@gmail.com>
This commit is contained in:
TSRBerry
2023-11-11 21:56:57 +01:00
committed by GitHub
parent 55557525b1
commit 5c3cfb84c0
33 changed files with 1168 additions and 816 deletions

View File

@@ -17,11 +17,12 @@ using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities;
using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.Loaders.Processes.Extensions;
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<DownloadableContentModel> _selectedDownloadableContents = new();
private string _search;
private readonly ulong _titleId;
private readonly ApplicationData _applicationData;
private static readonly DownloadableContentJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
@@ -92,18 +93,25 @@ namespace Ryujinx.Ava.UI.ViewModels
public IStorageProvider StorageProvider;
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<DownloadableContentContainer>();
Save();
}
try
{
@@ -120,6 +128,9 @@ namespace Ryujinx.Ava.UI.ViewModels
private void LoadDownloadableContents()
{
// NOTE: Try to load downloadable contents from PFS first.
AddDownloadableContent(_applicationData.Path);
foreach (DownloadableContentContainer downloadableContentContainer in _downloadableContentContainerList)
{
if (File.Exists(downloadableContentContainer.ContainerPath))
@@ -127,7 +138,11 @@ namespace Ryujinx.Ava.UI.ViewModels
using FileStream containerFile = File.OpenRead(downloadableContentContainer.ContainerPath);
PartitionFileSystem partitionFileSystem = new();
partitionFileSystem.Initialize(containerFile.AsStorage()).ThrowIfFailure();
if (partitionFileSystem.Initialize(containerFile.AsStorage()).IsFailure())
{
continue;
}
_virtualFileSystem.ImportTickets(partitionFileSystem);
@@ -220,22 +235,34 @@ 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)
{
return;
return true;
}
using FileStream containerFile = File.OpenRead(path);
PartitionFileSystem partitionFileSystem = new();
partitionFileSystem.Initialize(containerFile.AsStorage()).ThrowIfFailure();
bool containsDownloadableContent = false;
IFileSystem partitionFileSystem;
if (Path.GetExtension(path).ToLower() == ".xci")
{
partitionFileSystem = new Xci(_virtualFileSystem.KeySet, containerFile.AsStorage()).OpenPartition(XciPartitionType.Secure);
}
else
{
var pfsTemp = new PartitionFileSystem();
pfsTemp.Initialize(containerFile.AsStorage()).ThrowIfFailure();
partitionFileSystem = pfsTemp;
}
_virtualFileSystem.ImportTickets(partitionFileSystem);
@@ -253,7 +280,7 @@ namespace Ryujinx.Ava.UI.ViewModels
if (nca.Header.ContentType == NcaContentType.PublicData)
{
if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000) != _titleId)
if (nca.GetProgramIdBase() != _applicationData.IdBase)
{
break;
}
@@ -265,14 +292,11 @@ namespace Ryujinx.Ava.UI.ViewModels
OnPropertyChanged(nameof(UpdateCount));
Sort();
containsDownloadableContent = true;
return true;
}
}
if (!containsDownloadableContent)
{
await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogDlcNoDlcErrorMessage]);
}
return false;
}
public void Remove(DownloadableContentModel model)

View File

@@ -95,7 +95,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;
@@ -106,7 +106,6 @@ namespace Ryujinx.Ava.UI.ViewModels
public ApplicationData ListSelectedApplication;
public ApplicationData GridSelectedApplication;
private string TitleName { get; set; }
internal AppHost AppHost { get; set; }
public MainWindowViewModel()
@@ -930,8 +929,8 @@ namespace Ryujinx.Ava.UI.ViewModels
return SortMode switch
{
#pragma warning disable IDE0055 // Disable formatting
ApplicationSort.Title => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.TitleName)
: SortExpressionComparer<ApplicationData>.Descending(app => app.TitleName),
ApplicationSort.Title => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Name)
: SortExpressionComparer<ApplicationData>.Descending(app => app.Name),
ApplicationSort.Developer => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Developer)
: SortExpressionComparer<ApplicationData>.Descending(app => app.Developer),
ApplicationSort.LastPlayed => new LastPlayedSortComparer(IsAscending),
@@ -968,7 +967,7 @@ namespace Ryujinx.Ava.UI.ViewModels
{
if (arg is ApplicationData app)
{
return string.IsNullOrWhiteSpace(_searchText) || app.TitleName.ToLower().Contains(_searchText.ToLower());
return string.IsNullOrWhiteSpace(_searchText) || app.Name.ToLower().Contains(_searchText.ToLower());
}
return false;
@@ -1097,7 +1096,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;
@@ -1117,7 +1116,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;
@@ -1168,13 +1167,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;
}
}
@@ -1451,7 +1450,12 @@ namespace Ryujinx.Ava.UI.ViewModels
if (result.Count > 0)
{
await LoadApplication(result[0].Path.LocalPath);
ApplicationData applicationData = new()
{
Path = result[0].Path.LocalPath,
};
await LoadApplication(applicationData);
}
}
@@ -1465,11 +1469,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)
{
@@ -1489,7 +1499,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();
@@ -1498,7 +1508,8 @@ namespace Ryujinx.Ava.UI.ViewModels
AppHost = new AppHost(
RendererHostControl,
InputManager,
path,
application.Path,
application.Id,
VirtualFileSystem,
ContentManager,
AccountManager,
@@ -1516,17 +1527,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();

View File

@@ -1,4 +1,3 @@
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage;
@@ -8,6 +7,7 @@ using LibHac.Fs;
using LibHac.Fs.Fsa;
using LibHac.FsSystem;
using LibHac.Ns;
using LibHac.Tools.Fs;
using LibHac.Tools.FsSystem;
using LibHac.Tools.FsSystem.NcaUtils;
using Ryujinx.Ava.Common.Locale;
@@ -17,12 +17,16 @@ using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities;
using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.Loaders.Processes.Extensions;
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<TitleUpdateModel> _titleUpdates = new();
private AvaloniaList<object> _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,17 +169,41 @@ 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))
{
IntegrityCheckLevel checkLevel = ConfigurationState.Instance.System.EnableFsIntegrityChecks
? IntegrityCheckLevel.ErrorOnInvalid
: IntegrityCheckLevel.None;
using FileStream file = new(path, FileMode.Open, FileAccess.Read);
IFileSystem pfs;
try
{
var pfs = new PartitionFileSystem();
pfs.Initialize(file.AsStorage()).ThrowIfFailure();
(Nca patchNca, Nca controlNca) = ApplicationLibrary.GetGameUpdateDataFromPartition(VirtualFileSystem, pfs, TitleId.ToString("x16"), 0);
if (Path.GetExtension(path).ToLower() == ".xci")
{
pfs = new Xci(VirtualFileSystem.KeySet, file.AsStorage()).OpenPartition(XciPartitionType.Secure);
}
else
{
var pfsTemp = new PartitionFileSystem();
pfsTemp.Initialize(file.AsStorage()).ThrowIfFailure();
pfs = pfsTemp;
}
Dictionary<ulong, ContentCollection> updates = pfs.GetUpdateData(VirtualFileSystem, checkLevel);
Nca patchNca = null;
Nca controlNca = null;
if (updates.TryGetValue(ApplicationData.Id, out ContentCollection content))
{
patchNca = content.GetNcaByType(VirtualFileSystem.KeySet, ContentType.Program);
controlNca = content.GetNcaByType(VirtualFileSystem.KeySet, ContentType.Control);
}
if (controlNca != null && patchNca != null)
{
@@ -187,7 +218,10 @@ namespace Ryujinx.Ava.UI.ViewModels
}
else
{
Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]));
if (!ignoreNotFound)
{
Dispatcher.UIThread.InvokeAsync(() => ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]));
}
}
}
catch (Exception ex)