diff options
Diffstat (limited to 'src')
18 files changed, 734 insertions, 316 deletions
diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs new file mode 100644 index 00000000..8785aab8 --- /dev/null +++ b/src/SMAPI.Tests/Core/AssetNameTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; + +namespace SMAPI.Tests.Core +{ + /// <summary>Unit tests for <see cref="AssetName"/>.</summary> + [TestFixture] + internal class AssetNameTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = $"Assert that the {nameof(AssetName)} constructor creates an instance with the expected values.")] + [TestCase("SimpleName", "SimpleName", null, null)] + [TestCase("Data/Achievements", "Data/Achievements", null, null)] + [TestCase("Characters/Dialogue/Abigail", "Characters/Dialogue/Abigail", null, null)] + [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] + [TestCase("Characters/Dialogue\\Abigail.fr-FR", "Characters/Dialogue/Abigail.fr-FR", null, null)] + [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] + public void Constructor_Valid(string name, string expectedBaseName, string expectedLocale, LocalizedContentManager.LanguageCode? expectedLanguageCode) + { + // arrange + name = PathUtilities.NormalizeAssetName(name); + + // act + string calledWithLocale = null; + IAssetName assetName = AssetName.Parse(name, parseLocale: locale => expectedLanguageCode); + + // assert + assetName.Name.Should() + .NotBeNull() + .And.Be(name.Replace("\\", "/")); + assetName.BaseName.Should() + .NotBeNull() + .And.Be(expectedBaseName); + assetName.LocaleCode.Should() + .Be(expectedLocale); + assetName.LanguageCode.Should() + .Be(expectedLanguageCode); + } + + [Test(Description = $"Assert that the {nameof(AssetName)} constructor throws an exception if the value is invalid.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + [TestCase(" \t ")] + public void Constructor_NullOrWhitespace(string name) + { + // act + ArgumentException exception = Assert.Throws<ArgumentException>(() => _ = AssetName.Parse(name, null)); + + // assert + exception!.ParamName.Should().Be("rawName"); + exception.Message.Should().Be("The asset name can't be null or empty. (Parameter 'rawName')"); + } + + + /**** + ** IsEquivalentTo + ****/ + [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is included.")] + + // exact match (ignore case) + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + + // exact match (ignore formatting) + [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] + [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + + // whitespace-sensitive + [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = false)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)] + + // other is null or whitespace + [TestCase("Data/Achievements", null, ExpectedResult = false)] + [TestCase("Data/Achievements", "", ExpectedResult = false)] + [TestCase("Data/Achievements", " ", ExpectedResult = false)] + + // with locale codes + [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = true)] + public bool IsEquivalentTo_Name(string mainAssetName, string otherAssetName) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); + + // assert + return name.IsEquivalentTo(otherAssetName); + } + + [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is excluded.")] + + // a few samples from previous test to make sure + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)] + [TestCase("Data/Achievements", " ", ExpectedResult = false)] + + // with locale codes + [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = false)] + public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); + + // assert + return name.IsEquivalentTo(otherAssetName, useBaseName: true); + } + + + /**** + ** StartsWith + ****/ + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for inputs that aren't affected by the input options.")] + + // exact match (ignore case and formatting) + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] + [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + + // leading-whitespace-sensitive + [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = false)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)] + + // invalid prefixes + [TestCase("Data/Achievements", null, ExpectedResult = false)] + [TestCase("Data/Achievements", " ", ExpectedResult = false)] + + // with locale codes + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] + public bool StartsWith_SimpleCases(string mainAssetName, string prefix) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value is the same for any combination of options + bool result = name.StartsWith(prefix, true, true); + foreach (bool allowPartialWord in new[] { true, false }) + { + foreach (bool allowSubfolder in new[] { true, true }) + { + if (allowPartialWord && allowSubfolder) + continue; + + name.StartsWith(prefix, allowPartialWord, allowSubfolder) + .Should().Be(result, $"the value returned for options ({nameof(allowPartialWord)}: {allowPartialWord}, {nameof(allowSubfolder)}: {allowSubfolder}) should match the base case"); + } + } + + // assert value + return result; + } + + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowPartialWord' option.")] + [TestCase("Data/AchievementsToIgnore", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/AchievementsToIgnore", "Data/Achievements", false, ExpectedResult = false)] + [TestCase("Data/Achievements X", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements X", "Data/Achievements", false, ExpectedResult = true)] + [TestCase("Data/Achievements.X", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements.X", "Data/Achievements", false, ExpectedResult = true)] + + // with locale codes + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", false, ExpectedResult = true)] + public bool StartsWith_PartialWord(string mainAssetName, string prefix, bool allowPartialWord) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value is the same for any combination of options + bool result = name.StartsWith(prefix, allowPartialWord: allowPartialWord, allowSubfolder: true); + name.StartsWith(prefix, allowPartialWord, allowSubfolder: false) + .Should().Be(result, "specifying allowSubfolder should have no effect for these inputs"); + + // assert value + return result; + } + + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowSubfolder' option.")] + + // simple cases + [TestCase("Data/Achievements/Path", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/Achievements", false, ExpectedResult = false)] + [TestCase("Data/Achievements/Path", "Data\\Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data\\Achievements", false, ExpectedResult = false)] + + // trailing slash + [TestCase("Data/Achievements/Path", "Data/", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/", false, ExpectedResult = false)] + + // normalize slash style + [TestCase("Data/Achievements/Path", "Data\\", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data\\", false, ExpectedResult = false)] + [TestCase("Data/Achievements/Path", "Data/\\/", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/\\/", false, ExpectedResult = false)] + + // with locale code + [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", false, ExpectedResult = false)] + public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bool allowSubfolder) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value is the same for any combination of options + bool result = name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); + name.StartsWith(otherAssetName, allowPartialWord: false, allowSubfolder: allowSubfolder) + .Should().Be(result, "specifying allowPartialWord should have no effect for these inputs"); + + // assert value + return result; + } + + + /**** + ** GetHashCode + ****/ + [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates the same hash code for two asset names which differ only by capitalization.")] + public void GetHashCode_IsCaseInsensitive() + { + // arrange + string left = "data/ACHIEVEMENTS"; + string right = "DATA/achievements"; + + // act + int leftHash = AssetName.Parse(left, _ => null).GetHashCode(); + int rightHash = AssetName.Parse(right, _ => null).GetHashCode(); + + // assert + leftHash.Should().Be(rightHash, "two asset names which differ only by capitalization should produce the same hash code"); + } + + [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates few hash code collisions for an arbitrary set of asset names.")] + public void GetHashCode_HasFewCollisions() + { + // generate list of names + List<string> names = new(); + { + Random random = new(); + string characters = "abcdefghijklmnopqrstuvwxyz1234567890/"; + + while (names.Count < 1000) + { + char[] name = new char[random.Next(5, 20)]; + for (int i = 0; i < name.Length; i++) + name[i] = characters[random.Next(0, characters.Length)]; + + names.Add(new string(name)); + } + } + + // get distinct hash codes + HashSet<int> hashCodes = new(); + foreach (string name in names) + hashCodes.Add(AssetName.Parse(name, _ => null).GetHashCode()); + + // assert a collision frequency under 0.1% + float collisionFrequency = 1 - (hashCodes.Count / (names.Count * 1f)); + collisionFrequency.Should().BeLessOrEqualTo(0.001f, "hash codes should be relatively distinct with a collision rate under 0.1% for a small sample set"); + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs index 5c90d83b..05be8a3b 100644 --- a/src/SMAPI/Framework/Content/AssetData.cs +++ b/src/SMAPI/Framework/Content/AssetData.cs @@ -25,11 +25,11 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue> onDataReplaced) + public AssetData(string locale, IAssetName assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue> onDataReplaced) : base(locale, assetName, data.GetType(), getNormalizedPath) { this.Data = data; diff --git a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs index 26cbff5a..735b651c 100644 --- a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs +++ b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs @@ -11,11 +11,11 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForDictionary(string locale, string assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalizedPath, Action<IDictionary<TKey, TValue>> onDataReplaced) + public AssetDataForDictionary(string locale, IAssetName assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalizedPath, Action<IDictionary<TKey, TValue>> onDataReplaced) : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } } } diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index c75514bc..b0f1b5c7 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -21,11 +21,11 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) + public AssetDataForImage(string locale, IAssetName assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } /// <inheritdoc /> diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs index 0a5fa7e7..26e4986e 100644 --- a/src/SMAPI/Framework/Content/AssetDataForMap.cs +++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs @@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForMap(string locale, string assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced) + public AssetDataForMap(string locale, IAssetName assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced) : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } /// <inheritdoc /> diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index b7e8dfeb..d91873ae 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -13,10 +13,10 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> - public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalizedPath) + public AssetDataForObject(string locale, IAssetName assetName, object data, Func<string, string> getNormalizedPath) : base(locale, assetName, data, getNormalizedPath, onDataReplaced: null) { } /// <summary>Construct an instance.</summary> @@ -24,24 +24,24 @@ namespace StardewModdingAPI.Framework.Content /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalizedPath) - : this(info.Locale, info.AssetName, data, getNormalizedPath) { } + : this(info.Locale, info.Name, data, getNormalizedPath) { } /// <inheritdoc /> public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() { - return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith); + return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.Name, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith); } /// <inheritdoc /> public IAssetDataForImage AsImage() { - return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); + return new AssetDataForImage(this.Locale, this.Name, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); } /// <inheritdoc /> public IAssetDataForMap AsMap() { - return new AssetDataForMap(this.Locale, this.AssetName, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith); + return new AssetDataForMap(this.Locale, this.Name, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith); } /// <inheritdoc /> diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index d8106439..6a5b4f31 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -20,7 +20,11 @@ namespace StardewModdingAPI.Framework.Content public string Locale { get; } /// <inheritdoc /> - public string AssetName { get; } + public IAssetName Name { get; } + + /// <inheritdoc /> + [Obsolete($"Use {nameof(Name)} instead.")] + public string AssetName => this.Name.Name; /// <inheritdoc /> public Type DataType { get; } @@ -31,22 +35,22 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="type">The content type being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> - public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalizedPath) + public AssetInfo(string locale, IAssetName assetName, Type type, Func<string, string> getNormalizedPath) { this.Locale = locale; - this.AssetName = assetName; + this.Name = assetName; this.DataType = type; this.GetNormalizedPath = getNormalizedPath; } /// <inheritdoc /> + [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} instead.")] public bool AssetNameEquals(string path) { - path = this.GetNormalizedPath(path); - return this.AssetName.Equals(path, StringComparison.OrdinalIgnoreCase); + return this.Name.IsEquivalentTo(path); } diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs index 10488b84..981eed40 100644 --- a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs +++ b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs @@ -70,7 +70,7 @@ namespace StardewModdingAPI.Framework.Content } catch (Exception ex) { - this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } } @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Content } catch (Exception ex) { - this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } } diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs new file mode 100644 index 00000000..992647f8 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetName.cs @@ -0,0 +1,173 @@ +using System; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>An asset name that can be loaded through the content pipeline.</summary> + internal class AssetName : IAssetName + { + /********* + ** Fields + *********/ + /// <summary>A lowercase version of <see cref="Name"/> used for consistent hash codes and equality checks.</summary> + private readonly string ComparableName; + + + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public string Name { get; } + + /// <inheritdoc /> + public string BaseName { get; } + + /// <inheritdoc /> + public string LocaleCode { get; } + + /// <inheritdoc /> + public LocalizedContentManager.LanguageCode? LanguageCode { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="baseName">The base asset name without the locale code.</param> + /// <param name="localeCode">The locale code specified in the <see cref="Name"/>, if it's a valid code recognized by the game content.</param> + /// <param name="languageCode">The language code matching the <see cref="LocaleCode"/>, if applicable.</param> + public AssetName(string baseName, string localeCode, LocalizedContentManager.LanguageCode? languageCode) + { + // validate + if (string.IsNullOrWhiteSpace(baseName)) + throw new ArgumentException("The asset name can't be null or empty.", nameof(baseName)); + if (string.IsNullOrWhiteSpace(localeCode)) + localeCode = null; + + // set base values + this.BaseName = PathUtilities.NormalizeAssetName(baseName); + this.LocaleCode = localeCode; + this.LanguageCode = languageCode; + + // set derived values + this.Name = localeCode != null + ? string.Concat(this.BaseName, '.', this.LocaleCode) + : this.BaseName; + this.ComparableName = this.Name.ToLowerInvariant(); + } + + /// <summary>Parse a raw asset name into an instance.</summary> + /// <param name="rawName">The raw asset name to parse.</param> + /// <param name="parseLocale">Get the language code for a given locale, if it's valid.</param> + /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception> + public static AssetName Parse(string rawName, Func<string, LocalizedContentManager.LanguageCode?> parseLocale) + { + if (string.IsNullOrWhiteSpace(rawName)) + throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName)); + + string baseName = rawName; + string localeCode = null; + LocalizedContentManager.LanguageCode? languageCode = null; + + int lastPeriodIndex = rawName.LastIndexOf('.'); + if (lastPeriodIndex > 0 && rawName.Length > lastPeriodIndex + 1) + { + string possibleLocaleCode = rawName[(lastPeriodIndex + 1)..]; + LocalizedContentManager.LanguageCode? possibleLanguageCode = parseLocale(possibleLocaleCode); + + if (possibleLanguageCode != null) + { + baseName = rawName[..lastPeriodIndex]; + localeCode = possibleLocaleCode; + languageCode = possibleLanguageCode; + } + } + + return new AssetName(baseName, localeCode, languageCode); + } + + /// <inheritdoc /> + public bool IsEquivalentTo(string assetName, bool useBaseName = false) + { + // empty asset key is never equivalent + if (string.IsNullOrWhiteSpace(assetName)) + return false; + + assetName = PathUtilities.NormalizeAssetName(assetName); + + string compareTo = useBaseName ? this.BaseName : this.Name; + return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase); + } + + /// <inheritdoc /> + public bool StartsWith(string prefix, bool allowPartialWord = true, bool allowSubfolder = true) + { + // asset keys never start with null + if (prefix is null) + return false; + + // asset keys can't have a leading slash, but NormalizeAssetName will trim them + { + string trimmed = prefix.TrimStart(); + if (trimmed.StartsWith('/') || trimmed.StartsWith('\\')) + return false; + } + + // normalize prefix + { + string normalized = PathUtilities.NormalizeAssetName(prefix); + + string trimmed = prefix.TrimEnd(); + if (trimmed.EndsWith('/') || trimmed.EndsWith('\\')) + normalized += PathUtilities.PreferredAssetSeparator; + + prefix = normalized; + } + + // compare + 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) + ); + } + + + public bool IsDirectlyUnderPath(string assetFolder) + { + return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false); + } + + /// <inheritdoc /> + public bool Equals(IAssetName other) + { + return other switch + { + null => false, + AssetName otherImpl => this.ComparableName == otherImpl.ComparableName, + _ => StringComparer.OrdinalIgnoreCase.Equals(this.Name, other.Name) + }; + } + + /// <inheritdoc /> + public override int GetHashCode() + { + return this.ComparableName.GetHashCode(); + } + + /// <inheritdoc /> + public override string ToString() + { + return this.Name; + } + } +} diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 61cefd12..97a37b3f 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -248,6 +248,16 @@ namespace StardewModdingAPI.Framework this.InvalidateCache((contentManager, key, type) => contentManager is GameContentManager); } + /// <summary>Parse a raw asset name.</summary> + /// <param name="rawName">The raw asset name to parse.</param> + /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception> + public AssetName ParseAssetName(string rawName) + { + return !string.IsNullOrWhiteSpace(rawName) + ? AssetName.Parse(rawName, parseLocale: locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null) + : throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName)); + } + /// <summary>Get whether this asset is mapped to a mod folder.</summary> /// <param name="key">The asset key.</param> public bool IsManagedAssetKey(string key) @@ -306,11 +316,12 @@ namespace StardewModdingAPI.Framework /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns the invalidated asset keys.</returns> - public IEnumerable<string> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false) + public IEnumerable<IAssetName> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false) { string locale = this.GetLocale(); - return this.InvalidateCache((contentManager, assetName, type) => + return this.InvalidateCache((contentManager, rawName, type) => { + IAssetName assetName = this.ParseAssetName(rawName); IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName); return predicate(info); }, dispose); @@ -320,10 +331,10 @@ namespace StardewModdingAPI.Framework /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns the invalidated asset names.</returns> - public IEnumerable<string> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) + public IEnumerable<IAssetName> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary<string, Type> removedAssets = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase); + IDictionary<IAssetName, Type> removedAssets = new Dictionary<IAssetName, Type>(); this.ContentManagerLock.InReadLock(() => { // cached assets @@ -331,8 +342,9 @@ namespace StardewModdingAPI.Framework { foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) { - if (!removedAssets.ContainsKey(entry.Key)) - removedAssets[entry.Key] = entry.Value.GetType(); + AssetName assetName = this.ParseAssetName(entry.Key); + if (!removedAssets.ContainsKey(assetName)) + removedAssets[assetName] = entry.Value.GetType(); } } @@ -346,8 +358,8 @@ namespace StardewModdingAPI.Framework continue; // get map path - string mapPath = this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value); - if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath, typeof(Map))) + AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value)); + if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map))) removedAssets[mapPath] = typeof(Map); } } @@ -360,17 +372,17 @@ namespace StardewModdingAPI.Framework this.CoreAssets.Propagate( assets: removedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, - out IDictionary<string, bool> propagated, + out IDictionary<IAssetName, bool> propagated, out bool updatedNpcWarps ); // log summary StringBuilder report = new(); { - string[] invalidatedKeys = removedAssets.Keys.ToArray(); - string[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); + IAssetName[] invalidatedKeys = removedAssets.Keys.ToArray(); + IAssetName[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); - string FormatKeyList(IEnumerable<string> keys) => string.Join(", ", keys.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + string FormatKeyList(IEnumerable<IAssetName> keys) => string.Join(", ", keys.Select(p => p.Name).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); report.AppendLine($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)})."); report.AppendLine(propagated.Count > 0 @@ -422,15 +434,6 @@ namespace StardewModdingAPI.Framework return tilesheets ?? Array.Empty<TilesheetReference>(); } - /// <summary>Get the language enum which corresponds to a locale code (e.g. <see cref="LocalizedContentManager.LanguageCode.fr"/> given <c>fr-FR</c>).</summary> - /// <param name="locale">The locale code to search. This must exactly match the language; no fallback is performed.</param> - /// <param name="language">The matched language enum, if any.</param> - /// <returns>Returns whether a valid language was found.</returns> - public bool TryGetLanguageEnum(string locale, out LocalizedContentManager.LanguageCode language) - { - return this.LocaleCodes.Value.TryGetValue(locale, out language); - } - /// <summary>Get the locale code which corresponds to a language enum (e.g. <c>fr-FR</c> given <see cref="LocalizedContentManager.LanguageCode.fr"/>).</summary> /// <param name="language">The language enum to search.</param> public string GetLocaleCode(LocalizedContentManager.LanguageCode language) diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 5645c0fa..be892b33 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -160,14 +160,6 @@ namespace StardewModdingAPI.Framework.ContentManagers return this.IsNormalizedKeyLoaded(assetName, language); } - /// <inheritdoc /> - public IEnumerable<string> GetAssetKeys() - { - return this.Cache.Keys - .Select(this.GetAssetName) - .Distinct(); - } - /**** ** Cache invalidation ****/ @@ -177,13 +169,13 @@ namespace StardewModdingAPI.Framework.ContentManagers IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); this.Cache.Remove((key, asset) => { - this.ParseCacheKey(key, out string assetName, out _); + string baseAssetName = this.Coordinator.ParseAssetName(key).BaseName; // check if asset should be removed - bool remove = removeAssets.ContainsKey(assetName); - if (!remove && predicate(assetName, asset.GetType())) + bool remove = removeAssets.ContainsKey(baseAssetName); + if (!remove && predicate(baseAssetName, asset.GetType())) { - removeAssets[assetName] = asset; + removeAssets[baseAssetName] = asset; remove = true; } @@ -275,44 +267,9 @@ namespace StardewModdingAPI.Framework.ContentManagers this.BaseDisposableReferences.Clear(); } - /// <summary>Parse a cache key into its component parts.</summary> - /// <param name="cacheKey">The input cache key.</param> - /// <param name="assetName">The original asset name.</param> - /// <param name="localeCode">The asset locale code (or <c>null</c> if not localized).</param> - protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode) - { - // handle localized key - if (!string.IsNullOrWhiteSpace(cacheKey)) - { - int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.Ordinal); - if (lastSepIndex >= 0) - { - string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); - if (this.Coordinator.TryGetLanguageEnum(suffix, out _)) - { - assetName = cacheKey.Substring(0, lastSepIndex); - localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); - return; - } - } - } - - // handle simple key - assetName = cacheKey; - localeCode = null; - } - /// <summary>Get whether an asset has already been loaded.</summary> /// <param name="normalizedAssetName">The normalized asset name.</param> /// <param name="language">The language to check.</param> protected abstract bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language); - - /// <summary>Get the asset name from a cache key.</summary> - /// <param name="cacheKey">The input cache key.</param> - private string GetAssetName(string cacheKey) - { - this.ParseCacheKey(cacheKey, out string assetName, out string _); - return assetName; - } } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index ab198076..0ca9e277 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -73,46 +73,46 @@ namespace StardewModdingAPI.Framework.ContentManagers } // normalize asset name - assetName = this.AssertAndNormalizeAssetName(assetName); - if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) - return this.Load<T>(newAssetName, newLanguage, useCache); + IAssetName parsedName = this.Coordinator.ParseAssetName(assetName); + if (parsedName.LanguageCode.HasValue) + return this.Load<T>(parsedName.BaseName, parsedName.LanguageCode.Value, useCache); // get from cache - if (useCache && this.IsLoaded(assetName, language)) - return this.RawLoad<T>(assetName, language, useCache: true); + if (useCache && this.IsLoaded(parsedName.Name, language)) + return this.RawLoad<T>(parsedName.Name, language, useCache: true); // get managed asset - if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) + if (this.Coordinator.TryParseManagedAssetKey(parsedName.Name, out string contentManagerID, out string relativePath)) { T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath); - this.TrackAsset(assetName, managedAsset, language, useCache); + this.TrackAsset(parsedName.Name, managedAsset, language, useCache); return managedAsset; } // load asset T data; - if (this.AssetsBeingLoaded.Contains(assetName)) + if (this.AssetsBeingLoaded.Contains(parsedName.Name)) { - this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); + this.Monitor.Log($"Broke loop while loading asset '{parsedName.Name}'.", LogLevel.Warn); this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}"); - data = this.RawLoad<T>(assetName, language, useCache); + data = this.RawLoad<T>(parsedName.Name, language, useCache); } else { - data = this.AssetsBeingLoaded.Track(assetName, () => + data = this.AssetsBeingLoaded.Track(parsedName.Name, () => { string locale = this.GetLocale(language); - IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); + IAssetInfo info = new AssetInfo(locale, parsedName, typeof(T), this.AssertAndNormalizeAssetName); IAssetData asset = this.ApplyLoader<T>(info) - ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, language, useCache), this.AssertAndNormalizeAssetName); + ?? new AssetDataForObject(info, this.RawLoad<T>(parsedName.Name, language, useCache), this.AssertAndNormalizeAssetName); asset = this.ApplyEditors<T>(info, asset); return (T)asset.Data; }); } // update cache & return data - this.TrackAsset(assetName, data, language, useCache); + this.TrackAsset(parsedName.Name, data, language, useCache); return data; } @@ -124,13 +124,16 @@ namespace StardewModdingAPI.Framework.ContentManagers // find assets for which a translatable version was loaded HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); foreach (string key in this.LocalizedAssetNames.Where(p => p.Key != p.Value).Select(p => p.Key)) - removeAssetNames.Add(this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) ? assetName : key); + { + IAssetName assetName = this.Coordinator.ParseAssetName(key); + removeAssetNames.Add(assetName.BaseName); + } // invalidate translatable assets string[] invalidated = this .InvalidateCache((key, type) => removeAssetNames.Contains(key) - || (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName)) + || removeAssetNames.Contains(this.Coordinator.ParseAssetName(key).BaseName) ) .Select(p => p.Key) .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) @@ -168,9 +171,10 @@ namespace StardewModdingAPI.Framework.ContentManagers { // handle explicit language in asset name { - if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) + IAssetName parsedName = this.Coordinator.ParseAssetName(assetName); + if (parsedName.LanguageCode.HasValue) { - this.TrackAsset(newAssetName, value, newLanguage, useCache); + this.TrackAsset(parsedName.BaseName, value, parsedName.LanguageCode.Value, useCache); return; } } @@ -238,30 +242,6 @@ namespace StardewModdingAPI.Framework.ContentManagers } } - /// <summary>Parse an asset key that contains an explicit language into its asset name and language, if applicable.</summary> - /// <param name="rawAsset">The asset key to parse.</param> - /// <param name="assetName">The asset name without the language code.</param> - /// <param name="language">The language code removed from the asset name.</param> - /// <returns>Returns whether the asset key contains an explicit language and was successfully parsed.</returns> - private bool TryParseExplicitLanguageAssetKey(string rawAsset, out string assetName, out LanguageCode language) - { - if (string.IsNullOrWhiteSpace(rawAsset)) - throw new SContentLoadException("The asset key is empty."); - - // extract language code - int splitIndex = rawAsset.LastIndexOf('.'); - if (splitIndex != -1 && this.Coordinator.TryGetLanguageEnum(rawAsset.Substring(splitIndex + 1), out language)) - { - assetName = rawAsset.Substring(0, splitIndex); - return true; - } - - // no explicit language code found - assetName = rawAsset; - language = this.Language; - return false; - } - /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary> /// <param name="info">The basic asset metadata.</param> /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> @@ -277,7 +257,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } catch (Exception ex) { - entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return false; } }) @@ -289,7 +269,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (loaders.Length > 1) { string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); - this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); + this.Monitor.Log($"Multiple mods want to provide the '{info.Name}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); return null; } @@ -300,11 +280,11 @@ namespace StardewModdingAPI.Framework.ContentManagers try { data = loader.Load<T>(info); - this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); + this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.Name}'.", LogLevel.Trace); } catch (Exception ex) { - mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when loading asset '{info.Name}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return null; } @@ -349,7 +329,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } catch (Exception ex) { - mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } @@ -358,22 +338,22 @@ namespace StardewModdingAPI.Framework.ContentManagers try { editor.Edit<T>(asset); - this.Monitor.Log($"{mod.DisplayName} edited {info.AssetName}."); + this.Monitor.Log($"{mod.DisplayName} edited {info.Name}."); } catch (Exception ex) { - mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when editing asset '{info.Name}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } // validate edit if (asset.Data == null) { - mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); + mod.LogAsMod($"Mod incorrectly set asset '{info.Name}' to a null value; ignoring override.", LogLevel.Warn); asset = GetNewData(prevAsset); } else if (!(asset.Data is T)) { - mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + mod.LogAsMod($"Mod incorrectly set asset '{asset.Name}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); asset = GetNewData(prevAsset); } } @@ -393,21 +373,21 @@ namespace StardewModdingAPI.Framework.ContentManagers // can't load a null asset if (data == null) { - mod.LogAsMod($"SMAPI blocked asset replacement for '{info.AssetName}': mod incorrectly set asset to a null value.", LogLevel.Error); + mod.LogAsMod($"SMAPI blocked asset replacement for '{info.Name}': mod incorrectly set asset to a null value.", LogLevel.Error); return false; } // when replacing a map, the vanilla tilesheets must have the same order and IDs if (data is Map loadedMap) { - TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.AssetName); + TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.Name.Name); foreach (TilesheetReference vanillaSheet in vanillaTilesheetRefs) { // add missing tilesheet if (loadedMap.GetTileSheet(vanillaSheet.Id) == null) { mod.Monitor.LogOnce("SMAPI fixed maps loaded by this mod to prevent errors. See the log file for details.", LogLevel.Warn); - this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.AssetName}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource})."); + this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.Name}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource})."); loadedMap.AddTileSheet(new TileSheet(vanillaSheet.Id, loadedMap, vanillaSheet.ImageSource, vanillaSheet.SheetSize, vanillaSheet.TileSize)); } @@ -417,17 +397,17 @@ namespace StardewModdingAPI.Framework.ContentManagers { // only show warning if not farm map // This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting. - bool isFarmMap = info.AssetNameEquals("Maps/Farm") || info.AssetNameEquals("Maps/Farm_Combat") || info.AssetNameEquals("Maps/Farm_Fishing") || info.AssetNameEquals("Maps/Farm_Foraging") || info.AssetNameEquals("Maps/Farm_FourCorners") || info.AssetNameEquals("Maps/Farm_Island") || info.AssetNameEquals("Maps/Farm_Mining"); + bool isFarmMap = info.Name.IsEquivalentTo("Maps/Farm") || info.Name.IsEquivalentTo("Maps/Farm_Combat") || info.Name.IsEquivalentTo("Maps/Farm_Fishing") || info.Name.IsEquivalentTo("Maps/Farm_Foraging") || info.Name.IsEquivalentTo("Maps/Farm_FourCorners") || info.Name.IsEquivalentTo("Maps/Farm_Island") || info.Name.IsEquivalentTo("Maps/Farm_Mining"); string reason = $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help."; SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval); if (isFarmMap) { - mod.LogAsMod($"SMAPI blocked '{info.AssetName}' map load: {reason}", LogLevel.Error); + mod.LogAsMod($"SMAPI blocked '{info.Name}' map load: {reason}", LogLevel.Error); return false; } - mod.LogAsMod($"SMAPI found an issue with '{info.AssetName}' map load: {reason}", LogLevel.Warn); + mod.LogAsMod($"SMAPI found an issue with '{info.Name}' map load: {reason}", LogLevel.Warn); } } } diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs index d7963305..ba7dbc06 100644 --- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs @@ -58,9 +58,6 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="language">The language.</param> bool IsLoaded(string assetName, LocalizedContentManager.LanguageCode language); - /// <summary>Get the cached asset keys.</summary> - IEnumerable<string> GetAssetKeys(); - /// <summary>Purge matched assets from the cache.</summary> /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index beb90a5d..21f88d47 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -80,7 +80,7 @@ namespace StardewModdingAPI.Framework.ContentManagers { // normalize key bool isXnbFile = Path.GetExtension(assetName).ToLower() == ".xnb"; - assetName = this.AssertAndNormalizeAssetName(assetName); + IAssetName parsedName = this.Coordinator.ParseAssetName(assetName); // disable caching // This is necessary to avoid assets being shared between content managers, which can @@ -97,21 +97,21 @@ namespace StardewModdingAPI.Framework.ContentManagers // resolve managed asset key { - if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) + if (this.Coordinator.TryParseManagedAssetKey(parsedName.Name, out string contentManagerID, out string relativePath)) { if (contentManagerID != this.Name) - throw new SContentLoadException($"Can't load managed asset key '{assetName}' through content manager '{this.Name}' for a different mod."); - assetName = relativePath; + throw new SContentLoadException($"Can't load managed asset key '{parsedName}' through content manager '{this.Name}' for a different mod."); + parsedName = this.Coordinator.ParseAssetName(relativePath); } } // get local asset - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}"); + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{parsedName}' from {this.Name}: {reasonPhrase}"); T asset; try { // get file - FileInfo file = this.GetModFile(isXnbFile ? $"{assetName}.xnb" : assetName); // .xnb extension is stripped from asset names passed to the content manager + FileInfo file = this.GetModFile(isXnbFile ? $"{parsedName}.xnb" : parsedName.Name); // .xnb extension is stripped from asset names passed to the content manager if (!file.Exists) throw GetContentError("the specified path doesn't exist."); @@ -121,11 +121,11 @@ namespace StardewModdingAPI.Framework.ContentManagers // XNB file case ".xnb": { - asset = this.RawLoad<T>(assetName, useCache: false); + asset = this.RawLoad<T>(parsedName.Name, useCache: false); if (asset is Map map) { - map.assetPath = assetName; - this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: true); + map.assetPath = parsedName.Name; + this.FixTilesheetPaths(map, relativeMapPath: parsedName.Name, fixEagerPathPrefixes: true); } } break; @@ -173,8 +173,8 @@ namespace StardewModdingAPI.Framework.ContentManagers // fetch & cache FormatManager formatManager = FormatManager.Instance; Map map = formatManager.LoadMap(file.FullName); - map.assetPath = assetName; - this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: false); + map.assetPath = parsedName.Name; + this.FixTilesheetPaths(map, relativeMapPath: parsedName.Name, fixEagerPathPrefixes: false); asset = (T)(object)map; } break; @@ -185,11 +185,11 @@ namespace StardewModdingAPI.Framework.ContentManagers } catch (Exception ex) when (!(ex is SContentLoadException)) { - throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex); + throw new SContentLoadException($"The content manager failed loading content asset '{parsedName}' from {this.Name}.", ex); } // track & return asset - this.TrackAsset(assetName, asset, language, useCache); + this.TrackAsset(parsedName.Name, asset, language, useCache); return asset; } diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index bfca2264..a01248a8 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -129,7 +129,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent); this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.", LogLevel.Trace); - return this.ContentCore.InvalidateCache(asset => asset.AssetNameEquals(actualKey)).Any(); + return this.ContentCore.InvalidateCache(asset => asset.Name.IsEquivalentTo(actualKey)).Any(); } /// <inheritdoc /> @@ -153,7 +153,8 @@ namespace StardewModdingAPI.Framework.ModHelpers throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); assetName ??= $"temp/{Guid.NewGuid():N}"; - return new AssetDataForObject(this.CurrentLocale, assetName, data, this.NormalizeAssetName); + + return new AssetDataForObject(this.CurrentLocale, this.ContentCore.ParseAssetName(assetName), data, this.NormalizeAssetName); } diff --git a/src/SMAPI/IAssetInfo.cs b/src/SMAPI/IAssetInfo.cs index 6cdf01ee..6ac8358d 100644 --- a/src/SMAPI/IAssetInfo.cs +++ b/src/SMAPI/IAssetInfo.cs @@ -11,7 +11,11 @@ namespace StardewModdingAPI /// <summary>The content's locale code, if the content is localized.</summary> string Locale { get; } + /// <summary>The asset name being read.</summary> + public IAssetName Name { get; } + /// <summary>The normalized asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + [Obsolete($"Use {nameof(Name)} instead.")] string AssetName { get; } /// <summary>The content data type.</summary> @@ -23,6 +27,7 @@ namespace StardewModdingAPI *********/ /// <summary>Get whether the asset name being loaded matches a given name after normalization.</summary> /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> + [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} instead.")] bool AssetNameEquals(string path); } } diff --git a/src/SMAPI/IAssetName.cs b/src/SMAPI/IAssetName.cs new file mode 100644 index 00000000..a5bfea93 --- /dev/null +++ b/src/SMAPI/IAssetName.cs @@ -0,0 +1,44 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>The name for an asset loaded through the content pipeline.</summary> + public interface IAssetName : IEquatable<IAssetName> + { + /********* + ** Accessors + *********/ + /// <summary>The full normalized asset name, including the locale if applicable (like <c>Data/Achievements.fr-FR</c>).</summary> + string Name { get; } + + /// <summary>The base asset name without the locale code.</summary> + string BaseName { get; } + + /// <summary>The locale code specified in the <see cref="Name"/>, if it's a valid code recognized by the game content.</summary> + string LocaleCode { get; } + + /// <summary>The language code matching the <see cref="LocaleCode"/>, if applicable.</summary> + LocalizedContentManager.LanguageCode? LanguageCode { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get whether the given asset name is equivalent, ignoring capitalization and formatting.</summary> + /// <param name="assetName">The asset name to compare this instance to.</param> + /// <param name="useBaseName">Whether to compare the given name with the <see cref="BaseName"/> (if true) or <see cref="Name"/> (if false). This has no effect on any locale included in the given <paramref name="assetName"/>.</param> + bool IsEquivalentTo(string assetName, bool useBaseName = false); + + /// <summary>Get whether the asset name starts with the given value, ignoring capitalization and formatting. This can be used with a trailing slash to test for an asset folder, like <c>Data/</c>.</summary> + /// <param name="prefix">The prefix to match.</param> + /// <param name="allowPartialWord">Whether to match if the prefix occurs mid-word, so <c>Data/AchievementsToIgnore</c> matches prefix <c>Data/Achievements</c>. If this is false, the prefix only matches if the asset name starts with the prefix followed by a non-alphanumeric character (including <c>.</c>, <c>/</c>, or <c>\\</c>) or the end of string.</param> + /// <param name="allowSubfolder">Whether to match the prefix if there's a subfolder path after it, so <c>Data/Achievements/Example</c> matches prefix <c>Data/Achievements</c>. If this is false, the prefix only matches if the asset name has no <c>/</c> or <c>\\</c> characters after the prefix.</param> + bool StartsWith(string prefix, bool allowPartialWord = true, bool allowSubfolder = true); + + /// <summary>Get whether the asset is directly within the given asset path.</summary> + /// <remarks>For example, <c>Characters/Dialogue/Abigail</c> is directly under <c>Characters/Dialogue</c> but not <c>Characters</c> or <c>Characters/Dialogue/Ab</c>. To allow sub-paths, use <see cref="StartsWith"/> instead.</remarks> + /// <param name="assetFolder">The asset path to check. This doesn't need a trailing slash.</param> + bool IsDirectlyUnderPath(string assetFolder); + } +} diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index a6c4bb24..e7fac578 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -87,22 +87,22 @@ namespace StardewModdingAPI.Metadata /// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param> /// <param name="propagatedAssets">A lookup of asset names to whether they've been propagated.</param> /// <param name="updatedNpcWarps">Whether the NPC pathfinding cache was reloaded.</param> - public void Propagate(IDictionary<string, Type> assets, bool ignoreWorld, out IDictionary<string, bool> propagatedAssets, out bool updatedNpcWarps) + public void Propagate(IDictionary<IAssetName, Type> assets, bool ignoreWorld, out IDictionary<IAssetName, bool> propagatedAssets, out bool updatedNpcWarps) { // group into optimized lists var buckets = assets.GroupBy(p => { - if (this.IsInFolder(p.Key, "Characters") || this.IsInFolder(p.Key, "Characters/Monsters")) + if (p.Key.IsDirectlyUnderPath("Characters") || p.Key.IsDirectlyUnderPath("Characters/Monsters")) return AssetBucket.Sprite; - if (this.IsInFolder(p.Key, "Portraits")) + if (p.Key.IsDirectlyUnderPath("Portraits")) return AssetBucket.Portrait; return AssetBucket.Other; }); // reload assets - propagatedAssets = assets.ToDictionary(p => p.Key, _ => false, StringComparer.OrdinalIgnoreCase); + propagatedAssets = assets.ToDictionary(p => p.Key, _ => false); updatedNpcWarps = false; foreach (var bucket in buckets) { @@ -149,16 +149,16 @@ namespace StardewModdingAPI.Metadata ** Private methods *********/ /// <summary>Reload one of the game's core assets (if applicable).</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <param name="type">The asset type to reload.</param> /// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param> /// <param name="changedWarps">Whether any map warps were changed as part of this propagation.</param> /// <returns>Returns whether an asset was loaded. The return value may be true or false, or a non-null value for true.</returns> [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")] - private bool PropagateOther(string key, Type type, bool ignoreWorld, out bool changedWarps) + private bool PropagateOther(IAssetName assetName, Type type, bool ignoreWorld, out bool changedWarps) { var content = this.MainContentManager; - key = this.AssertAndNormalizeAssetName(key); + string key = assetName.Name; changedWarps = false; /**** @@ -170,7 +170,7 @@ namespace StardewModdingAPI.Metadata { foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) { - if (this.IsSameAssetKey(tilesheet.ImageSource, key)) + if (assetName.IsEquivalentTo(tilesheet.ImageSource)) Game1.mapDisplayDevice.LoadTileSheet(tilesheet); } } @@ -188,7 +188,7 @@ namespace StardewModdingAPI.Metadata { GameLocation location = info.Location; - if (this.IsSameAssetKey(location.mapPath.Value, key)) + if (assetName.IsEquivalentTo(location.mapPath.Value)) { static ISet<string> GetWarpSet(GameLocation location) { @@ -213,14 +213,13 @@ namespace StardewModdingAPI.Metadata /**** ** Propagate by key ****/ - Reflector reflection = this.Reflection; - switch (key.ToLower().Replace("\\", "/")) // normalized key so we can compare statically + switch (assetName.Name.ToLower().Replace("\\", "/")) // normalized key so we can compare statically { /**** ** Animals ****/ case "animals/horse": - return !ignoreWorld && this.ReloadPetOrHorseSprites<Horse>(content, key); + return !ignoreWorld && this.ReloadPetOrHorseSprites<Horse>(content, assetName); /**** ** Buildings @@ -231,7 +230,7 @@ namespace StardewModdingAPI.Metadata case "buildings/houses_paintmask": // Farm { - bool removedFromCache = this.RemoveFromPaintMaskCache(key); + bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); Farm farm = Game1.getFarm(); farm?.ApplyHousePaint(); @@ -250,7 +249,7 @@ namespace StardewModdingAPI.Metadata case "characters/farmer/farmer_base_bald": case "characters/farmer/farmer_girl_base": case "characters/farmer/farmer_girl_base_bald": - return !ignoreWorld && this.ReloadPlayerSprites(key); + return !ignoreWorld && this.ReloadPlayerSprites(assetName); case "characters/farmer/hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hairStylesTexture, key); @@ -313,7 +312,7 @@ namespace StardewModdingAPI.Metadata return true; case "data/npcdispositions": // NPC constructor - return !ignoreWorld && this.ReloadNpcDispositions(content, key); + return !ignoreWorld && this.ReloadNpcDispositions(content, assetName); case "data/npcgifttastes": // Game1.LoadContent Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key); @@ -393,7 +392,7 @@ namespace StardewModdingAPI.Metadata } if (!ignoreWorld) - this.ReloadDoorSprites(content, key); + this.ReloadDoorSprites(content, assetName); return true; case "loosesprites/cursors2": // Game1.LoadContent @@ -425,7 +424,7 @@ namespace StardewModdingAPI.Metadata return true; case "loosesprites/suspensionbridge": // SuspensionBridge constructor - return !ignoreWorld && this.ReloadSuspensionBridges(content, key); + return !ignoreWorld && this.ReloadSuspensionBridges(content, assetName); /**** ** Content\Maps @@ -456,7 +455,7 @@ namespace StardewModdingAPI.Metadata return false; case "minigames/titlebuttons": // TitleMenu - return this.ReloadTitleButtons(content, key); + return this.ReloadTitleButtons(content, assetName); /**** ** Content\Strings @@ -480,14 +479,14 @@ namespace StardewModdingAPI.Metadata return true; case "tilesheets/chairtiles": // Game1.LoadContent - return this.ReloadChairTiles(content, key, ignoreWorld); + return this.ReloadChairTiles(content, assetName, ignoreWorld); case "tilesheets/craftables": // Game1.LoadContent Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key); return true; case "tilesheets/critters": // Critter constructor - return !ignoreWorld && this.ReloadCritterTextures(content, key) > 0; + return !ignoreWorld && this.ReloadCritterTextures(content, assetName) > 0; case "tilesheets/crops": // Game1.LoadContent Game1.cropSpriteSheet = content.Load<Texture2D>(key); @@ -541,7 +540,7 @@ namespace StardewModdingAPI.Metadata return true; case "terrainfeatures/grass": // from Grass - return !ignoreWorld && this.ReloadGrassTextures(content, key); + return !ignoreWorld && this.ReloadGrassTextures(content, assetName); case "terrainfeatures/hoedirt": // from HoeDirt HoeDirt.lightTexture = content.Load<Texture2D>(key); @@ -556,27 +555,27 @@ namespace StardewModdingAPI.Metadata return true; case "terrainfeatures/mushroom_tree": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.mushroomTree); + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.mushroomTree); case "terrainfeatures/tree_palm": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.palmTree); + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.palmTree); case "terrainfeatures/tree1_fall": // from Tree case "terrainfeatures/tree1_spring": // from Tree case "terrainfeatures/tree1_summer": // from Tree case "terrainfeatures/tree1_winter": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.bushyTree); + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.bushyTree); case "terrainfeatures/tree2_fall": // from Tree case "terrainfeatures/tree2_spring": // from Tree case "terrainfeatures/tree2_summer": // from Tree case "terrainfeatures/tree2_winter": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.leafyTree); + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.leafyTree); case "terrainfeatures/tree3_fall": // from Tree case "terrainfeatures/tree3_spring": // from Tree case "terrainfeatures/tree3_winter": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.pineTree); + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.pineTree); } /**** @@ -585,25 +584,25 @@ namespace StardewModdingAPI.Metadata if (!ignoreWorld) { // dynamic textures - if (this.KeyStartsWith(key, "animals/cat")) - return this.ReloadPetOrHorseSprites<Cat>(content, key); - if (this.KeyStartsWith(key, "animals/dog")) - return this.ReloadPetOrHorseSprites<Dog>(content, key); - if (this.IsInFolder(key, "Animals")) - return this.ReloadFarmAnimalSprites(content, key); + if (assetName.StartsWith("animals/cat")) + return this.ReloadPetOrHorseSprites<Cat>(content, assetName); + if (assetName.StartsWith("animals/dog")) + return this.ReloadPetOrHorseSprites<Dog>(content, assetName); + if (assetName.IsDirectlyUnderPath("Animals")) + return this.ReloadFarmAnimalSprites(content, assetName); - if (this.IsInFolder(key, "Buildings")) - return this.ReloadBuildings(key); + if (assetName.IsDirectlyUnderPath("Buildings")) + return this.ReloadBuildings(assetName); - if (this.KeyStartsWith(key, "LooseSprites/Fence")) - return this.ReloadFenceTextures(key); + if (assetName.StartsWith("LooseSprites/Fence")) + return this.ReloadFenceTextures(assetName); // dynamic data - if (this.IsInFolder(key, "Characters/Dialogue")) - return this.ReloadNpcDialogue(key); + if (assetName.IsDirectlyUnderPath("Characters/Dialogue")) + return this.ReloadNpcDialogue(assetName); - if (this.IsInFolder(key, "Characters/schedules")) - return this.ReloadNpcSchedules(key); + if (assetName.IsDirectlyUnderPath("Characters/schedules")) + return this.ReloadNpcSchedules(assetName); } return false; @@ -618,14 +617,14 @@ namespace StardewModdingAPI.Metadata ****/ /// <summary>Reload buttons on the title screen.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> /// <remarks>Derived from the <see cref="TitleMenu"/> constructor and <see cref="TitleMenu.setUpIcons"/>.</remarks> - private bool ReloadTitleButtons(LocalizedContentManager content, string key) + private bool ReloadTitleButtons(LocalizedContentManager content, IAssetName assetName) { if (Game1.activeClickableMenu is TitleMenu titleMenu) { - Texture2D texture = content.Load<Texture2D>(key); + Texture2D texture = content.Load<Texture2D>(assetName.Name); titleMenu.titleButtonsTexture = texture; titleMenu.backButton.texture = texture; @@ -645,21 +644,21 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for matching pets or horses.</summary> /// <typeparam name="TAnimal">The animal type.</typeparam> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadPetOrHorseSprites<TAnimal>(LocalizedContentManager content, string key) + private bool ReloadPetOrHorseSprites<TAnimal>(LocalizedContentManager content, IAssetName assetName) where TAnimal : NPC { // find matches TAnimal[] animals = this.GetCharacters() .OfType<TAnimal>() - .Where(p => this.IsSameAssetKey(p.Sprite?.Texture?.Name, key)) + .Where(p => assetName.IsEquivalentTo(p.Sprite?.Texture?.Name)) .ToArray(); if (!animals.Any()) return false; // update sprites - Texture2D texture = content.Load<Texture2D>(key); + Texture2D texture = content.Load<Texture2D>(assetName.Name); foreach (TAnimal animal in animals) animal.Sprite.spriteTexture = texture; return true; @@ -667,10 +666,10 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for matching farm animals.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> /// <remarks>Derived from <see cref="FarmAnimal.reload"/>.</remarks> - private bool ReloadFarmAnimalSprites(LocalizedContentManager content, string key) + private bool ReloadFarmAnimalSprites(LocalizedContentManager content, IAssetName assetName) { // find matches FarmAnimal[] animals = this.GetFarmAnimals().ToArray(); @@ -678,7 +677,7 @@ namespace StardewModdingAPI.Metadata return false; // update sprites - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name)); foreach (FarmAnimal animal in animals) { // get expected key @@ -690,23 +689,23 @@ namespace StardewModdingAPI.Metadata expectedKey = $"Animals/{expectedKey}"; // reload asset - if (this.IsSameAssetKey(expectedKey, key)) + if (assetName.IsEquivalentTo(expectedKey)) animal.Sprite.spriteTexture = texture.Value; } return texture.IsValueCreated; } /// <summary>Reload building textures.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadBuildings(string key) + private bool ReloadBuildings(IAssetName assetName) { // get paint mask info const string paintMaskSuffix = "_PaintMask"; - bool isPaintMask = key.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase); + bool isPaintMask = assetName.BaseName.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase); // get building type - string type = Path.GetFileName(key); + string type = Path.GetFileName(assetName.Name)!; if (isPaintMask) type = type.Substring(0, type.Length - paintMaskSuffix.Length); @@ -718,7 +717,7 @@ namespace StardewModdingAPI.Metadata .ToArray(); // remove from paint mask cache - bool removedFromCache = this.RemoveFromPaintMaskCache(key); + bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); // reload textures if (buildings.Any()) @@ -734,12 +733,12 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload map seat textures.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadChairTiles(LocalizedContentManager content, string key, bool ignoreWorld) + private bool ReloadChairTiles(LocalizedContentManager content, IAssetName assetName, bool ignoreWorld) { - MapSeat.mapChairTexture = content.Load<Texture2D>(key); + MapSeat.mapChairTexture = content.Load<Texture2D>(assetName.Name); if (!ignoreWorld) { @@ -747,7 +746,7 @@ namespace StardewModdingAPI.Metadata { foreach (MapSeat seat in location.mapSeats.Where(p => p != null)) { - if (this.IsSameAssetKey(seat._loadedTextureFile, key)) + if (assetName.IsEquivalentTo(seat._loadedTextureFile)) seat.overlayTexture = MapSeat.mapChairTexture; } } @@ -758,9 +757,9 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload critter textures.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns the number of reloaded assets.</returns> - private int ReloadCritterTextures(LocalizedContentManager content, string key) + private int ReloadCritterTextures(LocalizedContentManager content, IAssetName assetName) { // get critters Critter[] critters = @@ -768,7 +767,7 @@ namespace StardewModdingAPI.Metadata from location in this.GetLocations() where location.critters != null from Critter critter in location.critters - where this.IsSameAssetKey(critter.sprite?.Texture?.Name, key) + where assetName.IsEquivalentTo(critter.sprite?.Texture?.Name) select critter ) .ToArray(); @@ -776,7 +775,7 @@ namespace StardewModdingAPI.Metadata return 0; // update sprites - Texture2D texture = content.Load<Texture2D>(key); + Texture2D texture = content.Load<Texture2D>(assetName.Name); foreach (var entry in critters) entry.sprite.spriteTexture = texture; @@ -785,11 +784,11 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for interior doors.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any doors were affected.</returns> - private bool ReloadDoorSprites(LocalizedContentManager content, string key) + private bool ReloadDoorSprites(LocalizedContentManager content, IAssetName assetName) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name)); foreach (GameLocation location in this.GetLocations()) { @@ -803,7 +802,7 @@ namespace StardewModdingAPI.Metadata continue; string curKey = this.Reflection.GetField<string>(door.Sprite, "textureName").GetValue(); - if (this.IsSameAssetKey(curKey, key)) + if (assetName.IsEquivalentTo(curKey)) door.Sprite.texture = texture.Value; } } @@ -827,12 +826,12 @@ namespace StardewModdingAPI.Metadata } /// <summary>Reload the sprites for a fence type.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadFenceTextures(string key) + private bool ReloadFenceTextures(IAssetName assetName) { - // get fence type - if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType)) + // get fence type (e.g. LooseSprites/Fence3 => 3) + if (!int.TryParse(this.GetSegments(assetName.BaseName)[1].Substring("Fence".Length), out int fenceType)) return false; // get fences @@ -855,22 +854,22 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload tree textures.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadGrassTextures(LocalizedContentManager content, string key) + private bool ReloadGrassTextures(LocalizedContentManager content, IAssetName assetName) { Grass[] grasses = ( from location in this.GetLocations() from grass in location.terrainFeatures.Values.OfType<Grass>() - where this.IsSameAssetKey(grass.textureName(), key) + where assetName.IsEquivalentTo(grass.textureName()) select grass ) .ToArray(); if (grasses.Any()) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name)); foreach (Grass grass in grasses) grass.texture = texture; return true; @@ -932,11 +931,11 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the disposition data for matching NPCs.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any NPCs were affected.</returns> - private bool ReloadNpcDispositions(LocalizedContentManager content, string key) + private bool ReloadNpcDispositions(LocalizedContentManager content, IAssetName assetName) { - IDictionary<string, string> data = content.Load<Dictionary<string, string>>(key); + IDictionary<string, string> data = content.Load<Dictionary<string, string>>(assetName.Name); bool changed = false; foreach (NPC npc in this.GetCharacters()) { @@ -953,16 +952,16 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for matching NPCs.</summary> /// <param name="keys">The asset keys to reload.</param> /// <param name="propagated">The asset keys which have been propagated.</param> - private void ReloadNpcSprites(IEnumerable<string> keys, IDictionary<string, bool> propagated) + private void ReloadNpcSprites(IEnumerable<IAssetName> keys, IDictionary<IAssetName, bool> propagated) { // get NPCs - HashSet<string> lookup = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase); + IDictionary<string, IAssetName> lookup = keys.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); var characters = ( from npc in this.GetCharacters() let key = this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name) - where key != null && lookup.Contains(key) - select new { Npc = npc, Key = key } + where key != null && lookup.ContainsKey(key) + select new { Npc = npc, AssetName = lookup[key] } ) .ToArray(); if (!characters.Any()) @@ -971,56 +970,56 @@ namespace StardewModdingAPI.Metadata // update sprite foreach (var target in characters) { - target.Npc.Sprite.spriteTexture = this.LoadAndDisposeIfNeeded(target.Npc.Sprite.spriteTexture, target.Key); - propagated[target.Key] = true; + target.Npc.Sprite.spriteTexture = this.LoadAndDisposeIfNeeded(target.Npc.Sprite.spriteTexture, target.AssetName.Name); + propagated[target.AssetName] = true; } } /// <summary>Reload the portraits for matching NPCs.</summary> /// <param name="keys">The asset key to reload.</param> /// <param name="propagated">The asset keys which have been propagated.</param> - private void ReloadNpcPortraits(IEnumerable<string> keys, IDictionary<string, bool> propagated) + private void ReloadNpcPortraits(IEnumerable<IAssetName> keys, IDictionary<IAssetName, bool> propagated) { // get NPCs - HashSet<string> lookup = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase); + IDictionary<string, IAssetName> lookup = keys.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); var characters = ( from npc in this.GetCharacters() where npc.isVillager() let key = this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name) - where key != null && lookup.Contains(key) - select new { Npc = npc, Key = key } + where key != null && lookup.ContainsKey(key) + select new { Npc = npc, AssetName = lookup[key] } ) .ToList(); // special case: Gil is a private NPC field on the AdventureGuild class (only used for the portrait) { string gilKey = this.NormalizeAssetNameIgnoringEmpty("Portraits/Gil"); - if (lookup.Contains(gilKey)) + if (lookup.TryGetValue(gilKey, out IAssetName assetName)) { GameLocation adventureGuild = Game1.getLocationFromName("AdventureGuild"); if (adventureGuild != null) - characters.Add(new { Npc = this.Reflection.GetField<NPC>(adventureGuild, "Gil").GetValue(), Key = gilKey }); + characters.Add(new { Npc = this.Reflection.GetField<NPC>(adventureGuild, "Gil").GetValue(), AssetName = assetName }); } } // update portrait foreach (var target in characters) { - target.Npc.Portrait = this.LoadAndDisposeIfNeeded(target.Npc.Portrait, target.Key); - propagated[target.Key] = true; + target.Npc.Portrait = this.LoadAndDisposeIfNeeded(target.Npc.Portrait, target.AssetName.Name); + propagated[target.AssetName] = true; } } /// <summary>Reload the sprites for matching players.</summary> - /// <param name="key">The asset key to reload.</param> - private bool ReloadPlayerSprites(string key) + /// <param name="assetName">The asset name to reload.</param> + private bool ReloadPlayerSprites(IAssetName assetName) { Farmer[] players = ( from player in Game1.getOnlineFarmers() - where this.IsSameAssetKey(player.getTexture(), key) + where assetName.IsEquivalentTo(player.getTexture()) select player ) .ToArray(); @@ -1036,11 +1035,11 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload suspension bridge textures.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadSuspensionBridges(LocalizedContentManager content, string key) + private bool ReloadSuspensionBridges(LocalizedContentManager content, IAssetName assetName) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name)); foreach (GameLocation location in this.GetLocations(buildingInteriors: false)) { @@ -1059,10 +1058,10 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload tree textures.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <param name="type">The type to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type) + private bool ReloadTreeTextures(LocalizedContentManager content, IAssetName assetName, int type) { Tree[] trees = this.GetLocations() .SelectMany(p => p.terrainFeatures.Values.OfType<Tree>()) @@ -1071,7 +1070,7 @@ namespace StardewModdingAPI.Metadata if (trees.Any()) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.Name)); foreach (Tree tree in trees) tree.texture = texture; return true; @@ -1084,12 +1083,12 @@ namespace StardewModdingAPI.Metadata ** Reload data methods ****/ /// <summary>Reload the dialogue data for matching NPCs.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any assets were reloaded.</returns> - private bool ReloadNpcDialogue(string key) + private bool ReloadNpcDialogue(IAssetName assetName) { // get NPCs - string name = Path.GetFileName(key); + string name = Path.GetFileName(assetName.Name); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; @@ -1114,12 +1113,12 @@ namespace StardewModdingAPI.Metadata } /// <summary>Reload the schedules for matching NPCs.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any assets were reloaded.</returns> - private bool ReloadNpcSchedules(string key) + private bool ReloadNpcSchedules(IAssetName assetName) { // get NPCs - string name = Path.GetFileName(key); + string name = Path.GetFileName(assetName.Name); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; @@ -1243,39 +1242,6 @@ namespace StardewModdingAPI.Metadata return this.AssertAndNormalizeAssetName(path); } - /// <summary>Get whether a given asset key is equivalent to a normalized asset key, ignoring unimportant differences like capitalization and formatting.</summary> - /// <param name="actualKey">The actual key to check.</param> - /// <param name="normalizedKey">The key to match, already normalized via <see cref="AssertAndNormalizeAssetName"/> or <see cref="NormalizeAssetNameIgnoringEmpty"/>.</param> - private bool IsSameAssetKey(string actualKey, string normalizedKey) - { - if (actualKey is null || normalizedKey is null) - return false; - - return normalizedKey.Equals(PathUtilities.NormalizeAssetName(actualKey), StringComparison.OrdinalIgnoreCase); - } - - /// <summary>Get whether a key starts with a substring after the substring is normalized.</summary> - /// <param name="key">The key to check.</param> - /// <param name="rawSubstring">The substring to normalize and find.</param> - private bool KeyStartsWith(string key, string rawSubstring) - { - if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(rawSubstring)) - return false; - - return key.StartsWith(this.NormalizeAssetNameIgnoringEmpty(rawSubstring), StringComparison.OrdinalIgnoreCase); - } - - /// <summary>Get whether a normalized asset key is in the given folder.</summary> - /// <param name="key">The normalized asset key (like <c>Animals/cat</c>).</param> - /// <param name="folder">The key folder (like <c>Animals</c>); doesn't need to be normalized.</param> - /// <param name="allowSubfolders">Whether to return true if the key is inside a subfolder of the <paramref name="folder"/>.</param> - private bool IsInFolder(string key, string folder, bool allowSubfolders = false) - { - return - this.KeyStartsWith(key, $"{folder}/") - && (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1); - } - /// <summary>Get the segments in a path (e.g. 'a/b' is 'a' and 'b').</summary> /// <param name="path">The path to check.</param> private string[] GetSegments(string path) @@ -1285,13 +1251,6 @@ namespace StardewModdingAPI.Metadata : Array.Empty<string>(); } - /// <summary>Count the number of segments in a path (e.g. 'a/b' is 2).</summary> - /// <param name="path">The path to check.</param> - private int CountSegments(string path) - { - return this.GetSegments(path).Length; - } - /// <summary>Load a texture, and dispose the old one if <see cref="AggressiveMemoryOptimizations"/> is enabled and it's different from the new instance.</summary> /// <param name="oldTexture">The previous texture to dispose.</param> /// <param name="key">The asset key to load.</param> @@ -1315,8 +1274,8 @@ namespace StardewModdingAPI.Metadata } /// <summary>Remove a case-insensitive key from the paint mask cache.</summary> - /// <param name="key">The paint mask asset key.</param> - private bool RemoveFromPaintMaskCache(string key) + /// <param name="assetName">The paint mask asset name.</param> + private bool RemoveFromPaintMaskCache(IAssetName assetName) { // make cache case-insensitive // This is needed for cache invalidation since mods may specify keys with a different capitalization @@ -1324,7 +1283,7 @@ namespace StardewModdingAPI.Metadata BuildingPainter.paintMaskLookup = new Dictionary<string, List<List<int>>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase); // remove key from cache - return BuildingPainter.paintMaskLookup.Remove(key); + return BuildingPainter.paintMaskLookup.Remove(assetName.Name); } /// <summary>Metadata about a location used in asset propagation.</summary> |