From ea2287af036bd2ab41743bac8e3bba7c95a61d7e Mon Sep 17 00:00:00 2001 From: Evan Husted Date: Sun, 2 Feb 2025 13:17:31 -0600 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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;