using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Security.Cryptography; using UnityEngine; namespace ShrinkModFramework { internal static class ShrinkExternalModAssemblyLoader { internal sealed class ExternalAssemblyRevision { public string Path; public string Revision; public Assembly Assembly; public long LoadedBytes; } internal sealed class RevisionState { public Dictionary Current = new(StringComparer.OrdinalIgnoreCase); public Dictionary Known = new(StringComparer.OrdinalIgnoreCase); } private static readonly Dictionary CurrentAssemblyRevisions = new(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary ExternalAssemblyHistory = new(); private static readonly Dictionary KnownAssemblyFiles = new(StringComparer.OrdinalIgnoreCase); private static readonly object ResolveLock = new(); private static bool _resolveRegistered; private static int _lastWarnedResidentCount; public static IReadOnlyList LoadExternalAssemblies( ShrinkModFrameworkSettings settings, bool verboseLogging, IEnumerable authorizedDllPaths = null) { return ScanExternalAssemblyRevisions(settings, verboseLogging, authorizedDllPaths) .Select(revision => revision.Assembly) .ToArray(); } internal static IReadOnlyList ScanExternalAssemblyRevisions( ShrinkModFrameworkSettings settings, bool verboseLogging, IEnumerable authorizedDllPaths = null) { if (settings == null || !settings.enableExternalDllMods) { CurrentAssemblyRevisions.Clear(); KnownAssemblyFiles.Clear(); return Array.Empty(); } #if ENABLE_IL2CPP && !UNITY_EDITOR Debug.LogWarning("[ShrinkModFramework] IL2CPP 运行时不支持外部 DLL 加载,已跳过授权模组。"); return Array.Empty(); #else RegisterAssemblyResolve(); var dllPaths = NormalizeAuthorizedPaths(authorizedDllPaths); var presentPaths = new HashSet( dllPaths.Where(File.Exists), StringComparer.OrdinalIgnoreCase); KnownAssemblyFiles.Clear(); foreach (var dllPath in presentPaths) KnownAssemblyFiles[Path.GetFileNameWithoutExtension(dllPath)] = dllPath; foreach (var dllPath in presentPaths .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) { try { var bytes = File.ReadAllBytes(dllPath); var revision = ComputeSha256(bytes); if (CurrentAssemblyRevisions.TryGetValue(dllPath, out var current) && string.Equals(current.Revision, revision, StringComparison.Ordinal)) { continue; } var pdbPath = Path.ChangeExtension(dllPath, ".pdb"); byte[] pdbBytes = null; if (File.Exists(pdbPath)) pdbBytes = File.ReadAllBytes(pdbPath); var assembly = pdbBytes != null ? Assembly.Load(bytes, pdbBytes) : Assembly.Load(bytes); var loadedRevision = new ExternalAssemblyRevision { Path = dllPath, Revision = revision, Assembly = assembly, LoadedBytes = bytes.LongLength + (pdbBytes?.LongLength ?? 0L) }; CurrentAssemblyRevisions[dllPath] = loadedRevision; ExternalAssemblyHistory[assembly] = loadedRevision; if (verboseLogging) Debug.Log($"[ShrinkModFramework] 已加载外部模组程序集:{assembly.GetName().Name} ({revision[..12]})"); } catch (Exception ex) { Debug.LogError($"[ShrinkModFramework] 加载外部 DLL 失败:{dllPath}\n{ex.Message}"); } } WarnIfResidentLimitExceeded(settings); foreach (var missingPath in CurrentAssemblyRevisions.Keys .Where(path => !presentPaths.Contains(path)) .ToArray()) { CurrentAssemblyRevisions.Remove(missingPath); var assemblyName = Path.GetFileNameWithoutExtension(missingPath); if (KnownAssemblyFiles.TryGetValue(assemblyName, out var knownPath) && string.Equals(knownPath, missingPath, StringComparison.OrdinalIgnoreCase)) { KnownAssemblyFiles.Remove(assemblyName); } } return CurrentAssemblyRevisions.Values .OrderBy(revision => revision.Path, StringComparer.OrdinalIgnoreCase) .ToArray(); #endif } private static string[] NormalizeAuthorizedPaths(IEnumerable authorizedDllPaths) { if (authorizedDllPaths == null) return Array.Empty(); var normalized = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var rawPath in authorizedDllPaths) { if (string.IsNullOrWhiteSpace(rawPath)) continue; string fullPath; try { fullPath = Path.GetFullPath(rawPath.Trim()); } catch (Exception exception) { throw new ArgumentException($"授权 DLL 路径无效:{rawPath}", nameof(authorizedDllPaths), exception); } if (!string.Equals(Path.GetExtension(fullPath), ".dll", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException($"授权路径不是 DLL:{fullPath}", nameof(authorizedDllPaths)); } normalized.Add(fullPath); } return normalized.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray(); } internal static bool IsExternalAssembly(Assembly assembly) => assembly != null && ExternalAssemblyHistory.ContainsKey(assembly); internal static ShrinkExternalAssemblyDiagnostic CaptureDiagnostic(int softLimit) { var normalizedLimit = Math.Max(1, softLimit); var currentAssemblies = new HashSet( CurrentAssemblyRevisions.Values.Select(item => item.Assembly)); var revisions = ExternalAssemblyHistory.Values .OrderBy(item => item.Path, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.Revision, StringComparer.Ordinal) .Select(item => new ShrinkExternalAssemblyRevisionDiagnostic( item.Path, item.Revision, item.Assembly.GetName().Name ?? string.Empty, item.LoadedBytes, currentAssemblies.Contains(item.Assembly))) .ToArray(); return new ShrinkExternalAssemblyDiagnostic( CurrentAssemblyRevisions.Count, revisions, revisions.Sum(item => item.LoadedBytes), normalizedLimit); } internal static RevisionState CaptureState() { return new RevisionState { Current = CurrentAssemblyRevisions.ToDictionary( pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase), Known = KnownAssemblyFiles.ToDictionary( pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase) }; } internal static void RestoreState(RevisionState state) { if (state == null) throw new ArgumentNullException(nameof(state)); CurrentAssemblyRevisions.Clear(); foreach (var pair in state.Current) CurrentAssemblyRevisions[pair.Key] = pair.Value; KnownAssemblyFiles.Clear(); foreach (var pair in state.Known) KnownAssemblyFiles[pair.Key] = pair.Value; } internal static bool TryGetCurrentRevision(Assembly assembly, out string revision) { foreach (var current in CurrentAssemblyRevisions.Values) { if (ReferenceEquals(current.Assembly, assembly)) { revision = current.Revision; return true; } } revision = null; return false; } public static string GetExternalModsDirectory(ShrinkModFrameworkSettings settings) { var folderName = settings != null && !string.IsNullOrWhiteSpace(settings.externalModsFolderName) ? settings.externalModsFolderName.Trim() : "Mods"; return Path.Combine(Application.persistentDataPath, folderName); } internal static void ResetForTesting() { CurrentAssemblyRevisions.Clear(); KnownAssemblyFiles.Clear(); if (_resolveRegistered) { AppDomain.CurrentDomain.AssemblyResolve -= OnAssemblyResolve; _resolveRegistered = false; } } internal static void ResetForDomainReload() { ResetForTesting(); ExternalAssemblyHistory.Clear(); _lastWarnedResidentCount = 0; } private static void WarnIfResidentLimitExceeded(ShrinkModFrameworkSettings settings) { var limit = Math.Max(1, settings != null ? settings.externalAssemblyRevisionSoftLimit : 16); var residentCount = ExternalAssemblyHistory.Count; if (residentCount < limit || residentCount == _lastWarnedResidentCount) return; _lastWarnedResidentCount = residentCount; var bytes = ExternalAssemblyHistory.Values.Sum(item => item.LoadedBytes); Debug.LogWarning( $"[ShrinkModFramework] 外部 DLL 常驻 revision 已达到 {residentCount} 个(载入文件约 {bytes} bytes," + $"软阈值 {limit})。Mono 无法卸载这些 Assembly;建议在维护窗口执行 Domain Reload 或重启进程。"); } private static string ComputeSha256(byte[] bytes) { using var sha256 = SHA256.Create(); return BitConverter.ToString(sha256.ComputeHash(bytes)).Replace("-", string.Empty); } private static void RegisterAssemblyResolve() { lock (ResolveLock) { if (_resolveRegistered) return; AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; _resolveRegistered = true; } } private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args) { var requestedName = new AssemblyName(args.Name).Name; if (string.IsNullOrWhiteSpace(requestedName)) return null; if (!KnownAssemblyFiles.TryGetValue(requestedName, out var path) || !File.Exists(path)) return null; lock (ResolveLock) { try { var bytes = File.ReadAllBytes(path); var revision = ComputeSha256(bytes); if (CurrentAssemblyRevisions.TryGetValue(path, out var current) && string.Equals(current.Revision, revision, StringComparison.Ordinal)) { return current.Assembly; } var pdbPath = Path.ChangeExtension(path, ".pdb"); var pdbBytes = File.Exists(pdbPath) ? File.ReadAllBytes(pdbPath) : null; var assembly = pdbBytes != null ? Assembly.Load(bytes, pdbBytes) : Assembly.Load(bytes); var loadedRevision = new ExternalAssemblyRevision { Path = path, Revision = revision, Assembly = assembly, LoadedBytes = bytes.LongLength + (pdbBytes?.LongLength ?? 0L) }; CurrentAssemblyRevisions[path] = loadedRevision; ExternalAssemblyHistory[assembly] = loadedRevision; return assembly; } catch { return null; } } } } }