From 9e5072ee9d00350dedfa1f5f844de0b3b7267827 Mon Sep 17 00:00:00 2001 From: "Diffuin[bot]" Date: Wed, 14 Oct 2026 00:36:23 +0000 Subject: [PATCH] chore(diffuin): address #297 --- S1API.Tests/Trash/RecyclerContractTests.cs | 91 +++++++++++++ S1API/Internal/Patches/RecyclerPatches.cs | 26 ++++ S1API/Trash/Recycler.cs | 97 ++++++++++++++ S1API/Trash/RecyclerRegistry.cs | 144 +++++++++++++++++++++ S1API/Trash/RecyclerState.cs | 17 +++ 5 files changed, 375 insertions(+) create mode 100644 S1API.Tests/Trash/RecyclerContractTests.cs create mode 100644 S1API/Internal/Patches/RecyclerPatches.cs create mode 100644 S1API/Trash/Recycler.cs create mode 100644 S1API/Trash/RecyclerRegistry.cs create mode 100644 S1API/Trash/RecyclerState.cs diff --git a/S1API.Tests/Trash/RecyclerContractTests.cs b/S1API.Tests/Trash/RecyclerContractTests.cs new file mode 100644 index 00000000..2e12f319 --- /dev/null +++ b/S1API.Tests/Trash/RecyclerContractTests.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using S1API.Trash; +using UnityEngine; + +#if (MONOMELON || MONOBEPINEX) +using S1Recycler = ScheduleOne.ObjectScripts.Recycler; +#else +using S1Recycler = Il2CppScheduleOne.ObjectScripts.Recycler; +#endif + +namespace S1API.Tests.Trash; + +public sealed class RecyclerContractTests +{ + [Theory] + [InlineData(RecyclerState.HatchClosed, S1Recycler.EState.HatchClosed, 0)] + [InlineData(RecyclerState.HatchOpen, S1Recycler.EState.HatchOpen, 1)] + [InlineData(RecyclerState.Processing, S1Recycler.EState.Processing, 2)] + public void ManagedStatePreservesNativeValues(RecyclerState managed, S1Recycler.EState native, int value) + { + Assert.Equal(value, (int)managed); + Assert.Equal(value, (int)native); + } + + [Fact] + public void RecyclerExposesOnlyReadOnlyWorldState() + { + Type type = typeof(Recycler); + Assert.True(type.IsSealed); + Assert.Empty(type.GetConstructors()); + Assert.Empty(type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)); + + PropertyInfo[] properties = type.GetProperties(); + Assert.Equal(4, properties.Length); + Assert.All(properties, property => Assert.Null(property.SetMethod)); + Assert.Equal(typeof(RecyclerState), type.GetProperty(nameof(Recycler.State))!.PropertyType); + Assert.Equal(typeof(bool), type.GetProperty(nameof(Recycler.IsHatchOpen))!.PropertyType); + Assert.Equal(typeof(Vector3), type.GetProperty(nameof(Recycler.Position))!.PropertyType); + Assert.Equal(typeof(GameObject), type.GetProperty(nameof(Recycler.GameObject))!.PropertyType); + Assert.Empty(type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(method => !method.IsSpecialName)); + } + + [Fact] + public void RegistryExposesSnapshotDiscoveryAndManagedSignals() + { + Type type = typeof(RecyclerRegistry); + MethodInfo method = Assert.Single(type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(method => !method.IsSpecialName)); + Assert.Equal(nameof(RecyclerRegistry.GetAll), method.Name); + Assert.Empty(method.GetParameters()); + Assert.Equal(typeof(IReadOnlyList), method.ReturnType); + + EventInfo[] events = type.GetEvents(); + Assert.Equal(2, events.Length); + Assert.All(events, signal => Assert.Equal(typeof(Action), signal.EventHandlerType)); + Assert.Contains(events, signal => signal.Name == nameof(RecyclerRegistry.ProcessingStarted)); + Assert.Contains(events, signal => signal.Name == nameof(RecyclerRegistry.ProcessingStopped)); + } + + [Fact] + public void ProcessingSignalsPreserveWrapperIdentityAndDoNotDeduplicate() + { + Recycler recycler = TestObjectFactory.CreateUninitialized(); + var starts = new List(); + var stops = new List(); + Action started = starts.Add; + Action stopped = stops.Add; + RecyclerRegistry.ProcessingStarted += started; + RecyclerRegistry.ProcessingStopped += stopped; + try + { + RecyclerRegistry.NotifyProcessingStarted(recycler); + RecyclerRegistry.NotifyProcessingStarted(recycler); + RecyclerRegistry.NotifyProcessingStopped(recycler); + Assert.Equal(2, starts.Count); + Assert.All(starts, observed => Assert.Same(recycler, observed)); + Assert.Same(recycler, Assert.Single(stops)); + } + finally + { + RecyclerRegistry.ProcessingStarted -= started; + RecyclerRegistry.ProcessingStopped -= stopped; + } + + RecyclerRegistry.NotifyProcessingStarted(recycler); + RecyclerRegistry.NotifyProcessingStopped(recycler); + Assert.Equal(2, starts.Count); + Assert.Single(stops); + } +} diff --git a/S1API/Internal/Patches/RecyclerPatches.cs b/S1API/Internal/Patches/RecyclerPatches.cs new file mode 100644 index 00000000..58dad6c2 --- /dev/null +++ b/S1API/Internal/Patches/RecyclerPatches.cs @@ -0,0 +1,26 @@ +#if (MONOMELON || MONOBEPINEX) +using S1Recycler = ScheduleOne.ObjectScripts.Recycler; +#else +using S1Recycler = Il2CppScheduleOne.ObjectScripts.Recycler; +#endif + +using HarmonyLib; +using S1API.Trash; + +namespace S1API.Internal.Patches +{ + /// INTERNAL: Registers native recyclers and releases their managed event listeners. + [HarmonyPatch] + internal static class RecyclerPatches + { + [HarmonyPostfix] + [HarmonyPatch(typeof(S1Recycler), nameof(S1Recycler.Start))] + private static void Started(S1Recycler __instance) => + RecyclerRegistry.Wrap(__instance); + + [HarmonyPrefix] + [HarmonyPatch(typeof(S1Recycler), "OnDestroy")] + private static void Destroying(S1Recycler __instance) => + RecyclerRegistry.Remove(__instance); + } +} diff --git a/S1API/Trash/Recycler.cs b/S1API/Trash/Recycler.cs new file mode 100644 index 00000000..24c08d5d --- /dev/null +++ b/S1API/Trash/Recycler.cs @@ -0,0 +1,97 @@ +#if (MONOMELON || MONOBEPINEX) +using S1Recycler = ScheduleOne.ObjectScripts.Recycler; +#else +using S1Recycler = Il2CppScheduleOne.ObjectScripts.Recycler; +#endif + +using UnityEngine; +using UnityEngine.Events; + +namespace S1API.Trash +{ + /// + /// Provides read-only access to a native world recycler. + /// + /// + /// Use this wrapper while its native object exists in the loaded scene. Obtain a new + /// snapshot from after a scene change. + /// + public sealed class Recycler + { + private readonly UnityEvent? _onStart; + private readonly UnityEvent? _onStop; + private readonly UnityAction _startListener; + private readonly UnityAction _stopListener; + private bool _listening; + + internal readonly S1Recycler S1Recycler; + + internal Recycler(S1Recycler recycler) + { + S1Recycler = recycler; + _onStart = recycler.onStart; + _onStop = recycler.onStop; +#if (MONOMELON || MONOBEPINEX) + _startListener = new UnityAction(NotifyProcessingStarted); + _stopListener = new UnityAction(NotifyProcessingStopped); +#else + // Retain the converted delegates so removal uses the same native targets. + _startListener = (UnityAction)NotifyProcessingStarted; + _stopListener = (UnityAction)NotifyProcessingStopped; +#endif + + try + { + if (_onStart != null) + _onStart.AddListener(_startListener); + if (_onStop != null) + _onStop.AddListener(_stopListener); + _listening = true; + } + catch + { + Cleanup(); + throw; + } + } + + /// + /// Gets the current native state. Processing may continue to be reported after the stop signal. + /// + public RecyclerState State => + (RecyclerState)(int)S1Recycler.State; + + /// Gets whether the native hatch is open. + public bool IsHatchOpen => + S1Recycler.IsHatchOpen; + + /// Gets the recycler's world position. + public Vector3 Position => + S1Recycler.transform.position; + + /// Gets the game object that owns the native recycler. + public GameObject GameObject => + S1Recycler.gameObject; + + internal void Cleanup() + { + _listening = false; + if (_onStart != null) + _onStart.RemoveListener(_startListener); + if (_onStop != null) + _onStop.RemoveListener(_stopListener); + } + + private void NotifyProcessingStarted() + { + if (_listening) + RecyclerRegistry.NotifyProcessingStarted(this); + } + + private void NotifyProcessingStopped() + { + if (_listening) + RecyclerRegistry.NotifyProcessingStopped(this); + } + } +} diff --git a/S1API/Trash/RecyclerRegistry.cs b/S1API/Trash/RecyclerRegistry.cs new file mode 100644 index 00000000..5a756849 --- /dev/null +++ b/S1API/Trash/RecyclerRegistry.cs @@ -0,0 +1,144 @@ +#if (MONOMELON || MONOBEPINEX) +using S1Recycler = ScheduleOne.ObjectScripts.Recycler; +#else +using S1Recycler = Il2CppScheduleOne.ObjectScripts.Recycler; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Lifecycle; +using S1API.Logging; + +namespace S1API.Trash +{ + /// + /// Discovers active native world recyclers and forwards their local processing signals. + /// + /// + /// This API observes the native flow without changing authority, replication, or persistence. + /// Events forward each native signal; they do not deduplicate signals or replay past processing. + /// + public static class RecyclerRegistry + { + private static readonly Log Logger = new Log("RecyclerRegistry"); + private static readonly Dictionary Recyclers = new Dictionary(); + private static bool _lifecycleHooked; + + /// Raised when a recycler's native processing-start signal fires on this peer. + public static event Action? ProcessingStarted; + + /// + /// Raised when a recycler's native processing-stop signal fires on this peer. + /// This signal does not imply a state change or that cash is ready for collection. + /// + /// Destroying a recycler or leaving the scene does not synthesize a stop signal. + public static event Action? ProcessingStopped; + + /// + /// Gets an immutable snapshot of active recyclers in the loaded scene, reusing wrappers for live native objects. + /// + /// A read-only list of recyclers. The list does not change when scene membership changes. + public static IReadOnlyList GetAll() + { + EnsureLifecycleHook(); + PruneDestroyedRecyclers(); + var nativeRecyclers = UnityEngine.Object.FindObjectsOfType(); + var recyclers = new List(nativeRecyclers.Length); + for (int index = 0; index < nativeRecyclers.Length; index++) + { + S1Recycler native = nativeRecyclers[index]; + if (native != null) + recyclers.Add(Wrap(native)); + } + + return new ReadOnlyCollection(recyclers); + } + + internal static Recycler Wrap(S1Recycler native) + { + EnsureLifecycleHook(); + int instanceId = native.GetInstanceID(); + if (Recyclers.TryGetValue(instanceId, out Recycler? recycler)) + { + if (recycler.S1Recycler != null) + return recycler; + + recycler.Cleanup(); + Recyclers.Remove(instanceId); + } + + recycler = new Recycler(native); + Recyclers.Add(instanceId, recycler); + return recycler; + } + + internal static void Remove(S1Recycler native) + { + int instanceId = native.GetInstanceID(); + if (!Recyclers.TryGetValue(instanceId, out Recycler? recycler)) + return; + + recycler.Cleanup(); + Recyclers.Remove(instanceId); + } + + internal static void NotifyProcessingStarted(Recycler recycler) => + Invoke(ProcessingStarted, recycler, nameof(ProcessingStarted)); + + internal static void NotifyProcessingStopped(Recycler recycler) => + Invoke(ProcessingStopped, recycler, nameof(ProcessingStopped)); + + private static void Invoke(Action? handlers, Recycler recycler, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(recycler); } + catch (Exception exception) + { + Logger.Warning($"A RecyclerRegistry.{eventName} subscriber failed: {exception.Message}"); + } + } + } + + private static void EnsureLifecycleHook() + { + if (_lifecycleHooked) + return; + + GameLifecycle.OnPreSceneChange += ClearSceneState; + _lifecycleHooked = true; + } + + private static void PruneDestroyedRecyclers() + { + List? destroyedInstanceIds = null; + foreach (KeyValuePair pair in Recyclers) + { + if (pair.Value.S1Recycler != null) + continue; + + pair.Value.Cleanup(); + destroyedInstanceIds ??= new List(); + destroyedInstanceIds.Add(pair.Key); + } + + if (destroyedInstanceIds == null) + return; + + foreach (int instanceId in destroyedInstanceIds) + Recyclers.Remove(instanceId); + } + + private static void ClearSceneState() + { + foreach (Recycler recycler in Recyclers.Values) + recycler.Cleanup(); + + Recyclers.Clear(); + } + } +} diff --git a/S1API/Trash/RecyclerState.cs b/S1API/Trash/RecyclerState.cs new file mode 100644 index 00000000..621f8095 --- /dev/null +++ b/S1API/Trash/RecyclerState.cs @@ -0,0 +1,17 @@ +namespace S1API.Trash +{ + /// + /// Describes the native world recycler's state. + /// + public enum RecyclerState + { + /// The recycler is waiting for its hatch to be opened. + HatchClosed = 0, + + /// The hatch is open for trash insertion. + HatchOpen = 1, + + /// The recycler is processing or waiting for cash collection. + Processing = 2 + } +}