diff options
Diffstat (limited to 'src/StardewModdingAPI/Framework')
3 files changed, 329 insertions, 0 deletions
diff --git a/src/StardewModdingAPI/Framework/AssemblyRewriting/AssemblyTypeRewriter.cs b/src/StardewModdingAPI/Framework/AssemblyRewriting/AssemblyTypeRewriter.cs new file mode 100644 index 00000000..3459488e --- /dev/null +++ b/src/StardewModdingAPI/Framework/AssemblyRewriting/AssemblyTypeRewriter.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Mono.Cecil.Rocks; +using StardewModdingAPI.AssemblyRewriters; + +namespace StardewModdingAPI.Framework.AssemblyRewriting +{ + /// <summary>Rewrites type references.</summary> + internal class AssemblyTypeRewriter + { + /********* + ** Properties + *********/ + /// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary> + private readonly PlatformAssemblyMap AssemblyMap; + + /// <summary>A type => assembly lookup for types which should be rewritten.</summary> + private readonly IDictionary<string, Assembly> TypeAssemblies; + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current <see cref="Platform"/>.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public AssemblyTypeRewriter(PlatformAssemblyMap assemblyMap, IMonitor monitor) + { + // save config + this.AssemblyMap = assemblyMap; + this.Monitor = monitor; + + // collect type => assembly lookup + this.TypeAssemblies = new Dictionary<string, Assembly>(); + foreach (Assembly assembly in 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; + } + } + } + + /// <summary>Rewrite the types referenced by an assembly.</summary> + /// <param name="assembly">The assembly to rewrite.</param> + public void RewriteAssembly(AssemblyDefinition assembly) + { + ModuleDefinition module = assembly.Modules.Single(); // technically an assembly can have multiple modules, but none of the build tools (including MSBuild) support it; simplify by assuming one module + + // remove old assembly references + bool shouldRewrite = false; + for (int i = 0; i < module.AssemblyReferences.Count; i++) + { + if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name)) + { + this.Monitor.Log($"removing reference to {module.AssemblyReferences[i]}", LogLevel.Trace); + shouldRewrite = true; + module.AssemblyReferences.RemoveAt(i); + i--; + } + } + if (!shouldRewrite) + return; + + // add target assembly references + foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values) + { + this.Monitor.Log($" adding reference to {target}", LogLevel.Trace); + module.AssemblyReferences.Add(target); + } + + // rewrite type scopes to use target assemblies + IEnumerable<TypeReference> typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); + string lastTypeLogged = null; + foreach (TypeReference type in typeReferences) + { + this.ChangeTypeScope(type, shouldLog: type.FullName != lastTypeLogged); + lastTypeLogged = type.FullName; + } + + // rewrite incompatible methods + IMethodRewriter[] methodRewriters = Constants.GetMethodRewriters().ToArray(); + foreach (MethodDefinition method in this.GetMethods(module)) + { + // skip methods with no rewritable method + bool hasMethodToRewrite = method.Body.Instructions.Any(op => (op.OpCode == OpCodes.Call || op.OpCode == OpCodes.Callvirt) && methodRewriters.Any(rewriter => rewriter.ShouldRewrite((MethodReference)op.Operand))); + if (!hasMethodToRewrite) + continue; + + // rewrite method references + method.Body.SimplifyMacros(); + ILProcessor cil = method.Body.GetILProcessor(); + Instruction[] instructions = cil.Body.Instructions.ToArray(); + foreach (Instruction op in instructions) + { + if (op.OpCode == OpCodes.Call || op.OpCode == OpCodes.Callvirt) + { + IMethodRewriter rewriter = methodRewriters.FirstOrDefault(p => p.ShouldRewrite((MethodReference)op.Operand)); + if (rewriter != null) + { + MethodReference methodRef = (MethodReference)op.Operand; + this.Monitor.Log($"rewriting method reference {methodRef.DeclaringType.FullName}.{methodRef.Name}", LogLevel.Trace); + rewriter.Rewrite(module, cil, op, methodRef, this.AssemblyMap); + } + } + } + method.Body.OptimizeMacros(); + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> + /// <param name="type">The type reference to rewrite.</param> + /// <param name="shouldLog">Whether to log a message.</param> + private void ChangeTypeScope(TypeReference type, bool shouldLog) + { + // 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]; + if (shouldLog) + this.Monitor.Log($"redirecting {type.FullName} from {type.Scope.Name} to {assemblyRef.Name}", LogLevel.Trace); + type.Scope = assemblyRef; + } + + /// <summary>Get all methods in a module.</summary> + /// <param name="module">The module to search.</param> + private IEnumerable<MethodDefinition> GetMethods(ModuleDefinition module) + { + return ( + from type in module.GetTypes() + where type.HasMethods + from method in type.Methods + where method.HasBody + select method + ); + } + } +} diff --git a/src/StardewModdingAPI/Framework/AssemblyRewriting/CachePaths.cs b/src/StardewModdingAPI/Framework/AssemblyRewriting/CachePaths.cs new file mode 100644 index 00000000..17c4d188 --- /dev/null +++ b/src/StardewModdingAPI/Framework/AssemblyRewriting/CachePaths.cs @@ -0,0 +1,33 @@ +namespace StardewModdingAPI.Framework.AssemblyRewriting +{ + /// <summary>Contains the paths for an assembly's cached data.</summary> + internal struct CachePaths + { + /********* + ** Accessors + *********/ + /// <summary>The directory path which contains the assembly.</summary> + public string Directory { get; } + + /// <summary>The file path of the assembly file.</summary> + public string Assembly { get; } + + /// <summary>The file path containing the MD5 hash for the assembly.</summary> + public string Hash { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="directory">The directory path which contains the assembly.</param> + /// <param name="assembly">The file path of the assembly file.</param> + /// <param name="hash">The file path containing the MD5 hash for the assembly.</param> + public CachePaths(string directory, string assembly, string hash) + { + this.Directory = directory; + this.Assembly = assembly; + this.Hash = hash; + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/ModAssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModAssemblyLoader.cs new file mode 100644 index 00000000..51018b0b --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModAssemblyLoader.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using Mono.Cecil; +using StardewModdingAPI.AssemblyRewriters; +using StardewModdingAPI.Framework.AssemblyRewriting; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Preprocesses and loads mod assemblies.</summary> + internal class ModAssemblyLoader + { + /********* + ** Properties + *********/ + /// <summary>The directory in which to cache data.</summary> + private readonly string CacheDirPath; + + /// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary> + private readonly PlatformAssemblyMap AssemblyMap; + + /// <summary>Rewrites assembly types to match the current platform.</summary> + private readonly AssemblyTypeRewriter AssemblyTypeRewriter; + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="cacheDirPath">The cache directory.</param> + /// <param name="targetPlatform">The current game platform.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public ModAssemblyLoader(string cacheDirPath, Platform targetPlatform, IMonitor monitor) + { + this.CacheDirPath = cacheDirPath; + this.Monitor = monitor; + this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform); + this.AssemblyTypeRewriter = new AssemblyTypeRewriter(this.AssemblyMap, monitor); + } + + /// <summary>Preprocess an assembly and cache the modified version.</summary> + /// <param name="assemblyPath">The assembly file path.</param> + public void ProcessAssembly(string assemblyPath) + { + // read assembly data + string assemblyFileName = Path.GetFileName(assemblyPath); + string assemblyDir = Path.GetDirectoryName(assemblyPath); + byte[] assemblyBytes = File.ReadAllBytes(assemblyPath); + string hash = $"SMAPI {Constants.Version}|" + string.Join("", MD5.Create().ComputeHash(assemblyBytes).Select(p => p.ToString("X2"))); + + // check cache + CachePaths cachePaths = this.GetCacheInfo(assemblyPath); + bool canUseCache = File.Exists(cachePaths.Assembly) && File.Exists(cachePaths.Hash) && hash == File.ReadAllText(cachePaths.Hash); + + // process assembly if not cached + if (!canUseCache) + { + this.Monitor.Log($"Loading {assemblyFileName} for the first time; preprocessing...", LogLevel.Trace); + + // read assembly definition + AssemblyDefinition assembly; + using (Stream readStream = new MemoryStream(assemblyBytes)) + assembly = AssemblyDefinition.ReadAssembly(readStream); + + // rewrite assembly to match platform + this.AssemblyTypeRewriter.RewriteAssembly(assembly); + + // write cache + using (MemoryStream outStream = new MemoryStream()) + { + // get assembly bytes + assembly.Write(outStream); + byte[] outBytes = outStream.ToArray(); + + // write assembly data + Directory.CreateDirectory(cachePaths.Directory); + File.WriteAllBytes(cachePaths.Assembly, outBytes); + File.WriteAllText(cachePaths.Hash, hash); + + // copy any mdb/pdb files + foreach (string path in Directory.GetFiles(assemblyDir, "*.mdb").Concat(Directory.GetFiles(assemblyDir, "*.pdb"))) + { + string filename = Path.GetFileName(path); + File.Copy(path, Path.Combine(cachePaths.Directory, filename), overwrite: true); + } + } + } + } + + /// <summary>Load a preprocessed assembly.</summary> + /// <param name="assemblyPath">The assembly file path.</param> + public Assembly LoadCachedAssembly(string assemblyPath) + { + CachePaths cachePaths = this.GetCacheInfo(assemblyPath); + if (!File.Exists(cachePaths.Assembly)) + throw new InvalidOperationException($"The assembly {assemblyPath} doesn't exist in the preprocessed cache."); + return Assembly.UnsafeLoadFrom(cachePaths.Assembly); // unsafe load allows DLLs downloaded from the Internet without the user needing to 'unblock' them + } + + /// <summary>Resolve an assembly from its name.</summary> + /// <param name="name">The assembly name.</param> + /// <remarks> + /// 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 <see cref="AppDomain.AssemblyResolve"/>, + /// the implicit assumption is that loading the exact assembly failed. + /// </remarks> + 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 + *********/ + /// <summary>Get the cache details for an assembly.</summary> + /// <param name="assemblyPath">The assembly file path.</param> + private CachePaths GetCacheInfo(string assemblyPath) + { + string key = Path.GetFileNameWithoutExtension(assemblyPath); + string dirPath = Path.Combine(this.CacheDirPath, new DirectoryInfo(Path.GetDirectoryName(assemblyPath)).Name); + string cacheAssemblyPath = Path.Combine(dirPath, $"{key}.dll"); + string cacheHashPath = Path.Combine(dirPath, $"{key}.hash"); + return new CachePaths(dirPath, cacheAssemblyPath, cacheHashPath); + } + } +} |