summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs173
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs33
-rw-r--r--src/SMAPI/Framework/Content/RawTextureData.cs10
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs16
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs4
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs168
-rw-r--r--src/SMAPI/Framework/Logging/LogManager.cs19
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs34
-rw-r--r--src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs118
-rw-r--r--src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs57
-rw-r--r--src/SMAPI/Framework/SCore.cs29
-rw-r--r--src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs25
12 files changed, 349 insertions, 337 deletions
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 97729c95..3393b22f 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using StardewValley;
@@ -29,86 +30,154 @@ namespace StardewModdingAPI.Framework.Content
: base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
/// <inheritdoc />
- public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
+ public void PatchImage(IRawTextureData source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
{
- // get texture
+ 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 a null source texture.");
- Texture2D target = this.Data;
+ throw new ArgumentNullException(nameof(source), "Can't patch from null source data.");
- // get areas
- sourceArea ??= new Rectangle(0, 0, source.Width, source.Height);
- targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
+ // 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;
- // validate
+ 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];
+ }
+ }
+ }
+ }
+
+ // apply
+ this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ }
+
+ /// <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.");
if (!source.Bounds.Contains(sourceArea.Value))
throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture.");
- if (!target.Bounds.Contains(targetArea.Value))
- throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture.");
- if (sourceArea.Value.Size != targetArea.Value.Size)
- throw new InvalidOperationException("The source and target areas must be the same size.");
// get source data
int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height;
Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount);
source.GetData(0, sourceArea, sourceData, 0, pixelCount);
- // merge data in overlay mode
+ // apply
+ this.PatchImageImpl(sourceData, source.Width, source.Height, sourceArea.Value, targetArea.Value, patchMode);
+ }
+
+ /// <inheritdoc />
+ public bool ExtendImage(int minWidth, int minHeight)
+ {
+ if (this.Data.Width >= minWidth && this.Data.Height >= minHeight)
+ return false;
+
+ Texture2D original = this.Data;
+ Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight));
+ this.ReplaceWith(texture);
+ this.PatchImage(original);
+ return true;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the bounds for an image patch.</summary>
+ /// <param name="sourceArea">The source area to set if needed.</param>
+ /// <param name="targetArea">The target area to set if needed.</param>
+ /// <param name="sourceWidth">The width of the full source image.</param>
+ /// <param name="sourceHeight">The height of the full source image.</param>
+ private void GetPatchBounds([NotNull] ref Rectangle? sourceArea, [NotNull] ref Rectangle? targetArea, int sourceWidth, int sourceHeight)
+ {
+ sourceArea ??= new Rectangle(0, 0, sourceWidth, sourceHeight);
+ targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, this.Data.Width), Math.Min(sourceArea.Value.Height, this.Data.Height));
+ }
+
+ /// <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="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>
+ /// <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)
+ {
+ // get texture
+ Texture2D target = this.Data;
+ int pixelCount = sourceArea.Width * sourceArea.Height;
+
+ // validate
+ if (sourceArea.X < 0 || sourceArea.Y < 0 || sourceArea.Right > sourceWidth || sourceArea.Bottom > sourceHeight)
+ throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture.");
+ if (!target.Bounds.Contains(targetArea))
+ throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture.");
+ if (sourceArea.Size != targetArea.Size)
+ throw new InvalidOperationException("The source and target areas must be the same size.");
+
+ // merge data
if (patchMode == PatchMode.Overlay)
{
// get target data
- Color[] targetData = GC.AllocateUninitializedArray<Color>(pixelCount);
- target.GetData(0, targetArea, targetData, 0, pixelCount);
+ Color[] mergedData = GC.AllocateUninitializedArray<Color>(pixelCount);
+ target.GetData(0, targetArea, mergedData, 0, pixelCount);
// merge pixels
- for (int i = 0; i < sourceData.Length; i++)
+ for (int i = 0; i < pixelCount; i++)
{
Color above = sourceData[i];
- Color below = targetData[i];
+ Color below = mergedData[i];
// shortcut transparency
if (above.A < MinOpacity)
- {
- sourceData[i] = below;
continue;
- }
if (below.A < MinOpacity)
- {
- sourceData[i] = above;
- continue;
- }
+ mergedData[i] = above;
// merge pixels
- // This performs a conventional alpha blend for the pixels, which are already
- // premultiplied by the content pipeline. The formula is derived from
- // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/.
- // Note: don't use named arguments here since they're different between
- // Linux/macOS and Windows.
- float alphaBelow = 1 - (above.A / 255f);
- sourceData[i] = new Color(
- (int)(above.R + (below.R * alphaBelow)), // r
- (int)(above.G + (below.G * alphaBelow)), // g
- (int)(above.B + (below.B * alphaBelow)), // b
- Math.Max(above.A, below.A) // a
- );
+ else
+ {
+ // This performs a conventional alpha blend for the pixels, which are already
+ // 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(
+ r: (int)(above.R + (below.R * alphaBelow)),
+ g: (int)(above.G + (below.G * alphaBelow)),
+ b: (int)(above.B + (below.B * alphaBelow)),
+ alpha: Math.Max(above.A, below.A)
+ );
+ }
}
- }
-
- // patch target texture
- target.SetData(0, targetArea, sourceData, 0, pixelCount);
- }
-
- /// <inheritdoc />
- public bool ExtendImage(int minWidth, int minHeight)
- {
- if (this.Data.Width >= minWidth && this.Data.Height >= minHeight)
- return false;
- Texture2D original = this.Data;
- Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight));
- this.ReplaceWith(texture);
- this.PatchImage(original);
- return true;
+ target.SetData(0, targetArea, mergedData, 0, pixelCount);
+ }
+ else
+ target.SetData(0, targetArea, sourceData, 0, pixelCount);
}
}
}
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
index 736ee5da..bf42812b 100644
--- a/src/SMAPI/Framework/Content/ContentCache.cs
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.Content
** Fields
*********/
/// <summary>The underlying asset cache.</summary>
- private readonly IDictionary<string, object> Cache;
+ private readonly Dictionary<string, object> Cache;
/*********
@@ -29,7 +29,7 @@ namespace StardewModdingAPI.Framework.Content
}
/// <summary>The current cache keys.</summary>
- public IEnumerable<string> Keys => this.Cache.Keys;
+ public Dictionary<string, object>.KeyCollection Keys => this.Cache.Keys;
/*********
@@ -89,33 +89,36 @@ namespace StardewModdingAPI.Framework.Content
/// <returns>Returns the removed key (if any).</returns>
public bool Remove(string key, bool dispose)
{
- // get entry
- if (!this.Cache.TryGetValue(key, out object? value))
+ // remove and get entry
+ if (!this.Cache.Remove(key, out object? value))
return false;
// dispose & remove entry
if (dispose && value is IDisposable disposable)
disposable.Dispose();
- return this.Cache.Remove(key);
+ return true;
}
- /// <summary>Purge matched assets from the cache.</summary>
+ /// <summary>Purge assets matching <paramref name="predicate"/> from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param>
- /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
- /// <returns>Returns the removed keys (if any).</returns>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <see langword="true"/> when they're being invalidated as part of a <see cref="IDisposable.Dispose"/>, to avoid crashing the game.</param>
+ /// <returns>Returns any removed keys.</returns>
public IEnumerable<string> Remove(Func<string, object, bool> predicate, bool dispose)
{
- List<string> removed = new List<string>();
- foreach (string key in this.Cache.Keys.ToArray())
+ List<string> removed = new();
+ foreach ((string key, object value) in this.Cache)
{
- if (predicate(key, this.Cache[key]))
- {
- this.Remove(key, dispose);
+ if (predicate(key, value))
removed.Add(key);
- }
}
- return removed;
+
+ foreach (string key in removed)
+ this.Remove(key, dispose);
+
+ return removed.Count == 0
+ ? Enumerable.Empty<string>() // let GC collect the list in gen0 instead of potentially living longer
+ : removed;
}
}
}
diff --git a/src/SMAPI/Framework/Content/RawTextureData.cs b/src/SMAPI/Framework/Content/RawTextureData.cs
new file mode 100644
index 00000000..4a0835b0
--- /dev/null
+++ b/src/SMAPI/Framework/Content/RawTextureData.cs
@@ -0,0 +1,10 @@
+using Microsoft.Xna.Framework;
+
+namespace StardewModdingAPI.Framework.Content
+{
+ /// <summary>The raw data for an image read from the filesystem.</summary>
+ /// <param name="Width">The image width.</param>
+ /// <param name="Height">The image height.</param>
+ /// <param name="Data">The loaded image data.</param>
+ internal record RawTextureData(int Width, int Height, Color[] Data) : IRawTextureData;
+}
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index cfeb35c8..3e09ac62 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -32,6 +32,9 @@ 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;
@@ -130,7 +133,8 @@ 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>
- 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)
+ /// <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)
{
this.GetFileLookup = getFileLookup;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
@@ -141,6 +145,7 @@ 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",
@@ -219,7 +224,8 @@ namespace StardewModdingAPI.Framework
reflection: this.Reflection,
jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing,
- fileLookup: this.GetFileLookup(rootDirectory)
+ fileLookup: this.GetFileLookup(rootDirectory),
+ useRawImageLoading: this.UseRawImageLoading
);
this.ContentManagers.Add(manager);
return manager;
@@ -465,7 +471,7 @@ namespace StardewModdingAPI.Framework
assets: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value),
ignoreWorld: Context.IsWorldFullyUnloaded,
out IDictionary<IAssetName, bool> propagated,
- out bool updatedNpcWarps
+ out bool updatedWarpRoutes
);
// log summary
@@ -481,8 +487,8 @@ namespace StardewModdingAPI.Framework
? $"Propagated {propagatedKeys.Length} core assets ({FormatKeyList(propagatedKeys)})."
: "Propagated 0 core assets."
);
- if (updatedNpcWarps)
- report.AppendLine("Updated NPC pathfinding cache.");
+ if (updatedWarpRoutes)
+ report.AppendLine("Updated NPC warp route cache.");
}
this.Monitor.Log(report.ToString().TrimEnd());
}
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
index 4390d472..446f4a67 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -9,6 +9,7 @@ using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Deprecations;
+using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Internal;
@@ -93,6 +94,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <inheritdoc />
public override T LoadExact<T>(IAssetName assetName, bool useCache)
{
+ if (typeof(IRawTextureData).IsAssignableFrom(typeof(T)))
+ throw new SContentLoadException(ContentLoadErrorType.Other, $"Can't load {nameof(IRawTextureData)} assets from the game content pipeline. This asset type is only available for mod files.");
+
// raise first-load callback
if (GameContentManager.IsFirstLoad)
{
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index 1b94b8c6..8c5d0f84 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -7,6 +9,8 @@ using BmFont;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
+using SkiaSharp;
+using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Toolkit.Serialization;
@@ -20,11 +24,14 @@ using xTile.Tiles;
namespace StardewModdingAPI.Framework.ContentManagers
{
/// <summary>A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
- internal class ModContentManager : BaseContentManager
+ internal sealed class ModContentManager : BaseContentManager
{
/*********
** Fields
*********/
+ /// <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;
@@ -38,7 +45,17 @@ namespace StardewModdingAPI.Framework.ContentManagers
private readonly IFileLookup FileLookup;
/// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary>
- private static readonly HashSet<string> LocalTilesheetExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".xnb" };
+ private static readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" };
+
+ /// <summary>A lookup of image file paths to whether they have PyTK scaling information.</summary>
+ private static readonly Dictionary<string, bool> IsPyTkScaled = new(StringComparer.OrdinalIgnoreCase);
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether to enable legacy compatibility mode for PyTK scale-up textures.</summary>
+ internal static bool EnablePyTkLegacyMode;
/*********
@@ -57,13 +74,15 @@ 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>
- 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)
+ /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param>
+ public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, IFileLookup fileLookup, bool useRawImageLoading)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true)
{
this.GameContentManager = gameContentManager;
this.FileLookup = fileLookup;
this.JsonHelper = jsonHelper;
this.ModName = modName;
+ this.UseRawImageLoading = useRawImageLoading;
this.TryLocalizeKeys = false;
}
@@ -119,8 +138,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
_ => this.HandleUnknownFileType<T>(assetName, file)
};
}
- catch (Exception ex) when (ex is not SContentLoadException)
+ catch (Exception ex)
{
+ if (ex is SContentLoadException)
+ throw;
+
throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex);
}
@@ -130,6 +152,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
/// <inheritdoc />
+ [Obsolete($"Temporary {nameof(ModContentManager)}s are unsupported")]
public override LocalizedContentManager CreateTemporary()
{
throw new NotSupportedException("Can't create a temporary mod content manager.");
@@ -155,11 +178,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="file">The file to load.</param>
private T LoadFont<T>(IAssetName assetName, FileInfo file)
{
- // validate
- if (!typeof(T).IsAssignableFrom(typeof(XmlSource)))
- throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'.");
+ this.AssertValidType<T>(assetName, file, typeof(XmlSource));
- // load
string source = File.ReadAllText(file.FullName);
return (T)(object)new XmlSource(source);
}
@@ -182,15 +202,89 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="file">The file to load.</param>
private T LoadImageFile<T>(IAssetName assetName, FileInfo file)
{
- // validate
- if (typeof(T) != typeof(Texture2D))
- throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
+ this.AssertValidType<T>(assetName, file, typeof(Texture2D), typeof(IRawTextureData));
+ bool expectsRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData));
+ bool asRawData = expectsRawData || this.UseRawImageLoading;
+
+ // disable raw data if PyTK will rescale the image (until it supports raw data)
+ if (asRawData && !expectsRawData)
+ {
+ if (ModContentManager.EnablePyTkLegacyMode)
+ {
+ if (!ModContentManager.IsPyTkScaled.TryGetValue(file.FullName, out bool isScaled))
+ {
+ string? dirPath = file.DirectoryName;
+ string fileName = $"{Path.GetFileNameWithoutExtension(file.Name)}.pytk.json";
+
+ string path = dirPath is not null
+ ? Path.Combine(dirPath, fileName)
+ : fileName;
+
+ ModContentManager.IsPyTkScaled[file.FullName] = isScaled = File.Exists(path);
+ }
+
+ asRawData = !isScaled;
+ if (!asRawData)
+ this.Monitor.LogOnce("Enabled compatibility mode for PyTK scaled textures. This won't cause any issues, but may impact performance.", LogLevel.Warn);
+ }
+ else
+ asRawData = true;
+ }
// load
- using FileStream stream = File.OpenRead(file.FullName);
- Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
- return (T)(object)texture;
+ if (asRawData)
+ {
+ IRawTextureData raw = this.LoadRawImageData(file, expectsRawData);
+
+ if (expectsRawData)
+ return (T)raw;
+ else
+ {
+ Texture2D texture = new(Game1.graphics.GraphicsDevice, raw.Width, raw.Height);
+ texture.SetData(raw.Data);
+ return (T)(object)texture;
+ }
+ }
+ else
+ {
+ using FileStream stream = File.OpenRead(file.FullName);
+ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
+ texture = this.PremultiplyTransparency(texture);
+ return (T)(object)texture;
+ }
+ }
+
+ /// <summary>Load the raw image data from a file on disk.</summary>
+ /// <param name="file">The file whose data to load.</param>
+ /// <param name="forRawData">Whether the data is being loaded for an <see cref="IRawTextureData"/> (true) or <see cref="Texture2D"/> (false) instance.</param>
+ /// <remarks>This is separate to let framework mods intercept the data before it's loaded, if needed.</remarks>
+ [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")]
+ [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")]
+ private IRawTextureData LoadRawImageData(FileInfo file, bool forRawData)
+ {
+ // load raw data
+ int width;
+ int height;
+ SKPMColor[] rawPixels;
+ {
+ using FileStream stream = File.OpenRead(file.FullName);
+ using SKBitmap bitmap = SKBitmap.Decode(stream);
+ rawPixels = SKPMColor.PreMultiply(bitmap.Pixels);
+ width = bitmap.Width;
+ height = bitmap.Height;
+ }
+
+ // convert to XNA pixel format
+ var pixels = GC.AllocateUninitializedArray<Color>(rawPixels.Length);
+ for (int i = 0; i < pixels.Length; i++)
+ {
+ SKPMColor pixel = rawPixels[i];
+ pixels[i] = pixel.Alpha == 0
+ ? Color.Transparent
+ : new Color(r: pixel.Red, g: pixel.Green, b: pixel.Blue, alpha: pixel.Alpha);
+ }
+
+ return new RawTextureData(width, height, pixels);
}
/// <summary>Load an unpacked image file (<c>.tbin</c> or <c>.tmx</c>).</summary>
@@ -199,11 +293,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="file">The file to load.</param>
private T LoadMapFile<T>(IAssetName assetName, FileInfo file)
{
- // validate
- if (typeof(T) != typeof(Map))
- throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'.");
+ this.AssertValidType<T>(assetName, file, typeof(Map));
- // load
FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName);
map.assetPath = assetName.Name;
@@ -216,6 +307,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="assetName">The asset name relative to the loader root directory.</param>
private T LoadXnbFile<T>(IAssetName assetName)
{
+ if (typeof(IRawTextureData).IsAssignableFrom(typeof(T)))
+ throw this.GetLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file.");
+
// the underlying content manager adds a .xnb extension implicitly, so
// we need to strip it here to avoid trying to load a '.xnb.xnb' file.
IAssetName loadName = assetName.Name.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)
@@ -242,11 +336,24 @@ namespace StardewModdingAPI.Framework.ContentManagers
throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'.");
}
+ /// <summary>Assert that the asset type is compatible with one of the allowed types.</summary>
+ /// <typeparam name="TAsset">The actual asset type.</typeparam>
+ /// <param name="assetName">The asset name relative to the loader root directory.</param>
+ /// <param name="file">The file being loaded.</param>
+ /// <param name="validTypes">The allowed asset types.</param>
+ /// <exception cref="SContentLoadException">The <typeparamref name="TAsset"/> is not compatible with any of the <paramref name="validTypes"/>.</exception>
+ 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))}'.");
+ }
+
/// <summary>Get 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>
+ [DebuggerStepThrough, DebuggerHidden]
private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null)
{
return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception);
@@ -261,7 +368,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
FileInfo file = this.FileLookup.GetFile(path);
// try with default image extensions
- if (!file.Exists && typeof(Texture2D).IsAssignableFrom(typeof(T)) && !ModContentManager.LocalTilesheetExtensions.Contains(file.Extension))
+ if (!file.Exists && typeof(Texture2D).IsAssignableFrom(typeof(T)) && !ModContentManager.LocalTilesheetExtensions.Contains(file.Extension, StringComparer.OrdinalIgnoreCase))
{
foreach (string extension in ModContentManager.LocalTilesheetExtensions)
{
@@ -284,7 +391,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
private Texture2D PremultiplyTransparency(Texture2D texture)
{
// premultiply pixels
- Color[] data = new Color[texture.Width * texture.Height];
+ Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height);
texture.GetData(data);
bool changed = false;
for (int i = 0; i < data.Length; i++)
@@ -324,7 +431,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// reverse incorrect eager tilesheet path prefixing
if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder))
- imageSource = imageSource.Substring(relativeMapFolder.Length + 1);
+ imageSource = imageSource[(relativeMapFolder.Length + 1)..];
// validate tilesheet path
string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'.";
@@ -345,8 +452,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
tilesheet.ImageSource = assetName.Name;
}
}
- catch (Exception ex) when (ex is not SContentLoadException)
+ catch (Exception ex)
{
+ if (ex is SContentLoadException)
+ throw;
+
throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix