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) } } }