summaryrefslogtreecommitdiff
path: root/src/SMAPI
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI')
-rw-r--r--src/SMAPI/Constants.cs2
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs164
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs10
-rw-r--r--src/SMAPI/Framework/ContentManagers/BaseContentManager.cs2
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs132
-rw-r--r--src/SMAPI/Framework/Deprecations/DeprecationManager.cs2
-rw-r--r--src/SMAPI/Framework/InternalExtensions.cs30
-rw-r--r--src/SMAPI/Framework/Logging/LogManager.cs12
-rw-r--r--src/SMAPI/Framework/Logging/LogOnceCacheKey.cs10
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs47
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs2
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs16
-rw-r--r--src/SMAPI/Framework/Monitor.cs11
-rw-r--r--src/SMAPI/Framework/SCore.cs85
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs2
-rw-r--r--src/SMAPI/IMod.cs10
-rw-r--r--src/SMAPI/Mod.cs6
-rw-r--r--src/SMAPI/SMAPI.config.json20
-rw-r--r--src/SMAPI/SMAPI.csproj2
-rw-r--r--src/SMAPI/Utilities/Keybind.cs4
21 files changed, 377 insertions, 194 deletions
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 493aebd9..31dafa8e 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -52,7 +52,7 @@ namespace StardewModdingAPI
internal static int? LogScreenId { get; set; }
/// <summary>SMAPI's current raw semantic version.</summary>
- internal static string RawApiVersion = "3.16.2";
+ internal static string RawApiVersion = "3.17.0";
}
/// <summary>Contains SMAPI's constants and assumptions.</summary>
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 3393b22f..241c09a8 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
/// <inheritdoc />
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;
+ // 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;
+
+ // 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;
+ }
+
+ // else copy the pixels within the smaller area & apply that
+ int pixelCount = areaWidth * areaHeight;
+ Color[] sourceData = ArrayPool<Color>.Shared.Rent(pixelCount);
+ try
{
- 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
+ for (int y = areaY, maxY = areaY + areaHeight; y < maxY; y++)
{
- 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];
- }
- }
+ int sourceIndex = (y * source.Width) + areaX;
+ int targetIndex = (y - areaY) * areaWidth;
+ Array.Copy(source.Data, sourceIndex, sourceData, targetIndex, areaWidth);
}
- }
- // apply
- this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ }
+ finally
+ {
+ ArrayPool<Color>.Shared.Return(sourceData);
+ }
}
/// <inheritdoc />
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<Color>(pixelCount);
- source.GetData(0, sourceArea, sourceData, 0, pixelCount);
-
- // apply
- this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ Color[] sourceData = ArrayPool<Color>.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<Color>.Shared.Return(sourceData);
+ }
}
/// <inheritdoc />
@@ -94,7 +106,7 @@ namespace StardewModdingAPI.Framework.Content
return false;
Texture2D original = this.Data;
- Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight));
+ Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)).SetName(original.Name);
this.ReplaceWith(texture);
this.PatchImage(original);
return true;
@@ -117,15 +129,16 @@ namespace StardewModdingAPI.Framework.Content
/// <summary>Overwrite part of the image.</summary>
/// <param name="sourceData">The image data to patch into the content.</param>
- /// <param name="sourceWidth">The pixel width of the source image.</param>
- /// <param name="sourceHeight">The pixel height of the source image.</param>
+ /// <param name="sourceWidth">The pixel width of the original source image.</param>
+ /// <param name="sourceHeight">The pixel height of the original source image.</param>
/// <param name="sourceArea">The part of the <paramref name="sourceData"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="sourceData"/> texture.</param>
/// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param>
/// <param name="patchMode">Indicates how an image should be patched.</param>
+ /// <param name="startRow">The row to start on, for the sourceData.</param>
/// <exception cref="ArgumentNullException">One of the arguments is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception>
/// <exception cref="InvalidOperationException">The content being read isn't an image.</exception>
- 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<Color>.Shared.Rent(pixelCount);
+ try
{
- // get target data
- Color[] mergedData = GC.AllocateUninitializedArray<Color>(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<Color>.Shared.Return(mergedData);
+ }
}
}
}
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 9e044b44..cf26307f 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -34,9 +34,6 @@ namespace StardewModdingAPI.Framework
/// <summary>An asset key prefix for assets from SMAPI mod folders.</summary>
private readonly string ManagedPrefix = "SMAPI";
- /// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary>
- private readonly bool UseRawImageLoading;
-
/// <summary>Get a file lookup for the given directory.</summary>
private readonly Func<string, IFileLookup> GetFileLookup;
@@ -139,8 +136,7 @@ namespace StardewModdingAPI.Framework
/// <param name="getFileLookup">Get a file lookup for the given directory.</param>
/// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param>
/// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param>
- /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param>
- public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations, bool useRawImageLoading)
+ public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations)
{
this.GetFileLookup = getFileLookup;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
@@ -151,7 +147,6 @@ namespace StardewModdingAPI.Framework
this.OnAssetsInvalidated = onAssetsInvalidated;
this.RequestAssetOperations = requestAssetOperations;
this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory);
- this.UseRawImageLoading = useRawImageLoading;
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager(
name: "Game1.content",
@@ -230,8 +225,7 @@ namespace StardewModdingAPI.Framework
reflection: this.Reflection,
jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing,
- fileLookup: this.GetFileLookup(rootDirectory),
- useRawImageLoading: this.UseRawImageLoading
+ fileLookup: this.GetFileLookup(rootDirectory)
);
this.ContentManagers.Add(manager);
return manager;
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
index 54f8e2a2..1e9f4ffe 100644
--- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
@@ -336,7 +336,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
{
// track asset key
if (value is Texture2D texture)
- texture.Name = assetName.Name;
+ texture.SetName(assetName);
// save to cache
// Note: even if the asset was loaded and cached right before this method was called,
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index cc6f8372..badbd766 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;
@@ -28,9 +30,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
/*********
** Fields
*********/
- /// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary>
- private readonly bool UseRawImageLoading;
-
/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
private readonly JsonHelper JsonHelper;
@@ -72,15 +71,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</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="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(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)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true)
{
this.GameContentManager = gameContentManager;
this.FileLookup = fileLookup;
this.JsonHelper = jsonHelper;
this.ModName = modName;
- this.UseRawImageLoading = useRawImageLoading;
this.TryLocalizeKeys = false;
}
@@ -111,7 +108,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 +120,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get file
FileInfo file = this.GetModFile<T>(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 +138,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 +187,7 @@ 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
+ 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;
}
@@ -201,48 +199,52 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadImageFile<T>(IAssetName assetName, FileInfo file)
{
this.AssertValidType<T>(assetName, file, typeof(Texture2D), typeof(IRawTextureData));
- bool expectsRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
- bool asRawData = expectsRawData || this.UseRawImageLoading;
+ bool returnRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
#if SMAPI_DEPRECATED
- // disable raw data if PyTK will rescale the image (until it supports raw data)
- if (asRawData && !expectsRawData)
+ if (!returnRawData && this.ShouldDisableIntermediateRawDataLoad<T>(assetName, file))
{
- if (ModContentManager.EnablePyTkLegacyMode)
- {
- // PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits),
- // but doesn't support IRawTextureData loads yet. We can't just check if the
- // current file has a '.pytk.json' rescale file though, since PyTK may still
- // rescale it if the original asset or another edit gets rescaled.
- asRawData = false;
- this.Monitor.LogOnce("Enabled compatibility mode for PyTK 1.23.* or earlier. This won't cause any issues, but may impact performance. This will no longer be supported in the upcoming SMAPI 4.0.0.", LogLevel.Warn);
- }
+ using FileStream stream = File.OpenRead(file.FullName);
+ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream).SetName(assetName);
+ this.PremultiplyTransparency(texture);
+ return (T)(object)texture;
}
#endif
- // load
- if (asRawData)
- {
- IRawTextureData raw = this.LoadRawImageData(file, expectsRawData);
+ IRawTextureData raw = this.LoadRawImageData(file, returnRawData);
- if (expectsRawData)
- return (T)raw;
- else
- {
- Texture2D texture = new(Game1.graphics.GraphicsDevice, raw.Width, raw.Height);
- texture.SetData(raw.Data);
- return (T)(object)texture;
- }
- }
+ if (returnRawData)
+ return (T)raw;
else
{
- using FileStream stream = File.OpenRead(file.FullName);
- Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
+ Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, raw.Width, raw.Height).SetName(assetName);
+ texture.SetData(raw.Data);
return (T)(object)texture;
}
}
+#if SMAPI_DEPRECATED
+ /// <summary>Get whether to disable loading an image as <see cref="IRawTextureData"/> before building a <see cref="Texture2D"/> instance. This isn't called if the mod requested <see cref="IRawTextureData"/> directly.</summary>
+ /// <typeparam name="T">The type of asset being loaded.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="file">The file being loaded.</param>
+ private bool ShouldDisableIntermediateRawDataLoad<T>(IAssetName assetName, FileInfo file)
+ {
+ // disable raw data if PyTK will rescale the image (until it supports raw data)
+ if (ModContentManager.EnablePyTkLegacyMode)
+ {
+ // PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits),
+ // but doesn't support IRawTextureData loads yet. We can't just check if the
+ // current file has a '.pytk.json' rescale file though, since PyTK may still
+ // rescale it if the original asset or another edit gets rescaled.
+ this.Monitor.LogOnce("Enabled compatibility mode for PyTK 1.23.* or earlier. This won't cause any issues, but may impact performance. This will no longer be supported in the upcoming SMAPI 4.0.0.", LogLevel.Warn);
+ return true;
+ }
+
+ return false;
+ }
+#endif
+
/// <summary>Load the raw image data from a file on disk.</summary>
/// <param name="file">The file whose data to load.</param>
/// <param name="forRawData">Whether the data is being loaded for an <see cref="IRawTextureData"/> (true) or <see cref="Texture2D"/> (false) instance.</param>
@@ -301,7 +303,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private T LoadXnbFile<T>(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 +328,8 @@ 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'.");
+ this.ThrowLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'.");
+ return default;
}
/// <summary>Assert that the asset type is compatible with one of the allowed types.</summary>
@@ -338,18 +341,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
private void AssertValidType<TAsset>(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))}'.");
}
- /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary>
+ /// <summary>Throw an error which indicates that an asset couldn't be loaded.</summary>
/// <param name="errorType">Why loading an asset through the content pipeline failed.</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="exception">The underlying exception, if applicable.</param>
+ /// <exception cref="SContentLoadException" />
+ [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);
}
/// <summary>Get a file from the mod folder.</summary>
@@ -381,26 +387,32 @@ 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 void PremultiplyTransparency(Texture2D texture)
{
- // premultiply pixels
- Color[] data = GC.AllocateUninitializedArray<Color>(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<Color>.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
- if (changed)
- texture.SetData(data);
+ 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;
+ }
- return texture;
+ if (changed)
+ texture.SetData(data, 0, count);
+ }
+ finally
+ {
+ ArrayPool<Color>.Shared.Return(data);
+ }
}
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>
diff --git a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs
index f58f085e..5a5850d1 100644
--- a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs
+++ b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs
@@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.Deprecations
foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase))
{
// build message
- string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version}).";
+ string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase}) and will break in the upcoming SMAPI 4.0.0.";
// get log level
LogLevel level;
diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs
index ba9bbcec..7ad30d35 100644
--- a/src/SMAPI/Framework/InternalExtensions.cs
+++ b/src/SMAPI/Framework/InternalExtensions.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Microsoft.Xna.Framework.Graphics;
@@ -163,5 +164,34 @@ namespace StardewModdingAPI.Framework
{
return reflection.GetField<bool>(spriteBatch, "_beginCalled").GetValue();
}
+
+ /****
+ ** Texture2D
+ ****/
+ /// <summary>Set the texture name field.</summary>
+ /// <param name="texture">The texture whose name to set.</param>
+ /// <param name="assetName">The asset name to set.</param>
+ /// <returns>Returns the texture for chaining.</returns>
+ [return: NotNullIfNotNull("texture")]
+ public static Texture2D? SetName(this Texture2D? texture, IAssetName assetName)
+ {
+ if (texture != null)
+ texture.Name = assetName.Name;
+
+ return texture;
+ }
+
+ /// <summary>Set the texture name field.</summary>
+ /// <param name="texture">The texture whose name to set.</param>
+ /// <param name="assetName">The asset name to set.</param>
+ /// <returns>Returns the texture for chaining.</returns>
+ [return: NotNullIfNotNull("texture")]
+ public static Texture2D? SetName(this Texture2D? texture, string assetName)
+ {
+ if (texture != null)
+ texture.Name = assetName;
+
+ return texture;
+ }
}
}
diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs
index c0b7c0ba..ffffc9c7 100644
--- a/src/SMAPI/Framework/Logging/LogManager.cs
+++ b/src/SMAPI/Framework/Logging/LogManager.cs
@@ -223,7 +223,7 @@ namespace StardewModdingAPI.Framework.Logging
// show update alert
if (File.Exists(Constants.UpdateMarker))
{
- string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new[] { '|' }, 2);
+ string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split('|', 2);
if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion? updateFound))
{
if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion))
@@ -269,7 +269,11 @@ namespace StardewModdingAPI.Framework.Logging
public void LogIntro(string modsPath, IDictionary<string, object?> customSettings)
{
// log platform
- this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
+ this.Monitor.Log($"SMAPI {Constants.ApiVersion} "
+#if !SMAPI_DEPRECATED
+ + "(strict mode) "
+#endif
+ + $"with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
// log basic info
this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info);
@@ -280,6 +284,10 @@ namespace StardewModdingAPI.Framework.Logging
// log custom settings
if (customSettings.Any())
this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}");
+
+#if !SMAPI_DEPRECATED
+ this.Monitor.Log("SMAPI is running in 'strict mode', which removes all deprecated APIs. This can significantly improve performance, but some mods may not work. You can reinstall SMAPI to disable it if you run into problems.", LogLevel.Info);
+#endif
}
/// <summary>Log details for settings that don't match the default.</summary>
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
+{
+ /// <summary>The cache key for the <see cref="Monitor.LogOnceCache"/>.</summary>
+ /// <param name="Message">The log message.</param>
+ /// <param name="Level">The log level.</param>
+ [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/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
index 348ba225..93edd597 100644
--- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
@@ -1,5 +1,7 @@
+using System;
using System.Collections.Generic;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Internal;
namespace StardewModdingAPI.Framework.ModHelpers
{
@@ -15,8 +17,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Encapsulates monitoring and logging for the mod.</summary>
private readonly IMonitor Monitor;
- /// <summary>The mod IDs for APIs accessed by this instanced.</summary>
- private readonly HashSet<string> AccessedModApis = new();
+ /// <summary>The APIs accessed by this instance.</summary>
+ private readonly Dictionary<string, object?> AccessedModApis = new();
/// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary>
private readonly IInterfaceProxyFactory ProxyFactory;
@@ -66,11 +68,44 @@ namespace StardewModdingAPI.Framework.ModHelpers
return null;
}
- // get raw API
+ // get the target mod
IModMetadata? mod = this.Registry.Get(uniqueID);
- if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID))
- this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.");
- return mod?.Api;
+ if (mod == null)
+ return null;
+
+ // fetch API
+ if (!this.AccessedModApis.TryGetValue(mod.Manifest.UniqueID, out object? api))
+ {
+ // if the target has a global API, this is mutually exclusive with per-mod APIs
+ if (mod.Api != null)
+ api = mod.Api;
+
+ // else try to get a per-mod API
+ else
+ {
+ try
+ {
+ api = mod.Mod?.GetApi(this.Mod);
+ if (api != null && !api.GetType().IsPublic)
+ {
+ api = null;
+ this.Monitor.Log($"{mod.DisplayName} provides a per-mod API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Failed loading the per-mod API instance from {mod.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error);
+ api = null;
+ }
+ }
+
+ // cache & log API access
+ this.AccessedModApis[mod.Manifest.UniqueID] = api;
+ if (api != null)
+ this.Monitor.Log($"Accessed mod-provided API ({api.GetType().FullName}) for {mod.DisplayName}.");
+ }
+
+ return api;
}
/// <inheritdoc />
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
/// </remarks>
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/ModLoading/Finders/EventFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs
index f5d449c5..4dd9ccc6 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs
@@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName && this.MethodNames.Contains(methodRef.Name))
{
- string eventName = methodRef.Name.Split(new[] { '_' }, 2)[1];
+ string eventName = methodRef.Name.Split('_', 2)[1];
this.MethodNames.Remove($"add_{eventName}");
this.MethodNames.Remove($"remove_{eventName}");
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 9444c046..bceb0940 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -22,8 +22,8 @@ namespace StardewModdingAPI.Framework.Models
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
[nameof(LogNetworkTraffic)] = false,
[nameof(RewriteMods)] = true,
- [nameof(UseRawImageLoading)] = true,
- [nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux
+ [nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux,
+ [nameof(SuppressHarmonyDebugMode)] = true
};
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
@@ -67,9 +67,6 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether SMAPI should rewrite mods for compatibility.</summary>
public bool RewriteMods { get; set; }
- /// <summary>Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</summary>
- public bool UseRawImageLoading { get; set; }
-
/// <summary>Whether to make SMAPI file APIs case-insensitive, even on Linux.</summary>
public bool UseCaseInsensitivePaths { get; set; }
@@ -79,6 +76,9 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>The colors to use for text written to the SMAPI console.</summary>
public ColorSchemeConfig ConsoleColors { get; set; }
+ /// <summary>Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and creates a file on your desktop. Debug mode should never be enabled by a released mod.</summary>
+ public bool SuppressHarmonyDebugMode { get; set; }
+
/// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary>
public HashSet<string> SuppressUpdateChecks { get; set; }
@@ -95,12 +95,12 @@ namespace StardewModdingAPI.Framework.Models
/// <param name="webApiBaseUrl">The base URL for SMAPI's web API, used to perform update checks.</param>
/// <param name="verboseLogging">The log contexts for which to enable verbose logging, which may show a lot more information to simplify troubleshooting.</param>
/// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</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="useCaseInsensitivePaths">>Whether to make SMAPI file APIs case-insensitive, even on Linux.</param>
/// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param>
/// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param>
+ /// <param name="suppressHarmonyDebugMode">Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and creates a file on your desktop. Debug mode should never be enabled by a released mod.</param>
/// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param>
- public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useRawImageLoading, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks)
+ public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks)
{
this.DeveloperMode = developerMode;
this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)];
@@ -110,10 +110,10 @@ namespace StardewModdingAPI.Framework.Models
this.WebApiBaseUrl = webApiBaseUrl;
this.VerboseLogging = new HashSet<string>(verboseLogging ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(this.RewriteMods)];
- this.UseRawImageLoading = useRawImageLoading ?? (bool)SConfig.DefaultValues[nameof(this.UseRawImageLoading)];
this.UseCaseInsensitivePaths = useCaseInsensitivePaths ?? (bool)SConfig.DefaultValues[nameof(this.UseCaseInsensitivePaths)];
this.LogNetworkTraffic = logNetworkTraffic ?? (bool)SConfig.DefaultValues[nameof(this.LogNetworkTraffic)];
this.ConsoleColors = consoleColors;
+ this.SuppressHarmonyDebugMode = suppressHarmonyDebugMode ?? (bool)SConfig.DefaultValues[nameof(this.SuppressHarmonyDebugMode)];
this.SuppressUpdateChecks = new HashSet<string>(suppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
}
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;
/// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary>
- private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max();
+ private static readonly int MaxLevelLength = Enum.GetValues<LogLevel>().Max(level => level.ToString().Length);
+
+ /// <summary>The cached representation for each level when added to a log header.</summary>
+ private static readonly Dictionary<ConsoleLogLevel, string> LogStrings = Enum.GetValues<ConsoleLogLevel>().ToDictionary(level => level, level => level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength));
/// <summary>A cache of messages that should only be logged once.</summary>
- private readonly HashSet<string> LogOnceCache = new();
+ private readonly HashSet<LogOnceCacheKey> LogOnceCache = new();
/// <summary>Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</summary>
private readonly Func<int?> GetScreenIdForLog;
@@ -84,7 +87,7 @@ namespace StardewModdingAPI.Framework
/// <inheritdoc />
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
/// <param name="level">The log level.</param>
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}]";
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 16ff2537..40979b09 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -501,6 +501,15 @@ namespace StardewModdingAPI.Framework
return;
}
+ /*********
+ ** Prevent Harmony debug mode
+ *********/
+ if (HarmonyLib.Harmony.DEBUG && this.Settings.SuppressHarmonyDebugMode)
+ {
+ HarmonyLib.Harmony.DEBUG = false;
+ this.Monitor.LogOnce("A mod enabled Harmony debug mode, which impacts performance and creates a file on your desktop. SMAPI will try to keep it disabled. (You can allow debug mode by editing the smapi-internal/config.json file.)", LogLevel.Warn);
+ }
+
#if SMAPI_DEPRECATED
/*********
** Reload assets when interceptors are added/removed
@@ -1324,8 +1333,7 @@ namespace StardewModdingAPI.Framework
onAssetLoaded: this.OnAssetLoaded,
onAssetsInvalidated: this.OnAssetsInvalidated,
getFileLookup: this.GetFileLookup,
- requestAssetOperations: this.RequestAssetOperations,
- useRawImageLoading: this.Settings.UseRawImageLoading
+ requestAssetOperations: this.RequestAssetOperations
);
if (this.ContentCore.Language != this.Translator.LocaleEnum)
this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language);
@@ -1384,7 +1392,7 @@ namespace StardewModdingAPI.Framework
}
// check min length for specific types
- switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0])
+ switch (fields[SObject.objectInfoTypeIndex].Split(' ', 2)[0])
{
case "Cooking":
if (fields.Length < SObject.objectInfoBuffDurationIndex + 1)
@@ -1672,26 +1680,33 @@ namespace StardewModdingAPI.Framework
// initialize translations
this.ReloadTranslations(loaded);
-#if SMAPI_DEPRECATED
// set temporary PyTK compatibility mode
// This is part of a three-part fix for PyTK 1.23.* and earlier. When removing this,
// search 'Platonymous.Toolkit' to find the other part in SMAPI and Content Patcher.
{
IModInfo? pyTk = this.ModRegistry.Get("Platonymous.Toolkit");
- ModContentManager.EnablePyTkLegacyMode = pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0");
- }
+ if (pyTk is not null && pyTk.Manifest.Version.IsOlderThan("1.24.0"))
+#if SMAPI_DEPRECATED
+ ModContentManager.EnablePyTkLegacyMode = true;
+#else
+ this.Monitor.Log("PyTK's image scaling is not compatible with SMAPI strict mode.", LogLevel.Warn);
#endif
+ }
// initialize loaded non-content-pack mods
this.Monitor.Log("Launching mods...", LogLevel.Debug);
foreach (IModMetadata metadata in loadedMods)
{
+ IMod mod =
+ metadata.Mod
+ ?? throw new InvalidOperationException($"The '{metadata.DisplayName}' mod is not initialized correctly."); // should never happen, but avoids nullability warnings
+
#if SMAPI_DEPRECATED
// add interceptors
- if (metadata.Mod?.Helper is ModHelper helper)
+ if (mod.Helper is ModHelper helper)
{
// ReSharper disable SuspiciousTypeConversion.Global
- if (metadata.Mod is IAssetEditor editor)
+ if (mod is IAssetEditor editor)
{
SCore.DeprecationManager.Warn(
source: metadata,
@@ -1704,7 +1719,7 @@ namespace StardewModdingAPI.Framework
this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor));
}
- if (metadata.Mod is IAssetLoader loader)
+ if (mod is IAssetLoader loader)
{
SCore.DeprecationManager.Warn(
source: metadata,
@@ -1749,35 +1764,41 @@ namespace StardewModdingAPI.Framework
}
#endif
- // call entry method
+ // initialize mod
Context.HeuristicModsRunningCode.Push(metadata);
- try
{
- IMod mod = metadata.Mod!;
- mod.Entry(mod.Helper!);
- }
- catch (Exception ex)
- {
- metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error);
- }
+ // call entry method
+ try
+ {
+ mod.Entry(mod.Helper!);
+ }
+ catch (Exception ex)
+ {
+ metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
- // get mod API
- try
- {
- object? api = metadata.Mod!.GetApi();
- if (api != null && !api.GetType().IsPublic)
+ // get mod API
+ try
{
- api = null;
- this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn);
+ object? api = mod.GetApi();
+ if (api != null && !api.GetType().IsPublic)
+ {
+ api = null;
+ this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn);
+ }
+
+ if (api != null)
+ this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).");
+ metadata.SetApi(api);
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error);
}
- if (api != null)
- this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).");
- metadata.SetApi(api);
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error);
+ // validate mod doesn't implement both GetApi() and GetApi(mod)
+ if (metadata.Api != null && mod.GetType().GetMethod(nameof(Mod.GetApi), new Type[] { typeof(IModInfo) })!.DeclaringType != typeof(Mod))
+ metadata.LogAsMod($"Mod implements both {nameof(Mod.GetApi)}() and {nameof(Mod.GetApi)}({nameof(IModInfo)}), which isn't allowed. The latter will be ignored.", LogLevel.Error);
}
Context.HeuristicModsRunningCode.TryPop(out _);
}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
index 0b13434a..b24f4178 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs
@@ -63,7 +63,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
{
if (this.LastValues.Count > 0)
{
- this.AddedImpl.AddRange(this.LastValues);
+ this.RemovedImpl.AddRange(this.LastValues);
this.LastValues.Clear();
}
return;
diff --git a/src/SMAPI/IMod.cs b/src/SMAPI/IMod.cs
index b81ba0e3..87c9880c 100644
--- a/src/SMAPI/IMod.cs
+++ b/src/SMAPI/IMod.cs
@@ -23,7 +23,15 @@ namespace StardewModdingAPI
/// <param name="helper">Provides simplified APIs for writing mods.</param>
void Entry(IModHelper helper);
- /// <summary>Get an API that other mods can access. This is always called after <see cref="Entry"/>.</summary>
+ /// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is only called once even if multiple mods access it.</summary>
+ /// <remarks>You can implement <see cref="GetApi()"/> to provide one instance to all mods, or <see cref="GetApi(IModInfo)"/> to provide a separate instance per mod. These are mutually exclusive, so you can only implement one of them.</remarks>
+ /// <remarks>Returns the API instance, or <c>null</c> if the mod has no API.</remarks>
object? GetApi();
+
+ /// <summary>Get an <a href="https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations">API that other mods can access</a>. This is always called after <see cref="Entry"/>, and is called once per mod that accesses the API (even if they access it multiple times).</summary>
+ /// <param name="mod">The mod accessing the API.</param>
+ /// <remarks>Returns the API instance, or <c>null</c> if the mod has no API. Note that <paramref name="mod"/> is provided for informational purposes only, and that denying API access to specific mods is strongly discouraged and may be considered abusive.</remarks>
+ /// <inheritdoc cref="GetApi()" include="/Remarks" />
+ object? GetApi(IModInfo mod);
}
}
diff --git a/src/SMAPI/Mod.cs b/src/SMAPI/Mod.cs
index f764752b..01157886 100644
--- a/src/SMAPI/Mod.cs
+++ b/src/SMAPI/Mod.cs
@@ -30,6 +30,12 @@ namespace StardewModdingAPI
return null;
}
+ /// <inheritdoc />
+ public virtual object? GetApi(IModInfo mod)
+ {
+ return null;
+ }
+
/// <summary>Release or reset unmanaged resources.</summary>
public void Dispose()
{
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index 97e8e00c..635e3add 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -55,18 +55,6 @@ copy all the settings, or you may cause bugs due to overridden changes in future
"UseCaseInsensitivePaths": null,
/**
- * Whether to use the experimental Pintail API proxying library, instead of the original
- * proxying built into SMAPI itself.
- */
- "UsePintail": true,
-
- /**
- * Whether to use raw image data when possible, instead of initializing an XNA Texture2D
- * instance through the GPU.
- */
- "UseRawImageLoading": true,
-
- /**
* Whether to add a section to the 'mod issues' list for mods which directly use potentially
* sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as
* part of their normal functionality, so these warnings are meaningless without further
@@ -139,6 +127,14 @@ copy all the settings, or you may cause bugs due to overridden changes in future
},
/**
+ * Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and
+ * creates a file on your desktop. Debug mode should never be enabled by a released mod.
+ *
+ * If you actually need debug mode to test your own mod, set this to false.
+ */
+ "SuppressHarmonyDebugMode": true,
+
+ /**
* The mod IDs SMAPI should ignore when performing update checks or validating update keys.
*/
"SuppressUpdateChecks": [
diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj
index 36db0545..e5d8937c 100644
--- a/src/SMAPI/SMAPI.csproj
+++ b/src/SMAPI/SMAPI.csproj
@@ -26,7 +26,7 @@
<PackageReference Include="Mono.Cecil" Version="0.11.4" />
<PackageReference Include="MonoMod.Common" Version="22.3.5.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
- <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.1.1" />
+ <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.2.0" />
<PackageReference Include="Pintail" Version="2.2.1" />
<PackageReference Include="Platonymous.TMXTile" Version="1.5.9" />
<PackageReference Include="System.Reflection.Emit" Version="4.7.0" />
diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs
index 3455ce77..3532620d 100644
--- a/src/SMAPI/Utilities/Keybind.cs
+++ b/src/SMAPI/Utilities/Keybind.cs
@@ -54,12 +54,12 @@ namespace StardewModdingAPI.Utilities
}
// parse buttons
- string[] rawButtons = input.Split('+');
+ string[] rawButtons = input.Split('+', StringSplitOptions.TrimEntries);
SButton[] buttons = new SButton[rawButtons.Length];
List<string> rawErrors = new List<string>();
for (int i = 0; i < buttons.Length; i++)
{
- string rawButton = rawButtons[i].Trim();
+ string rawButton = rawButtons[i];
if (string.IsNullOrWhiteSpace(rawButton))
rawErrors.Add("Invalid empty button value");
else if (!Enum.TryParse(rawButton, ignoreCase: true, out SButton button))