Compare commits

...

4 Commits

Author SHA1 Message Date
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
Evan Husted
37af8c70aa 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!
2025-02-02 02:21:33 -06:00
Evan Husted
50cee3fd19 feature: HorizonStatic PlayReportPrinted event 2025-02-02 02:20:14 -06:00
4 changed files with 143 additions and 14 deletions

View File

@@ -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<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, 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;
return formatSpec.ValueFormatter(valuePackObject.ToObject());
}
return Optional<string>.None;
}
}
public class PlayReportGameSpec
{
public required string TitleIdStr { get; init; }
public List<PlayReportValueFormatterSpec> Analyses { get; } = [];
public PlayReportGameSpec AddValueFormatter(string reportKey, Func<object, string> valueFormatter)
{
Analyses.Add(new PlayReportValueFormatterSpec
{
Priority = Analyses.Count,
ReportKey = reportKey,
ValueFormatter = valueFormatter
});
return this;
}
public PlayReportGameSpec AddValueFormatter(int priority, string reportKey, Func<object, string> 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<object, string> ValueFormatter { get; init; }
}
}

View File

@@ -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<MessagePackObject> PlayReportPrinted;
[ThreadStatic]
private static HorizonOptions _options;

View File

@@ -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());

View File

@@ -1,11 +1,19 @@
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.Helper;
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
@@ -16,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";
@@ -30,6 +38,7 @@ namespace Ryujinx.Ava
private static DiscordRpcClient _discordClient;
private static RichPresence _discordPresenceMain;
private static RichPresence _discordPresencePlaying;
public static void Initialize()
{
@@ -37,8 +46,7 @@ namespace Ryujinx.Ava
{
Assets = new Assets
{
LargeImageKey = "ryujinx",
LargeImageText = TruncateToByteLength(_description)
LargeImageKey = "ryujinx", LargeImageText = TruncateToByteLength(_description)
},
Details = "Main Menu",
State = "Idling",
@@ -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<bool> evnt)
@@ -77,16 +86,15 @@ namespace Ryujinx.Ava
{
if (titleId.TryGet(out string tid))
SwitchToPlayingState(
ApplicationLibrary.LoadAndSaveMetaData(tid),
ApplicationLibrary.LoadAndSaveMetaData(tid),
Switch.Shared.Processes.ActiveApplication
);
else
else
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,44 @@ 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 SwitchToMainState() => _discordClient?.SetPresence(_discordPresenceMain);
private static void UpdatePlayingState()
{
_discordClient?.SetPresence(_discordPresencePlaying);
}
private static void SwitchToMainState()
{
_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")
);
private static void HandlePlayReport(MessagePackObject playReport)
{
if (!TitleIDs.CurrentApplication.Value.HasValue) return;
if (_discordPresencePlaying is null) return;
Optional<string> details = _playReportAnalyzer.Run(TitleIDs.CurrentApplication.Value, playReport);
if (!details.HasValue) return;
_discordPresencePlaying.Details = details;
UpdatePlayingState();
Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report.");
}
private static string TruncateToByteLength(string input)
{