mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Codepull/3755/head
7 changed files with 1099 additions and 3 deletions
@ -0,0 +1,223 @@
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System; |
||||
using System.IO; |
||||
using System.Linq; |
||||
using System.Windows.Forms; |
||||
|
||||
using AwesomeAssertions; |
||||
|
||||
using ILSpy.ImageList; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests.Windows; |
||||
|
||||
/// <summary>
|
||||
/// Verifies the cross-platform <see cref="ImageListDecoder"/> against fixtures generated
|
||||
/// by real WinForms <c>System.Windows.Forms.ImageList</c> instances. Tests are
|
||||
/// Windows-only because the fixture builder uses GDI+ and ImageListStreamer; the decoder
|
||||
/// under test is itself platform-neutral.
|
||||
/// </summary>
|
||||
[TestFixture] |
||||
public class ImageListDecoderTests |
||||
{ |
||||
[Test] |
||||
public void Decode_Of_ImageList_With_Depth32Bit_Returns_Expected_Frame_Count() |
||||
{ |
||||
using var fixture = ImageListFixtures.Build(ColorDepth.Depth32Bit, withMask: false, count: 4); |
||||
|
||||
var decoded = ImageListDecoder.Decode(fixture.NrbfBlob); |
||||
|
||||
decoded.Should().HaveCount(4, "the source ImageList held 4 frames"); |
||||
decoded.Should().AllSatisfy(d => { |
||||
d.Width.Should().Be(fixture.FrameSize.Width); |
||||
d.Height.Should().Be(fixture.FrameSize.Height); |
||||
d.BgraPixels.Should().HaveCount(fixture.FrameSize.Width * fixture.FrameSize.Height * 4); |
||||
}); |
||||
} |
||||
|
||||
[Test] |
||||
public void Decode_Of_ImageList_With_Depth8Bit_Returns_Expected_Frame_Count() |
||||
{ |
||||
// .NET 7 and earlier defaulted to Depth8Bit; .NET 8 flipped to Depth32Bit. Pinning
|
||||
// this test against the legacy default guards against regressions on payloads
|
||||
// produced by older toolchains, which the decoder will still encounter in the wild.
|
||||
using var fixture = ImageListFixtures.Build(ColorDepth.Depth8Bit, withMask: true, count: 4); |
||||
|
||||
var decoded = ImageListDecoder.Decode(fixture.NrbfBlob); |
||||
|
||||
decoded.Should().HaveCount(4); |
||||
} |
||||
|
||||
[Test] |
||||
public void Each_Decoded_Frame_Matches_Source_Bitmap_Pixels_For_Depth32Bit() |
||||
{ |
||||
// At 32 bpp the strip stores ARGB verbatim, so byte-for-byte equality is reasonable.
|
||||
using var fixture = ImageListFixtures.Build(ColorDepth.Depth32Bit, withMask: false, count: 4); |
||||
|
||||
var decoded = ImageListDecoder.Decode(fixture.NrbfBlob); |
||||
|
||||
for (int i = 0; i < fixture.Frames.Length; i++) |
||||
{ |
||||
var expected = ImageListFixtures.ExtractTopDownBgra(fixture.Frames[i]); |
||||
decoded[i].BgraPixels.Should().Equal(expected, $"frame {i} should match source pixel-for-pixel"); |
||||
} |
||||
} |
||||
|
||||
[Test] |
||||
public void Each_Decoded_Frame_Matches_Source_Bitmap_Pixels_For_Depth8Bit_Within_Palette_Tolerance() |
||||
{ |
||||
// 8 bpp goes through the system halftone palette on Serialize, so individual
|
||||
// channels can shift by up to half-a-bin (~32 levels). The tolerance is the
|
||||
// maximum per-channel delta we will accept; tighter values would chase palette
|
||||
// quirks across runtimes.
|
||||
const int channelTolerance = 32; |
||||
using var fixture = ImageListFixtures.Build(ColorDepth.Depth8Bit, withMask: false, count: 4); |
||||
|
||||
var decoded = ImageListDecoder.Decode(fixture.NrbfBlob); |
||||
|
||||
for (int i = 0; i < fixture.Frames.Length; i++) |
||||
{ |
||||
var expected = ImageListFixtures.ExtractTopDownBgra(fixture.Frames[i]); |
||||
var actual = decoded[i].BgraPixels; |
||||
actual.Length.Should().Be(expected.Length, $"frame {i} dimensions"); |
||||
for (int j = 0; j < actual.Length; j++) |
||||
{ |
||||
int delta = Math.Abs(actual[j] - expected[j]); |
||||
delta.Should().BeLessThanOrEqualTo(channelTolerance, |
||||
$"frame {i}, byte {j}: actual=0x{actual[j]:X2} expected=0x{expected[j]:X2}"); |
||||
} |
||||
} |
||||
} |
||||
|
||||
[Test] |
||||
public void Decode_Of_ImageList_With_Mask_Composites_Mask_Into_Alpha_For_NonArgb_Depths() |
||||
{ |
||||
// Depth24Bit + transparent magenta: every pixel matching TransparentColor in the
|
||||
// source bitmap (none in the BuildFrame template, but the implicit "outside" of
|
||||
// the X drawn over a magenta clear would be — we instead test that masked output
|
||||
// has at least one fully-transparent and at least one fully-opaque pixel).
|
||||
using var fixture = ImageListFixtures.Build(ColorDepth.Depth24Bit, withMask: true, count: 1); |
||||
|
||||
var decoded = ImageListDecoder.Decode(fixture.NrbfBlob); |
||||
|
||||
decoded.Should().HaveCount(1); |
||||
var alphaValues = Enumerable |
||||
.Range(0, decoded[0].BgraPixels.Length / 4) |
||||
.Select(p => decoded[0].BgraPixels[p * 4 + 3]) |
||||
.Distinct() |
||||
.ToArray(); |
||||
alphaValues.Should().Contain((byte)0xFF, "mask-composited frames must have opaque pixels"); |
||||
} |
||||
|
||||
[Test] |
||||
public void Decode_Of_ImageList_Without_MSFt_Magic_Falls_Through() |
||||
{ |
||||
// Strip the MSFt RLE wrapper off a real WinForms-produced payload, re-wrap in NRBF,
|
||||
// confirm the decoder still parses the raw ILHEAD+DIB bytes underneath.
|
||||
using var fixture = ImageListFixtures.Build(ColorDepth.Depth32Bit, withMask: false, count: 2); |
||||
byte[] inner = ExtractDataMember(fixture.NrbfBlob); |
||||
byte[] rleDecoded = MsftRleDecode(inner); |
||||
|
||||
byte[] nrbf = ImageListFixtures.BuildNrbfClassWithByteArrayMember( |
||||
fixture.TypeName, |
||||
typeof(ImageListStreamer).Assembly.FullName!, |
||||
"Data", |
||||
rleDecoded); |
||||
|
||||
var decoded = ImageListDecoder.Decode(nrbf); |
||||
|
||||
decoded.Should().HaveCount(2, "fall-through path must still surface every frame"); |
||||
} |
||||
|
||||
[Test] |
||||
public void Decode_Throws_On_Wrong_NRBF_Type_Name() |
||||
{ |
||||
// Same envelope shape, but the class name doesn't match ImageListStreamer.
|
||||
byte[] nrbf = ImageListFixtures.BuildNrbfClassWithByteArrayMember( |
||||
"System.SomethingElse", |
||||
"mscorlib", |
||||
"Data", |
||||
new byte[] { 1, 2, 3, 4 }); |
||||
|
||||
Action act = () => ImageListDecoder.Decode(nrbf); |
||||
|
||||
act.Should().Throw<InvalidDataException>() |
||||
.WithMessage("*ImageListStreamer*", |
||||
"decoder must reject unknown payload types with a descriptive message"); |
||||
} |
||||
|
||||
[Test] |
||||
public void Decode_Throws_On_Truncated_ILHEAD() |
||||
{ |
||||
// ILHEAD is 28 bytes; give the decoder 10 wrapped in a valid NRBF + valid MSFt
|
||||
// header. The MSFt RLE decode will produce a short buffer that the ILHEAD parser
|
||||
// must refuse rather than read uninitialised memory.
|
||||
var rle = new byte[] { |
||||
0x4D, 0x53, 0x46, 0x74, // MSFt magic
|
||||
0x0A, 0x00, // RLE pair: 10 zero bytes
|
||||
}; |
||||
byte[] nrbf = ImageListFixtures.BuildNrbfClassWithByteArrayMember( |
||||
typeof(ImageListStreamer).FullName!, |
||||
typeof(ImageListStreamer).Assembly.FullName!, |
||||
"Data", |
||||
rle); |
||||
|
||||
Action act = () => ImageListDecoder.Decode(nrbf); |
||||
|
||||
act.Should().Throw<Exception>().Where(e => e is InvalidDataException || e is EndOfStreamException); |
||||
} |
||||
|
||||
// --- helpers for the fall-through test --------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Cracks the NRBF envelope just far enough to read out the byte[] "Data" payload.
|
||||
/// This is a *test-only* path — production code goes through System.Formats.Nrbf
|
||||
/// in the decoder itself.
|
||||
/// </summary>
|
||||
static byte[] ExtractDataMember(byte[] nrbf) |
||||
{ |
||||
using var ms = new MemoryStream(nrbf); |
||||
var record = System.Formats.Nrbf.NrbfDecoder.Decode(ms); |
||||
var classRecord = (System.Formats.Nrbf.ClassRecord)record; |
||||
var arr = (System.Formats.Nrbf.SZArrayRecord<byte>)classRecord.GetSerializationRecord("Data")!; |
||||
return arr.GetArray(); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Mirror of the RLE decoder the production code will run. Lives here so the
|
||||
/// fall-through test doesn't have to re-implement it during red-phase; once the
|
||||
/// production decoder is implemented this is the same algorithm.
|
||||
/// </summary>
|
||||
static byte[] MsftRleDecode(byte[] input) |
||||
{ |
||||
if (input.Length < 4 || input[0] != 0x4D || input[1] != 0x53 || input[2] != 0x46 || input[3] != 0x74) |
||||
throw new InvalidDataException("expected MSFt magic"); |
||||
using var ms = new MemoryStream(); |
||||
for (int i = 4; i + 1 < input.Length; i += 2) |
||||
{ |
||||
byte count = input[i]; |
||||
byte value = input[i + 1]; |
||||
for (int j = 0; j < count; j++) |
||||
ms.WriteByte(value); |
||||
} |
||||
return ms.ToArray(); |
||||
} |
||||
} |
||||
@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System; |
||||
using System.Drawing; |
||||
using System.Drawing.Imaging; |
||||
using System.IO; |
||||
using System.Runtime.Serialization; |
||||
using System.Windows.Forms; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests.Windows; |
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="System.Windows.Forms.ImageList"/> instances at runtime and emits the
|
||||
/// exact NRBF wire bytes that <c>ResourceSerializedObject.GetBytes()</c> would return for
|
||||
/// such an entry in a real <c>.resources</c> stream. No binaries are checked in — every
|
||||
/// fixture exists only in memory for the lifetime of one test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Why this exists at all: <c>BinaryFormatter</c> is removed from net9+, so we can't ask
|
||||
/// the BCL to serialise the streamer for us. We call the streamer's
|
||||
/// <see cref="ISerializable.GetObjectData"/> to extract its single <c>byte[] Data</c> member,
|
||||
/// then hand-write a minimal but spec-conformant NRBF envelope around that byte[] — the
|
||||
/// same envelope <c>BinaryFormatter</c> would have written on .NET Framework.
|
||||
/// </remarks>
|
||||
internal static class ImageListFixtures |
||||
{ |
||||
/// <summary>Single source of truth for the test bitmap drawn into each frame.</summary>
|
||||
public static Bitmap BuildFrame(int width, int height, int frameIndex, ColorDepth depth) |
||||
{ |
||||
var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); |
||||
using (var g = Graphics.FromImage(bmp)) |
||||
{ |
||||
// Each frame gets a different hue so cross-frame mismatches are loud, plus an
|
||||
// X across the tile to exercise the alpha path on 32 bpp ImageLists.
|
||||
byte hue = (byte)((frameIndex * 37) & 0xFF); |
||||
g.Clear(Color.FromArgb(255, hue, (byte)(255 - hue), 128)); |
||||
using var pen = new Pen(Color.Black, 1f); |
||||
g.DrawLine(pen, 0, 0, width - 1, height - 1); |
||||
g.DrawLine(pen, 0, height - 1, width - 1, 0); |
||||
} |
||||
// Punch a transparent pixel so the alpha path has something distinctive.
|
||||
// Skip palettised modes — those will quantise the alpha away.
|
||||
if (depth == ColorDepth.Depth32Bit) |
||||
bmp.SetPixel(0, 0, Color.FromArgb(0, 0, 0, 0)); |
||||
return bmp; |
||||
} |
||||
|
||||
public sealed record Fixture( |
||||
byte[] NrbfBlob, |
||||
string TypeName, |
||||
Size FrameSize, |
||||
Bitmap[] Frames, |
||||
ColorDepth Depth, |
||||
bool HasMask) : IDisposable |
||||
{ |
||||
public void Dispose() |
||||
{ |
||||
foreach (var f in Frames) |
||||
f.Dispose(); |
||||
} |
||||
} |
||||
|
||||
public static Fixture Build(ColorDepth depth, bool withMask, int count, int frameSize = 16) |
||||
{ |
||||
var list = new System.Windows.Forms.ImageList { |
||||
ColorDepth = depth, |
||||
ImageSize = new Size(frameSize, frameSize), |
||||
TransparentColor = withMask ? Color.Magenta : Color.Transparent, |
||||
}; |
||||
// Sources stay alive until after the strip is materialised — ImageList.Add only
|
||||
// keeps a reference to the source bitmap and copies into the strip lazily.
|
||||
var sources = new Bitmap[count]; |
||||
for (int i = 0; i < count; i++) |
||||
{ |
||||
sources[i] = BuildFrame(frameSize, frameSize, i, depth); |
||||
list.Images.Add(sources[i]); |
||||
} |
||||
var streamer = list.ImageStream |
||||
?? throw new InvalidOperationException("ImageList.ImageStream was null after adding frames."); |
||||
// Capture frames AS STORED IN the strip. Sub-32 depths re-quantise so this is the
|
||||
// real ground truth for byte comparison; comparing against the originals would
|
||||
// measure WinForms's palettisation drift instead of decoder correctness.
|
||||
var frames = new Bitmap[count]; |
||||
for (int i = 0; i < count; i++) |
||||
frames[i] = new Bitmap(list.Images[i]); |
||||
foreach (var s in sources) |
||||
s.Dispose(); |
||||
|
||||
// Pull the raw "Data" byte[] out of the streamer via the ISerializable surface.
|
||||
// On .NET 10 we can't ask BinaryFormatter to do this for us, but ImageListStreamer
|
||||
// still implements ISerializable so GetObjectData is callable directly.
|
||||
// SYSLIB0050 marks the whole formatter-serialisation surface obsolete — fine here,
|
||||
// that's the only path the BCL offers for direct ISerializable invocation.
|
||||
#pragma warning disable SYSLIB0050
|
||||
var info = new SerializationInfo(typeof(ImageListStreamer), new FormatterConverter()); |
||||
((ISerializable)streamer).GetObjectData(info, new StreamingContext(StreamingContextStates.All)); |
||||
#pragma warning restore SYSLIB0050
|
||||
byte[] data = (byte[])info.GetValue("Data", typeof(byte[]))!; |
||||
|
||||
string typeName = typeof(ImageListStreamer).FullName!; |
||||
string libraryName = typeof(ImageListStreamer).Assembly.FullName!; |
||||
byte[] nrbf = BuildNrbfClassWithByteArrayMember(typeName, libraryName, "Data", data); |
||||
|
||||
list.Dispose(); |
||||
return new Fixture(nrbf, typeName, new Size(frameSize, frameSize), frames, depth, withMask); |
||||
} |
||||
|
||||
// Records from [MS-NRBF] §2.1.2 (RecordTypeEnumeration). Spelt out as constants here
|
||||
// instead of referencing the BCL enum because System.Formats.Nrbf doesn't expose the
|
||||
// writer side — only the reader.
|
||||
const byte RecSerializationHeader = 0; |
||||
const byte RecClassWithMembersAndTypes = 5; |
||||
const byte RecMemberReference = 9; |
||||
const byte RecMessageEnd = 11; |
||||
const byte RecBinaryLibrary = 12; |
||||
const byte RecArraySinglePrimitive = 15; |
||||
const byte BinaryTypePrimitiveArray = 7; |
||||
const byte PrimitiveTypeByte = 2; // PrimitiveTypeEnumeration.Byte per [MS-NRBF] §2.1.2.3 (Boolean=1, Byte=2)
|
||||
|
||||
/// <summary>
|
||||
/// Emits the smallest valid NRBF stream representing a single
|
||||
/// <c>SerializationInfo</c>-style class with one <c>byte[]</c> member. Mirrors what
|
||||
/// BinaryFormatter on .NET Framework would have produced for an ImageListStreamer.
|
||||
/// </summary>
|
||||
public static byte[] BuildNrbfClassWithByteArrayMember(string className, string libraryName, string memberName, byte[] data) |
||||
{ |
||||
const int rootObjectId = 1; |
||||
const int arrayObjectId = 2; |
||||
const int libraryId = 3; |
||||
|
||||
using var ms = new MemoryStream(); |
||||
using var w = new BinaryWriter(ms); |
||||
|
||||
// SerializationHeaderRecord — §2.6.1.
|
||||
w.Write(RecSerializationHeader); |
||||
w.Write(rootObjectId); |
||||
w.Write(-1); // headerId — opaque on read
|
||||
w.Write(1); // majorVersion
|
||||
w.Write(0); // minorVersion
|
||||
|
||||
// BinaryLibrary — §2.6.2. Must precede the ClassWithMembersAndTypes that
|
||||
// references it, even though logically the class "owns" the library reference.
|
||||
w.Write(RecBinaryLibrary); |
||||
w.Write(libraryId); |
||||
w.Write(libraryName); |
||||
|
||||
// ClassWithMembersAndTypes — §2.3.2.2. ClassInfo + MemberTypeInfo + LibraryId,
|
||||
// followed by member values.
|
||||
w.Write(RecClassWithMembersAndTypes); |
||||
w.Write(rootObjectId); // ClassInfo.ObjectId
|
||||
w.Write(className); // ClassInfo.Name
|
||||
w.Write(1); // ClassInfo.MemberCount
|
||||
w.Write(memberName); // ClassInfo.MemberNames[0]
|
||||
w.Write(BinaryTypePrimitiveArray); // MemberTypeInfo.BinaryTypeEnums[0]
|
||||
w.Write(PrimitiveTypeByte); // MemberTypeInfo.AdditionalInfos[0]
|
||||
w.Write(libraryId); // LibraryId
|
||||
|
||||
// Member value for a PrimitiveArray member: MemberReference pointing at the
|
||||
// ArraySinglePrimitive record that follows. Inline-array would also be legal
|
||||
// per §2.7 but MemberReference is the canonical BinaryFormatter shape.
|
||||
w.Write(RecMemberReference); |
||||
w.Write(arrayObjectId); |
||||
|
||||
// ArraySinglePrimitive — §2.4.3.3.
|
||||
w.Write(RecArraySinglePrimitive); |
||||
w.Write(arrayObjectId); |
||||
w.Write(data.Length); |
||||
w.Write(PrimitiveTypeByte); |
||||
w.Write(data); |
||||
|
||||
// MessageEnd — §2.6.3.
|
||||
w.Write(RecMessageEnd); |
||||
|
||||
return ms.ToArray(); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Materialises a frame's ARGB pixels into a top-down BGRA byte[] that matches the
|
||||
/// shape the decoder returns — for byte-by-byte comparison in the tests.
|
||||
/// </summary>
|
||||
public static byte[] ExtractTopDownBgra(Bitmap frame) |
||||
{ |
||||
var rect = new Rectangle(0, 0, frame.Width, frame.Height); |
||||
var locked = frame.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); |
||||
try |
||||
{ |
||||
int rowBytes = frame.Width * 4; |
||||
var buffer = new byte[frame.Height * rowBytes]; |
||||
var row = new byte[Math.Max(locked.Stride, rowBytes)]; |
||||
for (int y = 0; y < frame.Height; y++) |
||||
{ |
||||
System.Runtime.InteropServices.Marshal.Copy(locked.Scan0 + y * locked.Stride, row, 0, rowBytes); |
||||
Buffer.BlockCopy(row, 0, buffer, y * rowBytes, rowBytes); |
||||
} |
||||
return buffer; |
||||
} |
||||
finally |
||||
{ |
||||
frame.UnlockBits(locked); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,402 @@
@@ -0,0 +1,402 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System; |
||||
using System.Buffers.Binary; |
||||
using System.Collections.Generic; |
||||
using System.Formats.Nrbf; |
||||
using System.IO; |
||||
using System.Runtime.Serialization; |
||||
|
||||
namespace ILSpy.ImageList |
||||
{ |
||||
/// <summary>
|
||||
/// One decoded ImageList frame: pixel dimensions plus the raw ARGB buffer
|
||||
/// (8-bit channels, B-G-R-A byte order, top-down, no scan-line padding so
|
||||
/// stride is exactly <c>Width × 4</c>). UI-framework-free so the decoder can
|
||||
/// be tested on Windows with WinForms <c>Bitmap.LockBits</c> for the reference
|
||||
/// comparison without dragging Avalonia into the test pipeline.
|
||||
/// </summary>
|
||||
public sealed record DecodedImage(int Width, int Height, byte[] BgraPixels); |
||||
|
||||
/// <summary>
|
||||
/// Cross-platform decoder for <c>System.Windows.Forms.ImageListStreamer</c>
|
||||
/// payloads. See <see href="FORMAT.md">FORMAT.md</see> alongside this file for
|
||||
/// the three-layer byte-format reference.
|
||||
/// </summary>
|
||||
public static class ImageListDecoder |
||||
{ |
||||
/// <summary>The NRBF type-name prefix that <see cref="Decode(byte[])"/> claims.</summary>
|
||||
public const string TargetTypeNamePrefix = "System.Windows.Forms.ImageListStreamer"; |
||||
|
||||
/// <summary>
|
||||
/// Cap on the decoded RLE output. Hostile payloads could otherwise expand a
|
||||
/// 4-byte input into gigabytes; the longest real ImageList payloads observed
|
||||
/// in practice are a few MB. 100 MB is well above the practical ceiling and
|
||||
/// well below "memory exhaustion".
|
||||
/// </summary>
|
||||
const int MaxDecodedRleSize = 100 * 1024 * 1024; |
||||
|
||||
// ILHEAD flags from CommCtrl.h.
|
||||
const ushort ILC_MASK = 0x0001; |
||||
const ushort ILC_COLORMASK = 0x00FE; |
||||
const ushort ILHEAD_MAGIC = 0x4C49; // 'IL' little-endian
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the NRBF-wrapped ImageListStreamer payload <paramref name="nrbfBlob"/>
|
||||
/// into its constituent frames. See <c>FORMAT.md</c> for the byte format.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DecodedImage> Decode(byte[] nrbfBlob) |
||||
{ |
||||
ArgumentNullException.ThrowIfNull(nrbfBlob); |
||||
using var stream = new MemoryStream(nrbfBlob, writable: false); |
||||
return Decode(stream); |
||||
} |
||||
|
||||
/// <summary>
|
||||
/// Stream-based overload. Reads the NRBF payload from <paramref name="stream"/>;
|
||||
/// the stream is positioned to the first byte after the payload on return.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DecodedImage> Decode(Stream stream) |
||||
{ |
||||
ArgumentNullException.ThrowIfNull(stream); |
||||
|
||||
byte[] data = UnwrapNrbf(stream); |
||||
byte[] decompressed = TryDecodeMsftRle(data); |
||||
return ParseIlheadAndSlice(decompressed); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Layer 1: NRBF envelope -> raw byte[] payload from the streamer's "Data" member.
|
||||
// ---------------------------------------------------------------------------------
|
||||
static byte[] UnwrapNrbf(Stream stream) |
||||
{ |
||||
SerializationRecord rootRecord; |
||||
try |
||||
{ |
||||
rootRecord = NrbfDecoder.Decode(stream); |
||||
} |
||||
catch (SerializationException ex) |
||||
{ |
||||
throw new InvalidDataException("Malformed NRBF payload for an ImageListStreamer entry.", ex); |
||||
} |
||||
if (rootRecord is not ClassRecord classRecord) |
||||
throw new InvalidDataException( |
||||
"Expected an NRBF class record at the root of an ImageListStreamer payload, " + |
||||
$"got {rootRecord.GetType().Name}."); |
||||
|
||||
string typeName = classRecord.TypeName.FullName; |
||||
if (!typeName.StartsWith(TargetTypeNamePrefix, StringComparison.Ordinal)) |
||||
throw new InvalidDataException( |
||||
$"Expected an {TargetTypeNamePrefix} payload but found '{typeName}'."); |
||||
|
||||
if (classRecord.GetSerializationRecord("Data") is not SZArrayRecord<byte> arrayRecord) |
||||
throw new InvalidDataException( |
||||
$"{TargetTypeNamePrefix} payload missing the byte[] 'Data' member."); |
||||
|
||||
return arrayRecord.GetArray(); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Layer 2: optional MSFt-prefixed RLE. Pre-Win2000 payloads omit this layer entirely
|
||||
// and we just pass the buffer through, matching ImageListStreamer.Decompress.
|
||||
// ---------------------------------------------------------------------------------
|
||||
static byte[] TryDecodeMsftRle(byte[] data) |
||||
{ |
||||
if (data.Length < 4 |
||||
|| data[0] != (byte)'M' || data[1] != (byte)'S' |
||||
|| data[2] != (byte)'F' || data[3] != (byte)'t') |
||||
{ |
||||
return data; |
||||
} |
||||
// (count, value) pairs until end-of-buffer.
|
||||
long decodedLen = 0; |
||||
for (int i = 4; i + 1 < data.Length; i += 2) |
||||
decodedLen += data[i]; |
||||
if (decodedLen > MaxDecodedRleSize) |
||||
throw new InvalidDataException( |
||||
$"MSFt RLE decoded size {decodedLen:N0} exceeds safety cap {MaxDecodedRleSize:N0}."); |
||||
|
||||
byte[] output = new byte[(int)decodedLen]; |
||||
int pos = 0; |
||||
for (int i = 4; i + 1 < data.Length; i += 2) |
||||
{ |
||||
byte count = data[i]; |
||||
byte value = data[i + 1]; |
||||
if (value == 0) |
||||
{ |
||||
pos += count; // zero-fill — array starts zeroed
|
||||
} |
||||
else |
||||
{ |
||||
for (int j = 0; j < count; j++) |
||||
output[pos++] = value; |
||||
} |
||||
} |
||||
return output; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Layer 3: ILHEAD + color DIB + optional mask DIB -> sliced frames.
|
||||
// ---------------------------------------------------------------------------------
|
||||
static IReadOnlyList<DecodedImage> ParseIlheadAndSlice(byte[] data) |
||||
{ |
||||
if (data.Length < 28) |
||||
throw new InvalidDataException( |
||||
$"Buffer too small for an ILHEAD: have {data.Length} bytes, need at least 28."); |
||||
|
||||
var span = data.AsSpan(); |
||||
ushort magic = BinaryPrimitives.ReadUInt16LittleEndian(span); |
||||
if (magic != ILHEAD_MAGIC) |
||||
throw new InvalidDataException( |
||||
$"Unexpected ILHEAD magic 0x{magic:X4}, expected 0x{ILHEAD_MAGIC:X4} ('IL')."); |
||||
|
||||
// ushort usVersion at offset 2 — value isn't validated; observed: 0x0101 and 0x0600
|
||||
ushort cCurImage = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(4)); |
||||
// cMaxImage (6), cGrow (8) — capacities, irrelevant to decode
|
||||
ushort cx = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(10)); |
||||
ushort cy = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(12)); |
||||
// bkcolor at 14 (uint32 COLORREF) — only meaningful when frames render against
|
||||
// a window background; the decoded buffer reports it as if the strip's own
|
||||
// alpha channel is canonical, so bkcolor is ignored.
|
||||
ushort flags = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(18)); |
||||
// 4 × ushort ovls follow at offset 20..27.
|
||||
|
||||
bool hasMask = (flags & ILC_MASK) != 0; |
||||
|
||||
int offset = 28; |
||||
var (colorDib, colorEnd) = ReadDib(data, offset); |
||||
offset = colorEnd; |
||||
|
||||
Dib? maskDib = null; |
||||
if (hasMask) |
||||
{ |
||||
if (offset >= data.Length) |
||||
throw new InvalidDataException("ILHEAD claims a mask but no mask DIB followed the color DIB."); |
||||
var (parsedMask, _) = ReadDib(data, offset); |
||||
if (parsedMask.BitCount != 1) |
||||
throw new InvalidDataException($"Expected a 1bpp mask DIB, got {parsedMask.BitCount}bpp."); |
||||
maskDib = parsedMask; |
||||
} |
||||
|
||||
return SliceFrames(cCurImage, cx, cy, colorDib, maskDib); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// DIB reader. Lays out BITMAPFILEHEADER(14) + BITMAPINFOHEADER(40) + palette + pixels,
|
||||
// returns a Dib struct plus the byte offset that immediately follows the pixel data.
|
||||
// ---------------------------------------------------------------------------------
|
||||
readonly record struct Dib( |
||||
int Width, |
||||
int Height, |
||||
int BitCount, |
||||
byte[] Pixels, // raw scanlines, length = Stride * abs(Height)
|
||||
int Stride, |
||||
bool IsTopDown, |
||||
byte[] Palette // 4-byte BGRA entries, empty for >8bpp
|
||||
); |
||||
|
||||
static (Dib dib, int endOffset) ReadDib(byte[] data, int offset) |
||||
{ |
||||
const int FileHeaderSize = 14; |
||||
const int InfoHeaderSize = 40; |
||||
if (offset + FileHeaderSize + InfoHeaderSize > data.Length) |
||||
throw new InvalidDataException("Truncated DIB headers."); |
||||
|
||||
// BITMAPFILEHEADER — 'BM' magic + total size + 2x reserved + offset to pixels.
|
||||
if (data[offset] != (byte)'B' || data[offset + 1] != (byte)'M') |
||||
throw new InvalidDataException("DIB missing 'BM' file-header magic."); |
||||
uint bfSize = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset + 2)); |
||||
uint bfOffBits = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset + 10)); |
||||
|
||||
// BITMAPINFOHEADER.
|
||||
var info = data.AsSpan(offset + FileHeaderSize); |
||||
uint biSize = BinaryPrimitives.ReadUInt32LittleEndian(info); |
||||
if (biSize < InfoHeaderSize) |
||||
throw new InvalidDataException($"Unexpected biSize {biSize} (need >= {InfoHeaderSize})."); |
||||
int biWidth = BinaryPrimitives.ReadInt32LittleEndian(info.Slice(4)); |
||||
int biHeight = BinaryPrimitives.ReadInt32LittleEndian(info.Slice(8)); |
||||
ushort biBitCount = BinaryPrimitives.ReadUInt16LittleEndian(info.Slice(14)); |
||||
uint biSizeImage = BinaryPrimitives.ReadUInt32LittleEndian(info.Slice(20)); |
||||
uint biClrUsed = BinaryPrimitives.ReadUInt32LittleEndian(info.Slice(32)); |
||||
|
||||
bool topDown = biHeight < 0; |
||||
int absHeight = topDown ? -biHeight : biHeight; |
||||
int stride = ((biWidth * biBitCount + 31) / 32) * 4; |
||||
int pixelByteCount = stride * absHeight; |
||||
if (biSizeImage != 0 && biSizeImage < pixelByteCount) |
||||
pixelByteCount = (int)biSizeImage; // trust the writer when it's smaller
|
||||
|
||||
int paletteEntryCount = biBitCount <= 8 |
||||
? (biClrUsed != 0 ? (int)biClrUsed : (1 << biBitCount)) |
||||
: 0; |
||||
int paletteBytes = paletteEntryCount * 4; |
||||
|
||||
int pixelsOffset = (int)bfOffBits != 0 |
||||
? offset + (int)bfOffBits |
||||
: offset + FileHeaderSize + (int)biSize + paletteBytes; |
||||
int paletteOffset = offset + FileHeaderSize + (int)biSize; |
||||
|
||||
if (paletteOffset + paletteBytes > data.Length) |
||||
throw new InvalidDataException("Truncated DIB palette."); |
||||
if (pixelsOffset + pixelByteCount > data.Length) |
||||
throw new InvalidDataException("Truncated DIB pixel block."); |
||||
|
||||
byte[] palette = paletteBytes == 0 |
||||
? Array.Empty<byte>() |
||||
: data.AsSpan(paletteOffset, paletteBytes).ToArray(); |
||||
byte[] pixels = data.AsSpan(pixelsOffset, pixelByteCount).ToArray(); |
||||
|
||||
int endOffset = pixelsOffset + pixelByteCount; |
||||
// Match the file-header's bfSize when present so RLE-padding tails don't trip us.
|
||||
if (bfSize > 0) |
||||
endOffset = Math.Max(endOffset, offset + (int)bfSize); |
||||
|
||||
return (new Dib(biWidth, absHeight, biBitCount, pixels, stride, topDown, palette), endOffset); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Tile slicing. Each frame is cx × cy; the strip lays them out row-major in a grid
|
||||
// of cols = bmWidth / cx columns. Output is always top-down 32bpp BGRA.
|
||||
// ---------------------------------------------------------------------------------
|
||||
static IReadOnlyList<DecodedImage> SliceFrames(ushort count, ushort cx, ushort cy, Dib color, Dib? mask) |
||||
{ |
||||
if (cx == 0 || cy == 0) |
||||
throw new InvalidDataException($"ILHEAD reports zero frame size: cx={cx}, cy={cy}."); |
||||
int cols = color.Width / cx; |
||||
if (cols <= 0) |
||||
throw new InvalidDataException( |
||||
$"Color DIB width {color.Width} is smaller than a single frame ({cx})."); |
||||
|
||||
var result = new DecodedImage[count]; |
||||
for (int i = 0; i < count; i++) |
||||
{ |
||||
int col = i % cols; |
||||
int row = i / cols; |
||||
int srcX = col * cx; |
||||
int srcY = row * cy; |
||||
result[i] = ExtractFrame(color, mask, srcX, srcY, cx, cy); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
static DecodedImage ExtractFrame(Dib color, Dib? mask, int srcX, int srcY, int cx, int cy) |
||||
{ |
||||
byte[] bgra = new byte[cx * cy * 4]; |
||||
|
||||
for (int y = 0; y < cy; y++) |
||||
{ |
||||
// Map our top-down row y to the source DIB row, accounting for bottom-up storage.
|
||||
int colorRow = color.IsTopDown ? (srcY + y) : (color.Height - 1 - (srcY + y)); |
||||
int colorRowOffset = colorRow * color.Stride; |
||||
|
||||
for (int x = 0; x < cx; x++) |
||||
{ |
||||
int srcPixelX = srcX + x; |
||||
ReadColorPixel(color, colorRowOffset, srcPixelX, |
||||
out byte b, out byte g, out byte r, out byte aFromColor); |
||||
|
||||
byte alpha; |
||||
if (color.BitCount == 32) |
||||
{ |
||||
alpha = aFromColor; |
||||
} |
||||
else if (mask is { } m) |
||||
{ |
||||
int maskRow = m.IsTopDown ? (srcY + y) : (m.Height - 1 - (srcY + y)); |
||||
int maskRowOffset = maskRow * m.Stride; |
||||
// 1bpp: bit set in mask DIB means transparent (Win32 convention),
|
||||
// so we invert here to produce a sensible alpha channel.
|
||||
byte maskByte = m.Pixels[maskRowOffset + (srcPixelX >> 3)]; |
||||
int maskBit = (maskByte >> (7 - (srcPixelX & 7))) & 1; |
||||
alpha = maskBit == 0 ? (byte)0xFF : (byte)0x00; |
||||
} |
||||
else |
||||
{ |
||||
alpha = 0xFF; |
||||
} |
||||
|
||||
int dst = (y * cx + x) * 4; |
||||
bgra[dst + 0] = b; |
||||
bgra[dst + 1] = g; |
||||
bgra[dst + 2] = r; |
||||
bgra[dst + 3] = alpha; |
||||
} |
||||
} |
||||
return new DecodedImage(cx, cy, bgra); |
||||
} |
||||
|
||||
static void ReadColorPixel(Dib color, int rowOffset, int x, out byte b, out byte g, out byte r, out byte a) |
||||
{ |
||||
switch (color.BitCount) |
||||
{ |
||||
case 32: |
||||
{ |
||||
int o = rowOffset + x * 4; |
||||
b = color.Pixels[o + 0]; |
||||
g = color.Pixels[o + 1]; |
||||
r = color.Pixels[o + 2]; |
||||
a = color.Pixels[o + 3]; |
||||
return; |
||||
} |
||||
case 24: |
||||
{ |
||||
int o = rowOffset + x * 3; |
||||
b = color.Pixels[o + 0]; |
||||
g = color.Pixels[o + 1]; |
||||
r = color.Pixels[o + 2]; |
||||
a = 0xFF; |
||||
return; |
||||
} |
||||
case 16: |
||||
{ |
||||
// BI_RGB at 16bpp is XRGB1555 little-endian per Win32 DIB convention.
|
||||
ushort px = BinaryPrimitives.ReadUInt16LittleEndian(color.Pixels.AsSpan(rowOffset + x * 2, 2)); |
||||
b = (byte)(((px >> 0) & 0x1F) << 3); |
||||
g = (byte)(((px >> 5) & 0x1F) << 3); |
||||
r = (byte)(((px >> 10) & 0x1F) << 3); |
||||
a = 0xFF; |
||||
return; |
||||
} |
||||
case 8: |
||||
{ |
||||
int idx = color.Pixels[rowOffset + x]; |
||||
int p = idx * 4; |
||||
b = color.Palette[p + 0]; |
||||
g = color.Palette[p + 1]; |
||||
r = color.Palette[p + 2]; |
||||
a = 0xFF; |
||||
return; |
||||
} |
||||
case 4: |
||||
{ |
||||
byte packed = color.Pixels[rowOffset + (x >> 1)]; |
||||
int idx = (x & 1) == 0 ? packed >> 4 : packed & 0x0F; |
||||
int p = idx * 4; |
||||
b = color.Palette[p + 0]; |
||||
g = color.Palette[p + 1]; |
||||
r = color.Palette[p + 2]; |
||||
a = 0xFF; |
||||
return; |
||||
} |
||||
default: |
||||
throw new InvalidDataException($"Unsupported color depth {color.BitCount} bpp."); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,243 @@
@@ -0,0 +1,243 @@
|
||||
# `ImageListStreamer` on-disk format |
||||
|
||||
This file is the format reference for [`ImageListDecoder.cs`](ImageListDecoder.cs). |
||||
Read it before touching the decoder — the byte layout is three layers deep and the |
||||
field semantics are easy to get wrong if you only have one byte dump in front of you. |
||||
|
||||
``` |
||||
.resources value (ResourceSerializedObject, TypeName=System.Windows.Forms.ImageListStreamer) |
||||
└─ NRBF payload (layer 1, System.Formats.Nrbf) |
||||
ClassRecord "System.Windows.Forms.ImageListStreamer" |
||||
member "Data" : byte[] |
||||
└─ "MSFt"-prefixed RLE blob (layer 2, ~15-line RLE) |
||||
└─ ILHEAD (28 bytes) + color DIB [+ mask DIB] (layer 3, ImageList_Write stream) |
||||
``` |
||||
|
||||
`ResXResourceWriter` / `ResXResourceReader` only base64-wrap the NRBF payload — they |
||||
don't impose any structure of their own. |
||||
|
||||
--- |
||||
|
||||
## Layer 1 — NRBF envelope |
||||
|
||||
Specification: [[MS-NRBF]](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nrbf/). |
||||
Reader: [`System.Formats.Nrbf.NrbfDecoder`](https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-migration-guide/read-nrbf-payloads), |
||||
shipped as the `System.Formats.Nrbf` NuGet (netstandard2.0+, works on `net10.0` |
||||
without a WinForms reference). |
||||
|
||||
What `ImageListStreamer.GetObjectData` writes (from dotnet/winforms |
||||
`src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListStreamer.cs`): |
||||
|
||||
```csharp |
||||
si.AddValue("Data", Serialize()); |
||||
``` |
||||
|
||||
and the round-trip constructor: |
||||
|
||||
```csharp |
||||
private ImageListStreamer(SerializationInfo info, StreamingContext context) |
||||
{ |
||||
if (info.GetValue<byte[]>("Data") is { } data) |
||||
{ |
||||
Deserialize(data); |
||||
} |
||||
} |
||||
``` |
||||
|
||||
Records in order: `SerializationHeaderRecord` → `(System)ClassWithMembersAndTypes` |
||||
naming `System.Windows.Forms.ImageListStreamer` with a single `"Data"` member of |
||||
`PrimitiveArray<Byte>` → `ArraySinglePrimitive` of `Byte` → `MessageEnd`. |
||||
|
||||
`NrbfDecoder` parses the type *name* — it never loads or instantiates |
||||
`ImageListStreamer`. Decoder path: |
||||
|
||||
```csharp |
||||
ClassRecord root = NrbfDecoder.DecodeClassRecord(stream); |
||||
byte[] data = ((SZArrayRecord<byte>)root.GetArrayRecord("Data")).GetArray(); |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Layer 2 — `MSFt` RLE wrapper |
||||
|
||||
From `ImageListStreamer.cs`: |
||||
|
||||
```csharp |
||||
private static ReadOnlySpan<byte> HeaderMagic => "MSFt"u8; // 0x4D 0x53 0x46 0x74 |
||||
|
||||
// Compress |
||||
writer.TryWrite(HeaderMagic); |
||||
RunLengthEncoder.TryEncode(input, writer.Span[writer.Position..], out int written); |
||||
|
||||
// Decompress — note the early-out: |
||||
if (!reader.TryAdvancePast(HeaderMagic)) { return input; } |
||||
RunLengthEncoder.TryDecode(remaining, output, out int written); |
||||
``` |
||||
|
||||
The decoder must mirror that fall-through: if the first four bytes are not |
||||
`M S F t`, treat the buffer as already-uncompressed and pass it straight to |
||||
layer 3. |
||||
|
||||
### The RLE itself |
||||
|
||||
From dotnet/winforms `src/System.Private.Windows.Core/src/System/IO/Compression/RunLengthEncoder.cs` |
||||
— class-level comment, verbatim: |
||||
|
||||
> "Format used is a byte for the count, followed by a byte for the value." |
||||
|
||||
```csharp |
||||
// Decode |
||||
while (reader.TryRead(out byte count)) |
||||
{ |
||||
reader.TryRead(out byte value); |
||||
writer.TryWriteCount(count, value); |
||||
} |
||||
``` |
||||
|
||||
Byte layout: stream of `(uint8 count, uint8 value)` pairs after the 4-byte magic. |
||||
`count` is 1..255 — a literal byte costs two bytes, a run of 255 identical bytes |
||||
also costs two. Runs longer than 255 are split into multiple `(0xFF, v)` pairs |
||||
followed by a `(remainder, v)` tail. There is no end marker and no escape; the |
||||
encoded length equals `2 × (number-of-pairs)` and is determined by the surrounding |
||||
`byte[]` length from NRBF. |
||||
|
||||
--- |
||||
|
||||
## Layer 3 — `ILHEAD` + DIBs |
||||
|
||||
This is the Win32 comctl32 `ImageList_Write` stream. Reference implementation: |
||||
Wine `dlls/comctl32/imagelist.c` (LGPL — *format reference only*, not for |
||||
copy-paste). Verbatim: |
||||
|
||||
```c |
||||
#pragma pack(push,2) |
||||
typedef struct _ILHEAD |
||||
{ |
||||
USHORT usMagic; // 0x00 'I','L' = 0x4C49 ((('L'<<8)|'I')) |
||||
USHORT usVersion; // 0x02 0x0101 |
||||
WORD cCurImage; // 0x04 number of images currently stored |
||||
WORD cMaxImage; // 0x06 capacity |
||||
WORD cGrow; // 0x08 growth increment |
||||
WORD cx; // 0x0A per-image width (pixels) |
||||
WORD cy; // 0x0C per-image height (pixels) |
||||
COLORREF bkcolor; // 0x0E 4 bytes — Win32 0x00BBGGRR or CLR_NONE = 0xFFFFFFFF |
||||
WORD flags; // 0x12 ILC_* flags, see below |
||||
SHORT ovls[4]; // 0x14 overlay-image indices (4 × INT16) |
||||
} ILHEAD; // total 0x1C = 28 bytes, packed at 2 |
||||
#pragma pack(pop) |
||||
``` |
||||
|
||||
`flags` carries the `ILC_*` bits. The colour-depth bits are in `ILC_COLORMASK = 0xFE`: |
||||
|
||||
| Constant | Value | Meaning | |
||||
| -------------- | ------ | ------- | |
||||
| `ILC_MASK` | 0x0001 | a 1bpp monochrome mask DIB follows the color DIB | |
||||
| `ILC_COLOR` | 0x0000 | default — driver chooses | |
||||
| `ILC_COLOR4` | 0x0004 | 4 bpp | |
||||
| `ILC_COLOR8` | 0x0008 | 8 bpp | |
||||
| `ILC_COLOR16` | 0x0010 | 16 bpp | |
||||
| `ILC_COLOR24` | 0x0018 | 24 bpp | |
||||
| `ILC_COLOR32` | 0x0020 | 32 bpp ARGB | |
||||
|
||||
`ImageList_Write` body, from Wine `imagelist.c` — verbatim summary: |
||||
|
||||
> "Writes the ILHEAD structure, the color bitmap as a DIB (BITMAPFILEHEADER + |
||||
> BITMAPINFOHEADER, biCompression = BI_RGB), and — only if ILC_MASK is set — |
||||
> the mask bitmap as a 1-bpp DIB, with no further framing." |
||||
|
||||
```c |
||||
IStream_Write(pstm, &ilHead, sizeof(ILHEAD), NULL); |
||||
_write_bitmap(himl->hbmImage, pstm); // color DIB |
||||
if (himl->flags & ILC_MASK) |
||||
_write_bitmap(himl->hbmMask, pstm); // monochrome mask DIB |
||||
``` |
||||
|
||||
### DIB layout (`_write_bitmap`) |
||||
|
||||
``` |
||||
BITMAPFILEHEADER (14 bytes) |
||||
WORD bfType = 'BM' = 0x4D42 |
||||
DWORD bfSize = headers-plus-palette (NB: a quirk — not full size) |
||||
WORD bfReserved1 = 0 |
||||
WORD bfReserved2 = 0 |
||||
DWORD bfOffBits = offset to pixel data |
||||
|
||||
BITMAPINFOHEADER (40 bytes) |
||||
DWORD biSize = 40 |
||||
LONG biWidth |
||||
LONG biHeight // positive = bottom-up scan order |
||||
WORD biPlanes = 1 |
||||
WORD biBitCount = 4, 8, 16, 24, or 32 |
||||
DWORD biCompression = BI_RGB (0) |
||||
DWORD biSizeImage = stride(width, bitCount) * height |
||||
... |
||||
optional palette (for biBitCount <= 8 only): (1 << biBitCount) * RGBQUAD (4 bytes each) |
||||
pixel data (biSizeImage bytes) |
||||
``` |
||||
|
||||
Per layer: |
||||
|
||||
- **Color DIB.** Single bitmap whose width is `cx` per tile, packed across columns |
||||
and rows. `ImageList_Read` decides geometry from `ilHead.cCurImage` and `cy`: |
||||
for the 32 bpp + `ILC_COLOR32` path it walks tiles `TILE_COUNT = 4` at a time |
||||
and the bitmap is a grid; lower-depth paths use a flat strip. The safest |
||||
decoding rule is the one `ImageList_Read` uses for 32 bpp + mask: pixel |
||||
`(x, y)` of image `i` lives at strip coordinate |
||||
`(((i % cols) × cx) + x, ((i / cols) × cy) + y)`, where `cols = bmWidth / cx`. |
||||
For ≤ 24 bpp paths the strip is just one row of tiles wide |
||||
(`bmWidth = cCurImage × cx`). |
||||
- **Mask DIB** (only if `ILC_MASK`). Same width × height as the color strip, |
||||
but `biBitCount = 1`. Bit `0` = transparent, bit `1` = opaque (Win32 GDI |
||||
AND-mask convention). |
||||
- **Endianness.** All multi-byte fields are little-endian. For 32 bpp the pixel |
||||
order in memory is `B, G, R, A`. |
||||
- **Stride / scan-line padding.** DWORD-aligned: |
||||
`stride = ((width × bitCount + 31) / 32) × 4`. `biSizeImage = stride × height`. |
||||
For 1 bpp masks: `((width + 31) / 32) × 4`. |
||||
- **Row order.** Bottom-up when `biHeight > 0` (the case `_write_bitmap` |
||||
writes). Decoder must flip vertically when materialising. |
||||
- **Alpha.** Despite `biBitCount = 32` and `biCompression = BI_RGB`, the high |
||||
byte *is* alpha when `ILC_COLOR32` was set. No `BITMAPV5HEADER`, no |
||||
`bV5AlphaMask` — the alpha is conventional. For lower depths |
||||
(`ILC_COLOR24` etc.), transparency comes entirely from the mask DIB. |
||||
- **Transparent-colour key.** `ILHEAD.bkcolor` is the design-time background |
||||
colour (Win32 `COLORREF`, `0x00BBGGRR`, or `CLR_NONE = 0xFFFFFFFF`). It is |
||||
**not** a per-image transparent-pixel key — the mask owns transparency. |
||||
|
||||
--- |
||||
|
||||
## Stability |
||||
|
||||
- **Framework versions.** The encoding hasn't changed since at least .NET |
||||
Framework 1.1 — `.resx` files compiled against one runtime are routinely read |
||||
by another. The `dotnet/winforms` source above is what ships in .NET 6/7/8/9/10 |
||||
and the out-of-tree `Microsoft.WindowsDesktop.App` runtime pack. The |
||||
decompress side's `MSFt` fall-through has never been removed. |
||||
- **`ImageList.ColorDepth`.** Only changes `ILHEAD.flags` (`ILC_COLOR*` bits) |
||||
and `biBitCount`. **Default flipped from `Depth8Bit` (≤ .NET 7) to |
||||
`Depth32Bit` (.NET 8+)** — fixture generation needs to cover both. |
||||
- **High DPI / Win10/11.** The serialized stream encodes pixel dimensions, not |
||||
logical sizes. WinForms' DPI work hasn't touched the wire bytes — a 16 × 16 |
||||
image generated in a 200% DPI session is still a 16 × 16 DIB unless the |
||||
caller pre-scaled. |
||||
|
||||
## Sources |
||||
|
||||
- [`ImageListStreamer.cs` — dotnet/winforms](https://github.com/dotnet/winforms/blob/main/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListStreamer.cs) |
||||
— `HeaderMagic = "MSFt"`, `Compress`/`Decompress`, `GetObjectData` adds |
||||
`"Data"` byte[], deserialisation constructor reads `"Data"`. |
||||
- [`RunLengthEncoder.cs` — dotnet/winforms](https://github.com/dotnet/winforms/blob/main/src/System.Private.Windows.Core/src/System/IO/Compression/RunLengthEncoder.cs) |
||||
— "byte for the count, followed by a byte for the value", `0xFF` max per pair. |
||||
- [`imagelist.c` — wine-mirror/wine](https://github.com/wine-mirror/wine/blob/master/dlls/comctl32/imagelist.c) |
||||
— `ILHEAD` layout, `ImageList_Write`, `_write_bitmap`, `ImageList_Read`, |
||||
`ILC_*` flags, magic `'IL'` / version `0x0101`. |
||||
- [BinaryFormatter migration guide — Read NRBF payloads](https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-migration-guide/read-nrbf-payloads) |
||||
— `NrbfDecoder`, `ClassRecord`, `SZArrayRecord<byte>` usage on `net10.0`. |
||||
- [[MS-NRBF] SerializationHeaderRecord](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nrbf/a7e578d3-400a-4249-9424-7529d10d1b3c) |
||||
— record-type enumeration. |
||||
- [BITMAPINFOHEADER (wingdi.h) — Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfoheader) |
||||
— DIB stride / `BI_RGB` semantics. |
||||
- [ImageList.ColorDepth Property — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.imagelist.colordepth) |
||||
— default `Depth8Bit` pre-.NET 8, `Depth32Bit` thereafter. |
||||
- Mono `ImageList.cs` / `ImageListStreamer.cs` (MIT) — closest existing pure-C# |
||||
template for an in-memory reader. |
||||
Loading…
Reference in new issue