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;
}
}
}
}