diff options
Diffstat (limited to 'src/SMAPI/Framework')
22 files changed, 363 insertions, 212 deletions
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 529fb93a..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 /> @@ -41,39 +41,40 @@ namespace StardewModdingAPI.Framework.Content targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height)); // validate - if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height) + if (!source.Bounds.Contains(sourceArea.Value)) throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); - if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > target.Width || targetArea.Value.Bottom > target.Height) + if (!target.Bounds.Contains(targetArea.Value)) throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture."); - if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height) + if (sourceArea.Value.Size != targetArea.Value.Size) throw new InvalidOperationException("The source and target areas must be the same size."); // get source data int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; - Color[] sourceData = new Color[pixelCount]; + Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount); source.GetData(0, sourceArea, sourceData, 0, pixelCount); // merge data in overlay mode if (patchMode == PatchMode.Overlay) { // get target data - Color[] targetData = new Color[pixelCount]; + Color[] targetData = GC.AllocateUninitializedArray<Color>(pixelCount); target.GetData(0, targetArea, targetData, 0, pixelCount); // merge pixels - Color[] newData = new Color[targetArea.Value.Width * targetArea.Value.Height]; - target.GetData(0, targetArea, newData, 0, newData.Length); for (int i = 0; i < sourceData.Length; i++) { Color above = sourceData[i]; Color below = targetData[i]; // shortcut transparency - if (above.A < AssetDataForImage.MinOpacity) + if (above.A < MinOpacity) + { + sourceData[i] = below; continue; - if (below.A < AssetDataForImage.MinOpacity) + } + if (below.A < MinOpacity) { - newData[i] = above; + sourceData[i] = above; continue; } @@ -84,14 +85,13 @@ namespace StardewModdingAPI.Framework.Content // Note: don't use named arguments here since they're different between // Linux/macOS and Windows. float alphaBelow = 1 - (above.A / 255f); - newData[i] = new Color( + sourceData[i] = new Color( (int)(above.R + (below.R * alphaBelow)), // r (int)(above.G + (below.G * alphaBelow)), // g (int)(above.B + (below.B * alphaBelow)), // b Math.Max(above.A, below.A) // a ); } - sourceData = newData; } // patch target texture @@ -105,7 +105,7 @@ namespace StardewModdingAPI.Framework.Content return false; Texture2D original = this.Data; - Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); + Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); this.ReplaceWith(texture); this.PatchImage(original); return true; 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 99091f3e..00f9439c 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -14,6 +14,7 @@ using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using StardewValley.GameData; using xTile; namespace StardewModdingAPI.Framework @@ -46,7 +47,7 @@ namespace StardewModdingAPI.Framework private readonly Action OnLoadingFirstAsset; /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> - private readonly IList<IContentManager> ContentManagers = new List<IContentManager>(); + private readonly List<IContentManager> ContentManagers = new(); /// <summary>The language code for language-agnostic mod assets.</summary> private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage; @@ -56,16 +57,16 @@ namespace StardewModdingAPI.Framework /// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary> /// <remarks>The game may add content managers in asynchronous threads (e.g. when populating the load screen).</remarks> - private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim(); + private readonly ReaderWriterLockSlim ContentManagerLock = new(); /// <summary>A cache of ordered tilesheet IDs used by vanilla maps.</summary> - private readonly IDictionary<string, TilesheetReference[]> VanillaTilesheets = new Dictionary<string, TilesheetReference[]>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, TilesheetReference[]> VanillaTilesheets = new(StringComparer.OrdinalIgnoreCase); /// <summary>An unmodified content manager which doesn't intercept assets, used to compare asset data.</summary> private readonly LocalizedContentManager VanillaContentManager; /// <summary>The language enum values indexed by locale code.</summary> - private Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes; + private Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes; /********* @@ -106,7 +107,7 @@ namespace StardewModdingAPI.Framework this.Reflection = reflection; this.JsonHelper = jsonHelper; this.OnLoadingFirstAsset = onLoadingFirstAsset; - this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory); + this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory); this.ContentManagers.Add( this.MainContentManager = new GameContentManager( name: "Game1.content", @@ -136,7 +137,7 @@ namespace StardewModdingAPI.Framework this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations); - this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes); + this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(includeCustomLanguages: false)); } /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary> @@ -145,7 +146,7 @@ namespace StardewModdingAPI.Framework { return this.ContentManagerLock.InWriteLock(() => { - GameContentManager manager = new GameContentManager( + GameContentManager manager = new( name: name, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: this.MainContentManager.RootDirectory, @@ -171,7 +172,7 @@ namespace StardewModdingAPI.Framework { return this.ContentManagerLock.InWriteLock(() => { - ModContentManager manager = new ModContentManager( + ModContentManager manager = new( name: name, gameContentManager: gameContentManager, serviceProvider: this.MainContentManager.ServiceProvider, @@ -196,12 +197,17 @@ namespace StardewModdingAPI.Framework return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } - /// <summary>Perform any cleanup needed when the locale changes.</summary> - public void OnLocaleChanged() + /// <summary>Perform any updates needed when the game loads custom languages from <c>Data/AdditionalLanguages</c>.</summary> + public void OnAdditionalLanguagesInitialized() { - // rebuild locale cache (which may change due to custom mod languages) - this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes); + // update locale cache for custom languages, and load it now (since languages added later won't work) + this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(includeCustomLanguages: true)); + _ = this.LocaleCodes.Value; + } + /// <summary>Perform any updates needed when the locale changes.</summary> + public void OnLocaleChanged() + { // reload affected content this.ContentManagerLock.InReadLock(() => { @@ -242,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) @@ -300,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); @@ -314,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 @@ -325,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(); } } @@ -340,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); } } @@ -354,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 StringBuilder(); + 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 @@ -416,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) @@ -486,9 +495,22 @@ namespace StardewModdingAPI.Framework } /// <summary>Get the language enums (like <see cref="LocalizedContentManager.LanguageCode.ja"/>) indexed by locale code (like <c>ja-JP</c>).</summary> - private IDictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes() + /// <param name="includeCustomLanguages">Whether to read custom languages from <c>Data/AdditionalLanguages</c>.</param> + private Dictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes(bool includeCustomLanguages) { - IDictionary<string, LocalizedContentManager.LanguageCode> map = new Dictionary<string, LocalizedContentManager.LanguageCode>(); + var map = new Dictionary<string, LocalizedContentManager.LanguageCode>(StringComparer.OrdinalIgnoreCase); + + // custom languages + if (includeCustomLanguages) + { + foreach (ModLanguage language in Game1.content.Load<List<ModLanguage>>("Data/AdditionalLanguages")) + { + if (!string.IsNullOrWhiteSpace(language?.LanguageCode)) + map[language.LanguageCode] = LocalizedContentManager.LanguageCode.mod; + } + } + + // vanilla languages (override custom language if they conflict) foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode))) { string locale = this.GetLocaleCode(code); diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 5645c0fa..26f0921d 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -56,7 +56,7 @@ namespace StardewModdingAPI.Framework.ContentManagers public LanguageCode Language => this.GetCurrentLanguage(); /// <inheritdoc /> - public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); + public string FullRootDirectory => Path.Combine(Constants.GamePath, this.RootDirectory); /// <inheritdoc /> public bool IsNamespaced { get; } @@ -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..50ea6e61 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; } @@ -252,16 +252,20 @@ namespace StardewModdingAPI.Framework.ContentManagers // premultiply pixels Color[] data = new Color[texture.Width * texture.Height]; texture.GetData(data); + bool changed = false; for (int i = 0; i < data.Length; i++) { - var pixel = data[i]; - if (pixel.A == byte.MinValue || pixel.A == byte.MaxValue) + Color pixel = data[i]; + if (pixel.A is (byte.MinValue or byte.MaxValue)) continue; // no need to change fully transparent/opaque pixels data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) + changed = true; } - texture.SetData(data); + if (changed) + texture.SetData(data); + return texture; } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index fa20a079..f48c3aeb 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.Events private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new List<ManagedEventHandler<TEventArgs>>(); /// <summary>A cached snapshot of <see cref="Handlers"/>, or <c>null</c> to rebuild it next raise.</summary> - private ManagedEventHandler<TEventArgs>[] CachedHandlers = new ManagedEventHandler<TEventArgs>[0]; + private ManagedEventHandler<TEventArgs>[] CachedHandlers = Array.Empty<ManagedEventHandler<TEventArgs>>(); /// <summary>The total number of event handlers registered for this events, regardless of whether they're still registered.</summary> private int RegistrationIndex; diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs index b0bb7f80..3a99214f 100644 --- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs +++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs @@ -104,10 +104,10 @@ namespace StardewModdingAPI.Framework.Input this.LeftStickPos.Y = isDown ? 1 : 0; break; case SButton.LeftThumbstickDown: - this.LeftStickPos.Y = isDown ? 1 : 0; + this.LeftStickPos.Y = isDown ? -1 : 0; break; case SButton.LeftThumbstickLeft: - this.LeftStickPos.X = isDown ? 1 : 0; + this.LeftStickPos.X = isDown ? -1 : 0; break; case SButton.LeftThumbstickRight: this.LeftStickPos.X = isDown ? 1 : 0; @@ -118,10 +118,10 @@ namespace StardewModdingAPI.Framework.Input this.RightStickPos.Y = isDown ? 1 : 0; break; case SButton.RightThumbstickDown: - this.RightStickPos.Y = isDown ? 1 : 0; + this.RightStickPos.Y = isDown ? -1 : 0; break; case SButton.RightThumbstickLeft: - this.RightStickPos.X = isDown ? 1 : 0; + this.RightStickPos.X = isDown ? -1 : 0; break; case SButton.RightThumbstickRight: this.RightStickPos.X = isDown ? 1 : 0; 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/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 10bf9f94..9174aea6 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -92,9 +92,9 @@ namespace StardewModdingAPI.Framework.Models custom[pair.Key] = value; } - HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? new string[0], StringComparer.OrdinalIgnoreCase); + HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); if (SConfig.DefaultSuppressUpdateChecks.Count != curSuppressUpdateChecks.Count || SConfig.DefaultSuppressUpdateChecks.Any(p => !curSuppressUpdateChecks.Contains(p))) - custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks ?? new string[0]) + "]"; + custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks ?? Array.Empty<string>()) + "]"; return custom; } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 58537031..8f810644 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -43,6 +43,7 @@ using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Utilities; using StardewValley; +using StardewValley.Menus; using xTile.Display; using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix; using PathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; @@ -123,6 +124,9 @@ namespace StardewModdingAPI.Framework /// <summary>Whether post-game-startup initialization has been performed.</summary> private bool IsInitialized; + /// <summary>Whether the game has initialized for any custom languages from <c>Data/AdditionalLanguages</c>.</summary> + private bool AreCustomLanguagesInitialized; + /// <summary>Whether the player just returned to the title screen.</summary> public bool JustReturnedToTitle { get; set; } @@ -988,6 +992,13 @@ namespace StardewModdingAPI.Framework // preloaded if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0) this.OnLoadStageChanged(LoadStage.Loaded); + + // additional languages initialized + if (!this.AreCustomLanguagesInitialized && TitleMenu.ticksUntilLanguageLoad < 0) + { + this.AreCustomLanguagesInitialized = true; + this.ContentCore.OnAdditionalLanguagesInitialized(); + } } /********* @@ -1246,7 +1257,7 @@ namespace StardewModdingAPI.Framework { using RegistryKey key = Registry.LocalMachine.OpenSubKey(registryKey); if (key == null) - return new string[0]; + return Array.Empty<string>(); return key .GetSubKeyNames() @@ -1574,13 +1585,13 @@ namespace StardewModdingAPI.Framework /// <param name="list">A list of interceptors to update for the change.</param> private void OnAssetInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list) { - foreach (T interceptor in added ?? new T[0]) + foreach (T interceptor in added ?? Array.Empty<T>()) { this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: true)); list.Add(new ModLinked<T>(mod, interceptor)); } - foreach (T interceptor in removed ?? new T[0]) + foreach (T interceptor in removed ?? Array.Empty<T>()) { this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: false)); foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray()) diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs index 30e6274f..009e0282 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers @@ -16,10 +17,10 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers public bool IsChanged { get; } = false; /// <summary>The values added since the last reset.</summary> - public IEnumerable<TValue> Added { get; } = new TValue[0]; + public IEnumerable<TValue> Added { get; } = Array.Empty<TValue>(); /// <summary>The values removed since the last reset.</summary> - public IEnumerable<TValue> Removed { get; } = new TValue[0]; + public IEnumerable<TValue> Removed { get; } = Array.Empty<TValue>(); /********* diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs index 6d3a62bb..748e4ecc 100644 --- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs +++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; @@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.StateTracking this.FurnitureWatcher }); - this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]); + this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: Array.Empty<KeyValuePair<Vector2, SObject>>()); } /// <summary>Update the current value if needed.</summary> diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs index 0908b02a..72f45a87 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots ** Fields *********/ /// <summary>An empty item list diff.</summary> - private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(new Item[0], new Item[0], new ItemStackSizeChange[0]); + private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(Array.Empty<Item>(), Array.Empty<Item>(), Array.Empty<ItemStackSizeChange>()); /********* diff --git a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs index 9d63ab2c..173438f1 100644 --- a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs +++ b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs @@ -24,7 +24,7 @@ namespace MonoMod.Utils { // .NET Framework can break member ordering if using Module.Resolve* on certain members. - private static object[] _NoArgs = new object[0]; + private static object[] _NoArgs = Array.Empty<object>(); private static object[] _CacheGetterArgs = { /* MemberListType.All */ 0, /* name apparently always null? */ null }; private static Type t_RuntimeModule = |