summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI/Framework/ModLoading
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2017-05-19 18:04:57 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2017-05-19 18:04:57 -0400
commit16281fb58944e7e829b184b014e27822c91c9f43 (patch)
treee9db3b9943d61163a87190c4293673a002d17da1 /src/StardewModdingAPI/Framework/ModLoading
parentc84310dfebafd3085dc418f3620154f9934865de (diff)
parentcbb1777ba00f581b428e61a0f7245a87ac53cf09 (diff)
downloadSMAPI-16281fb58944e7e829b184b014e27822c91c9f43.tar.gz
SMAPI-16281fb58944e7e829b184b014e27822c91c9f43.tar.bz2
SMAPI-16281fb58944e7e829b184b014e27822c91c9f43.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src/StardewModdingAPI/Framework/ModLoading')
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs61
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs292
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs31
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs39
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs14
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs18
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs57
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs12
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs291
9 files changed, 815 insertions, 0 deletions
diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
new file mode 100644
index 00000000..4378798c
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
@@ -0,0 +1,61 @@
+using System.Collections.Generic;
+using Mono.Cecil;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>A minimal assembly definition resolver which resolves references to known assemblies.</summary>
+ internal class AssemblyDefinitionResolver : DefaultAssemblyResolver
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The known assemblies.</summary>
+ private readonly IDictionary<string, AssemblyDefinition> Loaded = new Dictionary<string, AssemblyDefinition>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Add known assemblies to the resolver.</summary>
+ /// <param name="assemblies">The known assemblies.</param>
+ public void Add(params AssemblyDefinition[] assemblies)
+ {
+ foreach (AssemblyDefinition assembly in assemblies)
+ {
+ this.Loaded[assembly.Name.Name] = assembly;
+ this.Loaded[assembly.Name.FullName] = assembly;
+ }
+ }
+
+ /// <summary>Resolve an assembly reference.</summary>
+ /// <param name="name">The assembly name.</param>
+ public override AssemblyDefinition Resolve(AssemblyNameReference name) => this.ResolveName(name.Name) ?? base.Resolve(name);
+
+ /// <summary>Resolve an assembly reference.</summary>
+ /// <param name="name">The assembly name.</param>
+ /// <param name="parameters">The assembly reader parameters.</param>
+ public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) => this.ResolveName(name.Name) ?? base.Resolve(name, parameters);
+
+ /// <summary>Resolve an assembly reference.</summary>
+ /// <param name="fullName">The assembly full name (including version, etc).</param>
+ public override AssemblyDefinition Resolve(string fullName) => this.ResolveName(fullName) ?? base.Resolve(fullName);
+
+ /// <summary>Resolve an assembly reference.</summary>
+ /// <param name="fullName">The assembly full name (including version, etc).</param>
+ /// <param name="parameters">The assembly reader parameters.</param>
+ public override AssemblyDefinition Resolve(string fullName, ReaderParameters parameters) => this.ResolveName(fullName) ?? base.Resolve(fullName, parameters);
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Resolve a known assembly definition based on its short or full name.</summary>
+ /// <param name="name">The assembly's short or full name.</param>
+ private AssemblyDefinition ResolveName(string name)
+ {
+ return this.Loaded.ContainsKey(name)
+ ? this.Loaded[name]
+ : null;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
new file mode 100644
index 00000000..42bd7bfb
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -0,0 +1,292 @@
+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.ModLoading
+{
+ /// <summary>Preprocesses and loads mod assemblies.</summary>
+ internal class AssemblyLoader
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Metadata for mapping assemblies to the current 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="targetPlatform">The current game platform.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ 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<string, Assembly>();
+ 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;
+ }
+ }
+ }
+
+ /// <summary>Preprocess and load an assembly.</summary>
+ /// <param name="assemblyPath">The assembly file path.</param>
+ /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param>
+ /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns>
+ /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception>
+ public Assembly Load(string assemblyPath, bool assumeCompatible)
+ {
+ // get referenced local assemblies
+ AssemblyParseResult[] assemblies;
+ {
+ AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver();
+ HashSet<string> visitedAssemblyNames = new HashSet<string>(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;
+ }
+
+ /// <summary>Resolve an assembly by 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
+ *********/
+ /****
+ ** Assembly parsing
+ ****/
+ /// <summary>Get a list of referenced local assemblies starting from the mod assembly, ordered from leaf to root.</summary>
+ /// <param name="file">The assembly file to load.</param>
+ /// <param name="visitedAssemblyNames">The assembly names that should be skipped.</param>
+ /// <param name="assemblyResolver">A resolver which resolves references to known assemblies.</param>
+ /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns>
+ private IEnumerable<AssemblyParseResult> GetReferencedLocalAssemblies(FileInfo file, HashSet<string> 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
+ ****/
+ /// <summary>Rewrite the types referenced by an assembly.</summary>
+ /// <param name="assembly">The assembly to rewrite.</param>
+ /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param>
+ /// <returns>Returns whether the assembly was modified.</returns>
+ /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception>
+ private bool RewriteAssembly(AssemblyDefinition assembly, bool assumeCompatible)
+ {
+ ModuleDefinition module = assembly.MainModule;
+ HashSet<string> loggedMessages = new HashSet<string>();
+
+ // 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<TypeReference> typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName);
+ foreach (TypeReference type in typeReferences)
+ this.ChangeTypeScope(type);
+ }
+
+ // find (and optionally rewrite) incompatible instructions
+ bool anyRewritten = false;
+ IInstructionRewriter[] rewriters = Constants.GetRewriters().ToArray();
+ foreach (MethodDefinition method in this.GetMethods(module))
+ {
+ // check method definition
+ foreach (IInstructionRewriter rewriter in rewriters)
+ {
+ try
+ {
+ if (rewriter.Rewrite(module, method, this.AssemblyMap, platformChanged))
+ {
+ this.LogOnce(this.Monitor, loggedMessages, $"Rewrote {assembly.Name.Name} to fix {rewriter.NounPhrase}...");
+ anyRewritten = true;
+ }
+ }
+ catch (IncompatibleInstructionException)
+ {
+ if (!assumeCompatible)
+ throw new IncompatibleInstructionException(rewriter.NounPhrase, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}.");
+ this.LogOnce(this.Monitor, loggedMessages, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn);
+ }
+ }
+
+ // check CIL instructions
+ ILProcessor cil = method.Body.GetILProcessor();
+ foreach (Instruction instruction in cil.Body.Instructions.ToArray())
+ {
+ foreach (IInstructionRewriter rewriter in rewriters)
+ {
+ try
+ {
+ if (rewriter.Rewrite(module, cil, instruction, this.AssemblyMap, platformChanged))
+ {
+ this.LogOnce(this.Monitor, loggedMessages, $"Rewrote {assembly.Name.Name} to fix {rewriter.NounPhrase}...");
+ anyRewritten = true;
+ }
+ }
+ catch (IncompatibleInstructionException)
+ {
+ if (!assumeCompatible)
+ throw new IncompatibleInstructionException(rewriter.NounPhrase, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}.");
+ this.LogOnce(this.Monitor, loggedMessages, $"Found an incompatible CIL instruction ({rewriter.NounPhrase}) while loading assembly {assembly.Name.Name}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn);
+ }
+ }
+ }
+ }
+
+ return platformChanged || anyRewritten;
+ }
+
+ /// <summary>Get the correct reference to use for compatibility with the current platform.</summary>
+ /// <param name="type">The type reference to rewrite.</param>
+ 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;
+ }
+
+ /// <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
+ );
+ }
+
+ /// <summary>Log a message for the player or developer the first time it occurs.</summary>
+ /// <param name="monitor">The monitor through which to log the message.</param>
+ /// <param name="hash">The hash of logged messages.</param>
+ /// <param name="message">The message to log.</param>
+ /// <param name="level">The log severity level.</param>
+ private void LogOnce(IMonitor monitor, HashSet<string> hash, string message, LogLevel level = LogLevel.Trace)
+ {
+ if (!hash.Contains(message))
+ {
+ monitor.Log(message, level);
+ hash.Add(message);
+ }
+ }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs
new file mode 100644
index 00000000..69c99afe
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs
@@ -0,0 +1,31 @@
+using System.IO;
+using Mono.Cecil;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Metadata about a parsed assembly definition.</summary>
+ internal class AssemblyParseResult
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The original assembly file.</summary>
+ public readonly FileInfo File;
+
+ /// <summary>The assembly definition.</summary>
+ public readonly AssemblyDefinition Definition;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="file">The original assembly file.</param>
+ /// <param name="assembly">The assembly definition.</param>
+ public AssemblyParseResult(FileInfo file, AssemblyDefinition assembly)
+ {
+ this.File = file;
+ this.Definition = assembly;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs
new file mode 100644
index 00000000..3771ffdd
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs
@@ -0,0 +1,39 @@
+using StardewModdingAPI.Framework.Models;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Metadata for a mod.</summary>
+ internal interface IModMetadata
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's display name.</summary>
+ string DisplayName { get; }
+
+ /// <summary>The mod's full directory path.</summary>
+ string DirectoryPath { get; }
+
+ /// <summary>The mod manifest.</summary>
+ IManifest Manifest { get; }
+
+ /// <summary>Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary>
+ ModCompatibility Compatibility { get; }
+
+ /// <summary>The metadata resolution status.</summary>
+ ModMetadataStatus Status { get; }
+
+ /// <summary>The reason the metadata is invalid, if any.</summary>
+ string Error { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Set the mod status.</summary>
+ /// <param name="status">The metadata resolution status.</param>
+ /// <param name="error">The reason the metadata is invalid, if any.</param>
+ /// <returns>Return the instance for chaining.</returns>
+ IModMetadata SetStatus(ModMetadataStatus status, string error = null);
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs
new file mode 100644
index 00000000..ab11272a
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>An exception which indicates that something went seriously wrong while loading mods, and SMAPI should abort outright.</summary>
+ public class InvalidModStateException : Exception
+ {
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The error message.</param>
+ /// <param name="ex">The underlying exception, if any.</param>
+ public InvalidModStateException(string message, Exception ex = null)
+ : base(message, ex) { }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs
new file mode 100644
index 00000000..0774b487
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs
@@ -0,0 +1,18 @@
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>The status of a given mod in the dependency-sorting algorithm.</summary>
+ internal enum ModDependencyStatus
+ {
+ /// <summary>The mod hasn't been visited yet.</summary>
+ Queued,
+
+ /// <summary>The mod is currently being analysed as part of a dependency chain.</summary>
+ Checking,
+
+ /// <summary>The mod has already been sorted.</summary>
+ Sorted,
+
+ /// <summary>The mod couldn't be sorted due to a metadata issue (e.g. missing dependencies).</summary>
+ Failed
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs
new file mode 100644
index 00000000..7b25e090
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs
@@ -0,0 +1,57 @@
+using StardewModdingAPI.Framework.Models;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Metadata for a mod.</summary>
+ internal class ModMetadata : IModMetadata
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's display name.</summary>
+ public string DisplayName { get; }
+
+ /// <summary>The mod's full directory path.</summary>
+ public string DirectoryPath { get; }
+
+ /// <summary>The mod manifest.</summary>
+ public IManifest Manifest { get; }
+
+ /// <summary>Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary>
+ public ModCompatibility Compatibility { get; }
+
+ /// <summary>The metadata resolution status.</summary>
+ public ModMetadataStatus Status { get; private set; }
+
+ /// <summary>The reason the metadata is invalid, if any.</summary>
+ public string Error { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="displayName">The mod's display name.</param>
+ /// <param name="directoryPath">The mod's full directory path.</param>
+ /// <param name="manifest">The mod manifest.</param>
+ /// <param name="compatibility">Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param>
+ public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility)
+ {
+ this.DisplayName = displayName;
+ this.DirectoryPath = directoryPath;
+ this.Manifest = manifest;
+ this.Compatibility = compatibility;
+ }
+
+ /// <summary>Set the mod status.</summary>
+ /// <param name="status">The metadata resolution status.</param>
+ /// <param name="error">The reason the metadata is invalid, if any.</param>
+ /// <returns>Return the instance for chaining.</returns>
+ public IModMetadata SetStatus(ModMetadataStatus status, string error = null)
+ {
+ this.Status = status;
+ this.Error = error;
+ return this;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs
new file mode 100644
index 00000000..1b2b0b55
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs
@@ -0,0 +1,12 @@
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Indicates the status of a mod's metadata resolution.</summary>
+ internal enum ModMetadataStatus
+ {
+ /// <summary>The mod has been found, but hasn't been processed yet.</summary>
+ Found,
+
+ /// <summary>The mod cannot be loaded.</summary>
+ Failed
+ }
+} \ No newline at end of file
diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs
new file mode 100644
index 00000000..2c68a639
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs
@@ -0,0 +1,291 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StardewModdingAPI.Framework.Models;
+using StardewModdingAPI.Framework.Serialisation;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Finds and processes mod metadata.</summary>
+ internal class ModResolver
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get manifest metadata for each folder in the given root path.</summary>
+ /// <param name="rootPath">The root path to search for mods.</param>
+ /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
+ /// <param name="compatibilityRecords">Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param>
+ /// <returns>Returns the manifests by relative folder.</returns>
+ public IEnumerable<IModMetadata> ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable<ModCompatibility> compatibilityRecords)
+ {
+ compatibilityRecords = compatibilityRecords.ToArray();
+ foreach (DirectoryInfo modDir in this.GetModFolders(rootPath))
+ {
+ // read file
+ Manifest manifest = null;
+ string path = Path.Combine(modDir.FullName, "manifest.json");
+ string error = null;
+ try
+ {
+ // read manifest
+ manifest = jsonHelper.ReadJsonFile<Manifest>(path);
+
+ // validate
+ if (manifest == null)
+ {
+ error = File.Exists(path)
+ ? "its manifest is invalid."
+ : "it doesn't have a manifest.";
+ }
+ else if (string.IsNullOrWhiteSpace(manifest.EntryDll))
+ error = "its manifest doesn't set an entry DLL.";
+ }
+ catch (Exception ex)
+ {
+ error = $"parsing its manifest failed:\n{ex.GetLogSummary()}";
+ }
+
+ // get compatibility record
+ ModCompatibility compatibility = null;
+ if (manifest != null)
+ {
+ string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll;
+ compatibility = (
+ from mod in compatibilityRecords
+ where
+ mod.ID.Contains(key, StringComparer.InvariantCultureIgnoreCase)
+ && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion))
+ && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion)
+ select mod
+ ).FirstOrDefault();
+ }
+ // build metadata
+ string displayName = !string.IsNullOrWhiteSpace(manifest?.Name)
+ ? manifest.Name
+ : modDir.FullName.Replace(rootPath, "").Trim('/', '\\');
+ ModMetadataStatus status = error == null
+ ? ModMetadataStatus.Found
+ : ModMetadataStatus.Failed;
+
+ yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility).SetStatus(status, error);
+ }
+ }
+
+ /// <summary>Validate manifest metadata.</summary>
+ /// <param name="mods">The mod manifests to validate.</param>
+ /// <param name="apiVersion">The current SMAPI version.</param>
+ public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion)
+ {
+ foreach (IModMetadata mod in mods)
+ {
+ // skip if already failed
+ if (mod.Status == ModMetadataStatus.Failed)
+ continue;
+
+ // validate compatibility
+ {
+ ModCompatibility compatibility = mod.Compatibility;
+ if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken)
+ {
+ bool hasOfficialUrl = !string.IsNullOrWhiteSpace(mod.Compatibility.UpdateUrl);
+ bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(mod.Compatibility.UnofficialUpdateUrl);
+
+ string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game";
+ string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersionLabel ?? compatibility.UpperVersion} here:";
+ if (hasOfficialUrl)
+ error += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}";
+ if (hasUnofficialUrl)
+ error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}";
+
+ mod.SetStatus(ModMetadataStatus.Failed, error);
+ continue;
+ }
+ }
+
+ // validate SMAPI version
+ if (!string.IsNullOrWhiteSpace(mod.Manifest.MinimumApiVersion))
+ {
+ if (!SemanticVersion.TryParse(mod.Manifest.MinimumApiVersion, out ISemanticVersion minVersion))
+ {
+ mod.SetStatus(ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{mod.Manifest.MinimumApiVersion}'. This should be a semantic version number like {apiVersion}.");
+ continue;
+ }
+ if (minVersion.IsNewerThan(apiVersion))
+ {
+ mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod.");
+ continue;
+ }
+ }
+
+ // validate DLL path
+ string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll);
+ if (!File.Exists(assemblyPath))
+ mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
+ }
+ }
+
+#if EXPERIMENTAL
+ /// <summary>Sort the given mods by the order they should be loaded.</summary>
+ /// <param name="mods">The mods to process.</param>
+ public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods)
+ {
+ // initialise metadata
+ mods = mods.ToArray();
+ var sortedMods = new Stack<IModMetadata>();
+ var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued);
+
+ // handle failed mods
+ foreach (IModMetadata mod in mods.Where(m => m.Status == ModMetadataStatus.Failed))
+ {
+ states[mod] = ModDependencyStatus.Failed;
+ sortedMods.Push(mod);
+ }
+
+ // sort mods
+ foreach (IModMetadata mod in mods)
+ this.ProcessDependencies(mods.ToArray(), mod, states, sortedMods, new List<IModMetadata>());
+
+ return sortedMods.Reverse();
+ }
+#endif
+
+
+ /*********
+ ** Private methods
+ *********/
+#if EXPERIMENTAL
+ /// <summary>Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies.</summary>
+ /// <param name="mods">The full list of mods being validated.</param>
+ /// <param name="mod">The mod whose dependencies to process.</param>
+ /// <param name="states">The dependency state for each mod.</param>
+ /// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param>
+ /// <param name="currentChain">The current change of mod dependencies.</param>
+ /// <returns>Returns the mod dependency status.</returns>
+ private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain)
+ {
+ // check if already visited
+ switch (states[mod])
+ {
+ // already sorted or failed
+ case ModDependencyStatus.Sorted:
+ case ModDependencyStatus.Failed:
+ return states[mod];
+
+ // dependency loop
+ case ModDependencyStatus.Checking:
+ // This should never happen. The higher-level mod checks if the dependency is
+ // already being checked, so it can fail without visiting a mod twice. If this
+ // case is hit, that logic didn't catch the dependency loop for some reason.
+ throw new InvalidModStateException($"A dependency loop was not caught by the calling iteration ({string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {mod.DisplayName})).");
+
+ // not visited yet, start processing
+ case ModDependencyStatus.Queued:
+ break;
+
+ // sanity check
+ default:
+ throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'.");
+ }
+
+ // no dependencies, mark sorted
+ if (mod.Manifest.Dependencies == null || !mod.Manifest.Dependencies.Any())
+ {
+ sortedMods.Push(mod);
+ return states[mod] = ModDependencyStatus.Sorted;
+ }
+
+ // missing required dependencies, mark failed
+ {
+ string[] missingModIDs =
+ (
+ from dependency in mod.Manifest.Dependencies
+ where mods.All(m => m.Manifest.UniqueID != dependency.UniqueID)
+ orderby dependency.UniqueID
+ select dependency.UniqueID
+ )
+ .ToArray();
+ if (missingModIDs.Any())
+ {
+ sortedMods.Push(mod);
+ mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", missingModIDs)}).");
+ return states[mod] = ModDependencyStatus.Failed;
+ }
+ }
+
+ // process dependencies
+ {
+ states[mod] = ModDependencyStatus.Checking;
+
+ // get mods to load first
+ IModMetadata[] modsToLoadFirst =
+ (
+ from other in mods
+ where mod.Manifest.Dependencies.Any(required => required.UniqueID == other.Manifest.UniqueID)
+ select other
+ )
+ .ToArray();
+
+ // recursively sort dependencies
+ foreach (IModMetadata requiredMod in modsToLoadFirst)
+ {
+ var subchain = new List<IModMetadata>(currentChain) { mod };
+
+ // detect dependency loop
+ if (states[requiredMod] == ModDependencyStatus.Checking)
+ {
+ sortedMods.Push(mod);
+ mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName}).");
+ return states[mod] = ModDependencyStatus.Failed;
+ }
+
+ // recursively process each dependency
+ var substatus = this.ProcessDependencies(mods, requiredMod, states, sortedMods, subchain);
+ switch (substatus)
+ {
+ // sorted successfully
+ case ModDependencyStatus.Sorted:
+ break;
+
+ // failed, which means this mod can't be loaded either
+ case ModDependencyStatus.Failed:
+ sortedMods.Push(mod);
+ mod.SetStatus(ModMetadataStatus.Failed, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded.");
+ return states[mod] = ModDependencyStatus.Failed;
+
+ // unexpected status
+ case ModDependencyStatus.Queued:
+ case ModDependencyStatus.Checking:
+ throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{substatus}' status.");
+
+ // sanity check
+ default:
+ throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'.");
+ }
+ }
+
+ // all requirements sorted successfully
+ sortedMods.Push(mod);
+ return states[mod] = ModDependencyStatus.Sorted;
+ }
+ }
+#endif
+
+ /// <summary>Get all mod folders in a root folder, passing through empty folders as needed.</summary>
+ /// <param name="rootPath">The root folder path to search.</param>
+ private IEnumerable<DirectoryInfo> GetModFolders(string rootPath)
+ {
+ foreach (string modRootPath in Directory.GetDirectories(rootPath))
+ {
+ DirectoryInfo directory = new DirectoryInfo(modRootPath);
+
+ // if a folder only contains another folder, check the inner folder instead
+ while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1)
+ directory = directory.GetDirectories().First();
+
+ yield return directory;
+ }
+ }
+ }
+}