.NET Decompiler with support for PDB generation, ReadyToRun, Metadata (&more) - cross-platform!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

10 KiB

ImageListStreamer on-disk format

This file is the format reference for 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]. Reader: System.Formats.Nrbf.NrbfDecoder, 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):

si.AddValue("Data", Serialize());

and the round-trip constructor:

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 ByteMessageEnd.

NrbfDecoder parses the type name — it never loads or instantiates ImageListStreamer. Decoder path:

ClassRecord root = NrbfDecoder.DecodeClassRecord(stream);
byte[] data = ((SZArrayRecord<byte>)root.GetArrayRecord("Data")).GetArray();

Layer 2 — MSFt RLE wrapper

From ImageListStreamer.cs:

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."

// 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:

#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."

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