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
{
/// A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.
- internal class ModContentManager : BaseContentManager
+ internal sealed class ModContentManager : BaseContentManager
{
/*********
** Fields
@@ -44,7 +45,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private readonly IFileLookup FileLookup;
/// If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.
- private static readonly HashSet LocalTilesheetExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".xnb" };
+ private static readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" };
/*********
@@ -64,8 +65,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// A callback to invoke when the content manager is being disposed.
/// A lookup for files within the .
/// Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.
- public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action 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 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(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
}
///
+ [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
*********/
+ ///
+ /// Validates that the provided type is compatible with .
+ ///
+ /// Type to validate compatibility of.
+ /// Type to validate compatibility against.
+ /// The asset name relative to the loader root directory.
+ /// The file being loaded.
+ /// The exception to throw if the type validation fails, otherwise .
+ /// if the type validation succeeds, otherwise
+ private bool ValidateType(
+ 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;
+ }
+
+ ///
+ /// Validates that the provided type
+ /// is compatible with or
+ ///
+ /// Type to validate compatibility of.
+ /// First type to validate compatibility against.
+ /// /// Second type to validate compatibility against.
+ /// The asset name relative to the loader root directory.
+ /// The file being loaded.
+ /// The exception to throw if the type validation fails, otherwise .
+ /// if the type validation succeeds, otherwise
+ private bool ValidateType(
+ 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;
+ }
+
+
/// Load an unpacked font file (.fnt).
/// The type of asset to load.
/// The asset name relative to the loader root directory.
@@ -164,8 +251,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadFont(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(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(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(IAssetName assetName, FileInfo file)
{
// validate type
- bool asRawData = false;
- if (typeof(T) != typeof(Texture2D))
+ if (!this.ValidateType(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;
}
}
/// Load the raw image data from a file on disk.
/// The file whose data to load.
- /// The pixel width for the loaded image data.
- /// The pixel height for the loaded image data.
- /// The premultiplied pixel data.
/// Whether the data is being loaded for an (true) or (false) instance.
/// This is separate to let framework mods intercept the data before it's loaded, if needed.
[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(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);
}
/// Load an unpacked image file (.tbin or .tmx).
@@ -261,8 +352,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadMapFile(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(assetName, file, out var exception))
+ {
+ throw exception;
+ }
// load
FormatManager formatManager = FormatManager.Instance;
@@ -277,8 +370,14 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// The asset name relative to the loader root directory.
private T LoadXnbFile(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
/// The file to load.
private T HandleUnknownFileType(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'."
+ );
}
/// Get an error which indicates that an asset couldn't be loaded.
@@ -311,6 +414,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// The asset name that failed to load.
/// The reason the file couldn't be loaded.
/// The underlying exception, if applicable.
+ [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
/// The texture to premultiply.
/// Returns a premultiplied texture.
/// Based on code by David Gouveia.
- 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(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
/// Fix custom map tilesheet paths so they can be found by the content manager.
/// The map whose tilesheets to fix.
/// The relative map path within the mod folder.
- /// Whether to undo the game's eager tilesheet path prefixing for maps loaded from an .xnb file, which incorrectly prefixes tilesheet paths with the map's local asset key folder.
+ ///
+ /// Whether to undo the game's eager tilesheet path prefixing for maps loaded from an .xnb file,
+ /// which incorrectly prefixes tilesheet paths with the map's local asset key folder.
+ ///
/// A map tilesheet couldn't be resolved.
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
/// See remarks on .
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(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(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);
}
/// Get the asset key for a tilesheet in the game's Maps content folder.
/// The tilesheet image source.
- 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;
}