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 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 392895d3a..f55eb8d66 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"; @@ -38,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() { @@ -45,8 +47,7 @@ namespace Ryujinx.Ava { Assets = new Assets { - LargeImageKey = "ryujinx", - LargeImageText = TruncateToByteLength(_description) + LargeImageKey = "ryujinx", LargeImageText = TruncateToByteLength(_description) }, Details = "Main Menu", State = "Idling", @@ -86,10 +87,10 @@ namespace Ryujinx.Ava { if (titleId.TryGet(out string tid)) SwitchToPlayingState( - ApplicationLibrary.LoadAndSaveMetaData(tid), + ApplicationLibrary.LoadAndSaveMetaData(tid), Switch.Shared.Processes.ActiveApplication ); - else + else SwitchToMainState(); } @@ -113,8 +114,9 @@ namespace Ryujinx.Ava private static void SwitchToPlayingState(ApplicationMetadata appMeta, ProcessResult procRes) { _discordClient?.SetPresence(_discordPresencePlaying ??= CreatePlayingState(appMeta, procRes)); + _currentApp = appMeta; } - + private static void UpdatePlayingState() { _discordClient?.SetPresence(_discordPresencePlaying); @@ -124,38 +126,31 @@ namespace Ryujinx.Ava { _discordClient?.SetPresence(_discordPresenceMain); _discordPresencePlaying = null; + _currentApp = null; } - + private static void HandlePlayReport(MessagePackObject playReport) { + if (_discordClient is null) return; 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; + PlayReportFormattedValue value = PlayReport.Analyzer.Run(TitleIDs.CurrentApplication.Value, _currentApp, playReport); - _discordPresencePlaying.Details = x.Formatter(valuePackObject.ToObject()); - UpdatePlayingState(); - Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report."); - }); - } + if (!value.Handled) return; - // title ID -> Play Report key & value formatter - private static readonly ReadOnlyDictionary Formatter)> - _playReportValues = new(new Dictionary Formatter)> + if (value.Reset) { - { - // Breath of the Wild Master Mode display - "01007ef00011e000", - ("IsHardMode", val => val is 1 ? "Playing Master Mode" : "Playing Normal Mode") - } - }); + _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(); + } private static string TruncateToByteLength(string input) { diff --git a/src/Ryujinx/Utilities/PlayReport.cs b/src/Ryujinx/Utilities/PlayReport.cs new file mode 100644 index 000000000..af56bae12 --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport.cs @@ -0,0 +1,198 @@ +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 +{ + 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, Mario Kart 8 Deluxe (China) + ["0100152000022000", "010075100E8EC000"], + spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode) + ); + + private static PlayReportFormattedValue BreathOfTheWild_MasterMode(ref PlayReportValue value) + => value.BoxedValue is 1 ? "Playing Master Mode" : PlayReportFormattedValue.ForceReset; + + private static PlayReportFormattedValue SuperMarioOdyssey_AssistMode(ref PlayReportValue value) + => value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; + + private static PlayReportFormattedValue SuperMarioOdysseyChina_AssistMode(ref PlayReportValue value) + => value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; + + private static PlayReportFormattedValue SuperMario3DWorldOrBowsersFury(ref PlayReportValue value) + => value.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; + + private static PlayReportFormattedValue MarioKart8Deluxe_Mode(ref PlayReportValue value) + => value.BoxedValue 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", + _ => PlayReportFormattedValue.ForceReset + }; + } + + #region Analyzer implementation + + public class PlayReportAnalyzer + { + private readonly List _specs = []; + + public PlayReportAnalyzer AddSpec(string titleId, Func transform) + { + _specs.Add(transform(new PlayReportGameSpec { TitleIds = [titleId] })); + return this; + } + + public PlayReportAnalyzer AddSpec(string titleId, Action 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 PlayReportFormattedValue Run(string runningGameId, ApplicationMetadata appMeta, MessagePackObject playReport) + { + if (!playReport.IsDictionary) + return PlayReportFormattedValue.Unhandled; + + if (!_specs.TryGetFirst(s => runningGameId.EqualsAnyIgnoreCase(s.TitleIds), out PlayReportGameSpec spec)) + return PlayReportFormattedValue.Unhandled; + + 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 PlayReportFormattedValue.Unhandled; + } + + } + + public class PlayReportGameSpec + { + public required string[] TitleIds { 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 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 PlayReportValueFormatter ValueFormatter { get; init; } + } + + public delegate PlayReportFormattedValue PlayReportValueFormatter(ref PlayReportValue value); + + #endregion +}