mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Codepull/3755/head
7 changed files with 1099 additions and 3 deletions
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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."); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue