From bb165f2079e33d02c0e673db73ac5b336272a3fa Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 11 May 2017 23:21:02 -0400 Subject: organise a few framework classes --- .../ModLoading/AssemblyDefinitionResolver.cs | 61 +++++ .../Framework/ModLoading/AssemblyLoader.cs | 292 +++++++++++++++++++++ .../Framework/ModLoading/AssemblyParseResult.cs | 31 +++ .../Framework/ModLoading/ModMetadata.cs | 40 +++ 4 files changed, 424 insertions(+) create mode 100644 src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs create mode 100644 src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs create mode 100644 src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs create mode 100644 src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs (limited to 'src/StardewModdingAPI/Framework/ModLoading') 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 +{ + /// A minimal assembly definition resolver which resolves references to known assemblies. + internal class AssemblyDefinitionResolver : DefaultAssemblyResolver + { + /********* + ** Properties + *********/ + /// The known assemblies. + private readonly IDictionary Loaded = new Dictionary(); + + + /********* + ** Public methods + *********/ + /// Add known assemblies to the resolver. + /// The known assemblies. + public void Add(params AssemblyDefinition[] assemblies) + { + foreach (AssemblyDefinition assembly in assemblies) + { + this.Loaded[assembly.Name.Name] = assembly; + this.Loaded[assembly.Name.FullName] = assembly; + } + } + + /// Resolve an assembly reference. + /// The assembly name. + public override AssemblyDefinition Resolve(AssemblyNameReference name) => this.ResolveName(name.Name) ?? base.Resolve(name); + + /// Resolve an assembly reference. + /// The assembly name. + /// The assembly reader parameters. + public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) => this.ResolveName(name.Name) ?? base.Resolve(name, parameters); + + /// Resolve an assembly reference. + /// The assembly full name (including version, etc). + public override AssemblyDefinition Resolve(string fullName) => this.ResolveName(fullName) ?? base.Resolve(fullName); + + /// Resolve an assembly reference. + /// The assembly full name (including version, etc). + /// The assembly reader parameters. + public override AssemblyDefinition Resolve(string fullName, ReaderParameters parameters) => this.ResolveName(fullName) ?? base.Resolve(fullName, parameters); + + + /********* + ** Private methods + *********/ + /// Resolve a known assembly definition based on its short or full name. + /// The assembly's short or full name. + 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 +{ + /// Preprocesses and loads mod assemblies. + internal class AssemblyLoader + { + /********* + ** Properties + *********/ + /// Metadata for mapping assemblies to the current platform. + private readonly PlatformAssemblyMap AssemblyMap; + + /// A type => assembly lookup for types which should be rewritten. + private readonly IDictionary TypeAssemblies; + + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The current game platform. + /// Encapsulates monitoring and logging. + public AssemblyLoader(Platform targetPlatform, IMonitor monitor) + { + this.Monitor = monitor; + this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform); + + // generate type => assembly lookup for types which should be rewritten + this.TypeAssemblies = new Dictionary(); + foreach (Assembly assembly in this.AssemblyMap.Targets) + { + ModuleDefinition module = this.AssemblyMap.TargetModules[assembly]; + foreach (TypeDefinition type in module.GetTypes()) + { + if (!type.IsPublic) + continue; // no need to rewrite + if (type.Namespace.Contains("<")) + continue; // ignore assembly metadata + this.TypeAssemblies[type.FullName] = assembly; + } + } + } + + /// Preprocess and load an assembly. + /// The assembly file path. + /// Assume the mod is compatible, even if incompatible code is detected. + /// Returns the rewrite metadata for the preprocessed assembly. + /// An incompatible CIL instruction was found while rewriting the assembly. + public Assembly Load(string assemblyPath, bool assumeCompatible) + { + // get referenced local assemblies + AssemblyParseResult[] assemblies; + { + AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver(); + HashSet visitedAssemblyNames = new HashSet(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded + assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, resolver).ToArray(); + if (!assemblies.Any()) + throw new InvalidOperationException($"Could not load '{assemblyPath}' because it doesn't exist."); + resolver.Add(assemblies.Select(p => p.Definition).ToArray()); + } + + // rewrite & load assemblies in leaf-to-root order + Assembly lastAssembly = null; + foreach (AssemblyParseResult assembly in assemblies) + { + bool changed = this.RewriteAssembly(assembly.Definition, assumeCompatible); + if (changed) + { + this.Monitor.Log($"Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); + using (MemoryStream outStream = new MemoryStream()) + { + assembly.Definition.Write(outStream); + byte[] bytes = outStream.ToArray(); + lastAssembly = Assembly.Load(bytes); + } + } + else + { + this.Monitor.Log($"Loading {assembly.File.Name}...", LogLevel.Trace); + lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); + } + } + + // last assembly loaded is the root + return lastAssembly; + } + + /// Resolve an assembly by its name. + /// The assembly name. + /// + /// This implementation returns the first loaded assembly which matches the short form of + /// the assembly name, to resolve assembly resolution issues when rewriting + /// assemblies (especially with Mono). Since this is meant to be called on , + /// the implicit assumption is that loading the exact assembly failed. + /// + public Assembly ResolveAssembly(string name) + { + string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture) + return AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(p => p.GetName().Name == shortName); + } + + + /********* + ** Private methods + *********/ + /**** + ** Assembly parsing + ****/ + /// Get a list of referenced local assemblies starting from the mod assembly, ordered from leaf to root. + /// The assembly file to load. + /// The assembly names that should be skipped. + /// A resolver which resolves references to known assemblies. + /// Returns the rewrite metadata for the preprocessed assembly. + private IEnumerable GetReferencedLocalAssemblies(FileInfo file, HashSet visitedAssemblyNames, IAssemblyResolver assemblyResolver) + { + // validate + if (file.Directory == null) + throw new InvalidOperationException($"Could not get directory from file path '{file.FullName}'."); + if (!file.Exists) + yield break; // not a local assembly + + // read assembly + byte[] assemblyBytes = File.ReadAllBytes(file.FullName); + AssemblyDefinition assembly; + using (Stream readStream = new MemoryStream(assemblyBytes)) + assembly = AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Deferred) { AssemblyResolver = assemblyResolver }); + + // skip if already visited + if (visitedAssemblyNames.Contains(assembly.Name.Name)) + yield break; + visitedAssemblyNames.Add(assembly.Name.Name); + + // yield referenced assemblies + foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences) + { + FileInfo dependencyFile = new FileInfo(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll")); + foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver)) + yield return result; + } + + // yield assembly + yield return new AssemblyParseResult(file, assembly); + } + + /**** + ** Assembly rewriting + ****/ + /// Rewrite the types referenced by an assembly. + /// The assembly to rewrite. + /// Assume the mod is compatible, even if incompatible code is detected. + /// Returns whether the assembly was modified. + /// An incompatible CIL instruction was found while rewriting the assembly. + private bool RewriteAssembly(AssemblyDefinition assembly, bool assumeCompatible) + { + ModuleDefinition module = assembly.MainModule; + HashSet loggedMessages = new HashSet(); + + // swap assembly references if needed (e.g. XNA => MonoGame) + bool platformChanged = false; + for (int i = 0; i < module.AssemblyReferences.Count; i++) + { + // remove old assembly reference + if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name)) + { + this.LogOnce(this.Monitor, loggedMessages, $"Rewriting {assembly.Name.Name} for OS..."); + platformChanged = true; + module.AssemblyReferences.RemoveAt(i); + i--; + } + } + if (platformChanged) + { + // add target assembly references + foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values) + module.AssemblyReferences.Add(target); + + // rewrite type scopes to use target assemblies + IEnumerable typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); + foreach (TypeReference type in typeReferences) + this.ChangeTypeScope(type); + } + + // find (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; + } + + /// Get the correct reference to use for compatibility with the current platform. + /// The type reference to rewrite. + private void ChangeTypeScope(TypeReference type) + { + // check skip conditions + if (type == null || type.FullName.StartsWith("System.")) + return; + + // get assembly + Assembly assembly; + if (!this.TypeAssemblies.TryGetValue(type.FullName, out assembly)) + return; + + // replace scope + AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly]; + type.Scope = assemblyRef; + } + + /// Get all methods in a module. + /// The module to search. + private IEnumerable GetMethods(ModuleDefinition module) + { + return ( + from type in module.GetTypes() + where type.HasMethods + from method in type.Methods + where method.HasBody + select method + ); + } + + /// Log a message for the player or developer the first time it occurs. + /// The monitor through which to log the message. + /// The hash of logged messages. + /// The message to log. + /// The log severity level. + private void LogOnce(IMonitor monitor, HashSet hash, string message, LogLevel level = LogLevel.Trace) + { + if (!hash.Contains(message)) + { + 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 +{ + /// Metadata about a parsed assembly definition. + internal class AssemblyParseResult + { + /********* + ** Accessors + *********/ + /// The original assembly file. + public readonly FileInfo File; + + /// The assembly definition. + public readonly AssemblyDefinition Definition; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The original assembly file. + /// The assembly definition. + 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/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs new file mode 100644 index 00000000..1ac167dc --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -0,0 +1,40 @@ +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// Metadata for a mod. + internal class ModMetadata + { + /********* + ** Accessors + *********/ + /// The mod's display name. + public string DisplayName { get; } + + /// The mod's full directory path. + public string DirectoryPath { get; } + + /// The mod manifest. + public IManifest Manifest { get; } + + /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + public ModCompatibility Compatibility { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's display name. + /// The mod's full directory path. + /// The mod manifest. + /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility) + { + this.DisplayName = displayName; + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Compatibility = compatibility; + } + } +} -- cgit From 66d2b5746ab063b89ca42525a78e217e71d00858 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 17:24:41 -0400 Subject: move mod metadata resolution into its own class (#285) --- .../Framework/ModLoading/ModResolver.cs | 300 +++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs (limited to 'src/StardewModdingAPI/Framework/ModLoading') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs new file mode 100644 index 00000000..450fe6bf --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// Finds and processes mod metadata. + internal class ModResolver + { + /********* + ** Properties + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Manages deprecation warnings. + private readonly DeprecationManager DeprecationManager; + + /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + private readonly ModCompatibility[] CompatibilityRecords; + + + /********* + ** Public methods + *********/ + public ModResolver(IMonitor monitor, DeprecationManager deprecationManager, IEnumerable compatibilityRecords) + { + this.Monitor = monitor; + this.DeprecationManager = deprecationManager; + this.CompatibilityRecords = compatibilityRecords.ToArray(); + } + + /// Find all mods in the given folder. + /// The root mod path to search. + /// The JSON helper with which to read the manifest file. + /// A list to populate with any deprecation warnings. + public ModMetadata[] FindMods(string rootPath, JsonHelper jsonHelper, IList deprecationWarnings) + { + this.Monitor.Log("Finding mods..."); + void LogSkip(string displayName, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {displayName} because {reasonPhrase}", level); + + // load mod metadata + List mods = new List(); + foreach (string modRootPath in Directory.GetDirectories(rootPath)) + { + if (this.Monitor.IsExiting) + return new ModMetadata[0]; // exit in progress + + // init metadata + string displayName = modRootPath.Replace(rootPath, "").Trim('/', '\\'); + + // passthrough empty directories + DirectoryInfo directory = new DirectoryInfo(modRootPath); + while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) + directory = directory.GetDirectories().First(); + + // get manifest path + string manifestPath = Path.Combine(directory.FullName, "manifest.json"); + if (!File.Exists(manifestPath)) + { + LogSkip(displayName, "it doesn't have a manifest.", LogLevel.Warn); + continue; + } + + // read manifest + Manifest manifest; + try + { + // read manifest file + string json = File.ReadAllText(manifestPath); + if (string.IsNullOrEmpty(json)) + { + LogSkip(displayName, "its manifest is empty."); + continue; + } + + // parse manifest + manifest = jsonHelper.ReadJsonFile(Path.Combine(directory.FullName, "manifest.json")); + if (manifest == null) + { + LogSkip(displayName, "its manifest is invalid."); + continue; + } + + // validate manifest + if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + { + LogSkip(displayName, "its manifest doesn't set an entry DLL."); + continue; + } + if (string.IsNullOrWhiteSpace(manifest.UniqueID)) + deprecationWarnings.Add(() => this.Monitor.Log($"{manifest.Name} doesn't have a {nameof(IManifest.UniqueID)} in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); + } + catch (Exception ex) + { + LogSkip(displayName, $"parsing its manifest failed:\n{ex.GetLogSummary()}"); + continue; + } + if (!string.IsNullOrWhiteSpace(manifest.Name)) + displayName = manifest.Name; + + // validate compatibility + ModCompatibility compatibility = this.GetCompatibilityRecord(manifest); + if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken) + { + bool hasOfficialUrl = !string.IsNullOrWhiteSpace(compatibility.UpdateUrl); + bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(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.UpperVersion} here:"; + if (hasOfficialUrl) + error += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}"; + if (hasUnofficialUrl) + error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; + + LogSkip(displayName, error); + } + + // validate SMAPI version + if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) + { + try + { + ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion); + if (minVersion.IsNewerThan(Constants.ApiVersion)) + { + LogSkip(displayName, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); + continue; + } + } + catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version")) + { + LogSkip(displayName, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + continue; + } + } + + // create per-save directory + if (manifest.PerSaveConfigs) + { + deprecationWarnings.Add(() => this.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); + try + { + string psDir = Path.Combine(directory.FullName, "psconfigs"); + Directory.CreateDirectory(psDir); + if (!Directory.Exists(psDir)) + { + LogSkip(displayName, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); + continue; + } + } + catch (Exception ex) + { + LogSkip(displayName, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); + continue; + } + } + + // validate DLL path + string assemblyPath = Path.Combine(directory.FullName, manifest.EntryDll); + if (!File.Exists(assemblyPath)) + { + LogSkip(displayName, $"its DLL '{manifest.EntryDll}' doesn't exist."); + continue; + } + + // add mod metadata + mods.Add(new ModMetadata(displayName, directory.FullName, manifest, compatibility)); + } + + return this.HandleModDependencies(mods.ToArray()); + } + + + /********* + ** Private methods + *********/ + /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. + /// The mod manifest. + /// Returns the incompatibility record if applicable, else null. + private ModCompatibility GetCompatibilityRecord(IManifest manifest) + { + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + return ( + from mod in this.CompatibilityRecords + where + mod.ID == key + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + select mod + ).FirstOrDefault(); + } + + /// Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. + /// The mods to process. + private ModMetadata[] HandleModDependencies(ModMetadata[] mods) + { + this.Monitor.Log("Checking mod dependencies..."); + var unsortedMods = mods.ToList(); + var sortedMods = new Stack(); + var visitedMods = new bool[unsortedMods.Count]; + var currentChain = new List(); + bool success = true; + + for (int index = 0; index < unsortedMods.Count; index++) + { + success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (!success) + break; + } + + if (!success) + { + // Failed to sort list, return no mods. + this.Monitor.Log("No mods will be loaded.", LogLevel.Error); + return new ModMetadata[0]; + } + + return sortedMods.Reverse().ToArray(); + } + + /// 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. + /// The index of the mod being processed in the . + /// The mods which have been processed. + /// The list in which to save mods sorted by dependency order. + /// The current change of mod dependencies. + /// The mods remaining to sort. + /// Returns whether the mod can be loaded. + private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + { + // visit mod + if (visitedMods[modIndex]) + return true; // already sorted + ModMetadata mod = unsortedMods[modIndex]; + visitedMods[modIndex] = true; + + // process dependencies + bool success = true; + if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) + { + // validate required dependencies are present + { + string missingMods = null; + foreach (IManifestDependency dependency in mod.Manifest.Dependencies) + { + if (!unsortedMods.Any(m => m.Manifest.UniqueID.Equals(dependency.UniqueID))) + missingMods += $"{dependency.UniqueID}, "; + } + if (missingMods != null) + { + this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + return false; + } + } + + // get mods which should be loaded before this one + ModMetadata[] modsToLoadFirst = + ( + from unsorted in unsortedMods + where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) + select unsorted + ) + .ToArray(); + + // detect circular references + ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); + if (circularReferenceMod != null) + { + this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); + string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; + for (int i = currentChain.Count - 1; i >= 0; i--) + { + chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; + if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; + } + this.Monitor.Log(chain, LogLevel.Error); + return false; + } + currentChain.Add(mod); + + // recursively sort dependencies + foreach (ModMetadata requiredMod in modsToLoadFirst) + { + int index = unsortedMods.IndexOf(requiredMod); + success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (!success) + break; + } + } + + // mark mod sorted + sortedMods.Push(mod); + currentChain.Remove(mod); + return success; + } + } +} -- cgit From 63edebaef1019ce103f5a86d55e1d1c4eb8d371c Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 18:20:09 -0400 Subject: decouple mod metadata resolution from main SMAPI logic (#285) This makes the logic more self-contained for eventual unit testing, and makes failed mods available during dependency resolution so we can make errors more relevant. --- .../Framework/ModLoading/ModMetadata.cs | 34 +++ .../Framework/ModLoading/ModResolver.cs | 267 ++++++++++----------- 2 files changed, 157 insertions(+), 144 deletions(-) (limited to 'src/StardewModdingAPI/Framework/ModLoading') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs index 1ac167dc..72c4692b 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -20,6 +20,12 @@ namespace StardewModdingAPI.Framework.ModLoading /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. public ModCompatibility Compatibility { get; } + /// The metadata resolution status. + public ModMetadataStatus Status { get; set; } + + /// The reason the metadata is invalid, if any. + public string Error { get; set; } + /********* ** Public methods @@ -30,11 +36,39 @@ namespace StardewModdingAPI.Framework.ModLoading /// The mod manifest. /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility) + : this(displayName, directoryPath, manifest, compatibility, ModMetadataStatus.Found, null) + { + this.DisplayName = displayName; + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Compatibility = compatibility; + } + + /// Construct an instance. + /// The mod's display name. + /// The mod's full directory path. + /// The mod manifest. + /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + /// The metadata resolution status. + /// The reason the metadata is invalid, if any. + public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility, ModMetadataStatus status, string error) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; this.Manifest = manifest; this.Compatibility = compatibility; + this.Status = status; + this.Error = error; } } + + /// Indicates the status of a mod's metadata resolution. + internal enum ModMetadataStatus + { + /// The mod has been found, but hasn't been processed yet. + Found, + + /// The mod cannot be loaded. + Failed + } } diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 450fe6bf..30c38aca 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -13,12 +13,6 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Properties *********/ - /// Encapsulates monitoring and logging. - private readonly IMonitor Monitor; - - /// Manages deprecation warnings. - private readonly DeprecationManager DeprecationManager; - /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. private readonly ModCompatibility[] CompatibilityRecords; @@ -26,78 +20,43 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Public methods *********/ - public ModResolver(IMonitor monitor, DeprecationManager deprecationManager, IEnumerable compatibilityRecords) + /// Construct an instance. + /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + public ModResolver(IEnumerable compatibilityRecords) { - this.Monitor = monitor; - this.DeprecationManager = deprecationManager; this.CompatibilityRecords = compatibilityRecords.ToArray(); } + /// Read mod metadata from the given folder in dependency order. + /// The root path to search for mods. + /// The JSON helper with which to read manifests. + public IEnumerable GetMods(string rootPath, JsonHelper jsonHelper) + { + ModMetadata[] mods = this.GetDataFromFolder(rootPath, jsonHelper).ToArray(); + mods = this.ProcessDependencies(mods.ToArray()); + return mods; + } + + + /********* + ** Private methods + *********/ /// Find all mods in the given folder. /// The root mod path to search. /// The JSON helper with which to read the manifest file. - /// A list to populate with any deprecation warnings. - public ModMetadata[] FindMods(string rootPath, JsonHelper jsonHelper, IList deprecationWarnings) + private IEnumerable GetDataFromFolder(string rootPath, JsonHelper jsonHelper) { - this.Monitor.Log("Finding mods..."); - void LogSkip(string displayName, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {displayName} because {reasonPhrase}", level); - // load mod metadata - List mods = new List(); - foreach (string modRootPath in Directory.GetDirectories(rootPath)) + foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) { - if (this.Monitor.IsExiting) - return new ModMetadata[0]; // exit in progress - - // init metadata - string displayName = modRootPath.Replace(rootPath, "").Trim('/', '\\'); - - // passthrough empty directories - DirectoryInfo directory = new DirectoryInfo(modRootPath); - while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) - directory = directory.GetDirectories().First(); - - // get manifest path - string manifestPath = Path.Combine(directory.FullName, "manifest.json"); - if (!File.Exists(manifestPath)) - { - LogSkip(displayName, "it doesn't have a manifest.", LogLevel.Warn); - continue; - } + string displayName = modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); // read manifest Manifest manifest; - try - { - // read manifest file - string json = File.ReadAllText(manifestPath); - if (string.IsNullOrEmpty(json)) - { - LogSkip(displayName, "its manifest is empty."); - continue; - } - - // parse manifest - manifest = jsonHelper.ReadJsonFile(Path.Combine(directory.FullName, "manifest.json")); - if (manifest == null) - { - LogSkip(displayName, "its manifest is invalid."); - continue; - } - - // validate manifest - if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - { - LogSkip(displayName, "its manifest doesn't set an entry DLL."); - continue; - } - if (string.IsNullOrWhiteSpace(manifest.UniqueID)) - deprecationWarnings.Add(() => this.Monitor.Log($"{manifest.Name} doesn't have a {nameof(IManifest.UniqueID)} in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); - } - catch (Exception ex) { - LogSkip(displayName, $"parsing its manifest failed:\n{ex.GetLogSummary()}"); - continue; + string manifestPath = Path.Combine(modDir.FullName, "manifest.json"); + if (!this.TryReadManifest(manifestPath, jsonHelper, out manifest, out string error)) + yield return new ModMetadata(displayName, modDir.FullName, null, null, ModMetadataStatus.Failed, error); } if (!string.IsNullOrWhiteSpace(manifest.Name)) displayName = manifest.Name; @@ -116,89 +75,35 @@ namespace StardewModdingAPI.Framework.ModLoading if (hasUnofficialUrl) error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; - LogSkip(displayName, error); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, error); } // validate SMAPI version if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) { - try - { - ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion); - if (minVersion.IsNewerThan(Constants.ApiVersion)) - { - LogSkip(displayName, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); - continue; - } - } - catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version")) - { - LogSkip(displayName, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); - continue; - } - } - - // create per-save directory - if (manifest.PerSaveConfigs) - { - deprecationWarnings.Add(() => this.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); - try - { - string psDir = Path.Combine(directory.FullName, "psconfigs"); - Directory.CreateDirectory(psDir); - if (!Directory.Exists(psDir)) - { - LogSkip(displayName, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); - continue; - } - } - catch (Exception ex) - { - LogSkip(displayName, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); - continue; - } + if (!SemanticVersion.TryParse(manifest.MinimumApiVersion, out ISemanticVersion minVersion)) + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + if (minVersion.IsNewerThan(Constants.ApiVersion)) + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); } // validate DLL path - string assemblyPath = Path.Combine(directory.FullName, manifest.EntryDll); + string assemblyPath = Path.Combine(modDir.FullName, manifest.EntryDll); if (!File.Exists(assemblyPath)) { - LogSkip(displayName, $"its DLL '{manifest.EntryDll}' doesn't exist."); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"its DLL '{manifest.EntryDll}' doesn't exist."); continue; } // add mod metadata - mods.Add(new ModMetadata(displayName, directory.FullName, manifest, compatibility)); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility); } - - return this.HandleModDependencies(mods.ToArray()); - } - - - /********* - ** Private methods - *********/ - /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. - /// The mod manifest. - /// Returns the incompatibility record if applicable, else null. - private ModCompatibility GetCompatibilityRecord(IManifest manifest) - { - string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; - return ( - from mod in this.CompatibilityRecords - where - mod.ID == key - && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) - && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) - select mod - ).FirstOrDefault(); } /// Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. /// The mods to process. - private ModMetadata[] HandleModDependencies(ModMetadata[] mods) + private ModMetadata[] ProcessDependencies(ModMetadata[] mods) { - this.Monitor.Log("Checking mod dependencies..."); var unsortedMods = mods.ToList(); var sortedMods = new Stack(); var visitedMods = new bool[unsortedMods.Count]; @@ -207,17 +112,16 @@ namespace StardewModdingAPI.Framework.ModLoading for (int index = 0; index < unsortedMods.Count; index++) { - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (unsortedMods[index].Status == ModMetadataStatus.Failed) + continue; + + success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } if (!success) - { - // Failed to sort list, return no mods. - this.Monitor.Log("No mods will be loaded.", LogLevel.Error); return new ModMetadata[0]; - } return sortedMods.Reverse().ToArray(); } @@ -229,14 +133,18 @@ namespace StardewModdingAPI.Framework.ModLoading /// The current change of mod dependencies. /// The mods remaining to sort. /// Returns whether the mod can be loaded. - private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + private bool ProcessDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) { // visit mod if (visitedMods[modIndex]) return true; // already sorted - ModMetadata mod = unsortedMods[modIndex]; visitedMods[modIndex] = true; + // mod already failed + ModMetadata mod = unsortedMods[modIndex]; + if (mod.Status == ModMetadataStatus.Failed) + return false; + // process dependencies bool success = true; if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) @@ -251,7 +159,8 @@ namespace StardewModdingAPI.Framework.ModLoading } if (missingMods != null) { - this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)})."; return false; } } @@ -269,14 +178,8 @@ namespace StardewModdingAPI.Framework.ModLoading ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); if (circularReferenceMod != null) { - this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); - string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; - for (int i = currentChain.Count - 1; i >= 0; i--) - { - chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; - if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; - } - this.Monitor.Log(chain, LogLevel.Error); + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."; return false; } currentChain.Add(mod); @@ -285,7 +188,7 @@ namespace StardewModdingAPI.Framework.ModLoading foreach (ModMetadata requiredMod in modsToLoadFirst) { int index = unsortedMods.IndexOf(requiredMod); - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } @@ -296,5 +199,81 @@ namespace StardewModdingAPI.Framework.ModLoading currentChain.Remove(mod); return success; } + + /// Get all mod folders in a root folder, passing through empty folders as needed. + /// The root folder path to search. + private IEnumerable 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; + } + } + + /// Read a manifest file if it's valid, else set a relevant error phrase. + /// The absolute path to the manifest file. + /// The JSON helper with which to read the manifest file. + /// The loaded manifest, if reading succeeded. + /// The read error, if reading failed. + /// Returns whether the manifest was read successfully. + private bool TryReadManifest(string path, JsonHelper jsonHelper, out Manifest manifest, out string errorPhrase) + { + try + { + // validate path + if (!File.Exists(path)) + { + manifest = null; + errorPhrase = "it doesn't have a manifest."; + return false; + } + + // parse manifest + manifest = jsonHelper.ReadJsonFile(path); + if (manifest == null) + { + errorPhrase = "its manifest is invalid."; + return false; + } + + // validate manifest + if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + { + errorPhrase = "its manifest doesn't set an entry DLL."; + return false; + } + + errorPhrase = null; + return true; + } + catch (Exception ex) + { + manifest = null; + errorPhrase = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; + return false; + } + } + + /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. + /// The mod manifest. + /// Returns the incompatibility record if applicable, else null. + private ModCompatibility GetCompatibilityRecord(IManifest manifest) + { + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + return ( + from mod in this.CompatibilityRecords + where + mod.ID == key + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + select mod + ).FirstOrDefault(); + } } } -- cgit From 9b6c0d1021b07ec04b589f1bd0eb69e36082b600 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 18:58:19 -0400 Subject: decouple reading manifest files from validating metadata (#285) --- .../Framework/ModLoading/ModMetadata.cs | 15 +- .../Framework/ModLoading/ModResolver.cs | 219 +++++++++------------ 2 files changed, 98 insertions(+), 136 deletions(-) (limited to 'src/StardewModdingAPI/Framework/ModLoading') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs index 72c4692b..7be85a83 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -36,7 +36,6 @@ namespace StardewModdingAPI.Framework.ModLoading /// The mod manifest. /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility) - : this(displayName, directoryPath, manifest, compatibility, ModMetadataStatus.Found, null) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; @@ -44,21 +43,15 @@ namespace StardewModdingAPI.Framework.ModLoading this.Compatibility = compatibility; } - /// Construct an instance. - /// The mod's display name. - /// The mod's full directory path. - /// The mod manifest. - /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + /// Set the mod status. /// The metadata resolution status. /// The reason the metadata is invalid, if any. - public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility, ModMetadataStatus status, string error) + /// Return the instance for chaining. + public ModMetadata SetStatus(ModMetadataStatus status, string error = null) { - this.DisplayName = displayName; - this.DirectoryPath = directoryPath; - this.Manifest = manifest; - this.Compatibility = compatibility; this.Status = status; this.Error = error; + return this; } } diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 30c38aca..829575af 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -10,99 +10,12