using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.ModLoading.Framework; using StardewModdingAPI.Framework.ModLoading.Symbols; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading { /// Preprocesses and loads mod assemblies. internal class AssemblyLoader : IDisposable { /********* ** Fields *********/ /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; /// Whether to detect paranoid mode issues. private readonly bool ParanoidMode; /// Metadata for mapping assemblies to the current platform. private readonly PlatformAssemblyMap AssemblyMap; /// A type => assembly lookup for types which should be rewritten. private readonly IDictionary TypeAssemblies; /// A minimal assembly definition resolver which resolves references to known loaded assemblies. private readonly AssemblyDefinitionResolver AssemblyDefinitionResolver; /// Provides assembly symbol readers for Mono.Cecil. private readonly SymbolReaderProvider SymbolReaderProvider = new(); /// Provides assembly symbol writers for Mono.Cecil. private readonly SymbolWriterProvider SymbolWriterProvider = new(); /// The objects to dispose as part of this instance. private readonly HashSet Disposables = new(); /// Whether to rewrite mods for compatibility. private readonly bool RewriteMods; /********* ** Public methods *********/ /// Construct an instance. /// The current game platform. /// Encapsulates monitoring and logging. /// Whether to detect paranoid mode issues. /// Whether to rewrite mods for compatibility. public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode, bool rewriteMods) { this.Monitor = monitor; this.ParanoidMode = paranoidMode; this.RewriteMods = rewriteMods; this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform)); // init resolver this.AssemblyDefinitionResolver = this.TrackForDisposal(new AssemblyDefinitionResolver()); Constants.ConfigureAssemblyResolver(this.AssemblyDefinitionResolver); // generate type => assembly lookup for types which should be rewritten this.TypeAssemblies = new Dictionary(); foreach (Assembly assembly in this.AssemblyMap.Targets) { ModuleDefinition module = this.AssemblyMap.TargetModules[assembly]; foreach (TypeDefinition type in module.GetTypes()) { if (!type.IsPublic) continue; // no need to rewrite if (type.Namespace.Contains("<")) continue; // ignore assembly metadata this.TypeAssemblies[type.FullName] = assembly; } } } /// Preprocess and load an assembly. /// The mod for which the assembly is being loaded. /// The assembly file. /// Assume the mod is compatible, even if incompatible code is detected. /// Returns the rewrite metadata for the preprocessed assembly. /// An incompatible CIL instruction was found while rewriting the assembly. public Assembly Load(IModMetadata mod, FileInfo assemblyFile, bool assumeCompatible) { // get referenced local assemblies AssemblyParseResult[] assemblies; { HashSet visitedAssemblyNames = new HashSet( // don't try loading assemblies that are already loaded from assembly in AppDomain.CurrentDomain.GetAssemblies() let name = assembly.GetName().Name where name != null select name ); assemblies = this.GetReferencedLocalAssemblies(assemblyFile, visitedAssemblyNames, this.AssemblyDefinitionResolver).ToArray(); } // validate load if (!assemblies.Any() || assemblies[0].Status == AssemblyLoadStatus.Failed) { throw new SAssemblyLoadFailedException(!assemblyFile.Exists ? $"Could not load '{assemblyFile.FullName}' because it doesn't exist." : $"Could not load '{assemblyFile.FullName}'." ); } if (assemblies.Last().Status == AssemblyLoadStatus.AlreadyLoaded) // mod assembly is last in dependency order throw new SAssemblyLoadFailedException($"Could not load '{assemblyFile.FullName}' because it was already loaded. Do you have two copies of this mod?"); // rewrite & load assemblies in leaf-to-root order bool oneAssembly = assemblies.Length == 1; Assembly? lastAssembly = null; HashSet loggedMessages = new HashSet(); foreach (AssemblyParseResult assembly in assemblies) { if (!assembly.HasDefinition) continue; // rewrite assembly bool changed = this.RewriteAssembly(mod, assembly.Definition, loggedMessages, logPrefix: " "); // detect broken assembly reference foreach (AssemblyNameReference reference in assembly.Definition.MainModule.AssemblyReferences) { if (!reference.Name.StartsWith("System.") && !this.IsAssemblyLoaded(reference)) { this.Monitor.LogOnce(loggedMessages, $" Broken code in {assembly.File.Name}: reference to missing assembly '{reference.FullName}'."); if (!assumeCompatible) throw new IncompatibleInstructionException($"Found a reference to missing assembly '{reference.FullName}' while loading assembly {assembly.File.Name}."); mod.SetWarning(ModWarning.BrokenCodeLoaded); break; } } // load assembly if (changed) { if (!oneAssembly) this.Monitor.Log($" Loading {assembly.File.Name} (rewritten)..."); // load assembly using MemoryStream outAssemblyStream = new(); using MemoryStream outSymbolStream = new(); assembly.Definition.Write(outAssemblyStream, new WriterParameters { WriteSymbols = true, SymbolStream = outSymbolStream, SymbolWriterProvider = this.SymbolWriterProvider }); byte[] bytes = outAssemblyStream.ToArray(); lastAssembly = Assembly.Load(bytes, outSymbolStream.ToArray()); } else { if (!oneAssembly) this.Monitor.Log($" Loading {assembly.File.Name}..."); lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); } // track loaded assembly for definition resolution this.AssemblyDefinitionResolver.Add(assembly.Definition); } // special case: clear legacy-DLL warnings if the mod bundles a copy if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyCachingDll)) { if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Runtime.Caching.dll"))) mod.RemoveWarning(ModWarning.DetectedLegacyCachingDll); else { // remove duplicate warnings (System.Runtime.Caching.dll references these) mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll); mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll); } } if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyConfigurationDll)) { if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Configuration.ConfigurationManager.dll"))) mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll); } if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyPermissionsDll)) { if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Security.Permissions.dll"))) mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll); } // throw if incompatibilities detected if (!assumeCompatible && mod.Warnings.HasFlag(ModWarning.BrokenCodeLoaded)) throw new IncompatibleInstructionException(); // last assembly loaded is the root return lastAssembly!; } /// Get whether an assembly is loaded. /// The assembly name reference. public bool IsAssemblyLoaded(AssemblyNameReference reference) { try { _ = this.AssemblyDefinitionResolver.Resolve(reference); return true; } catch (AssemblyResolutionException) { return false; } } /// Resolve an assembly by its name. /// The assembly name. /// /// This implementation returns the first loaded assembly which matches the short form of /// the assembly name, to resolve assembly resolution issues when rewriting /// assemblies (especially with Mono). Since this is meant to be called on , /// the implicit assumption is that loading the exact assembly failed. /// public static Assembly? ResolveAssembly(string name) { string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture) return AppDomain.CurrentDomain .GetAssemblies() .FirstOrDefault(p => p.GetName().Name == shortName); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { foreach (IDisposable instance in this.Disposables) instance.Dispose(); } /********* ** Private methods *********/ /// Track an object for disposal as part of the assembly loader. /// The instance type. /// The disposable instance. private T TrackForDisposal(T instance) where T : IDisposable { this.Disposables.Add(instance); return instance; } /**** ** Assembly parsing ****/ /// Get a list of referenced local assemblies starting from the mod assembly, ordered from leaf to root. /// The assembly file to load. /// The assembly names that should be skipped. /// A resolver which resolves references to known assemblies. /// Returns the rewrite metadata for the preprocessed assembly. private IEnumerable GetReferencedLocalAssemblies(FileInfo file, HashSet visitedAssemblyNames, IAssemblyResolver assemblyResolver) { // validate if (file.Directory == null) throw new InvalidOperationException($"Could not get directory from file path '{file.FullName}'."); if (!file.Exists) yield break; // not a local assembly // read assembly AssemblyDefinition assembly; { byte[] assemblyBytes = File.ReadAllBytes(file.FullName); Stream readStream = this.TrackForDisposal(new MemoryStream(assemblyBytes)); try { // read assembly with symbols FileInfo symbolsFile = new(Path.Combine(Path.GetDirectoryName(file.FullName)!, Path.GetFileNameWithoutExtension(file.FullName)) + ".pdb"); if (symbolsFile.Exists) this.SymbolReaderProvider.TryAddSymbolData(file.Name, () => this.TrackForDisposal(symbolsFile.OpenRead())); assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true, ReadSymbols = true, SymbolReaderProvider = this.SymbolReaderProvider })); } catch (SymbolsNotMatchingException ex) { // read assembly without symbols this.Monitor.Log($" Failed loading PDB for '{file.Name}'. Technical details:\n{ex}"); readStream.Position = 0; assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true })); } } // skip if already visited if (visitedAssemblyNames.Contains(assembly.Name.Name)) { yield return new AssemblyParseResult(file, null, AssemblyLoadStatus.AlreadyLoaded); yield break; } visitedAssemblyNames.Add(assembly.Name.Name); // yield referenced assemblies foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences) { FileInfo dependencyFile = new(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll")); foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver)) yield return result; } // yield assembly yield return new AssemblyParseResult(file, assembly, AssemblyLoadStatus.Okay); } /**** ** Assembly rewriting ****/ /// Rewrite the types referenced by an assembly. /// The mod for which the assembly is being loaded. /// The assembly to rewrite. /// The messages that have already been logged for this mod. /// A string to prefix to log messages. /// Returns whether the assembly was modified. /// An incompatible CIL instruction was found while rewriting the assembly. private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet loggedMessages, string logPrefix) { ModuleDefinition module = assembly.MainModule; string filename = $"{assembly.Name.Name}.dll"; // swap assembly references if needed (e.g. XNA => MonoGame) bool platformChanged = false; if (this.RewriteMods) { for (int i = 0; i < module.AssemblyReferences.Count; i++) { // remove old assembly reference if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name)) { platformChanged = true; module.AssemblyReferences.RemoveAt(i); i--; this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewrote {filename} for OS..."); } } if (platformChanged) { // add target assembly references foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values) module.AssemblyReferences.Add(target); // rewrite type scopes to use target assemblies IEnumerable typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); foreach (TypeReference type in typeReferences) this.ChangeTypeScope(type); // rewrite types using custom attributes foreach (TypeDefinition type in module.GetTypes()) { foreach (CustomAttribute attr in type.CustomAttributes) { foreach (CustomAttributeArgument conField in attr.ConstructorArguments) { if (conField.Value is TypeReference typeRef) this.ChangeTypeScope(typeRef); } } } } } // find or rewrite code IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged, this.RewriteMods).ToArray(); RecursiveRewriter rewriter = new( module: module, rewriteModule: curModule => { bool rewritten = false; foreach (IInstructionHandler handler in handlers) rewritten |= handler.Handle(curModule); return rewritten; }, rewriteType: (type, replaceWith) => { bool rewritten = false; foreach (IInstructionHandler handler in handlers) rewritten |= handler.Handle(module, type, replaceWith); return rewritten; }, rewriteInstruction: (ref Instruction instruction, ILProcessor cil) => { bool rewritten = false; foreach (IInstructionHandler handler in handlers) rewritten |= handler.Handle(module, cil, instruction); return rewritten; } ); bool anyRewritten = rewriter.RewriteModule(); // handle rewrite flags foreach (IInstructionHandler handler in handlers) { foreach (var flag in handler.Flags) this.ProcessInstructionHandleResult(mod, handler, flag, loggedMessages, logPrefix, filename); } return platformChanged || anyRewritten; } /// Process the result from an instruction handler. /// The mod being analyzed. /// The instruction handler. /// The result returned by the handler. /// The messages already logged for the current mod. /// A string to prefix to log messages. /// The assembly filename for log messages. private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet loggedMessages, string logPrefix, string filename) { // get message template // ($phrase is replaced with the noun phrase or messages) string? template = null; switch (result) { case InstructionHandleResult.Rewritten: template = $"{logPrefix}Rewrote {filename} to fix $phrase..."; break; case InstructionHandleResult.NotCompatible: template = $"{logPrefix}Broken code in {filename}: $phrase."; mod.SetWarning(ModWarning.BrokenCodeLoaded); break; case InstructionHandleResult.DetectedGamePatch: template = $"{logPrefix}Detected game patcher in assembly {filename}."; // no need for phrase, which would confusingly be 'Harmony 1.x' here mod.SetWarning(ModWarning.PatchesGame); break; case InstructionHandleResult.DetectedSaveSerializer: template = $"{logPrefix}Detected possible save serializer change ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.ChangesSaveSerializer); break; case InstructionHandleResult.DetectedUnvalidatedUpdateTick: template = $"{logPrefix}Detected reference to $phrase in assembly {filename}."; mod.SetWarning(ModWarning.UsesUnvalidatedUpdateTick); break; case InstructionHandleResult.DetectedConsoleAccess: template = $"{logPrefix}Detected direct console access ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.AccessesConsole); break; case InstructionHandleResult.DetectedFilesystemAccess: template = $"{logPrefix}Detected filesystem access ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.AccessesFilesystem); break; case InstructionHandleResult.DetectedShellAccess: template = $"{logPrefix}Detected shell or process access ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.AccessesShell); break; case InstructionHandleResult.DetectedLegacyCachingDll: template = $"{logPrefix}Detected reference to System.Runtime.Caching.dll, which will be removed in SMAPI 4.0.0."; mod.SetWarning(ModWarning.DetectedLegacyCachingDll); break; case InstructionHandleResult.DetectedLegacyConfigurationDll: template = $"{logPrefix}Detected reference to System.Configuration.ConfigurationManager.dll, which will be removed in SMAPI 4.0.0."; mod.SetWarning(ModWarning.DetectedLegacyConfigurationDll); break; case InstructionHandleResult.DetectedLegacyPermissionsDll: template = $"{logPrefix}Detected reference to System.Security.Permissions.dll, which will be removed in SMAPI 4.0.0."; mod.SetWarning(ModWarning.DetectedLegacyPermissionsDll); break; case InstructionHandleResult.None: break; default: throw new NotSupportedException($"Unrecognized instruction handler result '{result}'."); } if (template == null) return; // format messages string phrase = handler.Phrases.Any() ? string.Join(", ", handler.Phrases) : handler.DefaultPhrase; this.Monitor.LogOnce(loggedMessages, template.Replace("$phrase", phrase)); } /// Get the correct reference to use for compatibility with the current platform. /// The type reference to rewrite. private void ChangeTypeScope(TypeReference? type) { // check skip conditions if (type == null || type.FullName.StartsWith("System.")) return; // get assembly if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly? assembly)) return; // replace scope AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly]; type.Scope = assemblyRef; } } }