summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/StardewModdingAPI/Framework')
-rw-r--r--src/StardewModdingAPI/Framework/AssemblyRewriting/AssemblyTypeRewriter.cs160
-rw-r--r--src/StardewModdingAPI/Framework/AssemblyRewriting/CachePaths.cs33
-rw-r--r--src/StardewModdingAPI/Framework/ModAssemblyLoader.cs136
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);
+ }
+ }
+}