diff --git a/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs b/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs
new file mode 100644
index 00000000..b9aaa8ca
--- /dev/null
+++ b/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs
@@ -0,0 +1,67 @@
+using S1API.Internal.Abstraction;
+using S1API.Saveables;
+
+namespace S1API.Tests.Saveables;
+
+///
+/// Freezes which types discovers and how it caches their instances.
+///
+public sealed class SaveableAutoRegistryTests
+{
+ ///
+ /// A direct, concrete subclass is registered; indirect and abstract subclasses and
+ /// S1API's own types are not.
+ ///
+ [Fact]
+ public void DiscoveryRegistersOnlyDirectConcreteSaveablesOutsideS1Api()
+ {
+ SaveableAutoRegistry.ClearCache();
+ try
+ {
+ Type[] registered = SaveableAutoRegistry.GetRegisteredSaveables()
+ .Select(saveable => saveable.GetType())
+ .ToArray();
+
+ Assert.Contains(typeof(DirectSaveableShape), registered);
+ Assert.DoesNotContain(typeof(IndirectSaveableShape), registered);
+ Assert.DoesNotContain(typeof(AbstractSaveableShape), registered);
+ Assert.DoesNotContain(registered, type => type.Assembly == typeof(Saveable).Assembly);
+ }
+ finally
+ {
+ SaveableAutoRegistry.ClearCache();
+ }
+ }
+
+ ///
+ /// Discovery creates each saveable once; later calls return the cached instance.
+ ///
+ [Fact]
+ public void DiscoveryReturnsTheSameInstanceOnRepeatedCalls()
+ {
+ SaveableAutoRegistry.ClearCache();
+ try
+ {
+ Saveable first = SaveableAutoRegistry.GetRegisteredSaveables().OfType().Single();
+ Saveable second = SaveableAutoRegistry.GetRegisteredSaveables().OfType().Single();
+
+ Assert.Same(first, second);
+ }
+ finally
+ {
+ SaveableAutoRegistry.ClearCache();
+ }
+ }
+
+ private sealed class DirectSaveableShape : Saveable
+ {
+ }
+
+ private abstract class AbstractSaveableShape : Saveable
+ {
+ }
+
+ private sealed class IndirectSaveableShape : AbstractSaveableShape
+ {
+ }
+}
diff --git a/S1API/Internal/Patches/ProductEffectPatches.cs b/S1API/Internal/Patches/ProductEffectPatches.cs
index e2e6c113..3cabdf05 100644
--- a/S1API/Internal/Patches/ProductEffectPatches.cs
+++ b/S1API/Internal/Patches/ProductEffectPatches.cs
@@ -17,6 +17,7 @@
using HarmonyLib;
using S1API.Entities;
using S1API.Internal.Products;
+using S1API.Internal.Utils;
using S1API.Logging;
using S1API.Properties;
using S1API.Products;
@@ -293,42 +294,34 @@ private static bool ProductEffects_Prefix(
}
}
+ ///
+ /// Maps each NPC ID to the concrete S1API NPC wrapper type that declares it through a public static
+ /// NPCId string property. The first wrapper found for an ID wins; the map is built once and cached.
+ ///
private static Dictionary GetNpcWrapperTypeById()
{
if (_npcWrapperTypeById != null)
return _npcWrapperTypeById;
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
- var npcBaseType = typeof(NPC);
- foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ // Only assemblies that reference S1API can hold an NPC wrapper. Enumerating every assembly reaches the
+ // IL2CPP interop assemblies, where GetTypes() can end the process without an exception to catch.
+ foreach (var type in ReflectionUtils.GetDerivedClasses())
{
- Type[] types;
- try
- {
- types = assembly.GetTypes();
- }
- catch
- {
+ if (type == null || type.IsAbstract)
continue;
- }
-
- foreach (var type in types)
- {
- if (type == null || type.IsAbstract || !npcBaseType.IsAssignableFrom(type))
- continue;
- var npcIdProperty = type.GetProperty("NPCId", BindingFlags.Public | BindingFlags.Static);
- if (npcIdProperty == null || npcIdProperty.PropertyType != typeof(string))
- continue;
+ var npcIdProperty = type.GetProperty("NPCId", BindingFlags.Public | BindingFlags.Static);
+ if (npcIdProperty == null || npcIdProperty.PropertyType != typeof(string))
+ continue;
- var npcId = npcIdProperty.GetValue(null) as string;
- if (string.IsNullOrWhiteSpace(npcId))
- continue;
+ var npcId = npcIdProperty.GetValue(null) as string;
+ if (string.IsNullOrWhiteSpace(npcId))
+ continue;
- if (!map.ContainsKey(npcId))
- map[npcId] = type;
- }
+ if (!map.ContainsKey(npcId))
+ map[npcId] = type;
}
_npcWrapperTypeById = map;
diff --git a/S1API/Saveables/SaveableAutoRegistry.cs b/S1API/Saveables/SaveableAutoRegistry.cs
index b22a7bfa..a0228762 100644
--- a/S1API/Saveables/SaveableAutoRegistry.cs
+++ b/S1API/Saveables/SaveableAutoRegistry.cs
@@ -37,71 +37,36 @@ public static IEnumerable GetRegisteredSaveables()
///
/// Discovers all classes that inherit from Saveable but are not part of S1API.
///
+ ///
+ /// Goes through , which only enumerates assemblies
+ /// that reference S1API. A name-prefix filter is not enough on IL2CPP: Assembly-CSharp and
+ /// Assembly-CSharp-firstpass match no prefix, and Assembly.GetTypes() on an interop assembly can end the
+ /// process with an uncatchable ExecutionEngineException (0x80131506) on the first save load.
+ ///
private static void DiscoverSaveableTypes()
{
try
{
- Assembly s1ApiAssembly = Assembly.GetExecutingAssembly();
-
- foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ // Skip S1API's own types to avoid registering internal classes like NPC
+ Assembly s1ApiAssembly = typeof(Saveable).Assembly;
+
+ foreach (Type type in ReflectionUtils.GetDerivedClasses())
{
try
{
- // Skip S1API assembly to avoid registering internal classes like NPC
- if (assembly == s1ApiAssembly)
- continue;
-
- // Skip system assemblies and known problematic assemblies
- if (assembly.FullName?.StartsWith("System") == true ||
- assembly.FullName?.StartsWith("Unity") == true ||
- assembly.FullName?.StartsWith("mscorlib") == true ||
- assembly.FullName?.StartsWith("netstandard") == true ||
- assembly.FullName?.StartsWith("Microsoft") == true ||
- assembly.FullName?.StartsWith("Il2Cpp") == true ||
- assembly.FullName?.StartsWith("MelonLoader") == true ||
- assembly.FullName?.StartsWith("0Harmony") == true ||
- assembly.IsDynamic)
- continue;
-
- Type[] types;
- try
- {
- types = assembly.GetTypes();
- }
- catch (ReflectionTypeLoadException ex)
- {
- // Use successfully loaded types if some failed to load
- types = ex.Types.OfType().ToArray();
- }
- catch
- {
- // Skip assemblies that can't have their types loaded
+ if (type == null || type.Assembly == s1ApiAssembly
+ || type.IsInterface || type.IsGenericTypeDefinition)
continue;
- }
- foreach (Type type in types)
+ // Check if this type directly inherits from Saveable
+ if (IsDirectSaveableInheritor(type))
{
- try
- {
- // Additional safety checks
- if (type == null || type.IsAbstract || type.IsInterface || type.IsGenericTypeDefinition)
- continue;
-
- // Check if this type directly inherits from Saveable
- if (IsDirectSaveableInheritor(type))
- {
- _discoveredSaveableTypes.Add(type);
- }
- }
- catch
- {
- // Skip types that can't be analyzed (e.g., generic types with constraints)
- }
+ _discoveredSaveableTypes.Add(type);
}
}
catch
{
- // Skip assemblies that can't be analyzed
+ // Skip types that can't be analyzed (e.g., generic types with constraints)
}
}
}