Compare commits

...

7 Commits

Author SHA1 Message Date
FluffyOMC
253cbb2810 Initial Implementation of SSBU PlayReport usage! (#638)
Currently, this has as many game modes as I could find to implement,
along with a list of all the characters in the game and their code ID.
2025-02-08 16:23:24 -06:00
Evan Husted
9c226dcc7a misc: chore: [ci skip] rename ValueFormatter to SingleValueFormatter and some minor cleanups 2025-02-08 01:34:44 -06:00
Evan Husted
30a534edcd misc: chore: [ci skip] generify Formatter Specs to be able to run formatters of different types at interleaving priorities 2025-02-08 01:26:23 -06:00
Evan Husted
1d88771d1b Play Report Analyzer v4
You can now access the *entire* play report data in any given value formatter.
The input types have been restructured and, notably, not every instance of Value has an ApplicationMetadata on it. It's now on the container type that also contains the matched values and the entire play report.
2025-02-08 00:22:34 -06:00
Evan Husted
4e8157688e UI: See what games do/don't have an image & dynamic RPC support in the Game Info popup 2025-02-07 18:34:11 -06:00
Evan Husted
5085af0050 UI: Changed the color of "Ingame" from yellow to orange to stand out better in light mode 2025-02-07 18:28:32 -06:00
Evan Husted
2c8edaf89e PlayReport: Add Sparse Multi Value formatters 2025-02-07 15:43:50 -06:00
15 changed files with 971 additions and 359 deletions

View File

@@ -1,5 +1,6 @@
using MsgPack;
using Ryujinx.Horizon.Common;
using Ryujinx.Horizon.Prepo.Types;
using Ryujinx.Memory;
using System;
using System.Threading;
@@ -8,7 +9,7 @@ namespace Ryujinx.Horizon
{
public static class HorizonStatic
{
internal static void HandlePlayReport(MessagePackObject report) =>
internal static void HandlePlayReport(PlayReport report) =>
new Thread(() => PlayReport?.Invoke(report))
{
Name = "HLE.PlayReportEvent",
@@ -16,7 +17,7 @@ namespace Ryujinx.Horizon
Priority = ThreadPriority.AboveNormal
}.Start();
public static event Action<MessagePackObject> PlayReport;
public static event Action<PlayReport> PlayReport;
[field: ThreadStatic]
public static HorizonOptions Options { get; private set; }

View File

@@ -1,4 +1,3 @@
using Gommon;
using MsgPack;
using MsgPack.Serialization;
using Ryujinx.Common.Logging;
@@ -12,19 +11,12 @@ using Ryujinx.Horizon.Sdk.Sf;
using Ryujinx.Horizon.Sdk.Sf.Hipc;
using System;
using System.Text;
using System.Threading;
using ApplicationId = Ryujinx.Horizon.Sdk.Ncm.ApplicationId;
namespace Ryujinx.Horizon.Prepo.Ipc
{
partial class PrepoService : IPrepoService
{
enum PlayReportKind
{
Normal,
System,
}
private readonly ArpApi _arp;
private readonly PrepoServicePermissionLevel _permissionLevel;
private ulong _systemSessionId;
@@ -200,6 +192,13 @@ namespace Ryujinx.Horizon.Prepo.Ipc
StringBuilder builder = new();
MessagePackObject deserializedReport = MessagePackSerializer.UnpackMessagePackObject(reportBuffer.ToArray());
PlayReport playReport = new()
{
Kind = playReportKind,
Room = gameRoom,
ReportData = deserializedReport
};
builder.AppendLine();
builder.AppendLine("PlayReport log:");
builder.AppendLine($" Kind: {playReportKind}");
@@ -209,10 +208,12 @@ namespace Ryujinx.Horizon.Prepo.Ipc
if (pid != 0)
{
builder.AppendLine($" Pid: {pid}");
playReport.Pid = pid;
}
else
{
builder.AppendLine($" ApplicationId: {applicationId}");
playReport.AppId = applicationId;
}
Result result = _arp.GetApplicationInstanceId(out ulong applicationInstanceId, pid);
@@ -223,17 +224,20 @@ namespace Ryujinx.Horizon.Prepo.Ipc
_arp.GetApplicationLaunchProperty(out ApplicationLaunchProperty applicationLaunchProperty, applicationInstanceId).AbortOnFailure();
playReport.Version = applicationLaunchProperty.Version;
builder.AppendLine($" ApplicationVersion: {applicationLaunchProperty.Version}");
if (!userId.IsNull)
{
builder.AppendLine($" UserId: {userId}");
playReport.UserId = userId;
}
builder.AppendLine($" Room: {gameRoom}");
builder.AppendLine($" Report: {MessagePackObjectFormatter.Format(deserializedReport)}");
HorizonStatic.HandlePlayReport(deserializedReport);
HorizonStatic.HandlePlayReport(playReport);
Logger.Info?.Print(LogClass.ServicePrepo, builder.ToString());

View File

@@ -0,0 +1,24 @@
using MsgPack;
using Ryujinx.Horizon.Sdk.Account;
using Ryujinx.Horizon.Sdk.Ncm;
namespace Ryujinx.Horizon.Prepo.Types
{
public struct PlayReport
{
public PlayReportKind Kind { get; init; }
public string Room { get; init; }
public MessagePackObject ReportData { get; init; }
public ApplicationId? AppId;
public ulong? Pid;
public uint Version;
public Uid? UserId;
}
public enum PlayReportKind
{
Normal,
System,
}
}

View File

@@ -23696,6 +23696,56 @@
"zh_CN": "选择一个要解压的 DLC",
"zh_TW": ""
}
},
{
"ID": "GameInfoRpcImage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Rich Presence Image",
"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": "GameInfoRpcDynamic",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Dynamic Rich Presence",
"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": ""
}
}
]
}

View File

@@ -10,6 +10,8 @@ using Ryujinx.Common.Logging;
using Ryujinx.HLE;
using Ryujinx.HLE.Loaders.Processes;
using Ryujinx.Horizon;
using Ryujinx.Horizon.Prepo.Types;
using System.Linq;
using System.Text;
namespace Ryujinx.Ava
@@ -37,6 +39,9 @@ namespace Ryujinx.Ava
private static RichPresence _discordPresencePlaying;
private static ApplicationMetadata _currentApp;
public static bool HasAssetImage(string titleId) => TitleIDs.DiscordGameAssetKeys.ContainsIgnoreCase(titleId);
public static bool HasAnalyzer(string titleId) => PlayReports.Analyzer.TitleIds.ContainsIgnoreCase(titleId);
public static void Initialize()
{
_discordPresenceMain = new RichPresence
@@ -120,20 +125,22 @@ namespace Ryujinx.Ava
_currentApp = null;
}
private static void HandlePlayReport(MessagePackObject playReport)
private static void HandlePlayReport(PlayReport playReport)
{
if (_discordClient is null) return;
if (!TitleIDs.CurrentApplication.Value.HasValue) return;
if (_discordPresencePlaying is null) return;
Analyzer.FormattedValue formattedValue =
FormattedValue formattedValue =
PlayReports.Analyzer.Format(TitleIDs.CurrentApplication.Value, _currentApp, playReport);
if (!formattedValue.Handled) return;
_discordPresencePlaying.Details = formattedValue.Reset
_discordPresencePlaying.Details = TruncateToByteLength(
formattedValue.Reset
? $"Playing {_currentApp.Title}"
: formattedValue.FormattedString;
: formattedValue.FormattedString
);
if (_discordClient.CurrentPresence.Details.Equals(_discordPresencePlaying.Details))
return; //don't trigger an update if the set presence Details are identical to current

View File

@@ -4,6 +4,7 @@
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
xmlns:ext="using:Ryujinx.Ava.Common.Markup"
xmlns:viewModels="using:Ryujinx.Ava.UI.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Ryujinx.Ava.UI.Controls.ApplicationDataView"
@@ -85,6 +86,49 @@
</StackPanel>
</Grid>
<Separator Margin="0, 10, 0, 10" Height="1" BorderBrush="Gray" Background="Gray" />
<StackPanel Orientation="Vertical" Spacing="5">
<StackPanel Orientation="Horizontal" Spacing="5">
<ui:SymbolIcon Foreground="ForestGreen" Symbol="Checkmark" IsVisible="{Binding AppData.HasRichPresenceAsset}"/>
<TextBlock
Foreground="ForestGreen"
HorizontalAlignment="Stretch"
IsVisible="{Binding AppData.HasRichPresenceAsset}"
Text="{ext:Locale GameInfoRpcImage}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
<ui:SymbolIcon Foreground="Red" Symbol="Cancel" IsVisible="{Binding !AppData.HasRichPresenceAsset}"/>
<TextBlock
Foreground="Red"
HorizontalAlignment="Stretch"
IsVisible="{Binding !AppData.HasRichPresenceAsset}"
Text="{ext:Locale GameInfoRpcImage}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="5">
<ui:SymbolIcon Foreground="ForestGreen" Symbol="Checkmark" IsVisible="{Binding AppData.HasDynamicRichPresenceSupport}"/>
<TextBlock
Foreground="ForestGreen"
HorizontalAlignment="Stretch"
IsVisible="{Binding AppData.HasDynamicRichPresenceSupport}"
Text="{ext:Locale GameInfoRpcDynamic}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
<ui:SymbolIcon Foreground="Red" Symbol="Cancel" IsVisible="{Binding !AppData.HasDynamicRichPresenceSupport}"/>
<TextBlock
Foreground="Red"
HorizontalAlignment="Stretch"
IsVisible="{Binding !AppData.HasDynamicRichPresenceSupport}"
Text="{ext:Locale GameInfoRpcDynamic}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
</StackPanel>
</StackPanel>
<Separator Margin="0, 10, 0, 10" Height="1" BorderBrush="Gray" Background="Gray" />
<TextBlock
HorizontalAlignment="Stretch"
IsVisible="{Binding AppData.HasLdnGames}"

View File

@@ -18,7 +18,7 @@ namespace Ryujinx.Ava.UI.Helpers
LocaleKeys.CompatibilityListNothing or
LocaleKeys.CompatibilityListBoots or
LocaleKeys.CompatibilityListMenus => Brushes.Red,
LocaleKeys.CompatibilityListIngame => Brushes.Yellow,
LocaleKeys.CompatibilityListIngame => Brushes.DarkOrange,
_ => Brushes.ForestGreen
};

View File

@@ -64,6 +64,9 @@ namespace Ryujinx.Ava.Utilities.AppLibrary
public bool HasLdnGames => PlayerCount != 0 && GameCount != 0;
public bool HasRichPresenceAsset => DiscordIntegrationModule.HasAssetImage(IdString);
public bool HasDynamicRichPresenceSupport => DiscordIntegrationModule.HasAnalyzer(IdString);
public TimeSpan TimePlayed { get; set; }
public DateTime? LastPlayed { get; set; }
public string FileExtension { get; set; }

View File

@@ -3,6 +3,7 @@ using MsgPack;
using Ryujinx.Ava.Utilities.AppLibrary;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
@@ -15,6 +16,10 @@ namespace Ryujinx.Ava.Utilities.PlayReport
{
private readonly List<GameSpec> _specs = [];
public string[] TitleIds => Specs.SelectMany(x => x.TitleIds).ToArray();
public IReadOnlyList<GameSpec> Specs => new ReadOnlyCollection<GameSpec>(_specs);
/// <summary>
/// Add an analysis spec matching a specific game by title ID, with the provided spec configuration.
/// </summary>
@@ -80,7 +85,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport
/// <summary>
/// Runs the configured <see cref="GameSpec.FormatterSpec"/> for the specified game title ID.
/// Runs the configured <see cref="FormatterSpec"/> for the specified game title ID.
/// </summary>
/// <param name="runningGameId">The game currently running.</param>
/// <param name="appMeta">The Application metadata information, including localized game name and play time information.</param>
@@ -89,270 +94,24 @@ namespace Ryujinx.Ava.Utilities.PlayReport
public FormattedValue Format(
string runningGameId,
ApplicationMetadata appMeta,
MessagePackObject playReport
Horizon.Prepo.Types.PlayReport playReport
)
{
if (!playReport.IsDictionary)
if (!playReport.ReportData.IsDictionary)
return FormattedValue.Unhandled;
if (!_specs.TryGetFirst(s => runningGameId.EqualsAnyIgnoreCase(s.TitleIds), out GameSpec spec))
return FormattedValue.Unhandled;
foreach (GameSpec.FormatterSpec formatSpec in spec.SimpleValueFormatters.OrderBy(x => x.Priority))
foreach (FormatterSpecBase formatSpec in spec.ValueFormatters.OrderBy(x => x.Priority))
{
if (!playReport.AsDictionary().TryGetValue(formatSpec.ReportKey, out MessagePackObject valuePackObject))
if (!formatSpec.Format(appMeta, playReport, out FormattedValue value))
continue;
return formatSpec.ValueFormatter(new Value
{
Application = appMeta, PackedValue = valuePackObject
});
}
foreach (GameSpec.MultiFormatterSpec formatSpec in spec.MultiValueFormatters.OrderBy(x => x.Priority))
{
List<MessagePackObject> packedObjects = [];
foreach (var reportKey in formatSpec.ReportKeys)
{
if (!playReport.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject))
continue;
packedObjects.Add(valuePackObject);
}
if (packedObjects.Count != formatSpec.ReportKeys.Length)
return FormattedValue.Unhandled;
return formatSpec.ValueFormatter(packedObjects
.Select(packObject => new Value { Application = appMeta, PackedValue = packObject })
.ToArray());
return value;
}
return FormattedValue.Unhandled;
}
/// <summary>
/// A potential formatted value returned by a <see cref="ValueFormatter"/>.
/// </summary>
public readonly struct FormattedValue
{
/// <summary>
/// Was any handler able to match anything in the Play Report?
/// </summary>
public bool Handled { get; private init; }
/// <summary>
/// Did the handler request the caller of the <see cref="Analyzer"/> to reset the existing value?
/// </summary>
public bool Reset { get; private init; }
/// <summary>
/// The formatted value, only present if <see cref="Handled"/> is true, and <see cref="Reset"/> is false.
/// </summary>
public string FormattedString { get; private init; }
/// <summary>
/// The intended path of execution for having a string to return: simply return the string.
/// This implicit conversion will make the struct for you.<br/><br/>
///
/// If the input is null, <see cref="Unhandled"/> is returned.
/// </summary>
/// <param name="formattedValue">The formatted string value.</param>
/// <returns>The automatically constructed <see cref="FormattedValue"/> struct.</returns>
public static implicit operator FormattedValue(string formattedValue)
=> formattedValue is not null
? new FormattedValue { Handled = true, FormattedString = formattedValue }
: Unhandled;
/// <summary>
/// Return this to tell the caller there is no value to return.
/// </summary>
public static FormattedValue Unhandled => default;
/// <summary>
/// Return this to suggest the caller reset the value it's using the <see cref="Analyzer"/> for.
/// </summary>
public static FormattedValue ForceReset => new() { Handled = true, Reset = true };
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="ValueFormatter"/>.
/// </summary>
public static readonly ValueFormatter AlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="ValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static ValueFormatter AlwaysReturns(string formattedValue) => _ => formattedValue;
}
}
/// <summary>
/// A mapping of title IDs to value formatter specs.
///
/// <remarks>Generally speaking, use the <see cref="Analyzer"/>.AddSpec(...) methods instead of creating this class yourself.</remarks>
/// </summary>
public class GameSpec
{
public required string[] TitleIds { get; init; }
public List<FormatterSpec> SimpleValueFormatters { get; } = [];
public List<MultiFormatterSpec> MultiValueFormatters { get; } = [];
/// <summary>
/// Add a value formatter to the current <see cref="GameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddValueFormatter(string reportKey, ValueFormatter valueFormatter)
{
SimpleValueFormatters.Add(new FormatterSpec
{
Priority = SimpleValueFormatters.Count, ReportKey = reportKey, ValueFormatter = valueFormatter
});
return this;
}
/// <summary>
/// Add a value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddValueFormatter(int priority, string reportKey,
ValueFormatter valueFormatter)
{
SimpleValueFormatters.Add(new FormatterSpec
{
Priority = priority, ReportKey = reportKey, ValueFormatter = valueFormatter
});
return this;
}
/// <summary>
/// Add a multi-value formatter to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddMultiValueFormatter(string[] reportKeys, MultiValueFormatter valueFormatter)
{
MultiValueFormatters.Add(new MultiFormatterSpec
{
Priority = SimpleValueFormatters.Count, ReportKeys = reportKeys, ValueFormatter = valueFormatter
});
return this;
}
/// <summary>
/// Add a multi-value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddMultiValueFormatter(int priority, string[] reportKeys,
MultiValueFormatter valueFormatter)
{
MultiValueFormatters.Add(new MultiFormatterSpec
{
Priority = priority, ReportKeys = reportKeys, ValueFormatter = valueFormatter
});
return this;
}
/// <summary>
/// A struct containing the data for a mapping of a key in a Play Report to a formatter for its potential value.
/// </summary>
public struct FormatterSpec
{
public required int Priority { get; init; }
public required string ReportKey { get; init; }
public ValueFormatter ValueFormatter { get; init; }
}
/// <summary>
/// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their potential values.
/// </summary>
public struct MultiFormatterSpec
{
public required int Priority { get; init; }
public required string[] ReportKeys { get; init; }
public MultiValueFormatter ValueFormatter { get; init; }
}
}
/// <summary>
/// The input data to a <see cref="ValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/> from the Play Report.
/// </summary>
public class Value
{
/// <summary>
/// The currently running application's <see cref="ApplicationMetadata"/>.
/// </summary>
public ApplicationMetadata Application { get; init; }
/// <summary>
/// The matched value from the Play Report.
/// </summary>
public MessagePackObject PackedValue { get; init; }
/// <summary>
/// Access the <see cref="PackedValue"/> as its underlying .NET type.<br/>
///
/// Does not seem to work well with comparing numeric types,
/// so use XValue properties for that.
/// </summary>
public object BoxedValue => PackedValue.ToObject();
#region AsX accessors
public bool BooleanValue => PackedValue.AsBoolean();
public byte ByteValye => PackedValue.AsByte();
public sbyte SByteValye => PackedValue.AsSByte();
public short ShortValye => PackedValue.AsInt16();
public ushort UShortValye => PackedValue.AsUInt16();
public int IntValye => PackedValue.AsInt32();
public uint UIntValye => PackedValue.AsUInt32();
public long LongValye => PackedValue.AsInt64();
public ulong ULongValye => PackedValue.AsUInt64();
public float FloatValue => PackedValue.AsSingle();
public double DoubleValue => PackedValue.AsDouble();
public string StringValue => PackedValue.AsString();
public Span<byte> BinaryValue => PackedValue.AsBinary();
#endregion
}
/// <summary>
/// The delegate type that powers single value formatters.<br/>
/// Takes in the result value from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate Analyzer.FormattedValue ValueFormatter(Value value);
/// <summary>
/// The delegate type that powers multiple value formatters.<br/>
/// Takes in the result value from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate Analyzer.FormattedValue MultiValueFormatter(Value[] value);
}

View File

@@ -0,0 +1,40 @@
namespace Ryujinx.Ava.Utilities.PlayReport
{
/// <summary>
/// The delegate type that powers single value formatters.<br/>
/// Takes in the result value from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate FormattedValue SingleValueFormatter(SingleValue value);
/// <summary>
/// The delegate type that powers multiple value formatters.<br/>
/// Takes in the result values from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate FormattedValue MultiValueFormatter(MultiValue value);
/// <summary>
/// The delegate type that powers multiple value formatters.
/// The dictionary passed to this delegate is sparsely populated;
/// that is, not every key specified in the Play Report needs to match for this to be used.<br/>
/// Takes in the result values from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate FormattedValue SparseMultiValueFormatter(SparseMultiValue value);
}

View File

@@ -0,0 +1,74 @@
using MsgPack;
using Ryujinx.Ava.Utilities.AppLibrary;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
public abstract class MatchedValue<T>
{
protected MatchedValue(T matched)
{
Matched = matched;
}
/// <summary>
/// The currently running application's <see cref="ApplicationMetadata"/>.
/// </summary>
public ApplicationMetadata Application { get; init; }
/// <summary>
/// The entire play report.
/// </summary>
public Horizon.Prepo.Types.PlayReport PlayReport { get; init; }
/// <summary>
/// The matched value from the Play Report.
/// </summary>
public T Matched { get; init; }
}
/// <summary>
/// The input data to a <see cref="SingleValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/> from the Play Report.
/// </summary>
public class SingleValue : MatchedValue<Value>
{
public SingleValue(Value matched) : base(matched)
{
}
}
/// <summary>
/// The input data to a <see cref="MultiValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/>s from the Play Report.
/// </summary>
public class MultiValue : MatchedValue<Value[]>
{
public MultiValue(Value[] matched) : base(matched)
{
}
public MultiValue(IEnumerable<MessagePackObject> matched) : base(Value.ConvertPackedObjects(matched))
{
}
}
/// <summary>
/// The input data to a <see cref="SparseMultiValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/>s from the Play Report.
/// </summary>
public class SparseMultiValue : MatchedValue<Dictionary<string, Value>>
{
public SparseMultiValue(Dictionary<string, Value> matched) : base(matched)
{
}
public SparseMultiValue(Dictionary<string, MessagePackObject> matched) : base(Value.ConvertPackedObjectMap(matched))
{
}
}
}

View File

@@ -0,0 +1,296 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
public partial class PlayReports
{
private static FormattedValue BreathOfTheWild_MasterMode(SingleValue value)
=> value.Matched.BoxedValue is 1 ? "Playing Master Mode" : FormattedValue.ForceReset;
private static FormattedValue TearsOfTheKingdom_CurrentField(SingleValue value) =>
value.Matched.DoubleValue switch
{
> 800d => "Exploring the Sky Islands",
< -201d => "Exploring the Depths",
_ => "Roaming Hyrule"
};
private static FormattedValue SuperMarioOdyssey_AssistMode(SingleValue value)
=> value.Matched.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode";
private static FormattedValue SuperMarioOdysseyChina_AssistMode(SingleValue value)
=> value.Matched.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式";
private static FormattedValue SuperMario3DWorldOrBowsersFury(SingleValue value)
=> value.Matched.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury";
private static FormattedValue MarioKart8Deluxe_Mode(SingleValue value)
=> value.Matched.StringValue 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",
_ => FormattedValue.ForceReset
};
private static FormattedValue PokemonSVUnionCircle(SingleValue value)
=> value.Matched.BoxedValue is 0 ? "Playing Alone" : "Playing in a group";
private static FormattedValue PokemonSVArea(SingleValue value)
=> value.Matched.StringValue switch
{
// Base Game Locations
"a_w01" => "South Area One",
"a_w02" => "Mesagoza",
"a_w03" => "The Pokemon League",
"a_w04" => "South Area Two",
"a_w05" => "South Area Four",
"a_w06" => "South Area Six",
"a_w07" => "South Area Five",
"a_w08" => "South Area Three",
"a_w09" => "West Area One",
"a_w10" => "Asado Desert",
"a_w11" => "West Area Two",
"a_w12" => "Medali",
"a_w13" => "Tagtree Thicket",
"a_w14" => "East Area Three",
"a_w15" => "Artazon",
"a_w16" => "East Area Two",
"a_w18" => "Casseroya Lake",
"a_w19" => "Glaseado Mountain",
"a_w20" => "North Area Three",
"a_w21" => "North Area One",
"a_w22" => "North Area Two",
"a_w23" => "The Great Crater of Paldea",
"a_w24" => "South Paldean Sea",
"a_w25" => "West Paldean Sea",
"a_w26" => "East Paldean Sea",
"a_w27" => "Nouth Paldean Sea",
//TODO DLC Locations
_ => FormattedValue.ForceReset
};
private static FormattedValue SuperSmashBrosUltimate_Mode(SparseMultiValue values)
{
// Check if the PlayReport is for a challenger approach or an achievement.
if (values.Matched.TryGetValue("fighter", out Value fighter) && values.Matched.ContainsKey("reason"))
{
return $"Challenger Approaches - {SuperSmashBrosUltimate_Character(fighter)}";
}
if (values.Matched.TryGetValue("fighter", out fighter) && values.Matched.ContainsKey("challenge_count"))
{
return $"Fighter Unlocked - {SuperSmashBrosUltimate_Character(fighter)}";
}
if (values.Matched.TryGetValue("anniversary", out Value anniversary))
{
return $"Achievement Unlocked - ID: {anniversary}";
}
if (values.Matched.ContainsKey("adv_slot"))
{
return "Playing Adventure Mode"; // Doing this as it can be a placeholder until we can grab the character.
}
// Check if we have a match_mode at this point, if not, go to default.
if (!values.Matched.TryGetValue("match_mode", out Value matchMode))
{
return "Smashing";
}
return matchMode.BoxedValue switch
{
0 when values.Matched.TryGetValue("player_1_fighter", out Value player) &&
values.Matched.TryGetValue("player_2_fighter", out Value challenger)
=> $"Last Smashed: {SuperSmashBrosUltimate_Character(challenger)}'s Fighter Challenge - {SuperSmashBrosUltimate_Character(player)}",
1 => $"Last Smashed: Normal Battle - {SuperSmashBrosUltimate_PlayerListing(values)}",
2 when values.Matched.TryGetValue("player_1_rank", out Value team)
=> team.BoxedValue is 0
? "Last Smashed: Squad Strike - Red Team Wins"
: "Last Smashed: Squad Strike - Blue Team Wins",
3 => $"Last Smashed: Custom Smash - {SuperSmashBrosUltimate_PlayerListing(values)}",
4 => $"Last Smashed: Super Sudden Death - {SuperSmashBrosUltimate_PlayerListing(values)}",
5 => $"Last Smashed: Smashdown - {SuperSmashBrosUltimate_PlayerListing(values)}",
6 => $"Last Smashed: Tourney Battle - {SuperSmashBrosUltimate_PlayerListing(values)}",
7 when values.Matched.TryGetValue("player_1_fighter", out Value player)
=> $"Last Smashed: Spirit Board Battle as {SuperSmashBrosUltimate_Character(player)}",
8 when values.Matched.TryGetValue("player_1_fighter", out Value player)
=> $"Playing Adventure Mode as {SuperSmashBrosUltimate_Character(player)}",
10 when values.Matched.TryGetValue("match_submode", out Value battle) &&
values.Matched.TryGetValue("player_1_fighter", out Value player)
=> $"Last Smashed: Classic Mode, Battle {(int)battle.BoxedValue + 1}/8 as {SuperSmashBrosUltimate_Character(player)}",
12 => $"Last Smashed: Century Smash - {SuperSmashBrosUltimate_PlayerListing(values)}",
13 => $"Last Smashed: All-Star Smash - {SuperSmashBrosUltimate_PlayerListing(values)}",
14 => $"Last Smashed: Cruel Smash - {SuperSmashBrosUltimate_PlayerListing(values)}",
15 when values.Matched.TryGetValue("player_1_fighter", out Value player)
=> $"Last Smashed: Home-Run Contest - {SuperSmashBrosUltimate_Character(player)}",
16 when values.Matched.TryGetValue("player_1_fighter", out Value player1) &&
values.Matched.TryGetValue("player_2_fighter", out Value player2)
=> $"Last Smashed: Home-Run Content (Co-op) - {SuperSmashBrosUltimate_Character(player1)} and {SuperSmashBrosUltimate_Character(player2)}",
17 => $"Last Smashed: Home-Run Contest (Versus) - {SuperSmashBrosUltimate_PlayerListing(values)}",
18 when values.Matched.TryGetValue("player_1_fighter", out Value player1) &&
values.Matched.TryGetValue("player_2_fighter", out Value player2)
=> $"Fresh out of Training mode - {SuperSmashBrosUltimate_Character(player1)} with {SuperSmashBrosUltimate_Character(player2)}",
58 => $"Last Smashed: LDN Battle - {SuperSmashBrosUltimate_PlayerListing(values)}",
63 when values.Matched.TryGetValue("player_1_fighter", out Value player)
=> $"Last Smashed: DLC Spirit Board Battle as {SuperSmashBrosUltimate_Character(player)}",
_ => "Smashing"
};
}
private static string SuperSmashBrosUltimate_Character(Value value) =>
BinaryPrimitives.ReverseEndianness(
BitConverter.ToInt64(((MsgPack.MessagePackExtendedTypeObject)value.BoxedValue).GetBody(), 0)) switch
{
0x0 => "Mario",
0x1 => "Donkey Kong",
0x2 => "Link",
0x3 => "Samus",
0x4 => "Dark Samus",
0x5 => "Yoshi",
0x6 => "Kirby",
0x7 => "Fox",
0x8 => "Pikachu",
0x9 => "Luigi",
0xA => "Ness",
0xB => "Captain Falcon",
0xC => "Jigglypuff",
0xD => "Peach",
0xE => "Daisy",
0xF => "Bowser",
0x10 => "Ice Climbers",
0x11 => "Sheik",
0x12 => "Zelda",
0x13 => "Dr. Mario",
0x14 => "Pichu",
0x15 => "Falco",
0x16 => "Marth",
0x17 => "Lucina",
0x18 => "Young Link",
0x19 => "Ganondorf",
0x1A => "Mewtwo",
0x1B => "Roy",
0x1C => "Chrom",
0x1D => "Mr Game & Watch",
0x1E => "Meta Knight",
0x1F => "Pit",
0x20 => "Dark Pit",
0x21 => "Zero Suit Samus",
0x22 => "Wario",
0x23 => "Snake",
0x24 => "Ike",
0x25 => "Pokémon Trainer",
0x26 => "Diddy Kong",
0x27 => "Lucas",
0x28 => "Sonic",
0x29 => "King Dedede",
0x2A => "Olimar",
0x2B => "Lucario",
0x2C => "R.O.B.",
0x2D => "Toon Link",
0x2E => "Wolf",
0x2F => "Villager",
0x30 => "Mega Man",
0x31 => "Wii Fit Trainer",
0x32 => "Rosalina & Luma",
0x33 => "Little Mac",
0x34 => "Greninja",
0x35 => "Palutena",
0x36 => "Pac-Man",
0x37 => "Robin",
0x38 => "Shulk",
0x39 => "Bowser Jr.",
0x3A => "Duck Hunt",
0x3B => "Ryu",
0x3C => "Ken",
0x3D => "Cloud",
0x3E => "Corrin",
0x3F => "Bayonetta",
0x40 => "Richter",
0x41 => "Inkling",
0x42 => "Ridley",
0x43 => "King K. Rool",
0x44 => "Simon",
0x45 => "Isabelle",
0x46 => "Incineroar",
0x47 => "Mii Brawler",
0x48 => "Mii Swordfighter",
0x49 => "Mii Gunner",
0x4A => "Piranha Plant",
0x4B => "Joker",
0x4C => "Hero",
0x4D => "Banjo",
0x4E => "Terry",
0x4F => "Byleth",
0x50 => "Min Min",
0x51 => "Steve",
0x52 => "Sephiroth",
0x53 => "Pyra/Mythra",
0x54 => "Kazuya",
0x55 => "Sora",
0xFE => "Random",
0xFF => "Scripted Entity",
_ => "Unknown"
};
private static string SuperSmashBrosUltimate_PlayerListing(SparseMultiValue values)
{
List<(string Character, int PlayerNumber, int? Rank)> players = [];
foreach (KeyValuePair<string, Value> player in values.Matched)
{
if (player.Key.StartsWith("player_") && player.Key.EndsWith("_fighter") &&
player.Value.BoxedValue is not null)
{
if (!int.TryParse(player.Key.Split('_')[1], out int playerNumber))
continue;
string character = SuperSmashBrosUltimate_Character(player.Value);
int? rank = values.Matched.TryGetValue($"player_{playerNumber}_rank", out Value rankValue)
? rankValue.IntValue
: null;
players.Add((character, playerNumber, rank));
}
}
players = players.OrderBy(p => p.Rank ?? int.MaxValue).ToList();
return players.Count > 4
? $"{players.Count} Players - " + string.Join(", ",
players.Take(3).Select(p => $"{p.Character}({p.PlayerNumber}){RankMedal(p.Rank)}"))
: string.Join(", ", players.Select(p => $"{p.Character}({p.PlayerNumber}){RankMedal(p.Rank)}"));
string RankMedal(int? rank) => rank switch
{
0 => "🥇",
1 => "🥈",
2 => "🥉",
_ => ""
};
}
}
}

View File

@@ -1,8 +1,11 @@
using static Ryujinx.Ava.Utilities.PlayReport.Analyzer;
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
public static class PlayReports
public static partial class PlayReports
{
public static Analyzer Analyzer { get; } = new Analyzer()
.AddSpec(
@@ -10,7 +13,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport
spec => spec
.AddValueFormatter("IsHardMode", BreathOfTheWild_MasterMode)
// reset to normal status when switching between normal & master mode in title screen
.AddValueFormatter("AoCVer", FormattedValue.AlwaysResets)
.AddValueFormatter("AoCVer", FormattedValue.SingleAlwaysResets)
)
.AddSpec(
"0100f2c0115b6000",
@@ -39,91 +42,23 @@ namespace Ryujinx.Ava.Utilities.PlayReport
spec => spec
.AddValueFormatter("area_no", PokemonSVArea)
.AddValueFormatter("team_circle", PokemonSVUnionCircle)
).AddSpec(
"01006a800016e000",
spec => spec
.AddSparseMultiValueFormatter(
[
// Metadata to figure out what PlayReport we have.
"match_mode", "match_submode", "anniversary", "fighter", "reason", "challenge_count",
"adv_slot",
// List of Fighters
"player_1_fighter", "player_2_fighter", "player_3_fighter", "player_4_fighter",
"player_5_fighter", "player_6_fighter", "player_7_fighter", "player_8_fighter",
// List of rankings/placements
"player_1_rank", "player_2_rank", "player_3_rank", "player_4_rank", "player_5_rank",
"player_6_rank", "player_7_rank", "player_8_rank"
],
SuperSmashBrosUltimate_Mode
)
);
private static FormattedValue BreathOfTheWild_MasterMode(Value value)
=> value.BoxedValue is 1 ? "Playing Master Mode" : FormattedValue.ForceReset;
private static FormattedValue TearsOfTheKingdom_CurrentField(Value value) =>
value.DoubleValue switch
{
> 800d => "Exploring the Sky Islands",
< -201d => "Exploring the Depths",
_ => "Roaming Hyrule"
};
private static FormattedValue SuperMarioOdyssey_AssistMode(Value value)
=> value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode";
private static FormattedValue SuperMarioOdysseyChina_AssistMode(Value value)
=> value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式";
private static FormattedValue SuperMario3DWorldOrBowsersFury(Value value)
=> value.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury";
private static FormattedValue MarioKart8Deluxe_Mode(Value value)
=> value.StringValue 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",
_ => FormattedValue.ForceReset
};
private static FormattedValue PokemonSVUnionCircle(Value value)
=> value.BoxedValue is 0 ? "Playing Alone" : "Playing in a group";
private static FormattedValue PokemonSVArea(Value value)
=> value.StringValue switch
{
// Base Game Locations
"a_w01" => "South Area One",
"a_w02" => "Mesagoza",
"a_w03" => "The Pokemon League",
"a_w04" => "South Area Two",
"a_w05" => "South Area Four",
"a_w06" => "South Area Six",
"a_w07" => "South Area Five",
"a_w08" => "South Area Three",
"a_w09" => "West Area One",
"a_w10" => "Asado Desert",
"a_w11" => "West Area Two",
"a_w12" => "Medali",
"a_w13" => "Tagtree Thicket",
"a_w14" => "East Area Three",
"a_w15" => "Artazon",
"a_w16" => "East Area Two",
"a_w18" => "Casseroya Lake",
"a_w19" => "Glaseado Mountain",
"a_w20" => "North Area Three",
"a_w21" => "North Area One",
"a_w22" => "North Area Two",
"a_w23" => "The Great Crater of Paldea",
"a_w24" => "South Paldean Sea",
"a_w25" => "West Paldean Sea",
"a_w26" => "East Paldean Sea",
"a_w27" => "Nouth Paldean Sea",
//TODO DLC Locations
_ => FormattedValue.ForceReset
};
}
}

View File

@@ -0,0 +1,215 @@
using FluentAvalonia.Core;
using MsgPack;
using Ryujinx.Ava.Utilities.AppLibrary;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
/// <summary>
/// A mapping of title IDs to value formatter specs.
///
/// <remarks>Generally speaking, use the <see cref="Analyzer"/>.AddSpec(...) methods instead of creating this class yourself.</remarks>
/// </summary>
public class GameSpec
{
private int _lastPriority;
public required string[] TitleIds { get; init; }
public List<FormatterSpecBase> ValueFormatters { get; } = [];
/// <summary>
/// Add a value formatter to the current <see cref="GameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddValueFormatter(string reportKey, SingleValueFormatter valueFormatter)
=> AddValueFormatter(_lastPriority++, reportKey, valueFormatter);
/// <summary>
/// Add a value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddValueFormatter(int priority, string reportKey,
SingleValueFormatter valueFormatter)
{
ValueFormatters.Add(new FormatterSpec
{
Priority = priority, ReportKeys = [reportKey], Formatter = valueFormatter
});
return this;
}
/// <summary>
/// Add a multi-value formatter to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddMultiValueFormatter(string[] reportKeys, MultiValueFormatter valueFormatter)
=> AddMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter);
/// <summary>
/// Add a multi-value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddMultiValueFormatter(int priority, string[] reportKeys,
MultiValueFormatter valueFormatter)
{
ValueFormatters.Add(new MultiFormatterSpec
{
Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter
});
return this;
}
/// <summary>
/// Add a multi-value formatter to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// <br/><br/>
/// The 'Sparse' multi-value formatters do not require every key to be present.
/// If you need this requirement, use <see cref="AddMultiValueFormatter(string[], Ryujinx.Ava.Utilities.PlayReport.MultiValueFormatter)"/>.
/// </summary>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddSparseMultiValueFormatter(string[] reportKeys, SparseMultiValueFormatter valueFormatter)
=> AddSparseMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter);
/// <summary>
/// Add a multi-value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// <br/><br/>
/// The 'Sparse' multi-value formatters do not require every key to be present.
/// If you need this requirement, use <see cref="AddMultiValueFormatter(int, string[], Ryujinx.Ava.Utilities.PlayReport.MultiValueFormatter)"/>.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddSparseMultiValueFormatter(int priority, string[] reportKeys,
SparseMultiValueFormatter valueFormatter)
{
ValueFormatters.Add(new SparseMultiFormatterSpec
{
Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter
});
return this;
}
}
/// <summary>
/// A struct containing the data for a mapping of a key in a Play Report to a formatter for its potential value.
/// </summary>
public class FormatterSpec : FormatterSpecBase
{
public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result)
{
if (!playReport.ReportData.AsDictionary().TryGetValue(ReportKeys[0], out MessagePackObject valuePackObject))
{
result = null;
return false;
}
result = valuePackObject;
return true;
}
}
/// <summary>
/// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their potential values.
/// </summary>
public class MultiFormatterSpec : FormatterSpecBase
{
public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result)
{
List<MessagePackObject> packedObjects = [];
foreach (var reportKey in ReportKeys)
{
if (!playReport.ReportData.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject))
{
result = null;
return false;
}
packedObjects.Add(valuePackObject);
}
result = packedObjects;
return true;
}
}
/// <summary>
/// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their sparsely populated potential values.
/// </summary>
public class SparseMultiFormatterSpec : FormatterSpecBase
{
public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result)
{
Dictionary<string, MessagePackObject> packedObjects = [];
foreach (var reportKey in ReportKeys)
{
if (!playReport.ReportData.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject))
continue;
packedObjects.Add(reportKey, valuePackObject);
}
result = packedObjects;
return true;
}
}
public abstract class FormatterSpecBase
{
public abstract bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object data);
public int Priority { get; init; }
public string[] ReportKeys { get; init; }
public Delegate Formatter { get; init; }
public bool Format(ApplicationMetadata appMeta, Horizon.Prepo.Types.PlayReport playReport, out FormattedValue formattedValue)
{
formattedValue = default;
if (!GetData(playReport, out object data))
return false;
if (data is FormattedValue fv)
{
formattedValue = fv;
return true;
}
switch (Formatter)
{
case SingleValueFormatter svf when data is MessagePackObject mpo:
formattedValue = svf(new SingleValue(mpo) { Application = appMeta, PlayReport = playReport });
return true;
case MultiValueFormatter mvf when data is List<MessagePackObject> messagePackObjects:
formattedValue = mvf(new MultiValue(messagePackObjects) { Application = appMeta, PlayReport = playReport });
return true;
case SparseMultiValueFormatter smvf when
data is Dictionary<string, MessagePackObject> sparseMessagePackObjects:
formattedValue = smvf(new SparseMultiValue(sparseMessagePackObjects) { Application = appMeta, PlayReport = playReport });
return true;
default:
throw new InvalidOperationException("Formatter delegate is not of a known type!");
}
}
}
}

View File

@@ -0,0 +1,160 @@
using MsgPack;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
/// <summary>
/// The base input data to a ValueFormatter delegate,
/// and the matched <see cref="MessagePackObject"/> from the Play Report.
/// </summary>
public readonly struct Value
{
public Value(MessagePackObject packedValue)
{
PackedValue = packedValue;
}
/// <summary>
/// The matched value from the Play Report.
/// </summary>
public MessagePackObject PackedValue { get; init; }
/// <summary>
/// Access the <see cref="PackedValue"/> as its underlying .NET type.<br/>
///
/// Does not seem to work well with comparing numeric types,
/// so use XValue properties for that.
/// </summary>
public object BoxedValue => PackedValue.ToObject();
public override string ToString()
{
object boxed = BoxedValue;
return boxed == null
? "null"
: boxed.ToString();
}
public static implicit operator Value(MessagePackObject matched) => new(matched);
public static Value[] ConvertPackedObjects(IEnumerable<MessagePackObject> packObjects)
=> packObjects.Select(packObject => new Value(packObject)).ToArray();
public static Dictionary<string, Value> ConvertPackedObjectMap(Dictionary<string, MessagePackObject> packObjects)
=> packObjects.ToDictionary(
x => x.Key,
x => new Value(x.Value)
);
#region AsX accessors
public bool BooleanValue => PackedValue.AsBoolean();
public byte ByteValue => PackedValue.AsByte();
public sbyte SByteValue => PackedValue.AsSByte();
public short ShortValue => PackedValue.AsInt16();
public ushort UShortValue => PackedValue.AsUInt16();
public int IntValue => PackedValue.AsInt32();
public uint UIntValue => PackedValue.AsUInt32();
public long LongValue => PackedValue.AsInt64();
public ulong ULongValue => PackedValue.AsUInt64();
public float FloatValue => PackedValue.AsSingle();
public double DoubleValue => PackedValue.AsDouble();
public string StringValue => PackedValue.AsString();
public Span<byte> BinaryValue => PackedValue.AsBinary();
#endregion
}
/// <summary>
/// A potential formatted value returned by a ValueFormatter delegate.
/// </summary>
public readonly struct FormattedValue
{
/// <summary>
/// Was any handler able to match anything in the Play Report?
/// </summary>
public bool Handled { get; private init; }
/// <summary>
/// Did the handler request the caller of the <see cref="Analyzer"/> to reset the existing value?
/// </summary>
public bool Reset { get; private init; }
/// <summary>
/// The formatted value, only present if <see cref="Handled"/> is true, and <see cref="Reset"/> is false.
/// </summary>
public string FormattedString { get; private init; }
/// <summary>
/// The intended path of execution for having a string to return: simply return the string.
/// This implicit conversion will make the struct for you.<br/><br/>
///
/// If the input is null, <see cref="Unhandled"/> is returned.
/// </summary>
/// <param name="formattedValue">The formatted string value.</param>
/// <returns>The automatically constructed <see cref="FormattedValue"/> struct.</returns>
public static implicit operator FormattedValue(string formattedValue)
=> formattedValue is not null
? new FormattedValue { Handled = true, FormattedString = formattedValue }
: Unhandled;
public override string ToString()
{
if (!Handled)
return "<Unhandled>";
if (Reset)
return "<Reset>";
return FormattedString;
}
/// <summary>
/// Return this to tell the caller there is no value to return.
/// </summary>
public static readonly FormattedValue Unhandled = default;
/// <summary>
/// Return this to suggest the caller reset the value it's using the <see cref="Analyzer"/> for.
/// </summary>
public static readonly FormattedValue ForceReset = new() { Handled = true, Reset = true };
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="SingleValueFormatter"/>.
/// </summary>
public static readonly SingleValueFormatter SingleAlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="MultiValueFormatter"/>.
/// </summary>
public static readonly MultiValueFormatter MultiAlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="SparseMultiValueFormatter"/>.
/// </summary>
public static readonly SparseMultiValueFormatter SparseMultiAlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="SingleValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static SingleValueFormatter SingleAlwaysReturns(string formattedValue) => _ => formattedValue;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="MultiValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static MultiValueFormatter MultiAlwaysReturns(string formattedValue) => _ => formattedValue;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="SparseMultiValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static SparseMultiValueFormatter SparseMultiAlwaysReturns(string formattedValue) => _ => formattedValue;
}
}