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.