From 562c88998b263ddeeb146762d5913d25fc8b62a3 Mon Sep 17 00:00:00 2001 From: sichii Date: Sun, 22 Sep 2024 14:23:13 -0400 Subject: [PATCH 01/17] fix palette remapper - palette remapper must account for preserved blacks... some images when converted from EPF to PNG have blacks that arent in the first palette index, and thus arent transparent when this image is then converted back into an EPF, the black is preserved by changing it to a near-black color when remapping palettes, we must take this possibility into account. It will show itself as the near-black color being in one palette, but not the other. With this we can guess that it was a preserved black, and we can re-map it back to a black that isnt in the target palette's 0 index. --- DALib/Extensions/PalettizedExtensions.cs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/DALib/Extensions/PalettizedExtensions.cs b/DALib/Extensions/PalettizedExtensions.cs index 9c373df..428afd3 100644 --- a/DALib/Extensions/PalettizedExtensions.cs +++ b/DALib/Extensions/PalettizedExtensions.cs @@ -4,6 +4,7 @@ using DALib.Definitions; using DALib.Drawing; using DALib.Utility; +using SkiaSharp; namespace DALib.Extensions; @@ -87,10 +88,29 @@ public static Palettized RemapPalette(this Palettized palettiz { (var epf, var palette) = palettized; + var reversedPalette = palette.Reverse() + .ToList(); + //create a dictionary that maps the old color indexes to the new color indexes var colorIndexMap = palette.Select((c, i) => (c, i)) - .DistinctBy(set => set.c) - .ToFrozenDictionary(set => (byte)set.i, set => (byte)newPalette.IndexOf(set.c)); + .ToFrozenDictionary( + set => (byte)set.i, + set => + { + var newIndex = newPalette.IndexOf(set.c); + + //if we couldn't find the color, and the color is what we use to preserve blacks + //look for a black that isn't in index 0 + //search backwards, then reverse the index + if ((newIndex == -1) && (set.c == CONSTANTS.RGB555_ALMOST_BLACK)) + { + var reversedIndex = reversedPalette.IndexOf(SKColors.Black); + + return (byte)(reversedPalette.Count - reversedIndex - 1); + } + + return (byte)newIndex; + }); foreach (var frame in epf) for (var i = 0; i < frame.Data.Length; i++) From 9d0a8daeefdb50ef96269e036e5a5b6829bedc25 Mon Sep 17 00:00:00 2001 From: sichii Date: Mon, 28 Oct 2024 14:46:36 -0400 Subject: [PATCH 02/17] performance (30x rendering on efa/spf) - created an optimized version of ScaleRange for the byte type - optimized existing ScaleRange --- DALib/Extensions/NumberExtensions.cs | 25 +++++++++++++ DALib/Utility/ColorCodec.cs | 24 ++++++------- DALib/Utility/MathEx.cs | 52 ++++++++++++++++------------ 3 files changed, 66 insertions(+), 35 deletions(-) create mode 100644 DALib/Extensions/NumberExtensions.cs diff --git a/DALib/Extensions/NumberExtensions.cs b/DALib/Extensions/NumberExtensions.cs new file mode 100644 index 0000000..03ba41a --- /dev/null +++ b/DALib/Extensions/NumberExtensions.cs @@ -0,0 +1,25 @@ +using System.Numerics; + +namespace DALib.Extensions; + +/// +/// Provides extension methods for numbers. +/// +/// +/// +public static class NumberExtensions where T: INumber +{ + /// + /// Whether the type is an integer type + /// + public static bool IsIntegerType { get; } = (typeof(T) == typeof(sbyte)) + || (typeof(T) == typeof(byte)) + || (typeof(T) == typeof(short)) + || (typeof(T) == typeof(ushort)) + || (typeof(T) == typeof(int)) + || (typeof(T) == typeof(uint)) + || (typeof(T) == typeof(long)) + || (typeof(T) == typeof(ulong)) + || (typeof(T) == typeof(nint)) + || (typeof(T) == typeof(nuint)); +} \ No newline at end of file diff --git a/DALib/Utility/ColorCodec.cs b/DALib/Utility/ColorCodec.cs index e6e54ba..7ae216e 100644 --- a/DALib/Utility/ColorCodec.cs +++ b/DALib/Utility/ColorCodec.cs @@ -20,21 +20,21 @@ public static SKColor DecodeRgb555(ushort encodedColor) var g = (byte)((encodedColor >> 5) & CONSTANTS.FIVE_BIT_MASK); var b = (byte)(encodedColor & CONSTANTS.FIVE_BIT_MASK); - r = MathEx.ScaleRange( + r = MathEx.ScaleRangeByteOptimized( r, 0, CONSTANTS.FIVE_BIT_MASK, 0, byte.MaxValue); - g = MathEx.ScaleRange( + g = MathEx.ScaleRangeByteOptimized( g, 0, CONSTANTS.FIVE_BIT_MASK, 0, byte.MaxValue); - b = MathEx.ScaleRange( + b = MathEx.ScaleRangeByteOptimized( b, 0, CONSTANTS.FIVE_BIT_MASK, @@ -56,21 +56,21 @@ public static SKColor DecodeRgb565(ushort encodedColor) var g = (byte)((encodedColor >> 5) & CONSTANTS.SIX_BIT_MASK); var b = (byte)(encodedColor & CONSTANTS.FIVE_BIT_MASK); - r = MathEx.ScaleRange( + r = MathEx.ScaleRangeByteOptimized( r, 0, CONSTANTS.FIVE_BIT_MASK, 0, byte.MaxValue); - g = MathEx.ScaleRange( + g = MathEx.ScaleRangeByteOptimized( g, 0, CONSTANTS.SIX_BIT_MASK, 0, byte.MaxValue); - b = MathEx.ScaleRange( + b = MathEx.ScaleRangeByteOptimized( b, 0, CONSTANTS.FIVE_BIT_MASK, @@ -88,21 +88,21 @@ public static SKColor DecodeRgb565(ushort encodedColor) /// public static ushort EncodeRgb555(SKColor color) { - var r = MathEx.ScaleRange( + var r = MathEx.ScaleRangeByteOptimized( color.Red, 0, byte.MaxValue, 0, CONSTANTS.FIVE_BIT_MASK); - var g = MathEx.ScaleRange( + var g = MathEx.ScaleRangeByteOptimized( color.Green, 0, byte.MaxValue, 0, CONSTANTS.FIVE_BIT_MASK); - var b = MathEx.ScaleRange( + var b = MathEx.ScaleRangeByteOptimized( color.Blue, 0, byte.MaxValue, @@ -120,21 +120,21 @@ public static ushort EncodeRgb555(SKColor color) /// public static ushort EncodeRgb565(SKColor color) { - var r = MathEx.ScaleRange( + var r = MathEx.ScaleRangeByteOptimized( color.Red, 0, byte.MaxValue, 0, CONSTANTS.FIVE_BIT_MASK); - var g = MathEx.ScaleRange( + var g = MathEx.ScaleRangeByteOptimized( color.Green, 0, byte.MaxValue, 0, CONSTANTS.SIX_BIT_MASK); - var b = MathEx.ScaleRange( + var b = MathEx.ScaleRangeByteOptimized( color.Blue, 0, byte.MaxValue, diff --git a/DALib/Utility/MathEx.cs b/DALib/Utility/MathEx.cs index bb88f47..4a48652 100644 --- a/DALib/Utility/MathEx.cs +++ b/DALib/Utility/MathEx.cs @@ -1,5 +1,6 @@ using System; using System.Numerics; +using DALib.Extensions; namespace DALib.Utility; @@ -40,25 +41,26 @@ public static T2 ScaleRange( T2 newMax) where T1: INumber where T2: INumber { - var ret = ScaleRange( - double.CreateTruncating(num), - double.CreateTruncating(min), - double.CreateTruncating(max), - double.CreateTruncating(newMin), - double.CreateTruncating(newMax)); + if (min.Equals(max)) + throw new ArgumentOutOfRangeException(nameof(min), "Min and max cannot be the same value"); + + // Compute the ratio as double for higher precision + var ratio = double.CreateChecked(num - min) / double.CreateChecked(max - min); - //get a rounded value and a truncated value - var roundedValue = Math.Round(ret, MidpointRounding.AwayFromZero); - var truncatedValue = T2.CreateTruncating(ret); + // Compute the scaled value + var scaledValue = ratio * double.CreateChecked(newMax - newMin) + double.CreateChecked(newMin); - //take whichever value is closer to the true value - var roundedDiff = Math.Abs(roundedValue - ret); - var truncatedDiff = Math.Abs(double.CreateTruncating(truncatedValue) - ret); + // Determine if T2 is an integer type + if (NumberExtensions.IsIntegerType) + { + // Round the scaled value to the nearest integer + var roundedValue = Math.Round(scaledValue, MidpointRounding.AwayFromZero); - if (roundedDiff < truncatedDiff) - return T2.CreateTruncating(roundedValue); + return T2.CreateChecked(roundedValue); + } - return truncatedValue; + // For floating-point types, return the scaled value directly + return T2.CreateChecked(scaledValue); } /// @@ -85,16 +87,20 @@ public static T2 ScaleRange( /// /// This method assumes that the input number is within the original range. No clamping or checking is performed. /// - public static double ScaleRange( - double num, - double min, - double max, - double newMin, - double newMax) + public static byte ScaleRangeByteOptimized( + byte num, + byte min, + byte max, + byte newMin, + byte newMax) { - if (min.Equals(max)) + if (min == max) throw new ArgumentOutOfRangeException(nameof(min), "Min and max cannot be the same value"); - return (newMax - newMin) * (num - min) / (max - min) + newMin; + // add (max - min) here for rounding + var numerator = (num - min) * (newMax - newMin) + (max - min) / 2; + var scaledValue = numerator / (max - min) + newMin; + + return (byte)scaledValue; // no bounds checking } } \ No newline at end of file From f0937f883e590c9c161ae320d018a8be65ff6832 Mon Sep 17 00:00:00 2001 From: sichii Date: Thu, 5 Dec 2024 12:31:08 -0500 Subject: [PATCH 03/17] minor changes - nothing actually important --- DALib/DALib.csproj | 4 +-- DALib/Data/DataArchive.cs | 14 +++++--- DALib/Utility/ImageProcessor.cs | 4 +-- DALib/Utility/MapImageCache.cs | 57 +++++++++++++++++------------- DALib/Utility/SKImageCache.cs | 16 +++++---- DALib/Utility/SKImageCollection.cs | 20 ++++++----- 6 files changed, 68 insertions(+), 47 deletions(-) diff --git a/DALib/DALib.csproj b/DALib/DALib.csproj index d006b40..e3cc5e8 100644 --- a/DALib/DALib.csproj +++ b/DALib/DALib.csproj @@ -30,8 +30,8 @@ - - + + diff --git a/DALib/Data/DataArchive.cs b/DALib/Data/DataArchive.cs index 39cce39..a034aca 100644 --- a/DALib/Data/DataArchive.cs +++ b/DALib/Data/DataArchive.cs @@ -5,6 +5,7 @@ using System.IO.MemoryMappedFiles; using System.Linq; using System.Text; +using System.Threading; using DALib.Abstractions; using DALib.Definitions; using DALib.Extensions; @@ -16,7 +17,10 @@ namespace DALib.Data; /// public class DataArchive : KeyedCollection, ISavable, IDisposable { - private bool IsDisposed; + /// + /// Whether the archive has been disposed. + /// + private int Disposed; /// /// The base stream of the archive @@ -69,10 +73,12 @@ protected DataArchive(Stream stream, bool newFormat = false) /// public virtual void Dispose() { - GC.SuppressFinalize(this); + if (Interlocked.CompareExchange(ref Disposed, 1, 0) == 1) + return; BaseStream.Dispose(); - IsDisposed = true; + + GC.SuppressFinalize(this); } /// @@ -267,7 +273,7 @@ public virtual void Patch(string entryName, ISavable item) Add(entry); } - internal void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(IsDisposed, this); + internal void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Disposed == 1, this); #region SaveTo /// diff --git a/DALib/Utility/ImageProcessor.cs b/DALib/Utility/ImageProcessor.cs index a28adbb..bb49fd9 100644 --- a/DALib/Utility/ImageProcessor.cs +++ b/DALib/Utility/ImageProcessor.cs @@ -207,7 +207,7 @@ public static Palettized QuantizeMultiple(QuantizerOptions op using var quantizedMosaic = Quantize(options, mosaic); using var bitmap = SKBitmap.FromImage(quantizedMosaic.Entity); - var quantizedImages = new List(); + var quantizedImages = new SKImageCollection([]); var x = 0; for (var i = 0; i < images.Length; i++) @@ -232,7 +232,7 @@ public static Palettized QuantizeMultiple(QuantizerOptions op return new Palettized { - Entity = new SKImageCollection(quantizedImages), + Entity = quantizedImages, Palette = quantizedMosaic.Palette }; } diff --git a/DALib/Utility/MapImageCache.cs b/DALib/Utility/MapImageCache.cs index 6575cf8..6b1e121 100644 --- a/DALib/Utility/MapImageCache.cs +++ b/DALib/Utility/MapImageCache.cs @@ -3,12 +3,28 @@ namespace DALib.Utility; /// -/// A collection of caches that can be used as a shared cache for rendering multiple maps. +/// A collection of caches that can be used as a shared cache for rendering multiple +/// maps. /// -public class MapImageCache: IDisposable +public sealed class MapImageCache : IDisposable { /// - /// Initializes a new instance of the class. + /// The background cache + /// + public SKImageCache BackgroundCache { get; } + + /// + /// The left foreground cache + /// + public SKImageCache LeftForegroundCache { get; } + + /// + /// The right foreground cache + /// + public SKImageCache RightForegroundCache { get; } + + /// + /// Initializes a new instance of the class. /// public MapImageCache() { @@ -16,38 +32,31 @@ public MapImageCache() LeftForegroundCache = new SKImageCache(); RightForegroundCache = new SKImageCache(); } + /// - /// Initializes a new instance of the class using the specified caches. + /// Initializes a new instance of the class using the specified caches. /// - /// The background cache - /// The left foreground cache - /// The right foreground cache + /// + /// The background cache + /// + /// + /// The left foreground cache + /// + /// + /// The right foreground cache + /// public MapImageCache(SKImageCache bgCache, SKImageCache lfgCache, SKImageCache rfgCache) { BackgroundCache = bgCache; LeftForegroundCache = lfgCache; RightForegroundCache = rfgCache; } - /// - /// The background cache - /// - public SKImageCache BackgroundCache { get; } - /// - /// The left foreground cache - /// - public SKImageCache LeftForegroundCache { get; } - /// - /// The right foreground cache - /// - public SKImageCache RightForegroundCache { get; } - /// - public virtual void Dispose() + /// + public void Dispose() { BackgroundCache.Dispose(); LeftForegroundCache.Dispose(); RightForegroundCache.Dispose(); - GC.SuppressFinalize(this); } -} - +} \ No newline at end of file diff --git a/DALib/Utility/SKImageCache.cs b/DALib/Utility/SKImageCache.cs index 730ce84..c15c4cc 100644 --- a/DALib/Utility/SKImageCache.cs +++ b/DALib/Utility/SKImageCache.cs @@ -1,6 +1,7 @@ -using SkiaSharp; using System; using System.Collections.Generic; +using System.Runtime.InteropServices; +using SkiaSharp; namespace DALib.Utility; @@ -10,17 +11,18 @@ namespace DALib.Utility; /// /// The type of the cache key. /// -public class SKImageCache(IEqualityComparer? comparer = null) : IDisposable where TKey: IEquatable +public sealed class SKImageCache(IEqualityComparer? comparer = null) : IDisposable where TKey: IEquatable { private readonly Dictionary Cache = new(comparer); /// - public virtual void Dispose() + public void Dispose() { - foreach (var image in Cache.Values) - image.Dispose(); + foreach (var key in Cache.Keys) + CollectionsMarshal.GetValueRefOrNullRef(Cache, key) + .Dispose(); - GC.SuppressFinalize(this); + Cache.Clear(); } /// @@ -33,7 +35,7 @@ public virtual void Dispose() /// /// The function used to create a new SKImage for the specified key. /// - public virtual SKImage GetOrCreate(TKey key, Func create) + public SKImage GetOrCreate(TKey key, Func create) { if (Cache.TryGetValue(key, out var image)) return image; diff --git a/DALib/Utility/SKImageCollection.cs b/DALib/Utility/SKImageCollection.cs index 2e7a203..b77f6ab 100644 --- a/DALib/Utility/SKImageCollection.cs +++ b/DALib/Utility/SKImageCollection.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Linq; using SkiaSharp; + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract namespace DALib.Utility; @@ -10,17 +11,20 @@ namespace DALib.Utility; /// /// Represents a disposable collection of SKImages. /// -public class SKImageCollection(IEnumerable images) : Collection( - images.Where(frame => frame is not null) - .ToList()), - IDisposable +public sealed class SKImageCollection(IEnumerable images) : Collection( + images.Where(frame => frame is not null) + .ToList()), + IDisposable { /// - public virtual void Dispose() + public void Dispose() { - foreach (var image in Items) - image.Dispose(); + for (var i = 0; i < Items.Count; i++) + { + var item = Items[i]; + item.Dispose(); + } - GC.SuppressFinalize(this); + Items.Clear(); } } \ No newline at end of file From 6e99f9defaf81ce96a10a34e7978141c4053288f Mon Sep 17 00:00:00 2001 From: sichii Date: Sat, 11 Jan 2025 03:29:31 -0500 Subject: [PATCH 04/17] minor fixes --- DALib/Comparers/NaturalStringComparer.cs | 60 ++++++++++++++++++++++++ DALib/Data/DataArchive.cs | 3 +- DALib/Extensions/SpanWriterExtensions.cs | 23 ++------- DALib/Utility/MathEx.cs | 3 +- 4 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 DALib/Comparers/NaturalStringComparer.cs diff --git a/DALib/Comparers/NaturalStringComparer.cs b/DALib/Comparers/NaturalStringComparer.cs new file mode 100644 index 0000000..a93b667 --- /dev/null +++ b/DALib/Comparers/NaturalStringComparer.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; + +namespace DALib; + +/// +/// A natural string comparer intended to work similarly to how windows orders files. Strings with numbers will be +/// compared numerically, rather than lexicographically. +/// +public sealed class NaturalStringComparer : IComparer +{ + public static IComparer Instance { get; } = new NaturalStringComparer(); + + /// + public int Compare(string? x, string? y) + { + if ((x == null) && (y == null)) + return 0; + + if (x == null) + return -1; + + if (y == null) + return 1; + + var ix = 0; + var iy = 0; + + while ((ix < x.Length) && (iy < y.Length)) + if (char.IsDigit(x[ix]) && char.IsDigit(y[iy])) + { + var lx = 0; + var ly = 0; + + while ((ix < x.Length) && char.IsDigit(x[ix])) + { + lx = lx * 10 + (x[ix] - '0'); + ix++; + } + + while ((iy < y.Length) && char.IsDigit(y[iy])) + { + ly = ly * 10 + (y[iy] - '0'); + iy++; + } + + if (lx != ly) + return lx.CompareTo(ly); + } else + { + if (x[ix] != y[iy]) + return x[ix] + .CompareTo(y[iy]); + + ix++; + iy++; + } + + return x.Length.CompareTo(y.Length); + } +} \ No newline at end of file diff --git a/DALib/Data/DataArchive.cs b/DALib/Data/DataArchive.cs index a034aca..200da8a 100644 --- a/DALib/Data/DataArchive.cs +++ b/DALib/Data/DataArchive.cs @@ -334,7 +334,8 @@ public virtual void Save(Stream stream) //plus 4 bytes for the final entry's end address (which could also be considered the total number of bytes) var address = HEADER_LENGTH + Count * ENTRY_HEADER_LENGTH + 4; - var entries = this.ToList(); + var entries = this.OrderBy(entry => entry.EntryName, NaturalStringComparer.Instance) + .ToList(); foreach (var entry in entries) { diff --git a/DALib/Extensions/SpanWriterExtensions.cs b/DALib/Extensions/SpanWriterExtensions.cs index 1599fdd..46849eb 100644 --- a/DALib/Extensions/SpanWriterExtensions.cs +++ b/DALib/Extensions/SpanWriterExtensions.cs @@ -1,4 +1,5 @@ using DALib.Memory; +using DALib.Utility; using SkiaSharp; namespace DALib.Extensions; @@ -17,16 +18,7 @@ public static class SpanWriterExtensions /// /// The RGB888 color to encode and write /// - public static void WriteRgb555Color(ref this SpanWriter writer, SKColor color) - { - var r = color.Red >> 3; - var g = color.Green >> 3; - var b = color.Blue >> 3; - - var rgb555 = (ushort)((r << 10) | (g << 5) | b); - - writer.WriteUInt16(rgb555); - } + public static void WriteRgb555Color(ref this SpanWriter writer, SKColor color) => writer.WriteUInt16(ColorCodec.EncodeRgb555(color)); /// /// Writes the given SKColor as a 16-bit RGB565 encoded color to the SpanWriter. @@ -37,14 +29,5 @@ public static void WriteRgb555Color(ref this SpanWriter writer, SKColor color) /// /// The RGB888 color to encode and write /// - public static void WriteRgb565Color(ref this SpanWriter writer, SKColor color) - { - var r = color.Red >> 3; - var g = color.Green >> 2; - var b = color.Blue >> 3; - - var rgb565 = (ushort)((r << 11) | (g << 5) | b); - - writer.WriteUInt16(rgb565); - } + public static void WriteRgb565Color(ref this SpanWriter writer, SKColor color) => writer.WriteUInt16(ColorCodec.EncodeRgb565(color)); } \ No newline at end of file diff --git a/DALib/Utility/MathEx.cs b/DALib/Utility/MathEx.cs index 4a48652..8e3bf21 100644 --- a/DALib/Utility/MathEx.cs +++ b/DALib/Utility/MathEx.cs @@ -97,10 +97,9 @@ public static byte ScaleRangeByteOptimized( if (min == max) throw new ArgumentOutOfRangeException(nameof(min), "Min and max cannot be the same value"); - // add (max - min) here for rounding var numerator = (num - min) * (newMax - newMin) + (max - min) / 2; var scaledValue = numerator / (max - min) + newMin; - return (byte)scaledValue; // no bounds checking + return (byte)scaledValue; } } \ No newline at end of file From 865756c91670d1ab284f32ce3293f00065690d78 Mon Sep 17 00:00:00 2001 From: sichii Date: Sun, 12 Jan 2025 03:02:51 -0500 Subject: [PATCH 05/17] fix for efa rendering --- DALib/Drawing/Graphics.cs | 87 +++++++++++++++++++-------- DALib/Extensions/SKColorExtensions.cs | 61 +++++++++++++++++++ DALib/Utility/ImageProcessor.cs | 29 +++++---- DALib/Utility/MathEx.cs | 7 ++- 4 files changed, 144 insertions(+), 40 deletions(-) diff --git a/DALib/Drawing/Graphics.cs b/DALib/Drawing/Graphics.cs index 8fd651d..da9f6ae 100644 --- a/DALib/Drawing/Graphics.cs +++ b/DALib/Drawing/Graphics.cs @@ -1,11 +1,11 @@ -using DALib.Data; +using System; +using System.Text; +using DALib.Data; using DALib.Definitions; using DALib.Extensions; using DALib.Memory; using DALib.Utility; using SkiaSharp; -using System; -using System.Text; namespace DALib.Drawing; @@ -89,6 +89,34 @@ public static SKImage RenderImage(SpfFrame spf, Palette spfPrimaryColorPalette) spf.Data!, spfPrimaryColorPalette); + /// + /// Renders a palette + /// + public static SKImage RenderImage(Palette palette) + { + using var bitmap = new SKBitmap(16 * 5, 16 * 5); + + using (var canvas = new SKCanvas(bitmap)) + for (var y = 0; y < 16; y++) + for (var x = 0; x < 16; x++) + { + var color = palette[x + y * 16]; + + using var paint = new SKPaint(); + paint.Color = color; + paint.IsAntialias = true; + + canvas.DrawRect( + x * 5, + y * 5, + 5, + 5, + paint); + } + + return SKImage.FromBitmap(bitmap); + } + /// /// Renders a colorized SpfFrame /// @@ -114,7 +142,11 @@ public static SKImage RenderImage(SpfFrame spf) /// public static SKImage RenderImage(EfaFrame efa, EfaBlendingType efaBlendingType = EfaBlendingType.Luminance) { - using var bitmap = new SKBitmap(efa.ImagePixelWidth, efa.ImagePixelHeight); + using var bitmap = new SKBitmap( + efa.ImagePixelWidth, + efa.ImagePixelHeight, + SKColorType.Bgra8888, + SKAlphaType.Unpremul); using var pixMap = bitmap.PeekPixels(); var pixelBuffer = pixMap.GetPixelSpan(); @@ -183,50 +215,57 @@ public static SKImage RenderImage(EfaFrame efa, EfaBlendingType efaBlendingType /// The amount of padding to add to the height of the file and beginning rendering position /// /// - /// A that can be reused to share caches between multiple map renderings. + /// A that can be reused to share caches between + /// multiple map renderings. /// - - public static SKImage RenderMap(MapFile map, DataArchive seoDat, DataArchive iaDat, int foregroundPadding = 512, + public static SKImage RenderMap( + MapFile map, + DataArchive seoDat, + DataArchive iaDat, + int foregroundPadding = 512, MapImageCache? cache = null) => RenderMap( map, Tileset.FromArchive("tilea", seoDat), PaletteLookup.FromArchive("mpt", seoDat) - .Freeze(), + .Freeze(), PaletteLookup.FromArchive("stc", iaDat) - .Freeze(), - iaDat, foregroundPadding, cache); + .Freeze(), + iaDat, + foregroundPadding, + cache); /// /// Renders a MapFile, given already extracted information /// /// - /// The to render + /// The to render /// /// - /// A representing a collection of background tiles + /// A representing a collection of background tiles /// /// - /// for background tiles + /// for background tiles /// /// - /// for foreground tiles + /// for foreground tiles /// /// - /// IA for reading foreground tile files + /// IA for reading foreground tile files /// /// /// The amount of padding to add to the height of the file and beginning rendering position /// /// - /// A that can be reused to share caches between multiple map renderings. + /// A that can be reused to share caches between + /// multiple map renderings. /// public static SKImage RenderMap( MapFile map, Tileset tiles, PaletteLookup bgPaletteLookup, PaletteLookup fgPaletteLookup, - DataArchive iaDat, + DataArchive iaDat, int foregroundPadding = 512, MapImageCache? cache = null) { @@ -240,13 +279,14 @@ public static SKImage RenderMap( //calculate width and height var width = (map.Width + map.Height + 1) * CONSTANTS.HALF_TILE_WIDTH; - var height = (map.Width + map.Height + 1 ) * CONSTANTS.HALF_TILE_HEIGHT + foregroundPadding; + var height = (map.Width + map.Height + 1) * CONSTANTS.HALF_TILE_HEIGHT + foregroundPadding; using var bitmap = new SKBitmap(width, height); using var canvas = new SKCanvas(bitmap); //the first tile drawn is the center tile at the top (0, 0) var bgInitialDrawX = (map.Height - 1) * CONSTANTS.HALF_TILE_WIDTH; var bgInitialDrawY = foregroundPadding; + try { //render background tiles and draw them to the canvas @@ -305,8 +345,7 @@ public static SKImage RenderMap( //for each X axis iteration, we want to move the draw position half a tile to the right and down from the initial draw position var lfgDrawX = fgInitialDrawX + x * CONSTANTS.HALF_TILE_WIDTH; - var lfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - lfgImage.Height + - CONSTANTS.HALF_TILE_HEIGHT; + var lfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - lfgImage.Height + CONSTANTS.HALF_TILE_HEIGHT; if ((lfgIndex % 10000) > 1) canvas.DrawImage(lfgImage, lfgDrawX, lfgDrawY); @@ -325,8 +364,7 @@ public static SKImage RenderMap( //for each X axis iteration, we want to move the draw position half a tile to the right and down from the initial draw position var rfgDrawX = fgInitialDrawX + (x + 1) * CONSTANTS.HALF_TILE_WIDTH; - var rfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - rfgImage.Height + - CONSTANTS.HALF_TILE_HEIGHT; + var rfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - rfgImage.Height + CONSTANTS.HALF_TILE_HEIGHT; if ((rfgIndex % 10000) > 1) canvas.DrawImage(rfgImage, rfgDrawX, rfgDrawY); @@ -336,14 +374,13 @@ public static SKImage RenderMap( fgInitialDrawX -= CONSTANTS.HALF_TILE_WIDTH; fgInitialDrawY += CONSTANTS.HALF_TILE_HEIGHT; } + return SKImage.FromBitmap(bitmap); - } - finally + } finally { if (dispose) cache.Dispose(); } - } /// diff --git a/DALib/Extensions/SKColorExtensions.cs b/DALib/Extensions/SKColorExtensions.cs index a3029b3..18ab6f0 100644 --- a/DALib/Extensions/SKColorExtensions.cs +++ b/DALib/Extensions/SKColorExtensions.cs @@ -9,6 +9,33 @@ namespace DALib.Extensions; /// public static class SKColorExtensions { + /// + /// Adjusts the brightness of an image by a percentage + /// + public static SKBitmap AdjustBrightness(this SKBitmap bitmap, float percent) + { + // @formatter:off + using var filter = SKColorFilter.CreateColorMatrix([ percent, 0, 0, 0, 0, + 0, percent, 0, 0, 0, + 0, 0, percent, 0, 0, + 0, 0, 0, 1, 0 ]); + // @formatter:on + + var newBitmap = bitmap.Copy(); + using var canvas = new SKCanvas(newBitmap); + + using var paint = new SKPaint(); + paint.ColorFilter = filter; + + canvas.DrawBitmap( + bitmap, + 0, + 0, + paint); + + return newBitmap; + } + /// /// Calculates the luminance of a color using the provided coefficient. /// @@ -19,6 +46,40 @@ public static class SKColorExtensions /// The coefficient to multiply the luminance by. Default value is 1.0f. /// public static float GetLuminance(this SKColor color, float coefficient = 1.0f) + { + var gamma = 2.0f; + + // Convert from [0..255] to [0..1]. + var r = color.Red / 255f; + var g = color.Green / 255f; + var b = color.Blue / 255f; + + // Convert to linear space (approx). + r = MathF.Pow(r, gamma); + g = MathF.Pow(g, gamma); + b = MathF.Pow(b, gamma); + + // Compute luminance in linear space. + // (Either the older 0.299/0.587/0.114 or the Rec. 709 ones: 0.2126/0.7152/0.0722) + /*var lumLinear = 0.2126f * r + 0.7152f * g + 0.0722f * b;*/ + var lumLinear = 0.299f * r + 0.587f * g + 0.114f * b; + + // Convert back to sRGB if needed. + var lumSrgb = MathF.Pow(lumLinear, 1f / gamma); + + return (byte)Math.Clamp(MathF.Round(lumSrgb * 255f * coefficient), 0, 255); + } + + /// + /// Calculates the luminance of a color using the provided coefficient. + /// + /// + /// The color whose luminance is being calculated. + /// + /// + /// The coefficient to multiply the luminance by. Default value is 1.0f. + /// + public static float GetSimpleLuminance(this SKColor color, float coefficient = 1.0f) => (0.299f * color.Red + 0.587f * color.Green + 0.114f * color.Blue) * coefficient; /// diff --git a/DALib/Utility/ImageProcessor.cs b/DALib/Utility/ImageProcessor.cs index bb49fd9..cdea563 100644 --- a/DALib/Utility/ImageProcessor.cs +++ b/DALib/Utility/ImageProcessor.cs @@ -129,8 +129,23 @@ public static Palettized Quantize(QuantizerOptions options, SKImage ima //don't quantize/dither if the image already has less than maximum number of colors if (colorCount <= options.MaxColors) + { + //all colors must be fully opaque or fully transparent + existingPixels = existingPixels.Select( + pixel => + { + //normally the quantizer would set all fully transparent pixels to black + //but since we didn't quantize the image, we have to do it manually + if (pixel.Alpha == 0) + return SKColors.Black; + + //all other colors are set to be fully opaque + return pixel.WithAlpha(byte.MaxValue); + }) + .ToArray(); + existingPixels.CopyTo(pixelBuffer); - else if (options.Ditherer is not null) + } else if (options.Ditherer is not null) { using var dSession = options.Ditherer!.Initialize(source, qSession); @@ -163,17 +178,7 @@ public static Palettized Quantize(QuantizerOptions options, SKImage ima if (colorCount <= options.MaxColors) palette = new Palette( - existingPixels.Select( - c => - { - //normally the quantizer would set all fully transparent pixels to black - //but since we didn't quantize the image, we have to do it manually - if (c.Alpha == 0) - return SKColors.Black; - - return c; - }) - .Distinct() + existingPixels.Distinct() .OrderBy(c => c.GetLuminance())); else palette = qSession.Palette!.ToDALibPalette(); diff --git a/DALib/Utility/MathEx.cs b/DALib/Utility/MathEx.cs index 8e3bf21..a298161 100644 --- a/DALib/Utility/MathEx.cs +++ b/DALib/Utility/MathEx.cs @@ -97,9 +97,10 @@ public static byte ScaleRangeByteOptimized( if (min == max) throw new ArgumentOutOfRangeException(nameof(min), "Min and max cannot be the same value"); - var numerator = (num - min) * (newMax - newMin) + (max - min) / 2; - var scaledValue = numerator / (max - min) + newMin; + // Cast to float (or double) to avoid truncation + var ratio = (float)(num - min) / (max - min); + var newValue = (newMax - newMin) * ratio + newMin; - return (byte)scaledValue; + return (byte)Math.Round(newValue); } } \ No newline at end of file From 83b2788f64ecc1f0a0dd1ff48dd40472f38d6002 Mon Sep 17 00:00:00 2001 From: sichii Date: Sun, 12 Jan 2025 19:11:01 -0500 Subject: [PATCH 06/17] fix compilation ordering --- DALib/Comparers/NaturalStringComparer.cs | 17 ++++++++++++----- DALib/Data/DataArchive.cs | 5 +++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/DALib/Comparers/NaturalStringComparer.cs b/DALib/Comparers/NaturalStringComparer.cs index a93b667..6d47830 100644 --- a/DALib/Comparers/NaturalStringComparer.cs +++ b/DALib/Comparers/NaturalStringComparer.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace DALib; +namespace DALib.Comparers; /// /// A natural string comparer intended to work similarly to how windows orders files. Strings with numbers will be @@ -10,9 +10,11 @@ public sealed class NaturalStringComparer : IComparer { public static IComparer Instance { get; } = new NaturalStringComparer(); + /// /// public int Compare(string? x, string? y) { + // ReSharper disable once ConvertIfStatementToSwitchStatement if ((x == null) && (y == null)) return 0; @@ -45,11 +47,15 @@ public int Compare(string? x, string? y) if (lx != ly) return lx.CompareTo(ly); - } else + } + else { - if (x[ix] != y[iy]) - return x[ix] - .CompareTo(y[iy]); + // Convert both characters to lower case for case-insensitive comparison + var cx = char.ToLowerInvariant(x[ix]); + var cy = char.ToLowerInvariant(y[iy]); + + if (cx != cy) + return cx.CompareTo(cy); ix++; iy++; @@ -57,4 +63,5 @@ public int Compare(string? x, string? y) return x.Length.CompareTo(y.Length); } + } \ No newline at end of file diff --git a/DALib/Data/DataArchive.cs b/DALib/Data/DataArchive.cs index 200da8a..d0dc3eb 100644 --- a/DALib/Data/DataArchive.cs +++ b/DALib/Data/DataArchive.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading; using DALib.Abstractions; +using DALib.Comparers; using DALib.Definitions; using DALib.Extensions; @@ -334,7 +335,7 @@ public virtual void Save(Stream stream) //plus 4 bytes for the final entry's end address (which could also be considered the total number of bytes) var address = HEADER_LENGTH + Count * ENTRY_HEADER_LENGTH + 4; - var entries = this.OrderBy(entry => entry.EntryName, NaturalStringComparer.Instance) + var entries = this.OrderBy(entry => entry.EntryName, StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var entry in entries) @@ -350,7 +351,7 @@ public virtual void Save(Stream stream) nameStr = nameStr.PadRight(CONSTANTS.DATA_ARCHIVE_ENTRY_NAME_LENGTH, '\0'); //get bytes for the name field (binaryWriter.Write(string) doesn't work for this) - var nameStrBytes = Encoding.ASCII.GetBytes(nameStr); + var nameStrBytes = Encoding.UTF8.GetBytes(nameStr); writer.Write(address); writer.Write(nameStrBytes); From dea6dd5b3ba7f96a644347113508f0d9d1388bdc Mon Sep 17 00:00:00 2001 From: sichii Date: Wed, 15 Jan 2025 11:31:14 -0500 Subject: [PATCH 07/17] archive entry ordering finally figured out --- ...referUnderscoreIgnoreCaseStringComparer.cs | 52 +++++ DALib/DALib.csproj | 7 +- DALib/Data/DataArchive.cs | 186 +++++++++++++++++- DALib/Definitions/RegexCache.cs | 10 + 4 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 DALib/Comparers/PreferUnderscoreIgnoreCaseStringComparer.cs create mode 100644 DALib/Definitions/RegexCache.cs diff --git a/DALib/Comparers/PreferUnderscoreIgnoreCaseStringComparer.cs b/DALib/Comparers/PreferUnderscoreIgnoreCaseStringComparer.cs new file mode 100644 index 0000000..797f0ec --- /dev/null +++ b/DALib/Comparers/PreferUnderscoreIgnoreCaseStringComparer.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace DALib.Comparers; + +/// +/// Compares two strings and returns a value indicating whether one is less than, equal to, or greater than the other. +/// Underscore will be considered less than any other character +/// +public sealed class PreferUnderscoreIgnoreCaseStringComparer : IComparer +{ + /// + /// Gets the default instance of the class. + /// + public static IComparer Instance { get; } = new PreferUnderscoreIgnoreCaseStringComparer(); + + /// + public int Compare(string? x, string? y) + { + if (StringComparer.OrdinalIgnoreCase.Equals(x, y)) + return 0; + + if (x == null) + return -1; + + if (y == null) + return 1; + + var xLength = x.Length; + var yLength = y.Length; + var minLength = xLength < yLength ? xLength : yLength; + + for (var i = 0; i < minLength; i++) + { + var xChar = char.ToUpperInvariant(x[i]); + var yChar = char.ToUpperInvariant(y[i]); + + if (xChar == yChar) + continue; + + if (xChar == '_') + return -1; + + if (yChar == '_') + return 1; + + return xChar.CompareTo(yChar); + } + + return xLength.CompareTo(yLength); + } +} \ No newline at end of file diff --git a/DALib/DALib.csproj b/DALib/DALib.csproj index e3cc5e8..5cf9139 100644 --- a/DALib/DALib.csproj +++ b/DALib/DALib.csproj @@ -1,7 +1,6 @@  - net8.0 DALib 0.5.3 README.md @@ -22,6 +21,8 @@ enable false true + net9.0 + latestmajor @@ -30,8 +31,8 @@ - - + + diff --git a/DALib/Data/DataArchive.cs b/DALib/Data/DataArchive.cs index d0dc3eb..44f4acf 100644 --- a/DALib/Data/DataArchive.cs +++ b/DALib/Data/DataArchive.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics; using System.IO; using System.IO.MemoryMappedFiles; using System.Linq; @@ -10,6 +11,7 @@ using DALib.Comparers; using DALib.Definitions; using DALib.Extensions; +using KGySoft.CoreLibraries; namespace DALib.Data; @@ -277,6 +279,183 @@ public virtual void Patch(string entryName, ISavable item) internal void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Disposed == 1, this); #region SaveTo + /// + /// Sorts the archive entries in a custom ordering + /// + /// + /// This all looks really complicated, but what's happening is conceptually simple... + /// + /// + /// + /// if the entry name is parsable as an integer, sort it as an integer + /// + /// + /// + /// + /// analyze the all entries in the archive and group them by their "prefix" + /// + /// + /// + /// + /// for each group, find the common numeric identifier length + /// + /// + /// + /// this isnt an exact process because there can be entries with /no/ numbers, and entries + /// /with/ numbers for the same prefix + /// + /// + /// + /// + /// there can also be entries with different lengths of numeric identifiers because they have + /// numeric tails (think khan archive entries ending in 01, 02, etc) + /// + /// + /// + /// + /// + /// + /// + /// sort by prefix (underscore are considered "less than" other characters) + /// + /// + /// + /// + /// then by common numeric identifier (no identifier is smallest) + /// + /// + /// + /// + /// then by tail (if there is a tail) + /// + /// + /// + /// + /// then by extension + /// + /// + /// + /// + public void Sort() + { + ThrowIfDisposed(); + + var entryPartsGroupedByPrefix = Items.Select( + entry => + { + var entryName = Path.GetFileNameWithoutExtension(entry.EntryName); + var extension = Path.GetExtension(entry.EntryName); + + //if the entry name is a number, we can't split it + //just sort it based on that number as a string + if (int.TryParse(entryName, out _)) + return new + { + Entry = entry, + Prefix = entryName, + NumericId = "", + Tail = "", + Extension = extension + }; + + var parts = RegexCache.EntryNameRegex + .Matches(entryName)[0].Groups; + + return new + { + Entry = entry, + Prefix = parts[1].Value, + NumericId = parts[2].Value, + Tail = parts[3].Value, + Extension = extension + }; + }) + .GroupBy(parts => parts.Prefix, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var prefixToCommonIdentifierLength = entryPartsGroupedByPrefix.ToDictionary( + group => group.Key, + group => + { + var first3 = group.Select(parts => parts.NumericId.Length) + .OrderBy(len => len) + .Take(3) + .ToList(); + + return first3 switch + { + { Count: 0 } => 0, + { Count: 1 } => first3[0], + { Count: 2 or 3 } => first3.FirstOrDefault(num => num > 0), + _ => throw new UnreachableException("We take 3, handling counts 0-3 should be all conditions") + }; + }, + StringComparer.OrdinalIgnoreCase); + + //for each entry... correct the common identifier to be the correct length and move any extra to the tail + var correctedParts = entryPartsGroupedByPrefix.SelectMany(group => group) + .Select( + parts => + { + var commonLength = prefixToCommonIdentifierLength[parts.Prefix]; + + if (parts.NumericId.Length > commonLength) + return parts with + { + NumericId = parts.NumericId[..commonLength], + Tail = parts.NumericId[commonLength..] + parts.Tail + }; + + return parts; + }); + + Items.Clear(); + + var orderedEntries = correctedParts + + //SORT BY PREFIX + .OrderBy(parts => parts.Prefix, PreferUnderscoreIgnoreCaseStringComparer.Instance) + + //THEN BY COMMON NUMERIC IDENTIFIER + .ThenBy( + parts => + { + var commonLength = prefixToCommonIdentifierLength[parts.Prefix]; + + //if we did not find a common identifier + if (commonLength == 0) + { + //check the tail for a number + //one could still be there if one of the entries has no identifier, but the others did + if (parts.Tail.Length == 0) + return -1; + + //starting with the first char, grab chars till we run into a non-digit + var tailDigits = new string( + parts.Tail + .TakeWhile(char.IsDigit) + .ToArray()); + + //if no digits were found, return 0 + if (tailDigits.Length == 0) + return -1; + + //parts those digits into an identifier and return it + return int.Parse(tailDigits); + } + + if (parts.NumericId.Length < commonLength) + return -1; + + return int.Parse(parts.NumericId[..commonLength]); + }) + .ThenBy(parts => parts.Tail, PreferUnderscoreIgnoreCaseStringComparer.Instance) + .ThenBy(parts => parts.Extension, PreferUnderscoreIgnoreCaseStringComparer.Instance) + .Select(parts => parts.Entry); + + Items.AddRange(orderedEntries); + } + /// /// /// Thrown if the object is already disposed. @@ -335,10 +514,9 @@ public virtual void Save(Stream stream) //plus 4 bytes for the final entry's end address (which could also be considered the total number of bytes) var address = HEADER_LENGTH + Count * ENTRY_HEADER_LENGTH + 4; - var entries = this.OrderBy(entry => entry.EntryName, StringComparer.OrdinalIgnoreCase) - .ToList(); + Sort(); - foreach (var entry in entries) + foreach (var entry in Items) { //reconstruct the name field with the required terminator var nameStr = entry.EntryName; @@ -361,7 +539,7 @@ public virtual void Save(Stream stream) writer.Write(address); - foreach (var entry in entries) + foreach (var entry in Items) { using var segment = entry.ToStreamSegment(); segment.CopyTo(stream); diff --git a/DALib/Definitions/RegexCache.cs b/DALib/Definitions/RegexCache.cs new file mode 100644 index 0000000..b5ec124 --- /dev/null +++ b/DALib/Definitions/RegexCache.cs @@ -0,0 +1,10 @@ +using System.Text.RegularExpressions; + +namespace DALib.Definitions; + +// ReSharper disable once PartialTypeWithSinglePart +internal static partial class RegexCache +{ + [GeneratedRegex(@"(\D+)(\d+)?(.+)?", RegexOptions.Compiled)] + internal static partial Regex EntryNameRegex { get; } +} \ No newline at end of file From acd7d08dc66756b08e213e1b85096451ca8cbc34 Mon Sep 17 00:00:00 2001 From: sichii Date: Wed, 15 Jan 2025 12:01:25 -0500 Subject: [PATCH 08/17] simplify sorting... +comments --- DALib/Data/DataArchive.cs | 58 +++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/DALib/Data/DataArchive.cs b/DALib/Data/DataArchive.cs index 44f4acf..bfe0d24 100644 --- a/DALib/Data/DataArchive.cs +++ b/DALib/Data/DataArchive.cs @@ -340,6 +340,9 @@ public void Sort() { ThrowIfDisposed(); + //split entries entry names based on a regex + //group the entries by their "prefix" + //the prefix is all of the letters in the entry name up till the first digit var entryPartsGroupedByPrefix = Items.Select( entry => { @@ -373,15 +376,20 @@ public void Sort() .GroupBy(parts => parts.Prefix, StringComparer.OrdinalIgnoreCase) .ToList(); + //look at each prefix grouping and extract a common length for the numeric part of the entry name + //sometimes there will be a combination of entries with and without numeric identifiers + //we prefer to store a non-zero length if possible var prefixToCommonIdentifierLength = entryPartsGroupedByPrefix.ToDictionary( group => group.Key, group => { + //order by numeric id length, take the first 3 entries var first3 = group.Select(parts => parts.NumericId.Length) .OrderBy(len => len) .Take(3) .ToList(); + //prefer to store a non-zero id length return first3 switch { { Count: 0 } => 0, @@ -392,13 +400,16 @@ public void Sort() }, StringComparer.OrdinalIgnoreCase); - //for each entry... correct the common identifier to be the correct length and move any extra to the tail + //now that we know the common length for the id for each prefix + //adjust the numeric id and tail to have the correct pieces of the entry name var correctedParts = entryPartsGroupedByPrefix.SelectMany(group => group) .Select( parts => { var commonLength = prefixToCommonIdentifierLength[parts.Prefix]; + //regex parsed more digits for this entry than some of the others with same prefix + //so we move those extra digits to the tail if (parts.NumericId.Length > commonLength) return parts with { @@ -413,45 +424,34 @@ public void Sort() var orderedEntries = correctedParts - //SORT BY PREFIX + //SORT BY PREFIX, UNDERSCORES ARE SPECIAL .OrderBy(parts => parts.Prefix, PreferUnderscoreIgnoreCaseStringComparer.Instance) //THEN BY COMMON NUMERIC IDENTIFIER .ThenBy( parts => { - var commonLength = prefixToCommonIdentifierLength[parts.Prefix]; - - //if we did not find a common identifier - if (commonLength == 0) - { - //check the tail for a number - //one could still be there if one of the entries has no identifier, but the others did - if (parts.Tail.Length == 0) - return -1; - - //starting with the first char, grab chars till we run into a non-digit - var tailDigits = new string( - parts.Tail - .TakeWhile(char.IsDigit) - .ToArray()); - - //if no digits were found, return 0 - if (tailDigits.Length == 0) - return -1; - - //parts those digits into an identifier and return it - return int.Parse(tailDigits); - } - - if (parts.NumericId.Length < commonLength) + //grab the common length of the identifier for the prefix + var commonIdentifierLength = prefixToCommonIdentifierLength[parts.Prefix]; + + //if it's 0, or the numeric id is shorter than the common length, return -1 + //the numeric id can be shorter than the length if... + //an entry was found WITH a numeric id, and another entry was found WITHOUT a numeric id + //we prefer to take a numeric id if one seems like it exists + if ((commonIdentifierLength == 0) || (parts.NumericId.Length < commonIdentifierLength)) return -1; - return int.Parse(parts.NumericId[..commonLength]); + //parse the numeric id and sort by it + return int.Parse(parts.NumericId); }) + + //THEN BY TAIL, UNDERSCORES ARE SPECIAL .ThenBy(parts => parts.Tail, PreferUnderscoreIgnoreCaseStringComparer.Instance) + + //THEN BY EXTENSION, UNDERSCORES ARE SPECIAL BUT PROBABLY DONT ACTUALLY MATTER .ThenBy(parts => parts.Extension, PreferUnderscoreIgnoreCaseStringComparer.Instance) - .Select(parts => parts.Entry); + .Select(parts => parts.Entry) + .ToList(); Items.AddRange(orderedEntries); } From 3e587e95fb0c3bad2cb0fc0e392e020700294f3a Mon Sep 17 00:00:00 2001 From: sichii Date: Wed, 22 Jan 2025 23:19:41 -0500 Subject: [PATCH 09/17] mpf optional frame clarity --- DALib/Drawing/MpfFile.cs | 46 +++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/DALib/Drawing/MpfFile.cs b/DALib/Drawing/MpfFile.cs index bde016b..1e5245e 100644 --- a/DALib/Drawing/MpfFile.cs +++ b/DALib/Drawing/MpfFile.cs @@ -60,6 +60,19 @@ public sealed class MpfFile : Collection, ISavable /// public MpfHeaderType HeaderType { get; set; } + /// + /// The number of frames in the standing animation including optional frames. If your normal standing animation has 4 + /// frames, but there are 2 extra frames that should occasionally be played, then you would put 6 here. (4 normal + /// frames + 2 optional frames) + /// + public byte OptionalAnimationFrameCount { get; set; } + + /// + /// Specifies the ratio of playing the optional standing frames. For example, if this is set to 50, it will play the + /// optional frames 50% of the time + /// + public byte OptionalAnimationRatio { get; set; } + /// /// The palette number used to colorize this image /// @@ -76,7 +89,7 @@ public sealed class MpfFile : Collection, ISavable public short PixelWidth { get; set; } /// - /// The number of frames for the standing animation + /// The number of frames for the standing animation without the optional frames /// public byte StandingFrameCount { get; set; } @@ -85,21 +98,6 @@ public sealed class MpfFile : Collection, ISavable /// public byte StandingFrameIndex { get; set; } - /// - /// Specifies the ratio of switching to other frames from the frames designated in StopMotionFrameCount. For example, - /// if you set this to 10 for a guy spinning nunchucks, about once every 10 times, he hits his own head. (about once - /// every 10 times the client will play the StopMotion frames) - /// - public byte StopMotionFailureRatio { get; set; } - - /// - /// Number of frames in the stationary action (For example, a guy continuously spinning nunchucks would need two frames - /// for the spinning action, so it should be written as 2) Usually, it should be written as 0. If it's written as 0, it - /// continuously repeats the entire action frame and if it's a number other than 0, it repeats only that many frames - /// and occasionally animates with the remaining frames. - /// - public byte StopMotionFrameCount { get; set; } - /// /// Unknown header bytes at the beginning of the file. Only used if HeaderType is set to Unknown /// @@ -195,8 +193,8 @@ private MpfFile(Stream stream) case MpfFormatType.MultipleAttacks: StandingFrameIndex = reader.ReadByte(); StandingFrameCount = reader.ReadByte(); - StopMotionFrameCount = reader.ReadByte(); - StopMotionFailureRatio = reader.ReadByte(); + OptionalAnimationFrameCount = reader.ReadByte(); + OptionalAnimationRatio = reader.ReadByte(); AttackFrameIndex = reader.ReadByte(); AttackFrameCount = reader.ReadByte(); Attack2StartIndex = reader.ReadByte(); @@ -212,8 +210,8 @@ private MpfFile(Stream stream) AttackFrameCount = reader.ReadByte(); StandingFrameIndex = reader.ReadByte(); StandingFrameCount = reader.ReadByte(); - StopMotionFrameCount = reader.ReadByte(); - StopMotionFailureRatio = reader.ReadByte(); + OptionalAnimationFrameCount = reader.ReadByte(); + OptionalAnimationRatio = reader.ReadByte(); break; } @@ -307,8 +305,8 @@ public void Save(Stream stream) writer.Write((short)FormatType); writer.Write(StandingFrameIndex); writer.Write(StandingFrameCount); - writer.Write(StopMotionFrameCount); - writer.Write(StopMotionFailureRatio); + writer.Write(OptionalAnimationFrameCount); + writer.Write(OptionalAnimationRatio); writer.Write(AttackFrameIndex); writer.Write(AttackFrameCount); writer.Write(Attack2StartIndex); @@ -321,8 +319,8 @@ public void Save(Stream stream) writer.Write(AttackFrameCount); writer.Write(StandingFrameIndex); writer.Write(StandingFrameCount); - writer.Write(StopMotionFrameCount); - writer.Write(StopMotionFailureRatio); + writer.Write(OptionalAnimationFrameCount); + writer.Write(OptionalAnimationRatio); } var startAddress = 0; From 2398299c46d9bdd32704be09227d791faec0f90f Mon Sep 17 00:00:00 2001 From: sichii Date: Thu, 23 Jan 2025 12:23:59 -0500 Subject: [PATCH 10/17] more misc stuff --- DALib/Definitions/Flags.cs | 25 +++++++++++++++++++++++++ DALib/Drawing/Graphics.cs | 4 ++-- DALib/Utility/MapImageCache.cs | 20 ++++++-------------- 3 files changed, 33 insertions(+), 16 deletions(-) create mode 100644 DALib/Definitions/Flags.cs diff --git a/DALib/Definitions/Flags.cs b/DALib/Definitions/Flags.cs new file mode 100644 index 0000000..0c435e9 --- /dev/null +++ b/DALib/Definitions/Flags.cs @@ -0,0 +1,25 @@ +using System; + +namespace DALib.Definitions; + +/// +/// Tils flags as used in sotp.dat +/// +[Flags] +public enum TileFlags : byte +{ + /// + /// Tile is a normal tile + /// + None = 0, + + /// + /// Tile is a wall + /// + Wall = 15, + + /// + /// Tile has luminosity based transparency (dark = more transparent) + /// + Transparent = 128 +} \ No newline at end of file diff --git a/DALib/Drawing/Graphics.cs b/DALib/Drawing/Graphics.cs index da9f6ae..91067f9 100644 --- a/DALib/Drawing/Graphics.cs +++ b/DALib/Drawing/Graphics.cs @@ -332,7 +332,7 @@ public static SKImage RenderMap( var rfgIndex = tile.RightForeground; //render left foreground - var lfgImage = cache.LeftForegroundCache.GetOrCreate( + var lfgImage = cache.ForegroundCache.GetOrCreate( lfgIndex, index => { @@ -351,7 +351,7 @@ public static SKImage RenderMap( canvas.DrawImage(lfgImage, lfgDrawX, lfgDrawY); //render right foreground - var rfgImage = cache.RightForegroundCache.GetOrCreate( + var rfgImage = cache.ForegroundCache.GetOrCreate( rfgIndex, index => { diff --git a/DALib/Utility/MapImageCache.cs b/DALib/Utility/MapImageCache.cs index 6b1e121..6d5e267 100644 --- a/DALib/Utility/MapImageCache.cs +++ b/DALib/Utility/MapImageCache.cs @@ -16,12 +16,7 @@ public sealed class MapImageCache : IDisposable /// /// The left foreground cache /// - public SKImageCache LeftForegroundCache { get; } - - /// - /// The right foreground cache - /// - public SKImageCache RightForegroundCache { get; } + public SKImageCache ForegroundCache { get; } /// /// Initializes a new instance of the class. @@ -29,8 +24,7 @@ public sealed class MapImageCache : IDisposable public MapImageCache() { BackgroundCache = new SKImageCache(); - LeftForegroundCache = new SKImageCache(); - RightForegroundCache = new SKImageCache(); + ForegroundCache = new SKImageCache(); } /// @@ -39,24 +33,22 @@ public MapImageCache() /// /// The background cache /// - /// + /// /// The left foreground cache /// /// /// The right foreground cache /// - public MapImageCache(SKImageCache bgCache, SKImageCache lfgCache, SKImageCache rfgCache) + public MapImageCache(SKImageCache bgCache, SKImageCache fgCache) { BackgroundCache = bgCache; - LeftForegroundCache = lfgCache; - RightForegroundCache = rfgCache; + ForegroundCache = fgCache; } /// public void Dispose() { BackgroundCache.Dispose(); - LeftForegroundCache.Dispose(); - RightForegroundCache.Dispose(); + ForegroundCache.Dispose(); } } \ No newline at end of file From 9bf0aea1f59c8565b547c14eefc0b3d31a7340f1 Mon Sep 17 00:00:00 2001 From: sichii Date: Thu, 23 Jan 2025 12:29:42 -0500 Subject: [PATCH 11/17] hpf transparency --- DALib/Drawing/Graphics.cs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/DALib/Drawing/Graphics.cs b/DALib/Drawing/Graphics.cs index 91067f9..f9ab6f1 100644 --- a/DALib/Drawing/Graphics.cs +++ b/DALib/Drawing/Graphics.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Text; using DALib.Data; using DALib.Definitions; @@ -62,14 +63,30 @@ public static SKImage RenderImage(MpfFrame frame, Palette palette) /// /// An optional custom offset used to move the image down, since these images are rendered from the bottom up /// - public static SKImage RenderImage(HpfFile hpf, Palette palette, int yOffset = 0) - => SimpleRender( + /// + /// An optional flag to enable transparency. Some foreground images has luminosity based transparency. This is + /// controlled via SOTP with the flag + /// + public static SKImage RenderImage( + HpfFile hpf, + Palette palette, + int yOffset = 0, + bool transparency = false) + { + if (transparency) + { + var semiTransparentColors = palette.Select(color => color.WithLuminanceAlpha()); + palette = new Palette(semiTransparentColors); + } + + return SimpleRender( 0, yOffset, hpf.PixelWidth, hpf.PixelHeight, hpf.Data, palette); + } /// /// Renders a palettized SPF frame From cbef9c581222a5953212c660363f09e79da7a55b Mon Sep 17 00:00:00 2001 From: sichii Date: Sun, 2 Feb 2025 08:14:12 -0500 Subject: [PATCH 12/17] correction on which tiles are not rendered by client --- DALib/Drawing/Graphics.cs | 4 ++-- DALib/Extensions/IntExtensions.cs | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 DALib/Extensions/IntExtensions.cs diff --git a/DALib/Drawing/Graphics.cs b/DALib/Drawing/Graphics.cs index f9ab6f1..0c4236b 100644 --- a/DALib/Drawing/Graphics.cs +++ b/DALib/Drawing/Graphics.cs @@ -364,7 +364,7 @@ public static SKImage RenderMap( var lfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - lfgImage.Height + CONSTANTS.HALF_TILE_HEIGHT; - if ((lfgIndex % 10000) > 1) + if (lfgIndex.IsRenderedTileIndex()) canvas.DrawImage(lfgImage, lfgDrawX, lfgDrawY); //render right foreground @@ -383,7 +383,7 @@ public static SKImage RenderMap( var rfgDrawY = fgInitialDrawY + (x + 1) * CONSTANTS.HALF_TILE_HEIGHT - rfgImage.Height + CONSTANTS.HALF_TILE_HEIGHT; - if ((rfgIndex % 10000) > 1) + if (rfgIndex.IsRenderedTileIndex()) canvas.DrawImage(rfgImage, rfgDrawX, rfgDrawY); } diff --git a/DALib/Extensions/IntExtensions.cs b/DALib/Extensions/IntExtensions.cs new file mode 100644 index 0000000..60546ee --- /dev/null +++ b/DALib/Extensions/IntExtensions.cs @@ -0,0 +1,29 @@ +namespace DALib.Extensions; + +/// +/// Provides extension methods for the struct +/// +public static class IntExtensions +{ + /// + /// Determined if the tile index is rendered by the client + /// + /// + /// The tile index to check + /// + /// + /// + /// true + /// + /// if the tile index is rendered by the client; otherwise, + /// + /// false + /// + /// . + /// + /// + /// 0-12 and 10000-10012 are not rendered by the client... 20000-20012 are rendered but are kinda buggy if you get + /// close to them + /// + public static bool IsRenderedTileIndex(this int tileIndex) => (tileIndex > 10012) || ((tileIndex % 10000) > 12); +} \ No newline at end of file From d253ad25405874bd541bc166ef8e170d598b06b9 Mon Sep 17 00:00:00 2001 From: sichii Date: Sat, 5 Apr 2025 18:43:16 -0400 Subject: [PATCH 13/17] fix tileset saving --- DALib/Drawing/Tileset.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/DALib/Drawing/Tileset.cs b/DALib/Drawing/Tileset.cs index 43ec749..a8ec5f2 100644 --- a/DALib/Drawing/Tileset.cs +++ b/DALib/Drawing/Tileset.cs @@ -1,14 +1,14 @@ -using DALib.Abstractions; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text; +using DALib.Abstractions; using DALib.Data; using DALib.Definitions; using DALib.Extensions; using DALib.Utility; using SkiaSharp; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Text; namespace DALib.Drawing; @@ -62,9 +62,7 @@ public void Save(Stream stream) { using var writer = new BinaryWriter(stream, Encoding.Default, true); - var tileCount = (int)(stream.Length / CONSTANTS.TILE_SIZE); - - for (var i = 0; i < tileCount; i++) + for (var i = 0; i < Count; i++) { var tile = this[i]; From 8978a398ffd07d74a3d8662597723e221b00d84e Mon Sep 17 00:00:00 2001 From: sichii Date: Sat, 19 Apr 2025 03:51:28 -0400 Subject: [PATCH 14/17] better lod compat --- DALib/Data/DataArchive.cs | 19 +++++++---- DALib/Extensions/PalettizedExtensions.cs | 43 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/DALib/Data/DataArchive.cs b/DALib/Data/DataArchive.cs index bfe0d24..dc51f55 100644 --- a/DALib/Data/DataArchive.cs +++ b/DALib/Data/DataArchive.cs @@ -64,12 +64,19 @@ protected DataArchive(Stream stream, bool newFormat = false) stream.Seek(-4, SeekOrigin.Current); - Add( - new DataArchiveEntry( - this, - name, - startAddress, - endAddress - startAddress)); + try + { + Add( + new DataArchiveEntry( + this, + name, + startAddress, + endAddress - startAddress)); + } catch + { + if (!newFormat) + throw; + } } } diff --git a/DALib/Extensions/PalettizedExtensions.cs b/DALib/Extensions/PalettizedExtensions.cs index 428afd3..7204646 100644 --- a/DALib/Extensions/PalettizedExtensions.cs +++ b/DALib/Extensions/PalettizedExtensions.cs @@ -123,4 +123,47 @@ public static Palettized RemapPalette(this Palettized palettiz Palette = newPalette }; } + + /// + /// Remaps the frame data of the given palettized hpf file to use the indexes of the new palette, assuming the colors + /// are the same + /// + public static Palettized RemapPalette(this Palettized palettized, Palette newPalette) + { + (var hpf, var palette) = palettized; + + var reversedPalette = palette.Reverse() + .ToList(); + + //create a dictionary that maps the old color indexes to the new color indexes + var colorIndexMap = palette.Select((c, i) => (c, i)) + .ToFrozenDictionary( + set => (byte)set.i, + set => + { + var newIndex = newPalette.IndexOf(set.c); + + //if we couldn't find the color, and the color is what we use to preserve blacks + //look for a black that isn't in index 0 + //search backwards, then reverse the index + if ((newIndex == -1) && (set.c == CONSTANTS.RGB555_ALMOST_BLACK)) + { + var reversedIndex = reversedPalette.IndexOf(SKColors.Black); + + return (byte)(reversedPalette.Count - reversedIndex - 1); + } + + return (byte)newIndex; + }); + + for (var i = 0; i < hpf.Data.Length; i++) + hpf.Data[i] = colorIndexMap[hpf.Data[i]]; + + //return the epffile with the new palette + return new Palettized + { + Entity = hpf, + Palette = newPalette + }; + } } \ No newline at end of file From 4906870699fbe9a8e51369c3ebf63ff3629c0bac Mon Sep 17 00:00:00 2001 From: sichii Date: Tue, 13 May 2025 16:55:49 -0400 Subject: [PATCH 15/17] epf saving - data is trimmed to what is actually used to render the image --- DALib/Drawing/EpfFile.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/DALib/Drawing/EpfFile.cs b/DALib/Drawing/EpfFile.cs index 3757bda..4178cc2 100644 --- a/DALib/Drawing/EpfFile.cs +++ b/DALib/Drawing/EpfFile.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +#region +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; @@ -8,6 +10,7 @@ using DALib.Extensions; using DALib.Utility; using SkiaSharp; +#endregion namespace DALib.Drawing; @@ -129,7 +132,7 @@ public void Save(Stream stream) writer.Write(PixelHeight); writer.Write(UnknownBytes); - var footerStartAddress = this.Sum(frame => frame.Data.Length); + var footerStartAddress = this.Sum(frame => Math.Min(frame.Data.Length, frame.PixelWidth * frame.PixelHeight)); writer.Write(footerStartAddress); @@ -137,8 +140,9 @@ public void Save(Stream stream) for (var i = 0; i < Count; i++) { var frame = this[i]; + var length = Math.Min(frame.Data.Length, frame.PixelWidth * frame.PixelHeight); - writer.Write(frame.Data); + writer.Write(frame.Data[..length]); } var dataIndex = 0; @@ -148,7 +152,8 @@ public void Save(Stream stream) { var frame = this[i]; - var dataEndAddress = dataIndex + frame.Data.Length; + var length = Math.Min(frame.Data.Length, frame.PixelWidth * frame.PixelHeight); + var dataEndAddress = dataIndex + length; writer.Write(frame.Top); writer.Write(frame.Left); @@ -157,7 +162,7 @@ public void Save(Stream stream) writer.Write(dataIndex); writer.Write(dataEndAddress); - dataIndex += frame.Data.Length; + dataIndex += length; } } #endregion From 19af2707d67eadbc5ee8a59060e7661a8aa550b4 Mon Sep 17 00:00:00 2001 From: sichii Date: Thu, 7 Aug 2025 05:52:39 -0400 Subject: [PATCH 16/17] better natural string comparer --- DALib/Comparers/NaturalStringComparer.cs | 104 ++++++++++++++++------- 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/DALib/Comparers/NaturalStringComparer.cs b/DALib/Comparers/NaturalStringComparer.cs index 6d47830..c0f0091 100644 --- a/DALib/Comparers/NaturalStringComparer.cs +++ b/DALib/Comparers/NaturalStringComparer.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +#region +using System.Collections.Generic; +#endregion namespace DALib.Comparers; @@ -8,14 +10,15 @@ namespace DALib.Comparers; /// public sealed class NaturalStringComparer : IComparer { - public static IComparer Instance { get; } = new NaturalStringComparer(); + /// + /// Comparers are basically static methods, no point in creating many instances + /// + public static NaturalStringComparer Instance { get; } = new(); - /// /// public int Compare(string? x, string? y) { - // ReSharper disable once ConvertIfStatementToSwitchStatement - if ((x == null) && (y == null)) + if (ReferenceEquals(x, y)) return 0; if (x == null) @@ -30,38 +33,77 @@ public int Compare(string? x, string? y) while ((ix < x.Length) && (iy < y.Length)) if (char.IsDigit(x[ix]) && char.IsDigit(y[iy])) { - var lx = 0; - var ly = 0; - - while ((ix < x.Length) && char.IsDigit(x[ix])) - { - lx = lx * 10 + (x[ix] - '0'); - ix++; - } - - while ((iy < y.Length) && char.IsDigit(y[iy])) - { - ly = ly * 10 + (y[iy] - '0'); - iy++; - } - - if (lx != ly) - return lx.CompareTo(ly); - } - else - { - // Convert both characters to lower case for case-insensitive comparison - var cx = char.ToLowerInvariant(x[ix]); - var cy = char.ToLowerInvariant(y[iy]); + // Compare numeric portions + var numResult = CompareNumericPortion( + x, + y, + ref ix, + ref iy); - if (cx != cy) - return cx.CompareTo(cy); + if (numResult != 0) + return numResult; + } else + { + // Compare single characters + if (x[ix] != y[iy]) + return x[ix] + .CompareTo(y[iy]); ix++; iy++; } - return x.Length.CompareTo(y.Length); + // Handle case where one string is exhausted + if (ix < x.Length) + return 1; // x has remaining characters + + if (iy < y.Length) + return -1; // y has remaining characters + + return 0; // both strings exhausted simultaneously } + private static int CompareNumericPortion( + string x, + string y, + ref int ix, + ref int iy) + { + // Skip leading zeros in x + while ((ix < x.Length) && (x[ix] == '0')) + ix++; + + // Skip leading zeros in y + while ((iy < y.Length) && (y[iy] == '0')) + iy++; + + // Count significant digits + var xDigitStart = ix; + var yDigitStart = iy; + + while ((ix < x.Length) && char.IsDigit(x[ix])) + ix++; + + while ((iy < y.Length) && char.IsDigit(y[iy])) + iy++; + + var xDigitCount = ix - xDigitStart; + var yDigitCount = iy - yDigitStart; + + // Compare by digit count first (longer number is larger) + if (xDigitCount != yDigitCount) + return xDigitCount.CompareTo(yDigitCount); + + // Same number of significant digits, compare lexicographically + for (var i = 0; i < xDigitCount; i++) + { + var xDigit = x[xDigitStart + i]; + var yDigit = y[yDigitStart + i]; + + if (xDigit != yDigit) + return xDigit.CompareTo(yDigit); + } + + return 0; // Numbers are equal + } } \ No newline at end of file From cc04b2098c63740a04af0ef1e02cf71458bfd2d7 Mon Sep 17 00:00:00 2001 From: sichii Date: Fri, 29 Aug 2025 23:29:24 -0400 Subject: [PATCH 17/17] cool new stuff --- DALib/Drawing/EpfFile.cs | 24 +++-- DALib/Extensions/SKColorExtensions.cs | 40 ++++++++ DALib/Utility/ImageProcessor.cs | 128 ++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 9 deletions(-) diff --git a/DALib/Drawing/EpfFile.cs b/DALib/Drawing/EpfFile.cs index 4178cc2..e00e1bb 100644 --- a/DALib/Drawing/EpfFile.cs +++ b/DALib/Drawing/EpfFile.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using DALib.Abstractions; using DALib.Data; @@ -248,29 +249,34 @@ public static Palettized FromImages(QuantizerOptions options, IEnumerab public static Palettized FromImages(QuantizerOptions options, params SKImage[] orderedFrames) { ImageProcessor.PreserveNonTransparentBlacks(orderedFrames); - + //ImageProcessor.CropTransparentPixels(orderedFrames); + using var quantized = ImageProcessor.QuantizeMultiple(options, orderedFrames); (var images, var palette) = quantized; + + // Crop all transparent edges and get the top-left offsets + var anchorPoints = ImageProcessor.CropTransparentPixels(images); - var imageWidth = (short)orderedFrames.Select(img => img.Width) - .Max(); + var imageWidth = (short)images.Select(img => img.Width) + .Max(); - var imageHeight = (short)orderedFrames.Select(img => img.Height) - .Max(); + var imageHeight = (short)images.Select(img => img.Height) + .Max(); var epfFile = new EpfFile(imageWidth, imageHeight); for (var i = 0; i < images.Count; i++) { var image = images[i]; + var dims = anchorPoints[i]; epfFile.Add( new EpfFrame { - Top = 0, - Left = 0, - Right = (short)image.Width, - Bottom = (short)image.Height, + Top = (short)dims.Y, + Left = (short)dims.X, + Right = (short)(dims.X + image.Width), + Bottom = (short)(dims.Y + image.Height), Data = image.GetPalettizedPixelData(palette) }); } diff --git a/DALib/Extensions/SKColorExtensions.cs b/DALib/Extensions/SKColorExtensions.cs index 18ab6f0..c6479d4 100644 --- a/DALib/Extensions/SKColorExtensions.cs +++ b/DALib/Extensions/SKColorExtensions.cs @@ -70,6 +70,17 @@ public static float GetLuminance(this SKColor color, float coefficient = 1.0f) return (byte)Math.Clamp(MathF.Round(lumSrgb * 255f * coefficient), 0, 255); } + public static SKColor GetRandomVividColor(Random? random = null) + { + random ??= Random.Shared; + + var hue = (float)(random.NextDouble() * 360.0f); // full hue range + var saturation = 80 + (float)(random.NextDouble() * 20); // 80–100% + var value = 80 + (float)(random.NextDouble() * 20); // 80–100% + + return SKColor.FromHsv(hue, saturation, value); + } + /// /// Calculates the luminance of a color using the provided coefficient. /// @@ -97,6 +108,35 @@ public static bool IsNearBlack(this SKColor color) Blue: <= CONSTANTS.RGB555_COLOR_LOSS_FACTOR }; + /// + /// Generates a random color within a given percent(positive or negative) of the given color + /// + /// + /// + /// + /// + /// + /// + public static SKColor Randomize(this SKColor color, float percent = 0.1f) + { + var random = new Random(); + var halfPercent = percent / 2f; + + var rFactor = 1f + (float)(random.NextDouble() * percent - halfPercent); + var gFactor = 1f + (float)(random.NextDouble() * percent - halfPercent); + var bFactor = 1f + (float)(random.NextDouble() * percent - halfPercent); + + var r = (byte)Math.Clamp(color.Red * rFactor, 0, 255); + var g = (byte)Math.Clamp(color.Green * gFactor, 0, 255); + var b = (byte)Math.Clamp(color.Blue * bFactor, 0, 255); + + return new SKColor( + r, + g, + b, + color.Alpha); + } + /// /// Returns a new SKColor with the alpha set based on the luminance of the color /// diff --git a/DALib/Utility/ImageProcessor.cs b/DALib/Utility/ImageProcessor.cs index cdea563..b801ad8 100644 --- a/DALib/Utility/ImageProcessor.cs +++ b/DALib/Utility/ImageProcessor.cs @@ -96,6 +96,112 @@ public static void PreserveNonTransparentBlacks(IList images) for (var i = 0; i < images.Count; i++) images[i] = PreserveNonTransparentBlacks(images[i]); } + + + /// + /// Crops an image to remove transparent pixels from all edges and returns the top-left offset + /// + /// + /// The image to crop + /// + /// + /// The top-left offset of the cropped content relative to the original image + /// + /// + /// A new image with transparent pixels removed from all edges, or the original image if it's already fully cropped + /// + public static SKImage CropTransparentPixels(SKImage image, out SKPoint topLeft) + { + using var bitmap = SKBitmap.FromImage(image); + + var transparentColor = SKColors.Black; + + var width = bitmap.Width; + var height = bitmap.Height; + + // Find the bounding box of non-transparent pixels + var minX = width; + var minY = height; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var pixel = bitmap.GetPixel(x, y); + + // Check if pixel is not transparent + if (pixel != transparentColor) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + } + + // If no non-transparent pixels found, return a 1x1 transparent image + if ((maxX < minX) || (maxY < minY)) + { + using var emptyBitmap = new SKBitmap(1, 1); + emptyBitmap.SetPixel(0, 0, transparentColor); + + topLeft = new SKPoint(0, 0); + image.Dispose(); + + return SKImage.FromBitmap(emptyBitmap); + } + + // Store the top-left offset + topLeft = new SKPoint(minX, minY); + + // Calculate the cropped dimensions + var croppedWidth = maxX - minX + 1; + var croppedHeight = maxY - minY + 1; + + // If the image is already fully cropped, return the original + if ((minX == 0) && (minY == 0) && (croppedWidth == width) && (croppedHeight == height)) + { + topLeft = new SKPoint(0, 0); + return image; + } + + // Create a new bitmap with the cropped dimensions + using var croppedBitmap = new SKBitmap(croppedWidth, croppedHeight); + + // Extract the subset from the original bitmap + bitmap.ExtractSubset( + croppedBitmap, + new SKRectI(minX, minY, maxX + 1, maxY + 1)); + + var result = SKImage.FromBitmap(croppedBitmap); + + // Dispose the original image since we're returning a new one + image.Dispose(); + + return result; + } + + /// + /// Crops transparent pixels from all edges of each image in the provided list and returns the top-left offsets. + /// + /// + /// The list of images to process, where each image will have its transparent pixels cropped. + /// + /// + /// An array of SKPoint containing the top-left offset for each cropped image + /// + public static SKPoint[] CropTransparentPixels(IList images) + { + var offsets = new SKPoint[images.Count]; + + for (var i = 0; i < images.Count; i++) + images[i] = CropTransparentPixels(images[i], out offsets[i]); + + return offsets; + } /// /// Quantizes the given image using the specified quantizer options. @@ -190,6 +296,28 @@ public static Palettized Quantize(QuantizerOptions options, SKImage ima }; } + /// + /// Merges multiple palettes into a single palette. + /// + /// + /// The collection of palettes to be merged. Each palette is represented as an enumerable of colors. + /// + /// + /// Thrown when the merged palette exceeds the maximum number of colors allowed. + /// + public static Palette MergePalettes(params IEnumerable palettes) + { + var uniqueColors = palettes.SelectMany(p => p) + .Distinct() + .ToList(); + + if (uniqueColors.Count > CONSTANTS.COLORS_PER_PALETTE) + throw new InvalidOperationException("Merged palette has more than 256 colors"); + + return new Palette(uniqueColors); + } + + /// /// Quantizes the given images using the specified quantizer options ///