Skip to content
Closed
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
67 changes: 67 additions & 0 deletions S1API.Tests/Saveables/SaveableAutoRegistryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using S1API.Internal.Abstraction;
using S1API.Saveables;

namespace S1API.Tests.Saveables;

/// <summary>
/// Freezes which types <see cref="SaveableAutoRegistry"/> discovers and how it caches their instances.
/// </summary>
public sealed class SaveableAutoRegistryTests
{
/// <summary>
/// A direct, concrete <see cref="Saveable"/> subclass is registered; indirect and abstract subclasses and
/// S1API's own types are not.
/// </summary>
[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();
}
}

/// <summary>
/// Discovery creates each saveable once; later calls return the cached instance.
/// </summary>
[Fact]
public void DiscoveryReturnsTheSameInstanceOnRepeatedCalls()
{
SaveableAutoRegistry.ClearCache();
try
{
Saveable first = SaveableAutoRegistry.GetRegisteredSaveables().OfType<DirectSaveableShape>().Single();
Saveable second = SaveableAutoRegistry.GetRegisteredSaveables().OfType<DirectSaveableShape>().Single();

Assert.Same(first, second);
}
finally
{
SaveableAutoRegistry.ClearCache();
}
}

private sealed class DirectSaveableShape : Saveable
{
}

private abstract class AbstractSaveableShape : Saveable
{
}

private sealed class IndirectSaveableShape : AbstractSaveableShape
{
}
}
41 changes: 17 additions & 24 deletions S1API/Internal/Patches/ProductEffectPatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -293,42 +294,34 @@ private static bool ProductEffects_Prefix(
}
}

/// <summary>
/// Maps each NPC ID to the concrete S1API NPC wrapper type that declares it through a public static
/// <c>NPCId</c> string property. The first wrapper found for an ID wins; the map is built once and cached.
/// </summary>
private static Dictionary<string, Type> GetNpcWrapperTypeById()
{
if (_npcWrapperTypeById != null)
return _npcWrapperTypeById;

var map = new Dictionary<string, Type>(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<NPC>())
{
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;
Expand Down
67 changes: 16 additions & 51 deletions S1API/Saveables/SaveableAutoRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,71 +37,36 @@ public static IEnumerable<Saveable> GetRegisteredSaveables()
/// <summary>
/// Discovers all classes that inherit from Saveable but are not part of S1API.
/// </summary>
/// <remarks>
/// Goes through <see cref="ReflectionUtils.GetDerivedClasses{TBaseClass}"/>, 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.
/// </remarks>
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<Saveable>())
{
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<Type>().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)
}
}
}
Expand Down
Loading