diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 3393b22f..0380dd9e 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -1,4 +1,5 @@
using System;
+using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
@@ -32,59 +33,70 @@ namespace StardewModdingAPI.Framework.Content
///
public void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
{
- this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
-
- // validate source data
if (source == null)
throw new ArgumentNullException(nameof(source), "Can't patch from null source data.");
- // get the pixels for the source area
- Color[] sourceData;
- {
- int areaX = sourceArea.Value.X;
- int areaY = sourceArea.Value.Y;
- int areaWidth = sourceArea.Value.Width;
- int areaHeight = sourceArea.Value.Height;
+ // get normalized bounds
+ this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
+ if (source.Data.Length < (sourceArea.Value.Bottom - 1) * source.Width + sourceArea.Value.Right)
+ throw new ArgumentException("Can't apply image patch because the source image is smaller than the source area.", nameof(source));
+ int areaX = sourceArea.Value.X;
+ int areaY = sourceArea.Value.Y;
+ int areaWidth = sourceArea.Value.Width;
+ int areaHeight = sourceArea.Value.Height;
- if (areaX == 0 && areaY == 0 && areaWidth == source.Width && areaHeight == source.Height)
- sourceData = source.Data;
- else
- {
- sourceData = new Color[areaWidth * areaHeight];
- int i = 0;
- for (int y = areaY, maxY = areaY + areaHeight - 1; y <= maxY; y++)
- {
- for (int x = areaX, maxX = areaX + areaWidth - 1; x <= maxX; x++)
- {
- int targetIndex = (y * source.Width) + x;
- sourceData[i++] = source.Data[targetIndex];
- }
- }
- }
+ // shortcut: if the area width matches the source image, we can apply the image as-is without needing
+ // to copy the pixels into a smaller subset. It's fine if the source is taller than the area, since we'll
+ // just ignore the extra data at the end of the pixel array.
+ if (areaWidth == source.Width)
+ {
+ this.PatchImageImpl(source.Data, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode, areaY);
+ return;
}
- // apply
- this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ // else copy the pixels within the smaller area & apply that
+ int pixelCount = areaWidth * areaHeight;
+ Color[] sourceData = ArrayPool.Shared.Rent(pixelCount);
+ try
+ {
+ for (int y = areaY, maxY = areaY + areaHeight; y < maxY; y++)
+ {
+ int sourceIndex = (y * source.Width) + areaX;
+ int targetIndex = (y - areaY) * areaWidth;
+ Array.Copy(source.Data, sourceIndex, sourceData, targetIndex, areaWidth);
+ }
+
+ this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(sourceData);
+ }
}
///
public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
{
- this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
-
- // validate source texture
if (source == null)
throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture.");
+
+ // get normalized bounds
+ this.GetPatchBounds(ref sourceArea, ref targetArea, source.Width, source.Height);
if (!source.Bounds.Contains(sourceArea.Value))
throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture.");
- // get source data
+ // get source data & apply
int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height;
- Color[] sourceData = GC.AllocateUninitializedArray(pixelCount);
- source.GetData(0, sourceArea, sourceData, 0, pixelCount);
-
- // apply
- this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ Color[] sourceData = ArrayPool.Shared.Rent(pixelCount);
+ try
+ {
+ source.GetData(0, sourceArea, sourceData, 0, pixelCount);
+ this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(sourceData);
+ }
}
///
@@ -117,15 +129,16 @@ namespace StardewModdingAPI.Framework.Content
/// Overwrite part of the image.
/// The image data to patch into the content.
- /// The pixel width of the source image.
- /// The pixel height of the source image.
+ /// The pixel width of the original source image.
+ /// The pixel height of the original source image.
/// The part of the to copy (or null to take the whole texture). This must be within the bounds of the texture.
/// The part of the content to patch (or null to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.
/// Indicates how an image should be patched.
+ /// The row to start on, for the sourceData.
/// One of the arguments is null.
/// The is outside the bounds of the spritesheet.
/// The content being read isn't an image.
- private void PatchImageImpl(Color[] sourceData, int sourceWidth, int sourceHeight, Rectangle sourceArea, Rectangle targetArea, PatchMode patchMode)
+ private void PatchImageImpl(Color[] sourceData, int sourceWidth, int sourceHeight, Rectangle sourceArea, Rectangle targetArea, PatchMode patchMode, int startRow = 0)
{
// get texture
Texture2D target = this.Data;
@@ -139,24 +152,69 @@ namespace StardewModdingAPI.Framework.Content
if (sourceArea.Size != targetArea.Size)
throw new InvalidOperationException("The source and target areas must be the same size.");
- // merge data
- if (patchMode == PatchMode.Overlay)
+ // shortcut: replace the entire area
+ if (patchMode == PatchMode.Replace)
+ {
+ target.SetData(0, targetArea, sourceData, startRow * sourceArea.Width, pixelCount);
+ return;
+ }
+
+ // skip transparent pixels at the start & end (e.g. large spritesheet with a few sprites replaced)
+ int startIndex = -1;
+ int endIndex = -1;
+ {
+ for (int i = startRow * sourceArea.Width; i < pixelCount; i++)
+ {
+ if (sourceData[i].A >= AssetDataForImage.MinOpacity)
+ {
+ startIndex = i;
+ break;
+ }
+ }
+ if (startIndex == -1)
+ return; // blank texture
+
+ for (int i = startRow * sourceArea.Width + pixelCount - 1; i >= startIndex; i--)
+ {
+ if (sourceData[i].A >= AssetDataForImage.MinOpacity)
+ {
+ endIndex = i;
+ break;
+ }
+ }
+ if (endIndex == -1)
+ return; // ???
+ }
+
+ // update target rectangle
+ int sourceOffset;
+ {
+ int topOffset = startIndex / sourceArea.Width;
+ int bottomOffset = endIndex / sourceArea.Width;
+
+ targetArea = new(targetArea.X, targetArea.Y + topOffset, targetArea.Width, bottomOffset - topOffset + 1);
+ pixelCount = targetArea.Width * targetArea.Height;
+ sourceOffset = topOffset * sourceArea.Width;
+ }
+
+ // apply
+ Color[] mergedData = ArrayPool.Shared.Rent(pixelCount);
+ try
{
- // get target data
- Color[] mergedData = GC.AllocateUninitializedArray(pixelCount);
target.GetData(0, targetArea, mergedData, 0, pixelCount);
- // merge pixels
- for (int i = 0; i < pixelCount; i++)
+ for (int i = startIndex; i <= endIndex; i++)
{
+ int targetIndex = i - sourceOffset;
+
Color above = sourceData[i];
- Color below = mergedData[i];
+ Color below = mergedData[targetIndex];
// shortcut transparency
- if (above.A < MinOpacity)
+ if (above.A < AssetDataForImage.MinOpacity)
continue;
- if (below.A < MinOpacity)
- mergedData[i] = above;
+ if (below.A < AssetDataForImage.MinOpacity || above.A == byte.MaxValue)
+ mergedData[targetIndex] = above;
// merge pixels
else
@@ -165,7 +223,7 @@ namespace StardewModdingAPI.Framework.Content
// premultiplied by the content pipeline. The formula is derived from
// https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/.
float alphaBelow = 1 - (above.A / 255f);
- mergedData[i] = new Color(
+ mergedData[targetIndex] = new Color(
r: (int)(above.R + (below.R * alphaBelow)),
g: (int)(above.G + (below.G * alphaBelow)),
b: (int)(above.B + (below.B * alphaBelow)),
@@ -176,8 +234,10 @@ namespace StardewModdingAPI.Framework.Content
target.SetData(0, targetArea, mergedData, 0, pixelCount);
}
- else
- target.SetData(0, targetArea, sourceData, 0, pixelCount);
+ finally
+ {
+ ArrayPool.Shared.Return(mergedData);
+ }
}
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index cc6f8372..72dcf6e1 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -1,9 +1,11 @@
using System;
+using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Runtime.CompilerServices;
using BmFont;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
@@ -111,7 +113,7 @@ 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.");
+ this.ThrowLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager.");
assetName = relativePath;
}
}
@@ -123,7 +125,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get file
FileInfo file = this.GetModFile(assetName.Name);
if (!file.Exists)
- throw this.GetLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "the specified path doesn't exist.");
+ this.ThrowLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "the specified path doesn't exist.");
// load content
asset = file.Extension.ToLower() switch
@@ -141,7 +143,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (ex is SContentLoadException)
throw;
- throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex);
+ this.ThrowLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex);
+ return default;
}
// track & return asset
@@ -189,7 +192,7 @@ 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
+ this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method
return asset;
}
@@ -238,7 +241,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
{
using FileStream stream = File.OpenRead(file.FullName);
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
+ this.PremultiplyTransparency(texture);
return (T)(object)texture;
}
}
@@ -301,7 +304,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadXnbFile(IAssetName assetName)
{
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.");
+ this.ThrowLoadError(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.
@@ -326,7 +329,8 @@ 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'.");
+ this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'.");
+ return default;
}
/// Assert that the asset type is compatible with one of the allowed types.
@@ -338,18 +342,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
private void AssertValidType(IAssetName assetName, FileInfo file, params Type[] validTypes)
{
if (!validTypes.Any(validType => validType.IsAssignableFrom(typeof(TAsset))))
- throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'.");
+ this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(TAsset)}'; must be type '{string.Join("' or '", validTypes.Select(p => p.FullName))}'.");
}
- /// Get an error which indicates that an asset couldn't be loaded.
+ /// Throw an error which indicates that an asset couldn't be loaded.
/// Why loading an asset through the content pipeline failed.
/// The asset name that failed to load.
/// The reason the file couldn't be loaded.
/// The underlying exception, if applicable.
+ ///
+ [DoesNotReturn]
[DebuggerStepThrough, DebuggerHidden]
- private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void ThrowLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
{
- return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
+ throw new SContentLoadException(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
}
/// Get a file from the mod folder.
@@ -381,26 +388,32 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// The texture to premultiply.
/// Returns a premultiplied texture.
/// Based on code by David Gouveia.
- private Texture2D PremultiplyTransparency(Texture2D texture)
+ private void PremultiplyTransparency(Texture2D texture)
{
- // premultiply pixels
- Color[] data = GC.AllocateUninitializedArray(texture.Width * texture.Height);
- texture.GetData(data);
- bool changed = false;
- for (int i = 0; i < data.Length; i++)
+ int count = texture.Width * texture.Height;
+ Color[] data = ArrayPool.Shared.Rent(count);
+ try
{
- Color pixel = data[i];
- if (pixel.A is (byte.MinValue or byte.MaxValue))
- continue; // no need to change fully transparent/opaque pixels
+ texture.GetData(data, 0, count);
- 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;
+ bool changed = false;
+ for (int i = 0; i < count; i++)
+ {
+ ref Color pixel = ref data[i];
+ if (pixel.A is (byte.MinValue or byte.MaxValue))
+ continue; // no need to change fully transparent/opaque pixels
+
+ data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4())
+ changed = true;
+ }
+
+ if (changed)
+ texture.SetData(data, 0, count);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(data);
}
-
- if (changed)
- texture.SetData(data);
-
- return texture;
}
/// Fix custom map tilesheet paths so they can be found by the content manager.
diff --git a/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs b/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs
new file mode 100644
index 00000000..4d31ffeb
--- /dev/null
+++ b/src/SMAPI/Framework/Logging/LogOnceCacheKey.cs
@@ -0,0 +1,10 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace StardewModdingAPI.Framework.Logging
+{
+ /// The cache key for the .
+ /// The log message.
+ /// The log level.
+ [SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local", Justification = "This is only used as a lookup key.")]
+ internal readonly record struct LogOnceCacheKey(string Message, LogLevel Level);
+}
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index 01037870..ae08d972 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -221,7 +221,7 @@ namespace StardewModdingAPI.Framework.ModLoading
///
public static Assembly? ResolveAssembly(string name)
{
- string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture)
+ string shortName = name.Split(',', 2).First(); // get simple name (without version and culture)
return AppDomain.CurrentDomain
.GetAssemblies()
.FirstOrDefault(p => p.GetName().Name == shortName);
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index 6b53daff..4ed2c9bb 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -25,10 +25,13 @@ namespace StardewModdingAPI.Framework
private readonly LogFileManager LogFile;
/// The maximum length of the values.
- private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast() select level.ToString().Length).Max();
+ private static readonly int MaxLevelLength = Enum.GetValues().Max(level => level.ToString().Length);
+
+ /// The cached representation for each level when added to a log header.
+ private static readonly Dictionary LogStrings = Enum.GetValues().ToDictionary(level => level, level => level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength));
/// A cache of messages that should only be logged once.
- private readonly HashSet LogOnceCache = new();
+ private readonly HashSet LogOnceCache = new();
/// Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.
private readonly Func GetScreenIdForLog;
@@ -84,7 +87,7 @@ namespace StardewModdingAPI.Framework
///
public void LogOnce(string message, LogLevel level = LogLevel.Trace)
{
- if (this.LogOnceCache.Add($"{message}|{level}"))
+ if (this.LogOnceCache.Add(new LogOnceCacheKey(message, level)))
this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
}
@@ -147,7 +150,7 @@ namespace StardewModdingAPI.Framework
/// The log level.
private string GenerateMessagePrefix(string source, ConsoleLogLevel level)
{
- string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength);
+ string levelStr = Monitor.LogStrings[level];
int? playerIndex = this.GetScreenIdForLog();
return $"[{DateTime.Now:HH:mm:ss} {levelStr}{(playerIndex != null ? $" screen_{playerIndex}" : "")} {source}]";