using System; using System.Collections.Generic; using System.IO; using System.Linq; using StardewModdingAPI.Framework.Exceptions; 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 from SMAPI's internal data. /// Returns the manifests by relative folder. public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable dataRecords) { dataRecords = dataRecords.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 (SParseException ex) { error = $"parsing its manifest failed: {ex.Message}"; } catch (Exception ex) { error = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; } // get internal data record (if any) ModDataRecord dataRecord = null; if (manifest != null) { string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; dataRecord = dataRecords.FirstOrDefault(record => record.ID.Matches(key, manifest)); } // add default update keys if (manifest != null && manifest.UpdateKeys == null && dataRecord?.UpdateKeys != null) manifest.UpdateKeys = dataRecord.UpdateKeys; // 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, dataRecord).SetStatus(status, error); } } /// Validate manifest metadata. /// The mod manifests to validate. /// The current SMAPI version. /// Maps vendor keys (like Nexus) to their mod URL template (where {0} is the mod ID). public void ValidateManifests(IEnumerable mods, ISemanticVersion apiVersion, IDictionary vendorModUrls) { mods = mods.ToArray(); // validate each manifest foreach (IModMetadata mod in mods) { // skip if already failed if (mod.Status == ModMetadataStatus.Failed) continue; // validate compatibility ModCompatibility compatibility = mod.DataRecord?.GetCompatibility(mod.Manifest.Version); switch (compatibility?.Status) { case ModStatus.Obsolete: mod.SetStatus(ModMetadataStatus.Failed, $"it's obsolete: {compatibility.ReasonPhrase}"); continue; case ModStatus.AssumeBroken: { // get reason string reasonPhrase = compatibility.ReasonPhrase ?? "it's no longer compatible"; // get update URLs List updateUrls = new List(); foreach (string key in mod.Manifest.UpdateKeys ?? new string[0]) { string[] parts = key.Split(new[] { ':' }, 2); if (parts.Length != 2) continue; string vendorKey = parts[0].Trim(); string modID = parts[1].Trim(); if (vendorModUrls.TryGetValue(vendorKey, out string urlTemplate)) updateUrls.Add(string.Format(urlTemplate, modID)); } if (mod.DataRecord.AlternativeUrl != null) updateUrls.Add(mod.DataRecord.AlternativeUrl); // build error string error = $"{reasonPhrase}. Please check for a "; if (mod.Manifest.Version.Equals(compatibility.UpperVersion)) error += "newer version"; else error += $"version newer than {compatibility.UpperVersion}"; error += " at " + string.Join(" or ", updateUrls); mod.SetStatus(ModMetadataStatus.Failed, error); } continue; } // validate SMAPI version if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true) { mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); continue; } // validate DLL value if (string.IsNullOrWhiteSpace(mod.Manifest.EntryDll)) { mod.SetStatus(ModMetadataStatus.Failed, "its manifest has no EntryDLL field."); continue; } if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) { mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); 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."); continue; } // validate required fields { List missingFields = new List(3); if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) missingFields.Add(nameof(IManifest.Name)); if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0") missingFields.Add(nameof(IManifest.Version)); if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) missingFields.Add(nameof(IManifest.UniqueID)); if (missingFields.Any()) mod.SetStatus(ModMetadataStatus.Failed, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); } } // validate IDs are unique { var duplicatesByID = mods .GroupBy(mod => mod.Manifest?.UniqueID?.Trim(), mod => mod, StringComparer.InvariantCultureIgnoreCase) .Where(p => p.Count() > 1); foreach (var group in duplicatesByID) { foreach (IModMetadata mod in group) { if (mod.Status == ModMetadataStatus.Failed) continue; // don't replace metadata error mod.SetStatus(ModMetadataStatus.Failed, $"its unique ID '{mod.Manifest.UniqueID}' is used by multiple mods ({string.Join(", ", group.Select(p => p.DisplayName))})."); } } } } /// Sort the given mods by the order they should be loaded. /// The mods to process. public IEnumerable ProcessDependencies(IEnumerable mods) { // initialise metadata mods = mods.ToArray(); var sortedMods = new Stack(); 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()); return sortedMods.Reverse(); } /********* ** 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 full list of mods being validated. /// The mod whose dependencies to process. /// The dependency state for each mod. /// The list in which to save mods sorted by dependency order. /// The current change of mod dependencies. /// Returns the mod dependency status. private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, IModMetadata mod, IDictionary states, Stack sortedMods, ICollection 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; } // get dependencies var dependencies = ( from entry in mod.Manifest.Dependencies let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) orderby entry.UniqueID select new { ID = entry.UniqueID, MinVersion = entry.MinimumVersion, Mod = dependencyMod, IsRequired = entry.IsRequired } ) .ToArray(); // missing required dependencies, mark failed { string[] failedIDs = (from entry in dependencies where entry.IsRequired && entry.Mod == null select entry.ID).ToArray(); if (failedIDs.Any()) { sortedMods.Push(mod); mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedIDs)})."); return states[mod] = ModDependencyStatus.Failed; } } // dependency min version not met, mark failed { string[] failedLabels = ( from entry in dependencies where entry.Mod != null && entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)" ) .ToArray(); if (failedLabels.Any()) { sortedMods.Push(mod); mod.SetStatus(ModMetadataStatus.Failed, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); return states[mod] = ModDependencyStatus.Failed; } } // process dependencies { states[mod] = ModDependencyStatus.Checking; // recursively sort dependencies foreach (var dependency in dependencies) { IModMetadata requiredMod = dependency.Mod; var subchain = new List(currentChain) { mod }; // ignore missing optional dependency if (!dependency.IsRequired && requiredMod == null) continue; // 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; } } /// 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; } } } }