diff --git a/src/Ryujinx.Horizon/HorizonStatic.cs b/src/Ryujinx.Horizon/HorizonStatic.cs index 15689f0c8..eb9dd4e31 100644 --- a/src/Ryujinx.Horizon/HorizonStatic.cs +++ b/src/Ryujinx.Horizon/HorizonStatic.cs @@ -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 PlayReport; + public static event Action PlayReport; [field: ThreadStatic] public static HorizonOptions Options { get; private set; } diff --git a/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs b/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs index 2f8657e0b..0ca851e6e 100644 --- a/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs +++ b/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs @@ -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; @@ -196,10 +188,17 @@ namespace Ryujinx.Horizon.Prepo.Ipc { return PrepoResult.InvalidBufferSize; } - + 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()); diff --git a/src/Ryujinx.Horizon/Prepo/Types/PlayReport.cs b/src/Ryujinx.Horizon/Prepo/Types/PlayReport.cs new file mode 100644 index 000000000..e896219d5 --- /dev/null +++ b/src/Ryujinx.Horizon/Prepo/Types/PlayReport.cs @@ -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, + } +} diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index 229b6ee09..1f820a223 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -10,6 +10,7 @@ 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; @@ -124,7 +125,7 @@ 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; diff --git a/src/Ryujinx/Utilities/PlayReport/Analyzer.cs b/src/Ryujinx/Utilities/PlayReport/Analyzer.cs index 338c198a1..668eb526c 100644 --- a/src/Ryujinx/Utilities/PlayReport/Analyzer.cs +++ b/src/Ryujinx/Utilities/PlayReport/Analyzer.cs @@ -85,7 +85,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// - /// Runs the configured for the specified game title ID. + /// Runs the configured for the specified game title ID. /// /// The game currently running. /// The Application metadata information, including localized game name and play time information. @@ -94,54 +94,21 @@ 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 (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.Formatter(new Value { Application = appMeta, PackedValue = valuePackObject }); - } - - foreach (MultiFormatterSpec formatSpec in spec.MultiValueFormatters.OrderBy(x => x.Priority)) - { - List 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.Formatter(packedObjects - .Select(packObject => new Value { Application = appMeta, PackedValue = packObject }) - .ToArray()); - } - - foreach (SparseMultiFormatterSpec formatSpec in spec.SparseMultiValueFormatters.OrderBy(x => x.Priority)) - { - Dictionary packedObjects = []; - foreach (var reportKey in formatSpec.ReportKeys) - { - if (!playReport.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject)) - continue; - - packedObjects.Add(reportKey, new Value { Application = appMeta, PackedValue = valuePackObject }); - } - - return formatSpec.Formatter(packedObjects); + return value; } return FormattedValue.Unhandled; diff --git a/src/Ryujinx/Utilities/PlayReport/Delegates.cs b/src/Ryujinx/Utilities/PlayReport/Delegates.cs index 7c8952e18..92569d32e 100644 --- a/src/Ryujinx/Utilities/PlayReport/Delegates.cs +++ b/src/Ryujinx/Utilities/PlayReport/Delegates.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace Ryujinx.Ava.Utilities.PlayReport +namespace Ryujinx.Ava.Utilities.PlayReport { /// /// The delegate type that powers single value formatters.
@@ -12,7 +10,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport ///
/// OR a signal to reset the value that the caller is using the for. ///
- public delegate FormattedValue ValueFormatter(Value value); + public delegate FormattedValue SingleValueFormatter(SingleValue value); /// /// The delegate type that powers multiple value formatters.
@@ -24,7 +22,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport ///
/// OR a signal to reset the value that the caller is using the for. ///
- public delegate FormattedValue MultiValueFormatter(Value[] value); + public delegate FormattedValue MultiValueFormatter(MultiValue value); /// /// The delegate type that powers multiple value formatters. @@ -38,5 +36,5 @@ namespace Ryujinx.Ava.Utilities.PlayReport ///
/// OR a signal to reset the value that the caller is using the for. ///
- public delegate FormattedValue SparseMultiValueFormatter(Dictionary values); + public delegate FormattedValue SparseMultiValueFormatter(SparseMultiValue value); } diff --git a/src/Ryujinx/Utilities/PlayReport/MatchedValues.cs b/src/Ryujinx/Utilities/PlayReport/MatchedValues.cs new file mode 100644 index 000000000..3086a9d65 --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport/MatchedValues.cs @@ -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 + { + protected MatchedValue(T matched) + { + Matched = matched; + } + + /// + /// The currently running application's . + /// + public ApplicationMetadata Application { get; init; } + + /// + /// The entire play report. + /// + public Horizon.Prepo.Types.PlayReport PlayReport { get; init; } + + /// + /// The matched value from the Play Report. + /// + public T Matched { get; init; } + } + + /// + /// The input data to a , + /// containing the currently running application's , + /// and the matched from the Play Report. + /// + public class SingleValue : MatchedValue + { + public SingleValue(Value matched) : base(matched) + { + } + } + + /// + /// The input data to a , + /// containing the currently running application's , + /// and the matched s from the Play Report. + /// + public class MultiValue : MatchedValue + { + public MultiValue(Value[] matched) : base(matched) + { + } + + public MultiValue(IEnumerable matched) : base(Value.ConvertPackedObjects(matched)) + { + } + } + + /// + /// The input data to a , + /// containing the currently running application's , + /// and the matched s from the Play Report. + /// + public class SparseMultiValue : MatchedValue> + { + public SparseMultiValue(Dictionary matched) : base(matched) + { + } + + public SparseMultiValue(Dictionary matched) : base(Value.ConvertPackedObjectMap(matched)) + { + } + } +} diff --git a/src/Ryujinx/Utilities/PlayReport/PlayReports.cs b/src/Ryujinx/Utilities/PlayReport/PlayReports.cs index ae954c81c..9e22cd6d2 100644 --- a/src/Ryujinx/Utilities/PlayReport/PlayReports.cs +++ b/src/Ryujinx/Utilities/PlayReport/PlayReports.cs @@ -39,28 +39,28 @@ .AddValueFormatter("team_circle", PokemonSVUnionCircle) ); - private static FormattedValue BreathOfTheWild_MasterMode(Value value) - => value.BoxedValue is 1 ? "Playing Master Mode" : FormattedValue.ForceReset; + private static FormattedValue BreathOfTheWild_MasterMode(SingleValue value) + => value.Matched.BoxedValue is 1 ? "Playing Master Mode" : FormattedValue.ForceReset; - private static FormattedValue TearsOfTheKingdom_CurrentField(Value value) => - value.DoubleValue switch + 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(Value value) - => value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; + 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(Value value) - => value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; + private static FormattedValue SuperMarioOdysseyChina_AssistMode(SingleValue value) + => value.Matched.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 SuperMario3DWorldOrBowsersFury(SingleValue value) + => value.Matched.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; - private static FormattedValue MarioKart8Deluxe_Mode(Value value) - => value.StringValue switch + private static FormattedValue MarioKart8Deluxe_Mode(SingleValue value) + => value.Matched.StringValue switch { // Single Player "Single" => "Single Player", @@ -87,11 +87,11 @@ _ => FormattedValue.ForceReset }; - private static FormattedValue PokemonSVUnionCircle(Value value) - => value.BoxedValue is 0 ? "Playing Alone" : "Playing in a group"; + private static FormattedValue PokemonSVUnionCircle(SingleValue value) + => value.Matched.BoxedValue is 0 ? "Playing Alone" : "Playing in a group"; - private static FormattedValue PokemonSVArea(Value value) - => value.StringValue switch + private static FormattedValue PokemonSVArea(SingleValue value) + => value.Matched.StringValue switch { // Base Game Locations "a_w01" => "South Area One", diff --git a/src/Ryujinx/Utilities/PlayReport/Specs.cs b/src/Ryujinx/Utilities/PlayReport/Specs.cs index 649813b7a..e7972fbb4 100644 --- a/src/Ryujinx/Utilities/PlayReport/Specs.cs +++ b/src/Ryujinx/Utilities/PlayReport/Specs.cs @@ -1,4 +1,7 @@ using FluentAvalonia.Core; +using MsgPack; +using Ryujinx.Ava.Utilities.AppLibrary; +using System; using System.Collections.Generic; using System.Linq; @@ -11,10 +14,11 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// public class GameSpec { + private int _lastPriority; + public required string[] TitleIds { get; init; } - public List SimpleValueFormatters { get; } = []; - public List MultiValueFormatters { get; } = []; - public List SparseMultiValueFormatters { get; } = []; + + public List ValueFormatters { get; } = []; /// @@ -24,8 +28,8 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// The key name to match. /// The function which can return a potential formatted value. /// The current , for chaining convenience. - public GameSpec AddValueFormatter(string reportKey, ValueFormatter valueFormatter) - => AddValueFormatter(SimpleValueFormatters.Count, reportKey, valueFormatter); + public GameSpec AddValueFormatter(string reportKey, SingleValueFormatter valueFormatter) + => AddValueFormatter(_lastPriority++, reportKey, valueFormatter); /// /// Add a value formatter at a specific priority to the current @@ -36,11 +40,11 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// The function which can return a potential formatted value. /// The current , for chaining convenience. public GameSpec AddValueFormatter(int priority, string reportKey, - ValueFormatter valueFormatter) + SingleValueFormatter valueFormatter) { - SimpleValueFormatters.Add(new FormatterSpec + ValueFormatters.Add(new FormatterSpec { - Priority = priority, ReportKey = reportKey, Formatter = valueFormatter + Priority = priority, ReportKeys = [reportKey], Formatter = valueFormatter }); return this; } @@ -53,7 +57,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// The function which can format the values. /// The current , for chaining convenience. public GameSpec AddMultiValueFormatter(string[] reportKeys, MultiValueFormatter valueFormatter) - => AddMultiValueFormatter(MultiValueFormatters.Count, reportKeys, valueFormatter); + => AddMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter); /// /// Add a multi-value formatter at a specific priority to the current @@ -66,7 +70,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport public GameSpec AddMultiValueFormatter(int priority, string[] reportKeys, MultiValueFormatter valueFormatter) { - MultiValueFormatters.Add(new MultiFormatterSpec + ValueFormatters.Add(new MultiFormatterSpec { Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter }); @@ -84,7 +88,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// The function which can format the values. /// The current , for chaining convenience. public GameSpec AddSparseMultiValueFormatter(string[] reportKeys, SparseMultiValueFormatter valueFormatter) - => AddSparseMultiValueFormatter(SparseMultiValueFormatters.Count, reportKeys, valueFormatter); + => AddSparseMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter); /// /// Add a multi-value formatter at a specific priority to the current @@ -100,7 +104,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport public GameSpec AddSparseMultiValueFormatter(int priority, string[] reportKeys, SparseMultiValueFormatter valueFormatter) { - SparseMultiValueFormatters.Add(new SparseMultiFormatterSpec + ValueFormatters.Add(new SparseMultiFormatterSpec { Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter }); @@ -111,30 +115,101 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// /// A struct containing the data for a mapping of a key in a Play Report to a formatter for its potential value. /// - public struct FormatterSpec + public class FormatterSpec : FormatterSpecBase { - public required int Priority { get; init; } - public required string ReportKey { get; init; } - public ValueFormatter Formatter { get; init; } + 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; + } } /// /// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their potential values. /// - public struct MultiFormatterSpec + public class MultiFormatterSpec : FormatterSpecBase { - public required int Priority { get; init; } - public required string[] ReportKeys { get; init; } - public MultiValueFormatter Formatter { get; init; } + public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result) + { + List 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; + } } /// /// 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. /// - public struct SparseMultiFormatterSpec + public class SparseMultiFormatterSpec : FormatterSpecBase { - public required int Priority { get; init; } - public required string[] ReportKeys { get; init; } - public SparseMultiValueFormatter Formatter { get; init; } + public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result) + { + Dictionary 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 messagePackObjects: + formattedValue = mvf(new MultiValue(messagePackObjects) { Application = appMeta, PlayReport = playReport }); + return true; + case SparseMultiValueFormatter smvf when + data is Dictionary sparseMessagePackObjects: + formattedValue = smvf(new SparseMultiValue(sparseMessagePackObjects) { Application = appMeta, PlayReport = playReport }); + return true; + default: + throw new InvalidOperationException("Formatter delegate is not of a known type!"); + } + } } } diff --git a/src/Ryujinx/Utilities/PlayReport/Value.cs b/src/Ryujinx/Utilities/PlayReport/Value.cs index 46d47366d..b3108a41e 100644 --- a/src/Ryujinx/Utilities/PlayReport/Value.cs +++ b/src/Ryujinx/Utilities/PlayReport/Value.cs @@ -1,20 +1,20 @@ using MsgPack; -using Ryujinx.Ava.Utilities.AppLibrary; using System; +using System.Collections.Generic; +using System.Linq; namespace Ryujinx.Ava.Utilities.PlayReport { /// - /// The input data to a , - /// containing the currently running application's , + /// The base input data to a ValueFormatter delegate, /// and the matched from the Play Report. /// - public class Value + public readonly struct Value { - /// - /// The currently running application's . - /// - public ApplicationMetadata Application { get; init; } + public Value(MessagePackObject packedValue) + { + PackedValue = packedValue; + } /// /// The matched value from the Play Report. @@ -37,6 +37,17 @@ namespace Ryujinx.Ava.Utilities.PlayReport : boxed.ToString(); } + public static implicit operator Value(MessagePackObject matched) => new(matched); + + public static Value[] ConvertPackedObjects(IEnumerable packObjects) + => packObjects.Select(packObject => new Value(packObject)).ToArray(); + + public static Dictionary ConvertPackedObjectMap(Dictionary packObjects) + => packObjects.ToDictionary( + x => x.Key, + x => new Value(x.Value) + ); + #region AsX accessors public bool BooleanValue => PackedValue.AsBoolean(); @@ -57,7 +68,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport } /// - /// A potential formatted value returned by a . + /// A potential formatted value returned by a ValueFormatter delegate. /// public readonly struct FormattedValue { @@ -103,28 +114,47 @@ namespace Ryujinx.Ava.Utilities.PlayReport /// /// Return this to tell the caller there is no value to return. /// - public static FormattedValue Unhandled => default; + public static readonly FormattedValue Unhandled = default; /// /// Return this to suggest the caller reset the value it's using the for. /// - public static FormattedValue ForceReset => new() { Handled = true, Reset = true }; + public static readonly FormattedValue ForceReset = new() { Handled = true, Reset = true }; /// - /// A delegate singleton you can use to always return in a . + /// A delegate singleton you can use to always return in a . /// - public static readonly ValueFormatter SingleAlwaysResets = _ => ForceReset; + public static readonly SingleValueFormatter SingleAlwaysResets = _ => ForceReset; /// /// A delegate singleton you can use to always return in a . /// public static readonly MultiValueFormatter MultiAlwaysResets = _ => ForceReset; + + /// + /// A delegate singleton you can use to always return in a . + /// + public static readonly SparseMultiValueFormatter SparseMultiAlwaysResets = _ => ForceReset; /// /// A delegate factory you can use to always return the specified - /// in a . + /// in a . /// /// The string to always return for this delegate instance. - public static ValueFormatter AlwaysReturns(string formattedValue) => _ => formattedValue; + public static SingleValueFormatter SingleAlwaysReturns(string formattedValue) => _ => formattedValue; + + /// + /// A delegate factory you can use to always return the specified + /// in a . + /// + /// The string to always return for this delegate instance. + public static MultiValueFormatter MultiAlwaysReturns(string formattedValue) => _ => formattedValue; + + /// + /// A delegate factory you can use to always return the specified + /// in a . + /// + /// The string to always return for this delegate instance. + public static SparseMultiValueFormatter SparseMultiAlwaysReturns(string formattedValue) => _ => formattedValue; } }