From 3ed3ea66a846080b69bd3d7192262b903c61f275 Mon Sep 17 00:00:00 2001 From: DooDesch Date: Thu, 15 Oct 2026 18:47:47 +0200 Subject: [PATCH 1/2] fix(saveables): keep saveable discovery out of the interop assemblies SaveableAutoRegistry.DiscoverSaveableTypes skipped assemblies by FullName prefix. On IL2CPP, Assembly-CSharp and Assembly-CSharp-firstpass match none of the prefixes, so the first save load of every session called Assembly.GetTypes() on both interop assemblies. That can end the process with an uncatchable ExecutionEngineException (0x80131506) inside BeforeBaseLoaders. Discovery now goes through ReflectionUtils.GetDerivedClasses(), which only enumerates assemblies that reference S1API and already skips Assembly-CSharp. A direct Saveable subclass has to reference S1API, so the result is the same set of types. GetNpcWrapperTypeById walked every loaded assembly with no filter at all; it now uses GetDerivedClasses() for the same reason. --- .../Saveables/SaveableAutoRegistryTests.cs | 57 ++++++++++++++++ .../Internal/Patches/ProductEffectPatches.cs | 37 ++++------ S1API/Saveables/SaveableAutoRegistry.cs | 67 +++++-------------- 3 files changed, 86 insertions(+), 75 deletions(-) create mode 100644 S1API.Tests/Saveables/SaveableAutoRegistryTests.cs diff --git a/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs b/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs new file mode 100644 index 00000000..3f4a5435 --- /dev/null +++ b/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs @@ -0,0 +1,57 @@ +using S1API.Internal.Abstraction; +using S1API.Saveables; + +namespace S1API.Tests.Saveables; + +public sealed class SaveableAutoRegistryTests +{ + [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(); + } + } + + [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..29cc4d6d 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; @@ -299,36 +300,24 @@ private static Dictionary GetNpcWrapperTypeById() 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) } } } From efe2667395035f98004beec4472b5b9e7b8e8975 Mon Sep 17 00:00:00 2001 From: DooDesch Date: Thu, 15 Oct 2026 19:28:25 +0200 Subject: [PATCH 2/2] docs(saveables): document npc wrapper lookup and discovery tests --- S1API.Tests/Saveables/SaveableAutoRegistryTests.cs | 10 ++++++++++ S1API/Internal/Patches/ProductEffectPatches.cs | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs b/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs index 3f4a5435..b9aaa8ca 100644 --- a/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs +++ b/S1API.Tests/Saveables/SaveableAutoRegistryTests.cs @@ -3,8 +3,15 @@ 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() { @@ -26,6 +33,9 @@ public void DiscoveryRegistersOnlyDirectConcreteSaveablesOutsideS1Api() } } + /// + /// Discovery creates each saveable once; later calls return the cached instance. + /// [Fact] public void DiscoveryReturnsTheSameInstanceOnRepeatedCalls() { diff --git a/S1API/Internal/Patches/ProductEffectPatches.cs b/S1API/Internal/Patches/ProductEffectPatches.cs index 29cc4d6d..3cabdf05 100644 --- a/S1API/Internal/Patches/ProductEffectPatches.cs +++ b/S1API/Internal/Patches/ProductEffectPatches.cs @@ -294,6 +294,10 @@ 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)