diff options
Diffstat (limited to 'src/SMAPI/Framework')
| -rw-r--r-- | src/SMAPI/Framework/Content/AssetDataForImage.cs | 173 | ||||
| -rw-r--r-- | src/SMAPI/Framework/Content/ContentCache.cs | 33 | ||||
| -rw-r--r-- | src/SMAPI/Framework/Content/RawTextureData.cs | 10 | ||||
| -rw-r--r-- | src/SMAPI/Framework/ContentCoordinator.cs | 16 | ||||
| -rw-r--r-- | src/SMAPI/Framework/ContentManagers/GameContentManager.cs | 4 | ||||
| -rw-r--r-- | src/SMAPI/Framework/ContentManagers/ModContentManager.cs | 168 | ||||
| -rw-r--r-- | src/SMAPI/Framework/Logging/LogManager.cs | 19 | ||||
| -rw-r--r-- | src/SMAPI/Framework/Models/SConfig.cs | 34 | ||||
| -rw-r--r-- | src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs | 118 | ||||
| -rw-r--r-- | src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs | 57 | ||||
| -rw-r--r-- | src/SMAPI/Framework/SCore.cs | 29 | ||||
| -rw-r--r-- | src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs | 25 |
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 |
