Compare commits

...

6 Commits

Author SHA1 Message Date
Evan Husted
fe43c32e60 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.
2025-02-02 20:47:42 -06:00
Evan Husted
8117e160c2 misc: chore: [ci skip] Move the play report analyzer definition into a PlayReport static class to avoid polluting the Discord integration module 2025-02-02 20:32:01 -06:00
Piplup
bf713a80d6 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)
2025-02-02 20:29:00 -06:00
Evan Husted
b38b5a1e70 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.
2025-02-02 16:59:06 -06:00
Evan Husted
2d7700949c 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.
2025-02-02 16:07:30 -06:00
Evan Husted
ea2287af03 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)
2025-02-02 13:17:31 -06:00
4 changed files with 193 additions and 36 deletions

View File

@@ -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
1 title_id game_name labels status last_updated
2483 0100A5200C2E0000 Safety First! playable 2021-01-06 09:05:23
2484 0100A51013530000 SaGa Frontier Remastered nvdec playable 2022-11-03 13:54:56
2485 010003A00D0B4000 SaGa SCARLET GRACE: AMBITIONS™ playable 2022-10-06 13:20:31
2486 01008D100D43E000 Saints Row IV®: Re-Elected™ ldn-untested;LAN ldn-untested;LAN;deadlock playable ingame 2023-12-04 18:33:37 2025-02-02 16:57:53
2487 0100DE600BEEE000 SAINTS ROW®: THE THIRD™ - THE FULL PACKAGE slow;LAN playable 2023-08-24 02:40:58
2488 01007F000EB36000 Sakai and... nvdec playable 2022-12-15 13:53:19
2489 0100B1400E8FE000 Sakuna: Of Rice and Ruin playable 2023-07-24 13:47:13

View File

@@ -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<MessagePackObject> PlayReportPrinted;

View File

@@ -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,39 +126,23 @@ namespace Ryujinx.Ava
{
_discordClient?.SetPresence(_discordPresenceMain);
_discordPresencePlaying = null;
_currentApp = 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;
Optional<string> details = 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 (!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<string, (string ReportKey, Func<object, string> Formatter)>
_playReportValues = new(new Dictionary<string, (string ReportKey, Func<object, string> 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)

View File

@@ -0,0 +1,171 @@
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
"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(ref PlayReportValue value)
=> value.BoxedValue is 1 ? "Playing Master Mode" : "Playing Normal 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(ref PlayReportValue value)
=> value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式";
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(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",
_ => $"Playing {value.Application.Title}"
};
}
#region Analyzer implementation
public class PlayReportAnalyzer
{
private readonly List<PlayReportGameSpec> _specs = [];
public PlayReportAnalyzer AddSpec(string titleId, Func<PlayReportGameSpec, PlayReportGameSpec> transform)
{
_specs.Add(transform(new PlayReportGameSpec { TitleIdStr = titleId }));
return this;
}
public PlayReportAnalyzer AddSpec(string titleId, Action<PlayReportGameSpec> transform)
{
_specs.Add(new PlayReportGameSpec { TitleIdStr = titleId }.Apply(transform));
return this;
}
public Optional<string> Run(string runningGameId, ApplicationMetadata appMeta, MessagePackObject playReport)
{
if (!playReport.IsDictionary)
return Optional<string>.None;
if (!_specs.TryGetFirst(s => s.TitleIdStr.EqualsIgnoreCase(runningGameId), out PlayReportGameSpec spec))
return Optional<string>.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<string>.None;
}
}
public class PlayReportGameSpec
{
public required string TitleIdStr { get; init; }
public List<PlayReportValueFormatterSpec> 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
}