From a31b9e29b94052228029b25d9c83416943cd3626 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:41:56 -0400 Subject: [PATCH 01/13] improve options system --- .editorconfig | 3 + MiraAPI.Example/Options/ExampleOptions.cs | 54 ++++- MiraAPI.Example/Options/ExampleOptions2.cs | 4 +- .../Delegate/ModdedOptionChangedAttribute.cs | 88 +++++++ .../Delegate/ModdedOptionGetterAttribute.cs | 88 +++++++ .../Delegate/ModdedOptionMemberAttribute.cs | 226 ++++++++++++++++++ .../Delegate/ModdedOptionSetterAttribute.cs | 88 +++++++ .../ModdedOptionVisibilityAttribute.cs | 189 +++++++++++++++ .../Attributes/ModdedEnumOptionAttribute.cs | 3 +- .../ModdedOptionVisibilityAttribute.cs | 201 ---------------- .../Attributes/ModdedStringOptionAttribute.cs | 39 +++ .../ModdedEnumOptionListAttribute.cs | 7 +- .../ModdedNumberOptionListAttribute.cs | 9 +- .../OptionList/ModdedStringOptionList.cs | 42 ++++ MiraAPI/GameOptions/IModdedOption.cs | 21 ++ MiraAPI/GameOptions/ModdedOptionsManager.cs | 99 ++++++-- .../OptionTypes/ModdedEnumOption.cs | 12 +- .../GameOptions/OptionTypes/ModdedOption.cs | 74 ++++-- .../OptionTypes/ModdedOptionList.cs | 3 + .../OptionTypes/ModdedStringOption.cs | 98 ++++---- MiraAPI/MiraApiPlugin.cs | 6 + MiraAPI/Patches/Options/SettingPatches.cs | 2 + MiraAPI/Presets/OptionPreset.cs | 3 - MiraAPI/Utilities/MiraNumberSuffixes.cs | 5 + 24 files changed, 1060 insertions(+), 304 deletions(-) create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs delete mode 100644 MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs diff --git a/.editorconfig b/.editorconfig index 8d85d5f..6daa6a7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -101,3 +101,6 @@ dotnet_diagnostic.IDE0046.severity = none # S1244: Floating point numbers should not be tested for equality dotnet_diagnostic.S1244.severity = none + +# S2325: Methods and properties that don't access instance data should be static +dotnet_diagnostic.S2325.severity = none diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index cba80e6..c846308 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Utilities; @@ -6,33 +7,68 @@ using UnityEngine; namespace MiraAPI.Example.Options; +[SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Demo.")] +[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Read above.")] +[SuppressMessage("Minor Code Smell", "S3400:Methods should not return constants", Justification = "Read above.")] public class ExampleOptions : AbstractOptionGroup { public override string GroupName => "options.exampleOptions1"; public override Color GroupColor => Color.green; [ModdedToggleOption("options.exampleOptions1.toggleOpt1")] - public bool ToggleOpt { get; set; } = false; + [ModdedOptionVisibility] // Automatically resolves to using ToggleOpt1Visibility, can also target toggle options + public bool ToggleOpt1 { get; set; } = false; - [ModdedToggleOption("options.exampleOptions1.toggleOpt1")] + [ModdedToggleOption("options.exampleOptions1.toggleOpt2")] + [ModdedOptionVisibility(nameof(ToggleOpt1))] // ToggleOpt2 will be visible only when ToggleOpt1 is true public bool ToggleOpt2 { get; set; } = true; - [ModdedToggleOption("Toggle Opt 3")] - [ModdedOptionVisibility(nameof(ToggleOpt2))] // ToggleOpt3 will be visible only when ToggleOpt2 is true - public bool ToggleOpt3 { get; set; } = true; - [ModdedNumberOption("options.exampleOptions1.numberOpt", min: 0, max: 10, increment: .25f, formatString: "0.00", suffixType: MiraNumberSuffixes.Percent)] + [ModdedOptionVisibility(nameof(NumberOptVisibility))] // Can also target static methods public float NumberOpt { get; set; } = 4f; - [ModdedEnumOption("options.exampleOptions1.bestApi", typeof(BestApi), ["Mira API", "Mitochondria", "Reactor"])] - public BestApi Opt { get; set; } = BestApi.MiraAPI; + [ModdedEnumOption("options.exampleOptions1.bestApi", typeof(Api), ["Mira API", "Mitochondria", "Reactor"])] + + // These three can target both instance and static methods + [ModdedOptionGetter(nameof(GetApi))] // Can also automatically resolve to {Option Name}Getter + [ModdedOptionSetter(nameof(SetApi))] // Can also automatically resolve to {Option Name}Setter + [ModdedOptionChanged(nameof(ApiChanged))] // Can also automatically resolve to {Option Name}Changed + public Api BestApi { get; set; } = Api.MiraAPI; + + [ModdedEnumOption("options.exampleOptions1.worstApi", ["Mira API", "Mitochondria", "Reactor"])] + public Api WorstApi { get; set; } = Api.MiraAPI; [ModdedEnumOptionList("Value State {0}", ["None", "Primary", "Secondary"])] public List ValueStates { get; } = [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; + + public bool ToggleOpt1Visibility() + { + return true; + } + + public static bool NumberOptVisibility() + { + return true; + } + + public static Api GetApi(Api currentValue) + { + return currentValue; + } + + public static Api SetApi(Api newValue, Api oldValue) + { + return default; + } + + public static void ApiChanged(Api currentValue) + { + // Something + } } -public enum BestApi +public enum Api { MiraAPI, Mitochondria, diff --git a/MiraAPI.Example/Options/ExampleOptions2.cs b/MiraAPI.Example/Options/ExampleOptions2.cs index 24a061c..bdc5a1b 100644 --- a/MiraAPI.Example/Options/ExampleOptions2.cs +++ b/MiraAPI.Example/Options/ExampleOptions2.cs @@ -7,9 +7,7 @@ namespace MiraAPI.Example.Options; public class ExampleOptions2 : AbstractOptionGroup { public override string GroupName => "options.exampleOptions2"; - public override uint GroupPriority => 0; // This group will be displayed first. The default value is uint.MaxValue. - public override MenuCategory ParentMenu => MenuCategory.CustomTwo; public ModdedPlayerOption PlayerOption2 { get; } = new("options.exampleOptions2.youMustChoseAPlayer", false); @@ -27,6 +25,8 @@ public class ExampleOptions2 : AbstractOptionGroup public ModdedEnumOption EnumOpt { get; } = new("options.exampleOptions2.enumOpt", 0) { ChangedEvent = x => Logger.Info($"changed Enum Opt to {x}"), + Getter = currentValue => currentValue, + Setter = (newValue, oldValue) => newValue, }; } diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs new file mode 100644 index 0000000..0d75a11 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The method to get the changed event from. +/// The type the method is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionChangedAttribute(string? methodName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(methodName, holderType, "Changed") +{ + internal Delegate? GetChanged(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != typeof(void)) + { + Error($"{MemberName}'s return type is not void."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != 1) + { + Error($"{MemberName} must have exactly one parameter."); + return null; + } + + var valueType = option.GetValueType(); + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s parameter must be of the type {valueType.Name}."); + return null; + } + + var delegateType = typeof(Action<>).MakeGenericType(valueType); + return CompileDynamicDelegate(type, method, instanceGetter, typeof(void), [valueType], delegateType, "Changed"); + } + + internal Func? GetListChanged(AbstractOptionGroup group, PropertyInfo property, Type valueType) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != typeof(void)) + { + Error($"{MemberName}'s return type is not void."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length is < 1 or > 2) + { + Error($"{MemberName} must have one parameter ({valueType.Name}) or two parameters ({valueType.Name}, int)."); + return null; + } + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s first parameter must be of the type {valueType.Name}."); + return null; + } + + var hasIndex = parameters.Length == 2; + + if (hasIndex && parameters[1].ParameterType != typeof(int)) + { + Error($"{MemberName}'s second parameter must be an int (the list index)."); + return null; + } + + var delegateType = typeof(Action<>).MakeGenericType(valueType); + return CompileDynamicListDelegate(type, method, instanceGetter, typeof(void), [valueType], delegateType, "ListChanged", hasIndex); + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs new file mode 100644 index 0000000..3434951 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The method to get the getter from. +/// The type the method is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionGetterAttribute(string? methodName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(methodName, holderType, "Getter") +{ + internal Delegate? GetGetter(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + var valueType = option.GetValueType(); + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != 1) + { + Error($"{MemberName} must have exactly one parameter."); + return null; + } + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s parameter must be of the type {valueType.Name}."); + return null; + } + + var delegateType = typeof(Func<,>).MakeGenericType(valueType, valueType); + return CompileDynamicDelegate(type, method, instanceGetter, valueType, [valueType], delegateType, "Getter"); + } + + internal Func? GetListGetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length is < 1 or > 2) + { + Error($"{MemberName} must have one parameter ({valueType.Name}) or two parameters ({valueType.Name}, int)."); + return null; + } + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s first parameter must be of the type {valueType.Name}."); + return null; + } + + var hasIndex = parameters.Length == 2; + + if (hasIndex && parameters[1].ParameterType != typeof(int)) + { + Error($"{MemberName}'s second parameter must be an int (the list index)."); + return null; + } + + var delegateType = typeof(Func<,>).MakeGenericType(valueType, valueType); + return CompileDynamicListDelegate(type, method, instanceGetter, valueType, [valueType], delegateType, "ListGetter", hasIndex); + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs new file mode 100644 index 0000000..1db3700 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs @@ -0,0 +1,226 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Base attribute for resolving and compiling option delegate members (, , , and ). +/// +/// The name of the target member, or to use the property name with . +/// The type containing the target member, or to use the declaring type. +/// The default suffix appended to the property name when is not specified. +[AttributeUsage(AttributeTargets.Property)] +public abstract class ModdedOptionMemberAttribute(string? memberName, Type? holderType, string defaultSuffix) : Attribute +{ + /// + /// Gets the resolved name of the target member. + /// + protected string? MemberName { get; private set; } = memberName; + + /// + /// Resolves the target (property or method) and its associated singleton instance getter, if applicable. + /// + /// The option group instance the property belongs to. + /// The option property decorated with this attribute. + /// When this method returns, contains the resolved expected to hold the target member. + /// + /// When this method returns, contains the getter + /// if the resolved member is an instance member on an ; otherwise, . + /// + /// The resolved if found; otherwise, . + [SuppressMessage("Major Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "It is safe.")] + protected MemberInfo? ResolveMember(AbstractOptionGroup group, PropertyInfo property, out Type type, out MethodInfo? instanceGetter) + { + var groupType = group.GetType(); + var flags = BindingFlags.Static | BindingFlags.Public; + + type = holderType ?? groupType; + MemberName ??= $"{property.Name}{defaultSuffix}"; + + if (type.IsAssignableTo(typeof(AbstractOptionGroup))) + { + flags |= BindingFlags.Instance; + + if (type == groupType) + flags |= BindingFlags.NonPublic; + + instanceGetter = typeof(OptionGroupSingleton<>) + .MakeGenericType(type) + .GetProperty(nameof(OptionGroupSingleton<>.Instance))! + .GetGetMethod(); + } + else + { + instanceGetter = null; + } + + var member = type.GetMember(MemberName, flags).FirstOrDefault(); + + if (instanceGetter != null) + { + var method = (member as PropertyInfo)?.GetGetMethod(true) ?? member as MethodInfo; + if (method is { IsStatic: true }) + instanceGetter = null; + } + + return member; + } + + /// + /// Resolves the target and its associated singleton instance getter, if applicable. + /// + /// The option group instance the property belongs to. + /// The option property decorated with this attribute. + /// When this method returns, contains the resolved expected to hold the target method. + /// + /// When this method returns, contains the getter + /// if the resolved method is an instance method on an ; otherwise, . + /// + /// The resolved if found; otherwise, . + protected MethodInfo? ResolveMethod(AbstractOptionGroup group, PropertyInfo property, out Type type, out MethodInfo? instanceGetter) + { + return ResolveMember(group, property, out type, out instanceGetter) as MethodInfo; + } + + /// + /// Compiles a using IL generation to invoke the resolved static or instance . + /// + /// The that owns the generated and holds the target method. + /// The target to invoke. + /// + /// The static getter used to push the target instance onto the evaluation stack for instance methods, + /// or if is static. + /// + /// The return of the generated delegate (or for actions). + /// The parameter types accepted by the generated delegate. + /// The concrete delegate to create (e.g., or ). + /// A descriptive suffix appended to the generated name for debugging and stack traces. + /// Whether to push the delegate parameters onto the stack before invoking . + /// An optional callback to emit additional IL instructions before returning. + /// A compiled of type bound to the generated IL method. + protected static Delegate CompileDynamicDelegate( + Type ownerType, + MethodInfo method, + MethodInfo? instanceGetter, + Type returnType, + Type[] paramTypes, + Type delegateType, + string suffix, + bool forwardParameters = true, + Action? postProcessCall = null) + { + var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}", returnType, paramTypes, ownerType, true); + var il = dm.GetILGenerator(); + + if (!method.IsStatic && instanceGetter != null) + il.Emit(OpCodes.Call, instanceGetter); + + if (forwardParameters) + { + for (byte i = 0; i < paramTypes.Length; i++) + { + if (i == 0) + il.Emit(OpCodes.Ldarg_0); + else if (i == 1) + il.Emit(OpCodes.Ldarg_1); + else if (i == 2) + il.Emit(OpCodes.Ldarg_2); + else if (i == 3) + il.Emit(OpCodes.Ldarg_3); + else + il.Emit(OpCodes.Ldarg_S, i); + } + } + + var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; + il.Emit(callOp, method); + + postProcessCall?.Invoke(il); + + il.Emit(OpCodes.Ret); + + return dm.CreateDelegate(delegateType); + } + + /// + /// Compiles a factory function that provides a for each index in an option list. + /// If is and is , + /// a single is compiled once and reused for all indices. + /// + /// The that owns the generated and holds the target method. + /// The target to invoke. + /// + /// The static getter used to push the target instance onto the evaluation stack for instance methods, + /// or if is static. + /// + /// The return of the generated delegate (or for actions). + /// The parameter types accepted by the generated delegate. + /// The concrete delegate to create. + /// A descriptive suffix appended to the generated name for debugging and stack traces. + /// Whether the target takes the list index as its final parameter. + /// An optional index-independent callback to emit additional IL instructions after is called. + /// An optional index-dependent callback to emit additional IL instructions after is called. + /// A factory function that returns a compiled of type for a given list index. + protected static Func CompileDynamicListDelegate( + Type ownerType, + MethodInfo method, + MethodInfo? instanceGetter, + Type returnType, + Type[] baseParamTypes, + Type delegateType, + string suffix, + bool hasIndexParameter, + Action? postProcessCall = null, + Action? postProcessListCall = null) + { + if (!hasIndexParameter && postProcessListCall is null) + { + var sharedDelegate = CompileDynamicDelegate( + ownerType, + method, + instanceGetter, + returnType, + baseParamTypes, + delegateType, + suffix, + forwardParameters: baseParamTypes.Length > 0, + postProcessCall: postProcessCall); + + return _ => sharedDelegate; + } + + return index => + { + var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}_{index}", returnType, baseParamTypes, ownerType, true); + var il = dm.GetILGenerator(); + + if (!method.IsStatic && instanceGetter != null) + il.Emit(OpCodes.Call, instanceGetter); + + for (byte i = 0; i < baseParamTypes.Length; i++) + { + if (i == 0) il.Emit(OpCodes.Ldarg_0); + else if (i == 1) il.Emit(OpCodes.Ldarg_1); + else if (i == 2) il.Emit(OpCodes.Ldarg_2); + else if (i == 3) il.Emit(OpCodes.Ldarg_3); + else il.Emit(OpCodes.Ldarg_S, i); + } + + if (hasIndexParameter) + il.Emit(OpCodes.Ldc_I4, index); + + var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; + il.Emit(callOp, method); + + postProcessCall?.Invoke(il); + postProcessListCall?.Invoke(il, index); + + il.Emit(OpCodes.Ret); + + return dm.CreateDelegate(delegateType); + }; + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs new file mode 100644 index 0000000..370c7f9 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The method to get the setter from. +/// The type the method is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionSetterAttribute(string? methodName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(methodName, holderType, "Setter") +{ + internal Delegate? GetSetter(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + var valueType = option.GetValueType(); + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != 2) + { + Error($"{MemberName} must have exactly two parameters."); + return null; + } + + if (parameters[0].ParameterType != valueType || parameters[1].ParameterType != valueType) + { + Error($"{MemberName}'s parameters must both be of the type {valueType.Name}."); + return null; + } + + var delegateType = typeof(Func<,,>).MakeGenericType(valueType, valueType, valueType); + return CompileDynamicDelegate(type, method, instanceGetter, valueType, [valueType, valueType], delegateType, "Setter"); + } + + internal Func? GetListSetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length is < 2 or > 3) + { + Error($"{MemberName} must have two parameters ({valueType.Name}, {valueType.Name}) or three parameters ({valueType.Name}, {valueType.Name}, int)."); + return null; + } + + if (parameters[0].ParameterType != valueType || parameters[1].ParameterType != valueType) + { + Error($"{MemberName}'s first two parameters must both be of the type {valueType.Name}."); + return null; + } + + var hasIndex = parameters.Length == 3; + + if (hasIndex && parameters[2].ParameterType != typeof(int)) + { + Error($"{MemberName}'s third parameter must be an int (the list index)."); + return null; + } + + var delegateType = typeof(Func<,,>).MakeGenericType(valueType, valueType, valueType); + return CompileDynamicListDelegate(type, method, instanceGetter, valueType, [valueType, valueType], delegateType, "ListSetter", hasIndex); + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs new file mode 100644 index 0000000..60e494d --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using MiraAPI.GameOptions.OptionTypes; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The member to get the visibility function from. +/// The type the member is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(memberName, holderType, "Visibility") +{ + private static readonly MethodInfo ModdedOptionBoolValueGetter = typeof(ModdedOption).GetProperty(nameof(ModdedOption<>.Value))!.GetGetMethod()!; + private static readonly MethodInfo ListBoolIndexerGetter = typeof(IList).GetProperty("Item")!.GetGetMethod()!; + + internal Func? GetVisibility(AbstractOptionGroup group, PropertyInfo property) + { + if (ResolveMember(group, property, out Type type, out var instanceGetter) is not { } member) + { + Error($"Member {MemberName} does not exist in {type.FullName}"); + return null; + } + + MethodInfo? targetMethod; + Action? postProcess = null; + + if (member is PropertyInfo vProperty) + { + if (vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption))) + { + postProcess = il => il.Emit(OpCodes.Callvirt, ModdedOptionBoolValueGetter); + } + else if (!vProperty.PropertyType.IsAssignableTo(typeof(bool))) + { + Error($"Property {MemberName} is not a bool or a bool ModdedOption."); + return null; + } + + if (vProperty.GetIndexParameters().Length > 0) + { + Error($"Property {MemberName} cannot be an indexer."); + return null; + } + + targetMethod = vProperty.GetGetMethod(true); + + if (targetMethod is null) + { + Error($"Property {MemberName} does not have a getter."); + return null; + } + } + else if (member is MethodInfo vMethod) + { + if (vMethod.ReturnType != typeof(bool)) + { + Error($"Method {MemberName} does not return a bool."); + return null; + } + + if (vMethod.GetParameters().Length > 0) + { + Error($"Method {MemberName} has too many parameters."); + return null; + } + + targetMethod = vMethod; + } + else + { + Error($"Member {MemberName} of {type.FullName} is not valid for Visible function."); + return null; + } + + return (Func)CompileDynamicDelegate(type, targetMethod, instanceGetter, typeof(bool), Type.EmptyTypes, typeof(Func), "Visibility", false, postProcess); + } + + internal Func? GetListVisibility(AbstractOptionGroup group, PropertyInfo property) + { + if (ResolveMember(group, property, out Type type, out var instanceGetter) is not { } member) + { + Error($"Member {MemberName} does not exist in {type.FullName}"); + return null; + } + + MethodInfo? targetMethod; + Action? postProcess = null; + Action? postProcessList = null; + + var hasIndexParam = false; + + if (member is PropertyInfo vProperty) + { + var isListBool = false; + + if (vProperty.PropertyType.IsAssignableTo(typeof(IList))) + { + isListBool = true; + postProcessList = (il, index) => + { + il.Emit(OpCodes.Ldc_I4, index); + il.Emit(OpCodes.Callvirt, ListBoolIndexerGetter); + }; + } + else if (vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption))) + { + postProcess = il => il.Emit(OpCodes.Callvirt, ModdedOptionBoolValueGetter); + } + else if (!vProperty.PropertyType.IsAssignableTo(typeof(bool))) + { + Error($"Property {MemberName} is not a bool value or indexer, a list of bools, or a bool ModdedOption."); + return null; + } + + var indexParams = vProperty.GetIndexParameters(); + if (indexParams.Length > 1) + { + Error($"Indexer {MemberName} has too many parameters."); + return null; + } + + if (isListBool && indexParams.Length != 0) + { + Error($"Property {MemberName} cannot be an indexer of lists of bools."); + return null; + } + + if (indexParams.Length == 1) + { + if (indexParams[0].ParameterType != typeof(int)) + { + Error($"Indexer {MemberName}'s parameter is not an int."); + return null; + } + + hasIndexParam = true; + } + + targetMethod = vProperty.GetGetMethod(true); + + if (targetMethod is null) + { + Error($"Property {MemberName} does not have a getter."); + return null; + } + } + else if (member is MethodInfo vMethod) + { + if (vMethod.ReturnType != typeof(bool)) + { + Error($"Method {MemberName} does not return a bool."); + return null; + } + + var paramList = vMethod.GetParameters(); + + if (paramList.Length > 1) + { + Error($"Method {MemberName} has too many parameters."); + return null; + } + + if (paramList.Length == 1) + { + if (paramList[0].ParameterType != typeof(int)) + { + Error($"Method {MemberName}'s parameter is not an int."); + return null; + } + + hasIndexParam = true; + } + + targetMethod = vMethod; + } + else + { + Error($"Member {MemberName} of {type.FullName} is not valid for Visibility function."); + return null; + } + + return CompileDynamicListDelegate(type, targetMethod, instanceGetter, typeof(bool), Type.EmptyTypes, typeof(Func), "ListVisibility", hasIndexParam, postProcess, postProcessList); + } +} diff --git a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs index cf517ba..22f4369 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs @@ -41,10 +41,11 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va /// Attribute for creating an enum option. /// /// The enum type. +/// The title of the option. [AttributeUsage(AttributeTargets.Property)] public class ModdedEnumOptionAttribute(string title, string[]? values = null) : ModdedOptionAttribute(title) - where T : Enum + where T : struct, Enum { internal override IModdedOption CreateOption(object? value, PropertyInfo property) { diff --git a/MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs deleted file mode 100644 index 690ba36..0000000 --- a/MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using MiraAPI.GameOptions.OptionTypes; - -namespace MiraAPI.GameOptions.Attributes; - -/// -/// Attribute to modify a property with a or . -/// -/// -/// Initializes a new instance of the class. -/// -/// The type the member is in. -/// The member to get the visibility function from. -[AttributeUsage(AttributeTargets.Property)] -public sealed class ModdedOptionVisibilityAttribute(Type? holderType = null, string? memberName = null) : Attribute -{ - /// - /// Initializes a new instance of the class. - /// - /// The member to get the visibility function from. - public ModdedOptionVisibilityAttribute(string memberName) - : this(null, memberName) - { - } - - internal Func? GetVisibility(AbstractOptionGroup group, PropertyInfo property) - { - if (GetVisibilityMember(group, property, out Type type, out var instanceGet) is not { } member) - { - Error($"Member {memberName} does not exist in {type.FullName}"); - return null; - } - - if (member is PropertyInfo vProperty) - { - if (!vProperty.PropertyType.IsAssignableTo(typeof(bool)) && - !vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption))) - { - Error($"Property {memberName} is not a bool or a bool ModdedOption."); - return null; - } - - var indexParams = vProperty.GetIndexParameters(); - if (indexParams.Length > 0) - { - Error($"Property {memberName} cannot be an indexer."); - return null; - } - - return () => (bool)vProperty.GetValue(instanceGet?.Invoke())!; - } - - if (member is MethodInfo vMethod) - { - if (vMethod.ReturnType != typeof(bool)) - { - Error($"Method {memberName} does not return a bool."); - return null; - } - - var paramList = vMethod.GetParameters(); - if (paramList.Length > 0) - { - Error($"Method {memberName} has too many parameters."); - return null; - } - - return () => (bool)vMethod.Invoke(instanceGet?.Invoke(), null)!; - } - - Error($"Member {memberName} of {type.FullName} is not valid for Visible function."); - return null; - } - - internal Func? GetListVisibility(AbstractOptionGroup group, PropertyInfo property) - { - if (GetVisibilityMember(group, property, out Type type, out var instanceGet) is not { } member) - { - Error($"Member {memberName} does not exist in {type.FullName}"); - return null; - } - - if (member is PropertyInfo vProperty) - { - if (!vProperty.PropertyType.IsAssignableTo(typeof(bool)) && - !vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption)) && - !vProperty.PropertyType.IsAssignableTo(typeof(IList))) - { - Error($"Property {memberName} is not a bool value or indexer, a list of bools, or a bool ModdedOption."); - return null; - } - - var indexParams = vProperty.GetIndexParameters(); - if (indexParams.Length > 1) - { - Error($"Indexer {memberName} has too many parameters."); - return null; - } - - if (vProperty.PropertyType.IsAssignableTo(typeof(IList))) - { - if (indexParams.Length != 0) - { - Error($"Property {memberName} cannot be an indexer of lists of bools."); - return null; - } - - return i => ((IList)vProperty.GetValue(instanceGet?.Invoke())!)[i]; - } - - if (indexParams.Length == 0) - { - return _ => (bool)vProperty.GetValue(instanceGet?.Invoke())!; - } - - if (indexParams[0].ParameterType != typeof(int)) - { - Error($"Indexer {memberName}'s parameter is not an int."); - return null; - } - - return i => (bool)vProperty.GetValue(instanceGet?.Invoke(), [i])!; - } - - if (member is MethodInfo vMethod) - { - if (vMethod.ReturnType != typeof(bool)) - { - Error($"Method {memberName} does not return a bool."); - return null; - } - - var paramList = vMethod.GetParameters(); - if (paramList.Length > 1) - { - Error($"Method {memberName} has too many parameters."); - return null; - } - - if (paramList.Length == 0) - { - return _ => (bool)vMethod.Invoke(instanceGet?.Invoke(), null)!; - } - - if (paramList[0].ParameterType != typeof(int)) - { - Error($"Method {memberName}'s parameter is not an int."); - return null; - } - - return i => (bool)vMethod.Invoke(instanceGet?.Invoke(), [i])!; - } - - Error($"Member {memberName} of {type.FullName} is not valid for Visible function."); - return null; - } - - [SuppressMessage("Major Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "It is safe.")] - private MemberInfo? GetVisibilityMember(AbstractOptionGroup group, PropertyInfo property, out Type type, out Func? instanceGet) - { - Type groupType = group.GetType(); - BindingFlags flags = BindingFlags.Static | BindingFlags.Public; - - instanceGet = null; - type = holderType ?? groupType; - memberName ??= $"{property.Name}Visible"; - if (type.IsAssignableTo(typeof(AbstractOptionGroup))) - { - flags |= BindingFlags.Instance; - - if (type == groupType) - { - flags |= BindingFlags.NonPublic; - instanceGet = () => group; - } - else - { - var instanceProp = typeof(OptionGroupSingleton<>).MakeGenericType(type) - .GetProperty(nameof(OptionGroupSingleton<>.Instance))!; - instanceGet = () => instanceProp.GetValue(null)!; - } - } - - MemberInfo? member = type.GetMember(memberName, flags).FirstOrDefault(); - if (instanceGet != null) - { - MethodInfo? method = (member as PropertyInfo)?.GetGetMethod() - ?? member as MethodInfo; - if (method != null && method.IsStatic) - { - instanceGet = null; - } - } - - return member; - } -} diff --git a/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs new file mode 100644 index 0000000..b0ff5e2 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs @@ -0,0 +1,39 @@ +using System; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Translation; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for a . +/// +/// The title of the option. +/// An option list of string value. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedStringOptionAttribute( + string title, + string[] values) + : ModdedOptionAttribute(title) +{ + internal override IModdedOption? CreateOption(object? value, PropertyInfo property) + { + var option = new ModdedStringOption(Title.Translate(), (string)(value ?? string.Empty), values); + return option; + } + + /// + public override object GetValue() + { + return HolderOption is ModdedStringOption opt + ? (object)opt.Value + : throw new InvalidOperationException($"Holder option for {Title} is not a ModdedStringOption."); + } + + /// + public override void SetValue(object value) + { + var opt = HolderOption as ModdedStringOption; + opt?.SetValue((string)value); + } +} diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs index 2104860..ec8d09f 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs @@ -8,6 +8,9 @@ namespace MiraAPI.GameOptions.Attributes; /// /// Attribute for creating a list of enum options. /// +/// The title of the option. +/// The type. +/// An option list of string values to use in place of the name. [AttributeUsage(AttributeTargets.Property)] public class ModdedEnumOptionListAttribute(string title, Type enumType, string[]? values = null) : ModdedOptionListAttribute(title) @@ -39,10 +42,12 @@ public class ModdedEnumOptionListAttribute(string title, Type enumType, string[] /// Attribute for creating a list of enum options. /// /// The enum type. +/// The option's title. +/// The option's possible values. [AttributeUsage(AttributeTargets.Property)] public class ModdedEnumOptionListAttribute(string title, string[]? values = null) : ModdedOptionListAttribute(title) - where T : Enum + where T : struct, Enum { internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) { diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs index 80fdeda..d9eec55 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs @@ -7,8 +7,15 @@ using MiraAPI.Utilities; namespace MiraAPI.GameOptions.Attributes; /// -/// A number option attribute for the modded options system. +/// Attribute for creating a list of number options. /// +/// The option title. +/// The minimum value. +/// The maximum value. +/// The increment. +/// The suffix type. +/// Optional format string for the option screen. +/// Flag indicating whether 0 means infinity. [AttributeUsage(AttributeTargets.Property)] public class ModdedNumberOptionListAttribute( string title, diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs new file mode 100644 index 0000000..461ee54 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Translation; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for creating a list of string options. +/// +/// The title of the option. +/// An option list of string value. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedStringOptionListAttribute( + string title, + string[] values) + : ModdedOptionListAttribute(title) +{ + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) + { + return new ModdedOptionList(value.Count, idx => + { + return new ModdedStringOption(Title.Translate(), (string)(value[idx] ?? string.Empty), values); + }); + } + + /// + public override void SetValue(int idx, object value) + { + var opt = HolderOptionList?[idx] as ModdedStringOption; + opt?.SetValue((string)value); + } + + /// + public override object GetValue(int idx) + { + return HolderOptionList?[idx] is ModdedStringOption opt + ? (object)opt.Value + : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedStringOption"); + } +} diff --git a/MiraAPI/GameOptions/IModdedOption.cs b/MiraAPI/GameOptions/IModdedOption.cs index fdf71a6..315fe2e 100644 --- a/MiraAPI/GameOptions/IModdedOption.cs +++ b/MiraAPI/GameOptions/IModdedOption.cs @@ -57,6 +57,21 @@ public interface IModdedOption /// Func Visible { get; set; } + /// + /// Gets or sets a getter event for the option, allowing for dynamic values based on external circumstances. + /// + Delegate? Getter { get; set; } + + /// + /// Gets or sets a setter event for the option, allowing for dynamic value changes based on external circumstances. + /// + Delegate? Setter { get; set; } + + /// + /// Gets or sets the event that is invoked when the value of the option changes. + /// + Delegate? ChangedEvent { get; set; } + /// /// Gets or sets a value indicating whether the option should be included with presets. /// @@ -114,6 +129,12 @@ public interface IModdedOption /// /// The representing the preset configuration. void LoadFromPreset(ConfigFile presetConfig); + + /// + /// Gets the type of the value that the option represents. + /// + /// The type of the option value. + Type GetValueType(); } /// diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index b893040..9959832 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -26,6 +26,7 @@ public static class ModdedOptionsManager { private static readonly Dictionary OptionAttributes = []; private static readonly Dictionary TypeToGroup = []; + private static readonly HashSet PatchedListTypes = []; internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary CreatedStringOptions = []; @@ -181,6 +182,27 @@ public static class ModdedOptionsManager option.Visible = visibilityFunc; } + var getterAtr = property.GetCustomAttribute(); + var getterFunc = getterAtr?.GetGetter(group, property, option); + if (getterFunc != null) + { + option.Getter = getterFunc; + } + + var setterAtr = property.GetCustomAttribute(); + var setterFunc = setterAtr?.GetSetter(group, property, option); + if (setterFunc != null) + { + option.Setter = setterFunc; + } + + var changedAtr = property.GetCustomAttribute(); + var changedFunc = changedAtr?.GetChanged(group, property, option); + if (changedFunc != null) + { + option.ChangedEvent = changedFunc; + } + RegisterOption(option, group, property.Name, pluginInfo); } @@ -255,28 +277,61 @@ public static class ModdedOptionsManager var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); - var listIndex = propertyList.GetType().GetProperty("Item")!; + var listType = propertyList.GetType(); - var listSetterOriginal = listIndex.GetSetMethod(); - var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); + if (PatchedListTypes.Add(listType)) + { + var listIndex = listType.GetProperty("Item")!; - var listGetterOriginal = listIndex.GetSetMethod(); - var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); + var listSetterOriginal = listIndex.GetSetMethod(); + var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); + PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); + + var listGetterOriginal = listIndex.GetGetMethod(); + var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); + PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); + } OptionAttributes.Add(property, attribute); attribute.HolderOptionList = optionList; attribute.Value = propertyVal; + var valueType = property.PropertyType.GetGenericArguments()[0]; + var visibilityAttr = property.GetCustomAttribute(); - var visibilityFunc = visibilityAttr?.GetListVisibility(group, property); - for (int i = 0; i < optionList.Count; i++) + var visibilityFactory = visibilityAttr?.GetListVisibility(group, property); + + var getterAttr = property.GetCustomAttribute(); + var getterFactory = getterAttr?.GetListGetter(group, property, valueType); + + var setterAttr = property.GetCustomAttribute(); + var setterFactory = setterAttr?.GetListSetter(group, property, valueType); + + var changedAttr = property.GetCustomAttribute(); + var changedFactory = changedAttr?.GetListChanged(group, property, valueType); + + for (var i = 0; i < optionList.Count; i++) { var option = optionList[i]; - if (visibilityFunc != null) + + if (visibilityFactory != null) { - option.Visible = () => visibilityFunc(i); + option.Visible = (Func)visibilityFactory(i); + } + + if (getterFactory != null) + { + option.Getter = getterFactory(i); + } + + if (setterFactory != null) + { + option.Setter = setterFactory(i); + } + + if (changedFactory != null) + { + option.ChangedEvent = changedFactory(i); } RegisterOption(option, group, property.Name + (i + 1), pluginInfo); @@ -350,7 +405,7 @@ public static class ModdedOptionsManager /// The original setter method. /// The new object value. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention.")] - [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Harmony naming convention.")] + [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertySetterPatch(MethodBase __originalMethod, object value) { var attribute = OptionAttributes.First(pair => pair.Key.GetSetMethod() == __originalMethod).Value; @@ -364,7 +419,7 @@ public static class ModdedOptionsManager /// The result of the property getter. /// so the original getter gets skipped. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] - [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Harmony naming convention.")] + [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyGetterPatch(MethodBase __originalMethod, ref object __result) { var attribute = OptionAttributes.First(pair => pair.Key.GetGetMethod() == __originalMethod).Value; @@ -382,9 +437,10 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertyListSetterPatch(object __instance, int index, object value) { - var attribute = (ModdedOptionListAttribute)OptionAttributes.First( - pair => pair.Value is ModdedOptionListAttribute list && ReferenceEquals(list.Value, __instance)).Value; - attribute.SetValue(index, value); + var match = OptionAttributes.Values + .OfType() + .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); + match?.SetValue(index, value); } /// @@ -398,9 +454,14 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyListGetterPatch(object __instance, int index, ref object __result) { - var attribute = (ModdedOptionListAttribute)OptionAttributes.First( - pair => pair.Value is ModdedOptionListAttribute list && ReferenceEquals(list.Value, __instance)).Value; - __result = attribute.GetValue(index); + var match = OptionAttributes.Values + .OfType() + .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); + + if (match is null) + return true; + + __result = match.GetValue(index); return false; } } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs index 69815b9..764df9c 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs @@ -113,7 +113,7 @@ public class ModdedEnumOption : ModdedOption /// /// The type. public class ModdedEnumOption : ModdedOption - where T : Enum + where T : struct, Enum { /// /// Gets the string values of the . @@ -130,7 +130,7 @@ public class ModdedEnumOption : ModdedOption public ModdedEnumOption(string title, T defaultValue, string[]? values = null, bool includeInPreset = true) : base(title, defaultValue, includeInPreset) { - Values = values ?? Enum.GetNames(typeof(T)); + Values = values ?? Enum.GetNames(); var data = ScriptableObject.CreateInstance(); data.Title = StringName; @@ -151,6 +151,10 @@ public class ModdedEnumOption : ModdedOption stringOption.SetUpFromData(Data, 20); stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + // Changing the button text to make them more distinct from number options + stringOption.MinusBtn.ChangeButtonText("<"); + stringOption.PlusBtn.ChangeButtonText(">"); + // SetUpFromData method doesn't work correctly so we must set the values manually stringOption.Title = StringName; stringOption.Values = (Data as StringGameSetting)?.Values ?? new Il2CppStructArray(0); @@ -176,13 +180,13 @@ public class ModdedEnumOption : ModdedOption /// public override void HandleNetData(byte[] data) { - SetValue((T)Enum.Parse(typeof(T), Encoding.Unicode.GetString(data))); + SetValue(Enum.Parse(Encoding.Unicode.GetString(data))); } /// public override T GetValueFromOptionBehaviour(OptionBehaviour optionBehaviour) { - return (T)Enum.Parse(typeof(T), optionBehaviour.GetInt().ToString(NumberFormatInfo.InvariantInfo)); + return Enum.Parse(optionBehaviour.GetInt().ToString(NumberFormatInfo.InvariantInfo)); } /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs index 24747ad..2342fc9 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using BepInEx.Configuration; using MiraAPI.Networking; using MiraAPI.PluginLoading; @@ -43,16 +44,32 @@ public abstract class ModdedOption : IModdedOption /// /// Gets or sets the value of the option. /// - public T Value { get; protected set; } + public T Value + { + get => Getter != null ? Getter(_value) : _value; + protected set + { + if (Setter != null) + value = Setter(value, _value); + + _value = value; + } + } + + private T _value; /// /// Gets the default value of the option. /// public T DefaultValue { get; } - /// - /// Gets or sets the event that is invoked when the value of the option changes. - /// + /// + public Func? Getter { get; set; } + + /// + public Func? Setter { get; set; } + + /// public Action? ChangedEvent { get; set; } /// @@ -67,6 +84,33 @@ public abstract class ModdedOption : IModdedOption /// public ConfigDefinition? ConfigDefinition { get; set; } + /// + public AbstractOptionGroup ParentGroup { get; set; } + + /// + public virtual OptionNotifConfiguration Configuration => new(ParentGroup); + + /// + Delegate? IModdedOption.Getter + { + get => Getter; + set => Getter = value as Func; + } + + /// + Delegate? IModdedOption.Setter + { + get => Setter; + set => Setter = value as Func; + } + + /// + Delegate? IModdedOption.ChangedEvent + { + get => ChangedEvent; + set => ChangedEvent = value as Action; + } + /// /// Initializes a new instance of the class. /// @@ -96,25 +140,25 @@ public abstract class ModdedOption : IModdedOption /// Whether to send the value to other players. public void SetValue(T newValue, bool sendRpc = true) { - var oldVal = Value; + var oldVal = _value; Value = newValue; - if (Value?.Equals(oldVal) == false) + if (!EqualityComparer.Default.Equals(_value, oldVal)) { - ChangedEvent?.Invoke(Value); + ChangedEvent?.Invoke(_value); } if (sendRpc && AmongUsClient.Instance.AmHost) { if (ParentMod?.GetConfigFile().TryGetEntry(ConfigDefinition, out var entry) == true) { - entry.Value = Value; + entry.Value = _value; } Rpc.Instance.Send(PlayerControl.LocalPlayer, [GetNetData()], true); } - OnValueChanged(newValue); + OnValueChanged(_value); } /// @@ -149,6 +193,12 @@ public abstract class ModdedOption : IModdedOption } } + /// + public Type GetValueType() + { + return typeof(T); + } + /// public abstract float GetFloatData(); @@ -188,10 +238,4 @@ public abstract class ModdedOption : IModdedOption { return option.Value; } - - /// - public AbstractOptionGroup ParentGroup { get; set; } - - /// - public virtual OptionNotifConfiguration Configuration => new(ParentGroup); } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs index 257ae6e..a9f5143 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs @@ -50,11 +50,13 @@ public class ModdedOptionList : IModdedOptionList return Options.GetEnumerator(); } + /// IEnumerator IEnumerable.GetEnumerator() { return Options.GetEnumerator(); } + /// IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)Options).GetEnumerator(); @@ -67,5 +69,6 @@ public class ModdedOptionList : IModdedOptionList /// option of type . public T this[int idx] => Options[idx]; + /// IModdedOption IReadOnlyList.this[int index] => this[index]; } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs index 8381177..6209881 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs @@ -25,6 +25,28 @@ public class ModdedStringOption : ModdedOption /// public StringNames[] StringNameValues { get; set; } + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// The default value as a string. + /// An option list of string values to use. + /// Whether to include this option in the preset or not. + public ModdedStringOption(string title, string defaultValue, string[] values, bool includeInPreset = true) + : base(title, defaultValue, includeInPreset) + { + Values = values; + Data = ScriptableObject.CreateInstance(); + var data = (StringGameSetting)Data; + + data.Title = StringName; + data.Type = global::OptionTypes.String; + StringNameValues = [.. values.Select(MiraLocaleManager.GetOrCreateLocaleString)]; + data.Values = StringNameValues; + + data.Index = GetIndex(Value); + } + /// /// Gets the string's index. /// @@ -61,61 +83,37 @@ public class ModdedStringOption : ModdedOption StringNameValues = [.. newStrVals]; } - if (Data != null) - { - var data = (StringGameSetting)Data; - data.Values = StringNameValues; - - data.Index = GetIndex(Value); - if (OptionBehaviour != null) - { - var strOpt = OptionBehaviour.TryCast(); - if (strOpt != null) - { - strOpt.Values = StringNameValues; - strOpt.Value = GetIndex(Value); - Warning("StringOption is valid"); - } - else - { - Error("StringOption is null."); - } - - Warning("OptionBehaviour is valid"); - } - else - { - Error("OptionBehaviour is null."); - } - - Warning("Data is valid"); - } - else + if (Data == null) { Error("Data is null."); + return; } - } - /// - /// Initializes a new instance of the class. - /// - /// The title of the option. - /// The default value as a string. - /// An option list of string values to use. - /// Whether to include this option in the preset or not. - public ModdedStringOption(string title, string defaultValue, string[] values, bool includeInPreset = true) - : base(title, defaultValue, includeInPreset) - { - Values = values; - Data = ScriptableObject.CreateInstance(); var data = (StringGameSetting)Data; - - data.Title = StringName; - data.Type = global::OptionTypes.String; - StringNameValues = [.. values.Select(MiraLocaleManager.GetOrCreateLocaleString)]; data.Values = StringNameValues; - data.Index = GetIndex(Value); + + Warning("Data is valid"); + + if (OptionBehaviour == null) + { + Error("OptionBehaviour is null."); + return; + } + + var strOpt = OptionBehaviour.TryCast(); + + Warning("OptionBehaviour is valid"); + + if (strOpt == null) + { + Error("StringOption is null."); + return; + } + + strOpt.Values = StringNameValues; + strOpt.Value = GetIndex(Value); + Warning("StringOption is valid"); } /// @@ -128,6 +126,10 @@ public class ModdedStringOption : ModdedOption stringOption.SetUpFromData(Data, 20); stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + // Changing the button text to make them more distinct from number options + stringOption.MinusBtn.ChangeButtonText("<"); + stringOption.PlusBtn.ChangeButtonText(">"); + // SetUpFromData method doesn't work correctly so we must set the values manually stringOption.Title = StringName; stringOption.Values = (Data as StringGameSetting)?.Values ?? new Il2CppStructArray(0); diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index a07c36d..3aa3bb5 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -67,6 +67,7 @@ public partial class MiraApiPlugin : BasePlugin, IMiraPlugin internal static ConfigEntry WikiSortingGroup { get; private set; } internal static ConfigEntry WikiSortingMethod { get; private set; } internal static ConfigEntry WikiSortingFilter { get; private set; } + internal static ConfigEntry DistanceSuffix { get; private set; } /// public override void Load() @@ -86,6 +87,11 @@ public partial class MiraApiPlugin : BasePlugin, IMiraPlugin "WikiSortingFilter", SortingFilter.AllRoles, "Determines what roles / modifiers are displayed at once."); + DistanceSuffix = Config.Bind( + "Display", + "Distance", + "unit(s)", + "Dictates the unit of distance used in numeric option displays representing a distance."); Harmony.PatchAll(); RoleOptionsCollectionPatch.PatchRoleMethods(Harmony); UiResetEvents.Initialize(); diff --git a/MiraAPI/Patches/Options/SettingPatches.cs b/MiraAPI/Patches/Options/SettingPatches.cs index 59b956c..3300a02 100644 --- a/MiraAPI/Patches/Options/SettingPatches.cs +++ b/MiraAPI/Patches/Options/SettingPatches.cs @@ -51,6 +51,7 @@ public static class SettingPatches MiraNumberSuffixes.None => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo), MiraNumberSuffixes.Multiplier => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "x", MiraNumberSuffixes.Percent => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "%", + MiraNumberSuffixes.Distance => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + MiraApiPlugin.DistanceSuffix.Value, _ => TranslationController.Instance.GetString( StringNames.GameSecondsAbbrev, (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), @@ -68,6 +69,7 @@ public static class SettingPatches MiraNumberSuffixes.None => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo), MiraNumberSuffixes.Multiplier => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "x", MiraNumberSuffixes.Percent => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "%", + MiraNumberSuffixes.Distance => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + MiraApiPlugin.DistanceSuffix.Value, _ => TranslationController.Instance.GetString( StringNames.GameSecondsAbbrev, (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), diff --git a/MiraAPI/Presets/OptionPreset.cs b/MiraAPI/Presets/OptionPreset.cs index 077afc9..6ecab1f 100644 --- a/MiraAPI/Presets/OptionPreset.cs +++ b/MiraAPI/Presets/OptionPreset.cs @@ -10,9 +10,6 @@ namespace MiraAPI.Presets; /// /// Represents a preset of game options that can be applied to the game. /// -/// -/// Initializes a new instance of the class with the specified name and configuration file. -/// /// The name of the preset. /// The plugin associated with the preset. /// The configuration file for the preset. diff --git a/MiraAPI/Utilities/MiraNumberSuffixes.cs b/MiraAPI/Utilities/MiraNumberSuffixes.cs index 2f55226..81274e9 100644 --- a/MiraAPI/Utilities/MiraNumberSuffixes.cs +++ b/MiraAPI/Utilities/MiraNumberSuffixes.cs @@ -24,4 +24,9 @@ public enum MiraNumberSuffixes /// Percentage suffix (%). /// Percent, + + /// + /// Distance suffix (m). + /// + Distance, } From f433454fe62d29bfacb50ec9f3e668dc77aff786 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:01:14 -0400 Subject: [PATCH 02/13] add missing documentation line --- MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs index 22f4369..5225eb4 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs @@ -42,6 +42,7 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va /// /// The enum type. /// The title of the option. +/// An option list of string values to use in place of the name. [AttributeUsage(AttributeTargets.Property)] public class ModdedEnumOptionAttribute(string title, string[]? values = null) : ModdedOptionAttribute(title) From f08a00ded7c2a505566d2e56365aa68a8dfa5394 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:58:01 -0400 Subject: [PATCH 03/13] field support --- .../Delegate/ModdedOptionMemberAttribute.cs | 38 +++++++---- .../ModdedOptionVisibilityAttribute.cs | 64 +++++++++++++++++++ .../OptionTypes/ModdedEnumOption.cs | 4 ++ 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs index 1db3700..43742ba 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs @@ -38,7 +38,7 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold var flags = BindingFlags.Static | BindingFlags.Public; type = holderType ?? groupType; - MemberName ??= $"{property.Name}{defaultSuffix}"; + MemberName ??= property.Name + defaultSuffix; if (type.IsAssignableTo(typeof(AbstractOptionGroup))) { @@ -59,13 +59,24 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold var member = type.GetMember(MemberName, flags).FirstOrDefault(); - if (instanceGetter != null) + if (instanceGetter == null) + return member; + + bool isStatic; + + if (member is FieldInfo field) + { + isStatic = field.IsStatic; + } + else { var method = (member as PropertyInfo)?.GetGetMethod(true) ?? member as MethodInfo; - if (method is { IsStatic: true }) - instanceGetter = null; + isStatic = method is { IsStatic: true }; } + if (isStatic) + instanceGetter = null; + return member; } @@ -186,8 +197,8 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold baseParamTypes, delegateType, suffix, - forwardParameters: baseParamTypes.Length > 0, - postProcessCall: postProcessCall); + baseParamTypes.Length > 0, + postProcessCall); return _ => sharedDelegate; } @@ -202,11 +213,16 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold for (byte i = 0; i < baseParamTypes.Length; i++) { - if (i == 0) il.Emit(OpCodes.Ldarg_0); - else if (i == 1) il.Emit(OpCodes.Ldarg_1); - else if (i == 2) il.Emit(OpCodes.Ldarg_2); - else if (i == 3) il.Emit(OpCodes.Ldarg_3); - else il.Emit(OpCodes.Ldarg_S, i); + if (i == 0) + il.Emit(OpCodes.Ldarg_0); + else if (i == 1) + il.Emit(OpCodes.Ldarg_1); + else if (i == 2) + il.Emit(OpCodes.Ldarg_2); + else if (i == 3) + il.Emit(OpCodes.Ldarg_3); + else + il.Emit(OpCodes.Ldarg_S, i); } if (hasIndexParameter) diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs index 60e494d..1077e58 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs @@ -71,6 +71,32 @@ public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, T targetMethod = vMethod; } + else if (member is FieldInfo field) + { + if (field.FieldType != typeof(bool)) + { + Error($"Field {MemberName} is not a bool."); + return null; + } + + var dynamicMethod = new DynamicMethod($"GetVisibility_{type.Name}_{field.Name}", typeof(bool), Type.EmptyTypes, type, true); + + var il = dynamicMethod.GetILGenerator(); + + if (instanceGetter != null) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, instanceGetter); + il.Emit(OpCodes.Ldfld, field); + } + else + { + il.Emit(OpCodes.Ldsfld, field); + } + + il.Emit(OpCodes.Ret); + return dynamicMethod.CreateDelegate>(); + } else { Error($"Member {MemberName} of {type.FullName} is not valid for Visible function."); @@ -118,6 +144,7 @@ public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, T } var indexParams = vProperty.GetIndexParameters(); + if (indexParams.Length > 1) { Error($"Indexer {MemberName} has too many parameters."); @@ -178,6 +205,43 @@ public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, T targetMethod = vMethod; } + else if (member is FieldInfo field) + { + if (field.FieldType.IsAssignableTo(typeof(IList))) + { + postProcess = il => + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Callvirt, ListBoolIndexerGetter); + }; + } + else if (field.FieldType != typeof(bool)) + { + Error($"Field {MemberName} is not a bool or a list of bools."); + return null; + } + + var dynamicMethod = new DynamicMethod($"GetListVisibility_{type.Name}_{field.Name}", typeof(bool), [typeof(int)], type, true); + var il = dynamicMethod.GetILGenerator(); + + if (instanceGetter != null) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, instanceGetter); + il.Emit(OpCodes.Ldfld, field); + } + else + { + il.Emit(OpCodes.Ldsfld, field); + } + + postProcess?.Invoke(il); + + il.Emit(OpCodes.Ret); + + var func = dynamicMethod.CreateDelegate>(); + return index => () => func(index); + } else { Error($"Member {MemberName} of {type.FullName} is not valid for Visibility function."); diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs index 764df9c..5d0b792 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs @@ -52,6 +52,10 @@ public class ModdedEnumOption : ModdedOption stringOption.SetUpFromData(Data, 20); stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + // Changing the button text to make them more distinct from number options + stringOption.MinusBtn.ChangeButtonText("<"); + stringOption.PlusBtn.ChangeButtonText(">"); + // SetUpFromData method doesn't work correctly so we must set the values manually stringOption.Title = StringName; stringOption.Values = (Data as StringGameSetting)?.Values ?? new Il2CppStructArray(0); From 7ed3944ad1f1698420ebd9a0b59311d09719410f Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:10:38 -0400 Subject: [PATCH 04/13] target non public getters and setters --- .../Delegate/ModdedOptionVisibilityAttribute.cs | 1 - MiraAPI/GameOptions/ModdedOptionsManager.cs | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs index 1077e58..72c35b0 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs @@ -80,7 +80,6 @@ public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, T } var dynamicMethod = new DynamicMethod($"GetVisibility_{type.Name}_{field.Name}", typeof(bool), Type.EmptyTypes, type, true); - var il = dynamicMethod.GetILGenerator(); if (instanceGetter != null) diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index 9959832..4dc150f 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -164,11 +164,11 @@ public static class ModdedOptionsManager return; } - var setterOriginal = property.GetSetMethod(); + var setterOriginal = property.GetSetMethod(true); var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); - var getterOriginal = property.GetGetMethod(); + var getterOriginal = property.GetGetMethod(true); var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); @@ -269,11 +269,11 @@ public static class ModdedOptionsManager return; } - var setterOriginal = property.GetSetMethod(); + var setterOriginal = property.GetSetMethod(true); var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); - var getterOriginal = property.GetGetMethod(); + var getterOriginal = property.GetGetMethod(true); var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); @@ -283,11 +283,11 @@ public static class ModdedOptionsManager { var listIndex = listType.GetProperty("Item")!; - var listSetterOriginal = listIndex.GetSetMethod(); + var listSetterOriginal = listIndex.GetSetMethod(true); var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); - var listGetterOriginal = listIndex.GetGetMethod(); + var listGetterOriginal = listIndex.GetGetMethod(true); var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); } From fb74a688c3012512cdbe776920d380e003bc85d1 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:28:40 -0400 Subject: [PATCH 05/13] remove useless translation calls + order players --- .../GameOptions/Attributes/ModdedEnumOptionAttribute.cs | 3 +-- .../Attributes/ModdedNumberOptionAttribute.cs | 3 +-- .../Attributes/ModdedStringOptionAttribute.cs | 3 +-- .../Attributes/ModdedToggleOptionAttribute.cs | 3 +-- MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs | 9 ++++++++- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs index 5225eb4..16e3b38 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Translation; namespace MiraAPI.GameOptions.Attributes; @@ -17,7 +16,7 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va { internal override IModdedOption CreateOption(object? value, PropertyInfo property) { - var opt = new ModdedEnumOption(Title.Translate(), (int)(value ?? 0), enumType, values); + var opt = new ModdedEnumOption(Title, (int)(value ?? 0), enumType, values); return opt; } diff --git a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs index 1d9e7da..f662945 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Translation; using MiraAPI.Utilities; namespace MiraAPI.GameOptions.Attributes; @@ -30,7 +29,7 @@ public class ModdedNumberOptionAttribute( internal override IModdedOption CreateOption(object? value, PropertyInfo property) { return new ModdedNumberOption( - Title.Translate(), + Title, (float)(value ?? min + increment), min, max, diff --git a/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs index b0ff5e2..2b1853f 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Translation; namespace MiraAPI.GameOptions.Attributes; @@ -18,7 +17,7 @@ public class ModdedStringOptionAttribute( { internal override IModdedOption? CreateOption(object? value, PropertyInfo property) { - var option = new ModdedStringOption(Title.Translate(), (string)(value ?? string.Empty), values); + var option = new ModdedStringOption(Title, (string)(value ?? string.Empty), values); return option; } diff --git a/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs index 825915d..8f570e6 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Translation; namespace MiraAPI.GameOptions.Attributes; @@ -14,7 +13,7 @@ public class ModdedToggleOptionAttribute(string title) : ModdedOptionAttribute(t { internal override IModdedOption CreateOption(object? value, PropertyInfo property) { - var toggleOpt = new ModdedToggleOption(Title.Translate(), (bool)(value ?? false)); + var toggleOpt = new ModdedToggleOption(Title, (bool)(value ?? false)); return toggleOpt; } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs index 40cf0f8..bd96499 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs @@ -86,7 +86,14 @@ public class ModdedPlayerOption : ModdedOption /// A list of filtered players. public List GetFilteredPlayers() { - return [.. GameData.Instance.AllPlayers.ToArray().Where(x => PlayerFilter?.Invoke(x) ?? true)]; + var players = GameData.Instance.AllPlayers.ToArray(); + + IEnumerable filtered = players; + + if (PlayerFilter != null) + filtered = players.Where(PlayerFilter); + + return [.. filtered.OrderBy(x => x.PlayerId)]; } /// From b074fdc3300df63e7afb0cd560924d2fa115bfd9 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:37:23 -0400 Subject: [PATCH 06/13] order code --- .../OptionTypes/ModdedNumberOption.cs | 66 ++++++++----------- .../OptionTypes/ModdedOptionList.cs | 14 ++-- .../OptionTypes/ModdedPlayerOption.cs | 11 ++-- .../OptionTypes/ModdedStringOption.cs | 8 +-- .../OptionTypes/ModdedToggleOption.cs | 6 +- 5 files changed, 47 insertions(+), 58 deletions(-) diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs index e9097ee..a45201f 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs @@ -61,10 +61,10 @@ public class ModdedNumberOption : ModdedOption /// The maximum value. /// The increment. /// The suffix type. - /// Optional format string for the option screen. - /// Determines what is shown for zero. Options: ∞, #, or a word value (such as Off). - /// Determines what is shown for negative 1. Options: ∞, #, or a word value (such as Off). + /// Determines what is shown for zero. + /// Determines what is shown for negative 1. /// Whether increments can be split in half. + /// Optional format string for the option screen. /// Whether to include this option in the preset or not. public ModdedNumberOption( string title, @@ -78,36 +78,8 @@ public class ModdedNumberOption : ModdedOption string? formatString = null, bool halfIncrements = false, bool includeInPreset = true) - : base(title, defaultValue, includeInPreset) + : this(title, defaultValue, min, max, increment, suffixType, zeroBehavior, negativeBehavior, halfIncrements, formatString, includeInPreset) { - Min = min; - Max = max; - Increment = increment; - SuffixType = suffixType; - ZeroWordValue = zeroBehavior; - NegativeWordValue = negativeBehavior; - ShiftIncrement = halfIncrements; - - Value = Mathf.Clamp(defaultValue, min, max); - - Data = ScriptableObject.CreateInstance(); - - var data = (FloatGameSetting)Data; - data.Type = global::OptionTypes.Float; - data.Title = StringName; - data.Value = Value; - data.Increment = Increment; - data.ValidRange = new FloatRange(Min, Max); - data.FormatString = formatString ?? - (defaultValue.IsInteger() && - Increment.IsInteger() && - Value.IsInteger() && - Min.IsInteger() && - Max.IsInteger() ? "0" : "0.0"); - - data.ZeroIsInfinity = ZeroInfinity; - data.SuffixType = (NumberSuffixes)SuffixType; - data.OptionName = FloatOptionNames.Invalid; } /// @@ -119,8 +91,8 @@ public class ModdedNumberOption : ModdedOption /// The maximum value. /// The increment. /// The suffix type. - /// Optional format string for the option screen. /// Whether zero is infinity or not. + /// Optional format string for the option screen. /// Whether to include this option in the preset or not. public ModdedNumberOption( string title, @@ -132,21 +104,35 @@ public class ModdedNumberOption : ModdedOption string? formatString = null, bool zeroInfinity = false, bool includeInPreset = true) + : this(title, defaultValue, min, max, increment, suffixType, zeroInfinity ? "∞" : "#", "#", increment == 1f, formatString, includeInPreset) + { + } + + private ModdedNumberOption( + string title, + float defaultValue, + float min, + float max, + float increment, + MiraNumberSuffixes suffixType, + string zeroBehavior, + string negativeBehavior, + bool shiftIncrement, + string? formatString, + bool includeInPreset) : base(title, defaultValue, includeInPreset) { Min = min; Max = max; Increment = increment; SuffixType = suffixType; - ZeroWordValue = zeroInfinity ? "∞" : "#"; - NegativeWordValue = "#"; - ShiftIncrement = Mathf.Approximately(increment, 1f); + ZeroWordValue = zeroBehavior; + ShiftIncrement = shiftIncrement; + NegativeWordValue = negativeBehavior; Value = Mathf.Clamp(defaultValue, min, max); - Data = ScriptableObject.CreateInstance(); - - var data = (FloatGameSetting)Data; + var data = ScriptableObject.CreateInstance(); data.Type = global::OptionTypes.Float; data.Title = StringName; data.Value = Value; @@ -162,6 +148,8 @@ public class ModdedNumberOption : ModdedOption data.ZeroIsInfinity = ZeroInfinity; data.SuffixType = (NumberSuffixes)SuffixType; data.OptionName = FloatOptionNames.Invalid; + + Data = data; } /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs index a9f5143..6e9e9ad 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs @@ -44,6 +44,13 @@ public class ModdedOptionList : IModdedOptionList Options = [.. options]; } + /// + /// Indexes the option of type . + /// + /// The option's index. + /// option of type . + public T this[int idx] => Options[idx]; + /// public IEnumerator GetEnumerator() { @@ -62,13 +69,6 @@ public class ModdedOptionList : IModdedOptionList return ((IEnumerable)Options).GetEnumerator(); } - /// - /// Indexes the option of type . - /// - /// The option's index. - /// option of type . - public T this[int idx] => Options[idx]; - /// IModdedOption IReadOnlyList.this[int index] => this[index]; } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs index bd96499..e45c81e 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs @@ -37,15 +37,16 @@ public class ModdedPlayerOption : ModdedOption public ModdedPlayerOption(string title, bool allowNone = true) : base(title, allowNone ? -1 : 0, false) { - Data = ScriptableObject.CreateInstance(); - var data = (PlayerSelectionGameSetting)Data; + Values = []; + AllowNone = allowNone; + Value = DefaultValue; + + var data = ScriptableObject.CreateInstance(); data.Title = StringName; data.Type = global::OptionTypes.Player; - Values = []; - AllowNone = allowNone; - Value = DefaultValue; + Data = data; } /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs index 6209881..291ba67 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs @@ -36,15 +36,15 @@ public class ModdedStringOption : ModdedOption : base(title, defaultValue, includeInPreset) { Values = values; - Data = ScriptableObject.CreateInstance(); - var data = (StringGameSetting)Data; + StringNameValues = [.. values.Select(MiraLocaleManager.GetOrCreateLocaleString)]; + var data = ScriptableObject.CreateInstance(); data.Title = StringName; data.Type = global::OptionTypes.String; - StringNameValues = [.. values.Select(MiraLocaleManager.GetOrCreateLocaleString)]; data.Values = StringNameValues; - data.Index = GetIndex(Value); + + Data = data; } /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs index 6fa8be3..1a331d4 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs @@ -20,11 +20,11 @@ public class ModdedToggleOption : ModdedOption public ModdedToggleOption(string title, bool defaultValue, bool includeInPreset = true) : base(title, defaultValue, includeInPreset) { - Data = ScriptableObject.CreateInstance(); - - var data = (CheckboxGameSetting)Data; + var data = ScriptableObject.CreateInstance(); data.Title = StringName; data.Type = global::OptionTypes.Checkbox; + + Data = data; } /// From 04b7701b7925804f7b56ef97f71d7cfa4ea357e3 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:46:41 -0400 Subject: [PATCH 07/13] more stuff --- MiraAPI.Example/Options/ExampleOptions.cs | 11 ++- MiraAPI.Example/Resources/Locale/en_US.xml | 1 + .../Attributes/ModdedEnumOptionAttribute.cs | 4 +- .../Attributes/ModdedNumberOptionAttribute.cs | 32 ++++++-- .../Attributes/ModdedOptionAttribute.cs | 1 + .../Attributes/ModdedPlayerOptionAttribute.cs | 49 ++++++++++++ .../Attributes/ModdedStringOptionAttribute.cs | 2 +- .../Attributes/ModdedToggleOptionAttribute.cs | 2 +- .../ModdedEnumOptionListAttribute.cs | 4 +- .../ModdedNumberOptionListAttribute.cs | 32 ++++++-- .../OptionList/ModdedOptionListAttribute.cs | 32 ++++---- .../ModdedPlayerOptionListAttribute.cs | 54 +++++++++++++ .../OptionList/ModdedStringOptionList.cs | 2 +- .../ModdedToggleOptionListAttribute.cs | 2 +- MiraAPI/GameOptions/ModdedOptionsManager.cs | 62 +++++++++------ .../OptionTypes/ModdedEnumOption.cs | 4 +- .../OptionTypes/ModdedNumberOption.cs | 76 ++++++++----------- .../OptionTypes/ModdedPlayerOption.cs | 7 +- .../OptionTypes/ModdedStringOption.cs | 2 +- .../OptionTypes/ModdedToggleOption.cs | 9 ++- .../HnsReimplemented/HideAndSeekHudHelper.cs | 2 +- MiraAPI/Hud/MiraHudHelper.cs | 2 +- MiraAPI/PluginLoading/MiraPluginManager.cs | 15 ++-- 23 files changed, 283 insertions(+), 124 deletions(-) create mode 100644 MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index c846308..c60038c 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -27,19 +27,22 @@ public class ExampleOptions : AbstractOptionGroup [ModdedOptionVisibility(nameof(NumberOptVisibility))] // Can also target static methods public float NumberOpt { get; set; } = 4f; + [ModdedNumberOption("options.exampleOptions1.numberOpt", min: 0, max: 10, suffixType: MiraNumberSuffixes.Distance)] + public int NumberOpt2 { get; set; } = 4; // Can also be int! + [ModdedEnumOption("options.exampleOptions1.bestApi", typeof(Api), ["Mira API", "Mitochondria", "Reactor"])] + public Api BestApi { get; set; } = Api.MiraAPI; + + [ModdedEnumOption("options.exampleOptions1.worstApi", ["Mira API", "Mitochondria", "Reactor"])] // These three can target both instance and static methods [ModdedOptionGetter(nameof(GetApi))] // Can also automatically resolve to {Option Name}Getter [ModdedOptionSetter(nameof(SetApi))] // Can also automatically resolve to {Option Name}Setter [ModdedOptionChanged(nameof(ApiChanged))] // Can also automatically resolve to {Option Name}Changed - public Api BestApi { get; set; } = Api.MiraAPI; - - [ModdedEnumOption("options.exampleOptions1.worstApi", ["Mira API", "Mitochondria", "Reactor"])] public Api WorstApi { get; set; } = Api.MiraAPI; [ModdedEnumOptionList("Value State {0}", ["None", "Primary", "Secondary"])] - public List ValueStates { get; } = + public List ValueStates { get; set; } = [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; public bool ToggleOpt1Visibility() diff --git a/MiraAPI.Example/Resources/Locale/en_US.xml b/MiraAPI.Example/Resources/Locale/en_US.xml index 8328c69..e446b69 100644 --- a/MiraAPI.Example/Resources/Locale/en_US.xml +++ b/MiraAPI.Example/Resources/Locale/en_US.xml @@ -47,6 +47,7 @@ Eliminate every other faction. Toggle Opt 2 Number Opt Best API + Worst API Example Options 2 YOU MUST CHOSE A PLAYER Eh whatever diff --git a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs index 16e3b38..2754920 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs @@ -32,7 +32,7 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va { return HolderOption is ModdedEnumOption opt ? Enum.ToObject(enumType, opt.Value) - : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType ${enumType.FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType {enumType.FullName} is not a ModdedEnumOption"); } } @@ -65,6 +65,6 @@ public class ModdedEnumOptionAttribute(string title, string[]? values = null) { return HolderOption is ModdedEnumOption opt ? opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType ${typeof(T).FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType {typeof(T).FullName} is not a ModdedEnumOption<{typeof(T).Name}>"); } } diff --git a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs index f662945..9de15ee 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs @@ -28,9 +28,16 @@ public class ModdedNumberOptionAttribute( { internal override IModdedOption CreateOption(object? value, PropertyInfo property) { + var final = value switch + { + null => min + increment, + int integer => integer, + _ => (float)value, + }; + return new ModdedNumberOption( Title, - (float)(value ?? min + increment), + final, min, max, increment, @@ -42,15 +49,28 @@ public class ModdedNumberOptionAttribute( /// public override void SetValue(object value) { - var opt = HolderOption as ModdedNumberOption; - opt?.SetValue((float)value); + if (HolderOption is not ModdedNumberOption opt) + return; + + var final = value switch + { + int integer => integer, + _ => (float)value, + }; + opt.SetValue(final); } /// public override object GetValue() { - return HolderOption is ModdedNumberOption opt - ? (object)opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{Title}\" is not a ModdedNumberOption"); + if (HolderOption is not ModdedNumberOption opt) + throw new InvalidOperationException($"HolderOption for option \"{Title}\" is not a ModdedNumberOption"); + + var value = opt.Value; + + if (ValueType == typeof(int)) + return (int)Math.Round(value); + + return value; } } diff --git a/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs index 98194f8..91b7856 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs @@ -13,6 +13,7 @@ namespace MiraAPI.GameOptions.Attributes; public abstract class ModdedOptionAttribute(string title, Type? roleType = null, Type? modeType = null) : PropertyOptionAttribute { internal IModdedOption? HolderOption { get; set; } + internal Type ValueType { get; set; } /// /// Gets the title of the option. diff --git a/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs new file mode 100644 index 0000000..086df17 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs @@ -0,0 +1,49 @@ +using System; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for a . +/// +/// The title of the option. +/// Whether the option includes a none option. If None is selected, value will return -1. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedPlayerOptionAttribute( + string title, + bool allowNone = true) + : ModdedOptionAttribute(title) +{ + internal override IModdedOption? CreateOption(object? value, PropertyInfo property) + { + var option = new ModdedPlayerOption(Title, allowNone); + return option; + } + + /// + public override object GetValue() + { + if (HolderOption is not ModdedPlayerOption opt) + throw new InvalidOperationException($"Holder option for {Title} is not a ModdedPlayerOption."); + + if (ValueType == typeof(int)) + return opt.Value; + + return opt.SelectedPlayer!; + } + + /// + public override void SetValue(object value) + { + if (HolderOption is not ModdedPlayerOption opt) + return; + + var final = value switch + { + NetworkedPlayerInfo info => opt.Values.IndexOf(info), + _ => (int)value, + }; + opt?.SetValue(final); + } +} diff --git a/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs index 2b1853f..9086b1f 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs @@ -25,7 +25,7 @@ public class ModdedStringOptionAttribute( public override object GetValue() { return HolderOption is ModdedStringOption opt - ? (object)opt.Value + ? opt.Value : throw new InvalidOperationException($"Holder option for {Title} is not a ModdedStringOption."); } diff --git a/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs index 8f570e6..2a2ac2f 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs @@ -28,7 +28,7 @@ public class ModdedToggleOptionAttribute(string title) : ModdedOptionAttribute(t public override object GetValue() { return HolderOption is ModdedToggleOption opt - ? (object)opt.Value + ? opt.Value : throw new InvalidOperationException($"Holder option for {Title} is not a ModdedToggleOption."); } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs index ec8d09f..bf2fdf0 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs @@ -34,7 +34,7 @@ public class ModdedEnumOptionListAttribute(string title, Type enumType, string[] { return HolderOptionList?[idx] is ModdedEnumOption opt ? Enum.ToObject(enumType, opt.Value) - : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType ${enumType.FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType {enumType.FullName} is not a ModdedEnumOption"); } } @@ -68,6 +68,6 @@ public class ModdedEnumOptionListAttribute(string title, string[]? values = n { return HolderOptionList?[idx] is ModdedEnumOption opt ? opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType ${typeof(T).FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType {typeof(T).FullName} is not a ModdedEnumOption<{typeof(T).FullName}>"); } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs index d9eec55..088883e 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs @@ -31,9 +31,16 @@ public class ModdedNumberOptionListAttribute( { return new ModdedOptionList(value.Count, idx => { + var final = value[idx] switch + { + null => min + increment, + int integer => integer, + _ => (float)value[idx]!, + }; + return new( GetFormattedTitle(idx), - (float)(value[idx] ?? min + increment), + final, min, max, increment, @@ -46,15 +53,28 @@ public class ModdedNumberOptionListAttribute( /// public override void SetValue(int idx, object value) { - var opt = HolderOptionList?[idx] as ModdedNumberOption; - opt?.SetValue((float)value); + if (HolderOptionList?[idx] is not ModdedNumberOption opt) + return; + + var final = value switch + { + int integer => integer, + _ => (float)value, + }; + opt.SetValue(final); } /// public override object GetValue(int idx) { - return HolderOptionList?[idx] is ModdedNumberOption opt - ? (object)opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedNumberOption"); + if (HolderOptionList?[idx] is not ModdedNumberOption opt) + throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedNumberOption"); + + var value = opt.Value; + + if (ValueType == typeof(int)) + return (int)Math.Round(value); + + return value; } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs index d0ee68e..d7ef6e6 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.RegularExpressions; +using MiraAPI.Translation; namespace MiraAPI.GameOptions.Attributes; @@ -14,8 +15,8 @@ namespace MiraAPI.GameOptions.Attributes; public abstract class ModdedOptionListAttribute(string title) : PropertyOptionAttribute { internal IModdedOptionList? HolderOptionList { get; set; } - - internal object? Value { get; set; } + internal IList? Value { get; set; } + internal Type ValueType { get; set; } /// /// Gets the title of the options. @@ -31,16 +32,16 @@ public abstract class ModdedOptionListAttribute(string title) : PropertyOptionAt /// The new values as an object. public override void SetValue(object value) { - Value = value; - var list = (IList)value; - if (list.Count != HolderOptionList!.Count) + Value = (IList)value; + + if (Value.Count != HolderOptionList!.Count) { throw new InvalidOperationException($"Value set to option list cannot change the list's length."); } - for (int i = 0; i < list.Count; i++) + for (int i = 0; i < Value.Count; i++) { - SetValue(i, list[i]!); + SetValue(i, Value[i]!); } } @@ -77,18 +78,19 @@ public abstract class ModdedOptionListAttribute(string title) : PropertyOptionAt [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "I don't even know if this is worth it.")] protected string GetFormattedTitle(int index) { - Match match = Regex.Match(Title, @"\{(\d+)(?::[^}]+)?\}", default, Regex.InfiniteMatchTimeout); + var match = Regex.Match(Title.Translate(), @"\{(\d+)(?::[^}]+)?\}", default, Regex.InfiniteMatchTimeout); - if (match.Success) + if (!match.Success) { - if (match.Groups.Count == 1 && match.Groups[0].Value == "0") - { - return string.Format(Title, index); - } - - Error("ModdedOptionList Title cannot contain more than the first parameter for formatting."); + return Title + index; } + if (match.Value == "{0}" && !match.NextMatch().Success) + { + return string.Format(Title, index); + } + + Error($"ModdedOptionList Title of {Title} cannot contain more than the first parameter for formatting."); return Title + index; } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs new file mode 100644 index 0000000..bbf3900 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for creating a list of player options. +/// +/// The option title. +/// Whether the option includes a none option. If None is selected, value will return -1. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedPlayerOptionListAttribute( + string title, + bool allowNone = true) + : ModdedOptionListAttribute(title) +{ + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) + { + return new ModdedOptionList(value.Count, idx => + { + return new(GetFormattedTitle(idx), allowNone); + }); + } + + /// + public override void SetValue(int idx, object value) + { + if (HolderOptionList?[idx] is not ModdedPlayerOption opt) + return; + + var final = value switch + { + NetworkedPlayerInfo info => opt.Values.IndexOf(info), + _ => (int)value, + }; + opt?.SetValue(final); + } + + /// + public override object GetValue(int idx) + { + if (HolderOptionList?[idx] is not ModdedNumberOption opt) + throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedNumberOption"); + + var value = opt.Value; + + if (ValueType == typeof(int)) + return (int)Math.Round(value); + + return value; + } +} diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs index 461ee54..78c4b66 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs @@ -36,7 +36,7 @@ public class ModdedStringOptionListAttribute( public override object GetValue(int idx) { return HolderOptionList?[idx] is ModdedStringOption opt - ? (object)opt.Value + ? opt.Value : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedStringOption"); } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs index 0bcb0a6..4e01069 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs @@ -30,7 +30,7 @@ public class ModdedToggleOptionListAttribute(string title) : ModdedOptionListAtt public override object GetValue(int idx) { return HolderOptionList?[idx] is ModdedToggleOption opt - ? (object)opt.Value + ? opt.Value : throw new InvalidOperationException($"Holder option for {GetFormattedTitle(idx)} is not a ModdedToggleOption."); } } diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index 4dc150f..4cc8b03 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -62,14 +62,14 @@ public static class ModdedOptionsManager internal static bool RegisterGroup(Type type) { - if (Activator.CreateInstance(type) is not AbstractOptionGroup group) + if (TypeToGroup.ContainsKey(type)) { + Error($"Group {type.Name} already exists."); return false; } - if (TypeToGroup.ContainsKey(type)) + if (Activator.CreateInstance(type) is not AbstractOptionGroup group) { - Logger.Error($"Group {type.Name} already exists."); return false; } @@ -79,17 +79,17 @@ public static class ModdedOptionsManager internal static bool RegisterGroup(Type type, MiraPluginInfo pluginInfo) { - if (Activator.CreateInstance(type) is not AbstractOptionGroup group) - { - return false; - } - if (TypeToGroup.ContainsKey(type)) { Error($"Group {type.Name} already exists."); return false; } + if (Activator.CreateInstance(type) is not AbstractOptionGroup group) + { + return false; + } + if (typeof(AbstractGameMode).IsAssignableFrom(group.OptionableType)) { if (GameModeOptionGroups.TryGetValue(group.OptionableType, out var oldList)) @@ -117,7 +117,7 @@ public static class ModdedOptionsManager Groups.Add(group); TypeToGroup.Add(type, group); typeof(OptionGroupSingleton<>).MakeGenericType(type) - .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! // Message suppression points to here + .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! .SetValue(null, group); } @@ -164,16 +164,21 @@ public static class ModdedOptionsManager return; } - var setterOriginal = property.GetSetMethod(true); - var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); - PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); - var getterOriginal = property.GetGetMethod(true); var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); + var setterOriginal = property.GetSetMethod(true); + + if (setterOriginal != null) + { + var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); + PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); + } + OptionAttributes.Add(property, attribute); attribute.HolderOption = option; + attribute.ValueType = property.PropertyType; var visibilityAttr = property.GetCustomAttribute(); var visibilityFunc = visibilityAttr?.GetVisibility(group, property); @@ -220,9 +225,9 @@ public static class ModdedOptionsManager return; } - Func configName = optionList.ConfigName ?? (i => property.Name + (i + 1)); + var configName = optionList.ConfigName ?? (i => property.Name + (i + 1)); - for (int i = 0; i < optionList.Count; i++) + for (var i = 0; i < optionList.Count; i++) { RegisterOption(optionList[i], group, configName(i), pluginInfo); } @@ -269,32 +274,41 @@ public static class ModdedOptionsManager return; } - var setterOriginal = property.GetSetMethod(true); - var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); - PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); - var getterOriginal = property.GetGetMethod(true); var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); + var setterOriginal = property.GetSetMethod(true); + + if (setterOriginal != null) + { + var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); + PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); + } + var listType = propertyList.GetType(); if (PatchedListTypes.Add(listType)) { var listIndex = listType.GetProperty("Item")!; - var listSetterOriginal = listIndex.GetSetMethod(true); - var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); - var listGetterOriginal = listIndex.GetGetMethod(true); var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); + + var listSetterOriginal = listIndex.GetSetMethod(true); + + if (listSetterOriginal != null) + { + var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); + PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); + } } OptionAttributes.Add(property, attribute); attribute.HolderOptionList = optionList; - attribute.Value = propertyVal; + attribute.Value = (IList)propertyVal; + attribute.ValueType = listType.GetGenericArguments()[0]; var valueType = property.PropertyType.GetGenericArguments()[0]; diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs index 5d0b792..7931d02 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs @@ -50,7 +50,7 @@ public class ModdedEnumOption : ModdedOption $"{ParentMod!.OptionsTitleText}.EnumOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); - stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + stringOption.OnValueChanged = (Action)ValueChanged; // Changing the button text to make them more distinct from number options stringOption.MinusBtn.ChangeButtonText("<"); @@ -153,7 +153,7 @@ public class ModdedEnumOption : ModdedOption $"{ParentMod!.OptionsTitleText}.EnumOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); - stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + stringOption.OnValueChanged = (Action)ValueChanged; // Changing the button text to make them more distinct from number options stringOption.MinusBtn.ChangeButtonText("<"); diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs index a45201f..f27ed13 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs @@ -78,8 +78,36 @@ public class ModdedNumberOption : ModdedOption string? formatString = null, bool halfIncrements = false, bool includeInPreset = true) - : this(title, defaultValue, min, max, increment, suffixType, zeroBehavior, negativeBehavior, halfIncrements, formatString, includeInPreset) + : base(title, defaultValue, includeInPreset) { + Min = min; + Max = max; + Increment = increment; + SuffixType = suffixType; + ZeroWordValue = zeroBehavior; + ShiftIncrement = halfIncrements; + NegativeWordValue = negativeBehavior; + + Value = Mathf.Clamp(defaultValue, min, max); + + var data = ScriptableObject.CreateInstance(); + data.Type = global::OptionTypes.Float; + data.Title = StringName; + data.Value = Value; + data.Increment = Increment; + data.ValidRange = new FloatRange(Min, Max); + data.FormatString = formatString ?? + (defaultValue.IsInteger() && + Increment.IsInteger() && + Value.IsInteger() && + Min.IsInteger() && + Max.IsInteger() ? "0" : "0.0"); + + data.ZeroIsInfinity = ZeroInfinity; + data.SuffixType = (NumberSuffixes)SuffixType; + data.OptionName = FloatOptionNames.Invalid; + + Data = data; } /// @@ -104,54 +132,10 @@ public class ModdedNumberOption : ModdedOption string? formatString = null, bool zeroInfinity = false, bool includeInPreset = true) - : this(title, defaultValue, min, max, increment, suffixType, zeroInfinity ? "∞" : "#", "#", increment == 1f, formatString, includeInPreset) + : this(title, defaultValue, min, max, increment, zeroInfinity ? "∞" : "#", "#", suffixType, formatString, increment == 1f, includeInPreset) { } - private ModdedNumberOption( - string title, - float defaultValue, - float min, - float max, - float increment, - MiraNumberSuffixes suffixType, - string zeroBehavior, - string negativeBehavior, - bool shiftIncrement, - string? formatString, - bool includeInPreset) - : base(title, defaultValue, includeInPreset) - { - Min = min; - Max = max; - Increment = increment; - SuffixType = suffixType; - ZeroWordValue = zeroBehavior; - ShiftIncrement = shiftIncrement; - NegativeWordValue = negativeBehavior; - - Value = Mathf.Clamp(defaultValue, min, max); - - var data = ScriptableObject.CreateInstance(); - data.Type = global::OptionTypes.Float; - data.Title = StringName; - data.Value = Value; - data.Increment = Increment; - data.ValidRange = new FloatRange(Min, Max); - data.FormatString = formatString ?? - (defaultValue.IsInteger() && - Increment.IsInteger() && - Value.IsInteger() && - Min.IsInteger() && - Max.IsInteger() ? "0" : "0.0"); - - data.ZeroIsInfinity = ZeroInfinity; - data.SuffixType = (NumberSuffixes)SuffixType; - data.OptionName = FloatOptionNames.Invalid; - - Data = data; - } - /// public override OptionBehaviour CreateOption( ToggleOption toggleOpt, diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs index e45c81e..513aad0 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs @@ -29,6 +29,11 @@ public class ModdedPlayerOption : ModdedOption /// public bool AllowNone { get; set; } + /// + /// Gets the player selected by this option. May be null if allowed to have None as an option. + /// + public NetworkedPlayerInfo? SelectedPlayer => Value == -1 ? null : Values[Value]; + /// /// Initializes a new instance of the class. /// @@ -64,7 +69,7 @@ public class ModdedPlayerOption : ModdedOption Values = GetFilteredPlayers(); playerOption.SetUpFromData(Data, 20); - playerOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + playerOption.OnValueChanged = (Action)ValueChanged; playerOption.Title = StringName; playerOption.TitleText.text = Title.Translate(); diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs index 291ba67..b1c9cc1 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs @@ -124,7 +124,7 @@ public class ModdedStringOption : ModdedOption $"{ParentMod!.OptionsTitleText}.StringOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); - stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + stringOption.OnValueChanged = (Action)ValueChanged; // Changing the button text to make them more distinct from number options stringOption.MinusBtn.ChangeButtonText("<"); diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs index 1a331d4..627245a 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs @@ -11,6 +11,11 @@ namespace MiraAPI.GameOptions.OptionTypes; /// public class ModdedToggleOption : ModdedOption { + /// + /// Gets or sets the delegate that dictates how a toggle option's value is displayed. + /// + public Func DisplayValue { get; set; } = value => value ? "On" : "Off"; + /// /// Initializes a new instance of the class. /// @@ -39,7 +44,7 @@ public class ModdedToggleOption : ModdedOption toggleOption.Title = StringName; toggleOption.TitleText.text = Title.Translate(); toggleOption.CheckMark.enabled = Value; - toggleOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + toggleOption.OnValueChanged = (Action)ValueChanged; OptionBehaviour = toggleOption; @@ -76,7 +81,7 @@ public class ModdedToggleOption : ModdedOption ModdedOptionsManager.AddSettingsChangeMessage( HudManager.Instance.Notifier, StringName, - newValue ? "On" : "Off", + DisplayValue(newValue).Translate(), Configuration.PopUpTextColor, Configuration.PopUpIconTmp, false); diff --git a/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs b/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs index 2f7d67a..89347e0 100644 --- a/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs +++ b/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs @@ -126,7 +126,7 @@ public sealed class HideAndSeekHudHelper(nint cppPtr) : MonoBehaviour(cppPtr) currentFinalHideTime = totalFinalHideTime; if (timerBar != null) { - Object.Destroy(timerBar); + Destroy(timerBar); } finalHideAlertSfx = GameManagerCreator.Instance.HideAndSeekManagerPrefab.FinalHideAlertSFX; diff --git a/MiraAPI/Hud/MiraHudHelper.cs b/MiraAPI/Hud/MiraHudHelper.cs index f2d1d4b..27c1968 100644 --- a/MiraAPI/Hud/MiraHudHelper.cs +++ b/MiraAPI/Hud/MiraHudHelper.cs @@ -151,7 +151,7 @@ public sealed class MiraHudHelper(nint cppPtr) : MonoBehaviour(cppPtr) var oldPos = settingsButton.transform.localPosition; settingsButton.transform.localPosition = new Vector3(oldPos.x, oldPos.y, -100); var chatButton = instance.Chat.chatButton.gameObject; - ClonedChatButton = Object.Instantiate(chatButton, chatButton.transform.parent); + ClonedChatButton = Instantiate(chatButton, chatButton.transform.parent); ClonedChatButton.SetActive(false); instance.Chat.chatButtonAspectPosition = ClonedChatButton.GetComponent(); chatButton.GetComponent().Destroy(); diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index b46d155..5ce5636 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -313,9 +313,9 @@ public sealed class MiraPluginManager foreach (var property in type.GetProperties()) { - if (property.GetMethod?.IsStatic == true) + if (property.GetMethod?.IsStatic == true && property.GetCustomAttribute() == null) { - Error($"Option property {property.Name} in {type.Name} must not be static."); + Error($"Non-ignored option property {property.Name} in {type.Name} must not be static."); continue; } @@ -325,6 +325,12 @@ public sealed class MiraPluginManager continue; } + if (property.PropertyType.IsAssignableTo(typeof(IModdedOptionList))) + { + ModdedOptionsManager.RegisterPropertyOptionList(type, property, pluginInfo); + continue; + } + var attribute = property.GetCustomAttribute(); if (attribute != null) { @@ -332,11 +338,6 @@ public sealed class MiraPluginManager continue; } - if (property.PropertyType.IsAssignableTo(typeof(IModdedOptionList))) - { - ModdedOptionsManager.RegisterPropertyOptionList(type, property, pluginInfo); - } - var listAttr = property.GetCustomAttribute(); if (listAttr != null) { From 5e12d1f0d60b2ef501c6180492de5bedb8ac2b16 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:07:17 -0400 Subject: [PATCH 08/13] oops, copy paste --- .../OptionList/ModdedPlayerOptionListAttribute.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs index bbf3900..c8e802f 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs @@ -41,14 +41,12 @@ public class ModdedPlayerOptionListAttribute( /// public override object GetValue(int idx) { - if (HolderOptionList?[idx] is not ModdedNumberOption opt) - throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedNumberOption"); - - var value = opt.Value; + if (HolderOptionList?[idx] is not ModdedPlayerOption opt) + throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedPlayerOption"); if (ValueType == typeof(int)) - return (int)Math.Round(value); + return opt.Value; - return value; + return opt.SelectedPlayer!; } } From aa69fafcf18a824d0ad6140cc085d4d2306bfcdd Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:16:58 -0400 Subject: [PATCH 09/13] minor changes --- MiraAPI/Patches/Options/SettingPatches.cs | 26 +++++++++++------------ MiraAPI/Utilities/MiraNumberSuffixes.cs | 2 +- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/MiraAPI/Patches/Options/SettingPatches.cs b/MiraAPI/Patches/Options/SettingPatches.cs index 3300a02..1ef854b 100644 --- a/MiraAPI/Patches/Options/SettingPatches.cs +++ b/MiraAPI/Patches/Options/SettingPatches.cs @@ -46,15 +46,14 @@ public static class SettingPatches } else { + var part = value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo); result = suffix switch { - MiraNumberSuffixes.None => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo), - MiraNumberSuffixes.Multiplier => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "x", - MiraNumberSuffixes.Percent => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "%", - MiraNumberSuffixes.Distance => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + MiraApiPlugin.DistanceSuffix.Value, - _ => TranslationController.Instance.GetString( - StringNames.GameSecondsAbbrev, - (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), + MiraNumberSuffixes.None => part, + MiraNumberSuffixes.Multiplier => part + "x", + MiraNumberSuffixes.Percent => part + "%", + MiraNumberSuffixes.Distance => string.Concat(part, " ", MiraApiPlugin.DistanceSuffix.Value), + _ => TranslationController.Instance.GetString(StringNames.GameSecondsAbbrev, part), }; } } @@ -64,15 +63,14 @@ public static class SettingPatches } else { + var part = value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo); result = suffix switch { - MiraNumberSuffixes.None => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo), - MiraNumberSuffixes.Multiplier => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "x", - MiraNumberSuffixes.Percent => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "%", - MiraNumberSuffixes.Distance => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + MiraApiPlugin.DistanceSuffix.Value, - _ => TranslationController.Instance.GetString( - StringNames.GameSecondsAbbrev, - (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), + MiraNumberSuffixes.None => part, + MiraNumberSuffixes.Multiplier => part + "x", + MiraNumberSuffixes.Percent => part + "%", + MiraNumberSuffixes.Distance => string.Concat(part, " ", MiraApiPlugin.DistanceSuffix.Value), + _ => TranslationController.Instance.GetString(StringNames.GameSecondsAbbrev, part), }; } diff --git a/MiraAPI/Utilities/MiraNumberSuffixes.cs b/MiraAPI/Utilities/MiraNumberSuffixes.cs index 81274e9..d434814 100644 --- a/MiraAPI/Utilities/MiraNumberSuffixes.cs +++ b/MiraAPI/Utilities/MiraNumberSuffixes.cs @@ -26,7 +26,7 @@ public enum MiraNumberSuffixes Percent, /// - /// Distance suffix (m). + /// Distance suffix (u). /// Distance, } From ca587ebb18fbdd5fb25c1d8877d3c04ffa0de506 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:21:09 -0400 Subject: [PATCH 10/13] optimise code + add new example option --- MiraAPI.Example/Options/ExampleOptions.cs | 3 ++ MiraAPI.Example/Resources/Locale/en_US.xml | 1 + .../Attributes/ModdedPlayerOptionAttribute.cs | 2 +- .../ModdedPlayerOptionListAttribute.cs | 2 +- MiraAPI/GameOptions/ModdedOptionsManager.cs | 39 +++++++++++++------ MiraAPI/PluginLoading/MiraPluginManager.cs | 5 ++- 6 files changed, 37 insertions(+), 15 deletions(-) diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index c60038c..6f33856 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -45,6 +45,9 @@ public class ExampleOptions : AbstractOptionGroup public List ValueStates { get; set; } = [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; + [ModdedPlayerOption("options.exampleOptions1.players")] + public NetworkedPlayerInfo PlayerSelected { get; set; } + public bool ToggleOpt1Visibility() { return true; diff --git a/MiraAPI.Example/Resources/Locale/en_US.xml b/MiraAPI.Example/Resources/Locale/en_US.xml index e446b69..b37cad6 100644 --- a/MiraAPI.Example/Resources/Locale/en_US.xml +++ b/MiraAPI.Example/Resources/Locale/en_US.xml @@ -48,6 +48,7 @@ Eliminate every other faction. Number Opt Best API Worst API + Choose A Player Example Options 2 YOU MUST CHOSE A PLAYER Eh whatever diff --git a/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs index 086df17..610d0c8 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs @@ -41,7 +41,7 @@ public class ModdedPlayerOptionAttribute( var final = value switch { - NetworkedPlayerInfo info => opt.Values.IndexOf(info), + NetworkedPlayerInfo info => info ? opt.Values.IndexOf(info) : (allowNone ? -1 : 0), _ => (int)value, }; opt?.SetValue(final); diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs index c8e802f..533492b 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs @@ -32,7 +32,7 @@ public class ModdedPlayerOptionListAttribute( var final = value switch { - NetworkedPlayerInfo info => opt.Values.IndexOf(info), + NetworkedPlayerInfo info => info ? opt.Values.IndexOf(info) : (allowNone ? -1 : 0), _ => (int)value, }; opt?.SetValue(final); diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index 4cc8b03..21c9f1a 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using BepInEx.Configuration; using HarmonyLib; using MiraAPI.GameModes; @@ -24,7 +25,10 @@ namespace MiraAPI.GameOptions; /// public static class ModdedOptionsManager { + private static readonly ConditionalWeakTable ListInstanceCache = []; private static readonly Dictionary OptionAttributes = []; + private static readonly Dictionary GetterLookups = []; + private static readonly Dictionary SetterLookups = []; private static readonly Dictionary TypeToGroup = []; private static readonly HashSet PatchedListTypes = []; @@ -168,12 +172,16 @@ public static class ModdedOptionsManager var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); + GetterLookups[getterOriginal!] = attribute; + var setterOriginal = property.GetSetMethod(true); if (setterOriginal != null) { var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); + + SetterLookups[setterOriginal] = attribute; } OptionAttributes.Add(property, attribute); @@ -278,12 +286,16 @@ public static class ModdedOptionsManager var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); + GetterLookups[getterOriginal!] = attribute; + var setterOriginal = property.GetSetMethod(true); if (setterOriginal != null) { var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); + + SetterLookups[setterOriginal] = attribute; } var listType = propertyList.GetType(); @@ -307,9 +319,11 @@ public static class ModdedOptionsManager OptionAttributes.Add(property, attribute); attribute.HolderOptionList = optionList; - attribute.Value = (IList)propertyVal; + attribute.Value = propertyList; attribute.ValueType = listType.GetGenericArguments()[0]; + ListInstanceCache.Add(propertyVal, attribute); + var valueType = property.PropertyType.GetGenericArguments()[0]; var visibilityAttr = property.GetCustomAttribute(); @@ -422,8 +436,13 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertySetterPatch(MethodBase __originalMethod, object value) { - var attribute = OptionAttributes.First(pair => pair.Key.GetSetMethod() == __originalMethod).Value; + if (!SetterLookups.TryGetValue(__originalMethod, out var attribute)) + return; + attribute.SetValue(value); + + if (attribute is ModdedOptionListAttribute listAttribute && value != null) + ListInstanceCache.AddOrUpdate(value, listAttribute); } /// @@ -436,7 +455,9 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyGetterPatch(MethodBase __originalMethod, ref object __result) { - var attribute = OptionAttributes.First(pair => pair.Key.GetGetMethod() == __originalMethod).Value; + if (!GetterLookups.TryGetValue(__originalMethod, out var attribute)) + return true; + __result = attribute.GetValue(); return false; } @@ -451,10 +472,8 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertyListSetterPatch(object __instance, int index, object value) { - var match = OptionAttributes.Values - .OfType() - .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); - match?.SetValue(index, value); + if (ListInstanceCache.TryGetValue(__instance, out var match)) + match.SetValue(index, value); } /// @@ -468,11 +487,7 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyListGetterPatch(object __instance, int index, ref object __result) { - var match = OptionAttributes.Values - .OfType() - .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); - - if (match is null) + if (!ListInstanceCache.TryGetValue(__instance, out var match)) return true; __result = match.GetValue(index); diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index 5ce5636..f021c3f 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -313,7 +313,10 @@ public sealed class MiraPluginManager foreach (var property in type.GetProperties()) { - if (property.GetMethod?.IsStatic == true && property.GetCustomAttribute() == null) + if (property.GetCustomAttribute() != null) + continue; + + if (property.GetMethod?.IsStatic == true) { Error($"Non-ignored option property {property.Name} in {type.Name} must not be static."); continue; From 6364f8eae69d3600d8f454d1e7a3872f3672dfc2 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:27:24 -0400 Subject: [PATCH 11/13] constructor parity + scrap toggle display delegate --- .../Attributes/ModdedNumberOptionAttribute.cs | 36 +++++++++++++++-- .../Attributes/ModdedToggleOptionAttribute.cs | 19 ++++++++- .../ModdedNumberOptionListAttribute.cs | 36 +++++++++++++++-- .../ModdedToggleOptionListAttribute.cs | 19 ++++++++- .../OptionTypes/ModdedToggleOption.cs | 39 ++++++++++++++----- MiraAPI/Resources/Locale/en_US.xml | 8 ++++ 6 files changed, 136 insertions(+), 21 deletions(-) diff --git a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs index 9de15ee..f43773e 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs @@ -12,20 +12,46 @@ namespace MiraAPI.GameOptions.Attributes; /// The minimum value. /// The maximum value. /// The increment. +/// Determines what is shown for zero. +/// Determines what is shown for negative 1. /// The suffix type. /// Optional format string for the option screen. -/// Flag indicating whether 0 means infinity. +/// Whether increments can be split in half (using Shift). [AttributeUsage(AttributeTargets.Property)] public class ModdedNumberOptionAttribute( string title, float min, float max, - float increment = 1, + float increment, + string zeroBehavior, + string negativeBehavior, MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, string? formatString = null, - bool zeroInfinity = false) + bool halfIncrements = false) : ModdedOptionAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// The minimum value. + /// The maximum value. + /// The increment. + /// The suffix type. + /// Whether zero is infinity or not. + /// Optional format string for the option screen. + public ModdedNumberOptionAttribute( + string title, + float min, + float max, + float increment = 1, + MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, + string? formatString = null, + bool zeroInfinity = false) + : this(title, min, max, increment, zeroInfinity ? "∞" : "#", "#", suffixType, formatString, increment == 1f) + { + } + internal override IModdedOption CreateOption(object? value, PropertyInfo property) { var final = value switch @@ -41,9 +67,11 @@ public class ModdedNumberOptionAttribute( min, max, increment, + zeroBehavior, + negativeBehavior, suffixType, formatString, - zeroInfinity); + halfIncrements); } /// diff --git a/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs index 2a2ac2f..042c1f3 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs @@ -8,12 +8,27 @@ namespace MiraAPI.GameOptions.Attributes; /// Attribute for a . /// /// The option title. +/// What the option displays when toggled on. +/// What the option displays when toggled off. [AttributeUsage(AttributeTargets.Property)] -public class ModdedToggleOptionAttribute(string title) : ModdedOptionAttribute(title) +public class ModdedToggleOptionAttribute( + string title, + string on, + string off) + : ModdedOptionAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The option title. + public ModdedToggleOptionAttribute(string title) + : this(title, "On", "Off") + { + } + internal override IModdedOption CreateOption(object? value, PropertyInfo property) { - var toggleOpt = new ModdedToggleOption(Title, (bool)(value ?? false)); + var toggleOpt = new ModdedToggleOption(Title, (bool)(value ?? false), on, off); return toggleOpt; } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs index 088883e..c393db3 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs @@ -13,20 +13,46 @@ namespace MiraAPI.GameOptions.Attributes; /// The minimum value. /// The maximum value. /// The increment. +/// Determines what is shown for zero. +/// Determines what is shown for negative 1. /// The suffix type. /// Optional format string for the option screen. -/// Flag indicating whether 0 means infinity. +/// Whether increments can be split in half (using Shift). [AttributeUsage(AttributeTargets.Property)] public class ModdedNumberOptionListAttribute( string title, float min, float max, - float increment = 1, + float increment, + string zeroBehavior, + string negativeBehavior, MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, string? formatString = null, - bool zeroInfinity = false) + bool halfIncrements = false) : ModdedOptionListAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// The minimum value. + /// The maximum value. + /// The increment. + /// The suffix type. + /// Whether zero is infinity or not. + /// Optional format string for the option screen. + public ModdedNumberOptionListAttribute( + string title, + float min, + float max, + float increment = 1, + MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, + string? formatString = null, + bool zeroInfinity = false) + : this(title, min, max, increment, zeroInfinity ? "∞" : "#", "#", suffixType, formatString, increment == 1f) + { + } + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) { return new ModdedOptionList(value.Count, idx => @@ -44,9 +70,11 @@ public class ModdedNumberOptionListAttribute( min, max, increment, + zeroBehavior, + negativeBehavior, suffixType, formatString, - zeroInfinity); + halfIncrements); }); } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs index 4e01069..9dab9a4 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs @@ -9,13 +9,28 @@ namespace MiraAPI.GameOptions.Attributes; /// Attribute for a list of toggle options. /// /// The option's title. +/// What the option displays when toggled on. +/// What the option displays when toggled off. [AttributeUsage(AttributeTargets.Property)] -public class ModdedToggleOptionListAttribute(string title) : ModdedOptionListAttribute(title) +public class ModdedToggleOptionListAttribute( + string title, + string on, + string off) + : ModdedOptionListAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The option's title. + public ModdedToggleOptionListAttribute(string title) + : this(title, "On", "Off") + { + } + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) { var optList = new ModdedOptionList( - value.Count, idx => new(GetFormattedTitle(idx), (bool)(value[idx] ?? false))); + value.Count, idx => new(GetFormattedTitle(idx), (bool)(value[idx] ?? false), on, off)); return optList; } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs index 627245a..a0759c6 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs @@ -12,9 +12,35 @@ namespace MiraAPI.GameOptions.OptionTypes; public class ModdedToggleOption : ModdedOption { /// - /// Gets or sets the delegate that dictates how a toggle option's value is displayed. + /// Gets or sets the display value when the option is toggled on. /// - public Func DisplayValue { get; set; } = value => value ? "On" : "Off"; + public string OnValue { get; set; } + + /// + /// Gets or sets the display value when the option is toggled off. + /// + public string OffValue { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The option title. + /// The default value. + /// What the option displays when toggled on. + /// What the option displays when toggled off. + /// Whether to include this option in the preset or not. + public ModdedToggleOption(string title, bool defaultValue, string on, string off, bool includeInPreset = true) + : base(title, defaultValue, includeInPreset) + { + OnValue = on; + OffValue = off; + + var data = ScriptableObject.CreateInstance(); + data.Title = StringName; + data.Type = global::OptionTypes.Checkbox; + + Data = data; + } /// /// Initializes a new instance of the class. @@ -23,13 +49,8 @@ public class ModdedToggleOption : ModdedOption /// The default value. /// Whether to include this option in the preset or not. public ModdedToggleOption(string title, bool defaultValue, bool includeInPreset = true) - : base(title, defaultValue, includeInPreset) + : this(title, defaultValue, "On", "Off", includeInPreset) { - var data = ScriptableObject.CreateInstance(); - data.Title = StringName; - data.Type = global::OptionTypes.Checkbox; - - Data = data; } /// @@ -81,7 +102,7 @@ public class ModdedToggleOption : ModdedOption ModdedOptionsManager.AddSettingsChangeMessage( HudManager.Instance.Notifier, StringName, - DisplayValue(newValue).Translate(), + (newValue ? OnValue : OffValue).Translate(), Configuration.PopUpTextColor, Configuration.PopUpIconTmp, false); diff --git a/MiraAPI/Resources/Locale/en_US.xml b/MiraAPI/Resources/Locale/en_US.xml index 3cff5f4..ed67fd2 100644 --- a/MiraAPI/Resources/Locale/en_US.xml +++ b/MiraAPI/Resources/Locale/en_US.xml @@ -66,4 +66,12 @@ Outlive the timer or eliminate the crew. Previous Next No Results Found + On + Off + Enabled + Disabled + Active + Inactive + Activated + Deactivated From 5106efe2acbe03d0166a0fd0f3aaba2a4d893104 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:52:53 -0400 Subject: [PATCH 12/13] optimisations and utilities --- MiraAPI.Example/Options/ExampleOptions.cs | 5 +- MiraAPI/GameOptions/AbstractOptionGroup.cs | 3 +- .../Delegate/ModdedOptionChangedAttribute.cs | 58 +---- .../Delegate/ModdedOptionGetterAttribute.cs | 58 +---- .../Delegate/ModdedOptionMemberAttribute.cs | 202 ++++++++++++++---- .../Delegate/ModdedOptionSetterAttribute.cs | 58 +---- .../ModdedOptionVisibilityAttribute.cs | 9 +- MiraAPI/GameOptions/ModdedList.cs | 9 + MiraAPI/GameOptions/ModdedOptionsManager.cs | 138 ++++++------ .../GameOptions/OptionTypes/ModdedOption.cs | 3 +- MiraAPI/Resources/Locale/en_US.xml | 2 +- MiraAPI/Roles/ICustomRole.cs | 2 +- MiraAPI/Utilities/DelegateUtilities.cs | 19 ++ 13 files changed, 284 insertions(+), 282 deletions(-) create mode 100644 MiraAPI/GameOptions/ModdedList.cs create mode 100644 MiraAPI/Utilities/DelegateUtilities.cs diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index b5745e5..9e2e1a9 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Utilities; @@ -43,7 +42,7 @@ public class ExampleOptions : AbstractOptionGroup public Api WorstApi { get; set; } = Api.MiraAPI; [ModdedEnumOptionList("Value State {0}", ["None", "Primary", "Secondary"])] - public List ValueStates { get; set; } = + public ModdedList ValueStates { get; set; } = [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; [ModdedPlayerOption("options.exampleOptions1.players")] diff --git a/MiraAPI/GameOptions/AbstractOptionGroup.cs b/MiraAPI/GameOptions/AbstractOptionGroup.cs index 52d8fdf..589f705 100644 --- a/MiraAPI/GameOptions/AbstractOptionGroup.cs +++ b/MiraAPI/GameOptions/AbstractOptionGroup.cs @@ -6,6 +6,7 @@ using System.Linq; using Il2CppInterop.Runtime.Attributes; using MiraAPI.Modifiers; using MiraAPI.Roles; +using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; using UnityEngine; @@ -55,7 +56,7 @@ public abstract class AbstractOptionGroup /// /// Gets the function that determines whether the group should be visible or not. /// - public virtual Func GroupVisible => () => true; + public virtual Func GroupVisible => DelegateUtilities.AlwaysTrue; /// /// Gets a value indicating whether this group's options are shown in the Match Info Guide. diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs index 0d75a11..f50dcda 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs @@ -14,73 +14,25 @@ public sealed class ModdedOptionChangedAttribute(string? methodName = null, Type { internal Delegate? GetChanged(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) { - if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) - { - Error($"Method {MemberName} does not exist in {type.FullName}"); + if (ResolveMethod(group, property, out var type, out var instanceGetter) is not { } method) return null; - } - - if (method.ReturnType != typeof(void)) - { - Error($"{MemberName}'s return type is not void."); - return null; - } - - var parameters = method.GetParameters(); - - if (parameters.Length != 1) - { - Error($"{MemberName} must have exactly one parameter."); - return null; - } var valueType = option.GetValueType(); - if (parameters[0].ParameterType != valueType) - { - Error($"{MemberName}'s parameter must be of the type {valueType.Name}."); + if (!ValidateSignature(method, typeof(void), valueType)) return null; - } var delegateType = typeof(Action<>).MakeGenericType(valueType); return CompileDynamicDelegate(type, method, instanceGetter, typeof(void), [valueType], delegateType, "Changed"); } - internal Func? GetListChanged(AbstractOptionGroup group, PropertyInfo property, Type valueType) + internal Func? GetListChanged(AbstractOptionGroup group, PropertyInfo property, Type valueType) { - if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) - { - Error($"Method {MemberName} does not exist in {type.FullName}"); + if (ResolveMethod(group, property, out var type, out var instanceGetter) is not { } method) return null; - } - if (method.ReturnType != typeof(void)) - { - Error($"{MemberName}'s return type is not void."); + if (!ValidateListSignature(method, typeof(void), out var hasIndex, valueType)) return null; - } - - var parameters = method.GetParameters(); - - if (parameters.Length is < 1 or > 2) - { - Error($"{MemberName} must have one parameter ({valueType.Name}) or two parameters ({valueType.Name}, int)."); - return null; - } - - if (parameters[0].ParameterType != valueType) - { - Error($"{MemberName}'s first parameter must be of the type {valueType.Name}."); - return null; - } - - var hasIndex = parameters.Length == 2; - - if (hasIndex && parameters[1].ParameterType != typeof(int)) - { - Error($"{MemberName}'s second parameter must be an int (the list index)."); - return null; - } var delegateType = typeof(Action<>).MakeGenericType(valueType); return CompileDynamicListDelegate(type, method, instanceGetter, typeof(void), [valueType], delegateType, "ListChanged", hasIndex); diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs index 3434951..6a31bf8 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs @@ -14,73 +14,25 @@ public sealed class ModdedOptionGetterAttribute(string? methodName = null, Type? { internal Delegate? GetGetter(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) { - if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) - { - Error($"Method {MemberName} does not exist in {type.FullName}"); + if (ResolveMethod(group, property, out var type, out var instanceGetter) is not { } method) return null; - } var valueType = option.GetValueType(); - if (method.ReturnType != valueType) - { - Error($"{MemberName}'s return type is not {valueType.Name}."); + if (!ValidateSignature(method, valueType, valueType)) return null; - } - - var parameters = method.GetParameters(); - - if (parameters.Length != 1) - { - Error($"{MemberName} must have exactly one parameter."); - return null; - } - - if (parameters[0].ParameterType != valueType) - { - Error($"{MemberName}'s parameter must be of the type {valueType.Name}."); - return null; - } var delegateType = typeof(Func<,>).MakeGenericType(valueType, valueType); return CompileDynamicDelegate(type, method, instanceGetter, valueType, [valueType], delegateType, "Getter"); } - internal Func? GetListGetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) + internal Func? GetListGetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) { - if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) - { - Error($"Method {MemberName} does not exist in {type.FullName}"); + if (ResolveMethod(group, property, out var type, out var instanceGetter) is not { } method) return null; - } - if (method.ReturnType != valueType) - { - Error($"{MemberName}'s return type is not {valueType.Name}."); + if (!ValidateListSignature(method, valueType, out var hasIndex, valueType)) return null; - } - - var parameters = method.GetParameters(); - - if (parameters.Length is < 1 or > 2) - { - Error($"{MemberName} must have one parameter ({valueType.Name}) or two parameters ({valueType.Name}, int)."); - return null; - } - - if (parameters[0].ParameterType != valueType) - { - Error($"{MemberName}'s first parameter must be of the type {valueType.Name}."); - return null; - } - - var hasIndex = parameters.Length == 2; - - if (hasIndex && parameters[1].ParameterType != typeof(int)) - { - Error($"{MemberName}'s second parameter must be an int (the list index)."); - return null; - } var delegateType = typeof(Func<,>).MakeGenericType(valueType, valueType); return CompileDynamicListDelegate(type, method, instanceGetter, valueType, [valueType], delegateType, "ListGetter", hasIndex); diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs index 43742ba..2e1826c 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -15,11 +16,116 @@ namespace MiraAPI.GameOptions.Attributes; [AttributeUsage(AttributeTargets.Property)] public abstract class ModdedOptionMemberAttribute(string? memberName, Type? holderType, string defaultSuffix) : Attribute { + private static readonly Dictionary DelegateCache = []; + + /// + /// A helper class to bind a specific list index to a dynamically compiled delegate. + /// + /// The index to bind. + public sealed class DelegateIndexClosure(int index) + { + /// + /// The bound index. + /// + [SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "No need to be private.")] + [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Read above.")] + public readonly int Index = index; + } + + /// + /// Exposes the index field so child attributes can emit instructions to read it. + /// + protected static readonly FieldInfo IndexClosureField = typeof(DelegateIndexClosure).GetField(nameof(DelegateIndexClosure.Index))!; + /// /// Gets the resolved name of the target member. /// protected string? MemberName { get; private set; } = memberName; + /// + /// Validates that a method matches the expected return type and parameter types. + /// + /// The method to validate. + /// The expected return type of the method. + /// The expected types of the parameters of the method. + /// true if the method is valid; false otherwise. + protected bool ValidateSignature(MethodInfo method, Type expectedReturnType, params Type[] expectedParamTypes) + { + if (method.ReturnType != expectedReturnType) + { + Error($"{MemberName}'s return type must be {expectedReturnType.Name}."); + return false; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != expectedParamTypes.Length) + { + Error($"{MemberName} must have exactly {expectedParamTypes.Length} parameter(s)."); + return false; + } + + for (var i = 0; i < expectedParamTypes.Length; i++) + { + if (parameters[i].ParameterType == expectedParamTypes[i]) + continue; + + Error($"{MemberName}'s parameter at index {i} must be {expectedParamTypes[i].Name}."); + return false; + } + + return true; + } + + /// + /// Validates that a list method matches the expected return type, base parameter types, and an optional integer index. + /// + /// The method to validate. + /// The expected return type of the method. + /// When the method returns, a flag that indicates if the method has an index parameter. + /// The expected types of the parameters of the method. + /// true if the method is valid; false otherwise. + protected bool ValidateListSignature(MethodInfo method, Type expectedReturnType, out bool hasIndex, params Type[] expectedParamTypes) + { + hasIndex = false; + + if (method.ReturnType != expectedReturnType) + { + Error($"{MemberName}'s return type must be {expectedReturnType.Name}."); + return false; + } + + var parameters = method.GetParameters(); + + if (parameters.Length < expectedParamTypes.Length || parameters.Length > expectedParamTypes.Length + 1) + { + Error($"{MemberName} must have {expectedParamTypes.Length} parameter(s), optionally with an extra trailing int parameter."); + return false; + } + + for (var i = 0; i < expectedParamTypes.Length; i++) + { + if (parameters[i].ParameterType == expectedParamTypes[i]) + continue; + + Error($"{MemberName}'s parameter at index {i} must be {expectedParamTypes[i].Name}."); + return false; + } + + if (parameters.Length == expectedParamTypes.Length + 1) + { + if (parameters[^1].ParameterType != typeof(int)) + { + Error($"{MemberName}'s final parameter must be an int (the list index)."); + return false; + } + + hasIndex = true; + } + + return true; + } + /// /// Resolves the target (property or method) and its associated singleton instance getter, if applicable. /// @@ -123,6 +229,9 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold bool forwardParameters = true, Action? postProcessCall = null) { + if (postProcessCall == null && DelegateCache.TryGetValue(method, out var cachedDelegate)) + return cachedDelegate; + var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}", returnType, paramTypes, ownerType, true); var il = dm.GetILGenerator(); @@ -133,16 +242,19 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold { for (byte i = 0; i < paramTypes.Length; i++) { - if (i == 0) - il.Emit(OpCodes.Ldarg_0); - else if (i == 1) - il.Emit(OpCodes.Ldarg_1); - else if (i == 2) - il.Emit(OpCodes.Ldarg_2); - else if (i == 3) - il.Emit(OpCodes.Ldarg_3); + var loadOp = i switch + { + 0 => OpCodes.Ldarg_0, + 1 => OpCodes.Ldarg_1, + 2 => OpCodes.Ldarg_2, + 3 => OpCodes.Ldarg_3, + _ => OpCodes.Ldarg_S, + }; + + if (loadOp == OpCodes.Ldarg_S) + il.Emit(loadOp, i); else - il.Emit(OpCodes.Ldarg_S, i); + il.Emit(loadOp); } } @@ -153,7 +265,12 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold il.Emit(OpCodes.Ret); - return dm.CreateDelegate(delegateType); + var compiledDelegate = dm.CreateDelegate(delegateType); + + if (postProcessCall == null) + DelegateCache[method] = compiledDelegate; + + return compiledDelegate; } /// @@ -185,7 +302,7 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold string suffix, bool hasIndexParameter, Action? postProcessCall = null, - Action? postProcessListCall = null) + Action? postProcessListCall = null) { if (!hasIndexParameter && postProcessListCall is null) { @@ -203,40 +320,47 @@ public abstract class ModdedOptionMemberAttribute(string? memberName, Type? hold return _ => sharedDelegate; } - return index => + var fullParamTypes = new Type[baseParamTypes.Length + 1]; + fullParamTypes[0] = typeof(DelegateIndexClosure); + Array.Copy(baseParamTypes, 0, fullParamTypes, 1, baseParamTypes.Length); + + var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}_List", returnType, fullParamTypes, ownerType, true); + var il = dm.GetILGenerator(); + + if (!method.IsStatic && instanceGetter != null) + il.Emit(OpCodes.Call, instanceGetter); + + for (var i = 0; i < baseParamTypes.Length; i++) { - var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}_{index}", returnType, baseParamTypes, ownerType, true); - var il = dm.GetILGenerator(); - - if (!method.IsStatic && instanceGetter != null) - il.Emit(OpCodes.Call, instanceGetter); - - for (byte i = 0; i < baseParamTypes.Length; i++) + var argPos = (byte)(i + 1); + var loadOp = argPos switch { - if (i == 0) - il.Emit(OpCodes.Ldarg_0); - else if (i == 1) - il.Emit(OpCodes.Ldarg_1); - else if (i == 2) - il.Emit(OpCodes.Ldarg_2); - else if (i == 3) - il.Emit(OpCodes.Ldarg_3); - else - il.Emit(OpCodes.Ldarg_S, i); - } + 1 => OpCodes.Ldarg_1, + 2 => OpCodes.Ldarg_2, + 3 => OpCodes.Ldarg_3, + _ => OpCodes.Ldarg_S, + }; - if (hasIndexParameter) - il.Emit(OpCodes.Ldc_I4, index); + if (loadOp == OpCodes.Ldarg_S) + il.Emit(loadOp, argPos); + else + il.Emit(loadOp); + } - var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; - il.Emit(callOp, method); + if (hasIndexParameter) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, IndexClosureField); + } - postProcessCall?.Invoke(il); - postProcessListCall?.Invoke(il, index); + var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; + il.Emit(callOp, method); - il.Emit(OpCodes.Ret); + postProcessCall?.Invoke(il); + postProcessListCall?.Invoke(il); - return dm.CreateDelegate(delegateType); - }; + il.Emit(OpCodes.Ret); + + return index => dm.CreateDelegate(delegateType, new DelegateIndexClosure(index)); } } diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs index 370c7f9..34cb2c1 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs @@ -14,73 +14,25 @@ public sealed class ModdedOptionSetterAttribute(string? methodName = null, Type? { internal Delegate? GetSetter(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) { - if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) - { - Error($"Method {MemberName} does not exist in {type.FullName}"); + if (ResolveMethod(group, property, out var type, out var instanceGetter) is not { } method) return null; - } var valueType = option.GetValueType(); - if (method.ReturnType != valueType) - { - Error($"{MemberName}'s return type is not {valueType.Name}."); + if (!ValidateSignature(method, valueType, valueType, valueType)) return null; - } - - var parameters = method.GetParameters(); - - if (parameters.Length != 2) - { - Error($"{MemberName} must have exactly two parameters."); - return null; - } - - if (parameters[0].ParameterType != valueType || parameters[1].ParameterType != valueType) - { - Error($"{MemberName}'s parameters must both be of the type {valueType.Name}."); - return null; - } var delegateType = typeof(Func<,,>).MakeGenericType(valueType, valueType, valueType); return CompileDynamicDelegate(type, method, instanceGetter, valueType, [valueType, valueType], delegateType, "Setter"); } - internal Func? GetListSetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) + internal Func? GetListSetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) { - if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) - { - Error($"Method {MemberName} does not exist in {type.FullName}"); + if (ResolveMethod(group, property, out var type, out var instanceGetter) is not { } method) return null; - } - if (method.ReturnType != valueType) - { - Error($"{MemberName}'s return type is not {valueType.Name}."); + if (!ValidateListSignature(method, valueType, out var hasIndex, valueType, valueType)) return null; - } - - var parameters = method.GetParameters(); - - if (parameters.Length is < 2 or > 3) - { - Error($"{MemberName} must have two parameters ({valueType.Name}, {valueType.Name}) or three parameters ({valueType.Name}, {valueType.Name}, int)."); - return null; - } - - if (parameters[0].ParameterType != valueType || parameters[1].ParameterType != valueType) - { - Error($"{MemberName}'s first two parameters must both be of the type {valueType.Name}."); - return null; - } - - var hasIndex = parameters.Length == 3; - - if (hasIndex && parameters[2].ParameterType != typeof(int)) - { - Error($"{MemberName}'s third parameter must be an int (the list index)."); - return null; - } var delegateType = typeof(Func<,,>).MakeGenericType(valueType, valueType, valueType); return CompileDynamicListDelegate(type, method, instanceGetter, valueType, [valueType, valueType], delegateType, "ListSetter", hasIndex); diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs index 72c35b0..6929cbf 100644 --- a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs @@ -105,7 +105,7 @@ public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, T return (Func)CompileDynamicDelegate(type, targetMethod, instanceGetter, typeof(bool), Type.EmptyTypes, typeof(Func), "Visibility", false, postProcess); } - internal Func? GetListVisibility(AbstractOptionGroup group, PropertyInfo property) + internal Func? GetListVisibility(AbstractOptionGroup group, PropertyInfo property) { if (ResolveMember(group, property, out Type type, out var instanceGetter) is not { } member) { @@ -115,7 +115,7 @@ public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, T MethodInfo? targetMethod; Action? postProcess = null; - Action? postProcessList = null; + Action? postProcessList = null; var hasIndexParam = false; @@ -126,9 +126,10 @@ public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, T if (vProperty.PropertyType.IsAssignableTo(typeof(IList))) { isListBool = true; - postProcessList = (il, index) => + postProcessList = il => { - il.Emit(OpCodes.Ldc_I4, index); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, IndexClosureField); il.Emit(OpCodes.Callvirt, ListBoolIndexerGetter); }; } diff --git a/MiraAPI/GameOptions/ModdedList.cs b/MiraAPI/GameOptions/ModdedList.cs new file mode 100644 index 0000000..b28b1cb --- /dev/null +++ b/MiraAPI/GameOptions/ModdedList.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace MiraAPI.GameOptions; + +/// +/// A simple list that extends . +/// +/// The type of the values that the list holds. +public sealed class ModdedList : List; diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index 21c9f1a..b30aa1c 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -26,12 +26,17 @@ namespace MiraAPI.GameOptions; public static class ModdedOptionsManager { private static readonly ConditionalWeakTable ListInstanceCache = []; - private static readonly Dictionary OptionAttributes = []; private static readonly Dictionary GetterLookups = []; private static readonly Dictionary SetterLookups = []; private static readonly Dictionary TypeToGroup = []; + private static readonly HashSet OptionAttributes = []; private static readonly HashSet PatchedListTypes = []; + private static readonly HarmonyMethod CachedGetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertyGetterPatch)); + private static readonly HarmonyMethod CachedSetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertySetterPatch)); + private static readonly HarmonyMethod CachedListGetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertyListGetterPatch)); + private static readonly HarmonyMethod CachedListSetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertyListSetterPatch)); + internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary CreatedStringOptions = []; internal static readonly Dictionary> GameModeOptionGroups = []; @@ -148,7 +153,7 @@ public static class ModdedOptionsManager PropertyInfo property, MiraPluginInfo pluginInfo) { - if (OptionAttributes.ContainsKey(property)) + if (!OptionAttributes.Add(property)) { Error($"Property {property.Name} already has an attribute registered."); return; @@ -168,52 +173,40 @@ public static class ModdedOptionsManager return; } - var getterOriginal = property.GetGetMethod(true); - var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); - - GetterLookups[getterOriginal!] = attribute; + var getterOriginal = property.GetGetMethod(true)!; + PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: CachedGetterPatch); + GetterLookups[getterOriginal] = attribute; var setterOriginal = property.GetSetMethod(true); if (setterOriginal != null) { - var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); - PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); - + PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: CachedSetterPatch); SetterLookups[setterOriginal] = attribute; } - OptionAttributes.Add(property, attribute); attribute.HolderOption = option; attribute.ValueType = property.PropertyType; - var visibilityAttr = property.GetCustomAttribute(); - var visibilityFunc = visibilityAttr?.GetVisibility(group, property); - if (visibilityFunc != null) - { - option.Visible = visibilityFunc; - } + var customAttributes = property.GetCustomAttributes(false); - var getterAtr = property.GetCustomAttribute(); - var getterFunc = getterAtr?.GetGetter(group, property, option); - if (getterFunc != null) + foreach (var attr in customAttributes) { - option.Getter = getterFunc; - } - - var setterAtr = property.GetCustomAttribute(); - var setterFunc = setterAtr?.GetSetter(group, property, option); - if (setterFunc != null) - { - option.Setter = setterFunc; - } - - var changedAtr = property.GetCustomAttribute(); - var changedFunc = changedAtr?.GetChanged(group, property, option); - if (changedFunc != null) - { - option.ChangedEvent = changedFunc; + switch (attr) + { + case ModdedOptionVisibilityAttribute visibilityAttr: + option.Visible = visibilityAttr.GetVisibility(group, property) ?? DelegateUtilities.AlwaysTrue; + break; + case ModdedOptionGetterAttribute getterAttr: + option.Getter = getterAttr.GetGetter(group, property, option); + break; + case ModdedOptionSetterAttribute setterAttr: + option.Setter = setterAttr.GetSetter(group, property, option); + break; + case ModdedOptionChangedAttribute changedAttr: + option.ChangedEvent = changedAttr.GetChanged(group, property, option); + break; + } } RegisterOption(option, group, property.Name, pluginInfo); @@ -247,7 +240,7 @@ public static class ModdedOptionsManager PropertyInfo property, MiraPluginInfo pluginInfo) { - if (OptionAttributes.ContainsKey(property)) + if (!OptionAttributes.Add(property)) { Error($"Property {property.Name} already has an attribute registered."); return; @@ -282,85 +275,84 @@ public static class ModdedOptionsManager return; } - var getterOriginal = property.GetGetMethod(true); - var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); + var listType = propertyList.GetType(); + var valueType = listType.GetGenericArguments()[0]; - GetterLookups[getterOriginal!] = attribute; + if (!listType.IsGenericType || listType.GetGenericTypeDefinition() != typeof(ModdedList<>)) + { + Error($"Please use ModdedList<{valueType.Name}> for modded option lists."); + return; + } + + var getterOriginal = property.GetGetMethod(true)!; + PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: CachedGetterPatch); + GetterLookups[getterOriginal] = attribute; var setterOriginal = property.GetSetMethod(true); if (setterOriginal != null) { - var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); - PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); - + PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: CachedSetterPatch); SetterLookups[setterOriginal] = attribute; } - var listType = propertyList.GetType(); - if (PatchedListTypes.Add(listType)) { var listIndex = listType.GetProperty("Item")!; var listGetterOriginal = listIndex.GetGetMethod(true); - var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); + PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: CachedListGetterPatch); var listSetterOriginal = listIndex.GetSetMethod(true); if (listSetterOriginal != null) - { - var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); - } + PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: CachedListSetterPatch); } - OptionAttributes.Add(property, attribute); attribute.HolderOptionList = optionList; attribute.Value = propertyList; - attribute.ValueType = listType.GetGenericArguments()[0]; + attribute.ValueType = valueType; ListInstanceCache.Add(propertyVal, attribute); - var valueType = property.PropertyType.GetGenericArguments()[0]; + var customAttributes = property.GetCustomAttributes(false); - var visibilityAttr = property.GetCustomAttribute(); - var visibilityFactory = visibilityAttr?.GetListVisibility(group, property); + Func? visibilityFactory = null, getterFactory = null, setterFactory = null, changedFactory = null; - var getterAttr = property.GetCustomAttribute(); - var getterFactory = getterAttr?.GetListGetter(group, property, valueType); - - var setterAttr = property.GetCustomAttribute(); - var setterFactory = setterAttr?.GetListSetter(group, property, valueType); - - var changedAttr = property.GetCustomAttribute(); - var changedFactory = changedAttr?.GetListChanged(group, property, valueType); + foreach (var attr in customAttributes) + { + switch (attr) + { + case ModdedOptionVisibilityAttribute visibilityAttr: + visibilityFactory = visibilityAttr.GetListVisibility(group, property); + break; + case ModdedOptionGetterAttribute getterAttr: + getterFactory = getterAttr.GetListGetter(group, property, valueType); + break; + case ModdedOptionSetterAttribute setterAttr: + setterFactory = setterAttr.GetListSetter(group, property, valueType); + break; + case ModdedOptionChangedAttribute changedAttr: + changedFactory = changedAttr.GetListChanged(group, property, valueType); + break; + } + } for (var i = 0; i < optionList.Count; i++) { var option = optionList[i]; if (visibilityFactory != null) - { - option.Visible = (Func)visibilityFactory(i); - } + option.Visible = (Func)(visibilityFactory(i) ?? DelegateUtilities.AlwaysTrue); if (getterFactory != null) - { option.Getter = getterFactory(i); - } if (setterFactory != null) - { option.Setter = setterFactory(i); - } if (changedFactory != null) - { option.ChangedEvent = changedFactory(i); - } RegisterOption(option, group, property.Name + (i + 1), pluginInfo); } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs index 2342fc9..e2486ae 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs @@ -4,6 +4,7 @@ using BepInEx.Configuration; using MiraAPI.Networking; using MiraAPI.PluginLoading; using MiraAPI.Translation; +using MiraAPI.Utilities; using Reactor.Networking.Rpc; using UnityEngine; @@ -124,7 +125,7 @@ public abstract class ModdedOption : IModdedOption DefaultValue = defaultValue; Value = defaultValue; StringName = MiraLocaleManager.GetOrCreateLocaleString(Title); - Visible = () => true; + Visible = DelegateUtilities.AlwaysTrue; IncludeInPreset = includeInPreset; } diff --git a/MiraAPI/Resources/Locale/en_US.xml b/MiraAPI/Resources/Locale/en_US.xml index ed67fd2..be6809d 100644 --- a/MiraAPI/Resources/Locale/en_US.xml +++ b/MiraAPI/Resources/Locale/en_US.xml @@ -57,7 +57,7 @@ Outlive the timer or eliminate the crew. Options Player List Game Settings - Search... + Search... Filter Grouping Order diff --git a/MiraAPI/Roles/ICustomRole.cs b/MiraAPI/Roles/ICustomRole.cs index 88b1f85..26dd0cf 100644 --- a/MiraAPI/Roles/ICustomRole.cs +++ b/MiraAPI/Roles/ICustomRole.cs @@ -339,7 +339,7 @@ public interface ICustomRole : IOptionable /// /// Gets the function that determines whether the role should be toggled on or off in the game settings. /// - Func VisibleInSettings => () => true; + Func VisibleInSettings => DelegateUtilities.AlwaysTrue; /// /// Gets the list of abilities or other similar information to display below the text of a role's wiki page. diff --git a/MiraAPI/Utilities/DelegateUtilities.cs b/MiraAPI/Utilities/DelegateUtilities.cs new file mode 100644 index 0000000..2ac4acf --- /dev/null +++ b/MiraAPI/Utilities/DelegateUtilities.cs @@ -0,0 +1,19 @@ +using System; + +namespace MiraAPI.Utilities; + +/// +/// A utility class for using delegates. +/// +public static class DelegateUtilities +{ + /// + /// Gets a Func<bool> that always returns true. + /// + public static Func AlwaysTrue { get; } = () => true; + + /// + /// Gets a Func<bool> that always returns false. + /// + public static Func AlwaysFalse { get; } = () => false; +} From d8162dcb32dfcce6819b18e35ad01667fe70b2e1 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:23:15 -0400 Subject: [PATCH 13/13] avoid patching indexers instead --- MiraAPI.Example/.editorconfig | 3 + MiraAPI.Example/ExampleEventHandlers.cs | 3 +- MiraAPI.Example/Options/ExampleOptions.cs | 3 +- ....cs => ModdedStringOptionListAttribute.cs} | 0 MiraAPI/GameOptions/CollectionBuilder.cs | 8 + MiraAPI/GameOptions/ModdedList.cs | 291 +++++++++++++++++- MiraAPI/GameOptions/ModdedOptionsManager.cs | 52 +--- 7 files changed, 303 insertions(+), 57 deletions(-) rename MiraAPI/GameOptions/Attributes/OptionList/{ModdedStringOptionList.cs => ModdedStringOptionListAttribute.cs} (100%) create mode 100644 MiraAPI/GameOptions/CollectionBuilder.cs diff --git a/MiraAPI.Example/.editorconfig b/MiraAPI.Example/.editorconfig index 171e612..7a18861 100644 --- a/MiraAPI.Example/.editorconfig +++ b/MiraAPI.Example/.editorconfig @@ -2,3 +2,6 @@ # SA1602: Enumeration items should be documented. dotnet_diagnostic.SA1602.severity = none + +# SA1601: Partial elements should be documented +dotnet_diagnostic.SA1601.severity = none diff --git a/MiraAPI.Example/ExampleEventHandlers.cs b/MiraAPI.Example/ExampleEventHandlers.cs index 3647899..20c0def 100644 --- a/MiraAPI.Example/ExampleEventHandlers.cs +++ b/MiraAPI.Example/ExampleEventHandlers.cs @@ -1,5 +1,4 @@ -using System.Diagnostics.CodeAnalysis; -using System.Globalization; +using System.Globalization; using System.Linq; using MiraAPI.Events; using MiraAPI.Events.Mira; diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index 9e2e1a9..9b424c0 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -42,8 +42,7 @@ public class ExampleOptions : AbstractOptionGroup public Api WorstApi { get; set; } = Api.MiraAPI; [ModdedEnumOptionList("Value State {0}", ["None", "Primary", "Secondary"])] - public ModdedList ValueStates { get; set; } = - [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; + public ModdedArray ValueStates { get; set; } = [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; [ModdedPlayerOption("options.exampleOptions1.players")] public NetworkedPlayerInfo PlayerSelected { get; set; } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionListAttribute.cs similarity index 100% rename from MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs rename to MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionListAttribute.cs diff --git a/MiraAPI/GameOptions/CollectionBuilder.cs b/MiraAPI/GameOptions/CollectionBuilder.cs new file mode 100644 index 0000000..99d42d9 --- /dev/null +++ b/MiraAPI/GameOptions/CollectionBuilder.cs @@ -0,0 +1,8 @@ +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface, Inherited = false)] +internal sealed class CollectionBuilderAttribute(Type builderType, string methodName) : Attribute +{ + public Type BuilderType { get; } = builderType; + public string MethodName { get; } = methodName; +} diff --git a/MiraAPI/GameOptions/ModdedList.cs b/MiraAPI/GameOptions/ModdedList.cs index b28b1cb..65f60ef 100644 --- a/MiraAPI/GameOptions/ModdedList.cs +++ b/MiraAPI/GameOptions/ModdedList.cs @@ -1,9 +1,294 @@ +using System; +using System.Collections; using System.Collections.Generic; +using System.Runtime.CompilerServices; namespace MiraAPI.GameOptions; /// -/// A simple list that extends . +/// A fixed-size array created specifically for modded options that automatically synchronizes with option attributes. /// -/// The type of the values that the list holds. -public sealed class ModdedList : List; +/// The type of the values that the array holds. +[CollectionBuilder(typeof(ModdedArrayBuilder), nameof(ModdedArrayBuilder.Create))] +public sealed class ModdedArray : IList, IReadOnlyList, IList +{ + private readonly T[] items; + private int version; + + /// + public int Count => items.Length; + + /// + public bool IsReadOnly => false; + + /// + bool IList.IsFixedSize => true; + + /// + bool ICollection.IsSynchronized => false; + + /// + object ICollection.SyncRoot => items.SyncRoot; + + /// + /// Initializes a new instance of the class with the provided capacity. + /// + /// The capacity of the list. + public ModdedArray(int capacity) + { + items = new T[capacity]; + } + + /// + /// Initializes a new instance of the class with initial values. + /// + /// The initial collection of values to initialise with. + public ModdedArray(IEnumerable collection) + { + items = [.. collection]; + } + + /// + /// Initializes a new instance of the class with initial values. + /// + /// The initial span of values to initialise with. + public ModdedArray(Span span) + { + items = [.. span]; + } + + /// + /// Initializes a new instance of the class with initial values. + /// + /// The initial span of values to initialise with. + public ModdedArray(ReadOnlySpan span) + { + items = [.. span]; + } + + /// + /// Gets or sets the element at the specified index, syncing with the attribute if registered. + /// + /// The index of the value. + public T this[int index] + { + get => ModdedOptionsManager.ListInstanceCache.TryGetValue(this, out var attribute) + ? (T)attribute.GetValue(index) + : items[index]; + set + { + if (ModdedOptionsManager.ListInstanceCache.TryGetValue(this, out var attribute)) + attribute.SetValue(index, value!); + else + items[index] = value; + + version++; + } + } + + /// + object? IList.this[int index] + { + get => this[index]; + set => this[index] = (T)value!; + } + + /// + public bool Contains(T item) + { + return Array.IndexOf(items, item) >= 0; + } + + /// + public void CopyTo(T[] array, int arrayIndex) + { + items.CopyTo(array, arrayIndex); + } + + /// + public int IndexOf(T item) + { + return Array.IndexOf(items, item); + } + + /// + public Enumerator GetEnumerator() + { + return new(this); + } + + /// + bool IList.Contains(object? value) + { + return ((IList)items).Contains(value); + } + + /// + int IList.IndexOf(object? value) + { + return ((IList)items).IndexOf(value); + } + + /// + void ICollection.CopyTo(Array array, int index) + { + items.CopyTo(array, index); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + int IList.Add(object? value) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.Clear() + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.Insert(int index, object? value) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.Remove(object? value) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.RemoveAt(int index) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void Insert(int index, T item) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void RemoveAt(int index) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void Add(T item) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void Clear() + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public bool Remove(T item) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + /// Enumerates the elements of a . + /// + public struct Enumerator : IEnumerator + { + private readonly ModdedArray array; + private readonly int version; + + private int index; + private T? current; + + internal Enumerator(ModdedArray moddedArray) + { + array = moddedArray; + version = moddedArray.version; + index = 0; + current = default; + } + + /// + public readonly T Current => current!; + + /// + readonly object? IEnumerator.Current + { + get + { + if (index == 0 || index == array.Count + 1) + throw new InvalidOperationException("Enumeration has not started or has already finished."); + + return Current; + } + } + + /// + public readonly void Dispose() + { + } + + /// + public bool MoveNext() + { + if (version != array.version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + if ((uint)index < (uint)array.Count) + { + current = array[index]; + index++; + return true; + } + + index = array.Count + 1; + current = default; + return false; + } + + /// + public void Reset() + { + if (version != array.version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + index = 0; + current = default; + } + } +} + +/// +/// Provides a builder for to support C# 12 collection expressions. +/// +public static class ModdedArrayBuilder +{ + /// + /// Creates a new from a span of values. + /// + /// The the type of the values. + /// The values to initialise with. + /// The instance initialised with the provided values. + public static ModdedArray Create(ReadOnlySpan values) + { + return new(values); + } +} diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index b30aa1c..3e11b3d 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -25,18 +25,15 @@ namespace MiraAPI.GameOptions; /// public static class ModdedOptionsManager { - private static readonly ConditionalWeakTable ListInstanceCache = []; private static readonly Dictionary GetterLookups = []; private static readonly Dictionary SetterLookups = []; private static readonly Dictionary TypeToGroup = []; private static readonly HashSet OptionAttributes = []; - private static readonly HashSet PatchedListTypes = []; private static readonly HarmonyMethod CachedGetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertyGetterPatch)); private static readonly HarmonyMethod CachedSetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertySetterPatch)); - private static readonly HarmonyMethod CachedListGetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertyListGetterPatch)); - private static readonly HarmonyMethod CachedListSetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertyListSetterPatch)); + internal static readonly ConditionalWeakTable ListInstanceCache = []; internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary CreatedStringOptions = []; internal static readonly Dictionary> GameModeOptionGroups = []; @@ -278,7 +275,7 @@ public static class ModdedOptionsManager var listType = propertyList.GetType(); var valueType = listType.GetGenericArguments()[0]; - if (!listType.IsGenericType || listType.GetGenericTypeDefinition() != typeof(ModdedList<>)) + if (!listType.IsGenericType || listType.GetGenericTypeDefinition() != typeof(ModdedArray<>)) { Error($"Please use ModdedList<{valueType.Name}> for modded option lists."); return; @@ -296,19 +293,6 @@ public static class ModdedOptionsManager SetterLookups[setterOriginal] = attribute; } - if (PatchedListTypes.Add(listType)) - { - var listIndex = listType.GetProperty("Item")!; - - var listGetterOriginal = listIndex.GetGetMethod(true); - PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: CachedListGetterPatch); - - var listSetterOriginal = listIndex.GetSetMethod(true); - - if (listSetterOriginal != null) - PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: CachedListSetterPatch); - } - attribute.HolderOptionList = optionList; attribute.Value = propertyList; attribute.ValueType = valueType; @@ -453,36 +437,4 @@ public static class ModdedOptionsManager __result = attribute.GetValue(); return false; } - - /// - /// Patches the setter of a list property to update the value of the option. - /// - /// The list's instance. - /// The index to find in the list. - /// The new object value. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] - [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] - public static void PropertyListSetterPatch(object __instance, int index, object value) - { - if (ListInstanceCache.TryGetValue(__instance, out var match)) - match.SetValue(index, value); - } - - /// - /// Patches the getter of a list property to return the value of the option. - /// - /// The list's instance. - /// The index to find in the list. - /// The result of the property getter. - /// False so the original getter gets skipped. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] - [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] - public static bool PropertyListGetterPatch(object __instance, int index, ref object __result) - { - if (!ListInstanceCache.TryGetValue(__instance, out var match)) - return true; - - __result = match.GetValue(index); - return false; - } }