diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5fb957777..76d55e5ca 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -74,6 +74,7 @@
+
diff --git a/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj b/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj
index 3512f74fe..cf34ed841 100644
--- a/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj
+++ b/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj
@@ -14,10 +14,12 @@
ICSharpCode.ILSpy.Tests.Windows
true
-
+
false
- false
+ true
+
+
diff --git a/ILSpy/Util/ImageListDecoder.cs b/ILSpy/Util/ImageListDecoder.cs
new file mode 100644
index 000000000..daa80d1a9
--- /dev/null
+++ b/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
+{
+ ///
+ /// 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 Width × 4). UI-framework-free so the decoder can
+ /// be tested on Windows with WinForms Bitmap.LockBits for the reference
+ /// comparison without dragging Avalonia into the test pipeline.
+ ///
+ public sealed record DecodedImage(int Width, int Height, byte[] BgraPixels);
+
+ ///
+ /// Cross-platform decoder for System.Windows.Forms.ImageListStreamer
+ /// payloads. See FORMAT.md alongside this file for
+ /// the three-layer byte-format reference.
+ ///
+ public static class ImageListDecoder
+ {
+ /// The NRBF type-name prefix that claims.
+ public const string TargetTypeNamePrefix = "System.Windows.Forms.ImageListStreamer";
+
+ ///
+ /// 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".
+ ///
+ 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
+
+ ///
+ /// Decodes the NRBF-wrapped ImageListStreamer payload
+ /// into its constituent frames. See FORMAT.md for the byte format.
+ ///
+ public static IReadOnlyList Decode(byte[] nrbfBlob)
+ {
+ ArgumentNullException.ThrowIfNull(nrbfBlob);
+ using var stream = new MemoryStream(nrbfBlob, writable: false);
+ return Decode(stream);
+ }
+
+ ///
+ /// Stream-based overload. Reads the NRBF payload from ;
+ /// the stream is positioned to the first byte after the payload on return.
+ ///
+ public static IReadOnlyList 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 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 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()
+ : 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 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.");
+ }
+ }
+ }
+}
diff --git a/doc/ImageListFormat.md b/doc/ImageListFormat.md
new file mode 100644
index 000000000..bd966ec6b
--- /dev/null
+++ b/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("Data") is { } data)
+ {
+ Deserialize(data);
+ }
+}
+```
+
+Records in order: `SerializationHeaderRecord` → `(System)ClassWithMembersAndTypes`
+naming `System.Windows.Forms.ImageListStreamer` with a single `"Data"` member of
+`PrimitiveArray` → `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)root.GetArrayRecord("Data")).GetArray();
+```
+
+---
+
+## Layer 2 — `MSFt` RLE wrapper
+
+From `ImageListStreamer.cs`:
+
+```csharp
+private static ReadOnlySpan 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` 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.