Browse Source

Cross-platform ImageList decoder + FORMAT.md

Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
a9a8f6477a
  1. 1
      Directory.Packages.props
  2. 8
      ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj
  3. 223
      ILSpy.Tests.Windows/ImageList/ImageListDecoderTests.cs
  4. 218
      ILSpy.Tests.Windows/ImageList/ImageListFixtures.cs
  5. 7
      ILSpy/ILSpy.csproj
  6. 402
      ILSpy/Util/ImageListDecoder.cs
  7. 243
      doc/ImageListFormat.md

1
Directory.Packages.props

@ -74,6 +74,7 @@
<PackageVersion Include="System.Memory" Version="4.6.3" /> <PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="System.Reflection.Metadata" Version="$(NetPackageVersion)" /> <PackageVersion Include="System.Reflection.Metadata" Version="$(NetPackageVersion)" />
<PackageVersion Include="System.Resources.Extensions" Version="$(NetPackageVersion)" /> <PackageVersion Include="System.Resources.Extensions" Version="$(NetPackageVersion)" />
<PackageVersion Include="System.Formats.Nrbf" Version="$(NetPackageVersion)" />
<PackageVersion Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" /> <PackageVersion Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="$(NetPackageVersion)" /> <PackageVersion Include="System.Security.Cryptography.Pkcs" Version="$(NetPackageVersion)" />
<PackageVersion Include="TomsToolbox.Composition.MicrosoftExtensions" Version="$(TomsToolboxVersion)" /> <PackageVersion Include="TomsToolbox.Composition.MicrosoftExtensions" Version="$(TomsToolboxVersion)" />

8
ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj

@ -14,10 +14,12 @@
<RootNamespace>ICSharpCode.ILSpy.Tests.Windows</RootNamespace> <RootNamespace>ICSharpCode.ILSpy.Tests.Windows</RootNamespace>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- Avalonia tests don't need WPF / WinForms reference assemblies even on the <!-- Avalonia tests don't need WPF on the -windows TFM. WinForms IS needed
-windows TFM. Suppress so the project doesn't pull in unused frameworks. --> though — the ImageList decoder tests use real
System.Windows.Forms.ImageList to generate reference fixtures at runtime,
then compare against the Avalonia decoder's output pixel-by-pixel. -->
<UseWPF>false</UseWPF> <UseWPF>false</UseWPF>
<UseWindowsForms>false</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<!-- Strong-named with the shared snk so ICSharpCode.ILSpyX can grant <!-- Strong-named with the shared snk so ICSharpCode.ILSpyX can grant
InternalsVisibleTo for the analyzer-library tests, mirroring the main InternalsVisibleTo for the analyzer-library tests, mirroring the main

223
ILSpy.Tests.Windows/ImageList/ImageListDecoderTests.cs

@ -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();
}
}

218
ILSpy.Tests.Windows/ImageList/ImageListFixtures.cs

@ -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);
}
}
}

7
ILSpy/ILSpy.csproj

@ -67,6 +67,13 @@
<PackageReference Include="System.Composition.Hosting" /> <PackageReference Include="System.Composition.Hosting" />
<PackageReference Include="System.Composition.TypedParts" /> <PackageReference Include="System.Composition.TypedParts" />
<!-- NRBF decoder for the ImageList resource viewer — first-party Microsoft MIT
package, netstandard2.0+, parses BinaryFormatter-shape payloads without
instantiating the contained types. Lets us decode
System.Windows.Forms.ImageListStreamer blobs on Linux/macOS without
referencing WinForms. -->
<PackageReference Include="System.Formats.Nrbf" />
<!-- CLI parsing --> <!-- CLI parsing -->
<PackageReference Include="McMaster.Extensions.CommandLineUtils" /> <PackageReference Include="McMaster.Extensions.CommandLineUtils" />
</ItemGroup> </ItemGroup>

402
ILSpy/Util/ImageListDecoder.cs

@ -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.");
}
}
}
}

243
doc/ImageListFormat.md

@ -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…
Cancel
Save