summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/SMAPI.ModBuildConfig/DeployModTask.cs40
-rw-r--r--src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs12
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/manifest.json4
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json4
-rw-r--r--src/SMAPI.Tests/Core/AssetNameTests.cs41
-rw-r--r--src/SMAPI.Tests/Core/AssumptionTests.cs62
-rw-r--r--src/SMAPI.Toolkit/Framework/ManifestValidator.cs106
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs4
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs3
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml6
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js11
-rw-r--r--src/SMAPI/Constants.cs6
-rw-r--r--src/SMAPI/Framework/Content/AssetInfo.cs4
-rw-r--r--src/SMAPI/Framework/Content/AssetName.cs114
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs5
-rw-r--r--src/SMAPI/Framework/Deprecations/DeprecationManager.cs2
-rw-r--r--src/SMAPI/Framework/ModHelpers/CommandHelper.cs2
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs4
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModHelper.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs135
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs50
-rw-r--r--src/SMAPI/Framework/SCore.cs49
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs10
-rw-r--r--src/SMAPI/SMAPI.config.json20
-rw-r--r--src/SMAPI/Utilities/AssetPathUtilities/AssetNamePartEnumerator.cs70
-rw-r--r--src/SMAPI/Utilities/PerScreen.cs2
27 files changed, 576 insertions, 196 deletions
diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs
index 88412d92..3508a6db 100644
--- a/src/SMAPI.ModBuildConfig/DeployModTask.cs
+++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs
@@ -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}...");
diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
index 80955f67..00f3f439 100644
--- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
+++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
@@ -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
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index d1296dbe..ddb0e20d 100644
--- a/src/SMAPI.Mods.ConsoleCommands/manifest.json
+++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json
@@ -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"
}
diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json
index c3757e8f..eeea1d28 100644
--- a/src/SMAPI.Mods.ErrorHandler/manifest.json
+++ b/src/SMAPI.Mods.ErrorHandler/manifest.json
@@ -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"
}
diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json
index 78821a3e..0acf066d 100644
--- a/src/SMAPI.Mods.SaveBackup/manifest.json
+++ b/src/SMAPI.Mods.SaveBackup/manifest.json
@@ -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"
}
diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs
index 655e9bae..2d546ec7 100644
--- a/src/SMAPI.Tests/Core/AssetNameTests.cs
+++ b/src/SMAPI.Tests/Core/AssetNameTests.cs
@@ -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
diff --git a/src/SMAPI.Tests/Core/AssumptionTests.cs b/src/SMAPI.Tests/Core/AssumptionTests.cs
new file mode 100644
index 00000000..efc9da3f
--- /dev/null
+++ b/src/SMAPI.Tests/Core/AssumptionTests.cs
@@ -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.");
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ManifestValidator.cs b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs
new file mode 100644
index 00000000..461dc325
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index 5e0dedf3..c39e612b 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -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")
{
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
index cda0f653..2a2e5f5c 100644
--- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
@@ -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
****/
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 28127903..c1251c21 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -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"
/>
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
index fccd00be..324218bb 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -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
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 77900ca6..02e75e05 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -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;
diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs
index af000300..52ef02e6 100644
--- a/src/SMAPI/Framework/Content/AssetInfo.cs
+++ b/src/SMAPI/Framework/Content/AssetInfo.cs
@@ -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)}",
diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs
index 148354a1..8355f9ec 100644
--- a/src/SMAPI/Framework/Content/AssetName.cs
+++ b/src/SMAPI/Framework/Content/AssetName.cs
@@ -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,42 +137,76 @@ 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;
+
+ // 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;
+ }
- prefix = normalized;
- }
+ // 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
- if (prefix.Length == 0)
- return true;
+ // 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;
- 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)
- );
- }
+ // 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;