doc/ImageListFormat.md
ImageListStreamer on-disk formatThis 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.
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 Byte → MessageEnd.
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();
MSFt RLE wrapperFrom 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.
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.
ILHEAD + DIBsThis 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
_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:
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).ILC_MASK). Same width × height as the color strip,
but biBitCount = 1. Bit 0 = transparent, bit 1 = opaque (Win32 GDI
AND-mask convention).B, G, R, A.stride = ((width × bitCount + 31) / 32) × 4. biSizeImage = stride × height.
For 1 bpp masks: ((width + 31) / 32) × 4.biHeight > 0 (the case _write_bitmap
writes). Decoder must flip vertically when materialising.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.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..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.ImageListStreamer.cs — dotnet/winforms
— HeaderMagic = "MSFt", Compress/Decompress, GetObjectData adds
"Data" byte[], deserialisation constructor reads "Data".RunLengthEncoder.cs — dotnet/winforms
— "byte for the count, followed by a byte for the value", 0xFF max per pair.imagelist.c — wine-mirror/wine
— ILHEAD layout, ImageList_Write, _write_bitmap, ImageList_Read,
ILC_* flags, magic 'IL' / version 0x0101.NrbfDecoder, ClassRecord, SZArrayRecord<byte> usage on net10.0.BI_RGB semantics.Depth8Bit pre-.NET 8, Depth32Bit thereafter.ImageList.cs / ImageListStreamer.cs (MIT) — closest existing pure-C#
template for an in-memory reader.