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 { /********* ** Public methods *********/ /// Get manifest metadata for each folder in the given root path. /// The root path to search for mods. /// The JSON helper with which to read manifests. /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. /// Returns the manifests by relative folder. public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable 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(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 == key && (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); } } /// Validate manifest metadata. /// The mod manifests to validate. /// The current SMAPI version. public void ValidateManifests(IEnumerable 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.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."); } } /// Sort the given mods by the order they should be loaded. /// The mods to process. public IEnumerable ProcessDependencies(IEnumerable mods) { var unsortedMods = mods.ToList(); var sortedMods = new Stack(); var visitedMods = new HashSet(); var currentChain = new List(); bool success = true; foreach (IModMetadata mod in unsortedMods) { if (mod.Status == ModMetadataStatus.Failed) continue; success = this.ProcessDependencies(mod, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } if (!success) return new ModMetadata[0]; return sortedMods.Reverse().ToArray(); } /********* ** Private methods *********/ /// 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 mod whose dependencies to process. /// The mods which have been visited. /// 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 ProcessDependencies(IModMetadata mod, HashSet visited, Stack sortedMods, List currentChain, List unsortedMods) { // visit mod if (visited.Contains(mod)) return true; // already sorted visited.Add(mod); // mod already failed if (mod.Status == ModMetadataStatus.Failed) return false; // 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) { mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)})."); return false; } } // get mods which should be loaded before this one IModMetadata[] modsToLoadFirst = ( from unsorted in unsortedMods where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) select unsorted ) .ToArray(); // detect circular references IModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); if (circularReferenceMod != null) { mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."); return false; } currentChain.Add(mod); // recursively sort dependencies foreach (IModMetadata requiredMod in modsToLoadFirst) { success = this.ProcessDependencies(requiredMod, visited, sortedMods, currentChain, unsortedMods); if (!success) break; } } // mark mod sorted sortedMods.Push(mod); 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; } } } }