diff options
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/ModContentManager.cs | 239 |
1 files changed, 183 insertions, 56 deletions
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index e1d9ce78..fe5aaf5d 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,11 +1,11 @@ using System; -using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Drawing; using System.Globalization; using System.IO; using System.Linq; using BmFont; -using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using SkiaSharp; @@ -19,11 +19,12 @@ using StardewValley; using xTile; using xTile.Format; using xTile.Tiles; +using Color = Microsoft.Xna.Framework.Color; namespace StardewModdingAPI.Framework.ContentManagers { /// <summary>A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> - internal class ModContentManager : BaseContentManager + internal sealed class ModContentManager : BaseContentManager { /********* ** Fields @@ -44,7 +45,7 @@ namespace StardewModdingAPI.Framework.ContentManagers private readonly IFileLookup FileLookup; /// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary> - private static readonly HashSet<string> LocalTilesheetExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".xnb" }; + private static readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" }; /********* @@ -64,8 +65,21 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> /// <param name="fileLookup">A lookup for files within the <paramref name="rootDirectory"/>.</param> /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param> - public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, IFileLookup fileLookup, bool useRawImageLoading) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) + public ModContentManager( + string name, + IContentManager gameContentManager, + IServiceProvider serviceProvider, + string modName, + string rootDirectory, + CultureInfo currentCulture, + ContentCoordinator coordinator, + IMonitor monitor, + Reflector reflection, + JsonHelper jsonHelper, + Action<BaseContentManager> onDisposing, + IFileLookup fileLookup, + bool useRawImageLoading + ) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) { this.GameContentManager = gameContentManager; this.FileLookup = fileLookup; @@ -102,7 +116,14 @@ namespace StardewModdingAPI.Framework.ContentManagers if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) { if (contentManagerID != this.Name) - throw this.GetLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager."); + { + throw this.GetLoadError( + assetName, + ContentLoadErrorType.AccessDenied, + "can't load a different mod's managed asset key through this mod content manager." + ); + } + assetName = relativePath; } } @@ -127,7 +148,11 @@ namespace StardewModdingAPI.Framework.ContentManagers _ => this.HandleUnknownFileType<T>(assetName, file) }; } - catch (Exception ex) when (ex is not SContentLoadException) + catch (SContentLoadException) + { + throw; + } + catch (Exception ex) { throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex); } @@ -138,6 +163,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <inheritdoc /> + [Obsolete($"Temporary {nameof(ModContentManager)}s are unsupported")] public override LocalizedContentManager CreateTemporary() { throw new NotSupportedException("Can't create a temporary mod content manager."); @@ -157,6 +183,67 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ + /// <summary> + /// Validates that the provided <typeparamref name="TInput">type</typeparamref> is compatible with <typeparamref name="TExpected"/>. + /// </summary> + /// <typeparam name="TInput">Type to validate compatibility of.</typeparam> + /// <typeparam name="TExpected">Type to validate compatibility against.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file being loaded.</param> + /// <param name="exception">The exception to throw if the type validation fails, otherwise <see langword="null"/>.</param> + /// <returns><see langword="true"/> if the type validation succeeds, otherwise <see langword="false"/></returns> + private bool ValidateType<TInput, TExpected>( + IAssetName assetName, + FileInfo file, + [NotNullWhen(false)] out SContentLoadException? exception + ) + { + if (typeof(TInput).IsAssignableFrom(typeof(TExpected))) + { + exception = null; + return true; + } + + exception = this.GetLoadError( + assetName, + ContentLoadErrorType.InvalidData, + $"can't read file with extension '{file.Extension}' as type '{typeof(TInput)}'; must be type '{typeof(TExpected)}'." + ); + return false; + } + + /// <summary> + /// Validates that the provided <typeparamref name="TInput">type</typeparamref> + /// is compatible with <typeparamref name="TExpected0"/> or <typeparamref name="TExpected1"/> + /// </summary> + /// <typeparam name="TInput">Type to validate compatibility of.</typeparam> + /// <typeparam name="TExpected0">First type to validate compatibility against.</typeparam> + /// /// <typeparam name="TExpected1">Second type to validate compatibility against.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file being loaded.</param> + /// <param name="exception">The exception to throw if the type validation fails, otherwise <see langword="null"/>.</param> + /// <returns><see langword="true"/> if the type validation succeeds, otherwise <see langword="false"/></returns> + private bool ValidateType<TInput, TExpected0, TExpected1>( + IAssetName assetName, + FileInfo file, + [NotNullWhen(false)] out SContentLoadException? exception + ) + { + if (typeof(TInput).IsAssignableFrom(typeof(TExpected0)) || typeof(TInput).IsAssignableFrom(typeof(TExpected1))) + { + exception = null; + return true; + } + + exception = this.GetLoadError( + assetName, + ContentLoadErrorType.InvalidData, + $"can't read file with extension '{file.Extension}' as type '{typeof(TInput)}'; must be type '{typeof(TExpected0)}' or '{typeof(TExpected1)}'." + ); + return false; + } + + /// <summary>Load an unpacked font file (<c>.fnt</c>).</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset name relative to the loader root directory.</param> @@ -164,8 +251,10 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadFont<T>(IAssetName assetName, FileInfo file) { // validate - if (!typeof(T).IsAssignableFrom(typeof(XmlSource))) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'."); + if (!this.ValidateType<T, XmlSource>(assetName, file, out var exception)) + { + throw exception; + } // load string source = File.ReadAllText(file.FullName); @@ -179,7 +268,10 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadDataFile<T>(IAssetName assetName, FileInfo file) { if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset)) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method + { + // should never happen as we check for file existence before calling this method + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); + } return asset; } @@ -191,24 +283,23 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadImageFile<T>(IAssetName assetName, FileInfo file) { // validate type - bool asRawData = false; - if (typeof(T) != typeof(Texture2D)) + if (!this.ValidateType<T, Texture2D, IRawTextureData>(assetName, file, out var exception)) { - asRawData = typeof(T) == typeof(IRawTextureData); - if (!asRawData) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}' or '{typeof(IRawTextureData)}'."); + throw exception; } + bool asRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData)); + // load if (asRawData || this.UseRawImageLoading) { - this.LoadRawImageData(file, out int width, out int height, out Color[] pixels, asRawData); + (Size size, Color[] pixels) = ModContentManager.LoadRawImageData(file, asRawData); if (asRawData) - return (T)(object)new RawTextureData(width, height, pixels); + return (T)(object)new RawTextureData(size.Width, size.Height, pixels); else { - Texture2D texture = new(Game1.graphics.GraphicsDevice, width, height); + Texture2D texture = new(Game1.graphics.GraphicsDevice, size.Width, size.Height); texture.SetData(pixels); return (T)(object)texture; } @@ -217,34 +308,32 @@ namespace StardewModdingAPI.Framework.ContentManagers { using FileStream stream = File.OpenRead(file.FullName); Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); + texture = ModContentManager.PremultiplyTransparency(texture); return (T)(object)texture; } } /// <summary>Load the raw image data from a file on disk.</summary> /// <param name="file">The file whose data to load.</param> - /// <param name="width">The pixel width for the loaded image data.</param> - /// <param name="height">The pixel height for the loaded image data.</param> - /// <param name="pixels">The premultiplied pixel data.</param> /// <param name="forRawData">Whether the data is being loaded for an <see cref="IRawTextureData"/> (true) or <see cref="Texture2D"/> (false) instance.</param> /// <remarks>This is separate to let framework mods intercept the data before it's loaded, if needed.</remarks> [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")] [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")] - private void LoadRawImageData(FileInfo file, out int width, out int height, out Color[] pixels, bool forRawData) + private static (Size Size, Color[] Data) LoadRawImageData(FileInfo file, bool forRawData) { + Size size; + // load raw data SKPMColor[] rawPixels; { using FileStream stream = File.OpenRead(file.FullName); using SKBitmap bitmap = SKBitmap.Decode(stream); rawPixels = SKPMColor.PreMultiply(bitmap.Pixels); - width = bitmap.Width; - height = bitmap.Height; + size = new(bitmap.Width, bitmap.Height); } // convert to XNA pixel format - pixels = new Color[rawPixels.Length]; + var pixels = GC.AllocateUninitializedArray<Color>(rawPixels.Length); for (int i = 0; i < pixels.Length; i++) { SKPMColor pixel = rawPixels[i]; @@ -252,6 +341,8 @@ namespace StardewModdingAPI.Framework.ContentManagers ? Color.Transparent : new Color(r: pixel.Red, g: pixel.Green, b: pixel.Blue, alpha: pixel.Alpha); } + + return (size, pixels); } /// <summary>Load an unpacked image file (<c>.tbin</c> or <c>.tmx</c>).</summary> @@ -261,8 +352,10 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadMapFile<T>(IAssetName assetName, FileInfo file) { // validate - if (typeof(T) != typeof(Map)) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + if (!this.ValidateType<T, Map>(assetName, file, out var exception)) + { + throw exception; + } // load FormatManager formatManager = FormatManager.Instance; @@ -277,8 +370,14 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset name relative to the loader root directory.</param> private T LoadXnbFile<T>(IAssetName assetName) { - if (typeof(T) == typeof(IRawTextureData)) - throw this.GetLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file."); + if (typeof(IRawTextureData).IsAssignableFrom(typeof(T))) + { + throw this.GetLoadError( + assetName, + ContentLoadErrorType.Other, + $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file." + ); + } // the underlying content manager adds a .xnb extension implicitly, so // we need to strip it here to avoid trying to load a '.xnb.xnb' file. @@ -303,7 +402,11 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="file">The file to load.</param> private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file) { - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'."); + throw this.GetLoadError( + assetName, + ContentLoadErrorType.InvalidName, + $"unknown file extension '{file.Extension}'; must be one of: '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'." + ); } /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary> @@ -311,6 +414,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset name that failed to load.</param> /// <param name="reasonPhrase">The reason the file couldn't be loaded.</param> /// <param name="exception">The underlying exception, if applicable.</param> + [DebuggerStepThrough, DebuggerHidden] private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null) { return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); @@ -325,16 +429,16 @@ namespace StardewModdingAPI.Framework.ContentManagers FileInfo file = this.FileLookup.GetFile(path); // try with default image extensions - if (!file.Exists && typeof(Texture2D).IsAssignableFrom(typeof(T)) && !ModContentManager.LocalTilesheetExtensions.Contains(file.Extension)) + if (file.Exists || !typeof(Texture2D).IsAssignableFrom(typeof(T)) || ModContentManager.LocalTilesheetExtensions.Contains(file.Extension)) + return file; + + foreach (string extension in ModContentManager.LocalTilesheetExtensions) { - foreach (string extension in ModContentManager.LocalTilesheetExtensions) + FileInfo result = new(file.FullName + extension); + if (result.Exists) { - FileInfo result = new(file.FullName + extension); - if (result.Exists) - { - file = result; - break; - } + file = result; + break; } } @@ -345,10 +449,10 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="texture">The texture to premultiply.</param> /// <returns>Returns a premultiplied texture.</returns> /// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks> - private Texture2D PremultiplyTransparency(Texture2D texture) + private static Texture2D PremultiplyTransparency(Texture2D texture) { // premultiply pixels - Color[] data = new Color[texture.Width * texture.Height]; + Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height); texture.GetData(data); bool changed = false; for (int i = 0; i < data.Length; i++) @@ -357,7 +461,12 @@ namespace StardewModdingAPI.Framework.ContentManagers 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()) + 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; } @@ -370,7 +479,10 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> /// <param name="map">The map whose tilesheets to fix.</param> /// <param name="relativeMapPath">The relative map path within the mod folder.</param> - /// <param name="fixEagerPathPrefixes">Whether to undo the game's eager tilesheet path prefixing for maps loaded from an <c>.xnb</c> file, which incorrectly prefixes tilesheet paths with the map's local asset key folder.</param> + /// <param name="fixEagerPathPrefixes"> + /// Whether to undo the game's eager tilesheet path prefixing for maps loaded from an <c>.xnb</c> file, + /// which incorrectly prefixes tilesheet paths with the map's local asset key folder. + /// </param> /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPathPrefixes) { @@ -388,18 +500,28 @@ namespace StardewModdingAPI.Framework.ContentManagers // reverse incorrect eager tilesheet path prefixing if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder)) - imageSource = imageSource.Substring(relativeMapFolder.Length + 1); + imageSource = imageSource[(relativeMapFolder.Length + 1)..]; // validate tilesheet path string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'."; if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) - throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)."); + { + throw new SContentLoadException( + ContentLoadErrorType.InvalidData, + $"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)." + ); + } // load best match try { if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error)) - throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} {error}"); + { + throw new SContentLoadException( + ContentLoadErrorType.InvalidData, + $"{errorPrefix} {error}" + ); + } if (assetName is not null) { @@ -409,7 +531,11 @@ namespace StardewModdingAPI.Framework.ContentManagers tilesheet.ImageSource = assetName.Name; } } - catch (Exception ex) when (ex is not SContentLoadException) + catch (SContentLoadException) + { + throw; + } + catch (Exception ex) { throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} The tilesheet couldn't be loaded.", ex); } @@ -425,7 +551,6 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks> private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error) { - assetName = null; error = null; // nothing to do @@ -440,7 +565,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // opened in Tiled, while still mapping it to the vanilla 'Maps/spring_town' asset at runtime. { string filename = Path.GetFileName(relativePath); - if (filename.StartsWith(".")) + if (filename.StartsWith('.')) relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.')); } @@ -455,10 +580,11 @@ namespace StardewModdingAPI.Framework.ContentManagers } // get from game assets - IAssetName contentKey = this.Coordinator.ParseAssetName(this.GetContentKeyForTilesheetImageSource(relativePath), allowLocales: false); + AssetName contentKey = this.Coordinator.ParseAssetName(ModContentManager.GetContentKeyForTilesheetImageSource(relativePath), allowLocales: false); try { - this.GameContentManager.LoadLocalized<Texture2D>(contentKey, this.GameContentManager.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset + // no need to bypass cache here, since we're not storing the asset + this.GameContentManager.LoadLocalized<Texture2D>(contentKey, this.GameContentManager.Language, useCache: true); assetName = contentKey; return true; } @@ -476,6 +602,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } // not found + assetName = null; error = "The tilesheet couldn't be found relative to either map file or the game's content folder."; return false; } @@ -486,16 +613,16 @@ namespace StardewModdingAPI.Framework.ContentManagers { // get file path string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); - if (!path.EndsWith(".xnb")) + if (!path.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)) path += ".xnb"; // get file - return new FileInfo(path).Exists; + return File.Exists(path); } /// <summary>Get the asset key for a tilesheet in the game's <c>Maps</c> content folder.</summary> /// <param name="relativePath">The tilesheet image source.</param> - private string GetContentKeyForTilesheetImageSource(string relativePath) + private static string GetContentKeyForTilesheetImageSource(string relativePath) { string key = relativePath; string topFolder = PathUtilities.GetSegments(key, limit: 2)[0]; @@ -506,7 +633,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // remove file extension from unpacked file if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) - key = key.Substring(0, key.Length - 4); + key = key[..^4]; return key; } |