From 2ab806f7591fa8c5db9a1a5494e1a229175d100d Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 01:42:04 -0600 Subject: [PATCH 01/49] UI: [ci skip] Fix ContentDialog symbols being backwards for right-to-left languages --- src/Ryujinx/UI/Helpers/ContentDialogHelper.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs b/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs index b5d085ba1..ae83f9e98 100644 --- a/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs +++ b/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs @@ -159,6 +159,7 @@ namespace Ryujinx.Ava.UI.Helpers Symbol = (Symbol)symbol, Margin = new Thickness(10), FontSize = 40, + FlowDirection = FlowDirection.LeftToRight, VerticalAlignment = VerticalAlignment.Center, }; From cc3b95eee1ea86e1adfcd52b87fd8c14371456b5 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 03:28:26 -0600 Subject: [PATCH 02/49] misc: chore: More descriptive error for trying to create a Metal EmbeddedWindow on non-ARM Mac --- src/Ryujinx/UI/Renderer/EmbeddedWindowMetal.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Ryujinx/UI/Renderer/EmbeddedWindowMetal.cs b/src/Ryujinx/UI/Renderer/EmbeddedWindowMetal.cs index eaf6f7bdf..9e92d9289 100644 --- a/src/Ryujinx/UI/Renderer/EmbeddedWindowMetal.cs +++ b/src/Ryujinx/UI/Renderer/EmbeddedWindowMetal.cs @@ -1,3 +1,4 @@ +using Ryujinx.Common.Helper; using SharpMetal.QuartzCore; using System; @@ -7,14 +8,12 @@ namespace Ryujinx.Ava.UI.Renderer { public CAMetalLayer CreateSurface() { - if (OperatingSystem.IsMacOS()) + if (OperatingSystem.IsMacOS() && RunningPlatform.IsArm) { return new CAMetalLayer(MetalLayer); } - else - { - throw new NotSupportedException(); - } + + throw new NotSupportedException($"Cannot create a {nameof(CAMetalLayer)} without being on ARM Mac."); } } } From a4a15a4c800d2c2200c45dbac5caa62035e975a3 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 03:28:49 -0600 Subject: [PATCH 03/49] misc: chore: simplify graphics backend selection logic in RendererHost constructor --- src/Ryujinx/UI/Renderer/RendererHost.cs | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Ryujinx/UI/Renderer/RendererHost.cs b/src/Ryujinx/UI/Renderer/RendererHost.cs index 7dfec8d62..f755b6d70 100644 --- a/src/Ryujinx/UI/Renderer/RendererHost.cs +++ b/src/Ryujinx/UI/Renderer/RendererHost.cs @@ -43,19 +43,19 @@ namespace Ryujinx.Ava.UI.Renderer public RendererHost(string titleId) { - switch (TitleIDs.SelectGraphicsBackend(titleId, ConfigurationState.Instance.Graphics.GraphicsBackend)) - { - case GraphicsBackend.OpenGl: - EmbeddedWindow = new EmbeddedWindowOpenGL(); - break; - case GraphicsBackend.Metal: - EmbeddedWindow = new EmbeddedWindowMetal(); - break; - case GraphicsBackend.Vulkan: - EmbeddedWindow = new EmbeddedWindowVulkan(); - break; - } - + Focusable = true; + FlowDirection = FlowDirection.LeftToRight; + + EmbeddedWindow = +#pragma warning disable CS8509 + TitleIDs.SelectGraphicsBackend(titleId, ConfigurationState.Instance.Graphics.GraphicsBackend) switch +#pragma warning restore CS8509 + { + GraphicsBackend.OpenGl => new EmbeddedWindowOpenGL(), + GraphicsBackend.Metal => new EmbeddedWindowMetal(), + GraphicsBackend.Vulkan => new EmbeddedWindowVulkan(), + }; + string backendText = EmbeddedWindow switch { EmbeddedWindowVulkan => "Vulkan", From 2c9a26c11c99d5a084c24098f1ee11b65489aea6 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 03:29:24 -0600 Subject: [PATCH 04/49] misc: chore: Regular Architecture bool properties in RunningPlatform without OS constraint --- src/Ryujinx.Common/Helpers/RunningPlatform.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Ryujinx.Common/Helpers/RunningPlatform.cs b/src/Ryujinx.Common/Helpers/RunningPlatform.cs index 61f5bd614..8d85c4a3c 100644 --- a/src/Ryujinx.Common/Helpers/RunningPlatform.cs +++ b/src/Ryujinx.Common/Helpers/RunningPlatform.cs @@ -10,14 +10,18 @@ namespace Ryujinx.Common.Helper public static bool IsMacOS => OperatingSystem.IsMacOS(); public static bool IsWindows => OperatingSystem.IsWindows(); public static bool IsLinux => OperatingSystem.IsLinux(); + + public static bool IsArm => RuntimeInformation.OSArchitecture is Architecture.Arm64; + + public static bool IsX64 => RuntimeInformation.OSArchitecture is Architecture.X64; - public static bool IsIntelMac => IsMacOS && RuntimeInformation.OSArchitecture is Architecture.X64; - public static bool IsArmMac => IsMacOS && RuntimeInformation.OSArchitecture is Architecture.Arm64; + public static bool IsIntelMac => IsMacOS && IsX64; + public static bool IsArmMac => IsMacOS && IsArm; - public static bool IsX64Windows => IsWindows && (RuntimeInformation.OSArchitecture is Architecture.X64); - public static bool IsArmWindows => IsWindows && (RuntimeInformation.OSArchitecture is Architecture.Arm64); + public static bool IsX64Windows => IsWindows && IsX64; + public static bool IsArmWindows => IsWindows && IsArm; - public static bool IsX64Linux => IsLinux && (RuntimeInformation.OSArchitecture is Architecture.X64); - public static bool IsArmLinux => IsLinux && (RuntimeInformation.OSArchitecture is Architecture.Arm64); + public static bool IsX64Linux => IsLinux && IsX64; + public static bool IsArmLinux => IsLinux && IsArmMac; } } From 9f94aa1c79fe2043d95b563d198ca1940581fca2 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 03:30:13 -0600 Subject: [PATCH 05/49] misc: chore: gpu: Lower default Syncpoint wait timeout from 1 second to 500ms --- .../Synchronization/SynchronizationManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs b/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs index b68a64a47..7165af6ad 100644 --- a/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs @@ -83,11 +83,11 @@ namespace Ryujinx.Graphics.Gpu.Synchronization // TODO: Remove this when GPU channel scheduling will be implemented. if (timeout == Timeout.InfiniteTimeSpan) { - timeout = TimeSpan.FromSeconds(1); + timeout = TimeSpan.FromMilliseconds(500); } using ManualResetEvent waitEvent = new(false); - SyncpointWaiterHandle info = _syncpoints[id].RegisterCallback(threshold, (x) => waitEvent.Set()); + SyncpointWaiterHandle info = _syncpoints[id].RegisterCallback(threshold, _ => waitEvent.Set()); if (info == null) { @@ -96,7 +96,7 @@ namespace Ryujinx.Graphics.Gpu.Synchronization bool signaled = waitEvent.WaitOne(timeout); - if (!signaled && info != null) + if (!signaled) { Logger.Error?.Print(LogClass.Gpu, $"Wait on syncpoint {id} for threshold {threshold} took more than {timeout.TotalMilliseconds}ms, resuming execution..."); From 38ef65aae017f0c7bd79d9f055fbcd8d7438124f Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 14:07:32 -0600 Subject: [PATCH 06/49] misc: chore: Move all GeneratedRegex methods into one static class with static instance accessors. --- src/Ryujinx.Common/Helpers/Patterns.cs | 118 ++++++++++++++++++ src/Ryujinx.Graphics.Vulkan/Vendor.cs | 8 +- src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs | 5 +- .../HOS/Applets/Error/ErrorApplet.cs | 6 +- .../CJKCharacterValidation.cs | 14 +-- .../NumericCharacterValidation.cs | 14 +-- .../Sockets/Sfdnsres/Proxy/DnsBlacklist.cs | 30 +---- .../Loaders/Executables/NsoExecutable.cs | 14 +-- .../UI/ViewModels/SettingsViewModel.cs | 6 +- 9 files changed, 139 insertions(+), 76 deletions(-) create mode 100644 src/Ryujinx.Common/Helpers/Patterns.cs diff --git a/src/Ryujinx.Common/Helpers/Patterns.cs b/src/Ryujinx.Common/Helpers/Patterns.cs new file mode 100644 index 000000000..84cc1353a --- /dev/null +++ b/src/Ryujinx.Common/Helpers/Patterns.cs @@ -0,0 +1,118 @@ +using System.Text.RegularExpressions; + +namespace Ryujinx.Common.Helper +{ + public static partial class Patterns + { + #region Accessors + + public static readonly Regex Numeric = NumericRegex(); + + public static readonly Regex AmdGcn = AmdGcnRegex(); + public static readonly Regex NvidiaConsumerClass = NvidiaConsumerClassRegex(); + + public static readonly Regex DomainLp1Ns = DomainLp1NsRegex(); + public static readonly Regex DomainLp1Lp1Npln = DomainLp1Lp1NplnRegex(); + public static readonly Regex DomainLp1Znc = DomainLp1ZncRegex(); + public static readonly Regex DomainSbApi = DomainSbApiRegex(); + public static readonly Regex DomainSbAccounts = DomainSbAccountsRegex(); + public static readonly Regex DomainAccounts = DomainAccountsRegex(); + + public static readonly Regex Module = ModuleRegex(); + public static readonly Regex FsSdk = FsSdkRegex(); + public static readonly Regex SdkMw = SdkMwRegex(); + + // ReSharper disable once InconsistentNaming + public static readonly Regex CJK = CJKRegex(); + + public static readonly Regex LdnPassphrase = LdnPassphraseRegex(); + + public static readonly Regex CleanText = CleanTextRegex(); + + #endregion + + #region Generated pattern stubs + + #region Numeric validation + + [GeneratedRegex("[0-9]|.")] + internal static partial Regex NumericRegex(); + + #endregion + + #region GPU names + + [GeneratedRegex( + "Radeon (((HD|R(5|7|9|X)) )?((M?[2-6]\\d{2}(\\D|$))|([7-8]\\d{3}(\\D|$))|Fury|Nano))|(Pro Duo)")] + internal static partial Regex AmdGcnRegex(); + + [GeneratedRegex("NVIDIA GeForce (R|G)?TX? (\\d{3}\\d?)M?")] + internal static partial Regex NvidiaConsumerClassRegex(); + + #endregion + + #region DNS blocking + + public static readonly Regex[] BlockedHosts = + [ + DomainLp1Ns, + DomainLp1Lp1Npln, + DomainLp1Znc, + DomainSbApi, + DomainSbAccounts, + DomainAccounts + ]; + + const RegexOptions DnsRegexOpts = + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture; + + [GeneratedRegex(@"^(.*)\-lp1\.(n|s)\.n\.srv\.nintendo\.net$", DnsRegexOpts)] + internal static partial Regex DomainLp1NsRegex(); + + [GeneratedRegex(@"^(.*)\-lp1\.lp1\.t\.npln\.srv\.nintendo\.net$", DnsRegexOpts)] + internal static partial Regex DomainLp1Lp1NplnRegex(); + + [GeneratedRegex(@"^(.*)\-lp1\.(znc|p)\.srv\.nintendo\.net$", DnsRegexOpts)] + internal static partial Regex DomainLp1ZncRegex(); + + [GeneratedRegex(@"^(.*)\-sb\-api\.accounts\.nintendo\.com$", DnsRegexOpts)] + internal static partial Regex DomainSbApiRegex(); + + [GeneratedRegex(@"^(.*)\-sb\.accounts\.nintendo\.com$", DnsRegexOpts)] + internal static partial Regex DomainSbAccountsRegex(); + + [GeneratedRegex(@"^accounts\.nintendo\.com$", DnsRegexOpts)] + internal static partial Regex DomainAccountsRegex(); + + #endregion + + #region Executable information + + [GeneratedRegex(@"[a-z]:[\\/][ -~]{5,}\.nss", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex ModuleRegex(); + + [GeneratedRegex(@"sdk_version: ([0-9.]*)")] + internal static partial Regex FsSdkRegex(); + + [GeneratedRegex(@"SDK MW[ -~]*")] + internal static partial Regex SdkMwRegex(); + + #endregion + + #region CJK + + [GeneratedRegex( + "\\p{IsHangulJamo}|\\p{IsCJKRadicalsSupplement}|\\p{IsCJKSymbolsandPunctuation}|\\p{IsEnclosedCJKLettersandMonths}|\\p{IsCJKCompatibility}|\\p{IsCJKUnifiedIdeographsExtensionA}|\\p{IsCJKUnifiedIdeographs}|\\p{IsHangulSyllables}|\\p{IsCJKCompatibilityForms}")] + private static partial Regex CJKRegex(); + + #endregion + + [GeneratedRegex("Ryujinx-[0-9a-f]{8}")] + private static partial Regex LdnPassphraseRegex(); + + [GeneratedRegex(@"[^\u0000\u0009\u000A\u000D\u0020-\uFFFF]..")] + private static partial Regex CleanTextRegex(); + + #endregion + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Vendor.cs b/src/Ryujinx.Graphics.Vulkan/Vendor.cs index 6a2a76a88..87c6407cd 100644 --- a/src/Ryujinx.Graphics.Vulkan/Vendor.cs +++ b/src/Ryujinx.Graphics.Vulkan/Vendor.cs @@ -16,14 +16,8 @@ namespace Ryujinx.Graphics.Vulkan Unknown, } - static partial class VendorUtils + static class VendorUtils { - [GeneratedRegex("Radeon (((HD|R(5|7|9|X)) )?((M?[2-6]\\d{2}(\\D|$))|([7-8]\\d{3}(\\D|$))|Fury|Nano))|(Pro Duo)")] - public static partial Regex AmdGcnRegex(); - - [GeneratedRegex("NVIDIA GeForce (R|G)?TX? (\\d{3}\\d?)M?")] - public static partial Regex NvidiaConsumerClassRegex(); - public static Vendor FromId(uint id) { return id switch diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs index 986baf91b..e90606dcf 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs @@ -1,5 +1,6 @@ using Gommon; using Ryujinx.Common.Configuration; +using Ryujinx.Common.Helper; using Ryujinx.Common.Logging; using Ryujinx.Graphics.GAL; using Ryujinx.Graphics.Shader; @@ -375,11 +376,11 @@ namespace Ryujinx.Graphics.Vulkan GpuVersion = $"Vulkan v{ParseStandardVulkanVersion(properties.ApiVersion)}, Driver v{ParseDriverVersion(ref properties)}"; - IsAmdGcn = !IsMoltenVk && Vendor == Vendor.Amd && VendorUtils.AmdGcnRegex().IsMatch(GpuRenderer); + IsAmdGcn = !IsMoltenVk && Vendor == Vendor.Amd && Patterns.AmdGcn.IsMatch(GpuRenderer); if (Vendor == Vendor.Nvidia) { - Match match = VendorUtils.NvidiaConsumerClassRegex().Match(GpuRenderer); + Match match = Patterns.NvidiaConsumerClass.Match(GpuRenderer); if (match != null && int.TryParse(match.Groups[2].Value, out int gpuNumber)) { diff --git a/src/Ryujinx.HLE/HOS/Applets/Error/ErrorApplet.cs b/src/Ryujinx.HLE/HOS/Applets/Error/ErrorApplet.cs index 54b5721c1..b41bc60b1 100644 --- a/src/Ryujinx.HLE/HOS/Applets/Error/ErrorApplet.cs +++ b/src/Ryujinx.HLE/HOS/Applets/Error/ErrorApplet.cs @@ -5,6 +5,7 @@ using LibHac.FsSystem; using LibHac.Ncm; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Common.Helper; using Ryujinx.Common.Logging; using Ryujinx.HLE.HOS.Services.Am.AppletAE; using Ryujinx.HLE.HOS.SystemState; @@ -30,9 +31,6 @@ namespace Ryujinx.HLE.HOS.Applets.Error public event EventHandler AppletStateChanged; - [GeneratedRegex(@"[^\u0000\u0009\u000A\u000D\u0020-\uFFFF]..")] - private static partial Regex CleanTextRegex(); - public ErrorApplet(Horizon horizon) { _horizon = horizon; @@ -107,7 +105,7 @@ namespace Ryujinx.HLE.HOS.Applets.Error private static string CleanText(string value) { - return CleanTextRegex().Replace(value, string.Empty).Replace("\0", string.Empty); + return Patterns.CleanText.Replace(value, string.Empty).Replace("\0", string.Empty); } private string GetMessageText(uint module, uint description, string key) diff --git a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs b/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs index 6134a3cdd..022fa9d5b 100644 --- a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs +++ b/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs @@ -1,17 +1,9 @@ -using System.Text.RegularExpressions; +using Ryujinx.Common.Helper; namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard { - public static partial class CJKCharacterValidation + public static class CJKCharacterValidation { - public static bool IsCJK(char value) - { - Regex regex = CJKRegex(); - - return regex.IsMatch(value.ToString()); - } - - [GeneratedRegex("\\p{IsHangulJamo}|\\p{IsCJKRadicalsSupplement}|\\p{IsCJKSymbolsandPunctuation}|\\p{IsEnclosedCJKLettersandMonths}|\\p{IsCJKCompatibility}|\\p{IsCJKUnifiedIdeographsExtensionA}|\\p{IsCJKUnifiedIdeographs}|\\p{IsHangulSyllables}|\\p{IsCJKCompatibilityForms}")] - private static partial Regex CJKRegex(); + public static bool IsCJK(char value) => Patterns.CJK.IsMatch(value.ToString()); } } diff --git a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/NumericCharacterValidation.cs b/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/NumericCharacterValidation.cs index d72b68eae..4f61773c0 100644 --- a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/NumericCharacterValidation.cs +++ b/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/NumericCharacterValidation.cs @@ -1,17 +1,9 @@ -using System.Text.RegularExpressions; +using Ryujinx.Common.Helper; namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard { - public static partial class NumericCharacterValidation + public static class NumericCharacterValidation { - public static bool IsNumeric(char value) - { - Regex regex = NumericRegex(); - - return regex.IsMatch(value.ToString()); - } - - [GeneratedRegex("[0-9]|.")] - private static partial Regex NumericRegex(); + public static bool IsNumeric(char value) => Patterns.Numeric.IsMatch(value.ToString()); } } diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/Proxy/DnsBlacklist.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/Proxy/DnsBlacklist.cs index 78c6be164..507e60573 100644 --- a/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/Proxy/DnsBlacklist.cs +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/Proxy/DnsBlacklist.cs @@ -1,37 +1,13 @@ +using Ryujinx.Common.Helper; using System.Text.RegularExpressions; namespace Ryujinx.HLE.HOS.Services.Sockets.Sfdnsres.Proxy { - static partial class DnsBlacklist + static class DnsBlacklist { - const RegexOptions RegexOpts = RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture; - - [GeneratedRegex(@"^(.*)\-lp1\.(n|s)\.n\.srv\.nintendo\.net$", RegexOpts)] - private static partial Regex BlockedHost1(); - [GeneratedRegex(@"^(.*)\-lp1\.lp1\.t\.npln\.srv\.nintendo\.net$", RegexOpts)] - private static partial Regex BlockedHost2(); - [GeneratedRegex(@"^(.*)\-lp1\.(znc|p)\.srv\.nintendo\.net$", RegexOpts)] - private static partial Regex BlockedHost3(); - [GeneratedRegex(@"^(.*)\-sb\-api\.accounts\.nintendo\.com$", RegexOpts)] - private static partial Regex BlockedHost4(); - [GeneratedRegex(@"^(.*)\-sb\.accounts\.nintendo\.com$", RegexOpts)] - private static partial Regex BlockedHost5(); - [GeneratedRegex(@"^accounts\.nintendo\.com$", RegexOpts)] - private static partial Regex BlockedHost6(); - - private static readonly Regex[] _blockedHosts = - [ - BlockedHost1(), - BlockedHost2(), - BlockedHost3(), - BlockedHost4(), - BlockedHost5(), - BlockedHost6() - ]; - public static bool IsHostBlocked(string host) { - foreach (Regex regex in _blockedHosts) + foreach (Regex regex in Patterns.BlockedHosts) { if (regex.IsMatch(host)) { diff --git a/src/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs b/src/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs index 5217612b9..84f229d8e 100644 --- a/src/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs +++ b/src/Ryujinx.HLE/Loaders/Executables/NsoExecutable.cs @@ -2,6 +2,7 @@ using LibHac.Common.FixedArrays; using LibHac.Fs; using LibHac.Loader; using LibHac.Tools.FsSystem; +using Ryujinx.Common.Helper; using Ryujinx.Common.Logging; using System; using System.Text; @@ -29,13 +30,6 @@ namespace Ryujinx.HLE.Loaders.Executables public string Name; public Array32 BuildId; - [GeneratedRegex(@"[a-z]:[\\/][ -~]{5,}\.nss", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex ModuleRegex(); - [GeneratedRegex(@"sdk_version: ([0-9.]*)")] - private static partial Regex FsSdkRegex(); - [GeneratedRegex(@"SDK MW[ -~]*")] - private static partial Regex SdkMwRegex(); - public NsoExecutable(IStorage inStorage, string name = null) { NsoReader reader = new(); @@ -90,7 +84,7 @@ namespace Ryujinx.HLE.Loaders.Executables if (string.IsNullOrEmpty(modulePath)) { - Match moduleMatch = ModuleRegex().Match(rawTextBuffer); + Match moduleMatch = Patterns.Module.Match(rawTextBuffer); if (moduleMatch.Success) { modulePath = moduleMatch.Value; @@ -99,13 +93,13 @@ namespace Ryujinx.HLE.Loaders.Executables stringBuilder.AppendLine($" Module: {modulePath}"); - Match fsSdkMatch = FsSdkRegex().Match(rawTextBuffer); + Match fsSdkMatch = Patterns.FsSdk.Match(rawTextBuffer); if (fsSdkMatch.Success) { stringBuilder.AppendLine($" FS SDK Version: {fsSdkMatch.Value.Replace("sdk_version: ", string.Empty)}"); } - MatchCollection sdkMwMatches = SdkMwRegex().Matches(rawTextBuffer); + MatchCollection sdkMwMatches = Patterns.SdkMw.Matches(rawTextBuffer); if (sdkMwMatches.Count != 0) { string libHeader = " SDK Libraries: "; diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 5a73dd574..488828482 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -16,6 +16,7 @@ using Ryujinx.Ava.Utilities.Configuration.System; using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration.Multiplayer; using Ryujinx.Common.GraphicsDriver; +using Ryujinx.Common.Helper; using Ryujinx.Common.Logging; using Ryujinx.Graphics.GAL; using Ryujinx.Graphics.Vulkan; @@ -330,9 +331,6 @@ namespace Ryujinx.Ava.UI.ViewModels } } - [GeneratedRegex("Ryujinx-[0-9a-f]{8}")] - private static partial Regex LdnPassphraseRegex(); - public bool IsInvalidLdnPassphraseVisible { get; set; } public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager) : this() @@ -470,7 +468,7 @@ namespace Ryujinx.Ava.UI.ViewModels private bool ValidateLdnPassphrase(string passphrase) { - return string.IsNullOrEmpty(passphrase) || (passphrase.Length == 16 && LdnPassphraseRegex().IsMatch(passphrase)); + return string.IsNullOrEmpty(passphrase) || (passphrase.Length == 16 && Patterns.LdnPassphrase.IsMatch(passphrase)); } public void ValidateAndSetTimeZone(string location) From ad9d6588e8e7a91e6185c11e8824b944a610bdb5 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 14:11:35 -0600 Subject: [PATCH 07/49] misc: chore: Collapse HLE swkbd character validation utils into a single class --- .../Applets/SoftwareKeyboard/CJKCharacterValidation.cs | 9 --------- ...ericCharacterValidation.cs => CharacterValidation.cs} | 3 ++- src/Ryujinx/UI/Applet/SwkbdAppletDialog.axaml.cs | 4 ++-- 3 files changed, 4 insertions(+), 12 deletions(-) delete mode 100644 src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs rename src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/{NumericCharacterValidation.cs => CharacterValidation.cs} (59%) diff --git a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs b/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs deleted file mode 100644 index 022fa9d5b..000000000 --- a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CJKCharacterValidation.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Ryujinx.Common.Helper; - -namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard -{ - public static class CJKCharacterValidation - { - public static bool IsCJK(char value) => Patterns.CJK.IsMatch(value.ToString()); - } -} diff --git a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/NumericCharacterValidation.cs b/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CharacterValidation.cs similarity index 59% rename from src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/NumericCharacterValidation.cs rename to src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CharacterValidation.cs index 4f61773c0..5ce8204cb 100644 --- a/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/NumericCharacterValidation.cs +++ b/src/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/CharacterValidation.cs @@ -2,8 +2,9 @@ using Ryujinx.Common.Helper; namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard { - public static class NumericCharacterValidation + public static class CharacterValidation { public static bool IsNumeric(char value) => Patterns.Numeric.IsMatch(value.ToString()); + public static bool IsCJK(char value) => Patterns.CJK.IsMatch(value.ToString()); } } diff --git a/src/Ryujinx/UI/Applet/SwkbdAppletDialog.axaml.cs b/src/Ryujinx/UI/Applet/SwkbdAppletDialog.axaml.cs index 75a9b3d41..dd5b7d9f1 100644 --- a/src/Ryujinx/UI/Applet/SwkbdAppletDialog.axaml.cs +++ b/src/Ryujinx/UI/Applet/SwkbdAppletDialog.axaml.cs @@ -144,12 +144,12 @@ namespace Ryujinx.Ava.UI.Controls case KeyboardMode.Numeric: localeText = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.SoftwareKeyboardModeNumeric); validationInfoText = string.IsNullOrEmpty(validationInfoText) ? localeText : string.Join("\n", validationInfoText, localeText); - _checkInput = text => text.All(NumericCharacterValidation.IsNumeric); + _checkInput = text => text.All(CharacterValidation.IsNumeric); break; case KeyboardMode.Alphabet: localeText = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.SoftwareKeyboardModeAlphabet); validationInfoText = string.IsNullOrEmpty(validationInfoText) ? localeText : string.Join("\n", validationInfoText, localeText); - _checkInput = text => text.All(value => !CJKCharacterValidation.IsCJK(value)); + _checkInput = text => text.All(value => !CharacterValidation.IsCJK(value)); break; case KeyboardMode.ASCII: localeText = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.SoftwareKeyboardModeASCII); From a46aacf2e2a723e06ac8f493e5c0445b5b6d219e Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sat, 1 Feb 2025 19:20:36 -0600 Subject: [PATCH 08/49] gpu: Switch the 500ms timeout back to 1s It seemed like it was waiting for 1 second no matter what; might as well have the log & syncpoint map match reality. --- .../Synchronization/SynchronizationManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs b/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs index 7165af6ad..d51b0ef60 100644 --- a/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Synchronization/SynchronizationManager.cs @@ -83,7 +83,7 @@ namespace Ryujinx.Graphics.Gpu.Synchronization // TODO: Remove this when GPU channel scheduling will be implemented. if (timeout == Timeout.InfiniteTimeSpan) { - timeout = TimeSpan.FromMilliseconds(500); + timeout = TimeSpan.FromSeconds(1); } using ManualResetEvent waitEvent = new(false); From 50cee3fd19e4503b63eec3ede8e23cc0d5275bf6 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 02:20:14 -0600 Subject: [PATCH 09/49] feature: HorizonStatic PlayReportPrinted event --- src/Ryujinx.Horizon/HorizonStatic.cs | 5 +++++ src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/Ryujinx.Horizon/HorizonStatic.cs b/src/Ryujinx.Horizon/HorizonStatic.cs index 305d54bd1..6de6c4d05 100644 --- a/src/Ryujinx.Horizon/HorizonStatic.cs +++ b/src/Ryujinx.Horizon/HorizonStatic.cs @@ -1,3 +1,4 @@ +using MsgPack; using Ryujinx.Horizon.Common; using Ryujinx.Memory; using System; @@ -6,6 +7,10 @@ namespace Ryujinx.Horizon { public static class HorizonStatic { + internal static void HandlePlayReport(MessagePackObject report) => PlayReportPrinted.Invoke(report); + + public static event Action PlayReportPrinted; + [ThreadStatic] private static HorizonOptions _options; diff --git a/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs b/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs index 4ed7dd48e..ab972d85e 100644 --- a/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs +++ b/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs @@ -230,6 +230,8 @@ namespace Ryujinx.Horizon.Prepo.Ipc builder.AppendLine($" Room: {gameRoom}"); builder.AppendLine($" Report: {MessagePackObjectFormatter.Format(deserializedReport)}"); + + HorizonStatic.HandlePlayReport(deserializedReport); Logger.Info?.Print(LogClass.ServicePrepo, builder.ToString()); From 37af8c70aa724e7e5096fcb16f821e810cf0942a Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 02:21:33 -0600 Subject: [PATCH 10/49] UI: RPC: Add the ability for the DiscordIntegrationModule to inspect values in Play Reports and dynamically show different gameplay values, depending on a predefined map of values and formatters. Currently only BOTW Master Mode is supported. Open to PRs! --- src/Ryujinx/DiscordIntegrationModule.cs | 62 +++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index 70ebedfa4..392895d3a 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -1,11 +1,18 @@ using DiscordRPC; using Gommon; +using MsgPack; using Ryujinx.Ava.Utilities; using Ryujinx.Ava.Utilities.AppLibrary; using Ryujinx.Ava.Utilities.Configuration; using Ryujinx.Common; +using Ryujinx.Common.Logging; using Ryujinx.HLE; using Ryujinx.HLE.Loaders.Processes; +using Ryujinx.Horizon; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Text; namespace Ryujinx.Ava @@ -30,6 +37,7 @@ namespace Ryujinx.Ava private static DiscordRpcClient _discordClient; private static RichPresence _discordPresenceMain; + private static RichPresence _discordPresencePlaying; public static void Initialize() { @@ -47,6 +55,7 @@ namespace Ryujinx.Ava ConfigurationState.Instance.EnableDiscordIntegration.Event += Update; TitleIDs.CurrentApplication.Event += (_, e) => Use(e.NewValue); + HorizonStatic.PlayReportPrinted += HandlePlayReport; } private static void Update(object sender, ReactiveEventArgs evnt) @@ -84,9 +93,8 @@ namespace Ryujinx.Ava SwitchToMainState(); } - private static void SwitchToPlayingState(ApplicationMetadata appMeta, ProcessResult procRes) - { - _discordClient?.SetPresence(new RichPresence + private static RichPresence CreatePlayingState(ApplicationMetadata appMeta, ProcessResult procRes) => + new() { Assets = new Assets { @@ -100,10 +108,54 @@ namespace Ryujinx.Ava ? $"Total play time: {ValueFormatUtils.FormatTimeSpan(appMeta.TimePlayed)}" : "Never played", Timestamps = GuestAppStartedAt ??= Timestamps.Now - }); + }; + + private static void SwitchToPlayingState(ApplicationMetadata appMeta, ProcessResult procRes) + { + _discordClient?.SetPresence(_discordPresencePlaying ??= CreatePlayingState(appMeta, procRes)); + } + + private static void UpdatePlayingState() + { + _discordClient?.SetPresence(_discordPresencePlaying); } - private static void SwitchToMainState() => _discordClient?.SetPresence(_discordPresenceMain); + private static void SwitchToMainState() + { + _discordClient?.SetPresence(_discordPresenceMain); + _discordPresencePlaying = null; + } + + private static void HandlePlayReport(MessagePackObject playReport) + { + if (!TitleIDs.CurrentApplication.Value.HasValue) return; + if (_discordPresencePlaying is null) return; + if (!playReport.IsDictionary) return; + + _playReportValues + .FindFirst(x => x.Key.EqualsIgnoreCase(TitleIDs.CurrentApplication.Value)) + .Convert(x => x.Value) + .IfPresent(x => + { + if (!playReport.AsDictionary().TryGetValue(x.ReportKey, out MessagePackObject valuePackObject)) + return; + + _discordPresencePlaying.Details = x.Formatter(valuePackObject.ToObject()); + UpdatePlayingState(); + Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); + }); + } + + // title ID -> Play Report key & value formatter + private static readonly ReadOnlyDictionary Formatter)> + _playReportValues = new(new Dictionary Formatter)> + { + { + // Breath of the Wild Master Mode display + "01007ef00011e000", + ("IsHardMode", val => val is 1 ? "Playing Master Mode" : "Playing Normal Mode") + } + }); private static string TruncateToByteLength(string input) { From ea2287af036bd2ab41743bac8e3bba7c95a61d7e Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 13:17:31 -0600 Subject: [PATCH 11/49] misc: chore: Rewrite play report checker to use a simple loop instead of Gommon Optionals (I love how a class that's supposed to guard against null values entering your code still allows them thats so cool) --- src/Ryujinx/DiscordIntegrationModule.cs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index 392895d3a..8d1a55582 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -132,18 +132,18 @@ namespace Ryujinx.Ava if (_discordPresencePlaying is null) return; if (!playReport.IsDictionary) return; - _playReportValues - .FindFirst(x => x.Key.EqualsIgnoreCase(TitleIDs.CurrentApplication.Value)) - .Convert(x => x.Value) - .IfPresent(x => - { - if (!playReport.AsDictionary().TryGetValue(x.ReportKey, out MessagePackObject valuePackObject)) - return; + foreach ((string titleId, (string reportKey, Func formatter)) in _playReportValues) + { + if (!TitleIDs.CurrentApplication.Value.Value.EqualsIgnoreCase(titleId)) + continue; + + if (!playReport.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject)) + return; - _discordPresencePlaying.Details = x.Formatter(valuePackObject.ToObject()); - UpdatePlayingState(); - Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); - }); + _discordPresencePlaying.Details = formatter(valuePackObject.ToObject()); + UpdatePlayingState(); + Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); + } } // title ID -> Play Report key & value formatter From 2d7700949c26352f6eba0c3e67f903a064ac9913 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 16:07:30 -0600 Subject: [PATCH 12/49] UI: Play Report Analysis V2 Support for multiple keys per game, and provide an order of resolution via Priority. (Currently) functionally identical to before, as only BOTW Master Mode is supported. --- .../Helpers/PlayReportAnalyzer.cs | 80 +++++++++++++++++++ src/Ryujinx.Horizon/HorizonStatic.cs | 2 +- src/Ryujinx/DiscordIntegrationModule.cs | 56 ++++++------- 3 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs diff --git a/src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs b/src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs new file mode 100644 index 000000000..b69b18f57 --- /dev/null +++ b/src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs @@ -0,0 +1,80 @@ +using Gommon; +using MsgPack; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Common.Helper +{ + public class PlayReportAnalyzer + { + private readonly List _specs = []; + + public PlayReportAnalyzer AddSpec(string titleId, Func transform) + { + _specs.Add(transform(new PlayReportGameSpec { TitleIdStr = titleId })); + return this; + } + + public PlayReportAnalyzer AddSpec(string titleId, Action transform) + { + _specs.Add(new PlayReportGameSpec { TitleIdStr = titleId }.Apply(transform)); + return this; + } + + public Optional Run(string runningGameId, MessagePackObject playReport) + { + if (!playReport.IsDictionary) + return Optional.None; + + if (!_specs.TryGetFirst(s => s.TitleIdStr.EqualsIgnoreCase(runningGameId), out PlayReportGameSpec spec)) + return Optional.None; + + foreach (PlayReportValueFormatterSpec formatSpec in spec.Analyses.OrderBy(x => x.Priority)) + { + if (!playReport.AsDictionary().TryGetValue(formatSpec.ReportKey, out MessagePackObject valuePackObject)) + continue; + + return formatSpec.ValueFormatter(valuePackObject.ToObject()); + } + + return Optional.None; + } + + } + + public class PlayReportGameSpec + { + public required string TitleIdStr { get; init; } + public List Analyses { get; } = []; + + public PlayReportGameSpec AddValueFormatter(string reportKey, Func valueFormatter) + { + Analyses.Add(new PlayReportValueFormatterSpec + { + Priority = Analyses.Count, + ReportKey = reportKey, + ValueFormatter = valueFormatter + }); + return this; + } + + public PlayReportGameSpec AddValueFormatter(int priority, string reportKey, Func valueFormatter) + { + Analyses.Add(new PlayReportValueFormatterSpec + { + Priority = priority, + ReportKey = reportKey, + ValueFormatter = valueFormatter + }); + return this; + } + } + + public struct PlayReportValueFormatterSpec + { + public required int Priority { get; init; } + public required string ReportKey { get; init; } + public required Func ValueFormatter { get; init; } + } +} diff --git a/src/Ryujinx.Horizon/HorizonStatic.cs b/src/Ryujinx.Horizon/HorizonStatic.cs index 6de6c4d05..f08ddb3c0 100644 --- a/src/Ryujinx.Horizon/HorizonStatic.cs +++ b/src/Ryujinx.Horizon/HorizonStatic.cs @@ -7,7 +7,7 @@ namespace Ryujinx.Horizon { public static class HorizonStatic { - internal static void HandlePlayReport(MessagePackObject report) => PlayReportPrinted.Invoke(report); + internal static void HandlePlayReport(MessagePackObject report) => PlayReportPrinted?.Invoke(report); public static event Action PlayReportPrinted; diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index 8d1a55582..add46bda4 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -5,6 +5,7 @@ using Ryujinx.Ava.Utilities; using Ryujinx.Ava.Utilities.AppLibrary; using Ryujinx.Ava.Utilities.Configuration; using Ryujinx.Common; +using Ryujinx.Common.Helper; using Ryujinx.Common.Logging; using Ryujinx.HLE; using Ryujinx.HLE.Loaders.Processes; @@ -23,12 +24,12 @@ namespace Ryujinx.Ava public static Timestamps GuestAppStartedAt { get; set; } private static string VersionString - => (ReleaseInformation.IsCanaryBuild ? "Canary " : string.Empty) + $"v{ReleaseInformation.Version}"; + => (ReleaseInformation.IsCanaryBuild ? "Canary " : string.Empty) + $"v{ReleaseInformation.Version}"; - private static readonly string _description = - ReleaseInformation.IsValid - ? $"{VersionString} {ReleaseInformation.ReleaseChannelOwner}/{ReleaseInformation.ReleaseChannelSourceRepo}@{ReleaseInformation.BuildGitHash}" - : "dev build"; + private static readonly string _description = + ReleaseInformation.IsValid + ? $"{VersionString} {ReleaseInformation.ReleaseChannelOwner}/{ReleaseInformation.ReleaseChannelSourceRepo}@{ReleaseInformation.BuildGitHash}" + : "dev build"; private const string ApplicationId = "1293250299716173864"; @@ -45,8 +46,7 @@ namespace Ryujinx.Ava { Assets = new Assets { - LargeImageKey = "ryujinx", - LargeImageText = TruncateToByteLength(_description) + LargeImageKey = "ryujinx", LargeImageText = TruncateToByteLength(_description) }, Details = "Main Menu", State = "Idling", @@ -86,10 +86,10 @@ namespace Ryujinx.Ava { if (titleId.TryGet(out string tid)) SwitchToPlayingState( - ApplicationLibrary.LoadAndSaveMetaData(tid), + ApplicationLibrary.LoadAndSaveMetaData(tid), Switch.Shared.Processes.ActiveApplication ); - else + else SwitchToMainState(); } @@ -114,7 +114,7 @@ namespace Ryujinx.Ava { _discordClient?.SetPresence(_discordPresencePlaying ??= CreatePlayingState(appMeta, procRes)); } - + private static void UpdatePlayingState() { _discordClient?.SetPresence(_discordPresencePlaying); @@ -126,37 +126,27 @@ namespace Ryujinx.Ava _discordPresencePlaying = null; } + private static readonly PlayReportAnalyzer _playReportAnalyzer = new PlayReportAnalyzer() + .AddSpec( // Breath of the Wild + "01007ef00011e000", + gameSpec => + gameSpec.AddValueFormatter("IsHardMode", val => val is 1 ? "Playing Master Mode" : "Playing Normal Mode") + ); + private static void HandlePlayReport(MessagePackObject playReport) { if (!TitleIDs.CurrentApplication.Value.HasValue) return; if (_discordPresencePlaying is null) return; - if (!playReport.IsDictionary) return; - foreach ((string titleId, (string reportKey, Func formatter)) in _playReportValues) - { - if (!TitleIDs.CurrentApplication.Value.Value.EqualsIgnoreCase(titleId)) - continue; - - if (!playReport.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject)) - return; + Optional details = _playReportAnalyzer.Run(TitleIDs.CurrentApplication.Value, playReport); - _discordPresencePlaying.Details = formatter(valuePackObject.ToObject()); - UpdatePlayingState(); - Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); - } + if (!details.HasValue) return; + + _discordPresencePlaying.Details = details; + UpdatePlayingState(); + Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); } - // title ID -> Play Report key & value formatter - private static readonly ReadOnlyDictionary Formatter)> - _playReportValues = new(new Dictionary Formatter)> - { - { - // Breath of the Wild Master Mode display - "01007ef00011e000", - ("IsHardMode", val => val is 1 ? "Playing Master Mode" : "Playing Normal Mode") - } - }); - private static string TruncateToByteLength(string input) { if (Encoding.UTF8.GetByteCount(input) <= ApplicationByteLimit) From b38b5a1e709c797b3e409c8d34775284f35bff96 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 16:59:06 -0600 Subject: [PATCH 13/49] docs: compat: Saints Row IV: Playable -> Ingame Deadlock label added. Game sometimes just stops loading in loading screens. Game continues like its doing something but you'll be sitting there for minutes wondering why nothing is happening. Considering the game isn't crashing, this might be an emulator-side mutex issue. I've seen that before. --- docs/compatibility.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compatibility.csv b/docs/compatibility.csv index 570c93618..53ad389b6 100644 --- a/docs/compatibility.csv +++ b/docs/compatibility.csv @@ -2483,7 +2483,7 @@ 0100A5200C2E0000,"Safety First!",,playable,2021-01-06 09:05:23 0100A51013530000,"SaGa Frontier Remastered",nvdec,playable,2022-11-03 13:54:56 010003A00D0B4000,"SaGa SCARLET GRACE: AMBITIONS™",,playable,2022-10-06 13:20:31 -01008D100D43E000,"Saints Row IV®: Re-Elected™",ldn-untested;LAN,playable,2023-12-04 18:33:37 +01008D100D43E000,"Saints Row IV®: Re-Elected™",ldn-untested;LAN;deadlock,ingame,2025-02-02 16:57:53 0100DE600BEEE000,"SAINTS ROW®: THE THIRD™ - THE FULL PACKAGE",slow;LAN,playable,2023-08-24 02:40:58 01007F000EB36000,"Sakai and...",nvdec,playable,2022-12-15 13:53:19 0100B1400E8FE000,"Sakuna: Of Rice and Ruin",,playable,2023-07-24 13:47:13 From bf713a80d66c14dc2b045fced8d1cfafbce91875 Mon Sep 17 00:00:00 2001 From: Piplup <100526773+piplup55@users.noreply.github.com> Date: Mon, 3 Feb 2025 02:29:00 +0000 Subject: [PATCH 14/49] PlayReportAnalyzer: Added Games (#614) Added Super Mario Odyssey, Super Mario Odyssey (China), Super Mario 3D World + Bowser's Fury, Mario Kart 8 Deluxe and Mario Kart 8 Deluxe (China) --- src/Ryujinx/DiscordIntegrationModule.cs | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index add46bda4..7f48089b9 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -82,6 +82,36 @@ namespace Ryujinx.Ava } } + public static string MarioKart8(object obj) + { + return obj switch + { + // Single Player + "Single" => "Single Player", + // Multiplayer + "Multi-2players" => "Multiplayer 2 Players", + "Multi-3players" => "Multiplayer 3 Players", + "Multi-4players" => "Multiplayer 4 Players", + // Wireless/LAN Play + "Local-Single" => "Wireless/LAN Play", + "Local-2players" => "Wireless/LAN Play 2 Players", + // CC Classes + "50cc" => "50cc", + "100cc" => "100cc", + "150cc" => "150cc", + "Mirror" => "Mirror (150cc)", + "200cc" => "200cc", + // Modes + "GrandPrix" => "Grand Prix", + "TimeAttack" => "Time Trials", + "VS" => "VS Races", + "Battle" => "Battle Mode", + "RaceStart" => "Selecting a Course", + "Race" => "Racing", + _ => "Playing Mario Kart 8 Deluxe" + }; + } + public static void Use(Optional titleId) { if (titleId.TryGet(out string tid)) @@ -131,6 +161,31 @@ namespace Ryujinx.Ava "01007ef00011e000", gameSpec => gameSpec.AddValueFormatter("IsHardMode", val => val is 1 ? "Playing Master Mode" : "Playing Normal Mode") + ) + .AddSpec( // Super Mario Odyssey + "0100000000010000", + gameSpec => + gameSpec.AddValueFormatter("is_kids_mode", val => val is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode") + ) + .AddSpec( // Super Mario Odyssey (China) + "010075000ECBE000", + gameSpec => + gameSpec.AddValueFormatter("is_kids_mode", val => val is 1 ? "Playing in 帮助模式" : "Playing in 普通模式") + ) + .AddSpec( // Super Mario 3D World + Bowser's Fury + "010028600EBDA000", + gameSpec => + gameSpec.AddValueFormatter("mode", val => val is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury") + ) + .AddSpec( // Mario Kart 8 Deluxe + "0100152000022000", + gameSpec => + gameSpec.AddValueFormatter("To", MarioKart8) + ) + .AddSpec( // Mario Kart 8 Deluxe (China) + "010075100E8EC000", + gameSpec => + gameSpec.AddValueFormatter("To", MarioKart8) ); private static void HandlePlayReport(MessagePackObject playReport) From 8117e160c2e82ffacea312bb4b9c41171b4396d5 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 20:32:01 -0600 Subject: [PATCH 15/49] misc: chore: [ci skip] Move the play report analyzer definition into a PlayReport static class to avoid polluting the Discord integration module --- src/Ryujinx/DiscordIntegrationModule.cs | 64 +-------------------- src/Ryujinx/Utilities/PlayReport.cs | 76 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 63 deletions(-) create mode 100644 src/Ryujinx/Utilities/PlayReport.cs diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index 7f48089b9..c9fa1f732 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -82,36 +82,6 @@ namespace Ryujinx.Ava } } - public static string MarioKart8(object obj) - { - return obj switch - { - // Single Player - "Single" => "Single Player", - // Multiplayer - "Multi-2players" => "Multiplayer 2 Players", - "Multi-3players" => "Multiplayer 3 Players", - "Multi-4players" => "Multiplayer 4 Players", - // Wireless/LAN Play - "Local-Single" => "Wireless/LAN Play", - "Local-2players" => "Wireless/LAN Play 2 Players", - // CC Classes - "50cc" => "50cc", - "100cc" => "100cc", - "150cc" => "150cc", - "Mirror" => "Mirror (150cc)", - "200cc" => "200cc", - // Modes - "GrandPrix" => "Grand Prix", - "TimeAttack" => "Time Trials", - "VS" => "VS Races", - "Battle" => "Battle Mode", - "RaceStart" => "Selecting a Course", - "Race" => "Racing", - _ => "Playing Mario Kart 8 Deluxe" - }; - } - public static void Use(Optional titleId) { if (titleId.TryGet(out string tid)) @@ -155,45 +125,13 @@ namespace Ryujinx.Ava _discordClient?.SetPresence(_discordPresenceMain); _discordPresencePlaying = null; } - - private static readonly PlayReportAnalyzer _playReportAnalyzer = new PlayReportAnalyzer() - .AddSpec( // Breath of the Wild - "01007ef00011e000", - gameSpec => - gameSpec.AddValueFormatter("IsHardMode", val => val is 1 ? "Playing Master Mode" : "Playing Normal Mode") - ) - .AddSpec( // Super Mario Odyssey - "0100000000010000", - gameSpec => - gameSpec.AddValueFormatter("is_kids_mode", val => val is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode") - ) - .AddSpec( // Super Mario Odyssey (China) - "010075000ECBE000", - gameSpec => - gameSpec.AddValueFormatter("is_kids_mode", val => val is 1 ? "Playing in 帮助模式" : "Playing in 普通模式") - ) - .AddSpec( // Super Mario 3D World + Bowser's Fury - "010028600EBDA000", - gameSpec => - gameSpec.AddValueFormatter("mode", val => val is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury") - ) - .AddSpec( // Mario Kart 8 Deluxe - "0100152000022000", - gameSpec => - gameSpec.AddValueFormatter("To", MarioKart8) - ) - .AddSpec( // Mario Kart 8 Deluxe (China) - "010075100E8EC000", - gameSpec => - gameSpec.AddValueFormatter("To", MarioKart8) - ); private static void HandlePlayReport(MessagePackObject playReport) { if (!TitleIDs.CurrentApplication.Value.HasValue) return; if (_discordPresencePlaying is null) return; - Optional details = _playReportAnalyzer.Run(TitleIDs.CurrentApplication.Value, playReport); + Optional details = PlayReport.Analyzer.Run(TitleIDs.CurrentApplication.Value, playReport); if (!details.HasValue) return; diff --git a/src/Ryujinx/Utilities/PlayReport.cs b/src/Ryujinx/Utilities/PlayReport.cs new file mode 100644 index 000000000..e913ffa13 --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport.cs @@ -0,0 +1,76 @@ +using Ryujinx.Common.Helper; + +namespace Ryujinx.Ava.Utilities +{ + public static class PlayReport + { + public static PlayReportAnalyzer Analyzer { get; } = new PlayReportAnalyzer() + .AddSpec( + "01007ef00011e000", + spec => spec.AddValueFormatter("IsHardMode", BreathOfTheWild_MasterMode) + ) + .AddSpec( // Super Mario Odyssey + "0100000000010000", + spec => + spec.AddValueFormatter("is_kids_mode", SuperMarioOdyssey_AssistMode) + ) + .AddSpec( // Super Mario Odyssey (China) + "010075000ECBE000", + spec => + spec.AddValueFormatter("is_kids_mode", SuperMarioOdysseyChina_AssistMode) + ) + .AddSpec( // Super Mario 3D World + Bowser's Fury + "010028600EBDA000", + spec => spec.AddValueFormatter("mode", SuperMario3DWorldOrBowsersFury) + ) + .AddSpec( // Mario Kart 8 Deluxe + "0100152000022000", + spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode) + ) + .AddSpec( // Mario Kart 8 Deluxe (China) + "010075100E8EC000", + spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode) + ); + + private static string BreathOfTheWild_MasterMode(object val) + => val is 1 ? "Playing Master Mode" : "Playing Normal Mode"; + + private static string SuperMarioOdyssey_AssistMode(object val) + => val is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; + + private static string SuperMarioOdysseyChina_AssistMode(object val) + => val is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; + + private static string SuperMario3DWorldOrBowsersFury(object val) + => val is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; + + private static string MarioKart8Deluxe_Mode(object obj) + => obj switch + { + // Single Player + "Single" => "Single Player", + // Multiplayer + "Multi-2players" => "Multiplayer 2 Players", + "Multi-3players" => "Multiplayer 3 Players", + "Multi-4players" => "Multiplayer 4 Players", + // Wireless/LAN Play + "Local-Single" => "Wireless/LAN Play", + "Local-2players" => "Wireless/LAN Play 2 Players", + // CC Classes + "50cc" => "50cc", + "100cc" => "100cc", + "150cc" => "150cc", + "Mirror" => "Mirror (150cc)", + "200cc" => "200cc", + // Modes + "GrandPrix" => "Grand Prix", + "TimeAttack" => "Time Trials", + "VS" => "VS Races", + "Battle" => "Battle Mode", + "RaceStart" => "Selecting a Course", + "Race" => "Racing", + //TODO: refactor value formatting system to pass in the name from the content archive so this can be localized properly + _ => "Playing Mario Kart 8 Deluxe" + }; + } +} From fe43c32e60008fa39dd6ab6216de8df97d13f98e Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 20:47:42 -0600 Subject: [PATCH 16/49] UI: The argument to Play Report value formatters is now a struct containing the current ApplicationMetadata & the BoxedValue that was the only argument previously. This allows for the title of Mario Kart to be localized when one of the value checkers doesn't match. --- .../Helpers/PlayReportAnalyzer.cs | 80 ------------ src/Ryujinx/DiscordIntegrationModule.cs | 5 +- src/Ryujinx/Utilities/PlayReport.cs | 121 ++++++++++++++++-- 3 files changed, 112 insertions(+), 94 deletions(-) delete mode 100644 src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs diff --git a/src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs b/src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs deleted file mode 100644 index b69b18f57..000000000 --- a/src/Ryujinx.Common/Helpers/PlayReportAnalyzer.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Gommon; -using MsgPack; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Ryujinx.Common.Helper -{ - public class PlayReportAnalyzer - { - private readonly List _specs = []; - - public PlayReportAnalyzer AddSpec(string titleId, Func transform) - { - _specs.Add(transform(new PlayReportGameSpec { TitleIdStr = titleId })); - return this; - } - - public PlayReportAnalyzer AddSpec(string titleId, Action transform) - { - _specs.Add(new PlayReportGameSpec { TitleIdStr = titleId }.Apply(transform)); - return this; - } - - public Optional Run(string runningGameId, MessagePackObject playReport) - { - if (!playReport.IsDictionary) - return Optional.None; - - if (!_specs.TryGetFirst(s => s.TitleIdStr.EqualsIgnoreCase(runningGameId), out PlayReportGameSpec spec)) - return Optional.None; - - foreach (PlayReportValueFormatterSpec formatSpec in spec.Analyses.OrderBy(x => x.Priority)) - { - if (!playReport.AsDictionary().TryGetValue(formatSpec.ReportKey, out MessagePackObject valuePackObject)) - continue; - - return formatSpec.ValueFormatter(valuePackObject.ToObject()); - } - - return Optional.None; - } - - } - - public class PlayReportGameSpec - { - public required string TitleIdStr { get; init; } - public List Analyses { get; } = []; - - public PlayReportGameSpec AddValueFormatter(string reportKey, Func valueFormatter) - { - Analyses.Add(new PlayReportValueFormatterSpec - { - Priority = Analyses.Count, - ReportKey = reportKey, - ValueFormatter = valueFormatter - }); - return this; - } - - public PlayReportGameSpec AddValueFormatter(int priority, string reportKey, Func valueFormatter) - { - Analyses.Add(new PlayReportValueFormatterSpec - { - Priority = priority, - ReportKey = reportKey, - ValueFormatter = valueFormatter - }); - return this; - } - } - - public struct PlayReportValueFormatterSpec - { - public required int Priority { get; init; } - public required string ReportKey { get; init; } - public required Func ValueFormatter { get; init; } - } -} diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index c9fa1f732..5561c1562 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -39,6 +39,7 @@ namespace Ryujinx.Ava private static DiscordRpcClient _discordClient; private static RichPresence _discordPresenceMain; private static RichPresence _discordPresencePlaying; + private static ApplicationMetadata _currentApp; public static void Initialize() { @@ -113,6 +114,7 @@ namespace Ryujinx.Ava private static void SwitchToPlayingState(ApplicationMetadata appMeta, ProcessResult procRes) { _discordClient?.SetPresence(_discordPresencePlaying ??= CreatePlayingState(appMeta, procRes)); + _currentApp = appMeta; } private static void UpdatePlayingState() @@ -124,6 +126,7 @@ namespace Ryujinx.Ava { _discordClient?.SetPresence(_discordPresenceMain); _discordPresencePlaying = null; + _currentApp = null; } private static void HandlePlayReport(MessagePackObject playReport) @@ -131,7 +134,7 @@ namespace Ryujinx.Ava if (!TitleIDs.CurrentApplication.Value.HasValue) return; if (_discordPresencePlaying is null) return; - Optional details = PlayReport.Analyzer.Run(TitleIDs.CurrentApplication.Value, playReport); + Optional details = PlayReport.Analyzer.Run(TitleIDs.CurrentApplication.Value, _currentApp, playReport); if (!details.HasValue) return; diff --git a/src/Ryujinx/Utilities/PlayReport.cs b/src/Ryujinx/Utilities/PlayReport.cs index e913ffa13..9665a1628 100644 --- a/src/Ryujinx/Utilities/PlayReport.cs +++ b/src/Ryujinx/Utilities/PlayReport.cs @@ -1,4 +1,10 @@ -using Ryujinx.Common.Helper; +using Gommon; +using MsgPack; +using Ryujinx.Ava.Utilities.AppLibrary; +using Ryujinx.Common.Helper; +using System; +using System.Collections.Generic; +using System.Linq; namespace Ryujinx.Ava.Utilities { @@ -32,20 +38,20 @@ namespace Ryujinx.Ava.Utilities spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode) ); - private static string BreathOfTheWild_MasterMode(object val) - => val is 1 ? "Playing Master Mode" : "Playing Normal Mode"; + private static string BreathOfTheWild_MasterMode(ref PlayReportValue value) + => value.BoxedValue is 1 ? "Playing Master Mode" : "Playing Normal Mode"; - private static string SuperMarioOdyssey_AssistMode(object val) - => val is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; + private static string SuperMarioOdyssey_AssistMode(ref PlayReportValue value) + => value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; - private static string SuperMarioOdysseyChina_AssistMode(object val) - => val is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; + private static string SuperMarioOdysseyChina_AssistMode(ref PlayReportValue value) + => value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; - private static string SuperMario3DWorldOrBowsersFury(object val) - => val is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; + private static string SuperMario3DWorldOrBowsersFury(ref PlayReportValue value) + => value.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; - private static string MarioKart8Deluxe_Mode(object obj) - => obj switch + private static string MarioKart8Deluxe_Mode(ref PlayReportValue value) + => value.BoxedValue switch { // Single Player "Single" => "Single Player", @@ -69,8 +75,97 @@ namespace Ryujinx.Ava.Utilities "Battle" => "Battle Mode", "RaceStart" => "Selecting a Course", "Race" => "Racing", - //TODO: refactor value formatting system to pass in the name from the content archive so this can be localized properly - _ => "Playing Mario Kart 8 Deluxe" + _ => $"Playing {value.Application.Title}" }; } + + #region Analyzer implementation + + public class PlayReportAnalyzer + { + private readonly List _specs = []; + + public PlayReportAnalyzer AddSpec(string titleId, Func transform) + { + _specs.Add(transform(new PlayReportGameSpec { TitleIdStr = titleId })); + return this; + } + + public PlayReportAnalyzer AddSpec(string titleId, Action transform) + { + _specs.Add(new PlayReportGameSpec { TitleIdStr = titleId }.Apply(transform)); + return this; + } + + public Optional Run(string runningGameId, ApplicationMetadata appMeta, MessagePackObject playReport) + { + if (!playReport.IsDictionary) + return Optional.None; + + if (!_specs.TryGetFirst(s => s.TitleIdStr.EqualsIgnoreCase(runningGameId), out PlayReportGameSpec spec)) + return Optional.None; + + foreach (PlayReportValueFormatterSpec formatSpec in spec.Analyses.OrderBy(x => x.Priority)) + { + if (!playReport.AsDictionary().TryGetValue(formatSpec.ReportKey, out MessagePackObject valuePackObject)) + continue; + + PlayReportValue value = new() + { + Application = appMeta, + BoxedValue = valuePackObject.ToObject() + }; + + return formatSpec.ValueFormatter(ref value); + } + + return Optional.None; + } + + } + + public class PlayReportGameSpec + { + public required string TitleIdStr { get; init; } + public List Analyses { get; } = []; + + public PlayReportGameSpec AddValueFormatter(string reportKey, PlayReportValueFormatter valueFormatter) + { + Analyses.Add(new PlayReportValueFormatterSpec + { + Priority = Analyses.Count, + ReportKey = reportKey, + ValueFormatter = valueFormatter + }); + return this; + } + + public PlayReportGameSpec AddValueFormatter(int priority, string reportKey, PlayReportValueFormatter valueFormatter) + { + Analyses.Add(new PlayReportValueFormatterSpec + { + Priority = priority, + ReportKey = reportKey, + ValueFormatter = valueFormatter + }); + return this; + } + } + + public struct PlayReportValue + { + public ApplicationMetadata Application { get; init; } + public object BoxedValue { get; init; } + } + + public struct PlayReportValueFormatterSpec + { + public required int Priority { get; init; } + public required string ReportKey { get; init; } + public required PlayReportValueFormatter ValueFormatter { get; init; } + } + + public delegate string PlayReportValueFormatter(ref PlayReportValue value); + + #endregion } From b2eecd28cea1d50dfc18e2cbfac0db80f4c11678 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 22:10:49 -0600 Subject: [PATCH 17/49] UI: RPC: Value Formatter V3 - Allows the ability to bind a single PlayReportGameSpec to multiple title IDs, like for MK8D - Allows the ability for the value formatters to tell the caller of the analyzer that they should reset the value, and also added the ability to explicitly not handle a value format. --- src/Ryujinx/DiscordIntegrationModule.cs | 18 ++++-- src/Ryujinx/Utilities/PlayReport.cs | 75 +++++++++++++++++-------- 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index 5561c1562..0e1f91869 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -134,13 +134,21 @@ namespace Ryujinx.Ava if (!TitleIDs.CurrentApplication.Value.HasValue) return; if (_discordPresencePlaying is null) return; - Optional details = PlayReport.Analyzer.Run(TitleIDs.CurrentApplication.Value, _currentApp, playReport); + PlayReportFormattedValue value = PlayReport.Analyzer.Run(TitleIDs.CurrentApplication.Value, _currentApp, playReport); - if (!details.HasValue) return; - - _discordPresencePlaying.Details = details; + if (!value.Handled) return; + + if (value.Reset) + { + _discordPresencePlaying.Details = $"Playing {_currentApp.Title}"; + Logger.Info?.Print(LogClass.UI, "Reset Discord RPC based on a supported play report value formatter."); + } + else + { + _discordPresencePlaying.Details = value.FormattedString; + Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); + } UpdatePlayingState(); - Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); } private static string TruncateToByteLength(string input) diff --git a/src/Ryujinx/Utilities/PlayReport.cs b/src/Ryujinx/Utilities/PlayReport.cs index 9665a1628..af56bae12 100644 --- a/src/Ryujinx/Utilities/PlayReport.cs +++ b/src/Ryujinx/Utilities/PlayReport.cs @@ -29,28 +29,24 @@ namespace Ryujinx.Ava.Utilities "010028600EBDA000", spec => spec.AddValueFormatter("mode", SuperMario3DWorldOrBowsersFury) ) - .AddSpec( // Mario Kart 8 Deluxe - "0100152000022000", - spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode) - ) - .AddSpec( // Mario Kart 8 Deluxe (China) - "010075100E8EC000", + .AddSpec( // Mario Kart 8 Deluxe, Mario Kart 8 Deluxe (China) + ["0100152000022000", "010075100E8EC000"], spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode) ); - private static string BreathOfTheWild_MasterMode(ref PlayReportValue value) - => value.BoxedValue is 1 ? "Playing Master Mode" : "Playing Normal Mode"; + private static PlayReportFormattedValue BreathOfTheWild_MasterMode(ref PlayReportValue value) + => value.BoxedValue is 1 ? "Playing Master Mode" : PlayReportFormattedValue.ForceReset; - private static string SuperMarioOdyssey_AssistMode(ref PlayReportValue value) + private static PlayReportFormattedValue SuperMarioOdyssey_AssistMode(ref PlayReportValue value) => value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; - private static string SuperMarioOdysseyChina_AssistMode(ref PlayReportValue value) + private static PlayReportFormattedValue SuperMarioOdysseyChina_AssistMode(ref PlayReportValue value) => value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; - private static string SuperMario3DWorldOrBowsersFury(ref PlayReportValue value) + private static PlayReportFormattedValue SuperMario3DWorldOrBowsersFury(ref PlayReportValue value) => value.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; - private static string MarioKart8Deluxe_Mode(ref PlayReportValue value) + private static PlayReportFormattedValue MarioKart8Deluxe_Mode(ref PlayReportValue value) => value.BoxedValue switch { // Single Player @@ -75,7 +71,7 @@ namespace Ryujinx.Ava.Utilities "Battle" => "Battle Mode", "RaceStart" => "Selecting a Course", "Race" => "Racing", - _ => $"Playing {value.Application.Title}" + _ => PlayReportFormattedValue.ForceReset }; } @@ -87,23 +83,35 @@ namespace Ryujinx.Ava.Utilities public PlayReportAnalyzer AddSpec(string titleId, Func transform) { - _specs.Add(transform(new PlayReportGameSpec { TitleIdStr = titleId })); + _specs.Add(transform(new PlayReportGameSpec { TitleIds = [titleId] })); return this; } public PlayReportAnalyzer AddSpec(string titleId, Action transform) { - _specs.Add(new PlayReportGameSpec { TitleIdStr = titleId }.Apply(transform)); + _specs.Add(new PlayReportGameSpec { TitleIds = [titleId] }.Apply(transform)); + return this; + } + + public PlayReportAnalyzer AddSpec(IEnumerable titleIds, Func transform) + { + _specs.Add(transform(new PlayReportGameSpec { TitleIds = [..titleIds] })); + return this; + } + + public PlayReportAnalyzer AddSpec(IEnumerable titleIds, Action transform) + { + _specs.Add(new PlayReportGameSpec { TitleIds = [..titleIds] }.Apply(transform)); return this; } - public Optional Run(string runningGameId, ApplicationMetadata appMeta, MessagePackObject playReport) + public PlayReportFormattedValue Run(string runningGameId, ApplicationMetadata appMeta, MessagePackObject playReport) { if (!playReport.IsDictionary) - return Optional.None; + return PlayReportFormattedValue.Unhandled; - if (!_specs.TryGetFirst(s => s.TitleIdStr.EqualsIgnoreCase(runningGameId), out PlayReportGameSpec spec)) - return Optional.None; + if (!_specs.TryGetFirst(s => runningGameId.EqualsAnyIgnoreCase(s.TitleIds), out PlayReportGameSpec spec)) + return PlayReportFormattedValue.Unhandled; foreach (PlayReportValueFormatterSpec formatSpec in spec.Analyses.OrderBy(x => x.Priority)) { @@ -119,14 +127,14 @@ namespace Ryujinx.Ava.Utilities return formatSpec.ValueFormatter(ref value); } - return Optional.None; + return PlayReportFormattedValue.Unhandled; } } public class PlayReportGameSpec { - public required string TitleIdStr { get; init; } + public required string[] TitleIds { get; init; } public List Analyses { get; } = []; public PlayReportGameSpec AddValueFormatter(string reportKey, PlayReportValueFormatter valueFormatter) @@ -158,14 +166,33 @@ namespace Ryujinx.Ava.Utilities public object BoxedValue { get; init; } } + public struct PlayReportFormattedValue + { + public bool Handled { get; private init; } + + public bool Reset { get; private init; } + + public string FormattedString { get; private init; } + + public static implicit operator PlayReportFormattedValue(string formattedValue) + => new() { Handled = true, FormattedString = formattedValue }; + + public static PlayReportFormattedValue Unhandled => default; + public static PlayReportFormattedValue ForceReset => new() { Handled = true, Reset = true }; + + public static PlayReportValueFormatter AlwaysResets = AlwaysResetsImpl; + + private static PlayReportFormattedValue AlwaysResetsImpl(ref PlayReportValue _) => ForceReset; + } + public struct PlayReportValueFormatterSpec { public required int Priority { get; init; } public required string ReportKey { get; init; } - public required PlayReportValueFormatter ValueFormatter { get; init; } + public PlayReportValueFormatter ValueFormatter { get; init; } } - public delegate string PlayReportValueFormatter(ref PlayReportValue value); - + public delegate PlayReportFormattedValue PlayReportValueFormatter(ref PlayReportValue value); + #endregion } From 55536f5d7815e82aa2032bf63878497d87c7e70c Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 22:14:43 -0600 Subject: [PATCH 18/49] misc: chore: Early exit HandlePlayReport if RPC is not enabled --- src/Ryujinx/DiscordIntegrationModule.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index 0e1f91869..f55eb8d66 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -131,6 +131,7 @@ namespace Ryujinx.Ava private static void HandlePlayReport(MessagePackObject playReport) { + if (_discordClient is null) return; if (!TitleIDs.CurrentApplication.Value.HasValue) return; if (_discordPresencePlaying is null) return; From 774edb7b29bf8ba44b0cea331977b37017ac6499 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 23:46:55 -0600 Subject: [PATCH 19/49] UI: Match System Time is now an active setting which you can toggle on/off. --- src/Ryujinx/AppHost.cs | 4 +- src/Ryujinx/Assets/locales.json | 40 +++++++++---------- .../UI/ViewModels/SettingsViewModel.cs | 24 ++++------- .../UI/Views/Settings/SettingsCPUView.axaml | 3 +- .../Views/Settings/SettingsGraphicsView.axaml | 3 +- .../Views/Settings/SettingsSystemView.axaml | 21 ++++++---- .../Settings/SettingsSystemView.axaml.cs | 2 - src/Ryujinx/UI/Windows/SettingsWindow.axaml | 3 +- .../Configuration/ConfigurationFileFormat.cs | 7 +++- .../ConfigurationState.Migration.cs | 3 +- .../Configuration/ConfigurationState.Model.cs | 7 ++++ 11 files changed, 64 insertions(+), 53 deletions(-) diff --git a/src/Ryujinx/AppHost.cs b/src/Ryujinx/AppHost.cs index d12795963..25f451858 100644 --- a/src/Ryujinx/AppHost.cs +++ b/src/Ryujinx/AppHost.cs @@ -938,7 +938,9 @@ namespace Ryujinx.Ava ConfigurationState.Instance.System.EnableInternetAccess, ConfigurationState.Instance.System.EnableFsIntegrityChecks ? IntegrityCheckLevel.ErrorOnInvalid : IntegrityCheckLevel.None, ConfigurationState.Instance.System.FsGlobalAccessLogMode, - ConfigurationState.Instance.System.SystemTimeOffset, + ConfigurationState.Instance.System.MatchSystemTime + ? 0 + : ConfigurationState.Instance.System.SystemTimeOffset, ConfigurationState.Instance.System.TimeZone, ConfigurationState.Instance.System.MemoryManagerMode, ConfigurationState.Instance.System.IgnoreMissingServices, diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index ec8a2aaac..3db41b963 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -4153,23 +4153,23 @@ "ar_SA": "", "de_DE": "", "el_GR": "", - "en_US": "Resync to PC Date & Time", + "en_US": "Match System Time", "es_ES": "", - "fr_FR": "Resynchronier la Date à celle du PC", + "fr_FR": "", "he_IL": "", - "it_IT": "Sincronizza data e ora con il PC", + "it_IT": "", "ja_JP": "", - "ko_KR": "PC 날짜와 시간에 동기화", - "no_NO": "Resynkroniser til PC-dato og -klokkeslett", + "ko_KR": "", + "no_NO": "", "pl_PL": "", "pt_BR": "", - "ru_RU": "Повторная синхронизация с датой и временем на компьютере", - "sv_SE": "Återsynka till datorns datum och tid", + "ru_RU": "", + "sv_SE": "", "th_TH": "", "tr_TR": "", - "uk_UA": "Синхронізувати з датою та часом ПК", - "zh_CN": "与 PC 日期和时间重新同步", - "zh_TW": "重新同步至 PC 的日期和時間" + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" } }, { @@ -15553,23 +15553,23 @@ "ar_SA": "", "de_DE": "", "el_GR": "", - "en_US": "Resync System Time to match your PC's current date & time.\n\nThis is not an active setting, it can still fall out of sync; in which case just click this button again.", + "en_US": "Sync System Time to match your PC's current date & time.", "es_ES": "", - "fr_FR": "Resynchronise la Date du Système pour qu'elle soit la même que celle du PC.\n\nCeci n'est pas un paramètrage automatique, la date peut se désynchroniser; dans ce cas là, rappuyer sur le boutton.", + "fr_FR": "Resynchronise la Date du Système pour qu'elle soit la même que celle du PC.", "he_IL": "", - "it_IT": "Sincronizza data e ora del sistema con quelle del PC.\n\nQuesta non è un'opzione attiva, perciò data e ora potrebbero tornare a non essere sincronizzate: in tal caso basterà cliccare nuovamente questo pulsante.", + "it_IT": "Sincronizza data e ora del sistema con quelle del PC.", "ja_JP": "", - "ko_KR": "시스템 시간을 PC의 현재 날짜 및 시간과 일치하도록 다시 동기화합니다.\n\n이 설정은 활성 설정이 아니므로 여전히 동기화되지 않을 수 있으며, 이 경우 이 버튼을 다시 클릭하면 됩니다.", - "no_NO": "Resynkroniser systemtiden slik at den samsvarer med PC-ens gjeldende dato og klokkeslett. \\Dette er ikke en aktiv innstilling, men den kan likevel komme ut av synkronisering; i så fall er det bare å klikke på denne knappen igjen.", + "ko_KR": "시스템 시간을 PC의 현재 날짜 및 시간과 일치하도록 다시 동기화합니다.", + "no_NO": "Resynkroniser systemtiden slik at den samsvarer med PC-ens gjeldende dato og klokkeslett.", "pl_PL": "", "pt_BR": "", - "ru_RU": "Повторно синхронизирует системное время, чтобы оно соответствовало текущей дате и времени вашего компьютера.\n\nЭто не активная настройка, она все еще может рассинхронизироваться; в этом случае просто нажмите эту кнопку еще раз.", - "sv_SE": "Återsynkronisera systemtiden för att matcha din dators aktuella datum och tid.\n\nDetta är inte en aktiv inställning och den kan tappa synken och om det händer så kan du klicka på denna knapp igen.", + "ru_RU": "Повторно синхронизирует системное время, чтобы оно соответствовало текущей дате и времени вашего компьютера.", + "sv_SE": "Återsynkronisera systemtiden för att matcha din dators aktuella datum och tid.", "th_TH": "", "tr_TR": "", - "uk_UA": "Синхронізувати системний час, щоб він відповідав поточній даті та часу вашого ПК.\n\nЦе не активне налаштування, тому синхронізація може збитися; у такому разі просто натискайте цю кнопку знову.", - "zh_CN": "重新同步系统时间以匹配您电脑的当前日期和时间。\n\n这个操作不会实时同步系统时间与电脑时间,时间仍然可能不同步;在这种情况下,只需再次单击此按钮即可。", - "zh_TW": "重新同步系統韌體時間至 PC 目前的日期和時間。\n\n這不是一個主動設定,它仍然可能會失去同步;在這種情況下,只需再次點擊此按鈕。" + "uk_UA": "Синхронізувати системний час, щоб він відповідав поточній даті та часу вашого ПК.", + "zh_CN": "重新同步系统时间以匹配您电脑的当前日期和时间。", + "zh_TW": "重新同步系統韌體時間至 PC 目前的日期和時間。" } }, { diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 488828482..d54313e76 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -116,10 +116,6 @@ namespace Ryujinx.Ava.UI.ViewModels public bool IsOpenGLAvailable => !OperatingSystem.IsMacOS(); - public bool IsAppleSiliconMac => OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64; - - public bool IsMacOS => OperatingSystem.IsMacOS(); - public bool EnableDiscordIntegration { get; set; } public bool CheckUpdatesOnStart { get; set; } public bool ShowConfirmExit { get; set; } @@ -201,7 +197,7 @@ namespace Ryujinx.Ava.UI.ViewModels public bool EnableTextureRecompression { get; set; } public bool EnableMacroHLE { get; set; } public bool EnableColorSpacePassthrough { get; set; } - public bool ColorSpacePassthroughAvailable => IsMacOS; + public bool ColorSpacePassthroughAvailable => RunningPlatform.IsMacOS; public bool EnableFileLog { get; set; } public bool EnableStub { get; set; } public bool EnableInfo { get; set; } @@ -297,6 +293,8 @@ namespace Ryujinx.Ava.UI.ViewModels } } + [ObservableProperty] private bool _matchSystemTime; + public DateTimeOffset CurrentDate { get; set; } public TimeSpan CurrentTime { get; set; } @@ -412,17 +410,6 @@ namespace Ryujinx.Ava.UI.ViewModels Dispatcher.UIThread.Post(() => OnPropertyChanged(nameof(PreferredGpuIndex))); } - public void MatchSystemTime() - { - (DateTimeOffset dto, TimeSpan timeOfDay) = DateTimeOffset.Now.Extract(); - - CurrentDate = dto; - CurrentTime = timeOfDay; - - OnPropertyChanged(nameof(CurrentDate)); - OnPropertyChanged(nameof(CurrentTime)); - } - public async Task LoadTimeZones() { _timeZoneContentManager = new TimeZoneContentManager(); @@ -524,7 +511,9 @@ namespace Ryujinx.Ava.UI.ViewModels CurrentDate = currentDateTime.Date; CurrentTime = currentDateTime.TimeOfDay; - EnableCustomVSyncInterval = config.Graphics.EnableCustomVSyncInterval.Value; + MatchSystemTime = config.System.MatchSystemTime; + + EnableCustomVSyncInterval = config.Graphics.EnableCustomVSyncInterval; CustomVSyncInterval = config.Graphics.CustomVSyncInterval; VSyncMode = config.Graphics.VSyncMode; EnableFsIntegrityChecks = config.System.EnableFsIntegrityChecks; @@ -629,6 +618,7 @@ namespace Ryujinx.Ava.UI.ViewModels config.System.TimeZone.Value = TimeZone; } + config.System.MatchSystemTime.Value = MatchSystemTime; config.System.SystemTimeOffset.Value = Convert.ToInt64((CurrentDate.ToUnixTimeSeconds() + CurrentTime.TotalSeconds) - DateTimeOffset.Now.ToUnixTimeSeconds()); config.System.EnableFsIntegrityChecks.Value = EnableFsIntegrityChecks; config.System.DramSize.Value = DramSize; diff --git a/src/Ryujinx/UI/Views/Settings/SettingsCPUView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsCPUView.axaml index 83f908a9c..62f087510 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsCPUView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsCPUView.axaml @@ -6,6 +6,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup" xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels" + xmlns:helper="clr-namespace:Ryujinx.Common.Helper;assembly=Ryujinx.Common" mc:Ignorable="d" x:DataType="viewModels:SettingsViewModel"> @@ -69,7 +70,7 @@ diff --git a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml index a2559f393..42515a4e9 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml @@ -8,6 +8,7 @@ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup" xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels" + xmlns:helper="clr-namespace:Ryujinx.Common.Helper;assembly=Ryujinx.Common" Design.Width="1000" mc:Ignorable="d" x:DataType="viewModels:SettingsViewModel"> @@ -48,7 +49,7 @@ - + diff --git a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml index d05f3b7bf..aa7144cf1 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml @@ -170,7 +170,8 @@ ToolTip.Tip="{ext:Locale TimeTooltip}" Width="250"/> @@ -181,17 +182,21 @@ - + Text="{ext:Locale SettingsTabSystemSystemTimeMatch}" + ToolTip.Tip="{ext:Locale MatchTimeTooltip}" + Width="250"/> + ViewModel.MatchSystemTime(); } } diff --git a/src/Ryujinx/UI/Windows/SettingsWindow.axaml b/src/Ryujinx/UI/Windows/SettingsWindow.axaml index 59302b6fc..7abf044ee 100644 --- a/src/Ryujinx/UI/Windows/SettingsWindow.axaml +++ b/src/Ryujinx/UI/Windows/SettingsWindow.axaml @@ -10,6 +10,7 @@ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels" xmlns:settings="clr-namespace:Ryujinx.Ava.UI.Views.Settings" xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers" + xmlns:helper="clr-namespace:Ryujinx.Common.Helper;assembly=Ryujinx.Common" Width="1100" Height="768" MinWidth="800" @@ -113,7 +114,7 @@ Spacing="10" Orientation="Horizontal" HorizontalAlignment="Right" - ReverseOrder="{Binding IsMacOS}"> + ReverseOrder="{x:Static helper:RunningPlatform.IsMacOS}"> appData = + ApplicationLibrary.Applications.Lookup(SelectedApplication.Id); + + return appData.HasValue && appData.Value.HasPlayabilityInfo; + } + } + public bool OpenUserSaveDirectoryEnabled => SelectedApplication.HasControlHolder && SelectedApplication.ControlHolder.Value.UserAccountSaveDataSize > 0; public bool OpenDeviceSaveDirectoryEnabled => SelectedApplication.HasControlHolder && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0; diff --git a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs index f6c43aade..a0bcd1aa2 100644 --- a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs +++ b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs @@ -50,7 +50,7 @@ namespace Ryujinx.Ava.UI.Views.Main UninstallFileTypesMenuItem.Command = Commands.Create(UninstallFileTypes); XciTrimmerMenuItem.Command = Commands.Create(XCITrimmerWindow.Show); AboutWindowMenuItem.Command = Commands.Create(AboutWindow.Show); - CompatibilityListMenuItem.Command = Commands.Create(CompatibilityList.Show); + CompatibilityListMenuItem.Command = Commands.Create(() => CompatibilityList.Show()); UpdateMenuItem.Command = Commands.Create(async () => { diff --git a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs index dec265623..ee86a4a33 100644 --- a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs +++ b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs @@ -135,6 +135,14 @@ namespace Ryujinx.Ava.Utilities.AppLibrary return id.ToString("X16"); } + public bool FindApplication(ulong id, out ApplicationData foundData) + { + DynamicData.Kernel.Optional appData = Applications.Lookup(id); + foundData = appData.HasValue ? appData.Value : null; + + return appData.HasValue; + } + /// The configured key set is missing a key. /// The NCA header could not be decrypted. /// The NCA version is not supported. diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityCsv.cs b/src/Ryujinx/Utilities/Compat/CompatibilityCsv.cs index d0e251fe0..c3fcf99ca 100644 --- a/src/Ryujinx/Utilities/Compat/CompatibilityCsv.cs +++ b/src/Ryujinx/Utilities/Compat/CompatibilityCsv.cs @@ -113,20 +113,17 @@ namespace Ryujinx.Ava.Utilities.Compat .Select(FormatLabelName) .JoinToString(", "); - public override string ToString() - { - StringBuilder sb = new("CompatibilityEntry: {"); - sb.Append($"{nameof(GameName)}=\"{GameName}\", "); - sb.Append($"{nameof(TitleId)}={TitleId}, "); - sb.Append($"{nameof(Labels)}={ - Labels.FormatCollection(it => $"\"{it}\"", separator: ", ", prefix: "[", suffix: "]") - }, "); - sb.Append($"{nameof(Status)}=\"{Status}\", "); - sb.Append($"{nameof(LastUpdated)}=\"{LastUpdated}\""); - sb.Append('}'); - - return sb.ToString(); - } + public override string ToString() => + new StringBuilder("CompatibilityEntry: {") + .Append($"{nameof(GameName)}=\"{GameName}\", ") + .Append($"{nameof(TitleId)}={TitleId}, ") + .Append($"{nameof(Labels)}={ + Labels.FormatCollection(it => $"\"{it}\"", separator: ", ", prefix: "[", suffix: "]") + }, ") + .Append($"{nameof(Status)}=\"{Status}\", ") + .Append($"{nameof(LastUpdated)}=\"{LastUpdated}\"") + .Append('}') + .ToString(); public static string FormatLabelName(string labelName) => labelName.ToLower() switch { diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml index 73ec84c53..132b10e26 100644 --- a/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml +++ b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml @@ -34,7 +34,7 @@ Text="{ext:Locale CompatibilityListWarning}" /> - + diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml.cs b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml.cs index e0d3b0c56..30d2649bc 100644 --- a/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml.cs +++ b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml.cs @@ -9,7 +9,7 @@ namespace Ryujinx.Ava.Utilities.Compat { public partial class CompatibilityList : UserControl { - public static async Task Show() + public static async Task Show(string titleId = null) { ContentDialog contentDialog = new() { @@ -18,7 +18,10 @@ namespace Ryujinx.Ava.Utilities.Compat CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose], Content = new CompatibilityList { - DataContext = new CompatibilityViewModel(RyujinxApp.MainWindow.ViewModel.ApplicationLibrary) + DataContext = new CompatibilityViewModel(RyujinxApp.MainWindow.ViewModel.ApplicationLibrary), + SearchBox = { + Text = titleId ?? "" + } } }; From fafb99c702a83a294838359535100f5883b86822 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 15:57:32 -0600 Subject: [PATCH 37/49] misc: chore: [ci skip] don't even bother looking up the application; the tag present on the control *is* a valid title ID and can't reasonably change in between the tag being set and playability information being requested. Even if it does, worst case scenario the compat list that pops up has no results. --- src/Ryujinx/UI/Controls/ApplicationListView.axaml.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Ryujinx/UI/Controls/ApplicationListView.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationListView.axaml.cs index 95fc911d0..7c6b0cf15 100644 --- a/src/Ryujinx/UI/Controls/ApplicationListView.axaml.cs +++ b/src/Ryujinx/UI/Controls/ApplicationListView.axaml.cs @@ -39,13 +39,7 @@ namespace Ryujinx.Ava.UI.Controls if (sender is not Button { Content: TextBlock playabilityLabel }) return; - if (!ulong.TryParse((string)playabilityLabel.Tag, NumberStyles.HexNumber, null, out ulong titleId)) - return; - - if (!mwvm.ApplicationLibrary.FindApplication(titleId, out ApplicationData appData)) - return; - - await CompatibilityList.Show(appData.IdString); + await CompatibilityList.Show((string)playabilityLabel.Tag); } private async void IdString_OnClick(object sender, RoutedEventArgs e) From e8a7d5b0b74d1d5ad2ba09cc7c07f6ba333972d3 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 17:21:54 -0600 Subject: [PATCH 38/49] UI: Only show DLC RomFS button under Extract Data when DLCs are available. Also convert the constructor of DlcSelectViewModel to expect a normal title id and not one already converted to the base ID. --- .../UI/Controls/ApplicationContextMenu.axaml | 1 + .../Controls/ApplicationContextMenu.axaml.cs | 2 +- .../UI/ViewModels/DlcSelectViewModel.cs | 4 +--- .../UI/ViewModels/MainWindowViewModel.cs | 2 ++ .../Utilities/AppLibrary/ApplicationLibrary.cs | 18 ++++++++++++++++++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml index 797bc27e0..2804485fe 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml @@ -117,6 +117,7 @@ Header="{ext:Locale GameListContextMenuExtractDataRomFS}" ToolTip.Tip="{ext:Locale GameListContextMenuExtractDataRomFSToolTip}" /> diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs index f29f70432..0d81484ba 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml.cs @@ -334,7 +334,7 @@ namespace Ryujinx.Ava.UI.Controls if (sender is not MenuItem { DataContext: MainWindowViewModel { SelectedApplication: not null } viewModel }) return; - DownloadableContentModel selectedDlc = await DlcSelectView.Show(viewModel.SelectedApplication.IdBase, viewModel.ApplicationLibrary); + DownloadableContentModel selectedDlc = await DlcSelectView.Show(viewModel.SelectedApplication.Id, viewModel.ApplicationLibrary); if (selectedDlc is not null) { diff --git a/src/Ryujinx/UI/ViewModels/DlcSelectViewModel.cs b/src/Ryujinx/UI/ViewModels/DlcSelectViewModel.cs index d50d8249a..b486aa766 100644 --- a/src/Ryujinx/UI/ViewModels/DlcSelectViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/DlcSelectViewModel.cs @@ -14,9 +14,7 @@ namespace Ryujinx.Ava.UI.ViewModels public DlcSelectViewModel(ulong titleId, ApplicationLibrary appLibrary) { - _dlcs = appLibrary.DownloadableContents.Items - .Where(x => x.Dlc.TitleIdBase == titleId) - .Select(x => x.Dlc) + _dlcs = appLibrary.FindDlcsFor(titleId) .OrderBy(it => it.IsBundled ? 0 : 1) .ThenBy(it => it.TitleId) .ToArray(); diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index f0e05d517..632e3b4f0 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -360,6 +360,8 @@ namespace Ryujinx.Ava.UI.ViewModels } } + public bool HasDlc => ApplicationLibrary.HasDlcs(SelectedApplication.Id); + public bool OpenUserSaveDirectoryEnabled => SelectedApplication.HasControlHolder && SelectedApplication.ControlHolder.Value.UserAccountSaveDataSize > 0; public bool OpenDeviceSaveDirectoryEnabled => SelectedApplication.HasControlHolder && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0; diff --git a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs index ee86a4a33..75737c3e5 100644 --- a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs +++ b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs @@ -142,6 +142,24 @@ namespace Ryujinx.Ava.Utilities.AppLibrary return appData.HasValue; } + + public bool FindUpdate(ulong id, out TitleUpdateModel foundData) + { + Gommon.Optional appData = + TitleUpdates.Keys.FindFirst(x => x.TitleId == id); + foundData = appData.HasValue ? appData.Value : null; + + return appData.HasValue; + } + + public TitleUpdateModel[] FindUpdatesFor(ulong id) + => TitleUpdates.Keys.Where(x => x.TitleIdBase == (id & ~0x1FFFUL)).ToArray(); + + public DownloadableContentModel[] FindDlcsFor(ulong id) + => DownloadableContents.Keys.Where(x => x.TitleIdBase == (id & ~0x1FFFUL)).ToArray(); + + public bool HasDlcs(ulong id) + => DownloadableContents.Keys.Any(x => x.TitleIdBase == (id & ~0x1FFFUL)); /// The configured key set is missing a key. /// The NCA header could not be decrypted. From 820e8f73750b7348f73b69f38b033ccb8d87adff Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 18:10:28 -0600 Subject: [PATCH 39/49] [ci skip] UI: Strip dumped file information out of the DLC name --- .../Utilities/AppLibrary/ApplicationLibrary.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs index 75737c3e5..9571394fe 100644 --- a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs +++ b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs @@ -128,11 +128,16 @@ namespace Ryujinx.Ava.Utilities.AppLibrary DynamicData.Kernel.Optional appData = Applications.Lookup(id); if (appData.HasValue) return appData.Value.Name; - - if (DownloadableContents.Keys.FindFirst(x => x.TitleId == id).TryGet(out DownloadableContentModel dlcData)) - return Path.GetFileNameWithoutExtension(dlcData.FileName); - return id.ToString("X16"); + if (!DownloadableContents.Keys.FindFirst(x => x.TitleId == id).TryGet(out DownloadableContentModel dlcData)) + return id.ToString("X16"); + + string name = Path.GetFileNameWithoutExtension(dlcData.FileName)!; + int idx = name.IndexOf('['); + if (idx != -1) + name = name[..idx]; + + return name; } public bool FindApplication(ulong id, out ApplicationData foundData) From b0fcc5bee1674c075c125ccb65773db8b3c466fe Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 18:21:24 -0600 Subject: [PATCH 40/49] misc: chore: Simplify HasCompatibilityEntry (Totally didn't realize that SelectedApplication is already an ApplicationData) --- src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index 632e3b4f0..d7a09a0e3 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -349,16 +349,7 @@ namespace Ryujinx.Ava.UI.ViewModels } } - public bool HasCompatibilityEntry - { - get - { - DynamicData.Kernel.Optional appData = - ApplicationLibrary.Applications.Lookup(SelectedApplication.Id); - - return appData.HasValue && appData.Value.HasPlayabilityInfo; - } - } + public bool HasCompatibilityEntry => SelectedApplication.HasPlayabilityInfo; public bool HasDlc => ApplicationLibrary.HasDlcs(SelectedApplication.Id); From 222ceb818b9f5c49f854762a9b544b349a9a6ea0 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 18:21:49 -0600 Subject: [PATCH 41/49] misc: chore: Use ApplicationLibrary helpers for getting DLCs & Updates for a game --- .../UI/ViewModels/DownloadableContentManagerViewModel.cs | 3 +-- src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs | 3 +-- src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs | 6 ++++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs index 1533b7d5d..a16a06ff5 100644 --- a/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/DownloadableContentManagerViewModel.cs @@ -69,8 +69,7 @@ namespace Ryujinx.Ava.UI.ViewModels private void LoadDownloadableContents() { - IEnumerable<(DownloadableContentModel Dlc, bool IsEnabled)> dlcs = _applicationLibrary.DownloadableContents.Items - .Where(it => it.Dlc.TitleIdBase == _applicationData.IdBase); + (DownloadableContentModel Dlc, bool IsEnabled)[] dlcs = _applicationLibrary.FindDlcConfigurationFor(_applicationData.Id); bool hasBundledContent = false; foreach ((DownloadableContentModel dlc, bool isEnabled) in dlcs) diff --git a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs index aaafc3913..2b88aceed 100644 --- a/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/TitleUpdateViewModel.cs @@ -41,8 +41,7 @@ namespace Ryujinx.Ava.UI.ViewModels private void LoadUpdates() { - IEnumerable<(TitleUpdateModel TitleUpdate, bool IsSelected)> updates = ApplicationLibrary.TitleUpdates.Items - .Where(it => it.TitleUpdate.TitleIdBase == ApplicationData.IdBase); + (TitleUpdateModel TitleUpdate, bool IsSelected)[] updates = ApplicationLibrary.FindUpdateConfigurationFor(ApplicationData.Id); bool hasBundledContent = false; SelectedUpdate = new TitleUpdateViewModelNoUpdate(); diff --git a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs index 9571394fe..79cac1a0e 100644 --- a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs +++ b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs @@ -160,9 +160,15 @@ namespace Ryujinx.Ava.Utilities.AppLibrary public TitleUpdateModel[] FindUpdatesFor(ulong id) => TitleUpdates.Keys.Where(x => x.TitleIdBase == (id & ~0x1FFFUL)).ToArray(); + public (TitleUpdateModel TitleUpdate, bool IsSelected)[] FindUpdateConfigurationFor(ulong id) + => TitleUpdates.Items.Where(x => x.TitleUpdate.TitleIdBase == (id & ~0x1FFFUL)).ToArray(); + public DownloadableContentModel[] FindDlcsFor(ulong id) => DownloadableContents.Keys.Where(x => x.TitleIdBase == (id & ~0x1FFFUL)).ToArray(); + public (DownloadableContentModel Dlc, bool IsEnabled)[] FindDlcConfigurationFor(ulong id) + => DownloadableContents.Items.Where(x => x.Dlc.TitleIdBase == (id & ~0x1FFFUL)).ToArray(); + public bool HasDlcs(ulong id) => DownloadableContents.Keys.Any(x => x.TitleIdBase == (id & ~0x1FFFUL)); From 1972a47f39014e8df9bcfcf3a61ca7d27eaaa030 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 19:32:17 -0600 Subject: [PATCH 42/49] UI: Game stats button on right click for Grid view users --- src/Ryujinx/Assets/locales.json | 50 ++++++++ src/Ryujinx/RyujinxApp.axaml.cs | 3 + .../UI/Controls/ApplicationContextMenu.axaml | 6 + .../Controls/ApplicationContextMenu.axaml.cs | 12 +- .../UI/Controls/ApplicationDataView.axaml | 116 ++++++++++++++++++ .../UI/Controls/ApplicationDataView.axaml.cs | 84 +++++++++++++ 6 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 src/Ryujinx/UI/Controls/ApplicationDataView.axaml create mode 100644 src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index 3da0b1728..9f9053ae0 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -2572,6 +2572,56 @@ "zh_TW": "" } }, + { + "ID": "GameListContextMenuShowGameData", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Show Game Stats", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "GameListContextMenuShowGameDataToolTip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Show the other various information about the currently selected game that is missing from the Grid view layout.", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, { "ID": "GameListContextMenuOpenModsDirectory", "Translations": { diff --git a/src/Ryujinx/RyujinxApp.axaml.cs b/src/Ryujinx/RyujinxApp.axaml.cs index be24315f6..32318776a 100644 --- a/src/Ryujinx/RyujinxApp.axaml.cs +++ b/src/Ryujinx/RyujinxApp.axaml.cs @@ -32,6 +32,9 @@ namespace Ryujinx.Ava public static MainWindow MainWindow => Current! .ApplicationLifetime.Cast() .MainWindow.Cast(); + + public static IClassicDesktopStyleApplicationLifetime AppLifetime => Current! + .ApplicationLifetime.Cast(); public static bool IsClipboardAvailable(out IClipboard clipboard) { diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml index 2804485fe..acade1df9 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml @@ -25,6 +25,12 @@ Header="{ext:Locale GameListContextMenuShowCompatEntry}" Icon="{ext:Icon mdi-gamepad}" ToolTip.Tip="{ext:Locale GameListContextMenuShowCompatEntryToolTip}"/> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs new file mode 100644 index 000000000..0bd22a243 --- /dev/null +++ b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs @@ -0,0 +1,84 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Styling; +using FluentAvalonia.UI.Controls; +using Ryujinx.Ava; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Controls; +using Ryujinx.Ava.UI.Helpers; +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.Ava.UI.Windows; +using Ryujinx.Ava.Utilities.AppLibrary; +using Ryujinx.Ava.Utilities.Compat; +using System.Linq; +using System.Threading.Tasks; + +namespace Ryujinx.Ava.UI.Controls +{ + public partial class ApplicationDataView : UserControl + { + public static async Task Show(ApplicationData appData) + { + ContentDialog contentDialog = new() + { + PrimaryButtonText = string.Empty, + SecondaryButtonText = string.Empty, + CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose], + Content = new ApplicationDataView { DataContext = appData } + }; + + Style closeButton = new(x => x.Name("CloseButton")); + closeButton.Setters.Add(new Setter(WidthProperty, 160d)); + + Style closeButtonParent = new(x => x.Name("CommandSpace")); + closeButtonParent.Setters.Add(new Setter(HorizontalAlignmentProperty, + Avalonia.Layout.HorizontalAlignment.Center)); + + contentDialog.Styles.Add(closeButton); + contentDialog.Styles.Add(closeButtonParent); + + await ContentDialogHelper.ShowAsync(contentDialog); + } + + public ApplicationDataView() + { + InitializeComponent(); + } + + private async void PlayabilityStatus_OnClick(object sender, RoutedEventArgs e) + { + if (sender is not Button { Content: TextBlock playabilityLabel }) + return; + + if (RyujinxApp.AppLifetime.Windows.TryGetFirst(x => x is ContentDialogOverlayWindow, out Window window)) + window.Close(ContentDialogResult.None); + + await CompatibilityList.Show((string)playabilityLabel.Tag); + } + + private async void IdString_OnClick(object sender, RoutedEventArgs e) + { + if (DataContext is not MainWindowViewModel mwvm) + return; + + if (sender is not Button { Content: TextBlock idText }) + return; + + if (!RyujinxApp.IsClipboardAvailable(out IClipboard clipboard)) + return; + + ApplicationData appData = mwvm.Applications.FirstOrDefault(it => it.IdString == idText.Text); + if (appData is null) + return; + + await clipboard.SetTextAsync(appData.IdString); + + NotificationHelper.ShowInformation( + "Copied Title ID", + $"{appData.Name} ({appData.IdString})"); + } + } +} + From bd08a111a8829a3e958f606890b2ad4b4642fc6b Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 22:47:12 -0600 Subject: [PATCH 43/49] UI: Show what each value is in the Game Info dialog, add game icon --- src/Ryujinx/Assets/locales.json | 6 +- .../UI/Controls/ApplicationContextMenu.axaml | 1 - .../UI/Controls/ApplicationDataView.axaml | 212 +++++++++--------- .../UI/Controls/ApplicationDataView.axaml.cs | 3 +- .../UI/Controls/ApplicationListView.axaml | 1 + .../Converters/MultiplayerInfoConverter.cs | 7 +- .../UI/ViewModels/ApplicationDataViewModel.cs | 34 +++ .../Utilities/AppLibrary/ApplicationData.cs | 3 + 8 files changed, 155 insertions(+), 112 deletions(-) create mode 100644 src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index 9f9053ae0..d597e8a4c 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -2578,7 +2578,7 @@ "ar_SA": "", "de_DE": "", "el_GR": "", - "en_US": "Show Game Stats", + "en_US": "Show Game Info", "es_ES": "", "fr_FR": "", "he_IL": "", @@ -2603,7 +2603,7 @@ "ar_SA": "", "de_DE": "", "el_GR": "", - "en_US": "Show the other various information about the currently selected game that is missing from the Grid view layout.", + "en_US": "Show stats & details about the currently selected game.", "es_ES": "", "fr_FR": "", "he_IL": "", @@ -23298,4 +23298,4 @@ } } ] -} \ No newline at end of file +} diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml index acade1df9..3e47a1910 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml @@ -26,7 +26,6 @@ Icon="{ext:Icon mdi-gamepad}" ToolTip.Tip="{ext:Locale GameListContextMenuShowCompatEntryToolTip}"/> - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + diff --git a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs index 0bd22a243..cc8091d4d 100644 --- a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs +++ b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs @@ -26,7 +26,8 @@ namespace Ryujinx.Ava.UI.Controls PrimaryButtonText = string.Empty, SecondaryButtonText = string.Empty, CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose], - Content = new ApplicationDataView { DataContext = appData } + MinWidth = 256, + Content = new ApplicationDataView { DataContext = new ApplicationDataViewModel(appData) } }; Style closeButton = new(x => x.Name("CloseButton")); diff --git a/src/Ryujinx/UI/Controls/ApplicationListView.axaml b/src/Ryujinx/UI/Controls/ApplicationListView.axaml index 151bf5b32..c01c4e8be 100644 --- a/src/Ryujinx/UI/Controls/ApplicationListView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationListView.axaml @@ -140,6 +140,7 @@ TextWrapping="Wrap" /> diff --git a/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs b/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs index 47d0b94d0..dc36098a1 100644 --- a/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs +++ b/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs @@ -12,12 +12,9 @@ namespace Ryujinx.Ava.UI.Helpers public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - if (value is ApplicationData applicationData) + if (value is ApplicationData { HasLdnGames: true } applicationData) { - if (applicationData.PlayerCount != 0 && applicationData.GameCount != 0) - { - return $"Hosted Games: {applicationData.GameCount}\nOnline Players: {applicationData.PlayerCount}"; - } + return $"Hosted Games: {applicationData.GameCount}\nOnline Players: {applicationData.PlayerCount}"; } return ""; diff --git a/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs b/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs new file mode 100644 index 000000000..73d555389 --- /dev/null +++ b/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs @@ -0,0 +1,34 @@ +using Gommon; +using Ryujinx.Ava.Utilities.AppLibrary; + +namespace Ryujinx.Ava.UI.ViewModels +{ + public class ApplicationDataViewModel : BaseModel + { + private const string FormatVersion = "Current Version: {0}"; + private const string FormatDeveloper = "Developed by {0}"; + + private const string FormatExtension = "Game type: {0}"; + private const string FormatLastPlayed = "Last played: {0}"; + private const string FormatPlayTime = "Play time: {0}"; + private const string FormatSize = "Size: {0}"; + + private const string FormatHostedGames = "Hosted Games: {0}"; + private const string FormatPlayerCount = "Online Players: {0}"; + + public ApplicationData AppData { get; } + + public ApplicationDataViewModel(ApplicationData appData) => AppData = appData; + + public string FormattedVersion => FormatVersion.Format(AppData.Version); + public string FormattedDeveloper => FormatDeveloper.Format(AppData.Developer); + + public string FormattedFileExtension => FormatExtension.Format(AppData.FileExtension); + public string FormattedLastPlayed => FormatLastPlayed.Format(AppData.LastPlayedString); + public string FormattedPlayTime => FormatPlayTime.Format(AppData.TimePlayedString); + public string FormattedFileSize => FormatSize.Format(AppData.FileSizeString); + + public string FormattedLdnInfo => + $"{FormatHostedGames.Format(AppData.GameCount)}\n{FormatPlayerCount.Format(AppData.PlayerCount)}"; + } +} diff --git a/src/Ryujinx/Utilities/AppLibrary/ApplicationData.cs b/src/Ryujinx/Utilities/AppLibrary/ApplicationData.cs index aef54819e..48e30e663 100644 --- a/src/Ryujinx/Utilities/AppLibrary/ApplicationData.cs +++ b/src/Ryujinx/Utilities/AppLibrary/ApplicationData.cs @@ -49,6 +49,9 @@ namespace Ryujinx.Ava.Utilities.AppLibrary public int PlayerCount { get; set; } public int GameCount { get; set; } + + public bool HasLdnGames => PlayerCount != 0 && GameCount != 0; + public TimeSpan TimePlayed { get; set; } public DateTime? LastPlayed { get; set; } public string FileExtension { get; set; } From 717851985e352b87934ab74739d601ea30c34758 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 23:28:37 -0600 Subject: [PATCH 44/49] UI: Reorganize Game Info dialog popup + localization --- src/Ryujinx/Assets/locales.json | 336 ++++++++++++------ .../UI/Controls/ApplicationDataView.axaml | 186 +++++----- .../UI/Controls/ApplicationDataView.axaml.cs | 1 + .../UI/ViewModels/ApplicationDataViewModel.cs | 28 +- 4 files changed, 317 insertions(+), 234 deletions(-) diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index d597e8a4c..cb222e935 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -1525,151 +1525,151 @@ { "ID": "GameListHeaderDeveloper", "Translations": { - "ar_SA": "المطور", - "de_DE": "Entwickler", - "el_GR": "Προγραμματιστής", - "en_US": "Developer", - "es_ES": "Desarrollador", - "fr_FR": "Développeur", - "he_IL": "מפתח", - "it_IT": "Sviluppatore", - "ja_JP": "開発元", - "ko_KR": "개발자", - "no_NO": "Utvikler", - "pl_PL": "Twórca", - "pt_BR": "Desenvolvedor", - "ru_RU": "Разработчик", - "sv_SE": "Utvecklare", - "th_TH": "ผู้พัฒนา", - "tr_TR": "Geliştirici", - "uk_UA": "Розробник", - "zh_CN": "制作商", - "zh_TW": "開發者" + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Developed by {0}", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" } }, { "ID": "GameListHeaderVersion", "Translations": { - "ar_SA": "الإصدار", + "ar_SA": "", "de_DE": "", - "el_GR": "Έκδοση", - "en_US": "Version", - "es_ES": "Versión", + "el_GR": "Έκδοση: {0}", + "en_US": "Version: {0}", + "es_ES": "Versión: {0}", "fr_FR": "", - "he_IL": "גרסה", - "it_IT": "Versione", - "ja_JP": "バージョン", - "ko_KR": "버전", - "no_NO": "Versjon", - "pl_PL": "Wersja", - "pt_BR": "Versão", - "ru_RU": "Версия", + "he_IL": "", + "it_IT": "Versione: {0}", + "ja_JP": "バージョン: {0}", + "ko_KR": "버전: {0}", + "no_NO": "Versjon: {0}", + "pl_PL": "Wersja: {0}", + "pt_BR": "Versão: {0}", + "ru_RU": "Версия: {0}", "sv_SE": "", - "th_TH": "เวอร์ชั่น", - "tr_TR": "Sürüm", - "uk_UA": "Версія", - "zh_CN": "版本", - "zh_TW": "版本" + "th_TH": "เวอร์ชั่น: {0}", + "tr_TR": "Sürüm: {0}", + "uk_UA": "Версія: {0}", + "zh_CN": "版本: {0}", + "zh_TW": "版本: {0}" } }, { "ID": "GameListHeaderTimePlayed", "Translations": { - "ar_SA": "وقت اللعب", - "de_DE": "Spielzeit", - "el_GR": "Χρόνος", - "en_US": "Play Time", - "es_ES": "Tiempo jugado", - "fr_FR": "Temps de jeu", - "he_IL": "זמן משחק", - "it_IT": "Tempo di gioco", - "ja_JP": "プレイ時間", - "ko_KR": "플레이 타임", - "no_NO": "Spilletid", - "pl_PL": "Czas w grze:", - "pt_BR": "Tempo de jogo", - "ru_RU": "Время в игре", - "sv_SE": "Speltid", - "th_TH": "เล่นไปแล้ว", - "tr_TR": "Oynama Süresi", - "uk_UA": "Зіграно часу", - "zh_CN": "游玩时长", - "zh_TW": "遊玩時數" + "ar_SA": "", + "de_DE": "Spielzeit: {0}", + "el_GR": "Χρόνος: {0}", + "en_US": "Play Time: {0}", + "es_ES": "Tiempo jugado: {0}", + "fr_FR": "Temps de jeu: {0}", + "he_IL": "", + "it_IT": "Tempo di gioco: {0}", + "ja_JP": "プレイ時間: {0}", + "ko_KR": "플레이 타임: {0}", + "no_NO": "Spilletid: {0}", + "pl_PL": "Czas w grze: {0}", + "pt_BR": "Tempo de jogo: {0}", + "ru_RU": "Время в игре: {0}", + "sv_SE": "Speltid: {0}", + "th_TH": "เล่นไปแล้ว: {0}", + "tr_TR": "Oynama Süresi: {0}", + "uk_UA": "Зіграно часу: {0}", + "zh_CN": "游玩时长: {0}", + "zh_TW": "遊玩時數: {0}" } }, { "ID": "GameListHeaderLastPlayed", "Translations": { - "ar_SA": "آخر مرة لُعبت", - "de_DE": "Zuletzt gespielt", - "el_GR": "Παίχτηκε", - "en_US": "Last Played", - "es_ES": "Jugado por última vez", - "fr_FR": "Dernière partie jouée", - "he_IL": "שוחק לאחרונה", - "it_IT": "Ultima partita", - "ja_JP": "最終プレイ日時", - "ko_KR": "마지막 플레이", - "no_NO": "Sist Spilt", - "pl_PL": "Ostatnio grane", - "pt_BR": "Último jogo", - "ru_RU": "Последний запуск", - "sv_SE": "Senast spelad", - "th_TH": "เล่นล่าสุด", - "tr_TR": "Son Oynama Tarihi", - "uk_UA": "Востаннє зіграно", - "zh_CN": "最近游玩", - "zh_TW": "最近遊玩" + "ar_SA": "", + "de_DE": "Zuletzt gespielt: {0}", + "el_GR": "Παίχτηκε: {0}", + "en_US": "Last Played: {0}", + "es_ES": "Jugado por última vez: {0}", + "fr_FR": "Dernière partie jouée: {0}", + "he_IL": "", + "it_IT": "Ultima partita: {0}", + "ja_JP": "最終プレイ日時: {0}", + "ko_KR": "마지막 플레이: {0}", + "no_NO": "Sist Spilt: {0}", + "pl_PL": "Ostatnio grane: {0}", + "pt_BR": "Último jogo: {0}", + "ru_RU": "Последний запуск: {0}", + "sv_SE": "Senast spelad: {0}", + "th_TH": "เล่นล่าสุด: {0}", + "tr_TR": "Son Oynama Tarihi: {0}", + "uk_UA": "Востаннє зіграно: {0}", + "zh_CN": "最近游玩: {0}", + "zh_TW": "最近遊玩: {0}" } }, { "ID": "GameListHeaderFileExtension", "Translations": { - "ar_SA": "صيغة الملف", - "de_DE": "Dateiformat", - "el_GR": "Κατάληξη", - "en_US": "File Ext", - "es_ES": "Extensión", - "fr_FR": "Extension du Fichier", - "he_IL": "סיומת קובץ", - "it_IT": "Estensione", - "ja_JP": "ファイル拡張子", - "ko_KR": "파일 확장자", - "no_NO": "Fil Eks.", - "pl_PL": "Rozszerzenie pliku", - "pt_BR": "Extensão", - "ru_RU": "Расширение файла", - "sv_SE": "Filänd", - "th_TH": "นามสกุลไฟล์", - "tr_TR": "Dosya Uzantısı", - "uk_UA": "Розширення файлу", - "zh_CN": "扩展名", - "zh_TW": "副檔名" + "ar_SA": "", + "de_DE": "Dateiformat: {0}", + "el_GR": "Κατάληξη: {0}", + "en_US": "Extension: {0}", + "es_ES": "Extensión: {0}", + "fr_FR": "Extension du Fichier: {0}", + "he_IL": "", + "it_IT": "Estensione: {0}", + "ja_JP": "ファイル拡張子: {0}", + "ko_KR": "파일 확장자: {0}", + "no_NO": "Fil Eks.: {0}", + "pl_PL": "Rozszerzenie pliku: {0}", + "pt_BR": "Extensão: {0}", + "ru_RU": "Расширение файла: {0}", + "sv_SE": "Filänd: {0}", + "th_TH": "นามสกุลไฟล์: {0}", + "tr_TR": "Dosya Uzantısı: {0}", + "uk_UA": "Розширення файлу: {0}", + "zh_CN": "扩展名: {0}", + "zh_TW": "副檔名: {0}" } }, { "ID": "GameListHeaderFileSize", "Translations": { - "ar_SA": "حجم الملف", - "de_DE": "Dateigröße", - "el_GR": "Μέγεθος Αρχείου", - "en_US": "File Size", - "es_ES": "Tamaño del archivo", - "fr_FR": "Taille du Fichier", - "he_IL": "גודל הקובץ", - "it_IT": "Dimensione file", - "ja_JP": "ファイルサイズ", - "ko_KR": "파일 크기", - "no_NO": "Fil Størrelse", - "pl_PL": "Rozmiar pliku", - "pt_BR": "Tamanho", - "ru_RU": "Размер файла", - "sv_SE": "Filstorlek", - "th_TH": "ขนาดไฟล์", - "tr_TR": "Dosya Boyutu", - "uk_UA": "Розмір файлу", - "zh_CN": "大小", - "zh_TW": "檔案大小" + "ar_SA": "", + "de_DE": "Dateigröße: {0}", + "el_GR": "Μέγεθος Αρχείου: {0}", + "en_US": "File Size: {0}", + "es_ES": "Tamaño del archivo: {0}", + "fr_FR": "Taille du Fichier: {0}", + "he_IL": "", + "it_IT": "Dimensione file: {0}", + "ja_JP": "ファイルサイズ: {0}", + "ko_KR": "파일 크기: {0}", + "no_NO": "Fil Størrelse: {0}", + "pl_PL": "Rozmiar pliku: {0}", + "pt_BR": "Tamanho: {0}", + "ru_RU": "Размер файла: {0}", + "sv_SE": "Filstorlek: {0}", + "th_TH": "ขนาดไฟล์: {0}", + "tr_TR": "Dosya Boyutu: {0}", + "uk_UA": "Розмір файлу: {0}", + "zh_CN": "大小: {0}", + "zh_TW": "檔案大小: {0}" } }, { @@ -1697,6 +1697,106 @@ "zh_TW": "路徑" } }, + { + "ID": "GameListHeaderCompatibilityStatus", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Compatibility:", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "GameListHeaderTitleId", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Title ID:", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "GameListHeaderHostedGames", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Hosted Games: {0}", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "GameListHeaderPlayerCount", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Online Players: {0}", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, { "ID": "GameListContextMenuOpenUserSaveDirectory", "Translations": { @@ -23298,4 +23398,4 @@ } } ] -} +} \ No newline at end of file diff --git a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml index dafd75087..a18fec656 100644 --- a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers" - xmlns:appLibrary="using:Ryujinx.Ava.Utilities.AppLibrary" + xmlns:ext="using:Ryujinx.Ava.Common.Markup" xmlns:viewModels="using:Ryujinx.Ava.UI.ViewModels" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" @@ -13,112 +13,102 @@ MaxWidth="256" MinWidth="256" Source="{Binding AppData.Icon, Converter={x:Static helpers:BitmapArrayValueConverter.Instance}}" /> - + - - - - - - + + + + + - - - - + - - - - - - - + + + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs index cc8091d4d..e85e1188e 100644 --- a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs +++ b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml.cs @@ -23,6 +23,7 @@ namespace Ryujinx.Ava.UI.Controls { ContentDialog contentDialog = new() { + Title = appData.Name, PrimaryButtonText = string.Empty, SecondaryButtonText = string.Empty, CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose], diff --git a/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs b/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs index 73d555389..9e0a3554a 100644 --- a/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs @@ -1,34 +1,26 @@ using Gommon; +using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Utilities.AppLibrary; namespace Ryujinx.Ava.UI.ViewModels { public class ApplicationDataViewModel : BaseModel { - private const string FormatVersion = "Current Version: {0}"; - private const string FormatDeveloper = "Developed by {0}"; - - private const string FormatExtension = "Game type: {0}"; - private const string FormatLastPlayed = "Last played: {0}"; - private const string FormatPlayTime = "Play time: {0}"; - private const string FormatSize = "Size: {0}"; - - private const string FormatHostedGames = "Hosted Games: {0}"; - private const string FormatPlayerCount = "Online Players: {0}"; - public ApplicationData AppData { get; } public ApplicationDataViewModel(ApplicationData appData) => AppData = appData; - public string FormattedVersion => FormatVersion.Format(AppData.Version); - public string FormattedDeveloper => FormatDeveloper.Format(AppData.Developer); + public string FormattedVersion => LocaleManager.Instance[LocaleKeys.GameListHeaderVersion].Format(AppData.Version); + public string FormattedDeveloper => LocaleManager.Instance[LocaleKeys.GameListHeaderDeveloper].Format(AppData.Developer); - public string FormattedFileExtension => FormatExtension.Format(AppData.FileExtension); - public string FormattedLastPlayed => FormatLastPlayed.Format(AppData.LastPlayedString); - public string FormattedPlayTime => FormatPlayTime.Format(AppData.TimePlayedString); - public string FormattedFileSize => FormatSize.Format(AppData.FileSizeString); + public string FormattedFileExtension => LocaleManager.Instance[LocaleKeys.GameListHeaderFileExtension].Format(AppData.FileExtension); + public string FormattedLastPlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderLastPlayed].Format(AppData.LastPlayedString); + public string FormattedPlayTime => LocaleManager.Instance[LocaleKeys.GameListHeaderTimePlayed].Format(AppData.TimePlayedString); + public string FormattedFileSize => LocaleManager.Instance[LocaleKeys.GameListHeaderFileSize].Format(AppData.FileSizeString); public string FormattedLdnInfo => - $"{FormatHostedGames.Format(AppData.GameCount)}\n{FormatPlayerCount.Format(AppData.PlayerCount)}"; + $"{LocaleManager.Instance[LocaleKeys.GameListHeaderHostedGames].Format(AppData.GameCount)}" + + $"\n" + + $"{LocaleManager.Instance[LocaleKeys.GameListHeaderPlayerCount].Format(AppData.PlayerCount)}"; } } From 4ae9f1c0d210d8a0af9b43e8ebaca59292ec5510 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 23:31:31 -0600 Subject: [PATCH 45/49] UI: Use Hosted Games & Player Count localization keys in list view too --- .../Converters/MultiplayerInfoConverter.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs b/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs index dc36098a1..f23a2beae 100644 --- a/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs +++ b/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs @@ -1,8 +1,11 @@ using Avalonia.Data.Converters; using Avalonia.Markup.Xaml; +using Gommon; +using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Utilities.AppLibrary; using System; using System.Globalization; +using System.Text; namespace Ryujinx.Ava.UI.Helpers { @@ -12,13 +15,17 @@ namespace Ryujinx.Ava.UI.Helpers public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - if (value is ApplicationData { HasLdnGames: true } applicationData) - { - return $"Hosted Games: {applicationData.GameCount}\nOnline Players: {applicationData.PlayerCount}"; - } - - return ""; + if (value is not ApplicationData { HasLdnGames: true } applicationData) + return ""; + return new StringBuilder() + .AppendLine( + LocaleManager.Instance[LocaleKeys.GameListHeaderHostedGames] + .Format(applicationData.GameCount)) + .Append( + LocaleManager.Instance[LocaleKeys.GameListHeaderPlayerCount] + .Format(applicationData.PlayerCount)) + .ToString(); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) From 4b1d94ccd8e468979eff1c061d51584a1b901747 Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 23:36:36 -0600 Subject: [PATCH 46/49] misc: chore: [ci skip] use MultiplayerInfoConverter instance instead of constructing for every use --- src/Ryujinx/UI/Controls/ApplicationListView.axaml | 2 +- src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Ryujinx/UI/Controls/ApplicationListView.axaml b/src/Ryujinx/UI/Controls/ApplicationListView.axaml index c01c4e8be..ab4b38621 100644 --- a/src/Ryujinx/UI/Controls/ApplicationListView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationListView.axaml @@ -141,7 +141,7 @@ diff --git a/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs b/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs index f23a2beae..7694e8883 100644 --- a/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs +++ b/src/Ryujinx/UI/Helpers/Converters/MultiplayerInfoConverter.cs @@ -11,7 +11,7 @@ namespace Ryujinx.Ava.UI.Helpers { internal class MultiplayerInfoConverter : MarkupExtension, IValueConverter { - private static readonly MultiplayerInfoConverter _instance = new(); + public static readonly MultiplayerInfoConverter Instance = new(); public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { @@ -35,7 +35,7 @@ namespace Ryujinx.Ava.UI.Helpers public override object ProvideValue(IServiceProvider serviceProvider) { - return _instance; + return Instance; } } } From 3ecc7819cc1b259e06adf1340b5e5ac843921c1d Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Tue, 4 Feb 2025 23:47:24 -0600 Subject: [PATCH 47/49] UI: Fix the app list sort types using the newly changed localization keys --- src/Ryujinx/Assets/locales.json | 127 +++++++++++++++++- .../UI/ViewModels/MainWindowViewModel.cs | 14 +- .../UI/Views/Main/MainViewControls.axaml | 12 +- 3 files changed, 139 insertions(+), 14 deletions(-) diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index cb222e935..8ec664c81 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -1673,7 +1673,132 @@ } }, { - "ID": "GameListHeaderPath", + "ID": "GameListSortDeveloper", + "Translations": { + "ar_SA": "المطور", + "de_DE": "Entwickler", + "el_GR": "Προγραμματιστής", + "en_US": "Developer", + "es_ES": "Desarrollador", + "fr_FR": "Développeur", + "he_IL": "מפתח", + "it_IT": "Sviluppatore", + "ja_JP": "開発元", + "ko_KR": "개발자", + "no_NO": "Utvikler", + "pl_PL": "Twórca", + "pt_BR": "Desenvolvedor", + "ru_RU": "Разработчик", + "sv_SE": "Utvecklare", + "th_TH": "ผู้พัฒนา", + "tr_TR": "Geliştirici", + "uk_UA": "Розробник", + "zh_CN": "制作商", + "zh_TW": "開發者" + } + }, + { + "ID": "GameListSortTimePlayed", + "Translations": { + "ar_SA": "وقت اللعب", + "de_DE": "Spielzeit", + "el_GR": "Χρόνος", + "en_US": "Play Time", + "es_ES": "Tiempo jugado", + "fr_FR": "Temps de jeu", + "he_IL": "זמן משחק", + "it_IT": "Tempo di gioco", + "ja_JP": "プレイ時間", + "ko_KR": "플레이 타임", + "no_NO": "Spilletid", + "pl_PL": "Czas w grze:", + "pt_BR": "Tempo de jogo", + "ru_RU": "Время в игре", + "sv_SE": "Speltid", + "th_TH": "เล่นไปแล้ว", + "tr_TR": "Oynama Süresi", + "uk_UA": "Зіграно часу", + "zh_CN": "游玩时长", + "zh_TW": "遊玩時數" + } + }, + { + "ID": "GameListSortLastPlayed", + "Translations": { + "ar_SA": "آخر مرة لُعبت", + "de_DE": "Zuletzt gespielt", + "el_GR": "Παίχτηκε", + "en_US": "Last Played", + "es_ES": "Jugado por última vez", + "fr_FR": "Dernière partie jouée", + "he_IL": "שוחק לאחרונה", + "it_IT": "Ultima partita", + "ja_JP": "最終プレイ日時", + "ko_KR": "마지막 플레이", + "no_NO": "Sist Spilt", + "pl_PL": "Ostatnio grane", + "pt_BR": "Último jogo", + "ru_RU": "Последний запуск", + "sv_SE": "Senast spelad", + "th_TH": "เล่นล่าสุด", + "tr_TR": "Son Oynama Tarihi", + "uk_UA": "Востаннє зіграно", + "zh_CN": "最近游玩", + "zh_TW": "最近遊玩" + } + }, + { + "ID": "GameListSortFileExtension", + "Translations": { + "ar_SA": "صيغة الملف", + "de_DE": "Dateiformat", + "el_GR": "Κατάληξη", + "en_US": "File Ext", + "es_ES": "Extensión", + "fr_FR": "Extension du Fichier", + "he_IL": "סיומת קובץ", + "it_IT": "Estensione", + "ja_JP": "ファイル拡張子", + "ko_KR": "파일 확장자", + "no_NO": "Fil Eks.", + "pl_PL": "Rozszerzenie pliku", + "pt_BR": "Extensão", + "ru_RU": "Расширение файла", + "sv_SE": "Filänd", + "th_TH": "นามสกุลไฟล์", + "tr_TR": "Dosya Uzantısı", + "uk_UA": "Розширення файлу", + "zh_CN": "扩展名", + "zh_TW": "副檔名" + } + }, + { + "ID": "GameListSortFileSize", + "Translations": { + "ar_SA": "حجم الملف", + "de_DE": "Dateigröße", + "el_GR": "Μέγεθος Αρχείου", + "en_US": "File Size", + "es_ES": "Tamaño del archivo", + "fr_FR": "Taille du Fichier", + "he_IL": "גודל הקובץ", + "it_IT": "Dimensione file", + "ja_JP": "ファイルサイズ", + "ko_KR": "파일 크기", + "no_NO": "Fil Størrelse", + "pl_PL": "Rozmiar pliku", + "pt_BR": "Tamanho", + "ru_RU": "Размер файла", + "sv_SE": "Filstorlek", + "th_TH": "ขนาดไฟล์", + "tr_TR": "Dosya Boyutu", + "uk_UA": "Розмір файлу", + "zh_CN": "大小", + "zh_TW": "檔案大小" + } + }, + { + "ID": "GameListSortPath", "Translations": { "ar_SA": "المسار", "de_DE": "Pfad", diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index d7a09a0e3..499eedc8d 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -633,15 +633,15 @@ namespace Ryujinx.Ava.UI.ViewModels { return SortMode switch { - ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication], - ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListHeaderDeveloper], - ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderLastPlayed], - ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderTimePlayed], - ApplicationSort.FileType => LocaleManager.Instance[LocaleKeys.GameListHeaderFileExtension], - ApplicationSort.FileSize => LocaleManager.Instance[LocaleKeys.GameListHeaderFileSize], - ApplicationSort.Path => LocaleManager.Instance[LocaleKeys.GameListHeaderPath], ApplicationSort.Favorite => LocaleManager.Instance[LocaleKeys.CommonFavorite], ApplicationSort.TitleId => LocaleManager.Instance[LocaleKeys.DlcManagerTableHeadingTitleIdLabel], + ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication], + ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListSortDeveloper], + ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListSortLastPlayed], + ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListSortTimePlayed], + ApplicationSort.FileType => LocaleManager.Instance[LocaleKeys.GameListSortFileExtension], + ApplicationSort.FileSize => LocaleManager.Instance[LocaleKeys.GameListSortFileSize], + ApplicationSort.Path => LocaleManager.Instance[LocaleKeys.GameListSortPath], _ => string.Empty, }; } diff --git a/src/Ryujinx/UI/Views/Main/MainViewControls.axaml b/src/Ryujinx/UI/Views/Main/MainViewControls.axaml index cdc66a138..db557b417 100644 --- a/src/Ryujinx/UI/Views/Main/MainViewControls.axaml +++ b/src/Ryujinx/UI/Views/Main/MainViewControls.axaml @@ -113,37 +113,37 @@ Tag="TitleId" /> From 479b38f035b35ceffe2d4d87999d18eafc29573f Mon Sep 17 00:00:00 2001 From: FluffyOMC <45863583+FluffyOMC@users.noreply.github.com> Date: Wed, 5 Feb 2025 01:42:20 -0500 Subject: [PATCH 48/49] Add tooltips to game status (#625) --- src/Ryujinx/Assets/locales.json | 125 ++++++++++++++++++ .../UI/Controls/ApplicationDataView.axaml | 4 +- .../UI/Controls/ApplicationListView.axaml | 3 +- .../Utilities/AppLibrary/ApplicationData.cs | 14 +- .../Utilities/Compat/CompatibilityCsv.cs | 15 ++- .../Utilities/Compat/CompatibilityList.axaml | 2 + 6 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index 8ec664c81..c3044f639 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -23497,6 +23497,131 @@ "zh_TW": "無法啟動" } }, + { + "ID": "CompatibilityListPlayableTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Boots and plays without any crashes or GPU bugs of any kind, and at a speed fast enough to reasonably enjoy on an average PC.", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListIngameTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Boots and goes in-game but suffers from one or more of the following: crashes, deadlocks, GPU bugs, distractingly bad audio, or is simply too slow. Game still might able to be played all the way through, but not as the game is intended to play.", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListMenusTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Boots and goes past the title screen but does not make it into main gameplay.", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListBootsTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Boots but does not make it past the title screen.", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListNothingTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Does not boot or shows no signs of activity.", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, { "ID": "ExtractAocListHeader", "Translations": { diff --git a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml index a18fec656..45ae75639 100644 --- a/src/Ryujinx/UI/Controls/ApplicationDataView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationDataView.axaml @@ -41,13 +41,12 @@ HorizontalAlignment="Left" Orientation="Vertical" Spacing="5"> - +