Repository navigation
Add read-only recycler registry and processing events #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Recycler>), method.ReturnType); | ||
|
|
||
| EventInfo[] events = type.GetEvents(); | ||
| Assert.Equal(2, events.Length); | ||
| Assert.All(events, signal => Assert.Equal(typeof(Action<Recycler>), 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<Recycler>(); | ||
| var starts = new List<Recycler>(); | ||
| var stops = new List<Recycler>(); | ||
| Action<Recycler> started = starts.Add; | ||
| Action<Recycler> 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| { | ||
| /// <summary>INTERNAL: Registers native recyclers and releases their managed event listeners.</summary> | ||
| [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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| { | ||
| /// <summary> | ||
| /// Provides read-only access to a native world recycler. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Use this wrapper while its native object exists in the loaded scene. Obtain a new | ||
| /// snapshot from <see cref="RecyclerRegistry.GetAll"/> after a scene change. | ||
| /// </remarks> | ||
| 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; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the current native state. Processing may continue to be reported after the stop signal. | ||
| /// </summary> | ||
| public RecyclerState State => | ||
| (RecyclerState)(int)S1Recycler.State; | ||
|
|
||
| /// <summary>Gets whether the native hatch is open.</summary> | ||
| public bool IsHatchOpen => | ||
| S1Recycler.IsHatchOpen; | ||
|
|
||
| /// <summary>Gets the recycler's world position.</summary> | ||
| public Vector3 Position => | ||
| S1Recycler.transform.position; | ||
|
|
||
| /// <summary>Gets the game object that owns the native recycler.</summary> | ||
| 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); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| { | ||
| /// <summary> | ||
| /// Discovers active native world recyclers and forwards their local processing signals. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// 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. | ||
| /// </remarks> | ||
| public static class RecyclerRegistry | ||
| { | ||
| private static readonly Log Logger = new Log("RecyclerRegistry"); | ||
| private static readonly Dictionary<int, Recycler> Recyclers = new Dictionary<int, Recycler>(); | ||
| private static bool _lifecycleHooked; | ||
|
|
||
| /// <summary>Raised when a recycler's native processing-start signal fires on this peer.</summary> | ||
| public static event Action<Recycler>? ProcessingStarted; | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| /// <remarks>Destroying a recycler or leaving the scene does not synthesize a stop signal.</remarks> | ||
| public static event Action<Recycler>? ProcessingStopped; | ||
|
|
||
| /// <summary> | ||
| /// Gets an immutable snapshot of active recyclers in the loaded scene, reusing wrappers for live native objects. | ||
| /// </summary> | ||
| /// <returns>A read-only list of recyclers. The list does not change when scene membership changes.</returns> | ||
| public static IReadOnlyList<Recycler> GetAll() | ||
| { | ||
| EnsureLifecycleHook(); | ||
| PruneDestroyedRecyclers(); | ||
| var nativeRecyclers = UnityEngine.Object.FindObjectsOfType<S1Recycler>(); | ||
| var recyclers = new List<Recycler>(nativeRecyclers.Length); | ||
| for (int index = 0; index < nativeRecyclers.Length; index++) | ||
| { | ||
| S1Recycler native = nativeRecyclers[index]; | ||
| if (native != null) | ||
| recyclers.Add(Wrap(native)); | ||
| } | ||
|
|
||
| return new ReadOnlyCollection<Recycler>(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<Recycler>? handlers, Recycler recycler, string eventName) | ||
| { | ||
| if (handlers == null) | ||
| return; | ||
|
|
||
| foreach (Action<Recycler> 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<int>? destroyedInstanceIds = null; | ||
| foreach (KeyValuePair<int, Recycler> pair in Recyclers) | ||
| { | ||
| if (pair.Value.S1Recycler != null) | ||
| continue; | ||
|
|
||
| pair.Value.Cleanup(); | ||
| destroyedInstanceIds ??= new List<int>(); | ||
| 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(); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| namespace S1API.Trash | ||
| { | ||
| /// <summary> | ||
| /// Describes the native world recycler's state. | ||
| /// </summary> | ||
| public enum RecyclerState | ||
| { | ||
| /// <summary>The recycler is waiting for its hatch to be opened.</summary> | ||
| HatchClosed = 0, | ||
|
|
||
| /// <summary>The hatch is open for trash insertion.</summary> | ||
| HatchOpen = 1, | ||
|
|
||
| /// <summary>The recycler is processing or waiting for cash collection.</summary> | ||
| Processing = 2 | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.