diff --git a/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs b/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs index 8fec19b6..4ee25eee 100644 --- a/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs +++ b/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs @@ -39,6 +39,12 @@ public void DealerDealBehaviourMatchesNativePriority() Assert.Equal(5, NPCPrefabBuilder.DealerAttendDealPriority); } + [Fact] + public void BehaviourStackObjectsRemainActiveWhileTheirInternalStateIsManaged() + { + Assert.True(NPCPrefabBuilder.BehaviourObjectsRemainActive); + } + [Theory] [InlineData("DealerHomeEvent", true)] [InlineData("HomeEvent", true)] diff --git a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs new file mode 100644 index 00000000..48cd4799 --- /dev/null +++ b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs @@ -0,0 +1,22 @@ +using S1API.Internal.Utils; + +namespace S1API.Tests.Internal.Utils; + +public sealed class ManagedEventRegistrationTrackerTests +{ + [Fact] + public void DuplicateAddsAreRemovedOneAtATimeInReverseRegistrationOrder() + { + var tracker = new ManagedEventRegistrationTracker(); + Action handler = () => { }; + + tracker.Add(handler, "first"); + tracker.Add(handler, "second"); + + Assert.True(tracker.TryTakeLast(handler, out string? second)); + Assert.Equal("second", second); + Assert.True(tracker.TryTakeLast(handler, out string? first)); + Assert.Equal("first", first); + Assert.False(tracker.TryTakeLast(handler, out _)); + } +} diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs new file mode 100644 index 00000000..41de113a --- /dev/null +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -0,0 +1,73 @@ +using S1API.Internal.Utils; + +namespace S1API.Tests.Internal.Utils; + +public sealed class ReflectionUtilsTests +{ + [Fact] + public void InstanceAccessSupportsMonoFieldAndIl2CppPropertyShapes() + { + var mono = new MonoShape(); + var il2Cpp = new Il2CppShape(); + + Assert.True(ReflectionUtils.TrySetFieldOrProperty(mono, "runtimeMember", 14)); + Assert.True(ReflectionUtils.TrySetFieldOrProperty(il2Cpp, "runtimeMember", 14)); + Assert.Equal(14, ReflectionUtils.TryGetFieldOrProperty(mono, "runtimeMember")); + Assert.Equal(14, ReflectionUtils.TryGetFieldOrProperty(il2Cpp, "runtimeMember")); + } + + [Fact] + public void StaticAccessSupportsMonoFieldAndIl2CppPropertyShapes() + { + ReflectionUtils.TrySetStaticFieldOrProperty(typeof(MonoStaticShape), "RuntimeMember", 31); + ReflectionUtils.TrySetStaticFieldOrProperty(typeof(Il2CppStaticShape), "RuntimeMember", 31); + + Assert.Equal(31, ReflectionUtils.TryGetStaticFieldOrProperty(typeof(MonoStaticShape), "RuntimeMember")); + Assert.Equal(31, ReflectionUtils.TryGetStaticFieldOrProperty(typeof(Il2CppStaticShape), "RuntimeMember")); + } + + [Fact] + public void StaticAccessWalksBaseTypesForNonPublicMembers() + { + ReflectionUtils.TrySetStaticFieldOrProperty(typeof(DerivedStaticShape), "RuntimeMember", 47); + + Assert.Equal( + 47, + ReflectionUtils.TryGetStaticFieldOrProperty(typeof(DerivedStaticShape), "RuntimeMember")); + } + + private sealed class MonoShape + { +#pragma warning disable CS0169 + private int runtimeMember; +#pragma warning restore CS0169 + } + + private sealed class Il2CppShape + { + public int runtimeMember { get; set; } + } + + private static class MonoStaticShape + { +#pragma warning disable CS0649 + public static int RuntimeMember; +#pragma warning restore CS0649 + } + + private static class Il2CppStaticShape + { + public static int RuntimeMember { get; set; } + } + + private class BaseStaticShape + { +#pragma warning disable CS0169, CS0649 + private static int RuntimeMember; +#pragma warning restore CS0169, CS0649 + } + + private sealed class DerivedStaticShape : BaseStaticShape + { + } +} diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 0857cd05..6a9883bc 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -4784,17 +4784,9 @@ private static void EnsureScheduleActionsOnPrefab(GameObject prefabRoot) // Best-effort wire internal references so actions have context even while inactive try { -#if MONOMELON - var npcField = t.GetField("npc", BindingFlags.NonPublic | BindingFlags.Instance); - var schedField = t.GetField("schedule", BindingFlags.NonPublic | BindingFlags.Instance); -#else - var npcField = t.GetField("npc", Il2CppSystem.Reflection.BindingFlags.NonPublic | Il2CppSystem.Reflection.BindingFlags.Public | Il2CppSystem.Reflection.BindingFlags.Instance); - var schedField = t.GetField("schedule", Il2CppSystem.Reflection.BindingFlags.NonPublic | Il2CppSystem.Reflection.BindingFlags.Public | Il2CppSystem.Reflection.BindingFlags.Instance); -#endif var baseNpc = prefabRoot.GetComponent(); - - npcField?.SetValue(comp, baseNpc); - schedField?.SetValue(comp, existingMgr); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "npc", baseNpc); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "schedule", existingMgr); } catch { } go.SetActive(false); diff --git a/S1API/Entities/NPCCustomer.cs b/S1API/Entities/NPCCustomer.cs index d54d13a0..3c8f622c 100644 --- a/S1API/Entities/NPCCustomer.cs +++ b/S1API/Entities/NPCCustomer.cs @@ -611,6 +611,7 @@ internal static bool EnsureDealAttendanceSupport(GameObject? prefabRoot, Type? o component = behaviourObject.AddComponent(); } + component.gameObject.SetActive(NPCPrefabBuilder.BehaviourObjectsRemainActive); component.EnabledOnAwake = false; component.Name = "Customer attend deal"; component.Priority = 4; @@ -770,8 +771,7 @@ private bool EnsureContractAssignedHook() try { - var onContractAssignedField = typeof(S1Economy.Customer).GetField("onContractAssigned", BindingFlags.Public | BindingFlags.Instance); - var evt = onContractAssignedField?.GetValue(Component); + var evt = Utils.ReflectionUtils.TryGetFieldOrProperty(Component, "onContractAssigned"); if (evt == null) return false; @@ -834,36 +834,35 @@ private void HandleContractAssigned(object contract) int winStart = 0; int winEnd = 0; - var contractType = contract.GetType(); - var paymentProp = contractType.GetProperty("Payment", BindingFlags.Public | BindingFlags.Instance); - if (paymentProp != null) - payment = Convert.ToSingle(paymentProp.GetValue(contract)); + var paymentValue = Utils.ReflectionUtils.TryGetFieldOrProperty(contract, "Payment"); + if (paymentValue != null) + payment = Convert.ToSingle(paymentValue); - var productListProp = contractType.GetProperty("ProductList", BindingFlags.Public | BindingFlags.Instance); - var productList = productListProp?.GetValue(contract); + var productList = Utils.ReflectionUtils.TryGetFieldOrProperty(contract, "ProductList"); if (productList != null) { - var entriesField = productList.GetType().GetField("entries", BindingFlags.Public | BindingFlags.Instance); - var entries = entriesField?.GetValue(productList) as System.Collections.IEnumerable; + var entries = Utils.ReflectionUtils.TryGetFieldOrProperty(productList, "entries") as System.Collections.IEnumerable; if (entries != null) { foreach (var e in entries) { - var qtyField = e.GetType().GetField("Quantity", BindingFlags.Public | BindingFlags.Instance); - if (qtyField != null) - totalQty += Convert.ToInt32(qtyField.GetValue(e)); + if (e != null) + { + var quantity = Utils.ReflectionUtils.TryGetFieldOrProperty(e, "Quantity"); + if (quantity != null) + totalQty += Convert.ToInt32(quantity); + } } } } - var windowProp = contractType.GetProperty("DeliveryWindow", BindingFlags.Public | BindingFlags.Instance); - var window = windowProp?.GetValue(contract); + var window = Utils.ReflectionUtils.TryGetFieldOrProperty(contract, "DeliveryWindow"); if (window != null) { - var startField = window.GetType().GetField("WindowStartTime", BindingFlags.Public | BindingFlags.Instance); - var endField = window.GetType().GetField("WindowEndTime", BindingFlags.Public | BindingFlags.Instance); - if (startField != null) winStart = Convert.ToInt32(startField.GetValue(window)); - if (endField != null) winEnd = Convert.ToInt32(endField.GetValue(window)); + var start = Utils.ReflectionUtils.TryGetFieldOrProperty(window, "WindowStartTime"); + var end = Utils.ReflectionUtils.TryGetFieldOrProperty(window, "WindowEndTime"); + if (start != null) winStart = Convert.ToInt32(start); + if (end != null) winEnd = Convert.ToInt32(end); } foreach (Action handler in handlers.GetInvocationList()) @@ -1113,19 +1112,8 @@ private System.Collections.IEnumerator WaitAndShowDialogue(S1Dialogue.DialogueCo private static void SetNonPublicInstanceField(object target, string fieldName, object? value) { - try - { - if (target == null || string.IsNullOrEmpty(fieldName)) return; - var type = target.GetType(); - FieldInfo? field = null; - while (type != null && field == null) - { - field = type.GetField(fieldName, BindingFlags.Instance | System.Reflection.BindingFlags.Public | BindingFlags.NonPublic); - type = type.BaseType; - } - field?.SetValue(target, value); - } - catch (Exception) { } + if (target == null || string.IsNullOrEmpty(fieldName)) return; + Utils.ReflectionUtils.TrySetFieldOrProperty(target, fieldName, value); } } } diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs index 3d85c7d2..4f482fbd 100644 --- a/S1API/Entities/NPCDealer.cs +++ b/S1API/Entities/NPCDealer.cs @@ -10,6 +10,7 @@ using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1UIPhoneMessages = Il2CppScheduleOne.UI.Phone.Messages; using S1Money = Il2CppScheduleOne.Money; +using NativeDealerRecruitedAction = Il2CppSystem.Action; #elif MONOMELON using S1Quests = ScheduleOne.Quests; using S1NPCs = ScheduleOne.NPCs; @@ -21,6 +22,7 @@ using S1DevUtilities = ScheduleOne.DevUtilities; using S1UIPhoneMessages = ScheduleOne.UI.Phone.Messages; using S1Money = ScheduleOne.Money; +using NativeDealerRecruitedAction = System.Action; #endif using System; @@ -33,6 +35,7 @@ using MelonLoader; using S1API.Economy; using S1API.Internal.Abstraction; +using S1API.Internal.Utils; using S1API.Map; #if (IL2CPPMELON) using Il2CppFishNet; @@ -62,9 +65,7 @@ public sealed class NPCDealer { internal readonly NPC NPC; private static readonly Logging.Log Logger = new Logging.Log("NPCDealer"); - private static readonly FieldInfo? DealerRecruitedField = typeof(S1Economy.Dealer).GetField("onDealerRecruited", BindingFlags.Public | BindingFlags.Static); - - private readonly Dictionary> _dealerRecruitedHandlers = new Dictionary>(); + private readonly ManagedEventRegistrationTracker _dealerRecruitedHandlers = new ManagedEventRegistrationTracker(); private Action? _contractAcceptedHandlers; private bool _contractAcceptedHooked; @@ -82,8 +83,10 @@ internal static void ClearStaticDelegates() { try { - if (DealerRecruitedField != null) - DealerRecruitedField.SetValue(null, null); + Internal.Utils.ReflectionUtils.TrySetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited", + null); } catch { } } @@ -161,18 +164,11 @@ private void TryHookConversationUIRefresh(object convoObj) var convo = convoObj as S1Messaging.MSGConversation; if (convo == null) return; - // Check if UI already exists (uiCreated field) - var uiCreatedField = typeof(S1Messaging.MSGConversation).GetField("uiCreated", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (uiCreatedField != null) + // Check if UI already exists (field on Mono, property on IL2CPP) + if (Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(convo, "uiCreated") is bool created && created) { - var uiCreated = uiCreatedField.GetValue(convo); - - if (uiCreated != null && uiCreated is bool created && created) - { - // UI already exists, refresh immediately - RefreshDealerCategoryBadge(); - } + // UI already exists, refresh immediately + RefreshDealerCategoryBadge(); } // Hook onLoaded (called after UI is loaded from save) @@ -720,31 +716,26 @@ private void InitializeRuntimeState(S1Economy.Dealer dealer) } } #else - // In IL2CPP, overflow slots are private fields - try to initialize via reflection - var overflowSlotsField = typeof(S1Economy.Dealer).GetField("overflowSlots", BindingFlags.NonPublic | BindingFlags.Instance); - if (overflowSlotsField != null) + var overflowSlots = dealer.overflowSlots; + if (overflowSlots == null || overflowSlots.Length == 0) { - var overflowSlots = overflowSlotsField.GetValue(dealer) as S1Items.ItemSlot[]; - if (overflowSlots == null || overflowSlots.Length == 0) + overflowSlots = new Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppReferenceArray(10); + for (int i = 0; i < 10; i++) { - overflowSlots = new S1Items.ItemSlot[10]; - for (int i = 0; i < 10; i++) - { - overflowSlots[i] = new S1Items.ItemSlot(); - // In IL2CPP, cast Dealer to IItemSlotOwner interface - overflowSlots[i].SetSlotOwner(dealer.Cast()); - } - overflowSlotsField.SetValue(dealer, overflowSlots); + overflowSlots[i] = new S1Items.ItemSlot(); + overflowSlots[i].SetSlotOwner(dealer.Cast()); } + dealer.overflowSlots = overflowSlots; } #endif // Ensure DealerAttendDealBehaviour exists (replaced NPCSignal_HandleDeal in v0.4.2f4) try { - var attendDealField = typeof(S1Economy.Dealer).GetField("_attendDealBehaviour", BindingFlags.NonPublic | BindingFlags.Instance); - var existingBehaviour = attendDealField?.GetValue(dealer) as S1NPCsBehaviour.DealerAttendDealBehaviour; - if (existingBehaviour == null) + var behaviour = Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + dealer, + "_attendDealBehaviour") as S1NPCsBehaviour.DealerAttendDealBehaviour; + if (behaviour == null) { // Get or create NPCBehaviour manager var npcBehaviour = NPC.gameObject.GetComponentInChildren(true); @@ -755,23 +746,24 @@ private void InitializeRuntimeState(S1Economy.Dealer dealer) npcBehaviour = behGo.AddComponent(); } - var behaviour = NPC.gameObject.GetComponentInChildren(true); + behaviour = NPC.gameObject.GetComponentInChildren(true); if (behaviour == null) { var go = new GameObject("DealerAttendDealBehaviour"); + go.SetActive(false); go.transform.SetParent(npcBehaviour.transform, false); behaviour = go.AddComponent(); - go.SetActive(false); } - behaviour.Name = "Attend deal"; - behaviour.Priority = NPCPrefabBuilder.DealerAttendDealPriority; - attendDealField?.SetValue(dealer, behaviour); - } - else - { - existingBehaviour.Name = "Attend deal"; - existingBehaviour.Priority = NPCPrefabBuilder.DealerAttendDealPriority; + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(behaviour, "beh", npcBehaviour); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(npcBehaviour, "Npc", dealer); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty( + dealer, + "_attendDealBehaviour", + behaviour); } + behaviour.gameObject.SetActive(NPCPrefabBuilder.BehaviourObjectsRemainActive); + behaviour.Name = "Attend deal"; + behaviour.Priority = NPCPrefabBuilder.DealerAttendDealPriority; } catch { /* ignore */ } @@ -855,13 +847,11 @@ public event Action OnRecruited add { EnsureDealer(); - if (Component == null || value == null || DealerRecruitedField == null) return; - if (_dealerRecruitedHandlers.ContainsKey(value)) - return; + if (Component == null || value == null) return; try { - Action wrapper = dealer => + Action managedWrapper = dealer => { if (dealer != Component) return; @@ -869,12 +859,28 @@ public event Action OnRecruited catch (Exception ex) { Logger.Warning($"Exception in OnRecruited handler for {NPC.ID}: {ex.Message}"); } }; - var existingValue = DealerRecruitedField.GetValue(null) as Action; +#if IL2CPPMELON + var wrapper = DelegateSupport.ConvertDelegate(managedWrapper) + ?? throw new InvalidOperationException("Failed to create IL2CPP dealer recruitment delegate."); + var existingValue = S1Economy.Dealer.onDealerRecruited; + var combined = existingValue != null + ? Il2CppSystem.Delegate.Combine(existingValue, wrapper).Cast() + : wrapper; + S1Economy.Dealer.onDealerRecruited = combined; +#else + NativeDealerRecruitedAction wrapper = managedWrapper; + var existingValue = Internal.Utils.ReflectionUtils.TryGetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited") as NativeDealerRecruitedAction; var combined = existingValue != null - ? (Action)Delegate.Combine(existingValue, wrapper) + ? (NativeDealerRecruitedAction)Delegate.Combine(existingValue, wrapper) : wrapper; - DealerRecruitedField.SetValue(null, combined); - _dealerRecruitedHandlers[value] = wrapper; + Internal.Utils.ReflectionUtils.TrySetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited", + combined); +#endif + _dealerRecruitedHandlers.Add(value, wrapper); } catch (Exception ex) { @@ -883,21 +889,33 @@ public event Action OnRecruited } remove { - if (value == null || DealerRecruitedField == null) + if (value == null) return; - if (!_dealerRecruitedHandlers.TryGetValue(value, out var wrapper)) + if (!_dealerRecruitedHandlers.TryTakeLast(value, out var wrapper)) return; - - _dealerRecruitedHandlers.Remove(value); try { - var existingValue = DealerRecruitedField.GetValue(null) as Action; +#if IL2CPPMELON + var existingValue = S1Economy.Dealer.onDealerRecruited; + if (existingValue == null) + return; + + var remaining = Il2CppSystem.Delegate.Remove(existingValue, wrapper); + S1Economy.Dealer.onDealerRecruited = remaining?.Cast(); +#else + var existingValue = Internal.Utils.ReflectionUtils.TryGetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited") as NativeDealerRecruitedAction; if (existingValue == null) return; - var remaining = (Action?)Delegate.Remove(existingValue, wrapper); - DealerRecruitedField.SetValue(null, remaining); + var remaining = (NativeDealerRecruitedAction?)Delegate.Remove(existingValue, wrapper); + Internal.Utils.ReflectionUtils.TrySetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited", + remaining); +#endif } catch (Exception ex) { @@ -1058,19 +1076,8 @@ public event Action OnRecommended private static void SetNonPublicInstanceField(object target, string fieldName, object value) { - try - { - if (target == null || string.IsNullOrEmpty(fieldName)) return; - var type = target.GetType(); - FieldInfo? field = null; - while (type != null && field == null) - { - field = type.GetField(fieldName, BindingFlags.Instance | System.Reflection.BindingFlags.Public | BindingFlags.NonPublic); - type = type.BaseType; - } - field?.SetValue(target, value); - } - catch (Exception) { } + if (target == null || string.IsNullOrEmpty(fieldName)) return; + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(target, fieldName, value); } } } diff --git a/S1API/Entities/NPCPrefabBuilder.cs b/S1API/Entities/NPCPrefabBuilder.cs index 248e09ab..800036f8 100644 --- a/S1API/Entities/NPCPrefabBuilder.cs +++ b/S1API/Entities/NPCPrefabBuilder.cs @@ -60,6 +60,7 @@ public sealed class NPCPrefabBuilder internal const int DealerAttendDealPriority = 5; internal const string DealerHomeEventName = "DealerHomeEvent"; + internal const bool BehaviourObjectsRemainActive = true; internal static bool IsDealerHomeEventName(string? name) => string.Equals(name, DealerHomeEventName, StringComparison.OrdinalIgnoreCase) @@ -367,8 +368,8 @@ public NPCPrefabBuilder EnsureDealer() var go = new GameObject("DealerAttendDealBehaviour"); go.transform.SetParent(npcBehaviour.transform, false); attendDeal = go.AddComponent(); - go.SetActive(false); } + attendDeal.gameObject.SetActive(BehaviourObjectsRemainActive); var baseNpcForDealer = prefabRoot.GetComponent(); SetBehaviourRefs(attendDeal, npcBehaviour, baseNpcForDealer); attendDeal.Name = "Attend deal"; @@ -688,8 +689,8 @@ public NPCPrefabBuilder EnsureSmokeBreak(string? cigarettePrefabPath = null, boo var go = new GameObject("SmokeBreakBehaviour"); go.transform.SetParent(npcBehaviour.gameObject.transform, false); smokeBreak = go.AddComponent(); - go.SetActive(false); } + smokeBreak.gameObject.SetActive(BehaviourObjectsRemainActive); smokeBreak.Name = "SmokeBreakBehaviour"; var smokeCigarette = smokeBreak.GetComponentInChildren(true); @@ -901,8 +902,8 @@ private NPCPrefabBuilder EnsureGraffitiInternal(string? sprayPaintEquippablePath var go = new GameObject("GraffitiBehaviour"); go.transform.SetParent(npcBehaviour.gameObject.transform, false); graffiti = go.AddComponent(); - go.SetActive(false); } + graffiti.gameObject.SetActive(BehaviourObjectsRemainActive); graffiti.Name = "GraffitiBehaviour"; var sprayPaint = graffiti.GetComponentInChildren(true); @@ -1226,10 +1227,8 @@ private void EnsurePrefabAction(int count, string namePrefix) where T : S1NPC try { var baseNpc = prefabRoot.GetComponent(); - var npcField = typeof(T).GetField("npc", BindingFlags.NonPublic | BindingFlags.Instance); - npcField?.SetValue(comp, baseNpc); - var schedField = typeof(T).GetField("schedule", BindingFlags.NonPublic | BindingFlags.Instance); - schedField?.SetValue(comp, mgr); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "npc", baseNpc); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "schedule", mgr); } catch (Exception ex) { @@ -1259,6 +1258,7 @@ private void EnsureIl2CppCustomerAttendDealBehaviour() component = behaviourObject.AddComponent(); } + component.gameObject.SetActive(BehaviourObjectsRemainActive); ReflectionUtils.TrySetFieldOrProperty(component, "EnabledOnAwake", false); ReflectionUtils.TrySetFieldOrProperty(component, "Name", "Customer attend deal"); ReflectionUtils.TrySetFieldOrProperty(component, "Priority", 4); diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 4eddcd0b..76d6efa4 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -1675,58 +1675,31 @@ private static void ApplyCustomerDataSafely(S1Economy.Customer customerComponent // Ensure internal data structures exist first try { - // Use reflection to access currentAffinityData field/property - PropertyInfo? currentAffinityProp; - FieldInfo? currentAffinityField; - currentAffinityField = customerType.GetField("currentAffinityData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - currentAffinityProp = customerType.GetProperty("currentAffinityData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - - S1Economy.CustomerAffinityData? currentAffinity = null; - if (currentAffinityField != null) - { - if (currentAffinityField is FieldInfo field) - { - currentAffinity = field.GetValue(customerComponent) as S1Economy.CustomerAffinityData; - } - else if (currentAffinityProp is PropertyInfo prop) - { - currentAffinity = prop.GetValue(customerComponent) as S1Economy.CustomerAffinityData; - } - } + var currentAffinity = Utils.ReflectionUtils.TryGetFieldOrProperty( + customerComponent, + "currentAffinityData") as S1Economy.CustomerAffinityData; if (currentAffinity == null) { currentAffinity = new S1Economy.CustomerAffinityData(); - var customerDataProp = customerType.GetProperty("CustomerData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - if (customerDataProp != null) + var customerData = Utils.ReflectionUtils.TryGetFieldOrProperty(customerComponent, "CustomerData"); + if (customerData != null) { - var customerData = customerDataProp.GetValue(customerComponent); - if (customerData != null) + var defaults = Utils.ReflectionUtils.TryGetFieldOrProperty( + customerData, + "DefaultAffinityData"); + if (defaults != null) { - var defaultAffinityProp = customerData.GetType().GetProperty("DefaultAffinityData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - var defaults = defaultAffinityProp?.GetValue(customerData); - if (defaults != null) - { - var copyToMethod = defaults.GetType().GetMethod("CopyTo", - BindingFlags.Public | BindingFlags.Instance); - copyToMethod?.Invoke(defaults, new object[] { currentAffinity }); - } + var copyToMethod = defaults.GetType().GetMethod("CopyTo", + BindingFlags.Public | BindingFlags.Instance); + copyToMethod?.Invoke(defaults, new object[] { currentAffinity }); } } - // Set the new currentAffinityData back - if (currentAffinityField is FieldInfo setField) - { - setField.SetValue(customerComponent, currentAffinity); - } - else if (currentAffinityProp is PropertyInfo setProp && setProp.CanWrite) - { - setProp.SetValue(customerComponent, currentAffinity); - } + Utils.ReflectionUtils.TrySetFieldOrProperty( + customerComponent, + "currentAffinityData", + currentAffinity); } if (cust.ProductAffinities != null && currentAffinity != null) diff --git a/S1API/Internal/Patches/TVPatches.cs b/S1API/Internal/Patches/TVPatches.cs index 231b8325..9ed195a4 100644 --- a/S1API/Internal/Patches/TVPatches.cs +++ b/S1API/Internal/Patches/TVPatches.cs @@ -256,14 +256,8 @@ static void Prefix(TVHomeScreen __instance) if (!SkipInterfaceClose) return; - // Set skipExit via reflection. If this fails on IL2CPP, - // TVInterface_Close_Patch will catch and skip Interface.Close() - var field = typeof(TVHomeScreen).GetField("skipExit", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - if (field != null) + if (ReflectionUtils.TrySetFieldOrProperty(__instance, "skipExit", true)) { - field.SetValue(__instance, true); SkipInterfaceClose = false; } } diff --git a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs new file mode 100644 index 00000000..669bbda8 --- /dev/null +++ b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace S1API.Internal.Utils +{ + /// + /// Tracks one native registration per managed event add operation. + /// + /// The runtime-specific handler type. + internal sealed class ManagedEventRegistrationTracker + { + private readonly Dictionary> _registrations = new Dictionary>(); + + internal void Add(Action managedHandler, TNativeHandler nativeHandler) + { + if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers)) + { + nativeHandlers = new List(); + _registrations.Add(managedHandler, nativeHandlers); + } + + nativeHandlers.Add(nativeHandler); + } + + internal bool TryTakeLast(Action managedHandler, out TNativeHandler nativeHandler) + { + if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers) + || nativeHandlers.Count == 0) + { + nativeHandler = default!; + return false; + } + + int lastIndex = nativeHandlers.Count - 1; + nativeHandler = nativeHandlers[lastIndex]; + nativeHandlers.RemoveAt(lastIndex); + if (nativeHandlers.Count == 0) + { + _registrations.Remove(managedHandler); + } + + return true; + } + } +} diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index a8685f25..29434f74 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -11,6 +11,16 @@ namespace S1API.Internal.Utils /// internal static class ReflectionUtils { + private const BindingFlags InstanceMemberFlags = BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.DeclaredOnly; + + private const BindingFlags StaticMemberFlags = BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Static + | BindingFlags.DeclaredOnly; + /// /// Identifies all classes derived from another class. /// @@ -283,7 +293,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob return false; var type = target.GetType(); - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + const BindingFlags flags = InstanceMemberFlags; // Try field first var fi = GetField(type, memberName, flags); @@ -321,13 +331,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob } } - string[] backingFieldNames = - { - $"<{memberName}>k__BackingField", - $"_{memberName}_k__BackingField" - }; - - foreach (string backingFieldName in backingFieldNames) + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { var backingField = GetField(type, backingFieldName, flags); if (backingField == null) @@ -360,7 +364,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob internal static object? TryGetFieldOrProperty(object target, string memberName) { var type = target.GetType(); - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + const BindingFlags flags = InstanceMemberFlags; // Try field first var fi = GetField(type, memberName, flags); @@ -390,13 +394,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob } } - string[] backingFieldNames = - { - $"<{memberName}>k__BackingField", - $"_{memberName}_k__BackingField" - }; - - foreach (string backingFieldName in backingFieldNames) + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { var backingField = GetField(type, backingFieldName, flags); if (backingField == null) @@ -453,10 +451,10 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob /// The value of the member, or null if not found or inaccessible. internal static object? TryGetStaticFieldOrProperty(Type type, string memberName) { - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + const BindingFlags flags = StaticMemberFlags; // Try field first - var fi = type.GetField(memberName, flags); + var fi = GetField(type, memberName, flags); if (fi != null) { try @@ -470,15 +468,33 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob } // Try property - var pi = type.GetProperty(memberName, flags); - if (pi == null || !pi.CanRead) return null; - try + var pi = GetProperty(type, memberName, flags); + if (pi != null && pi.CanRead) { - return pi.GetValue(null); + try + { + return pi.GetValue(null); + } + catch + { + // ignored + } } - catch + + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { - // ignored + var backingField = GetField(type, backingFieldName, flags); + if (backingField == null) + continue; + + try + { + return backingField.GetValue(null); + } + catch + { + // ignored + } } return null; @@ -494,10 +510,10 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob /// The value to set. internal static void TrySetStaticFieldOrProperty(Type type, string memberName, object? value) { - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + const BindingFlags flags = StaticMemberFlags; // Try field first - var fi = type.GetField(memberName, flags); + var fi = GetField(type, memberName, flags); if (fi != null) { try @@ -515,21 +531,50 @@ internal static void TrySetStaticFieldOrProperty(Type type, string memberName, o } // Try property - var pi = type.GetProperty(memberName, flags); - if (pi == null || !pi.CanWrite) return; - try + var pi = GetProperty(type, memberName, flags); + if (pi != null && pi.CanWrite) { - if (CanAssignValue(pi.PropertyType, value)) + try { - pi.SetValue(null, value); + if (CanAssignValue(pi.PropertyType, value)) + { + pi.SetValue(null, value); + return; + } + } + catch + { + // ignored } } - catch + + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { - // ignored + var backingField = GetField(type, backingFieldName, flags); + if (backingField == null) + continue; + + try + { + if (CanAssignValue(backingField.FieldType, value)) + { + backingField.SetValue(null, value); + return; + } + } + catch + { + // ignored + } } } + private static string[] GetBackingFieldNames(string memberName) => + [ + $"<{memberName}>k__BackingField", + $"_{memberName}_k__BackingField" + ]; + private static bool CanAssignValue(Type memberType, object? value) { if (value != null) diff --git a/S1API/Items/ItemManager.cs b/S1API/Items/ItemManager.cs index 8e64a22e..97fdc629 100644 --- a/S1API/Items/ItemManager.cs +++ b/S1API/Items/ItemManager.cs @@ -276,13 +276,7 @@ private static bool RemoveFromRuntimeCleanupQueue(S1ItemFramework.ItemDefinition return false; } - FieldInfo? runtimeItemsField = typeof(S1Registry).GetField("ItemsAddedAtRuntime", BindingFlags.NonPublic | BindingFlags.Instance); - if (runtimeItemsField == null) - { - return false; - } - - object? runtimeItems = runtimeItemsField.GetValue(S1Registry.Instance); + object? runtimeItems = ReflectionUtils.TryGetFieldOrProperty(S1Registry.Instance, "ItemsAddedAtRuntime"); if (runtimeItems == null) { return false; @@ -308,12 +302,8 @@ private static bool RemoveFromRuntimeCleanupQueue(S1ItemFramework.ItemDefinition continue; } - Type registerType = register.GetType(); - FieldInfo? idField = registerType.GetField("ID", BindingFlags.Public | BindingFlags.Instance); - FieldInfo? definitionField = registerType.GetField("Definition", BindingFlags.Public | BindingFlags.Instance); - - string? registeredId = idField?.GetValue(register) as string; - object? registeredDefinition = definitionField?.GetValue(register); + string? registeredId = ReflectionUtils.TryGetFieldOrProperty(register, "ID") as string; + object? registeredDefinition = ReflectionUtils.TryGetFieldOrProperty(register, "Definition"); if (!string.Equals(registeredId, itemId, StringComparison.OrdinalIgnoreCase) && !ReferenceEquals(registeredDefinition, nativeDefinition)) { diff --git a/S1API/Law/LawController.cs b/S1API/Law/LawController.cs index 85d76fb7..48457cd0 100644 --- a/S1API/Law/LawController.cs +++ b/S1API/Law/LawController.cs @@ -57,19 +57,10 @@ public static float InternalIntensity get { if (Internal == null) return 0f; -#if (IL2CPPMELON) - // Access via reflection as it's a private field in IL2CPP - return 0f; // Safe fallback - modders should use Intensity property instead -#else - // In Mono we can access the field directly through reflection if needed - var fieldInfo = typeof(S1Law.LawController).GetField("internalLawIntensity", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (fieldInfo != null) - { - return (float)fieldInfo.GetValue(Internal); - } - return 0f; -#endif + object? value = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + Internal, + "internalLawIntensity"); + return value == null ? 0f : System.Convert.ToSingle(value); } } diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 5dfe3631..5dab79f3 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.1.13", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.1.14", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 657798ce..346633c4 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.1.13 + 3.1.14 diff --git a/S1API/Vehicles/LandVehicle.cs b/S1API/Vehicles/LandVehicle.cs index 2cd49dc2..22af8ba7 100644 --- a/S1API/Vehicles/LandVehicle.cs +++ b/S1API/Vehicles/LandVehicle.cs @@ -13,6 +13,7 @@ using System; using System.Reflection; using UnityEngine; +using S1API.Internal.Utils; using S1API.Logging; using S1API.Storages; @@ -55,7 +56,7 @@ public LandVehicle(string vehicleCode) public float VehiclePrice { get => S1LandVehicle.VehiclePrice; - set => VehiclePriceField?.SetValue(S1LandVehicle, value); + set => ReflectionUtils.TrySetFieldOrProperty(S1LandVehicle, "vehiclePrice", value); } /// @@ -252,12 +253,6 @@ internal LandVehicle(string vehicleName, bool isDeferred) /// private static readonly Log _logger = new Log("S1API.LandVehicle"); - /// - /// The stored reference to protected vehiclePrice field in the land vehicle in-game. - /// - private static readonly FieldInfo? VehiclePriceField = - typeof(S1Vehicles.LandVehicle).GetField("vehiclePrice", BindingFlags.NonPublic); - /// /// Connection to the player that owns the vehicle. ///