From c3d31e4652bd208e2c8aa81c4345cf1adf986c27 Mon Sep 17 00:00:00 2001 From: "Diffuin[bot]" Date: Thu, 13 Aug 2026 02:26:38 +0000 Subject: [PATCH 1/3] chore(diffuin): address #243 --- S1API/Entities/NPC.cs | 222 +++++++++++++++++- S1API/Internal/Lifecycle/SceneStateCleaner.cs | 10 +- 2 files changed, 223 insertions(+), 9 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index ff8bbc5e..d3122ed4 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1,4 +1,5 @@ #if (IL2CPPMELON) +using NativeVehicleLifecycleAction = Il2CppSystem.Action; using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1AvatarEquipping = Il2CppScheduleOne.AvatarFramework.Equipping; using S1Dialogue = Il2CppScheduleOne.Dialogue; @@ -28,6 +29,7 @@ using S1Money = Il2CppScheduleOne.Money; using ConversationCategoryList = Il2CppSystem.Collections.Generic.List; #elif MONOMELON +using NativeVehicleLifecycleAction = System.Action; using S1DevUtilities = ScheduleOne.DevUtilities; using S1AvatarEquipping = ScheduleOne.AvatarFramework.Equipping; using S1Dialogue = ScheduleOne.Dialogue; @@ -2817,6 +2819,54 @@ public Map.Building? CurrentBuilding public LandVehicle? CurrentVehicle => S1NPC.CurrentVehicle != null ? new LandVehicle(S1NPC.CurrentVehicle) : null; + /// + /// Occurs when the NPC enters a vehicle. + /// + /// This event preserves the timing of the native vehicle-entry callback. + public event Action OnEnterVehicle + { + add + { + if (value == null) + return; + + _enterVehicleHandlers += value; + EnsureVehicleLifecycleHooks(); + } + remove + { + if (value == null) + return; + + _enterVehicleHandlers -= value; + RemoveVehicleLifecycleHooksWhenUnused(); + } + } + + /// + /// Occurs when the NPC exits a vehicle. + /// + /// This event preserves the timing of the native vehicle-exit callback. + public event Action OnExitVehicle + { + add + { + if (value == null) + return; + + _exitVehicleHandlers += value; + EnsureVehicleLifecycleHooks(); + } + remove + { + if (value == null) + return; + + _exitVehicleHandlers -= value; + RemoveVehicleLifecycleHooksWhenUnused(); + } + } + // TODO: Add Inventory (currently missing NPCInventory abstraction) // public ??? Inventory { get; set; } @@ -2949,12 +2999,6 @@ public void ClearConversationCategories() } } - // TODO: Add OnEnterVehicle listener (currently missing LandVehicle abstraction) - // public event Action OnEnterVehicle { } - - // TODO: Add OnExitVehicle listener (currently missing LandVehicle abstraction) - // public event Action OnExitVehicle { } - // TODO: Add OnExplosionHeard listener (currently missing NoiseEvent abstraction) // public event Action OnExplosionHeard { } @@ -4146,6 +4190,11 @@ private void RestoreRuntimeAvatarAppearance() private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; + private Action? _enterVehicleHandlers; + private Action? _exitVehicleHandlers; + private NativeVehicleLifecycleAction? _nativeEnterVehicleDispatcher; + private NativeVehicleLifecycleAction? _nativeExitVehicleDispatcher; + private bool _vehicleLifecycleHooksSubscribed; private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4685,6 +4734,167 @@ internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); _messaging?.Cleanup(); + CleanupVehicleLifecycleHooks(); + } + + private void EnsureVehicleLifecycleHooks() + { + if (_vehicleLifecycleHooksSubscribed || + (_enterVehicleHandlers == null && _exitVehicleHandlers == null)) + { + return; + } + + NativeVehicleLifecycleAction enterDispatcher = + GetOrCreateNativeEnterVehicleDispatcher(); + NativeVehicleLifecycleAction exitDispatcher = + GetOrCreateNativeExitVehicleDispatcher(); + + try + { + _vehicleLifecycleHooksSubscribed = true; +#if IL2CPPMELON + S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null + ? enterDispatcher + : Il2CppSystem.Delegate.Combine( + S1NPC.onEnterVehicle, + enterDispatcher) + .Cast(); + S1NPC.onExitVehicle = S1NPC.onExitVehicle == null + ? exitDispatcher + : Il2CppSystem.Delegate.Combine( + S1NPC.onExitVehicle, + exitDispatcher) + .Cast(); +#else + S1NPC.onEnterVehicle += enterDispatcher; + S1NPC.onExitVehicle += exitDispatcher; +#endif + } + catch (Exception ex) + { + RemoveVehicleLifecycleHooks(); + Logger.Warning( + $"Could not attach native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); + } + } + + private NativeVehicleLifecycleAction GetOrCreateNativeEnterVehicleDispatcher() + { + if (_nativeEnterVehicleDispatcher != null) + return _nativeEnterVehicleDispatcher; + +#if IL2CPPMELON + _nativeEnterVehicleDispatcher = + DelegateSupport.ConvertDelegate( + new Action(DispatchEnterVehicle)) + ?? throw new InvalidOperationException( + "Could not create the native vehicle-entry dispatcher."); +#else + _nativeEnterVehicleDispatcher = DispatchEnterVehicle; +#endif + return _nativeEnterVehicleDispatcher; + } + + private NativeVehicleLifecycleAction GetOrCreateNativeExitVehicleDispatcher() + { + if (_nativeExitVehicleDispatcher != null) + return _nativeExitVehicleDispatcher; + +#if IL2CPPMELON + _nativeExitVehicleDispatcher = + DelegateSupport.ConvertDelegate( + new Action(DispatchExitVehicle)) + ?? throw new InvalidOperationException( + "Could not create the native vehicle-exit dispatcher."); +#else + _nativeExitVehicleDispatcher = DispatchExitVehicle; +#endif + return _nativeExitVehicleDispatcher; + } + + private void RemoveVehicleLifecycleHooksWhenUnused() + { + if (_enterVehicleHandlers == null && _exitVehicleHandlers == null) + RemoveVehicleLifecycleHooks(); + } + + private void CleanupVehicleLifecycleHooks() + { + RemoveVehicleLifecycleHooks(); + _enterVehicleHandlers = null; + _exitVehicleHandlers = null; + _nativeEnterVehicleDispatcher = null; + _nativeExitVehicleDispatcher = null; + } + + private void RemoveVehicleLifecycleHooks() + { + if (!_vehicleLifecycleHooksSubscribed || + _nativeEnterVehicleDispatcher == null || + _nativeExitVehicleDispatcher == null) + { + _vehicleLifecycleHooksSubscribed = false; + return; + } + + try + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remainingEnter = Il2CppSystem.Delegate.Remove( + S1NPC.onEnterVehicle, + _nativeEnterVehicleDispatcher); + S1NPC.onEnterVehicle = + remainingEnter?.Cast(); + Il2CppSystem.Delegate? remainingExit = Il2CppSystem.Delegate.Remove( + S1NPC.onExitVehicle, + _nativeExitVehicleDispatcher); + S1NPC.onExitVehicle = + remainingExit?.Cast(); +#else + S1NPC.onEnterVehicle -= _nativeEnterVehicleDispatcher; + S1NPC.onExitVehicle -= _nativeExitVehicleDispatcher; +#endif + } + catch (Exception ex) + { + Logger.Warning( + $"Could not remove native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); + } + finally + { + _vehicleLifecycleHooksSubscribed = false; + } + } + + private void DispatchEnterVehicle(S1Vehicles.LandVehicle vehicle) => + DispatchVehicleLifecycleEvent(_enterVehicleHandlers, vehicle, "OnEnterVehicle"); + + private void DispatchExitVehicle(S1Vehicles.LandVehicle vehicle) => + DispatchVehicleLifecycleEvent(_exitVehicleHandlers, vehicle, "OnExitVehicle"); + + private void DispatchVehicleLifecycleEvent( + Action? handlers, + S1Vehicles.LandVehicle vehicle, + string eventName) + { + if (handlers == null) + return; + + var wrappedVehicle = new LandVehicle(vehicle); + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(wrappedVehicle); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + } } private sealed class DealerRecommendationSubscription diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index 1bdf9ca0..b5a3f704 100644 --- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs +++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs @@ -66,9 +66,14 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) for (int i = 0; i < NPC.All.Count; i++) { var npc = NPC.All[i]; - if (npc != null && npc.gameObject != null) + if (npc != null) { - TryRun(() => UnityEngine.Object.Destroy(npc.gameObject)); + TryRun( + npc.CleanupRuntimeHooks, + "Failed to remove NPC runtime hooks during scene cleanup"); + + if (npc.gameObject != null) + TryRun(() => UnityEngine.Object.Destroy(npc.gameObject)); } } NPC.All.Clear(); @@ -126,4 +131,3 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) } } } - From 3338967822baf27f002a66cff0eac51dcffa3fa5 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 17:30:07 -0700 Subject: [PATCH 2/3] refactor(Entities): simplify NPC vehicle event bridges --- .../Entities/NPCVehicleLifecycleApiTests.cs | 33 +++ .../ManagedEventRegistrationTrackerTests.cs | 24 ++ S1API/Entities/NPC.cs | 266 ++++++++---------- .../Utils/ManagedEventRegistrationTracker.cs | 21 +- 4 files changed, 198 insertions(+), 146 deletions(-) create mode 100644 S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs diff --git a/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs b/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs new file mode 100644 index 00000000..12e88faf --- /dev/null +++ b/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Reflection; +using S1API.Entities; +using S1API.Vehicles; + +namespace S1API.Tests.Entities; + +public sealed class NPCVehicleLifecycleApiTests +{ + [Theory] + [InlineData(nameof(NPC.OnEnterVehicle))] + [InlineData(nameof(NPC.OnExitVehicle))] + public void VehicleLifecycleEventsExposeManagedVehicleArguments(string eventName) + { + EventInfo? eventInfo = typeof(NPC).GetEvent(eventName); + + Assert.NotNull(eventInfo); + Assert.Equal(typeof(Action), eventInfo!.EventHandlerType); + } +} + +internal static class NPCVehicleLifecycleApiCompileFixture +{ + internal static void SubscribeAndUnsubscribe(NPC npc) + { + Action handler = _ => { }; + + npc.OnEnterVehicle += handler; + npc.OnExitVehicle += handler; + npc.OnEnterVehicle -= handler; + npc.OnExitVehicle -= handler; + } +} diff --git a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs index 48cd4799..3040f28f 100644 --- a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs +++ b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs @@ -19,4 +19,28 @@ public void DuplicateAddsAreRemovedOneAtATimeInReverseRegistrationOrder() Assert.Equal("first", first); Assert.False(tracker.TryTakeLast(handler, out _)); } + + [Fact] + public void TakeAllReturnsEveryRegistrationAndClearsTheTracker() + { + var tracker = new ManagedEventRegistrationTracker(); + Action firstHandler = () => { }; + Action secondHandler = _ => { }; + + tracker.Add(firstHandler, "first"); + tracker.Add(firstHandler, "second"); + tracker.Add(secondHandler, "third"); + + var registrations = tracker.TakeAll(); + + Assert.Equal(3, registrations.Count); + Assert.Contains(registrations, registration => + registration.ManagedHandler.Equals(firstHandler) && registration.NativeHandler == "first"); + Assert.Contains(registrations, registration => + registration.ManagedHandler.Equals(firstHandler) && registration.NativeHandler == "second"); + Assert.Contains(registrations, registration => + registration.ManagedHandler.Equals(secondHandler) && registration.NativeHandler == "third"); + Assert.False(tracker.TryTakeLast(firstHandler, out _)); + Assert.False(tracker.TryTakeLast(secondHandler, out _)); + } } diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index d3122ed4..2e7c33ef 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -2825,22 +2825,16 @@ public Map.Building? CurrentBuilding /// This event preserves the timing of the native vehicle-entry callback. public event Action OnEnterVehicle { - add - { - if (value == null) - return; - - _enterVehicleHandlers += value; - EnsureVehicleLifecycleHooks(); - } - remove - { - if (value == null) - return; - - _enterVehicleHandlers -= value; - RemoveVehicleLifecycleHooksWhenUnused(); - } + add => AddVehicleLifecycleHandler( + value, + _enterVehicleRegistrations, + SubscribeEnterVehicle, + nameof(OnEnterVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); } /// @@ -2849,22 +2843,16 @@ public event Action OnEnterVehicle /// This event preserves the timing of the native vehicle-exit callback. public event Action OnExitVehicle { - add - { - if (value == null) - return; - - _exitVehicleHandlers += value; - EnsureVehicleLifecycleHooks(); - } - remove - { - if (value == null) - return; - - _exitVehicleHandlers -= value; - RemoveVehicleLifecycleHooksWhenUnused(); - } + add => AddVehicleLifecycleHandler( + value, + _exitVehicleRegistrations, + SubscribeExitVehicle, + nameof(OnExitVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); } // TODO: Add Inventory (currently missing NPCInventory abstraction) @@ -4190,11 +4178,10 @@ private void RestoreRuntimeAvatarAppearance() private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; - private Action? _enterVehicleHandlers; - private Action? _exitVehicleHandlers; - private NativeVehicleLifecycleAction? _nativeEnterVehicleDispatcher; - private NativeVehicleLifecycleAction? _nativeExitVehicleDispatcher; - private bool _vehicleLifecycleHooksSubscribed; + private readonly ManagedEventRegistrationTracker _enterVehicleRegistrations = + new ManagedEventRegistrationTracker(); + private readonly ManagedEventRegistrationTracker _exitVehicleRegistrations = + new ManagedEventRegistrationTracker(); private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4737,162 +4724,155 @@ internal void CleanupRuntimeHooks() CleanupVehicleLifecycleHooks(); } - private void EnsureVehicleLifecycleHooks() + private void AddVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker registrations, + Action subscribe, + string eventName) { - if (_vehicleLifecycleHooksSubscribed || - (_enterVehicleHandlers == null && _exitVehicleHandlers == null)) - { + if (handler == null) return; + + try + { + NativeVehicleLifecycleAction nativeHandler = + CreateVehicleLifecycleHandler(handler, eventName); + subscribe(nativeHandler); + registrations.Add(handler, nativeHandler); } + catch (Exception ex) + { + Logger.Warning( + $"Could not subscribe to NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } - NativeVehicleLifecycleAction enterDispatcher = - GetOrCreateNativeEnterVehicleDispatcher(); - NativeVehicleLifecycleAction exitDispatcher = - GetOrCreateNativeExitVehicleDispatcher(); + private void RemoveVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker registrations, + Action unsubscribe, + string eventName) + { + if (handler == null || !registrations.TryTakeLast(handler, out var nativeHandler)) + return; try { - _vehicleLifecycleHooksSubscribed = true; -#if IL2CPPMELON - S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null - ? enterDispatcher - : Il2CppSystem.Delegate.Combine( - S1NPC.onEnterVehicle, - enterDispatcher) - .Cast(); - S1NPC.onExitVehicle = S1NPC.onExitVehicle == null - ? exitDispatcher - : Il2CppSystem.Delegate.Combine( - S1NPC.onExitVehicle, - exitDispatcher) - .Cast(); -#else - S1NPC.onEnterVehicle += enterDispatcher; - S1NPC.onExitVehicle += exitDispatcher; -#endif + unsubscribe(nativeHandler); } catch (Exception ex) { - RemoveVehicleLifecycleHooks(); + registrations.Add(handler, nativeHandler); Logger.Warning( - $"Could not attach native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); + $"Could not unsubscribe from NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); } } - private NativeVehicleLifecycleAction GetOrCreateNativeEnterVehicleDispatcher() + private NativeVehicleLifecycleAction CreateVehicleLifecycleHandler( + Action handler, + string eventName) { - if (_nativeEnterVehicleDispatcher != null) - return _nativeEnterVehicleDispatcher; + Action managedHandler = vehicle => + { + try + { + handler(new LandVehicle(vehicle)); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + }; #if IL2CPPMELON - _nativeEnterVehicleDispatcher = - DelegateSupport.ConvertDelegate( - new Action(DispatchEnterVehicle)) + return DelegateSupport.ConvertDelegate(managedHandler) ?? throw new InvalidOperationException( - "Could not create the native vehicle-entry dispatcher."); + $"Could not create the native {eventName} delegate."); #else - _nativeEnterVehicleDispatcher = DispatchEnterVehicle; + return managedHandler; #endif - return _nativeEnterVehicleDispatcher; } - private NativeVehicleLifecycleAction GetOrCreateNativeExitVehicleDispatcher() + private void SubscribeEnterVehicle(NativeVehicleLifecycleAction handler) { - if (_nativeExitVehicleDispatcher != null) - return _nativeExitVehicleDispatcher; - #if IL2CPPMELON - _nativeExitVehicleDispatcher = - DelegateSupport.ConvertDelegate( - new Action(DispatchExitVehicle)) - ?? throw new InvalidOperationException( - "Could not create the native vehicle-exit dispatcher."); + S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onEnterVehicle, handler) + .Cast(); #else - _nativeExitVehicleDispatcher = DispatchExitVehicle; + S1NPC.onEnterVehicle += handler; #endif - return _nativeExitVehicleDispatcher; } - private void RemoveVehicleLifecycleHooksWhenUnused() + private void UnsubscribeEnterVehicle(NativeVehicleLifecycleAction handler) { - if (_enterVehicleHandlers == null && _exitVehicleHandlers == null) - RemoveVehicleLifecycleHooks(); +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onEnterVehicle, + handler); + S1NPC.onEnterVehicle = remaining?.Cast(); +#else + S1NPC.onEnterVehicle -= handler; +#endif } - private void CleanupVehicleLifecycleHooks() + private void SubscribeExitVehicle(NativeVehicleLifecycleAction handler) { - RemoveVehicleLifecycleHooks(); - _enterVehicleHandlers = null; - _exitVehicleHandlers = null; - _nativeEnterVehicleDispatcher = null; - _nativeExitVehicleDispatcher = null; +#if IL2CPPMELON + S1NPC.onExitVehicle = S1NPC.onExitVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onExitVehicle, handler) + .Cast(); +#else + S1NPC.onExitVehicle += handler; +#endif } - private void RemoveVehicleLifecycleHooks() + private void UnsubscribeExitVehicle(NativeVehicleLifecycleAction handler) { - if (!_vehicleLifecycleHooksSubscribed || - _nativeEnterVehicleDispatcher == null || - _nativeExitVehicleDispatcher == null) - { - _vehicleLifecycleHooksSubscribed = false; - return; - } - - try - { #if IL2CPPMELON - Il2CppSystem.Delegate? remainingEnter = Il2CppSystem.Delegate.Remove( - S1NPC.onEnterVehicle, - _nativeEnterVehicleDispatcher); - S1NPC.onEnterVehicle = - remainingEnter?.Cast(); - Il2CppSystem.Delegate? remainingExit = Il2CppSystem.Delegate.Remove( - S1NPC.onExitVehicle, - _nativeExitVehicleDispatcher); - S1NPC.onExitVehicle = - remainingExit?.Cast(); + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onExitVehicle, + handler); + S1NPC.onExitVehicle = remaining?.Cast(); #else - S1NPC.onEnterVehicle -= _nativeEnterVehicleDispatcher; - S1NPC.onExitVehicle -= _nativeExitVehicleDispatcher; + S1NPC.onExitVehicle -= handler; #endif - } - catch (Exception ex) - { - Logger.Warning( - $"Could not remove native vehicle lifecycle hooks for '{GetSafeNpcId()}': {ex}"); - } - finally - { - _vehicleLifecycleHooksSubscribed = false; - } } - private void DispatchEnterVehicle(S1Vehicles.LandVehicle vehicle) => - DispatchVehicleLifecycleEvent(_enterVehicleHandlers, vehicle, "OnEnterVehicle"); - - private void DispatchExitVehicle(S1Vehicles.LandVehicle vehicle) => - DispatchVehicleLifecycleEvent(_exitVehicleHandlers, vehicle, "OnExitVehicle"); + private void CleanupVehicleLifecycleHooks() + { + CleanupVehicleLifecycleHandlers( + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); + CleanupVehicleLifecycleHandlers( + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); + } - private void DispatchVehicleLifecycleEvent( - Action? handlers, - S1Vehicles.LandVehicle vehicle, + private void CleanupVehicleLifecycleHandlers( + ManagedEventRegistrationTracker registrations, + Action unsubscribe, string eventName) { - if (handlers == null) - return; - - var wrappedVehicle = new LandVehicle(vehicle); - foreach (Action handler in handlers.GetInvocationList()) + foreach (var registration in registrations.TakeAll()) { try { - handler(wrappedVehicle); + unsubscribe(registration.NativeHandler); } catch (Exception ex) { + registrations.Add( + registration.ManagedHandler, + registration.NativeHandler); Logger.Warning( - $"NPC.{eventName} subscriber " + - $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + $"Could not clean up NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); } } } diff --git a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs index 669bbda8..09c8926e 100644 --- a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs +++ b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs @@ -9,9 +9,9 @@ namespace S1API.Internal.Utils /// The runtime-specific handler type. internal sealed class ManagedEventRegistrationTracker { - private readonly Dictionary> _registrations = new Dictionary>(); + private readonly Dictionary> _registrations = new Dictionary>(); - internal void Add(Action managedHandler, TNativeHandler nativeHandler) + internal void Add(Delegate managedHandler, TNativeHandler nativeHandler) { if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers)) { @@ -22,7 +22,7 @@ internal void Add(Action managedHandler, TNativeHandler nativeHandler) nativeHandlers.Add(nativeHandler); } - internal bool TryTakeLast(Action managedHandler, out TNativeHandler nativeHandler) + internal bool TryTakeLast(Delegate managedHandler, out TNativeHandler nativeHandler) { if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers) || nativeHandlers.Count == 0) @@ -41,5 +41,20 @@ internal bool TryTakeLast(Action managedHandler, out TNativeHandler nativeHandle return true; } + + internal IReadOnlyList<(Delegate ManagedHandler, TNativeHandler NativeHandler)> TakeAll() + { + var registrations = new List<(Delegate ManagedHandler, TNativeHandler NativeHandler)>(); + foreach (var registration in _registrations) + { + foreach (TNativeHandler nativeHandler in registration.Value) + { + registrations.Add((registration.Key, nativeHandler)); + } + } + + _registrations.Clear(); + return registrations; + } } } From c3e97719be8891f5bf214e3dc3ddd94a638abac3 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 13 Aug 2026 17:36:10 -0700 Subject: [PATCH 3/3] fix(Entities): initialize vehicle event tracking lazily --- S1API/Entities/NPC.cs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 2e7c33ef..2116ee6d 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -2827,7 +2827,8 @@ public event Action OnEnterVehicle { add => AddVehicleLifecycleHandler( value, - _enterVehicleRegistrations, + _enterVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), SubscribeEnterVehicle, nameof(OnEnterVehicle)); remove => RemoveVehicleLifecycleHandler( @@ -2845,7 +2846,8 @@ public event Action OnExitVehicle { add => AddVehicleLifecycleHandler( value, - _exitVehicleRegistrations, + _exitVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), SubscribeExitVehicle, nameof(OnExitVehicle)); remove => RemoveVehicleLifecycleHandler( @@ -4178,10 +4180,8 @@ private void RestoreRuntimeAvatarAppearance() private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; - private readonly ManagedEventRegistrationTracker _enterVehicleRegistrations = - new ManagedEventRegistrationTracker(); - private readonly ManagedEventRegistrationTracker _exitVehicleRegistrations = - new ManagedEventRegistrationTracker(); + private ManagedEventRegistrationTracker? _enterVehicleRegistrations; + private ManagedEventRegistrationTracker? _exitVehicleRegistrations; private bool _relationshipDataAppliedFromPrefab; private float? _loadedRelationshipDelta; private bool _loadedRelationshipUnlocked; @@ -4749,11 +4749,12 @@ private void AddVehicleLifecycleHandler( private void RemoveVehicleLifecycleHandler( Action? handler, - ManagedEventRegistrationTracker registrations, + ManagedEventRegistrationTracker? registrations, Action unsubscribe, string eventName) { - if (handler == null || !registrations.TryTakeLast(handler, out var nativeHandler)) + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var nativeHandler)) return; try @@ -4856,10 +4857,13 @@ private void CleanupVehicleLifecycleHooks() } private void CleanupVehicleLifecycleHandlers( - ManagedEventRegistrationTracker registrations, + ManagedEventRegistrationTracker? registrations, Action unsubscribe, string eventName) { + if (registrations == null) + return; + foreach (var registration in registrations.TakeAll()) { try