From 55cf43af5a7417af82cbc35c10fb23567f843ee4 Mon Sep 17 00:00:00 2001 From: "Diffuin[bot]" Date: Sun, 16 Aug 2026 07:36:29 +0000 Subject: [PATCH 1/2] chore(diffuin): address #240 --- .../Trash/TrashContainerApiCompileFixture.cs | 40 +++ S1API/Trash/TrashContainer.cs | 232 ++++++++++++++++++ S1API/Trash/TrashContentEntry.cs | 36 +++ 3 files changed, 308 insertions(+) create mode 100644 S1API.Tests/Trash/TrashContainerApiCompileFixture.cs create mode 100644 S1API/Trash/TrashContainer.cs create mode 100644 S1API/Trash/TrashContentEntry.cs diff --git a/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs b/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs new file mode 100644 index 00000000..3df394af --- /dev/null +++ b/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using S1API.Trash; +using UnityEngine; + +namespace S1API.Tests.Trash; + +internal static class TrashContainerApiCompileFixture +{ + internal static void ReadAndSubscribe(GameObject gameObject, TrashContainer container) + { + TrashContainer? existing = TrashContainer.FromGameObject(gameObject); + TrashContainer[] containers = TrashContainer.FindInScene(includeInactive: true); + GameObject? owner = container.GameObject; + int capacity = container.Capacity; + int level = container.Level; + float normalizedLevel = container.NormalizedLevel; + IReadOnlyList contents = container.Contents; + bool canBeBagged = container.CanBeBagged; + + Action trashAdded = _ => { }; + Action levelChanged = () => { }; + container.OnTrashAdded += trashAdded; + container.OnTrashLevelChanged += levelChanged; + container.OnTrashAdded -= trashAdded; + container.OnTrashLevelChanged -= levelChanged; + + bool bagged = container.TryBagTrash(); + + _ = existing; + _ = containers; + _ = owner; + _ = capacity; + _ = level; + _ = normalizedLevel; + _ = contents; + _ = canBeBagged; + _ = bagged; + } +} diff --git a/S1API/Trash/TrashContainer.cs b/S1API/Trash/TrashContainer.cs new file mode 100644 index 00000000..94ee8799 --- /dev/null +++ b/S1API/Trash/TrashContainer.cs @@ -0,0 +1,232 @@ +#if IL2CPPMELON +using S1InstanceFinder = Il2CppFishNet.InstanceFinder; +using S1Trash = Il2CppScheduleOne.Trash; +#elif MONOMELON +using S1InstanceFinder = FishNet.InstanceFinder; +using S1Trash = ScheduleOne.Trash; +#endif + +using System; +using System.Collections.Generic; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Trash +{ + /// + /// Provides managed access to an existing native trash container. + /// + /// + /// This wrapper does not add persistence or replacement replication for custom containers. + /// + public sealed class TrashContainer + { + private Action? _trashAdded; + private Action? _trashLevelChanged; + private bool _trashAddedSubscribed; + private bool _trashLevelChangedSubscribed; + + /// + /// INTERNAL: The native trash container. + /// + internal readonly S1Trash.TrashContainer S1TrashContainer; + + /// + /// INTERNAL: Creates a wrapper around a native trash container. + /// + /// The native trash container. + internal TrashContainer(S1Trash.TrashContainer trashContainer) + { + S1TrashContainer = trashContainer; + } + + /// + /// Gets a trash container attached directly to a game object. + /// + /// The game object to inspect. + /// A trash-container wrapper, or null when the game object has no container component. + /// is null or destroyed. + public static TrashContainer? FromGameObject(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + S1Trash.TrashContainer? trashContainer = + gameObject.GetComponent(); + return trashContainer == null ? null : new TrashContainer(trashContainer); + } + + /// + /// Finds native trash containers in the loaded scene. + /// + /// Whether to include containers on inactive game objects. + /// A snapshot of the trash containers found in the scene. + public static TrashContainer[] FindInScene(bool includeInactive = false) + { + var nativeContainers = + Object.FindObjectsOfType(includeInactive); + if (nativeContainers == null || nativeContainers.Length == 0) + return Array.Empty(); + + var containers = new List(nativeContainers.Length); + for (int index = 0; index < nativeContainers.Length; index++) + { + S1Trash.TrashContainer nativeContainer = nativeContainers[index]; + if (nativeContainer != null) + containers.Add(new TrashContainer(nativeContainer)); + } + + return containers.ToArray(); + } + + /// + /// Gets the game object that owns the native trash container. + /// + public GameObject? GameObject => + S1TrashContainer?.gameObject; + + /// + /// Gets the maximum number of capacity units the container can hold. + /// + public int Capacity => + S1TrashContainer.TrashCapacity; + + /// + /// Gets the current number of capacity units used by the container contents. + /// + public int Level => + S1TrashContainer.TrashLevel; + + /// + /// Gets the current level divided by the container capacity. + /// + public float NormalizedLevel => + S1TrashContainer.NormalizedTrashLevel; + + /// + /// Gets an immutable managed snapshot of the current content entries. + /// + public IReadOnlyList Contents + { + get + { + var entries = S1TrashContainer.Content?.Entries; + if (entries == null || entries.Count == 0) + return Array.Empty(); + + var snapshot = new List(entries.Count); + for (int index = 0; index < entries.Count; index++) + { + S1Trash.TrashContent.Entry entry = entries[index]; + if (entry == null) + continue; + + snapshot.Add(new TrashContentEntry( + entry.TrashID, + entry.Quantity, + entry.UnitSize, + entry.UnitValue)); + } + + return snapshot.AsReadOnly(); + } + } + + /// + /// Gets whether the native container currently has enough contents to create a trash bag. + /// + public bool CanBeBagged => + S1TrashContainer.CanBeBagged(); + + /// + /// Occurs after the native container adds trash. + /// + public event Action OnTrashAdded + { + add + { + if (value == null) + return; + + if (!_trashAddedSubscribed) + { + global::S1API.Utils.EventHelper.AddListener( + HandleTrashAdded, + S1TrashContainer.onTrashAdded); + _trashAddedSubscribed = true; + } + + _trashAdded += value; + } + remove + { + if (value == null) + return; + + _trashAdded -= value; + if (_trashAdded != null || !_trashAddedSubscribed) + return; + + global::S1API.Utils.EventHelper.RemoveListener( + HandleTrashAdded, + S1TrashContainer.onTrashAdded); + _trashAddedSubscribed = false; + } + } + + /// + /// Occurs after the native container level changes. + /// + public event Action OnTrashLevelChanged + { + add + { + if (value == null) + return; + + if (!_trashLevelChangedSubscribed) + { + global::S1API.Utils.EventHelper.AddListener( + HandleTrashLevelChanged, + S1TrashContainer.onTrashLevelChanged); + _trashLevelChangedSubscribed = true; + } + + _trashLevelChanged += value; + } + remove + { + if (value == null) + return; + + _trashLevelChanged -= value; + if (_trashLevelChanged != null || !_trashLevelChangedSubscribed) + return; + + global::S1API.Utils.EventHelper.RemoveListener( + HandleTrashLevelChanged, + S1TrashContainer.onTrashLevelChanged); + _trashLevelChangedSubscribed = false; + } + } + + /// + /// Bags the current contents through the native server-authoritative path when eligible. + /// + /// true when native bagging was invoked; otherwise, false. + public bool TryBagTrash() + { + if (!S1InstanceFinder.IsServer || !S1TrashContainer.CanBeBagged()) + return false; + + S1TrashContainer.BagTrash(); + return true; + } + + private void HandleTrashAdded(string trashId) => + _trashAdded?.Invoke(trashId); + + private void HandleTrashLevelChanged() => + _trashLevelChanged?.Invoke(); + } +} diff --git a/S1API/Trash/TrashContentEntry.cs b/S1API/Trash/TrashContentEntry.cs new file mode 100644 index 00000000..2c45e828 --- /dev/null +++ b/S1API/Trash/TrashContentEntry.cs @@ -0,0 +1,36 @@ +namespace S1API.Trash +{ + /// + /// Describes one immutable entry in a trash-container content snapshot. + /// + public readonly struct TrashContentEntry + { + internal TrashContentEntry(string? trashId, int quantity, int unitSize, int unitValue) + { + TrashId = trashId; + Quantity = quantity; + UnitSize = unitSize; + UnitValue = unitValue; + } + + /// + /// Gets the native trash-prefab identifier. + /// + public string? TrashId { get; } + + /// + /// Gets the number of trash items represented by this entry. + /// + public int Quantity { get; } + + /// + /// Gets the container-capacity units consumed by each item. + /// + public int UnitSize { get; } + + /// + /// Gets the sell value of each item. + /// + public int UnitValue { get; } + } +} From 8da02e51a62a3d2d2516f6984fd7d402531c3e35 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sun, 16 Aug 2026 01:48:27 -0700 Subject: [PATCH 2/2] fix(trash): preserve event subscriptions across wrappers --- .../TrashContainerApiCompatibilityTests.cs | 56 ++++ S1API/Trash/TrashContainer.cs | 277 +++++++++++++++--- 2 files changed, 291 insertions(+), 42 deletions(-) create mode 100644 S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs diff --git a/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs b/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs new file mode 100644 index 00000000..85f9c8e2 --- /dev/null +++ b/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using S1API.Trash; + +namespace S1API.Tests.Trash; + +public sealed class TrashContainerApiCompatibilityTests +{ + [Fact] + public void WrapperSurfaceUsesManagedTypes() + { + Assert.True(typeof(TrashContainer).IsSealed); + Assert.Equal(typeof(int), GetProperty(nameof(TrashContainer.Capacity)).PropertyType); + Assert.Equal(typeof(int), GetProperty(nameof(TrashContainer.Level)).PropertyType); + Assert.Equal(typeof(float), GetProperty(nameof(TrashContainer.NormalizedLevel)).PropertyType); + Assert.Equal( + typeof(IReadOnlyList), + GetProperty(nameof(TrashContainer.Contents)).PropertyType); + Assert.Equal(typeof(bool), GetProperty(nameof(TrashContainer.CanBeBagged)).PropertyType); + Assert.Equal(typeof(Action), GetEvent(nameof(TrashContainer.OnTrashAdded)).EventHandlerType); + Assert.Equal(typeof(Action), GetEvent(nameof(TrashContainer.OnTrashLevelChanged)).EventHandlerType); + Assert.Equal(typeof(bool), GetMethod(nameof(TrashContainer.TryBagTrash)).ReturnType); + } + + [Fact] + public void ContentEntryIsAnImmutableManagedValue() + { + Assert.True(typeof(TrashContentEntry).IsValueType); + Assert.True(typeof(TrashContentEntry).IsDefined(typeof(System.Runtime.CompilerServices.IsReadOnlyAttribute))); + + PropertyInfo[] properties = typeof(TrashContentEntry).GetProperties(BindingFlags.Instance | BindingFlags.Public); + Assert.Equal(4, properties.Length); + Assert.All(properties, property => Assert.Null(property.SetMethod)); + Assert.DoesNotContain( + typeof(TrashContentEntry).GetFields(BindingFlags.Instance | BindingFlags.Public), + field => !field.IsInitOnly); + } + + [Fact] + public void ConstructionDoesNotExposeNativeTypes() + { + Assert.Empty(typeof(TrashContainer).GetConstructors(BindingFlags.Instance | BindingFlags.Public)); + Assert.Empty(typeof(TrashContentEntry).GetConstructors(BindingFlags.Instance | BindingFlags.Public)); + } + + private static PropertyInfo GetProperty(string name) => + typeof(TrashContainer).GetProperty(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public property {name}."); + + private static EventInfo GetEvent(string name) => + typeof(TrashContainer).GetEvent(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public event {name}."); + + private static MethodInfo GetMethod(string name) => + typeof(TrashContainer).GetMethod(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public method {name}."); +} diff --git a/S1API/Trash/TrashContainer.cs b/S1API/Trash/TrashContainer.cs index 94ee8799..7cb4fbbb 100644 --- a/S1API/Trash/TrashContainer.cs +++ b/S1API/Trash/TrashContainer.cs @@ -1,13 +1,19 @@ #if IL2CPPMELON +using Il2CppInterop.Runtime; +using NativeTrashAddedAction = UnityEngine.Events.UnityAction; +using NativeTrashLevelChangedAction = UnityEngine.Events.UnityAction; using S1InstanceFinder = Il2CppFishNet.InstanceFinder; using S1Trash = Il2CppScheduleOne.Trash; #elif MONOMELON +using NativeTrashAddedAction = System.Action; +using NativeTrashLevelChangedAction = System.Action; using S1InstanceFinder = FishNet.InstanceFinder; using S1Trash = ScheduleOne.Trash; #endif using System; using System.Collections.Generic; +using S1API.Internal.Utils; using UnityEngine; using Object = UnityEngine.Object; @@ -21,10 +27,11 @@ namespace S1API.Trash /// public sealed class TrashContainer { - private Action? _trashAdded; - private Action? _trashLevelChanged; - private bool _trashAddedSubscribed; - private bool _trashLevelChangedSubscribed; + private static readonly Dictionary TrashAddedRegistrations = + new Dictionary(); + + private static readonly Dictionary TrashLevelChangedRegistrations = + new Dictionary(); /// /// INTERNAL: The native trash container. @@ -51,6 +58,7 @@ internal TrashContainer(S1Trash.TrashContainer trashContainer) if (gameObject == null) throw new ArgumentNullException(nameof(gameObject)); + PruneDestroyedRegistrationStates(); S1Trash.TrashContainer? trashContainer = gameObject.GetComponent(); return trashContainer == null ? null : new TrashContainer(trashContainer); @@ -63,6 +71,7 @@ internal TrashContainer(S1Trash.TrashContainer trashContainer) /// A snapshot of the trash containers found in the scene. public static TrashContainer[] FindInScene(bool includeInactive = false) { + PruneDestroyedRegistrationStates(); var nativeContainers = Object.FindObjectsOfType(includeInactive); if (nativeContainers == null || nativeContainers.Length == 0) @@ -141,6 +150,10 @@ public IReadOnlyList Contents /// /// Occurs after the native container adds trash. /// + /// + /// A handler may be removed through any wrapper for the same native container. + /// Duplicate subscriptions are removed one at a time. + /// public event Action OnTrashAdded { add @@ -148,35 +161,40 @@ public event Action OnTrashAdded if (value == null) return; - if (!_trashAddedSubscribed) - { - global::S1API.Utils.EventHelper.AddListener( - HandleTrashAdded, - S1TrashContainer.onTrashAdded); - _trashAddedSubscribed = true; - } - - _trashAdded += value; + NativeTrashAddedAction nativeHandler = CreateNativeTrashAddedHandler(value); + SubscribeTrashAdded(nativeHandler); + GetTrashAddedRegistrationState().Registrations.Add(value, nativeHandler); } remove { - if (value == null) + if (value == null || !TryTakeTrashAddedRegistration( + value, + out TrashAddedRegistrationState state, + out NativeTrashAddedAction nativeHandler)) return; - _trashAdded -= value; - if (_trashAdded != null || !_trashAddedSubscribed) - return; + try + { + UnsubscribeTrashAdded(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } - global::S1API.Utils.EventHelper.RemoveListener( - HandleTrashAdded, - S1TrashContainer.onTrashAdded); - _trashAddedSubscribed = false; + if (state.Registrations.IsEmpty) + TrashAddedRegistrations.Remove(S1TrashContainer.GetInstanceID()); } } /// /// Occurs after the native container level changes. /// + /// + /// A handler may be removed through any wrapper for the same native container. + /// Duplicate subscriptions are removed one at a time. + /// public event Action OnTrashLevelChanged { add @@ -184,29 +202,30 @@ public event Action OnTrashLevelChanged if (value == null) return; - if (!_trashLevelChangedSubscribed) - { - global::S1API.Utils.EventHelper.AddListener( - HandleTrashLevelChanged, - S1TrashContainer.onTrashLevelChanged); - _trashLevelChangedSubscribed = true; - } - - _trashLevelChanged += value; + NativeTrashLevelChangedAction nativeHandler = CreateNativeTrashLevelChangedHandler(value); + SubscribeTrashLevelChanged(nativeHandler); + GetTrashLevelChangedRegistrationState().Registrations.Add(value, nativeHandler); } remove { - if (value == null) + if (value == null || !TryTakeTrashLevelChangedRegistration( + value, + out TrashLevelChangedRegistrationState state, + out NativeTrashLevelChangedAction nativeHandler)) return; - _trashLevelChanged -= value; - if (_trashLevelChanged != null || !_trashLevelChangedSubscribed) - return; + try + { + UnsubscribeTrashLevelChanged(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } - global::S1API.Utils.EventHelper.RemoveListener( - HandleTrashLevelChanged, - S1TrashContainer.onTrashLevelChanged); - _trashLevelChangedSubscribed = false; + if (state.Registrations.IsEmpty) + TrashLevelChangedRegistrations.Remove(S1TrashContainer.GetInstanceID()); } } @@ -223,10 +242,184 @@ public bool TryBagTrash() return true; } - private void HandleTrashAdded(string trashId) => - _trashAdded?.Invoke(trashId); + private static NativeTrashAddedAction CreateNativeTrashAddedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native trash-added delegate."); +#else + return trashId => handler(trashId); +#endif + } + + private static NativeTrashLevelChangedAction CreateNativeTrashLevelChangedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native trash-level delegate."); +#else + return () => handler(); +#endif + } + + private void SubscribeTrashAdded(NativeTrashAddedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashAdded.AddListener(handler); +#else + global::S1API.Utils.EventHelper.AddListener(handler, S1TrashContainer.onTrashAdded); +#endif + } + + private void UnsubscribeTrashAdded(NativeTrashAddedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashAdded.RemoveListener(handler); +#else + global::S1API.Utils.EventHelper.RemoveListener(handler, S1TrashContainer.onTrashAdded); +#endif + } + + private void SubscribeTrashLevelChanged(NativeTrashLevelChangedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashLevelChanged.AddListener(handler); +#else + global::S1API.Utils.EventHelper.AddListener(handler, S1TrashContainer.onTrashLevelChanged); +#endif + } + + private void UnsubscribeTrashLevelChanged(NativeTrashLevelChangedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashLevelChanged.RemoveListener(handler); +#else + global::S1API.Utils.EventHelper.RemoveListener(handler, S1TrashContainer.onTrashLevelChanged); +#endif + } + + private TrashAddedRegistrationState GetTrashAddedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TrashContainer.GetInstanceID(); + if (TrashAddedRegistrations.TryGetValue(instanceId, out TrashAddedRegistrationState? state)) + return state; + + state = new TrashAddedRegistrationState(S1TrashContainer); + TrashAddedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeTrashAddedRegistration( + Action managedHandler, + out TrashAddedRegistrationState state, + out NativeTrashAddedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (TrashAddedRegistrations.TryGetValue( + S1TrashContainer.GetInstanceID(), + out TrashAddedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = null!; + return false; + } + + private TrashLevelChangedRegistrationState GetTrashLevelChangedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TrashContainer.GetInstanceID(); + if (TrashLevelChangedRegistrations.TryGetValue( + instanceId, + out TrashLevelChangedRegistrationState? state)) + return state; + + state = new TrashLevelChangedRegistrationState(S1TrashContainer); + TrashLevelChangedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeTrashLevelChangedRegistration( + Action managedHandler, + out TrashLevelChangedRegistrationState state, + out NativeTrashLevelChangedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (TrashLevelChangedRegistrations.TryGetValue( + S1TrashContainer.GetInstanceID(), + out TrashLevelChangedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = null!; + return false; + } + + private static void PruneDestroyedRegistrationStates() + { + PruneDestroyedRegistrationStates(TrashAddedRegistrations); + PruneDestroyedRegistrationStates(TrashLevelChangedRegistrations); + } - private void HandleTrashLevelChanged() => - _trashLevelChanged?.Invoke(); + private static void PruneDestroyedRegistrationStates(Dictionary registrations) + where TState : TrashContainerRegistrationState + { + List? destroyedIds = null; + foreach (KeyValuePair registration in registrations) + { + if (registration.Value.S1TrashContainer != null) + continue; + + destroyedIds ??= new List(); + destroyedIds.Add(registration.Key); + } + + if (destroyedIds == null) + return; + + foreach (int destroyedId in destroyedIds) + registrations.Remove(destroyedId); + } + + private abstract class TrashContainerRegistrationState + { + internal S1Trash.TrashContainer S1TrashContainer { get; } + + protected TrashContainerRegistrationState(S1Trash.TrashContainer trashContainer) + { + S1TrashContainer = trashContainer; + } + } + + private sealed class TrashAddedRegistrationState : TrashContainerRegistrationState + { + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal TrashAddedRegistrationState(S1Trash.TrashContainer trashContainer) + : base(trashContainer) + { + } + } + + private sealed class TrashLevelChangedRegistrationState : TrashContainerRegistrationState + { + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal TrashLevelChangedRegistrationState(S1Trash.TrashContainer trashContainer) + : base(trashContainer) + { + } + } } }