From d5084d31d9e5a31629f34fe5bc142e77dd45785b Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:47:31 +0100 Subject: [PATCH 1/2] perf(reflection): keep derived-type scans until an assembly loads GetDerivedClasses scanned every eligible assembly on each call, and custom NPC readiness calls it once per custom NPC. Results per base type are now kept per ordinary assembly until another assembly loads. Dynamic assemblies can gain types without a load event, so they stay in the plan and are re-scanned (and their eligibility re-checked) on every call. Each call returns a new list. Refs #351 Co-Authored-By: Claude Opus 5.5 --- .../Internal/Utils/ReflectionUtilsTests.cs | 40 +++++ S1API/Internal/Utils/ReflectionUtils.cs | 143 ++++++++++++++---- 2 files changed, 153 insertions(+), 30 deletions(-) diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index 0d8afed6..aac0c657 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -58,6 +58,46 @@ public void GetDerivedClassesFindsTypesInReferencingAssemblies() ReflectionUtils.GetDerivedClasses()); } + [Fact] + public void GetDerivedClassesReturnsAFreshListEachCall() + { + List first = ReflectionUtils.GetDerivedClasses(); + first.Clear(); + + Assert.Contains(typeof(DerivedLogShape), ReflectionUtils.GetDerivedClasses()); + Assert.NotSame(ReflectionUtils.GetDerivedClasses(), ReflectionUtils.GetDerivedClasses()); + } + + [Fact] + public void GetDerivedClassesGivesTheSameTypesAfterInvalidation() + { + var before = ReflectionUtils.GetDerivedClasses(); + ReflectionUtils.InvalidateDerivedClassCache(); + var after = ReflectionUtils.GetDerivedClasses(); + + Assert.Equal(before.OrderBy(t => t.FullName), after.OrderBy(t => t.FullName)); + } + + [Fact] + public void GetDerivedClassesSeesTypesAddedToADynamicAssemblyAfterCaching() + { + var name = new AssemblyName($"S1API.ReflectionUtilsTests.Late.{Guid.NewGuid():N}"); + var assembly = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(name.Name!); + ReflectionUtils.GetDerivedClasses(); // cache built while the dynamic assembly has no types + + TypeBuilder builder = module.DefineType("LateLog", TypeAttributes.Public | TypeAttributes.Class, typeof(Log)); + var ctor = builder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var il = ctor.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldstr, "late"); + il.Emit(OpCodes.Call, typeof(Log).GetConstructor(new[] { typeof(string) })!); + il.Emit(OpCodes.Ret); + Type late = builder.CreateType()!; + + Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); + } + [Fact] public void DerivedTypeScanExcludesAssembliesWithoutAReferencePathToTheBaseAssembly() { diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index a2ec62db..4c21dec7 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -30,48 +30,131 @@ internal static class ReflectionUtils /// A list of all types derived from the base class. internal static List GetDerivedClasses() { - List derivedClasses = new List(); Type baseType = typeof(TBaseClass); + ScanPlan? plan; + long generation; + lock (DerivedClassCacheLock) + { + DerivedClassCache.TryGetValue(baseType, out plan); + generation = _assemblyGeneration; + } + + if (plan == null) + { + plan = BuildScanPlan(baseType); + lock (DerivedClassCacheLock) + { + // Not stored if an assembly loaded while scanning. + if (generation == _assemblyGeneration) + DerivedClassCache[baseType] = plan; + } + } + + // A fresh list every call: some callers sort or modify the result. + var derivedClasses = new List(); + IReadOnlyDictionary? index = null; + foreach (ScanPlanEntry entry in plan.Entries) + { + if (entry.Types != null) + { + derivedClasses.AddRange(entry.Types); + } + else if (CanContainTypesDerivedFrom(entry.Assembly, baseType.Assembly.GetName(), + index ??= IndexAssembliesBySimpleName(AppDomain.CurrentDomain.GetAssemblies()))) + { + AddDerivedTypes(baseType, entry.Assembly, derivedClasses); + } + } + return derivedClasses; + } + + // Ordinary assemblies' results are kept until another assembly loads. Dynamic assemblies (every modded game + // has some) can gain types without an AssemblyLoad event, so they are re-scanned on every call. + private sealed class ScanPlanEntry + { + internal Assembly Assembly = null!; + internal Type[]? Types; // null: dynamic, scanned per call + } + + private sealed class ScanPlan + { + internal ScanPlanEntry[] Entries = Array.Empty(); + } + + private static readonly object DerivedClassCacheLock = new object(); + private static readonly Dictionary DerivedClassCache = new Dictionary(); + private static long _assemblyGeneration; + + static ReflectionUtils() + { + AppDomain.CurrentDomain.AssemblyLoad += (_, _) => InvalidateDerivedClassCache(); + } + + internal static void InvalidateDerivedClassCache() + { + lock (DerivedClassCacheLock) + { + _assemblyGeneration++; + DerivedClassCache.Clear(); + } + } + + private static ScanPlan BuildScanPlan(Type baseType) + { Assembly baseAssembly = baseType.Assembly; Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); IReadOnlyDictionary assembliesBySimpleName = IndexAssembliesBySimpleName(loadedAssemblies); - Assembly[] applicableAssemblies = loadedAssemblies - .Where(assembly => assembly == baseAssembly || !ShouldSkipAssembly(assembly)) - .Where(assembly => CanContainTypesDerivedFrom( - assembly, - baseAssembly.GetName(), - assembliesBySimpleName)) - .ToArray(); + var entries = new List(); + int scanned = 0; + foreach (Assembly assembly in loadedAssemblies) + { + if (assembly != baseAssembly && ShouldSkipAssembly(assembly)) + continue; + if (assembly.IsDynamic) + { + // Kept even if it can't contain derived types yet; eligibility is re-checked per call. + entries.Add(new ScanPlanEntry { Assembly = assembly }); + continue; + } + if (!CanContainTypesDerivedFrom(assembly, baseAssembly.GetName(), assembliesBySimpleName)) + continue; + var types = new List(); + AddDerivedTypes(baseType, assembly, types); + entries.Add(new ScanPlanEntry { Assembly = assembly, Types = types.ToArray() }); + scanned++; + } Logger.Debug( - $"[S1API][Reflection] Scanning {applicableAssemblies.Length} of {loadedAssemblies.Length} " + - $"loaded assemblies for types derived from '{baseType.FullName}'."); + $"[S1API][Reflection] Scanned {scanned} of {loadedAssemblies.Length} loaded assemblies for types " + + $"derived from '{baseType.FullName}'."); + return new ScanPlan { Entries = entries.ToArray() }; + } - foreach (Assembly assembly in applicableAssemblies) - foreach (Type type in SafeGetTypes(assembly)) + private static void AddDerivedTypes(Type baseType, Assembly assembly, List derivedClasses) + { + foreach (Type type in SafeGetTypes(assembly)) + { + try { - try - { - if (type == null) - continue; - if (baseType.IsAssignableFrom(type) - && type != baseType - && !type.IsAbstract) - { - derivedClasses.Add(type); - } - } - catch (TypeLoadException) - { - // Ideally, we'd log this, but can be noisy and we've got no logger elsewhere - } - catch (Exception) + if (type == null) + continue; + if (baseType.IsAssignableFrom(type) + && type != baseType + && !type.IsAbstract) { - // Catch-all for anything else (e.g., MissingMethodException) + derivedClasses.Add(type); } } - return derivedClasses; + catch (TypeLoadException) + { + // Ideally, we'd log this, but can be noisy and we've got no logger elsewhere + } + catch (Exception) + { + // Catch-all for anything else (e.g., MissingMethodException) + } + } } internal static bool CanContainTypesDerivedFrom( From ca4da6ff34587a34a2a6c50da75d2554dad06d16 Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 20:28:21 -0700 Subject: [PATCH 2/2] test(reflection): cover assembly load cache invalidation --- .../Internal/Utils/ReflectionUtilsTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index aac0c657..2b2ce6ad 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -98,6 +98,25 @@ public void GetDerivedClassesSeesTypesAddedToADynamicAssemblyAfterCaching() Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); } + [Fact] + public void GetDerivedClassesSeesAnAssemblyLoadedAfterCaching() + { + ReflectionUtils.GetDerivedClasses(); + var name = new AssemblyName($"S1API.ReflectionUtilsTests.NewAssembly.{Guid.NewGuid():N}"); + var assembly = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(name.Name!); + TypeBuilder builder = module.DefineType("NewAssemblyLog", TypeAttributes.Public | TypeAttributes.Class, typeof(Log)); + var ctor = builder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var il = ctor.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldstr, "new-assembly"); + il.Emit(OpCodes.Call, typeof(Log).GetConstructor(new[] { typeof(string) })!); + il.Emit(OpCodes.Ret); + Type late = builder.CreateType()!; + + Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); + } + [Fact] public void DerivedTypeScanExcludesAssembliesWithoutAReferencePathToTheBaseAssembly() {