Merge branch 'develop' into stable
This commit is contained in:
commit
b95d2a3f93
|
@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed.
|
|||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<!--set general build properties -->
|
||||
<Version>3.17.2</Version>
|
||||
<Version>3.18.0</Version>
|
||||
<Product>SMAPI</Product>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
|
||||
|
|
|
@ -7,6 +7,23 @@
|
|||
_If needed, you can update to SMAPI 3.16.0 first and then install the latest version._
|
||||
-->
|
||||
|
||||
## 3.18.0
|
||||
Released 12 November 2022 for Stardew Valley 1.5.6 or later. See [release highlights](https://www.patreon.com/posts/74565278).
|
||||
|
||||
* For players:
|
||||
* You can now override the mod load order in `smapi-internal/config.json` (thanks to Shockah!).
|
||||
* You can now disable console input in `smapi-internal/config.json`, which may reduce CPU usage on some Linux systems.
|
||||
* Fixed map edits not always applied for farmhands in multiplayer (thanks to SinZ163!).
|
||||
* Internal changes to prepare for the upcoming Stardew Valley 1.6 and SMAPI 4.0.
|
||||
|
||||
* For mod authors:
|
||||
* Optimized asset name comparisons (thanks to atravita!).
|
||||
* Raised all deprecation messages to the 'pending removal' level.
|
||||
* **This is the last major update before SMAPI 4.0.0, which will drop all deprecated APIs.** If you haven't [fixed deprecation warnings in your mod code](https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_4.0) (if any), you should do it soon. SMAPI 4.0.0 will release alongside the upcoming Stardew Valley 1.6.
|
||||
|
||||
* For the web UI:
|
||||
* The log parser now detects split-screen mode and shows which screen logged each message.
|
||||
|
||||
## 3.17.2
|
||||
Released 21 October 2022 for Stardew Valley 1.5.6 or later.
|
||||
|
||||
|
|
|
@ -412,6 +412,9 @@ The NuGet package is generated automatically in `StardewModdingAPI.ModBuildConfi
|
|||
when you compile it.
|
||||
|
||||
## Release notes
|
||||
## Upcoming release
|
||||
* Added `manifest.json` format validation on build (thanks to tylergibbs2!).
|
||||
|
||||
### 4.0.2
|
||||
Released 09 October 2022.
|
||||
|
||||
|
|
|
@ -7,7 +7,11 @@ using System.Reflection;
|
|||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Build.Framework;
|
||||
using Microsoft.Build.Utilities;
|
||||
using Newtonsoft.Json;
|
||||
using StardewModdingAPI.ModBuildConfig.Framework;
|
||||
using StardewModdingAPI.Toolkit.Framework;
|
||||
using StardewModdingAPI.Toolkit.Serialization;
|
||||
using StardewModdingAPI.Toolkit.Serialization.Models;
|
||||
using StardewModdingAPI.Toolkit.Utilities;
|
||||
|
||||
namespace StardewModdingAPI.ModBuildConfig
|
||||
|
@ -75,9 +79,41 @@ namespace StardewModdingAPI.ModBuildConfig
|
|||
this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}");
|
||||
}
|
||||
|
||||
// skip if nothing to do
|
||||
// (This must be checked before the manifest validation, to allow cases like unit test projects.)
|
||||
if (!this.EnableModDeploy && !this.EnableModZip)
|
||||
return true; // nothing to do
|
||||
return true;
|
||||
|
||||
// validate the manifest file
|
||||
IManifest manifest;
|
||||
{
|
||||
try
|
||||
{
|
||||
string manifestPath = Path.Combine(this.ProjectDir, "manifest.json");
|
||||
if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest))
|
||||
{
|
||||
this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist.");
|
||||
return false;
|
||||
}
|
||||
manifest = rawManifest;
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
// log the inner exception, otherwise the message will be generic
|
||||
Exception exToShow = ex.InnerException ?? ex;
|
||||
this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// validate manifest fields
|
||||
if (!ManifestValidator.TryValidateFields(manifest, out string error))
|
||||
{
|
||||
this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// deploy files
|
||||
try
|
||||
{
|
||||
// parse extra DLLs to bundle
|
||||
|
@ -101,7 +137,7 @@ namespace StardewModdingAPI.ModBuildConfig
|
|||
// create release zip
|
||||
if (this.EnableModZip)
|
||||
{
|
||||
string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {package.GetManifestVersion()}.zip");
|
||||
string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip");
|
||||
string zipPath = Path.Combine(this.ModZipPath, zipName);
|
||||
|
||||
this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}...");
|
||||
|
|
|
@ -3,8 +3,6 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StardewModdingAPI.Toolkit.Serialization;
|
||||
using StardewModdingAPI.Toolkit.Serialization.Models;
|
||||
using StardewModdingAPI.Toolkit.Utilities;
|
||||
|
||||
namespace StardewModdingAPI.ModBuildConfig.Framework
|
||||
|
@ -113,16 +111,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
|
|||
return new Dictionary<string, FileInfo>(this.Files, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Get a semantic version from the mod manifest.</summary>
|
||||
/// <exception cref="UserErrorException">The manifest is missing or invalid.</exception>
|
||||
public string GetManifestVersion()
|
||||
{
|
||||
if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest))
|
||||
throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor
|
||||
|
||||
return manifest.Version.ToString();
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private methods
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"Name": "Console Commands",
|
||||
"Author": "SMAPI",
|
||||
"Version": "3.17.2",
|
||||
"Version": "3.18.0",
|
||||
"Description": "Adds SMAPI console commands that let you manipulate the game.",
|
||||
"UniqueID": "SMAPI.ConsoleCommands",
|
||||
"EntryDll": "ConsoleCommands.dll",
|
||||
"MinimumApiVersion": "3.17.2"
|
||||
"MinimumApiVersion": "3.18.0"
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"Name": "Error Handler",
|
||||
"Author": "SMAPI",
|
||||
"Version": "3.17.2",
|
||||
"Version": "3.18.0",
|
||||
"Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
|
||||
"UniqueID": "SMAPI.ErrorHandler",
|
||||
"EntryDll": "ErrorHandler.dll",
|
||||
"MinimumApiVersion": "3.17.2"
|
||||
"MinimumApiVersion": "3.18.0"
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"Name": "Save Backup",
|
||||
"Author": "SMAPI",
|
||||
"Version": "3.17.2",
|
||||
"Version": "3.18.0",
|
||||
"Description": "Automatically backs up all your saves once per day into its folder.",
|
||||
"UniqueID": "SMAPI.SaveBackup",
|
||||
"EntryDll": "SaveBackup.dll",
|
||||
"MinimumApiVersion": "3.17.2"
|
||||
"MinimumApiVersion": "3.18.0"
|
||||
}
|
||||
|
|
|
@ -151,6 +151,12 @@ namespace SMAPI.Tests.Core
|
|||
|
||||
// with locale codes
|
||||
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)]
|
||||
|
||||
// prefix ends with path separator
|
||||
[TestCase("Data/Events/Boop", "Data/Events/", ExpectedResult = true)]
|
||||
[TestCase("Data/Events/Boop", "Data/Events\\", ExpectedResult = true)]
|
||||
[TestCase("Data/Events", "Data/Events/", ExpectedResult = false)]
|
||||
[TestCase("Data/Events", "Data/Events\\", ExpectedResult = false)]
|
||||
public bool StartsWith_SimpleCases(string mainAssetName, string prefix)
|
||||
{
|
||||
// arrange
|
||||
|
@ -243,6 +249,41 @@ namespace SMAPI.Tests.Core
|
|||
return result;
|
||||
}
|
||||
|
||||
[TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", true, ExpectedResult = true)]
|
||||
[TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", false, ExpectedResult = false)]
|
||||
[TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)]
|
||||
[TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)]
|
||||
public bool StartsWith_PartialMatchInPathSegment(string mainAssetName, string otherAssetName, bool allowSubfolder)
|
||||
{
|
||||
// arrange
|
||||
mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
|
||||
|
||||
// act
|
||||
AssetName name = AssetName.Parse(mainAssetName, _ => null);
|
||||
|
||||
// assert value
|
||||
return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder);
|
||||
}
|
||||
|
||||
// The enumerator strips the trailing path separator, so each of these cases has to be handled on each branch.
|
||||
[TestCase("Mods/SomeMod", "Mods/", false, ExpectedResult = true)]
|
||||
[TestCase("Mods/SomeMod", "Mods", false, ExpectedResult = false)]
|
||||
[TestCase("Mods/Jasper/Data", "Mods/Jas/", false, ExpectedResult = false)]
|
||||
[TestCase("Mods/Jasper/Data", "Mods/Jas", false, ExpectedResult = false)]
|
||||
[TestCase("Mods/Jas", "Mods/Jas/", false, ExpectedResult = false)]
|
||||
[TestCase("Mods/Jas", "Mods/Jas", false, ExpectedResult = true)]
|
||||
public bool StartsWith_PrefixHasSeparator(string mainAssetName, string otherAssetName, bool allowSubfolder)
|
||||
{
|
||||
// arrange
|
||||
mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
|
||||
|
||||
// act
|
||||
AssetName name = AssetName.Parse(mainAssetName, _ => null);
|
||||
|
||||
// assert value
|
||||
return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder);
|
||||
}
|
||||
|
||||
|
||||
/****
|
||||
** GetHashCode
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using FluentAssertions.Execution;
|
||||
using NUnit.Framework;
|
||||
using StardewModdingAPI.Framework.Models;
|
||||
|
||||
namespace SMAPI.Tests.Core
|
||||
{
|
||||
/// <summary>Unit tests which validate assumptions about .NET used in the SMAPI implementation.</summary>
|
||||
[TestFixture]
|
||||
internal class AssumptionTests
|
||||
{
|
||||
/*********
|
||||
** Unit tests
|
||||
*********/
|
||||
/****
|
||||
** Constructor
|
||||
****/
|
||||
[Test(Description = $"Assert that {nameof(HashSet<string>)} maintains insertion order when no elements are removed. If this fails, we'll need to change the implementation for the {nameof(SConfig.ModsToLoadEarly)} and {nameof(SConfig.ModsToLoadLate)} options.")]
|
||||
[TestCase("construct from array")]
|
||||
[TestCase("add incrementally")]
|
||||
public void HashSet_MaintainsInsertionOrderWhenNoElementsAreRemoved(string populateMethod)
|
||||
{
|
||||
// arrange
|
||||
string[] inserted = Enumerable.Range(0, 1000)
|
||||
.Select(_ => Guid.NewGuid().ToString("N"))
|
||||
.ToArray();
|
||||
|
||||
// act
|
||||
HashSet<string> set;
|
||||
switch (populateMethod)
|
||||
{
|
||||
case "construct from array":
|
||||
set = new(inserted, StringComparer.OrdinalIgnoreCase);
|
||||
break;
|
||||
|
||||
case "add incrementally":
|
||||
set = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string value in inserted)
|
||||
set.Add(value);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new AssertionFailedException($"Unknown populate method '{populateMethod}'.");
|
||||
}
|
||||
|
||||
// assert
|
||||
string[] actualOrder = set.ToArray();
|
||||
actualOrder.Should().HaveCount(inserted.Length);
|
||||
for (int i = 0; i < inserted.Length; i++)
|
||||
{
|
||||
string expected = inserted[i];
|
||||
string actual = actualOrder[i];
|
||||
|
||||
if (actual != expected)
|
||||
throw new AssertionFailedException($"The hash set differed at index {i}: expected {expected}, but found {actual} instead.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StardewModdingAPI.Toolkit.Utilities;
|
||||
|
||||
namespace StardewModdingAPI.Toolkit.Framework
|
||||
{
|
||||
/// <summary>Validates manifest fields.</summary>
|
||||
public static class ManifestValidator
|
||||
{
|
||||
/// <summary>Validate a manifest's fields.</summary>
|
||||
/// <param name="manifest">The manifest to validate.</param>
|
||||
/// <param name="error">The error message indicating why validation failed, if applicable.</param>
|
||||
/// <returns>Returns whether all manifest fields validated successfully.</returns>
|
||||
[SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method that ensures those annotations are respected.")]
|
||||
public static bool TryValidateFields(IManifest manifest, out string error)
|
||||
{
|
||||
//
|
||||
// Note: SMAPI assumes that it can grammatically append the returned sentence in the
|
||||
// form "failed loading <mod> because its <error>". Any errors returned should be valid
|
||||
// in that format, unless the SMAPI call is adjusted accordingly.
|
||||
//
|
||||
|
||||
bool hasDll = !string.IsNullOrWhiteSpace(manifest.EntryDll);
|
||||
bool isContentPack = manifest.ContentPackFor != null;
|
||||
|
||||
// validate use of EntryDll vs ContentPackFor fields
|
||||
if (hasDll == isContentPack)
|
||||
{
|
||||
error = hasDll
|
||||
? $"manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."
|
||||
: $"manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// validate EntryDll/ContentPackFor format
|
||||
if (hasDll)
|
||||
{
|
||||
if (manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any())
|
||||
{
|
||||
error = $"manifest has invalid filename '{manifest.EntryDll}' for the {nameof(IManifest.EntryDll)} field.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifest.ContentPackFor!.UniqueID))
|
||||
{
|
||||
error = $"manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// validate required fields
|
||||
{
|
||||
List<string> missingFields = new List<string>(3);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Name))
|
||||
missingFields.Add(nameof(IManifest.Name));
|
||||
if (manifest.Version == null || manifest.Version.ToString() == "0.0.0")
|
||||
missingFields.Add(nameof(IManifest.Version));
|
||||
if (string.IsNullOrWhiteSpace(manifest.UniqueID))
|
||||
missingFields.Add(nameof(IManifest.UniqueID));
|
||||
|
||||
if (missingFields.Any())
|
||||
{
|
||||
error = $"manifest is missing required fields ({string.Join(", ", missingFields)}).";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// validate ID format
|
||||
if (!PathUtilities.IsSlug(manifest.UniqueID))
|
||||
{
|
||||
error = "manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).";
|
||||
return false;
|
||||
}
|
||||
|
||||
// validate dependency format
|
||||
foreach (IManifestDependency? dependency in manifest.Dependencies)
|
||||
{
|
||||
if (dependency == null)
|
||||
{
|
||||
error = $"manifest has a null entry under {nameof(IManifest.Dependencies)}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dependency.UniqueID))
|
||||
{
|
||||
error = $"manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PathUtilities.IsSlug(dependency.UniqueID))
|
||||
{
|
||||
error = $"manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens).";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -108,6 +108,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
|
|||
}
|
||||
}
|
||||
|
||||
// detect split-screen mode
|
||||
if (message.ScreenId != 0)
|
||||
log.IsSplitScreen = true;
|
||||
|
||||
// collect SMAPI metadata
|
||||
if (message.Mod == "SMAPI")
|
||||
{
|
||||
|
|
|
@ -22,6 +22,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models
|
|||
/// <summary>The raw log text.</summary>
|
||||
public string? RawText { get; set; }
|
||||
|
||||
/// <summary>Whether there are messages from multiple screens in the log.</summary>
|
||||
public bool IsSplitScreen { get; set; }
|
||||
|
||||
/****
|
||||
** Log data
|
||||
****/
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script>
|
||||
<script src="~/Content/js/file-upload.js"></script>
|
||||
<script src="~/Content/js/log-parser.js?r=20220409"></script>
|
||||
<script src="~/Content/js/log-parser.js"></script>
|
||||
|
||||
<script id="serializedData" type="application/json">
|
||||
@if (!Model.ShowRaw)
|
||||
|
@ -86,7 +86,8 @@
|
|||
showMods: @this.ForJson(log?.Mods.Where(p => p.Loaded && !p.IsContentPack).Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, _ => true)),
|
||||
showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)),
|
||||
showLevels: @this.ForJson(defaultFilters),
|
||||
enableFilters: @this.ForJson(!Model.ShowRaw)
|
||||
enableFilters: @this.ForJson(!Model.ShowRaw),
|
||||
isSplitScreen: @this.ForJson(log?.IsSplitScreen ?? false)
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -494,7 +495,6 @@ else if (log?.IsValid == true)
|
|||
<log-line
|
||||
v-for="msg in visibleMessages"
|
||||
v-bind:key="msg.id"
|
||||
v-bind:showScreenId="showScreenId"
|
||||
v-bind:message="msg"
|
||||
v-bind:highlight="shouldHighlight"
|
||||
/>
|
||||
|
|
|
@ -424,10 +424,6 @@ smapi.logParser = function (state) {
|
|||
Vue.component("log-line", {
|
||||
functional: true,
|
||||
props: {
|
||||
showScreenId: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
|
@ -456,7 +452,7 @@ smapi.logParser = function (state) {
|
|||
"td",
|
||||
{
|
||||
attrs: {
|
||||
colspan: context.props.showScreenId ? 4 : 3
|
||||
colspan: state.isSplitScreen ? 4 : 3
|
||||
}
|
||||
},
|
||||
""
|
||||
|
@ -541,7 +537,7 @@ smapi.logParser = function (state) {
|
|||
},
|
||||
[
|
||||
createElement("td", message.Time),
|
||||
context.props.showScreenId ? createElement("td", message.ScreenId) : null,
|
||||
state.isSplitScreen ? createElement("td", { attrs: { title: (message.ScreenId == 0 ? "main screen" : "screen #" + (message.ScreenId + 1)) + " in split-screen mode" } }, `🖵${message.ScreenId + 1}`) : null,
|
||||
createElement("td", level.toUpperCase()),
|
||||
createElement(
|
||||
"td",
|
||||
|
@ -588,9 +584,6 @@ smapi.logParser = function (state) {
|
|||
anyModsShown: function () {
|
||||
return stats.modsShown > 0;
|
||||
},
|
||||
showScreenId: function () {
|
||||
return this.data.screenIds.length > 1;
|
||||
},
|
||||
|
||||
// Maybe not strictly necessary, but the Vue template is being
|
||||
// weird about accessing data entries on the app rather than
|
||||
|
|
|
@ -52,7 +52,7 @@ namespace StardewModdingAPI
|
|||
internal static int? LogScreenId { get; set; }
|
||||
|
||||
/// <summary>SMAPI's current raw semantic version.</summary>
|
||||
internal static string RawApiVersion = "3.17.2";
|
||||
internal static string RawApiVersion = "3.18.0";
|
||||
}
|
||||
|
||||
/// <summary>Contains SMAPI's constants and assumptions.</summary>
|
||||
|
@ -71,7 +71,7 @@ namespace StardewModdingAPI
|
|||
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.6");
|
||||
|
||||
/// <summary>The maximum supported version of Stardew Valley, if any.</summary>
|
||||
public static ISemanticVersion? MaximumGameVersion { get; } = null;
|
||||
public static ISemanticVersion? MaximumGameVersion { get; } = new GameVersion("1.5.6");
|
||||
|
||||
/// <summary>The target game platform.</summary>
|
||||
public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform;
|
||||
|
@ -90,7 +90,7 @@ namespace StardewModdingAPI
|
|||
source: null,
|
||||
nounPhrase: $"{nameof(Constants)}.{nameof(Constants.ExecutionPath)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info
|
||||
severity: DeprecationLevel.PendingRemoval
|
||||
);
|
||||
|
||||
return Constants.GamePath;
|
||||
|
|
|
@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.Content
|
|||
source: null,
|
||||
nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info,
|
||||
severity: DeprecationLevel.PendingRemoval,
|
||||
unlessStackIncludes: new[]
|
||||
{
|
||||
$"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}",
|
||||
|
@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Content
|
|||
source: null,
|
||||
nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info,
|
||||
severity: DeprecationLevel.PendingRemoval,
|
||||
unlessStackIncludes: new[]
|
||||
{
|
||||
$"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}",
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
using System;
|
||||
using StardewModdingAPI.Toolkit.Utilities;
|
||||
using StardewModdingAPI.Utilities.AssetPathUtilities;
|
||||
using StardewValley;
|
||||
using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
|
||||
|
||||
namespace StardewModdingAPI.Framework.Content
|
||||
{
|
||||
|
@ -94,10 +96,26 @@ namespace StardewModdingAPI.Framework.Content
|
|||
if (string.IsNullOrWhiteSpace(assetName))
|
||||
return false;
|
||||
|
||||
assetName = PathUtilities.NormalizeAssetName(assetName);
|
||||
AssetNamePartEnumerator curParts = new(useBaseName ? this.BaseName : this.Name);
|
||||
AssetNamePartEnumerator otherParts = new(assetName.AsSpan().Trim());
|
||||
|
||||
string compareTo = useBaseName ? this.BaseName : this.Name;
|
||||
return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase);
|
||||
while (true)
|
||||
{
|
||||
bool curHasMore = curParts.MoveNext();
|
||||
bool otherHasMore = otherParts.MoveNext();
|
||||
|
||||
// mismatch: lengths differ
|
||||
if (otherHasMore != curHasMore)
|
||||
return false;
|
||||
|
||||
// match: both reached the end without a mismatch
|
||||
if (!curHasMore)
|
||||
return true;
|
||||
|
||||
// mismatch: current segment is different
|
||||
if (!curParts.Current.Equals(otherParts.Current, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -119,50 +137,84 @@ namespace StardewModdingAPI.Framework.Content
|
|||
if (prefix is null)
|
||||
return false;
|
||||
|
||||
string rawTrimmed = prefix.Trim();
|
||||
// get initial values
|
||||
ReadOnlySpan<char> trimmedPrefix = prefix.AsSpan().Trim();
|
||||
if (trimmedPrefix.Length == 0)
|
||||
return true;
|
||||
ReadOnlySpan<char> pathSeparators = new(ToolkitPathUtilities.PossiblePathSeparators); // just to simplify calling other span APIs
|
||||
|
||||
// asset keys can't have a leading slash, but NormalizeAssetName will trim them
|
||||
if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\'))
|
||||
// asset keys can't have a leading slash, but AssetPathYielder will trim them
|
||||
if (pathSeparators.Contains(trimmedPrefix[0]))
|
||||
return false;
|
||||
|
||||
// normalize prefix
|
||||
// compare segments
|
||||
AssetNamePartEnumerator curParts = new(this.Name);
|
||||
AssetNamePartEnumerator prefixParts = new(trimmedPrefix);
|
||||
while (true)
|
||||
{
|
||||
string normalized = PathUtilities.NormalizeAssetName(prefix);
|
||||
bool curHasMore = curParts.MoveNext();
|
||||
bool prefixHasMore = prefixParts.MoveNext();
|
||||
|
||||
// keep trailing slash
|
||||
if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\'))
|
||||
normalized += PathUtilities.PreferredAssetSeparator;
|
||||
// reached end for one side
|
||||
if (prefixHasMore != curHasMore)
|
||||
{
|
||||
// mismatch: prefix is longer
|
||||
if (prefixHasMore)
|
||||
return false;
|
||||
|
||||
prefix = normalized;
|
||||
// match: every segment in the prefix matched and subfolders are allowed (e.g. prefix 'Data/Events' with target 'Data/Events/Beach')
|
||||
if (allowSubfolder)
|
||||
return true;
|
||||
|
||||
// Special case: the prefix ends with a path separator, but subfolders aren't allowed. This case
|
||||
// matches if there's no further path separator in the asset name *after* the current separator.
|
||||
// For example, the prefix 'A/B/' matches 'A/B/C' but not 'A/B/C/D'.
|
||||
return pathSeparators.Contains(trimmedPrefix[^1]) && curParts.Remainder.Length == 0;
|
||||
}
|
||||
|
||||
// previous segments matched exactly and both reached the end
|
||||
// match if prefix doesn't end with '/' (which should only match subfolders)
|
||||
if (!prefixHasMore)
|
||||
return !pathSeparators.Contains(trimmedPrefix[^1]);
|
||||
|
||||
// compare segment
|
||||
if (curParts.Current.Length == prefixParts.Current.Length)
|
||||
{
|
||||
// mismatch: segments aren't equivalent
|
||||
if (!curParts.Current.Equals(prefixParts.Current, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// mismatch: prefix has more beyond this, and this segment isn't an exact match
|
||||
if (prefixParts.Remainder.Length != 0)
|
||||
return false;
|
||||
|
||||
// mismatch: cur segment doesn't start with prefix
|
||||
if (!curParts.Current.StartsWith(prefixParts.Current, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
// mismatch: something like "Maps/" would need an exact match
|
||||
if (pathSeparators.Contains(trimmedPrefix[^1]))
|
||||
return false;
|
||||
|
||||
// mismatch: partial word match not allowed, and the first or last letter of the suffix isn't a word separator
|
||||
if (!allowPartialWord && char.IsLetterOrDigit(prefixParts.Current[^1]) && char.IsLetterOrDigit(curParts.Current[prefixParts.Current.Length]))
|
||||
return false;
|
||||
|
||||
// possible match
|
||||
return allowSubfolder || (pathSeparators.Contains(trimmedPrefix[^1]) ? curParts.Remainder.IndexOfAny(ToolkitPathUtilities.PossiblePathSeparators) < 0 : curParts.Remainder.Length == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// compare
|
||||
if (prefix.Length == 0)
|
||||
return true;
|
||||
|
||||
return
|
||||
this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& (
|
||||
allowPartialWord
|
||||
|| this.Name.Length == prefix.Length
|
||||
|| !char.IsLetterOrDigit(prefix[^1]) // last character in suffix is word separator
|
||||
|| !char.IsLetterOrDigit(this.Name[prefix.Length]) // or first character after it is
|
||||
)
|
||||
&& (
|
||||
allowSubfolder
|
||||
|| this.Name.Length == prefix.Length
|
||||
|| !this.Name[prefix.Length..].Contains(PathUtilities.PreferredAssetSeparator)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsDirectlyUnderPath(string? assetFolder)
|
||||
{
|
||||
if (assetFolder is null)
|
||||
return false;
|
||||
|
||||
return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false);
|
||||
return this.StartsWith(assetFolder + ToolkitPathUtilities.PreferredPathSeparator, allowPartialWord: false, allowSubfolder: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
|
@ -129,6 +129,7 @@ namespace StardewModdingAPI.Framework
|
|||
/// <param name="rootDirectory">The root directory to search for content.</param>
|
||||
/// <param name="currentCulture">The current culture for which to localize content.</param>
|
||||
/// <param name="monitor">Encapsulates monitoring and logging.</param>
|
||||
/// <param name="multiplayer">The multiplayer instance whose map cache to update during asset propagation.</param>
|
||||
/// <param name="reflection">Simplifies access to private code.</param>
|
||||
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
|
||||
/// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param>
|
||||
|
@ -136,7 +137,7 @@ namespace StardewModdingAPI.Framework
|
|||
/// <param name="getFileLookup">Get a file lookup for the given directory.</param>
|
||||
/// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param>
|
||||
/// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param>
|
||||
public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations)
|
||||
public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Multiplayer multiplayer, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations)
|
||||
{
|
||||
this.GetFileLookup = getFileLookup;
|
||||
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
|
||||
|
@ -177,7 +178,7 @@ namespace StardewModdingAPI.Framework
|
|||
this.ContentManagers.Add(contentManagerForAssetPropagation);
|
||||
|
||||
this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory);
|
||||
this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, name => this.ParseAssetName(name, allowLocales: true));
|
||||
this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, multiplayer, reflection, name => this.ParseAssetName(name, allowLocales: true));
|
||||
this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty<ModLanguage>()));
|
||||
}
|
||||
|
||||
|
|
|
@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.Deprecations
|
|||
foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase))
|
||||
{
|
||||
// build message
|
||||
string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase}) and will break in the upcoming SMAPI 4.0.0.";
|
||||
string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase}) and will break in the next major SMAPI update.";
|
||||
|
||||
// get log level
|
||||
LogLevel level;
|
||||
|
|
|
@ -43,7 +43,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
|||
source: this.Mod,
|
||||
nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.ConsoleCommands)}.{nameof(ICommandHelper.Trigger)}",
|
||||
version: "3.8.1",
|
||||
severity: DeprecationLevel.Info
|
||||
severity: DeprecationLevel.PendingRemoval
|
||||
);
|
||||
|
||||
return this.CommandManager.Trigger(name, arguments);
|
||||
|
|
|
@ -62,7 +62,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
|||
source: this.Mod,
|
||||
nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetLoaders)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info
|
||||
severity: DeprecationLevel.PendingRemoval
|
||||
);
|
||||
|
||||
return this.ObservableAssetLoaders;
|
||||
|
@ -78,7 +78,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
|||
source: this.Mod,
|
||||
nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetEditors)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info
|
||||
severity: DeprecationLevel.PendingRemoval
|
||||
);
|
||||
|
||||
return this.ObservableAssetEditors;
|
||||
|
|
|
@ -41,7 +41,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
|||
source: this.Mod,
|
||||
nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.Content)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info
|
||||
severity: DeprecationLevel.PendingRemoval
|
||||
);
|
||||
|
||||
return this.ContentImpl;
|
||||
|
|
|
@ -4,11 +4,11 @@ using System.Diagnostics.CodeAnalysis;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using StardewModdingAPI.Toolkit;
|
||||
using StardewModdingAPI.Toolkit.Framework;
|
||||
using StardewModdingAPI.Toolkit.Framework.ModData;
|
||||
using StardewModdingAPI.Toolkit.Framework.ModScanning;
|
||||
using StardewModdingAPI.Toolkit.Framework.UpdateData;
|
||||
using StardewModdingAPI.Toolkit.Serialization.Models;
|
||||
using StardewModdingAPI.Toolkit.Utilities;
|
||||
using StardewModdingAPI.Toolkit.Utilities.PathLookups;
|
||||
|
||||
namespace StardewModdingAPI.Framework.ModLoading
|
||||
|
@ -126,101 +126,24 @@ namespace StardewModdingAPI.Framework.ModLoading
|
|||
continue;
|
||||
}
|
||||
|
||||
// validate DLL / content pack fields
|
||||
// validate manifest format
|
||||
if (!ManifestValidator.TryValidateFields(mod.Manifest, out string manifestError))
|
||||
{
|
||||
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)
|
||||
{
|
||||
IFileLookup pathLookup = getFileLookup(mod.DirectoryPath);
|
||||
FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!);
|
||||
if (!file.Exists)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {manifestError}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// validate required fields
|
||||
// check that DLL exists if applicable
|
||||
if (!string.IsNullOrEmpty(mod.Manifest.EntryDll) && validateFilesExist)
|
||||
{
|
||||
List<string> missingFields = new List<string>(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())
|
||||
IFileLookup pathLookup = getFileLookup(mod.DirectoryPath);
|
||||
FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!);
|
||||
if (!file.Exists)
|
||||
{
|
||||
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest is missing required fields ({string.Join(", ", missingFields)}).");
|
||||
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
|
||||
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
|
||||
|
@ -242,10 +165,38 @@ namespace StardewModdingAPI.Framework.ModLoading
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply preliminary overrides to the load order based on the SMAPI configuration.</summary>
|
||||
/// <param name="mods">The mods to process.</param>
|
||||
/// <param name="modIdsToLoadEarly">The mod IDs SMAPI should load before any other mods (except those needed to load them).</param>
|
||||
/// <param name="modIdsToLoadLate">The mod IDs SMAPI should load after any other mods.</param>
|
||||
public IModMetadata[] ApplyLoadOrderOverrides(IModMetadata[] mods, HashSet<string> modIdsToLoadEarly, HashSet<string> modIdsToLoadLate)
|
||||
{
|
||||
if (!modIdsToLoadEarly.Any() && !modIdsToLoadLate.Any())
|
||||
return mods;
|
||||
|
||||
string[] earlyArray = modIdsToLoadEarly.ToArray();
|
||||
string[] lateArray = modIdsToLoadLate.ToArray();
|
||||
|
||||
return mods
|
||||
.OrderBy(mod =>
|
||||
{
|
||||
string id = mod.Manifest.UniqueID;
|
||||
|
||||
if (modIdsToLoadEarly.TryGetValue(id, out string? actualId))
|
||||
return -int.MaxValue + Array.IndexOf(earlyArray, actualId);
|
||||
|
||||
if (modIdsToLoadLate.TryGetValue(id, out actualId))
|
||||
return int.MaxValue - Array.IndexOf(lateArray, actualId);
|
||||
|
||||
return 0;
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Sort the given mods by the order they should be loaded.</summary>
|
||||
/// <param name="mods">The mods to process.</param>
|
||||
/// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param>
|
||||
public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods, ModDatabase modDatabase)
|
||||
public IEnumerable<IModMetadata> ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase)
|
||||
{
|
||||
// initialize metadata
|
||||
mods = mods.ToArray();
|
||||
|
@ -261,7 +212,7 @@ namespace StardewModdingAPI.Framework.ModLoading
|
|||
|
||||
// sort mods
|
||||
foreach (IModMetadata mod in mods)
|
||||
this.ProcessDependencies(mods.ToArray(), modDatabase, mod, states, sortedMods, new List<IModMetadata>());
|
||||
this.ProcessDependencies(mods, modDatabase, mod, states, sortedMods, new List<IModMetadata>());
|
||||
|
||||
return sortedMods.Reverse();
|
||||
}
|
||||
|
@ -278,7 +229,7 @@ namespace StardewModdingAPI.Framework.ModLoading
|
|||
/// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param>
|
||||
/// <param name="currentChain">The current change of mod dependencies.</param>
|
||||
/// <returns>Returns the mod dependency status.</returns>
|
||||
private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain)
|
||||
private ModDependencyStatus ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain)
|
||||
{
|
||||
// check if already visited
|
||||
switch (states[mod])
|
||||
|
@ -409,7 +360,7 @@ namespace StardewModdingAPI.Framework.ModLoading
|
|||
/// <summary>Get the dependencies declared in a manifest.</summary>
|
||||
/// <param name="manifest">The mod manifest.</param>
|
||||
/// <param name="loadedMods">The loaded mods.</param>
|
||||
private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods)
|
||||
private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IReadOnlyList<IModMetadata> loadedMods)
|
||||
{
|
||||
IModMetadata? FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id));
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ namespace StardewModdingAPI.Framework.Models
|
|||
private static readonly IDictionary<string, object> DefaultValues = new Dictionary<string, object>
|
||||
{
|
||||
[nameof(CheckForUpdates)] = true,
|
||||
[nameof(ListenForConsoleInput)] = true,
|
||||
[nameof(ParanoidWarnings)] = Constants.IsDebugBuild,
|
||||
[nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(),
|
||||
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
|
||||
|
@ -48,6 +49,9 @@ namespace StardewModdingAPI.Framework.Models
|
|||
/// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary>
|
||||
public bool CheckForUpdates { get; set; }
|
||||
|
||||
/// <summary>Whether SMAPI should listen for console input to support console commands.</summary>
|
||||
public bool ListenForConsoleInput { get; set; }
|
||||
|
||||
/// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary>
|
||||
public bool ParanoidWarnings { get; set; }
|
||||
|
||||
|
@ -82,28 +86,38 @@ namespace StardewModdingAPI.Framework.Models
|
|||
/// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary>
|
||||
public HashSet<string> SuppressUpdateChecks { get; set; }
|
||||
|
||||
/// <summary>The mod IDs SMAPI should load before any other mods (except those needed to load them).</summary>
|
||||
public HashSet<string> ModsToLoadEarly { get; set; }
|
||||
|
||||
/// <summary>The mod IDs SMAPI should load after any other mods.</summary>
|
||||
public HashSet<string> ModsToLoadLate { get; set; }
|
||||
|
||||
|
||||
/********
|
||||
** Public methods
|
||||
********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="developerMode">Whether to enable development features.</param>
|
||||
/// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param>
|
||||
/// <param name="paranoidWarnings">Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</param>
|
||||
/// <param name="useBetaChannel">Whether to show beta versions as valid updates.</param>
|
||||
/// <param name="gitHubProjectName">SMAPI's GitHub project name, used to perform update checks.</param>
|
||||
/// <param name="webApiBaseUrl">The base URL for SMAPI's web API, used to perform update checks.</param>
|
||||
/// <param name="verboseLogging">The log contexts for which to enable verbose logging, which may show a lot more information to simplify troubleshooting.</param>
|
||||
/// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</param>
|
||||
/// <param name="useCaseInsensitivePaths">>Whether to make SMAPI file APIs case-insensitive, even on Linux.</param>
|
||||
/// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param>
|
||||
/// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param>
|
||||
/// <param name="suppressHarmonyDebugMode">Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and creates a file on your desktop. Debug mode should never be enabled by a released mod.</param>
|
||||
/// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param>
|
||||
public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks)
|
||||
/// <param name="developerMode"><inheritdoc cref="DeveloperMode" path="/summary" /></param>
|
||||
/// <param name="checkForUpdates"><inheritdoc cref="CheckForUpdates" path="/summary" /></param>
|
||||
/// <param name="listenForConsoleInput"><inheritdoc cref="ListenForConsoleInput" path="/summary" /></param>
|
||||
/// <param name="paranoidWarnings"><inheritdoc cref="ParanoidWarnings" path="/summary" /></param>
|
||||
/// <param name="useBetaChannel"><inheritdoc cref="UseBetaChannel" path="/summary" /></param>
|
||||
/// <param name="gitHubProjectName"><inheritdoc cref="GitHubProjectName" path="/summary" /></param>
|
||||
/// <param name="webApiBaseUrl"><inheritdoc cref="WebApiBaseUrl" path="/summary" /></param>
|
||||
/// <param name="verboseLogging"><inheritdoc cref="VerboseLogging" path="/summary" /></param>
|
||||
/// <param name="rewriteMods"><inheritdoc cref="RewriteMods" path="/summary" /></param>
|
||||
/// <param name="useCaseInsensitivePaths"><inheritdoc cref="UseCaseInsensitivePaths" path="/summary" /></param>
|
||||
/// <param name="logNetworkTraffic"><inheritdoc cref="LogNetworkTraffic" path="/summary" /></param>
|
||||
/// <param name="consoleColors"><inheritdoc cref="ConsoleColors" path="/summary" /></param>
|
||||
/// <param name="suppressHarmonyDebugMode"><inheritdoc cref="SuppressHarmonyDebugMode" path="/summary" /></param>
|
||||
/// <param name="suppressUpdateChecks"><inheritdoc cref="SuppressUpdateChecks" path="/summary" /></param>
|
||||
/// <param name="modsToLoadEarly"><inheritdoc cref="ModsToLoadEarly" path="/summary" /></param>
|
||||
/// <param name="modsToLoadLate"><inheritdoc cref="ModsToLoadLate" path="/summary" /></param>
|
||||
public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate)
|
||||
{
|
||||
this.DeveloperMode = developerMode;
|
||||
this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)];
|
||||
this.ListenForConsoleInput = listenForConsoleInput ?? (bool)SConfig.DefaultValues[nameof(this.ListenForConsoleInput)];
|
||||
this.ParanoidWarnings = paranoidWarnings ?? (bool)SConfig.DefaultValues[nameof(this.ParanoidWarnings)];
|
||||
this.UseBetaChannel = useBetaChannel ?? (bool)SConfig.DefaultValues[nameof(this.UseBetaChannel)];
|
||||
this.GitHubProjectName = gitHubProjectName;
|
||||
|
@ -115,6 +129,8 @@ namespace StardewModdingAPI.Framework.Models
|
|||
this.ConsoleColors = consoleColors;
|
||||
this.SuppressHarmonyDebugMode = suppressHarmonyDebugMode ?? (bool)SConfig.DefaultValues[nameof(this.SuppressHarmonyDebugMode)];
|
||||
this.SuppressUpdateChecks = new HashSet<string>(suppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
this.ModsToLoadEarly = new HashSet<string>(modsToLoadEarly ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
this.ModsToLoadLate = new HashSet<string>(modsToLoadLate ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Override the value of <see cref="DeveloperMode"/>.</summary>
|
||||
|
@ -136,6 +152,12 @@ namespace StardewModdingAPI.Framework.Models
|
|||
custom[name] = value;
|
||||
}
|
||||
|
||||
if (this.ModsToLoadEarly.Any())
|
||||
custom[nameof(this.ModsToLoadEarly)] = $"[{string.Join(", ", this.ModsToLoadEarly)}]";
|
||||
|
||||
if (this.ModsToLoadLate.Any())
|
||||
custom[nameof(this.ModsToLoadLate)] = $"[{string.Join(", ", this.ModsToLoadLate)}]";
|
||||
|
||||
if (!this.SuppressUpdateChecks.SetEquals(SConfig.DefaultSuppressUpdateChecks))
|
||||
custom[nameof(this.SuppressUpdateChecks)] = $"[{string.Join(", ", this.SuppressUpdateChecks)}]";
|
||||
|
||||
|
|
|
@ -423,8 +423,29 @@ namespace StardewModdingAPI.Framework
|
|||
this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot).");
|
||||
mods = mods.Where(p => !p.IsIgnored).ToArray();
|
||||
|
||||
// load mods
|
||||
// validate manifests
|
||||
resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl, getFileLookup: this.GetFileLookup);
|
||||
|
||||
// apply load order customizations
|
||||
if (this.Settings.ModsToLoadEarly.Any() || this.Settings.ModsToLoadLate.Any())
|
||||
{
|
||||
HashSet<string> installedIds = new HashSet<string>(mods.Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string[] missingEarlyMods = this.Settings.ModsToLoadEarly.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
string[] missingLateMods = this.Settings.ModsToLoadLate.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
string[] duplicateMods = this.Settings.ModsToLoadLate.Where(id => this.Settings.ModsToLoadEarly.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
if (missingEarlyMods.Any())
|
||||
this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadEarly)} which aren't installed: '{string.Join("', '", missingEarlyMods)}'.", LogLevel.Warn);
|
||||
if (missingLateMods.Any())
|
||||
this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadLate)} which aren't installed: '{string.Join("', '", missingLateMods)}'.", LogLevel.Warn);
|
||||
if (duplicateMods.Any())
|
||||
this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs which are in both {nameof(this.Settings.ModsToLoadEarly)} and {nameof(this.Settings.ModsToLoadLate)}: '{string.Join("', '", duplicateMods)}'. These will be loaded early.", LogLevel.Warn);
|
||||
|
||||
mods = resolver.ApplyLoadOrderOverrides(mods, this.Settings.ModsToLoadEarly, this.Settings.ModsToLoadLate);
|
||||
}
|
||||
|
||||
// load mods
|
||||
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
|
||||
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
|
||||
|
||||
|
@ -447,14 +468,17 @@ namespace StardewModdingAPI.Framework
|
|||
this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error);
|
||||
|
||||
// start SMAPI console
|
||||
new Thread(
|
||||
() => this.LogManager.RunConsoleInputLoop(
|
||||
commandManager: this.CommandManager,
|
||||
reloadTranslations: this.ReloadTranslations,
|
||||
handleInput: input => this.RawCommandQueue.Add(input),
|
||||
continueWhile: () => this.IsGameRunning && !this.IsExiting
|
||||
)
|
||||
).Start();
|
||||
if (this.Settings.ListenForConsoleInput)
|
||||
{
|
||||
new Thread(
|
||||
() => this.LogManager.RunConsoleInputLoop(
|
||||
commandManager: this.CommandManager,
|
||||
reloadTranslations: this.ReloadTranslations,
|
||||
handleInput: input => this.RawCommandQueue.Add(input),
|
||||
continueWhile: () => this.IsGameRunning && !this.IsExiting
|
||||
)
|
||||
).Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised after an instance finishes loading its initial content.</summary>
|
||||
|
@ -1327,6 +1351,7 @@ namespace StardewModdingAPI.Framework
|
|||
rootDirectory: rootDirectory,
|
||||
currentCulture: Thread.CurrentThread.CurrentUICulture,
|
||||
monitor: this.Monitor,
|
||||
multiplayer: this.Multiplayer,
|
||||
reflection: this.Reflection,
|
||||
jsonHelper: this.Toolkit.JsonHelper,
|
||||
onLoadingFirstAsset: this.InitializeBeforeFirstAssetLoaded,
|
||||
|
@ -1712,7 +1737,7 @@ namespace StardewModdingAPI.Framework
|
|||
source: metadata,
|
||||
nounPhrase: $"{nameof(IAssetEditor)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info,
|
||||
severity: DeprecationLevel.PendingRemoval,
|
||||
logStackTrace: false
|
||||
);
|
||||
|
||||
|
@ -1725,7 +1750,7 @@ namespace StardewModdingAPI.Framework
|
|||
source: metadata,
|
||||
nounPhrase: $"{nameof(IAssetLoader)}",
|
||||
version: "3.14.0",
|
||||
severity: DeprecationLevel.Info,
|
||||
severity: DeprecationLevel.PendingRemoval,
|
||||
logStackTrace: false
|
||||
);
|
||||
|
||||
|
@ -1757,7 +1782,7 @@ namespace StardewModdingAPI.Framework
|
|||
metadata,
|
||||
$"using {name} without bundling it",
|
||||
"3.14.7",
|
||||
DeprecationLevel.Info,
|
||||
DeprecationLevel.PendingRemoval,
|
||||
logStackTrace: false
|
||||
);
|
||||
}
|
||||
|
|
|
@ -40,6 +40,9 @@ namespace StardewModdingAPI.Metadata
|
|||
/// <summary>Writes messages to the console.</summary>
|
||||
private readonly IMonitor Monitor;
|
||||
|
||||
/// <summary>The multiplayer instance whose map cache to update.</summary>
|
||||
private readonly Multiplayer Multiplayer;
|
||||
|
||||
/// <summary>Simplifies access to private game code.</summary>
|
||||
private readonly Reflector Reflection;
|
||||
|
||||
|
@ -70,13 +73,15 @@ namespace StardewModdingAPI.Metadata
|
|||
/// <param name="mainContent">The main content manager through which to reload assets.</param>
|
||||
/// <param name="disposableContent">An internal content manager used only for asset propagation.</param>
|
||||
/// <param name="monitor">Writes messages to the console.</param>
|
||||
/// <param name="multiplayer">The multiplayer instance whose map cache to update.</param>
|
||||
/// <param name="reflection">Simplifies access to private code.</param>
|
||||
/// <param name="parseAssetName">Parse a raw asset name.</param>
|
||||
public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Reflector reflection, Func<string, IAssetName> parseAssetName)
|
||||
public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Multiplayer multiplayer, Reflector reflection, Func<string, IAssetName> parseAssetName)
|
||||
{
|
||||
this.MainContentManager = mainContent;
|
||||
this.DisposableContentManager = disposableContent;
|
||||
this.Monitor = monitor;
|
||||
this.Multiplayer = multiplayer;
|
||||
this.Reflection = reflection;
|
||||
this.ParseAssetName = parseAssetName;
|
||||
}
|
||||
|
@ -1166,6 +1171,9 @@ namespace StardewModdingAPI.Metadata
|
|||
GameLocation location = locationInfo.Location;
|
||||
Vector2? playerPos = Game1.player?.Position;
|
||||
|
||||
// remove from multiplayer cache
|
||||
this.Multiplayer.cachedMultiplayerMaps.Remove(location.NameOrUniqueName);
|
||||
|
||||
// reload map
|
||||
location.interiorDoors.Clear(); // prevent errors when doors try to update tiles which no longer exist
|
||||
location.reloadMap();
|
||||
|
|
|
@ -41,6 +41,12 @@ copy all the settings, or you may cause bugs due to overridden changes in future
|
|||
*/
|
||||
"DeveloperMode": true,
|
||||
|
||||
/**
|
||||
* Whether SMAPI should listen for console input. Disabling this will prevent you from using
|
||||
* console commands. On some specific Linux systems, disabling this may reduce CPU usage.
|
||||
*/
|
||||
"ListenForConsoleInput": true,
|
||||
|
||||
/**
|
||||
* Whether SMAPI should rewrite mods for compatibility. This may prevent older mods from
|
||||
* loading, but bypasses a Visual Studio crash when debugging.
|
||||
|
@ -141,5 +147,17 @@ copy all the settings, or you may cause bugs due to overridden changes in future
|
|||
"SMAPI.ConsoleCommands",
|
||||
"SMAPI.ErrorHandler",
|
||||
"SMAPI.SaveBackup"
|
||||
]
|
||||
],
|
||||
|
||||
/**
|
||||
* The mod IDs SMAPI should load before any other mods (except those needed to load them)
|
||||
* or after any other mods.
|
||||
*
|
||||
* This lets you manually fix the load order if needed, but this is a last resort — SMAPI
|
||||
* automatically adjusts the load order based on mods' dependencies, so needing to manually
|
||||
* edit the order is usually a problem with one or both mods' metadata that can be reported to
|
||||
* the mod author.
|
||||
*/
|
||||
"ModsToLoadEarly": [],
|
||||
"ModsToLoadLate": []
|
||||
}
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
|
||||
|
||||
namespace StardewModdingAPI.Utilities.AssetPathUtilities
|
||||
{
|
||||
/// <summary>Handles enumerating the normalized segments in an asset name.</summary>
|
||||
internal ref struct AssetNamePartEnumerator
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The backing field for <see cref="Remainder"/>.</summary>
|
||||
private ReadOnlySpan<char> RemainderImpl;
|
||||
|
||||
|
||||
/*********
|
||||
** Properties
|
||||
*********/
|
||||
/// <summary>The remainder of the asset name being enumerated, ignoring segments which have already been yielded.</summary>
|
||||
public ReadOnlySpan<char> Remainder => this.RemainderImpl;
|
||||
|
||||
/// <summary>Get the current segment.</summary>
|
||||
public ReadOnlySpan<char> Current { get; private set; } = default;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="assetName">The asset name to enumerate.</param>
|
||||
public AssetNamePartEnumerator(ReadOnlySpan<char> assetName)
|
||||
{
|
||||
this.RemainderImpl = AssetNamePartEnumerator.TrimLeadingPathSeparators(assetName);
|
||||
}
|
||||
|
||||
/// <summary>Move the enumerator to the next segment.</summary>
|
||||
/// <returns>Returns true if a new value was found (accessible via <see cref="Current"/>).</returns>
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (this.RemainderImpl.Length == 0)
|
||||
return false;
|
||||
|
||||
int index = this.RemainderImpl.IndexOfAny(ToolkitPathUtilities.PossiblePathSeparators);
|
||||
|
||||
// no more separator characters found, I'm done.
|
||||
if (index < 0)
|
||||
{
|
||||
this.Current = this.RemainderImpl;
|
||||
this.RemainderImpl = ReadOnlySpan<char>.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Yield the next separate character bit
|
||||
this.Current = this.RemainderImpl[..index];
|
||||
this.RemainderImpl = AssetNamePartEnumerator.TrimLeadingPathSeparators(this.RemainderImpl[(index + 1)..]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>Trim path separators at the start of the given path or segment.</summary>
|
||||
/// <param name="span">The path or segment to trim.</param>
|
||||
private static ReadOnlySpan<char> TrimLeadingPathSeparators(ReadOnlySpan<char> span)
|
||||
{
|
||||
return span.TrimStart(new ReadOnlySpan<char>(ToolkitPathUtilities.PossiblePathSeparators));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -59,7 +59,7 @@ namespace StardewModdingAPI.Utilities
|
|||
null,
|
||||
$"calling the {nameof(PerScreen<T>)} constructor with null",
|
||||
"3.14.0",
|
||||
DeprecationLevel.Info
|
||||
DeprecationLevel.PendingRemoval
|
||||
);
|
||||
#else
|
||||
throw new ArgumentNullException(nameof(createNewState));
|
||||
|
|
Loading…
Reference in New Issue