Refactored ModContentManager.cs so it actually fit on my 1440p screens.

Changed LocalTilesheetExtensions into an array.
Marked 'CreateTemporary' as 'Obsolete' which is conventional for methods that only throw.
Moved the type validation logic into its own method as it's largely shared for each loader.
Changed allocators to use `GC.AllocateUninitializedArray`, as the data does not need to be initialized.
Changed `LoadRawImageData` to use a `ValueTuple` return instead of returning with multiple `out`s, which is bad practice.
Preferred rethrowing handlers rather than exception filters (which generate bizarre and _very difficult to patch_ code).
Marked GetLoadError as debugger step through and hidden, as it's just an exception generator.
Marked PremultiplyTransparency, GetContentKeyForTilesheetImageSource, and LoadRawImageData as static as they have no dependency on instance data (nor should they).
Fixed `.xnb` extension search to properly use OrdinalIgnoreCase.
This commit is contained in:
Ameisen 2022-05-29 18:11:23 -05:00 committed by Jesse Plamondon-Willard
parent 9d21e0bbec
commit 5585f5e876
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
1 changed files with 183 additions and 56 deletions

View File

@ -1,11 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using BmFont; using BmFont;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using SkiaSharp; using SkiaSharp;
@ -19,11 +19,12 @@ using StardewValley;
using xTile; using xTile;
using xTile.Format; using xTile.Format;
using xTile.Tiles; using xTile.Tiles;
using Color = Microsoft.Xna.Framework.Color;
namespace StardewModdingAPI.Framework.ContentManagers namespace StardewModdingAPI.Framework.ContentManagers
{ {
/// <summary>A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> /// <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 ** Fields
@ -44,7 +45,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private readonly IFileLookup FileLookup; 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> /// <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="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="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> /// <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) public ModContentManager(
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) 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.GameContentManager = gameContentManager;
this.FileLookup = fileLookup; this.FileLookup = fileLookup;
@ -102,7 +116,14 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
{ {
if (contentManagerID != this.Name) 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; assetName = relativePath;
} }
} }
@ -127,7 +148,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
_ => this.HandleUnknownFileType<T>(assetName, file) _ => 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); throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex);
} }
@ -138,6 +163,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
} }
/// <inheritdoc /> /// <inheritdoc />
[Obsolete($"Temporary {nameof(ModContentManager)}s are unsupported")]
public override LocalizedContentManager CreateTemporary() public override LocalizedContentManager CreateTemporary()
{ {
throw new NotSupportedException("Can't create a temporary mod content manager."); throw new NotSupportedException("Can't create a temporary mod content manager.");
@ -157,6 +183,67 @@ namespace StardewModdingAPI.Framework.ContentManagers
/********* /*********
** Private methods ** 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> /// <summary>Load an unpacked font file (<c>.fnt</c>).</summary>
/// <typeparam name="T">The type of asset to load.</typeparam> /// <typeparam name="T">The type of asset to load.</typeparam>
/// <param name="assetName">The asset name relative to the loader root directory.</param> /// <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) private T LoadFont<T>(IAssetName assetName, FileInfo file)
{ {
// validate // validate
if (!typeof(T).IsAssignableFrom(typeof(XmlSource))) if (!this.ValidateType<T, XmlSource>(assetName, file, out var exception))
throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'."); {
throw exception;
}
// load // load
string source = File.ReadAllText(file.FullName); string source = File.ReadAllText(file.FullName);
@ -179,7 +268,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadDataFile<T>(IAssetName assetName, FileInfo file) private T LoadDataFile<T>(IAssetName assetName, FileInfo file)
{ {
if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset)) 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; return asset;
} }
@ -191,24 +283,23 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadImageFile<T>(IAssetName assetName, FileInfo file) private T LoadImageFile<T>(IAssetName assetName, FileInfo file)
{ {
// validate type // validate type
bool asRawData = false; if (!this.ValidateType<T, Texture2D, IRawTextureData>(assetName, file, out var exception))
if (typeof(T) != typeof(Texture2D))
{ {
asRawData = typeof(T) == typeof(IRawTextureData); throw exception;
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)}'.");
} }
bool asRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
// load // load
if (asRawData || this.UseRawImageLoading) 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) if (asRawData)
return (T)(object)new RawTextureData(width, height, pixels); return (T)(object)new RawTextureData(size.Width, size.Height, pixels);
else else
{ {
Texture2D texture = new(Game1.graphics.GraphicsDevice, width, height); Texture2D texture = new(Game1.graphics.GraphicsDevice, size.Width, size.Height);
texture.SetData(pixels); texture.SetData(pixels);
return (T)(object)texture; return (T)(object)texture;
} }
@ -217,34 +308,32 @@ namespace StardewModdingAPI.Framework.ContentManagers
{ {
using FileStream stream = File.OpenRead(file.FullName); using FileStream stream = File.OpenRead(file.FullName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
texture = this.PremultiplyTransparency(texture); texture = ModContentManager.PremultiplyTransparency(texture);
return (T)(object)texture; return (T)(object)texture;
} }
} }
/// <summary>Load the raw image data from a file on disk.</summary> /// <summary>Load the raw image data from a file on disk.</summary>
/// <param name="file">The file whose data to load.</param> /// <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> /// <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> /// <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("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.")] [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 // load raw data
SKPMColor[] rawPixels; SKPMColor[] rawPixels;
{ {
using FileStream stream = File.OpenRead(file.FullName); using FileStream stream = File.OpenRead(file.FullName);
using SKBitmap bitmap = SKBitmap.Decode(stream); using SKBitmap bitmap = SKBitmap.Decode(stream);
rawPixels = SKPMColor.PreMultiply(bitmap.Pixels); rawPixels = SKPMColor.PreMultiply(bitmap.Pixels);
width = bitmap.Width; size = new(bitmap.Width, bitmap.Height);
height = bitmap.Height;
} }
// convert to XNA pixel format // 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++) for (int i = 0; i < pixels.Length; i++)
{ {
SKPMColor pixel = rawPixels[i]; SKPMColor pixel = rawPixels[i];
@ -252,6 +341,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
? Color.Transparent ? Color.Transparent
: new Color(r: pixel.Red, g: pixel.Green, b: pixel.Blue, alpha: pixel.Alpha); : 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> /// <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) private T LoadMapFile<T>(IAssetName assetName, FileInfo file)
{ {
// validate // validate
if (typeof(T) != typeof(Map)) if (!this.ValidateType<T, Map>(assetName, file, out var exception))
throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); {
throw exception;
}
// load // load
FormatManager formatManager = FormatManager.Instance; 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> /// <param name="assetName">The asset name relative to the loader root directory.</param>
private T LoadXnbFile<T>(IAssetName assetName) private T LoadXnbFile<T>(IAssetName assetName)
{ {
if (typeof(T) == typeof(IRawTextureData)) 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."); {
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 // 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. // 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> /// <param name="file">The file to load.</param>
private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file) 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> /// <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="assetName">The asset name that failed to load.</param>
/// <param name="reasonPhrase">The reason the file couldn't be loaded.</param> /// <param name="reasonPhrase">The reason the file couldn't be loaded.</param>
/// <param name="exception">The underlying exception, if applicable.</param> /// <param name="exception">The underlying exception, if applicable.</param>
[DebuggerStepThrough, DebuggerHidden]
private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null) private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
{ {
return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); 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); FileInfo file = this.FileLookup.GetFile(path);
// try with default image extensions // 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); file = result;
if (result.Exists) break;
{
file = result;
break;
}
} }
} }
@ -345,10 +449,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="texture">The texture to premultiply.</param> /// <param name="texture">The texture to premultiply.</param>
/// <returns>Returns a premultiplied texture.</returns> /// <returns>Returns a premultiplied texture.</returns>
/// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks> /// <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 // premultiply pixels
Color[] data = new Color[texture.Width * texture.Height]; Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height);
texture.GetData(data); texture.GetData(data);
bool changed = false; bool changed = false;
for (int i = 0; i < data.Length; i++) 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)) if (pixel.A is (byte.MinValue or byte.MaxValue))
continue; // no need to change fully transparent/opaque pixels 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; 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> /// <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="map">The map whose tilesheets to fix.</param>
/// <param name="relativeMapPath">The relative map path within the mod folder.</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> /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception>
private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPathPrefixes) private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPathPrefixes)
{ {
@ -388,18 +500,28 @@ namespace StardewModdingAPI.Framework.ContentManagers
// reverse incorrect eager tilesheet path prefixing // reverse incorrect eager tilesheet path prefixing
if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder)) if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder))
imageSource = imageSource.Substring(relativeMapFolder.Length + 1); imageSource = imageSource[(relativeMapFolder.Length + 1)..];
// validate tilesheet path // validate tilesheet path
string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'."; string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'.";
if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) 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 // load best match
try try
{ {
if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error)) 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) if (assetName is not null)
{ {
@ -409,7 +531,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
tilesheet.ImageSource = assetName.Name; 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); 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> /// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks>
private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error) private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error)
{ {
assetName = null;
error = null; error = null;
// nothing to do // 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. // opened in Tiled, while still mapping it to the vanilla 'Maps/spring_town' asset at runtime.
{ {
string filename = Path.GetFileName(relativePath); string filename = Path.GetFileName(relativePath);
if (filename.StartsWith(".")) if (filename.StartsWith('.'))
relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.')); relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.'));
} }
@ -455,10 +580,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
} }
// get from game assets // 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 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; assetName = contentKey;
return true; return true;
} }
@ -476,6 +602,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
} }
// not found // not found
assetName = null;
error = "The tilesheet couldn't be found relative to either map file or the game's content folder."; error = "The tilesheet couldn't be found relative to either map file or the game's content folder.";
return false; return false;
} }
@ -486,16 +613,16 @@ namespace StardewModdingAPI.Framework.ContentManagers
{ {
// get file path // get file path
string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); string path = Path.Combine(this.GameContentManager.FullRootDirectory, key);
if (!path.EndsWith(".xnb")) if (!path.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase))
path += ".xnb"; path += ".xnb";
// get file // 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> /// <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> /// <param name="relativePath">The tilesheet image source.</param>
private string GetContentKeyForTilesheetImageSource(string relativePath) private static string GetContentKeyForTilesheetImageSource(string relativePath)
{ {
string key = relativePath; string key = relativePath;
string topFolder = PathUtilities.GetSegments(key, limit: 2)[0]; string topFolder = PathUtilities.GetSegments(key, limit: 2)[0];
@ -506,7 +633,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// remove file extension from unpacked file // remove file extension from unpacked file
if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
key = key.Substring(0, key.Length - 4); key = key[..^4];
return key; return key;
} }