summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs239
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;
}