using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.AssemblyRewriters; namespace StardewModdingAPI.Framework { /// Preprocesses and loads mod assemblies. internal class AssemblyLoader { /********* ** Properties *********/ /// 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; /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; /********* ** Public methods *********/ /// Construct an instance. /// The current game platform. /// Encapsulates monitoring and logging. public AssemblyLoader(Platform targetPlatform, IMonitor monitor) { this.Monitor = monitor; this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform); // 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 assembly file path. /// 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(string assemblyPath, bool assumeCompatible) { // get referenced local assemblies AssemblyParseResult[] assemblies; { AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver(); HashSet visitedAssemblyNames = new HashSet(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, resolver).ToArray(); if (!assemblies.Any()) throw new InvalidOperationException($"Could not load '{assemblyPath}' because it doesn't exist."); resolver.Add(assemblies.Select(p => p.Definition).ToArray()); } // rewrite & load assemblies in leaf-to-root order Assembly lastAssembly = null; foreach (AssemblyParseResult assembly in assemblies) { bool changed = this.RewriteAssembly(assembly.Definition, assumeCompatible); if (changed) { this.Monitor.Log($"Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); using (MemoryStream outStream = new MemoryStream()) { assembly.Definition.Write(outStream); byte[] bytes = outStream.ToArray(); lastAssembly = Assembly.Load(bytes); } } else { this.Monitor.Log($"Loading {assembly.File.Name}...", LogLevel.Trace); lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); } } // last assembly loaded is the root return lastAssembly; } /// 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 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); } /********* ** Private methods *********/ /**** ** 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 byte[] assemblyBytes = File.ReadAllBytes(file.FullName); AssemblyDefinition assembly; using (Stream readStream = new MemoryStream(assemblyBytes)) assembly = AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Deferred) { AssemblyResolver = assemblyResolver }); // skip if already visited if (visitedAssemblyNames.Contains(assembly.Name.Name)) yield break; visitedAssemblyNames.Add(assembly.Name.Name); // yield referenced assemblies foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences) { FileInfo dependencyFile = new FileInfo(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); } /**** ** Assembly rewriting ****/ /// Rewrite the types referenced by an assembly. /// The assembly to rewrite. /// Assume the mod is compatible, even if incompatible code is detected. /// Returns whether the assembly was modified. /// An incompatible CIL instruction was found while rewriting the assembly. private bool RewriteAssembly(AssemblyDefinition assembly, bool assumeCompatible) { ModuleDefinition module = assembly.MainModule; HashSet loggedMessages = new HashSet(); // swap assembly references if needed (e.g. XNA => MonoGame) bool platformChanged = false; for (int i = 0; i < module.AssemblyReferences.Count; i++) { // remove old assembly reference if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name)) { this.LogOnce(this.Monitor, loggedMessages, $"Rewriting {assembly.Name.Name} for OS..."); platformChanged = true; module.AssemblyReferences.RemoveAt(i); i--; } } 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); } // find incompatible instructions bool anyRewritten = false; IInstructionFinder[] finders = Constants.GetIncompatibilityFinders().ToArray(); IInstructionRewriter[] rewriters = Constants.GetRewriters().ToArray(); foreach (MethodDefinition method in this.GetMethods(module)) { ILProcessor cil = method.Body.GetILProcessor(); foreach (Instruction instruction in cil.Body.Instructions.ToArray()) { // throw exception if instruction is incompatible but can't be rewritten IInstructionFinder finder = finders.FirstOrDefault(p => p.IsMatch(instruction, platformChanged)); if (finder != null) { if (!assumeCompatible) throw new IncompatibleInstructionException(finder.NounPhrase, $"Found an incompatible CIL instruction ({finder.NounPhrase}) while loading assembly {assembly.Name.Name}."); this.LogOnce(this.Monitor, loggedMessages, $"Found an incompatible CIL instruction ({finder.NounPhrase}) while loading assembly {assembly.Name.Name}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn); } // rewrite instruction if needed IInstructionRewriter rewriter = rewriters.FirstOrDefault(p => p.IsMatch(instruction, platformChanged)); if (rewriter != null) { this.LogOnce(this.Monitor, loggedMessages, $"Rewriting {assembly.Name.Name} to fix {rewriter.NounPhrase}..."); rewriter.Rewrite(module, cil, instruction, this.AssemblyMap); anyRewritten = true; } } } return platformChanged || anyRewritten; } /// 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 Assembly assembly; if (!this.TypeAssemblies.TryGetValue(type.FullName, out assembly)) return; // replace scope AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly]; type.Scope = assemblyRef; } /// Get all methods in a module. /// The module to search. private IEnumerable GetMethods(ModuleDefinition module) { return ( from type in module.GetTypes() where type.HasMethods from method in type.Methods where method.HasBody select method ); } /// Log a message for the player or developer the first time it occurs. /// The monitor through which to log the message. /// The hash of logged messages. /// The message to log. /// The log severity level. private void LogOnce(IMonitor monitor, HashSet hash, string message, LogLevel level = LogLevel.Trace) { if (!hash.Contains(message)) { this.Monitor.Log(message, level); hash.Add(message); } } } }