From a0f80a934c33b6579ab61975509220198beb5724 Mon Sep 17 00:00:00 2001 From: Drake53 <49623303+Drake53@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:17:52 +0100 Subject: [PATCH 1/2] Implement BLP1 encoder with AI. --- NuGet.config | 1 + README.md | 2 +- .../Blp1EncodingOptions.cs | 47 +++ src/War3Net.Drawing.Blp/BlpEncoder.cs | 298 ++++++++++++++++++ .../JpegBufferInputReader.cs | 64 ++++ .../War3Net.Drawing.Blp.csproj | 4 +- .../BlpEncoderRoundtripTest.cs | 156 +++++++++ .../War3Net.Drawing.Blp.Tests.csproj | 4 + 8 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 src/War3Net.Drawing.Blp/Blp1EncodingOptions.cs create mode 100644 src/War3Net.Drawing.Blp/BlpEncoder.cs create mode 100644 src/War3Net.Drawing.Blp/JpegBufferInputReader.cs create mode 100644 tests/War3Net.Drawing.Blp.Tests/BlpEncoderRoundtripTest.cs diff --git a/NuGet.config b/NuGet.config index b9dffb44..63c99eab 100644 --- a/NuGet.config +++ b/NuGet.config @@ -26,6 +26,7 @@ + diff --git a/README.md b/README.md index f1e17f4c..7495a41b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ War3Net is a collection of libraries for Warcraft III modding. | [War3Net.CodeAnalysis.Jass] | War3Net.CodeAnalysis.Jass is a library for parsing and rendering JASS source files. | [![VCodeJass]][PCodeJass] | | [War3Net.CodeAnalysis.Transpilers]| Transpiles JASS source code to C# or lua. | [![VCodeTrans]][PCodeTrans] | | [War3Net.Common] | Contains some methods used by several other War3Net projects. | [![VCommon]][PCommon] | -| [War3Net.Drawing.Blp] | War3Net.Drawing.Blp is a library for reading files with the ".blp" extension. | [![VBlp]][PBlp] | +| [War3Net.Drawing.Blp] | War3Net.Drawing.Blp is a library for reading and writing files with the ".blp" extension. | [![VBlp]][PBlp] | | [War3Net.IO.Casc] | Class library for opening CASC archives. | *Coming soon* | | [War3Net.IO.Compression] | Decompression and compression algorithms for compression methods commonly used in MPQ archives. | [![VCompress]][PCompress] | | [War3Net.IO.Mpq] | Class library for opening and creating MPQ files. | [![VMpq]][PMpq] | diff --git a/src/War3Net.Drawing.Blp/Blp1EncodingOptions.cs b/src/War3Net.Drawing.Blp/Blp1EncodingOptions.cs new file mode 100644 index 00000000..02c46a8f --- /dev/null +++ b/src/War3Net.Drawing.Blp/Blp1EncodingOptions.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------------ +// +// Licensed under the MIT license. +// See the LICENSE file in the project root for more information. +// +// ------------------------------------------------------------------------------ + +namespace War3Net.Drawing.Blp +{ + /// + /// Options for encoding a BLP1 file with JPEG compression. + /// + public class Blp1EncodingOptions + { + /// + /// Initializes a new instance of the class. + /// + public Blp1EncodingOptions() + { + GenerateMipmaps = true; + MipmapLevels = 0; + JpegQuality = 85; + } + + /// + /// Gets or sets a value indicating whether to generate mipmaps automatically. + /// + public bool GenerateMipmaps { get; set; } + + /// + /// Gets or sets the number of mipmap levels to generate (1-16). + /// Only used if is . + /// Set to 0 to generate all mipmaps. + /// + public int MipmapLevels { get; set; } + + /// + /// Gets or sets the JPEG quality (1-100). + /// + public int JpegQuality { get; set; } + + /// + /// Gets or sets the extra flags field (team colors/alpha info). + /// + public uint ExtraFlags { get; set; } + } +} \ No newline at end of file diff --git a/src/War3Net.Drawing.Blp/BlpEncoder.cs b/src/War3Net.Drawing.Blp/BlpEncoder.cs new file mode 100644 index 00000000..350f2a66 --- /dev/null +++ b/src/War3Net.Drawing.Blp/BlpEncoder.cs @@ -0,0 +1,298 @@ +// ------------------------------------------------------------------------------ +// +// Licensed under the MIT license. +// See the LICENSE file in the project root for more information. +// +// ------------------------------------------------------------------------------ + +using System; +using System.IO; +using System.Text; + +using JpegLibrary; + +namespace War3Net.Drawing.Blp +{ + /// + /// Encoder for creating BLP1 files with JPEG compression and BGRA channels. + /// BLP1 uses a non-standard 4-channel JPEG format (BGRA) instead of the typical 3-channel RGB. + /// The encoder stores normal (non-inverted) pixel values in the JPEG stream. + /// Decoding behavior is platform-specific: Windows WPF inverts after decoding, JpegLibrary returns raw values. + /// + public sealed class BlpEncoder + { + private readonly Blp1EncodingOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// Encoding options. + public BlpEncoder(Blp1EncodingOptions? options = null) + { + _options = options ?? new Blp1EncodingOptions(); + } + + /// + /// Encodes BGRA pixel data to a BLP1 JPEG file. + /// The encoder stores normal (non-inverted) pixel values using non-standard 4-channel JPEG (BGRA). + /// + /// The stream to write the BLP1 data to. + /// Width of the image. + /// Height of the image. + /// BGRA pixel data (4 bytes per pixel). + public void Encode(Stream stream, int width, int height, byte[] bgra) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (bgra is null) + { + throw new ArgumentNullException(nameof(bgra)); + } + + if (bgra.Length != width * height * 4) + { + throw new ArgumentException("BGRA data length does not match width * height * 4", nameof(bgra)); + } + + if (width <= 0 || height <= 0) + { + throw new ArgumentException("Width and height must be positive"); + } + + // Generate mipmaps + var mipmaps = GenerateMipmaps(width, height, bgra); + + // Encode each mipmap to JPEG + var jpegMipmaps = new byte[mipmaps.Length][]; + for (var i = 0; i < mipmaps.Length; i++) + { + var mipWidth = Math.Max(1, width >> i); + var mipHeight = Math.Max(1, height >> i); + jpegMipmaps[i] = EncodeJpeg(mipmaps[i], mipWidth, mipHeight); + } + + using (var writer = new BinaryWriter(stream, Encoding.ASCII, true)) + { + // Write header + WriteHeader(writer, width, height, jpegMipmaps.Length); + + // Calculate and write mipmap offsets and sizes + var (offsets, sizes) = CalculateMipmapOffsetsAndSizes(jpegMipmaps); + WriteMipmapInfo(writer, offsets, sizes); + + // Write JPEG header size (0 = not using shared header) + writer.Write(0u); + + // Write JPEG data for each mipmap + foreach (var jpegData in jpegMipmaps) + { + writer.Write(jpegData); + } + } + } + + private byte[][] GenerateMipmaps(int width, int height, byte[] bgra) + { + if (!_options.GenerateMipmaps) + { + return new[] { bgra }; + } + + // Calculate number of mipmap levels + var maxLevels = CalculateMaxMipmapLevels(width, height); + var levels = _options.MipmapLevels > 0 + ? Math.Min(_options.MipmapLevels, maxLevels) + : maxLevels; + + var mipmaps = new byte[levels][]; + mipmaps[0] = bgra; + + // Generate each mipmap level + for (var i = 1; i < levels; i++) + { + var prevWidth = Math.Max(1, width >> (i - 1)); + var prevHeight = Math.Max(1, height >> (i - 1)); + var currWidth = Math.Max(1, width >> i); + var currHeight = Math.Max(1, height >> i); + + mipmaps[i] = GenerateMipmap(mipmaps[i - 1], prevWidth, prevHeight, currWidth, currHeight); + } + + return mipmaps; + } + + private byte[] GenerateMipmap(byte[] source, int srcWidth, int srcHeight, int dstWidth, int dstHeight) + { + var dest = new byte[dstWidth * dstHeight * 4]; + + // Simple box filter downsampling + for (var y = 0; y < dstHeight; y++) + { + for (var x = 0; x < dstWidth; x++) + { + var srcX = x * 2; + var srcY = y * 2; + + // Sample 2x2 pixels and average them + int b = 0, g = 0, r = 0, a = 0; + var count = 0; + + for (var dy = 0; dy < 2 && srcY + dy < srcHeight; dy++) + { + for (var dx = 0; dx < 2 && srcX + dx < srcWidth; dx++) + { + var srcIdx = ((srcY + dy) * srcWidth + (srcX + dx)) * 4; + b += source[srcIdx + 0]; + g += source[srcIdx + 1]; + r += source[srcIdx + 2]; + a += source[srcIdx + 3]; + count++; + } + } + + var dstIdx = (y * dstWidth + x) * 4; + dest[dstIdx + 0] = (byte)(b / count); + dest[dstIdx + 1] = (byte)(g / count); + dest[dstIdx + 2] = (byte)(r / count); + dest[dstIdx + 3] = (byte)(a / count); + } + } + + return dest; + } + + private int CalculateMaxMipmapLevels(int width, int height) + { + var levels = 1; + var minDim = Math.Min(width, height); + + while (minDim > 1 && levels < 16) + { + minDim >>= 1; + levels++; + } + + return levels; + } + + private byte[] EncodeJpeg(byte[] bgra, int width, int height) + { + // BLP1 uses non-standard 4-channel JPEG (BGRA format instead of RGB/CMYK) + // Pixel values are stored normally (NOT inverted) in the JPEG stream + // The decoder handles platform-specific interpretation: + // - Windows WPF: interprets as RGB/RGBA, inverts after decoding + // - JpegLibrary: reads raw 4-channel data directly + var encoder = new JpegEncoder(); + + // Set quantization tables for all 4 components + // Each component needs its own quantization table with unique identifier + var qTable0 = JpegStandardQuantizationTable.ScaleByQuality( + JpegStandardQuantizationTable.GetLuminanceTable(JpegElementPrecision.Precision8Bit, 0), + _options.JpegQuality); + var qTable1 = JpegStandardQuantizationTable.ScaleByQuality( + JpegStandardQuantizationTable.GetChrominanceTable(JpegElementPrecision.Precision8Bit, 1), + _options.JpegQuality); + var qTable2 = JpegStandardQuantizationTable.ScaleByQuality( + JpegStandardQuantizationTable.GetChrominanceTable(JpegElementPrecision.Precision8Bit, 2), + _options.JpegQuality); + var qTable3 = JpegStandardQuantizationTable.ScaleByQuality( + JpegStandardQuantizationTable.GetChrominanceTable(JpegElementPrecision.Precision8Bit, 3), + _options.JpegQuality); + + encoder.SetQuantizationTable(qTable0); + encoder.SetQuantizationTable(qTable1); + encoder.SetQuantizationTable(qTable2); + encoder.SetQuantizationTable(qTable3); + + // Set Huffman tables (we'll reuse the same tables for all components) + encoder.SetHuffmanTable(true, 0, JpegStandardHuffmanEncodingTable.GetLuminanceDCTable()); + encoder.SetHuffmanTable(false, 0, JpegStandardHuffmanEncodingTable.GetLuminanceACTable()); + encoder.SetHuffmanTable(true, 1, JpegStandardHuffmanEncodingTable.GetChrominanceDCTable()); + encoder.SetHuffmanTable(false, 1, JpegStandardHuffmanEncodingTable.GetChrominanceACTable()); + encoder.SetHuffmanTable(true, 2, JpegStandardHuffmanEncodingTable.GetChrominanceDCTable()); + encoder.SetHuffmanTable(false, 2, JpegStandardHuffmanEncodingTable.GetChrominanceACTable()); + encoder.SetHuffmanTable(true, 3, JpegStandardHuffmanEncodingTable.GetChrominanceDCTable()); + encoder.SetHuffmanTable(false, 3, JpegStandardHuffmanEncodingTable.GetChrominanceACTable()); + + // Add 4 components (BGRA - non-standard for JPEG!) + // Parameters: componentId, quantTableId, dcTableId, acTableId, hSampling, vSampling + encoder.AddComponent(1, 0, 0, 0, 1, 1); // Blue component + encoder.AddComponent(2, 1, 1, 1, 1, 1); // Green component + encoder.AddComponent(3, 2, 2, 2, 1, 1); // Red component + encoder.AddComponent(4, 3, 3, 3, 1, 1); // Alpha component + + // Set input reader with original (non-inverted) pixel data + encoder.SetInputReader(new JpegBufferInputReader(width, height, 4, bgra)); + + // Encode to buffer + var writer = new System.Buffers.ArrayBufferWriter(); + encoder.SetOutput(writer); + encoder.Encode(); + + return writer.WrittenSpan.ToArray(); + } + + private void WriteHeader(BinaryWriter writer, int width, int height, int mipmapCount) + { + // Magic number - BLP1 + writer.Write((int)FileFormatVersion.BLP1); + + // Content type (0=JPEG) + writer.Write((int)FileContent.JPG); + + // Alpha depth (0 for JPEG format, even though we encode BGRA) + writer.Write(0u); + + // Width and height + writer.Write(width); + writer.Write(height); + + // Extra flags + writer.Write(_options.ExtraFlags); + + // Has mipmaps + writer.Write(mipmapCount > 1 ? 1u : 0u); + } + + private static (uint[] offsets, uint[] sizes) CalculateMipmapOffsetsAndSizes(byte[][] jpegMipmaps) + { + var offsets = new uint[16]; + var sizes = new uint[16]; + + // Header structure: + // 0x00-0x1B: BLP1 header (28 bytes) + // 0x1C-0x5B: Mipmap offsets (64 bytes) + // 0x5C-0x9B: Mipmap sizes (64 bytes) + // 0x9C-0x9F: JPEG header size field (4 bytes) + // 0xA0+: JPEG data starts here + uint currentOffset = 0x9C + 4; // After all header data and JPEG header size field + + for (var i = 0; i < jpegMipmaps.Length; i++) + { + offsets[i] = currentOffset; + sizes[i] = (uint)jpegMipmaps[i].Length; + currentOffset += sizes[i]; + } + + return (offsets, sizes); + } + + private void WriteMipmapInfo(BinaryWriter writer, uint[] offsets, uint[] sizes) + { + // Write 16 mipmap offsets + for (var i = 0; i < 16; i++) + { + writer.Write(offsets[i]); + } + + // Write 16 mipmap sizes + for (var i = 0; i < 16; i++) + { + writer.Write(sizes[i]); + } + } + } +} \ No newline at end of file diff --git a/src/War3Net.Drawing.Blp/JpegBufferInputReader.cs b/src/War3Net.Drawing.Blp/JpegBufferInputReader.cs new file mode 100644 index 00000000..914bb952 --- /dev/null +++ b/src/War3Net.Drawing.Blp/JpegBufferInputReader.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------------ +// +// Licensed under the MIT license. +// See the LICENSE file in the project root for more information. +// +// ------------------------------------------------------------------------------ + +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using JpegLibrary; + +namespace War3Net.Drawing.Blp +{ + /// + /// Input reader for JPEG encoding from a byte buffer. + /// + internal sealed class JpegBufferInputReader : JpegBlockInputReader + { + private readonly int _width; + private readonly int _height; + private readonly int _componentCount; + private readonly Memory _buffer; + + public JpegBufferInputReader(int width, int height, int componentCount, Memory buffer) + { + _width = width; + _height = height; + _componentCount = componentCount; + _buffer = buffer; + } + + public override int Width => _width; + + public override int Height => _height; + + public override void ReadBlock(ref short blockRef, int componentIndex, int x, int y) + { + var width = _width; + var componentCount = _componentCount; + + ref var sourceRef = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(_buffer.Span)); + + var blockWidth = Math.Min(width - x, 8); + var blockHeight = Math.Min(_height - y, 8); + + if (blockWidth != 8 || blockHeight != 8) + { + Unsafe.As(ref blockRef) = default; + } + + for (var offsetY = 0; offsetY < blockHeight; offsetY++) + { + var sourceRowOffset = ((y + offsetY) * width) + x; + ref var destinationRowRef = ref Unsafe.Add(ref blockRef, offsetY * 8); + for (var offsetX = 0; offsetX < blockWidth; offsetX++) + { + Unsafe.Add(ref destinationRowRef, offsetX) = Unsafe.Add(ref sourceRef, ((sourceRowOffset + offsetX) * componentCount) + componentIndex); + } + } + } + } +} \ No newline at end of file diff --git a/src/War3Net.Drawing.Blp/War3Net.Drawing.Blp.csproj b/src/War3Net.Drawing.Blp/War3Net.Drawing.Blp.csproj index 8bf46a1f..7d00c5a8 100644 --- a/src/War3Net.Drawing.Blp/War3Net.Drawing.Blp.csproj +++ b/src/War3Net.Drawing.Blp/War3Net.Drawing.Blp.csproj @@ -7,7 +7,7 @@ - War3Net.Drawing.Blp is a library for reading files with the ".blp" extension. + War3Net.Drawing.Blp is a library for reading and writing files with the ".blp" extension. blp;warcraft3 @@ -15,7 +15,7 @@ true - + diff --git a/tests/War3Net.Drawing.Blp.Tests/BlpEncoderRoundtripTest.cs b/tests/War3Net.Drawing.Blp.Tests/BlpEncoderRoundtripTest.cs new file mode 100644 index 00000000..c862b9ba --- /dev/null +++ b/tests/War3Net.Drawing.Blp.Tests/BlpEncoderRoundtripTest.cs @@ -0,0 +1,156 @@ +// ------------------------------------------------------------------------------ +// +// Licensed under the MIT license. +// See the LICENSE file in the project root for more information. +// +// ------------------------------------------------------------------------------ + +using System; +using System.IO; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +using War3Net.TestTools.UnitTesting; + +namespace War3Net.Drawing.Blp.Tests +{ + [TestClass] + public class BlpEncoderRoundtripTest + { + /// + /// Performs a roundtrip test: PNG -> BLP -> PNG for all mipmaps. + /// Uses VillageFallStonePath.png as input, encodes it to BLP1 JPEG, + /// then decodes all mipmap levels and saves them as separate PNG files. + /// + [TestMethod] + public void TestRoundtrip_VillageFallStonePath() + { + // Load the test image + var inputPngPath = TestDataProvider.GetPath("Blp/VillageFallStonePath.png"); + + if (!File.Exists(inputPngPath)) + { + Assert.Inconclusive($"Test image not found: {inputPngPath}"); + return; + } + + // Load PNG + var bgra = LoadPngAsBgra(inputPngPath, out var width, out var height); + Console.WriteLine($"Loaded PNG: {width}x{height} from {inputPngPath}"); + + // Create output directory + var outputDir = Path.Combine(Path.GetTempPath(), "BlpRoundtripTest_VillageFallStonePath"); + Directory.CreateDirectory(outputDir); + + try + { + // Save copy of original for comparison + var originalCopyPath = Path.Combine(outputDir, "original.png"); + SaveAsPng(bgra, width, height, originalCopyPath); + Console.WriteLine($"Saved original copy: {originalCopyPath}"); + + // Encode to BLP + var blpPath = Path.Combine(outputDir, "encoded.blp"); + + // Use default options + var options = new Blp1EncodingOptions(); + + var encoder = new BlpEncoder(options); + using (var blpStream = File.Create(blpPath)) + { + encoder.Encode(blpStream, width, height, bgra); + } + + Console.WriteLine($"Encoded to BLP: {blpPath}"); + + // Decode BLP and save all mipmaps as PNG + using (var blpStream = File.OpenRead(blpPath)) + { + using var blpFile = new BlpFile(blpStream); + + Console.WriteLine($"BLP contains {blpFile.MipMapCount} mipmap levels"); + Console.WriteLine($"Base dimensions: {blpFile.Width}x{blpFile.Height}"); + + // Decode and save each mipmap + for (var level = 0; level < blpFile.MipMapCount; level++) + { + var pixels = blpFile.GetPixels(level, out var mipWidth, out var mipHeight, bgra: true); + var mipPngPath = Path.Combine(outputDir, $"mipmap_{level}_{mipWidth}x{mipHeight}.png"); + + SaveAsPng(pixels, mipWidth, mipHeight, mipPngPath); + Console.WriteLine($" Saved mipmap {level}: {mipPngPath} ({mipWidth}x{mipHeight})"); + + // Verify dimensions + var expectedWidth = Math.Max(1, width >> level); + var expectedHeight = Math.Max(1, height >> level); + Assert.AreEqual(expectedWidth, mipWidth, $"Mipmap {level} width mismatch"); + Assert.AreEqual(expectedHeight, mipHeight, $"Mipmap {level} height mismatch"); + + // For level 0, compare with original (allowing for JPEG compression artifacts) + if (level == 0) + { + var differences = 0; + var maxDiff = 0; + long totalDiff = 0; + + for (var i = 0; i < bgra.Length; i++) + { + var diff = Math.Abs(bgra[i] - pixels[i]); + if (diff > 0) + { + differences++; + totalDiff += diff; + maxDiff = Math.Max(maxDiff, diff); + } + } + + var avgDiff = differences > 0 ? (double)totalDiff / differences : 0; + Console.WriteLine($" Comparison with original:"); + Console.WriteLine($" Pixels different: {differences} / {bgra.Length} ({100.0 * differences / bgra.Length:F2}%)"); + Console.WriteLine($" Max difference: {maxDiff}"); + Console.WriteLine($" Avg difference: {avgDiff:F2}"); + + // Assert that images are reasonably similar (JPEG is lossy) + Assert.IsTrue(maxDiff < 50, $"Max pixel difference too high: {maxDiff}"); + } + } + } + + Console.WriteLine(); + Console.WriteLine($"Roundtrip test completed successfully!"); + Console.WriteLine($"Output directory: {outputDir}"); + } + finally + { + // Note: Not deleting output directory so results can be inspected + Console.WriteLine(); + Console.WriteLine($"Test files kept in: {outputDir}"); + } + } + + private static void SaveAsPng(byte[] bgra, int width, int height, string path) + { + using (var image = Image.LoadPixelData(bgra, width, height)) + { + image.SaveAsPng(path); + } + } + + private static byte[] LoadPngAsBgra(string path, out int width, out int height) + { + using (var image = Image.Load(path)) + { + width = image.Width; + height = image.Height; + + var bgra = new byte[width * height * 4]; + image.CopyPixelDataTo(bgra); + + return bgra; + } + } + } +} \ No newline at end of file diff --git a/tests/War3Net.Drawing.Blp.Tests/War3Net.Drawing.Blp.Tests.csproj b/tests/War3Net.Drawing.Blp.Tests/War3Net.Drawing.Blp.Tests.csproj index 8739e6a3..9bdfd75f 100644 --- a/tests/War3Net.Drawing.Blp.Tests/War3Net.Drawing.Blp.Tests.csproj +++ b/tests/War3Net.Drawing.Blp.Tests/War3Net.Drawing.Blp.Tests.csproj @@ -5,6 +5,10 @@ net$(NETCoreAppMaximumVersion);net$(NETCoreAppMaximumVersion)-windows + + + + From df53b4b3b295afce15ef76b1995c9524c791b1fd Mon Sep 17 00:00:00 2001 From: Drake53 <49623303+Drake53@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:34:14 +0100 Subject: [PATCH 2/2] Update package version --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6b76cede..399e843a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -40,7 +40,7 @@ 5.8.0 0.1.0 5.8.0 - 5.8.0 + 5.9.0 0.1.0 5.8.0 5.8.1