summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2017-05-13 18:20:09 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2017-05-13 18:20:09 -0400
commit63edebaef1019ce103f5a86d55e1d1c4eb8d371c (patch)
treea61b1ebd08324477b049699b63f405a5025555d6 /src/StardewModdingAPI
parent66d2b5746ab063b89ca42525a78e217e71d00858 (diff)
downloadSMAPI-63edebaef1019ce103f5a86d55e1d1c4eb8d371c.tar.gz
SMAPI-63edebaef1019ce103f5a86d55e1d1c4eb8d371c.tar.bz2
SMAPI-63edebaef1019ce103f5a86d55e1d1c4eb8d371c.zip
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.
Diffstat (limited to 'src/StardewModdingAPI')
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs34
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs267
-rw-r--r--src/StardewModdingAPI/Program.cs51
-rw-r--r--src/StardewModdingAPI/SemanticVersion.cs17
4 files changed, 219 insertions, 150 deletions
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
/// <summary>Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary>
public ModCompatibility Compatibility { get; }
+ /// <summary>The metadata resolution status.</summary>
+ public ModMetadataStatus Status { get; set; }
+
+ /// <summary>The reason the metadata is invalid, if any.</summary>
+ public string Error { get; set; }
+
/*********
** Public methods
@@ -30,11 +36,39 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="manifest">The mod manifest.</param>
/// <param name="compatibility">Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param>
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;
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="displayName">The mod's display name.</param>
+ /// <param name="directoryPath">The mod's full directory path.</param>
+ /// <param name="manifest">The mod manifest.</param>
+ /// <param name="compatibility">Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param>
+ /// <param name="status">The metadata resolution status.</param>
+ /// <param name="error">The reason the metadata is invalid, if any.</param>
+ 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;
}
}
+
+ /// <summary>Indicates the status of a mod's metadata resolution.</summary>
+ internal enum ModMetadataStatus
+ {
+ /// <summary>The mod has been found, but hasn't been processed yet.</summary>
+ Found,
+
+ /// <summary>The mod cannot be loaded.</summary>
+ 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
*********/
- /// <summary>Encapsulates monitoring and logging.</summary>
- private readonly IMonitor Monitor;
-
- /// <summary>Manages deprecation warnings.</summary>
- private readonly DeprecationManager DeprecationManager;
-
/// <summary>Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary>
private readonly ModCompatibility[] CompatibilityRecords;
@@ -26,78 +20,43 @@ namespace StardewModdingAPI.Framework.ModLoading
/*********
** Public methods
*********/
- public ModResolver(IMonitor monitor, DeprecationManager deprecationManager, IEnumerable<ModCompatibility> compatibilityRecords)
+ /// <summary>Construct an instance.</summary>
+ /// <param name="compatibilityRecords">Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param>
+ public ModResolver(IEnumerable<ModCompatibility> compatibilityRecords)
{
- this.Monitor = monitor;
- this.DeprecationManager = deprecationManager;
this.CompatibilityRecords = compatibilityRecords.ToArray();
}
+ /// <summary>Read mod metadata from the given folder in dependency order.</summary>
+ /// <param name="rootPath">The root path to search for mods.</param>
+ /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
+ public IEnumerable<ModMetadata> GetMods(string rootPath, JsonHelper jsonHelper)
+ {
+ ModMetadata[] mods = this.GetDataFromFolder(rootPath, jsonHelper).ToArray();
+ mods = this.ProcessDependencies(mods.ToArray());
+ return mods;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
/// <summary>Find all mods in the given folder.</summary>
/// <param name="rootPath">The root mod path to search.</param>
/// <param name="jsonHelper">The JSON helper with which to read the manifest file.</param>
- /// <param name="deprecationWarnings">A list to populate with any deprecation warnings.</param>
- public ModMetadata[] FindMods(string rootPath, JsonHelper jsonHelper, IList<Action> deprecationWarnings)
+ private IEnumerable<ModMetadata> 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<ModMetadata> mods = new List<ModMetadata>();
- 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<Manifest>(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
- *********/
- /// <summary>Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code.</summary>
- /// <param name="manifest">The mod manifest.</param>
- /// <returns>Returns the incompatibility record if applicable, else <c>null</c>.</returns>
- 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();
}
/// <summary>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.</summary>
/// <param name="mods">The mods to process.</param>
- private ModMetadata[] HandleModDependencies(ModMetadata[] mods)
+ private ModMetadata[] ProcessDependencies(ModMetadata[] mods)
{
- this.Monitor.Log("Checking mod dependencies...");
var unsortedMods = mods.ToList();
var sortedMods = new Stack<ModMetadata>();
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
/// <param name="currentChain">The current change of mod dependencies.</param>
/// <param name="unsortedMods">The mods remaining to sort.</param>
/// <returns>Returns whether the mod can be loaded.</returns>
- private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack<ModMetadata> sortedMods, List<ModMetadata> currentChain, List<ModMetadata> unsortedMods)
+ private bool ProcessDependencies(int modIndex, bool[] visitedMods, Stack<ModMetadata> sortedMods, List<ModMetadata> currentChain, List<ModMetadata> 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;
}
+
+ /// <summary>Get all mod folders in a root folder, passing through empty folders as needed.</summary>
+ /// <param name="rootPath">The root folder path to search.</param>
+ private IEnumerable<DirectoryInfo> 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;
+ }
+ }
+
+ /// <summary>Read a manifest file if it's valid, else set a relevant error phrase.</summary>
+ /// <param name="path">The absolute path to the manifest file.</param>
+ /// <param name="jsonHelper">The JSON helper with which to read the manifest file.</param>
+ /// <param name="manifest">The loaded manifest, if reading succeeded.</param>
+ /// <param name="errorPhrase">The read error, if reading failed.</param>
+ /// <returns>Returns whether the manifest was read successfully.</returns>
+ 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<Manifest>(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;
+ }
+ }
+
+ /// <summary>Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code.</summary>
+ /// <param name="manifest">The mod manifest.</param>
+ /// <returns>Returns the incompatibility record if applicable, else <c>null</c>.</returns>
+ 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();
+ }
}
}
diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs
index 7b421895..c8840538 100644
--- a/src/StardewModdingAPI/Program.cs
+++ b/src/StardewModdingAPI/Program.cs
@@ -313,14 +313,45 @@ namespace StardewModdingAPI
// load mods
int modsLoaded;
{
- // load mods
+ // get mod metadata (in dependency order)
+ this.Monitor.Log("Loading mod metadata...");
JsonHelper jsonHelper = new JsonHelper();
+ ModMetadata[] mods = new ModResolver(this.Settings.ModCompatibility)
+ .GetMods(Constants.ModPath, new JsonHelper())
+ .ToArray();
+
+ // check for deprecated metadata
IList<Action> deprecationWarnings = new List<Action>();
- ModMetadata[] mods = new ModResolver(this.Monitor, this.DeprecationManager, this.Settings.ModCompatibility)
- .FindMods(Constants.ModPath, new JsonHelper(), deprecationWarnings);
- modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings);
+ foreach (ModMetadata mod in mods)
+ {
+ // missing unique ID
+ if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID))
+ deprecationWarnings.Add(() => this.Monitor.Log($"{mod.DisplayName} doesn't have specify a {nameof(IManifest.UniqueID)} field in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn));
- // log deprecation warnings together
+ // per-save directories
+ if ((mod.Manifest as Manifest)?.PerSaveConfigs == true)
+ {
+ deprecationWarnings.Add(() => this.DeprecationManager.Warn(mod.DisplayName, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info));
+ try
+ {
+ string psDir = Path.Combine(mod.DirectoryPath, "psconfigs");
+ Directory.CreateDirectory(psDir);
+ if (!Directory.Exists(psDir))
+ {
+ mod.Status = ModMetadataStatus.Failed;
+ mod.Error = "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason.";
+ }
+ }
+ catch (Exception ex)
+ {
+ mod.Status = ModMetadataStatus.Failed;
+ mod.Error = $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}";
+ }
+ }
+ }
+
+ // load mods
+ modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings);
foreach (Action warning in deprecationWarnings)
warning();
}
@@ -474,9 +505,17 @@ namespace StardewModdingAPI
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name);
foreach (ModMetadata metadata in mods)
{
+ // validate status
+ if (metadata.Status == ModMetadataStatus.Failed)
+ {
+ LogSkip(metadata, metadata.Error);
+ continue;
+ }
+
+ // get basic info
IManifest manifest = metadata.Manifest;
string assemblyPath = Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll);
-
+
// preprocess & load mod assembly
Assembly modAssembly;
try
diff --git a/src/StardewModdingAPI/SemanticVersion.cs b/src/StardewModdingAPI/SemanticVersion.cs
index db25dc11..a2adb657 100644
--- a/src/StardewModdingAPI/SemanticVersion.cs
+++ b/src/StardewModdingAPI/SemanticVersion.cs
@@ -182,6 +182,23 @@ namespace StardewModdingAPI
return result;
}
+ /// <summary>Parse a version string without throwing an exception if it fails.</summary>
+ /// <param name="version">The version string.</param>
+ /// <param name="parsed">The parsed representation.</param>
+ /// <returns>Returns whether parsing the version succeeded.</returns>
+ internal static bool TryParse(string version, out ISemanticVersion parsed)
+ {
+ try
+ {
+ parsed = new SemanticVersion(version);
+ return true;
+ }
+ catch
+ {
+ parsed = null;
+ return false;
+ }
+ }
/*********
** Private methods