diff options
Diffstat (limited to 'src/SMAPI/Framework/Content')
-rw-r--r-- | src/SMAPI/Framework/Content/AssetDataForImage.cs | 166 | ||||
-rw-r--r-- | src/SMAPI/Framework/Content/AssetInfo.cs | 4 | ||||
-rw-r--r-- | src/SMAPI/Framework/Content/AssetName.cs | 114 |
3 files changed, 198 insertions, 86 deletions
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 3393b22f..89ced1ba 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,19 +129,22 @@ 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 + // get texture info Texture2D target = this.Data; int pixelCount = sourceArea.Width * sourceArea.Height; + int firstPixel = startRow * sourceArea.Width; + int lastPixel = firstPixel + pixelCount - 1; // validate if (sourceArea.X < 0 || sourceArea.Y < 0 || sourceArea.Right > sourceWidth || sourceArea.Bottom > sourceHeight) @@ -139,24 +154,67 @@ 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, firstPixel, 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 = firstPixel; i <= lastPixel; i++) + { + if (sourceData[i].A >= AssetDataForImage.MinOpacity) + { + startIndex = i; + break; + } + } + if (startIndex == -1) + return; // blank texture + + for (int i = lastPixel; 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 - startRow, 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/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index af000300..52ef02e6 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs index 148354a1..8355f9ec 100644 --- a/src/SMAPI/Framework/Content/AssetName.cs +++ b/src/SMAPI/Framework/Content/AssetName.cs @@ -1,6 +1,8 @@ using System; using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities.AssetPathUtilities; using StardewValley; +using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; namespace StardewModdingAPI.Framework.Content { @@ -94,10 +96,26 @@ namespace StardewModdingAPI.Framework.Content if (string.IsNullOrWhiteSpace(assetName)) return false; - assetName = PathUtilities.NormalizeAssetName(assetName); + AssetNamePartEnumerator curParts = new(useBaseName ? this.BaseName : this.Name); + AssetNamePartEnumerator otherParts = new(assetName.AsSpan().Trim()); - string compareTo = useBaseName ? this.BaseName : this.Name; - return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase); + while (true) + { + bool curHasMore = curParts.MoveNext(); + bool otherHasMore = otherParts.MoveNext(); + + // mismatch: lengths differ + if (otherHasMore != curHasMore) + return false; + + // match: both reached the end without a mismatch + if (!curHasMore) + return true; + + // mismatch: current segment is different + if (!curParts.Current.Equals(otherParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + } } /// <inheritdoc /> @@ -119,42 +137,76 @@ namespace StardewModdingAPI.Framework.Content if (prefix is null) return false; - string rawTrimmed = prefix.Trim(); + // get initial values + ReadOnlySpan<char> trimmedPrefix = prefix.AsSpan().Trim(); + if (trimmedPrefix.Length == 0) + return true; + ReadOnlySpan<char> pathSeparators = new(ToolkitPathUtilities.PossiblePathSeparators); // just to simplify calling other span APIs - // asset keys can't have a leading slash, but NormalizeAssetName will trim them - if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\')) + // asset keys can't have a leading slash, but AssetPathYielder will trim them + if (pathSeparators.Contains(trimmedPrefix[0])) return false; - // normalize prefix + // compare segments + AssetNamePartEnumerator curParts = new(this.Name); + AssetNamePartEnumerator prefixParts = new(trimmedPrefix); + while (true) { - string normalized = PathUtilities.NormalizeAssetName(prefix); + bool curHasMore = curParts.MoveNext(); + bool prefixHasMore = prefixParts.MoveNext(); - // keep trailing slash - if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\')) - normalized += PathUtilities.PreferredAssetSeparator; + // reached end for one side + if (prefixHasMore != curHasMore) + { + // mismatch: prefix is longer + if (prefixHasMore) + return false; + + // match: every segment in the prefix matched and subfolders are allowed (e.g. prefix 'Data/Events' with target 'Data/Events/Beach') + if (allowSubfolder) + return true; + + // Special case: the prefix ends with a path separator, but subfolders aren't allowed. This case + // matches if there's no further path separator in the asset name *after* the current separator. + // For example, the prefix 'A/B/' matches 'A/B/C' but not 'A/B/C/D'. + return pathSeparators.Contains(trimmedPrefix[^1]) && curParts.Remainder.Length == 0; + } - prefix = normalized; - } + // previous segments matched exactly and both reached the end + // match if prefix doesn't end with '/' (which should only match subfolders) + if (!prefixHasMore) + return !pathSeparators.Contains(trimmedPrefix[^1]); - // compare - if (prefix.Length == 0) - return true; + // compare segment + if (curParts.Current.Length == prefixParts.Current.Length) + { + // mismatch: segments aren't equivalent + if (!curParts.Current.Equals(prefixParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + } + else + { + // mismatch: prefix has more beyond this, and this segment isn't an exact match + if (prefixParts.Remainder.Length != 0) + return false; - return - this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - && ( - allowPartialWord - || this.Name.Length == prefix.Length - || !char.IsLetterOrDigit(prefix[^1]) // last character in suffix is word separator - || !char.IsLetterOrDigit(this.Name[prefix.Length]) // or first character after it is - ) - && ( - allowSubfolder - || this.Name.Length == prefix.Length - || !this.Name[prefix.Length..].Contains(PathUtilities.PreferredAssetSeparator) - ); - } + // mismatch: cur segment doesn't start with prefix + if (!curParts.Current.StartsWith(prefixParts.Current, StringComparison.OrdinalIgnoreCase)) + return false; + + // mismatch: something like "Maps/" would need an exact match + if (pathSeparators.Contains(trimmedPrefix[^1])) + return false; + // mismatch: partial word match not allowed, and the first or last letter of the suffix isn't a word separator + if (!allowPartialWord && char.IsLetterOrDigit(prefixParts.Current[^1]) && char.IsLetterOrDigit(curParts.Current[prefixParts.Current.Length])) + return false; + + // possible match + return allowSubfolder || (pathSeparators.Contains(trimmedPrefix[^1]) ? curParts.Remainder.IndexOfAny(ToolkitPathUtilities.PossiblePathSeparators) < 0 : curParts.Remainder.Length == 0); + } + } + } /// <inheritdoc /> public bool IsDirectlyUnderPath(string? assetFolder) @@ -162,7 +214,7 @@ namespace StardewModdingAPI.Framework.Content if (assetFolder is null) return false; - return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false); + return this.StartsWith(assetFolder + ToolkitPathUtilities.PreferredPathSeparator, allowPartialWord: false, allowSubfolder: false); } /// <inheritdoc /> |