diff options
author | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-05-19 18:04:57 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-05-19 18:04:57 -0400 |
commit | 16281fb58944e7e829b184b014e27822c91c9f43 (patch) | |
tree | e9db3b9943d61163a87190c4293673a002d17da1 /src/StardewModdingAPI/Framework/ModLoading | |
parent | c84310dfebafd3085dc418f3620154f9934865de (diff) | |
parent | cbb1777ba00f581b428e61a0f7245a87ac53cf09 (diff) | |
download | SMAPI-16281fb58944e7e829b184b014e27822c91c9f43.tar.gz SMAPI-16281fb58944e7e829b184b014e27822c91c9f43.tar.bz2 SMAPI-16281fb58944e7e829b184b014e27822c91c9f43.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/StardewModdingAPI/Framework/ModLoading')
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; + } + } + } +} |