using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; 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 mod toolkit. /// The root path to search for mods. /// Handles access to SMAPI's internal mod metadata list. /// Returns the manifests by relative folder. public IEnumerable ReadManifests(ModToolkit toolkit, string rootPath, ModDatabase modDatabase) { foreach (ModFolder folder in toolkit.GetModFolders(rootPath)) { Manifest? manifest = folder.Manifest; // parse internal data record (if any) ModDataRecordVersionedFields? dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest); // apply defaults if (manifest != null && dataRecord?.UpdateKey is not null) manifest.OverrideUpdateKeys(dataRecord.UpdateKey); // build metadata bool shouldIgnore = folder.Type == ModType.Ignored; ModMetadataStatus status = folder.ManifestParseError == ModParseError.None || shouldIgnore ? ModMetadataStatus.Found : ModMetadataStatus.Failed; IModMetadata metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore); if (shouldIgnore) metadata.SetStatus(status, ModFailReason.DisabledByDotConvention, "disabled by dot convention"); else metadata.SetStatus(status, ModFailReason.InvalidManifest, folder.ManifestParseErrorText); yield return metadata; } } /// Validate manifest metadata. /// The mod manifests to validate. /// The current SMAPI version. /// Get an update URL for an update key (if valid). /// Whether to validate that files referenced in the manifest (like ) exist on disk. This can be disabled to only validate the manifest itself. [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifest values may be null before they're validated.")] [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Manifest values may be null before they're validated.")] public void ValidateManifests(IEnumerable mods, ISemanticVersion apiVersion, Func getUpdateUrl, bool validateFilesExist = true) { mods = mods.ToArray(); // validate each manifest foreach (IModMetadata mod in mods) { // skip if already failed if (mod.Status == ModMetadataStatus.Failed) continue; // validate compatibility from internal data switch (mod.DataRecord?.Status) { case ModStatus.Obsolete: mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Obsolete, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}", this.GetTechnicalReasonForStatusOverride(mod)); continue; case ModStatus.AssumeBroken: { // get reason string reasonPhrase = mod.DataRecord.StatusReasonPhrase ?? "it's no longer compatible"; // get update URLs List updateUrls = new List(); foreach (UpdateKey key in mod.GetUpdateKeys(validOnly: true)) { string? url = getUpdateUrl(key.ToString()); if (url != null) updateUrls.Add(url); } // default update URL updateUrls.Add("https://smapi.io/mods"); // build error string error = $"{reasonPhrase}. Please check for a "; if (mod.DataRecord.StatusUpperVersion == null || mod.Manifest.Version?.Equals(mod.DataRecord.StatusUpperVersion) == true) error += "newer version"; else error += $"version newer than {mod.DataRecord.StatusUpperVersion}"; error += " at " + string.Join(" or ", updateUrls); mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, error, this.GetTechnicalReasonForStatusOverride(mod)); } continue; } // validate SMAPI version if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); continue; } // validate DLL / content pack fields { bool hasDll = !string.IsNullOrWhiteSpace(mod.Manifest.EntryDll); bool isContentPack = mod.Manifest.ContentPackFor != null; // validate field presence if (!hasDll && !isContentPack) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); continue; } if (hasDll && isContentPack) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); continue; } // validate DLL if (hasDll) { // invalid filename format if (mod.Manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); continue; } // file doesn't exist if (validateFilesExist) { string fileName = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath).GetFilePath(mod.Manifest.EntryDll!); if (!File.Exists(Path.Combine(mod.DirectoryPath, fileName))) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); continue; } } } // validate content pack else { // invalid content pack ID if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor!.UniqueID)) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); 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.0") missingFields.Add(nameof(IManifest.Version)); if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) missingFields.Add(nameof(IManifest.UniqueID)); if (missingFields.Any()) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); continue; } } // validate ID format if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); // validate dependencies foreach (IManifestDependency? dependency in mod.Manifest.Dependencies) { // null dependency if (dependency == null) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a null entry under {nameof(IManifest.Dependencies)}."); continue; } // missing ID if (string.IsNullOrWhiteSpace(dependency.UniqueID)) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."); continue; } // invalid ID if (!PathUtilities.IsSlug(dependency.UniqueID)) mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); } } // validate IDs are unique { var duplicatesByID = mods .GroupBy(mod => mod.Manifest?.UniqueID?.Trim(), mod => mod, StringComparer.OrdinalIgnoreCase) .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 string folderList = string.Join(", ", group.Select(p => p.GetRelativePathWithRoot()).OrderBy(p => p)); mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, $"you have multiple copies of this mod installed. To fix this, delete these folders and reinstall the mod: {folderList}."); } } } } /// Sort the given mods by the order they should be loaded. /// The mods to process. /// Handles access to SMAPI's internal mod metadata list. public IEnumerable ProcessDependencies(IEnumerable mods, ModDatabase modDatabase) { // initialize metadata mods = mods.ToArray(); var sortedMods = new Stack(); var states = mods.ToDictionary(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(), modDatabase, 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. /// Handles access to SMAPI's internal mod metadata list. /// 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, ModDatabase modDatabase, 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]}'."); } // collect dependencies ModDependency[] dependencies = this.GetDependenciesFrom(mod.Manifest, mods).ToArray(); // mark sorted if no dependencies if (!dependencies.Any()) { sortedMods.Push(mod); return states[mod] = ModDependencyStatus.Sorted; } // mark failed if missing dependencies { string[] failedModNames = ( from entry in dependencies where entry.IsRequired && entry.Mod == null let displayName = modDatabase.Get(entry.ID)?.DisplayName ?? entry.ID let modUrl = modDatabase.GetModPageUrlFor(entry.ID) orderby displayName select modUrl != null ? $"{displayName}: {modUrl}" : displayName ).ToArray(); if (failedModNames.Any()) { sortedMods.Push(mod); mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it requires mods which aren't installed ({string.Join(", ", failedModNames)})."); 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, ModFailReason.MissingDependencies, $"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 (ModDependency dependency in dependencies) { IModMetadata? requiredMod = dependency.Mod; if (requiredMod == null) continue; // missing dependencies are handled earlier // detect dependency loop var subchain = new List(currentChain) { mod }; if (states[requiredMod] == ModDependencyStatus.Checking) { sortedMods.Push(mod); mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"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, modDatabase, requiredMod, states, sortedMods, subchain); switch (subStatus) { // sorted successfully case ModDependencyStatus.Sorted: case ModDependencyStatus.Failed when !dependency.IsRequired: // ignore failed optional dependency break; // failed, which means this mod can't be loaded either case ModDependencyStatus.Failed: sortedMods.Push(mod); mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"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 the dependencies declared in a manifest. /// The mod manifest. /// The loaded mods. private IEnumerable GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) { IModMetadata? FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id)); // yield dependencies foreach (IManifestDependency entry in manifest.Dependencies) yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired); // yield content pack parent if (manifest.ContentPackFor != null) yield return new ModDependency(manifest.ContentPackFor.UniqueID, manifest.ContentPackFor.MinimumVersion, FindMod(manifest.ContentPackFor.UniqueID), isRequired: true); } /// Get a technical message indicating why a mod's compatibility status was overridden, if applicable. /// The mod metadata. private string? GetTechnicalReasonForStatusOverride(IModMetadata mod) { // get compatibility list record ModDataRecordVersionedFields? data = mod.DataRecord; if (data == null) return null; // get status label string statusLabel = data.Status switch { ModStatus.AssumeBroken => "'assume broken'", ModStatus.AssumeCompatible => "'assume compatible'", ModStatus.Obsolete => "obsolete", _ => data.Status.ToString() }; // get reason string?[] reasons = new[] { data.StatusReasonPhrase, data.StatusReasonDetails } .Where(p => !string.IsNullOrWhiteSpace(p)) .ToArray(); // build message return $"marked {statusLabel} in SMAPI's internal compatibility list for " + (data.StatusUpperVersion != null ? $"versions up to {data.StatusUpperVersion}" : "all versions") + ": " + (reasons.Any() ? string.Join(": ", reasons) : "no reason given") + "."; } /********* ** Private models *********/ /// Represents a dependency from one mod to another. private readonly struct ModDependency { /********* ** Accessors *********/ /// The unique ID of the required mod. public string ID { get; } /// The minimum required version (if any). public ISemanticVersion? MinVersion { get; } /// Whether the mod shouldn't be loaded if the dependency isn't available. public bool IsRequired { get; } /// The loaded mod that fulfills the dependency (if available). public IModMetadata? Mod { get; } /********* ** Public methods *********/ /// Construct an instance. /// The unique ID of the required mod. /// The minimum required version (if any). /// The loaded mod that fulfills the dependency (if available). /// Whether the mod shouldn't be loaded if the dependency isn't available. public ModDependency(string id, ISemanticVersion? minVersion, IModMetadata? mod, bool isRequired) { this.ID = id; this.MinVersion = minVersion; this.Mod = mod; this.IsRequired = isRequired; } } } }