Compare commits
11 Commits
Canary-1.2
...
Canary-1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11bc32d98e | ||
|
|
063430ea16 | ||
|
|
65f08caaa3 | ||
|
|
f225b18c05 | ||
|
|
d8549f687b | ||
|
|
5ab50680b4 | ||
|
|
a0edc5c2b0 | ||
|
|
158ea7b4d6 | ||
|
|
8bc3de8303 | ||
|
|
c812106611 | ||
|
|
11e4d8f970 |
@@ -47,7 +47,8 @@ namespace Ryujinx.Common
|
||||
"01006f8002326000", // Animal Crossings: New Horizons
|
||||
"01009bf0072d4000", // Captain Toad: Treasure Tracker
|
||||
"01009510001ca000", // Fast RMX
|
||||
"01005CA01580E000", // Persona 5 Royale
|
||||
"01005CA01580E000", // Persona 5 Royal
|
||||
"0100b880154fc000", // Persona 5 The Royal (Japan)
|
||||
"010015100b514000", // Super Mario Bros. Wonder
|
||||
"0100000000010000", // Super Mario Odyssey
|
||||
|
||||
|
||||
@@ -2,35 +2,36 @@ using MsgPack;
|
||||
using Ryujinx.Horizon.Common;
|
||||
using Ryujinx.Memory;
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace Ryujinx.Horizon
|
||||
{
|
||||
public static class HorizonStatic
|
||||
{
|
||||
internal static void HandlePlayReport(MessagePackObject report) => PlayReportPrinted?.Invoke(report);
|
||||
internal static void HandlePlayReport(MessagePackObject report) =>
|
||||
new Thread(() => PlayReport?.Invoke(report))
|
||||
{
|
||||
Name = "HLE.PlayReportEvent",
|
||||
IsBackground = true,
|
||||
Priority = ThreadPriority.AboveNormal
|
||||
}.Start();
|
||||
|
||||
public static event Action<MessagePackObject> PlayReportPrinted;
|
||||
|
||||
[ThreadStatic]
|
||||
private static HorizonOptions _options;
|
||||
public static event Action<MessagePackObject> PlayReport;
|
||||
|
||||
[ThreadStatic]
|
||||
private static ISyscallApi _syscall;
|
||||
[field: ThreadStatic]
|
||||
public static HorizonOptions Options { get; private set; }
|
||||
|
||||
[ThreadStatic]
|
||||
private static IVirtualMemoryManager _addressSpace;
|
||||
[field: ThreadStatic]
|
||||
public static ISyscallApi Syscall { get; private set; }
|
||||
|
||||
[ThreadStatic]
|
||||
private static IThreadContext _threadContext;
|
||||
[field: ThreadStatic]
|
||||
public static IVirtualMemoryManager AddressSpace { get; private set; }
|
||||
|
||||
[ThreadStatic]
|
||||
private static int _threadHandle;
|
||||
[field: ThreadStatic]
|
||||
public static IThreadContext ThreadContext { get; private set; }
|
||||
|
||||
public static HorizonOptions Options => _options;
|
||||
public static ISyscallApi Syscall => _syscall;
|
||||
public static IVirtualMemoryManager AddressSpace => _addressSpace;
|
||||
public static IThreadContext ThreadContext => _threadContext;
|
||||
public static int CurrentThreadHandle => _threadHandle;
|
||||
[field: ThreadStatic]
|
||||
public static int CurrentThreadHandle { get; private set; }
|
||||
|
||||
public static void Register(
|
||||
HorizonOptions options,
|
||||
@@ -39,11 +40,11 @@ namespace Ryujinx.Horizon
|
||||
IThreadContext threadContext,
|
||||
int threadHandle)
|
||||
{
|
||||
_options = options;
|
||||
_syscall = syscallApi;
|
||||
_addressSpace = addressSpace;
|
||||
_threadContext = threadContext;
|
||||
_threadHandle = threadHandle;
|
||||
Options = options;
|
||||
Syscall = syscallApi;
|
||||
AddressSpace = addressSpace;
|
||||
ThreadContext = threadContext;
|
||||
CurrentThreadHandle = threadHandle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Gommon;
|
||||
using MsgPack;
|
||||
using MsgPack.Serialization;
|
||||
using Ryujinx.Common.Logging;
|
||||
@@ -11,6 +12,7 @@ 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
|
||||
|
||||
@@ -23069,7 +23069,7 @@
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "可游玩",
|
||||
"zh_TW": "可暢順遊玩 (Playable)"
|
||||
"zh_TW": "可暢順遊玩"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -23094,7 +23094,7 @@
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "进入游戏",
|
||||
"zh_TW": "大致可遊玩 (Ingame)"
|
||||
"zh_TW": "大致可遊玩"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -23119,7 +23119,7 @@
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "菜单",
|
||||
"zh_TW": "只開啟至遊戲開始功能表 (Menus)"
|
||||
"zh_TW": "只開啟至遊戲開始功能表"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -23144,7 +23144,7 @@
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "启动",
|
||||
"zh_TW": "只能啟動 (Boots)"
|
||||
"zh_TW": "只能啟動"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -23169,7 +23169,7 @@
|
||||
"tr_TR": "",
|
||||
"uk_UA": "",
|
||||
"zh_CN": "什么都没有",
|
||||
"zh_TW": "無法啟動 (Nothing)"
|
||||
"zh_TW": "無法啟動"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ namespace Ryujinx.Ava
|
||||
|
||||
ConfigurationState.Instance.EnableDiscordIntegration.Event += Update;
|
||||
TitleIDs.CurrentApplication.Event += (_, e) => Use(e.NewValue);
|
||||
HorizonStatic.PlayReportPrinted += HandlePlayReport;
|
||||
HorizonStatic.PlayReport += HandlePlayReport;
|
||||
}
|
||||
|
||||
private static void Update(object sender, ReactiveEventArgs<bool> evnt)
|
||||
@@ -135,21 +135,21 @@ namespace Ryujinx.Ava
|
||||
if (!TitleIDs.CurrentApplication.Value.HasValue) return;
|
||||
if (_discordPresencePlaying is null) return;
|
||||
|
||||
PlayReportFormattedValue value = PlayReport.Analyzer.Run(TitleIDs.CurrentApplication.Value, _currentApp, playReport);
|
||||
|
||||
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();
|
||||
PlayReport.Analyzer.FormatPlayReportValue(TitleIDs.CurrentApplication.Value, _currentApp, playReport)
|
||||
.Match(out bool handled,
|
||||
() =>
|
||||
{
|
||||
_discordPresencePlaying.Details = $"Playing {_currentApp.Title}";
|
||||
Logger.Info?.Print(LogClass.UI, "Reset Discord RPC based on a supported play report value formatter.");
|
||||
},
|
||||
formattedString =>
|
||||
{
|
||||
_discordPresencePlaying.Details = formattedString;
|
||||
Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report.");
|
||||
});
|
||||
|
||||
if (handled)
|
||||
UpdatePlayingState();
|
||||
}
|
||||
|
||||
private static string TruncateToByteLength(string input)
|
||||
|
||||
@@ -86,6 +86,13 @@
|
||||
Text="{Binding Version}"
|
||||
TextAlignment="Start"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
IsVisible="{Binding HasPlayabilityInfo}"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{Binding LocalizedStatus}"
|
||||
Foreground="{Binding PlayabilityStatus, Converter={x:Static helpers:PlayabilityStatusConverter.Shared}}"
|
||||
TextAlignment="Start"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<StackPanel
|
||||
|
||||
@@ -7,6 +7,8 @@ using LibHac.Ns;
|
||||
using LibHac.Tools.Fs;
|
||||
using LibHac.Tools.FsSystem;
|
||||
using LibHac.Tools.FsSystem.NcaUtils;
|
||||
using Ryujinx.Ava.Common.Locale;
|
||||
using Ryujinx.Ava.Utilities.Compat;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.HLE.FileSystem;
|
||||
using Ryujinx.HLE.Loaders.Processes.Extensions;
|
||||
@@ -21,9 +23,30 @@ namespace Ryujinx.Ava.Utilities.AppLibrary
|
||||
public bool Favorite { get; set; }
|
||||
public byte[] Icon { get; set; }
|
||||
public string Name { get; set; } = "Unknown";
|
||||
public ulong Id { get; set; }
|
||||
|
||||
private ulong _id;
|
||||
|
||||
public ulong Id
|
||||
{
|
||||
get => _id;
|
||||
set
|
||||
{
|
||||
_id = value;
|
||||
PlayabilityStatus = CompatibilityCsv.GetStatus(Id);
|
||||
}
|
||||
}
|
||||
public string Developer { get; set; } = "Unknown";
|
||||
public string Version { get; set; } = "0";
|
||||
|
||||
public bool HasPlayabilityInfo => PlayabilityStatus != null;
|
||||
|
||||
public string LocalizedStatus =>
|
||||
PlayabilityStatus.HasValue
|
||||
? LocaleManager.Instance[PlayabilityStatus!.Value]
|
||||
: string.Empty;
|
||||
|
||||
public LocaleKeys? PlayabilityStatus { get; set; }
|
||||
|
||||
public int PlayerCount { get; set; }
|
||||
public int GameCount { get; set; }
|
||||
public TimeSpan TimePlayed { get; set; }
|
||||
|
||||
@@ -47,11 +47,6 @@ namespace Ryujinx.Ava.Utilities.Compat
|
||||
Logger.Debug?.Print(LogClass.UI, "Compatibility CSV loaded.", "LoadCompatibility");
|
||||
}
|
||||
|
||||
public static void Unload()
|
||||
{
|
||||
_entries = null;
|
||||
}
|
||||
|
||||
private static CompatibilityEntry[] _entries;
|
||||
|
||||
public static CompatibilityEntry[] Entries
|
||||
@@ -64,6 +59,11 @@ namespace Ryujinx.Ava.Utilities.Compat
|
||||
return _entries;
|
||||
}
|
||||
}
|
||||
|
||||
public static LocaleKeys? GetStatus(string titleId)
|
||||
=> Entries.FirstOrDefault(x => x.TitleId.HasValue && x.TitleId.Value.EqualsIgnoreCase(titleId))?.Status;
|
||||
|
||||
public static LocaleKeys? GetStatus(ulong titleId) => GetStatus(titleId.ToString("X16"));
|
||||
}
|
||||
|
||||
public class CompatibilityEntry
|
||||
|
||||
@@ -32,8 +32,6 @@ namespace Ryujinx.Ava.Utilities.Compat
|
||||
contentDialog.Styles.Add(closeButtonParent);
|
||||
|
||||
await ContentDialogHelper.ShowAsync(contentDialog);
|
||||
|
||||
CompatibilityCsv.Unload();
|
||||
}
|
||||
|
||||
public CompatibilityList()
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
using Gommon;
|
||||
using MsgPack;
|
||||
using Ryujinx.Ava.Utilities.AppLibrary;
|
||||
using Ryujinx.Common.Helper;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PlayReportFormattedValue = Ryujinx.Ava.Utilities.PlayReportAnalyzer.FormattedValue;
|
||||
|
||||
namespace Ryujinx.Ava.Utilities
|
||||
{
|
||||
@@ -13,40 +7,54 @@ namespace Ryujinx.Ava.Utilities
|
||||
public static PlayReportAnalyzer Analyzer { get; } = new PlayReportAnalyzer()
|
||||
.AddSpec(
|
||||
"01007ef00011e000",
|
||||
spec => spec.AddValueFormatter("IsHardMode", BreathOfTheWild_MasterMode)
|
||||
spec => spec
|
||||
.AddValueFormatter("IsHardMode", BreathOfTheWild_MasterMode)
|
||||
// reset to normal status when switching between normal & master mode in title screen
|
||||
.AddValueFormatter("AoCVer", PlayReportFormattedValue.AlwaysResets)
|
||||
)
|
||||
.AddSpec( // Super Mario Odyssey
|
||||
.AddSpec(
|
||||
"0100f2c0115b6000",
|
||||
spec => spec.AddValueFormatter("PlayerPosY", TearsOfTheKingdom_CurrentField))
|
||||
.AddSpec(
|
||||
"0100000000010000",
|
||||
spec =>
|
||||
spec.AddValueFormatter("is_kids_mode", SuperMarioOdyssey_AssistMode)
|
||||
)
|
||||
.AddSpec( // Super Mario Odyssey (China)
|
||||
"010075000ECBE000",
|
||||
.AddSpec(
|
||||
"010075000ecbe000",
|
||||
spec =>
|
||||
spec.AddValueFormatter("is_kids_mode", SuperMarioOdysseyChina_AssistMode)
|
||||
)
|
||||
.AddSpec( // Super Mario 3D World + Bowser's Fury
|
||||
"010028600EBDA000",
|
||||
.AddSpec(
|
||||
"010028600ebda000",
|
||||
spec => spec.AddValueFormatter("mode", SuperMario3DWorldOrBowsersFury)
|
||||
)
|
||||
.AddSpec( // Mario Kart 8 Deluxe, Mario Kart 8 Deluxe (China)
|
||||
["0100152000022000", "010075100E8EC000"],
|
||||
.AddSpec( // Global & China IDs
|
||||
["0100152000022000", "010075100e8ec000"],
|
||||
spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode)
|
||||
);
|
||||
|
||||
private static PlayReportFormattedValue BreathOfTheWild_MasterMode(ref PlayReportValue value)
|
||||
private static PlayReportFormattedValue BreathOfTheWild_MasterMode(PlayReportValue value)
|
||||
=> value.BoxedValue is 1 ? "Playing Master Mode" : PlayReportFormattedValue.ForceReset;
|
||||
|
||||
private static PlayReportFormattedValue SuperMarioOdyssey_AssistMode(ref PlayReportValue value)
|
||||
private static PlayReportFormattedValue TearsOfTheKingdom_CurrentField(PlayReportValue value) =>
|
||||
value.PackedValue.AsDouble() switch
|
||||
{
|
||||
> 800d => "Exploring the Sky Islands",
|
||||
< -201d => "Exploring the Depths",
|
||||
_ => "Roaming Hyrule"
|
||||
};
|
||||
|
||||
private static PlayReportFormattedValue SuperMarioOdyssey_AssistMode(PlayReportValue value)
|
||||
=> value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode";
|
||||
|
||||
private static PlayReportFormattedValue SuperMarioOdysseyChina_AssistMode(ref PlayReportValue value)
|
||||
private static PlayReportFormattedValue SuperMarioOdysseyChina_AssistMode(PlayReportValue value)
|
||||
=> value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式";
|
||||
|
||||
private static PlayReportFormattedValue SuperMario3DWorldOrBowsersFury(ref PlayReportValue value)
|
||||
private static PlayReportFormattedValue SuperMario3DWorldOrBowsersFury(PlayReportValue value)
|
||||
=> value.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury";
|
||||
|
||||
private static PlayReportFormattedValue MarioKart8Deluxe_Mode(ref PlayReportValue value)
|
||||
private static PlayReportFormattedValue MarioKart8Deluxe_Mode(PlayReportValue value)
|
||||
=> value.BoxedValue switch
|
||||
{
|
||||
// Single Player
|
||||
@@ -74,125 +82,4 @@ namespace Ryujinx.Ava.Utilities
|
||||
_ => PlayReportFormattedValue.ForceReset
|
||||
};
|
||||
}
|
||||
|
||||
#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 { TitleIds = [titleId] }));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PlayReportAnalyzer AddSpec(string titleId, Action<PlayReportGameSpec> transform)
|
||||
{
|
||||
_specs.Add(new PlayReportGameSpec { TitleIds = [titleId] }.Apply(transform));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PlayReportAnalyzer AddSpec(IEnumerable<string> titleIds, Func<PlayReportGameSpec, PlayReportGameSpec> transform)
|
||||
{
|
||||
_specs.Add(transform(new PlayReportGameSpec { TitleIds = [..titleIds] }));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PlayReportAnalyzer AddSpec(IEnumerable<string> titleIds, Action<PlayReportGameSpec> 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<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 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
|
||||
}
|
||||
|
||||
274
src/Ryujinx/Utilities/PlayReportAnalyzer.cs
Normal file
274
src/Ryujinx/Utilities/PlayReportAnalyzer.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using Gommon;
|
||||
using MsgPack;
|
||||
using Ryujinx.Ava.Utilities.AppLibrary;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Ryujinx.Ava.Utilities
|
||||
{
|
||||
/// <summary>
|
||||
/// The entrypoint for the Play Report analysis system.
|
||||
/// </summary>
|
||||
public class PlayReportAnalyzer
|
||||
{
|
||||
private readonly List<PlayReportGameSpec> _specs = [];
|
||||
|
||||
/// <summary>
|
||||
/// Add an analysis spec matching a specific game by title ID, with the provided spec configuration.
|
||||
/// </summary>
|
||||
/// <param name="titleId">The ID of the game to listen to Play Reports in.</param>
|
||||
/// <param name="transform">The configuration function for the analysis spec.</param>
|
||||
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
|
||||
public PlayReportAnalyzer AddSpec(string titleId, Func<PlayReportGameSpec, PlayReportGameSpec> transform)
|
||||
{
|
||||
Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _),
|
||||
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(PlayReportGameSpec)}.");
|
||||
|
||||
_specs.Add(transform(new PlayReportGameSpec { TitleIds = [titleId] }));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an analysis spec matching a specific game by title ID, with the provided spec configuration.
|
||||
/// </summary>
|
||||
/// <param name="titleId">The ID of the game to listen to Play Reports in.</param>
|
||||
/// <param name="transform">The configuration function for the analysis spec.</param>
|
||||
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
|
||||
public PlayReportAnalyzer AddSpec(string titleId, Action<PlayReportGameSpec> transform)
|
||||
{
|
||||
Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _),
|
||||
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(PlayReportGameSpec)}.");
|
||||
|
||||
_specs.Add(new PlayReportGameSpec { TitleIds = [titleId] }.Apply(transform));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an analysis spec matching a specific set of games by title IDs, with the provided spec configuration.
|
||||
/// </summary>
|
||||
/// <param name="titleIds">The IDs of the games to listen to Play Reports in.</param>
|
||||
/// <param name="transform">The configuration function for the analysis spec.</param>
|
||||
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
|
||||
public PlayReportAnalyzer AddSpec(IEnumerable<string> titleIds,
|
||||
Func<PlayReportGameSpec, PlayReportGameSpec> transform)
|
||||
{
|
||||
string[] tids = titleIds.ToArray();
|
||||
Guard.Ensure(tids.All(x => ulong.TryParse(x, NumberStyles.HexNumber, null, out _)),
|
||||
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(PlayReportGameSpec)}.");
|
||||
|
||||
_specs.Add(transform(new PlayReportGameSpec { TitleIds = [..tids] }));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an analysis spec matching a specific set of games by title IDs, with the provided spec configuration.
|
||||
/// </summary>
|
||||
/// <param name="titleIds">The IDs of the games to listen to Play Reports in.</param>
|
||||
/// <param name="transform">The configuration function for the analysis spec.</param>
|
||||
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
|
||||
public PlayReportAnalyzer AddSpec(IEnumerable<string> titleIds, Action<PlayReportGameSpec> transform)
|
||||
{
|
||||
string[] tids = titleIds.ToArray();
|
||||
Guard.Ensure(tids.All(x => ulong.TryParse(x, NumberStyles.HexNumber, null, out _)),
|
||||
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(PlayReportGameSpec)}.");
|
||||
|
||||
_specs.Add(new PlayReportGameSpec { TitleIds = [..tids] }.Apply(transform));
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs the configured <see cref="PlayReportGameSpec.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>
|
||||
/// <param name="playReport">The Play Report received from HLE.</param>
|
||||
/// <returns>A struct representing a possible formatted value.</returns>
|
||||
public FormattedValue FormatPlayReportValue(
|
||||
string runningGameId,
|
||||
ApplicationMetadata appMeta,
|
||||
MessagePackObject playReport
|
||||
)
|
||||
{
|
||||
if (!playReport.IsDictionary)
|
||||
return FormattedValue.Unhandled;
|
||||
|
||||
if (!_specs.TryGetFirst(s => runningGameId.EqualsAnyIgnoreCase(s.TitleIds), out PlayReportGameSpec spec))
|
||||
return FormattedValue.Unhandled;
|
||||
|
||||
foreach (PlayReportGameSpec.FormatterSpec formatSpec in spec.SimpleValueFormatters.OrderBy(x => x.Priority))
|
||||
{
|
||||
if (!playReport.AsDictionary().TryGetValue(formatSpec.ReportKey, out MessagePackObject valuePackObject))
|
||||
continue;
|
||||
|
||||
return formatSpec.ValueFormatter(new PlayReportValue
|
||||
{
|
||||
Application = appMeta, PackedValue = valuePackObject
|
||||
});
|
||||
}
|
||||
|
||||
return FormattedValue.Unhandled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A potential formatted value returned by a <see cref="PlayReportValueFormatter"/>.
|
||||
/// </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="PlayReportAnalyzer"/> 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; }
|
||||
|
||||
public void Match(out bool wasHandled, Action onReset, Action<string> onSuccess)
|
||||
{
|
||||
if (!Handled)
|
||||
{
|
||||
wasHandled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Reset)
|
||||
onReset();
|
||||
else
|
||||
onSuccess(FormattedString);
|
||||
|
||||
wasHandled = true;
|
||||
}
|
||||
|
||||
|
||||
/// <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="PlayReportAnalyzer"/> 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="PlayReportValueFormatter"/>.
|
||||
/// </summary>
|
||||
public static readonly PlayReportValueFormatter AlwaysResets = _ => ForceReset;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mapping of title IDs to value formatter specs.
|
||||
///
|
||||
/// <remarks>Generally speaking, use the <see cref="PlayReportAnalyzer"/>.AddSpec(...) methods instead of creating this class yourself.</remarks>
|
||||
/// </summary>
|
||||
public class PlayReportGameSpec
|
||||
{
|
||||
public required string[] TitleIds { get; init; }
|
||||
public List<FormatterSpec> SimpleValueFormatters { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Add a value formatter to the current <see cref="PlayReportGameSpec"/>
|
||||
/// 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="PlayReportGameSpec"/>, for chaining convenience.</returns>
|
||||
public PlayReportGameSpec AddValueFormatter(string reportKey, PlayReportValueFormatter 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="PlayReportGameSpec"/>
|
||||
/// 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="PlayReportGameSpec"/>, for chaining convenience.</returns>
|
||||
public PlayReportGameSpec AddValueFormatter(int priority, string reportKey,
|
||||
PlayReportValueFormatter valueFormatter)
|
||||
{
|
||||
SimpleValueFormatters.Add(new FormatterSpec
|
||||
{
|
||||
Priority = priority, ReportKey = reportKey, 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 PlayReportValueFormatter ValueFormatter { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The input data to a <see cref="PlayReportValueFormatter"/>,
|
||||
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
|
||||
/// and the matched <see cref="MessagePackObject"/> from the Play Report.
|
||||
/// </summary>
|
||||
public class PlayReportValue
|
||||
{
|
||||
/// <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 <see cref="PackedValue"/> and the AsX (where X is a numerical type name i.e. Int32) methods for that.
|
||||
/// </summary>
|
||||
public object BoxedValue => PackedValue.ToObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The delegate type that powers the entire analysis system (as it currently is).<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="PlayReportAnalyzer"/> for.
|
||||
/// </summary>
|
||||
public delegate PlayReportAnalyzer.FormattedValue PlayReportValueFormatter(PlayReportValue value);
|
||||
}
|
||||
Reference in New Issue
Block a user