Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions S1API.Tests/Trash/RecyclerContractTests.cs
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);
}
}
26 changes: 26 additions & 0 deletions S1API/Internal/Patches/RecyclerPatches.cs
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);
}
}
97 changes: 97 additions & 0 deletions S1API/Trash/Recycler.cs
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 =>
Comment thread
ifBars marked this conversation as resolved.
(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);
}
}
}
144 changes: 144 additions & 0 deletions S1API/Trash/RecyclerRegistry.cs
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();
}
}
}
17 changes: 17 additions & 0 deletions S1API/Trash/RecyclerState.cs
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
}
}
Loading