diff --git a/Compression.Core/Dictionary/Lzw/NuLzwCodec.cs b/Compression.Core/Dictionary/Lzw/NuLzwCodec.cs new file mode 100644 index 000000000..41fb48589 --- /dev/null +++ b/Compression.Core/Dictionary/Lzw/NuLzwCodec.cs @@ -0,0 +1,505 @@ +using System.Buffers.Binary; +using Compression.Registry; + +namespace Compression.Core.Dictionary.Lzw; + +/// NuFX/ShrinkIt LZW dialect. +public enum NuLzwVariant { + /// Original ProDOS ShrinkIt LZW/1: dictionary resets for every 4096-byte chunk and the stream carries CRC-16/XMODEM. + Lzw1, + /// GS/ShrinkIt LZW/2: dictionary may persist between 4096-byte chunks and integrity is supplied by the NuFX thread header. + Lzw2, +} + +/// +/// Apple II NuFX/ShrinkIt RLE + LZW codec. +/// +/// +/// The native stream has no expanded-length field. Callers therefore provide the logical +/// expanded length when decoding; the codec trims the zero-filled tail of the final 4096-byte +/// chunk. LZW/1 includes a CRC-16/XMODEM over the padded chunks, while LZW/2 deliberately does +/// not because NuFX record version 3 stores the uncompressed CRC in the thread header. +/// +public static class NuLzwCodec { + private const int ChunkSize = 4096; + private const int ClearCode = 0x100; + private const int FirstCode = 0x101; + private const int LastCode = 0x0FFD; + private const int TableSize = 4096; + private const byte DefaultVolume = 254; + private const byte DefaultDelimiter = 0xDB; + + /// Compresses a native ShrinkIt LZW/1 or LZW/2 stream. + public static byte[] Compress( + ReadOnlySpan data, + NuLzwVariant variant, + byte volumeNumber = DefaultVolume, + byte rleDelimiter = DefaultDelimiter + ) { + using var output = new MemoryStream(); + var encoder = new EncoderState(); + ushort crc = 0; + + if (variant == NuLzwVariant.Lzw1) { + output.WriteByte(0); + output.WriteByte(0); + } + output.WriteByte(volumeNumber); + output.WriteByte(rleDelimiter); + + if (data.IsEmpty) + return output.ToArray(); + + var chunk = new byte[ChunkSize]; + for (var sourceOffset = 0; sourceOffset < data.Length; sourceOffset += ChunkSize) { + chunk.AsSpan().Clear(); + var logicalLength = Math.Min(ChunkSize, data.Length - sourceOffset); + data.Slice(sourceOffset, logicalLength).CopyTo(chunk); + + if (variant == NuLzwVariant.Lzw1) + crc = Crc16Xmodem(chunk, crc); + + var rle = CompressRle(chunk, rleDelimiter); + var rleLength = Math.Min(rle.Length, ChunkSize); + var lzwSource = rle.Length < ChunkSize ? rle : chunk; + var lzw = CompressLzw(lzwSource, encoder); + + if (variant == NuLzwVariant.Lzw2) { + if (lzw.Length + 2 < rle.Length) { + WriteUInt16(output, (ushort)(rleLength | 0x8000)); + WriteUInt16(output, checked((ushort)(lzw.Length + 4))); + output.Write(lzw); + } else if (rle.Length < ChunkSize) { + WriteUInt16(output, checked((ushort)rle.Length)); + output.Write(rle); + encoder.Reset(); + } else { + WriteUInt16(output, ChunkSize); + output.Write(chunk); + encoder.Reset(); + } + } else { + WriteUInt16(output, checked((ushort)rleLength)); + if (lzw.Length < rle.Length) { + output.WriteByte(1); + output.Write(lzw); + } else if (rle.Length < ChunkSize) { + output.WriteByte(0); + output.Write(rle); + } else { + output.WriteByte(0); + output.Write(chunk); + } + encoder.Reset(); + } + } + + var result = output.ToArray(); + if (variant == NuLzwVariant.Lzw1) + BinaryPrimitives.WriteUInt16LittleEndian(result, crc); + return result; + } + + /// Expands a native ShrinkIt stream to exactly logical bytes. + public static byte[] Decompress(ReadOnlySpan data, NuLzwVariant variant, int expandedLength) { + ArgumentOutOfRangeException.ThrowIfNegative(expandedLength); + var minimumHeader = variant == NuLzwVariant.Lzw1 ? 4 : 2; + if (data.Length < minimumHeader) + throw new InvalidDataException("NuLZW stream is shorter than its header."); + + var sourceOffset = 0; + ushort storedCrc = 0; + if (variant == NuLzwVariant.Lzw1) { + storedCrc = BinaryPrimitives.ReadUInt16LittleEndian(data); + sourceOffset += 2; + } + + _ = data[sourceOffset++]; // 5.25-inch volume number; transport metadata only. + var delimiter = data[sourceOffset++]; + if (expandedLength == 0) + return []; + + using var result = new MemoryStream(expandedLength); + var decoder = new DecoderState(); + ushort crc = 0; + + while (result.Length < expandedLength) { + if (sourceOffset + 2 > data.Length) + throw new InvalidDataException("NuLZW stream ended before the next chunk header."); + + var postRle = BinaryPrimitives.ReadUInt16LittleEndian(data[sourceOffset..]); + sourceOffset += 2; + var lzwUsed = false; + + if (variant == NuLzwVariant.Lzw2) { + lzwUsed = (postRle & 0x8000) != 0; + postRle &= 0x7FFF; + if (lzwUsed) { + if (sourceOffset + 2 > data.Length) + throw new InvalidDataException("NuLZW/2 stream ended in an LZW chunk header."); + // This word is a recovery hint, not a framing boundary. Some historical + // Macintosh-created ShrinkIt archives stored it byte-swapped or otherwise wrong. + // Decode until the declared expanded output has been produced and advance by the + // number of bits actually consumed, matching ShrinkIt/NuFX compatibility practice. + _ = BinaryPrimitives.ReadUInt16LittleEndian(data[sourceOffset..]); + sourceOffset += 2; + } + } else { + if (sourceOffset >= data.Length) + throw new InvalidDataException("NuLZW/1 stream ended before its LZW-use flag."); + lzwUsed = data[sourceOffset++] != 0; + } + + if (postRle > ChunkSize) + throw new InvalidDataException($"NuLZW chunk declares an invalid post-RLE length of {postRle}."); + + byte[] rleBytes; + if (lzwUsed) { + if (variant == NuLzwVariant.Lzw1) + decoder.Reset(); + var decoded = ExpandLzw(data[sourceOffset..], postRle, decoder); + rleBytes = decoded.Data; + sourceOffset += decoded.BytesConsumed; + } else { + if (variant == NuLzwVariant.Lzw2) + decoder.Reset(); + if (sourceOffset + postRle > data.Length) + throw new InvalidDataException("NuLZW stream ended inside an RLE/raw chunk."); + rleBytes = data.Slice(sourceOffset, postRle).ToArray(); + sourceOffset += postRle; + } + + var expandedChunk = ExpandRle(rleBytes, postRle, delimiter); + if (variant == NuLzwVariant.Lzw1) + crc = Crc16Xmodem(expandedChunk, crc); + + var copyLength = Math.Min(ChunkSize, expandedLength - checked((int)result.Length)); + result.Write(expandedChunk, 0, copyLength); + } + + if (variant == NuLzwVariant.Lzw1 && crc != storedCrc) + throw new InvalidDataException($"NuLZW/1 CRC mismatch: calculated 0x{crc:X4}, stored 0x{storedCrc:X4}."); + return result.ToArray(); + } + + /// Computes CRC-16/XMODEM (poly 0x1021, refin=false, refout=false) from an arbitrary seed. + public static ushort Crc16Xmodem(ReadOnlySpan data, ushort seed = 0) { + var crc = seed; + foreach (var value in data) { + crc ^= (ushort)(value << 8); + for (var bit = 0; bit < 8; bit++) + crc = (ushort)((crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1); + } + return crc; + } + + private static byte[] CompressRle(ReadOnlySpan source, byte delimiter) { + using var output = new MemoryStream(ChunkSize + 8); + var offset = 0; + while (offset < source.Length) { + var value = source[offset++]; + var count = 1; + while (offset < source.Length && source[offset] == value && count < 256) { + count++; + offset++; + } + + if (count > 3 || value == delimiter) { + output.WriteByte(delimiter); + output.WriteByte(value); + output.WriteByte((byte)(count - 1)); + } else { + for (var i = 0; i < count; i++) + output.WriteByte(value); + } + + if (output.Length >= ChunkSize) + return source.ToArray(); + } + return output.ToArray(); + } + + private static byte[] ExpandRle(ReadOnlySpan source, int postRleLength, byte delimiter) { + if (postRleLength == ChunkSize) { + if (source.Length < ChunkSize) + throw new InvalidDataException("NuLZW raw chunk is truncated."); + return source[..ChunkSize].ToArray(); + } + + var output = new byte[ChunkSize]; + var src = 0; + var dst = 0; + while (src < postRleLength) { + if (src >= source.Length) + throw new InvalidDataException("NuLZW RLE chunk is truncated."); + var value = source[src++]; + if (value == delimiter) { + if (src + 2 > source.Length || src + 2 > postRleLength) + throw new InvalidDataException("NuLZW RLE escape is truncated."); + value = source[src++]; + var count = source[src++] + 1; + if (dst + count > output.Length) + throw new InvalidDataException("NuLZW RLE expansion exceeds one 4096-byte chunk."); + output.AsSpan(dst, count).Fill(value); + dst += count; + } else { + if (dst >= output.Length) + throw new InvalidDataException("NuLZW RLE expansion exceeds one 4096-byte chunk."); + output[dst++] = value; + } + } + + if (src != postRleLength || dst != ChunkSize) + throw new InvalidDataException($"NuLZW RLE chunk expanded to {dst} bytes instead of {ChunkSize}."); + return output; + } + + private static byte[] CompressLzw(ReadOnlySpan source, EncoderState state) { + if (source.IsEmpty) + return []; + + var writer = new LsbBitWriter(); + if (state.NeedInitialClear) { + writer.Write(ClearCode, state.BitWidth); + state.Reset(); + } + + var sourceOffset = 0; + while (sourceOffset < source.Length) { + // a code, not a byte: once the dictionary grows past 255 the prefix is + // whatever code matched, which no longer fits in the input's width + int prefix = source[sourceOffset++]; + var specialBlockEndClear = false; + + while (sourceOffset < source.Length) { + var suffix = source[sourceOffset++]; + var key = ((int)prefix << 8) | suffix; + if (state.Dictionary.TryGetValue(key, out var existingCode)) { + prefix = existingCode; + continue; + } + + writer.Write(prefix, state.BitWidth); + state.Dictionary[key] = state.NextCode; + if (state.NextCode == (1 << state.BitWidth) - 1) + state.BitWidth++; + state.NextCode++; + prefix = suffix; + + if (state.NextCode <= LastCode) + continue; + + writer.Write(prefix, state.BitWidth); + if (sourceOffset < source.Length) { + writer.Write(ClearCode, state.BitWidth); + state.Reset(); + break; + } + + state.NeedInitialClear = true; + specialBlockEndClear = true; + sourceOffset = source.Length; + break; + } + + if (sourceOffset < source.Length) + continue; + + if (!specialBlockEndClear) { + writer.Write(prefix, state.BitWidth); + if (state.NextCode == (1 << state.BitWidth) - 1) + state.BitWidth++; + state.NextCode++; + if (state.NextCode > LastCode) + state.NeedInitialClear = true; + } + break; + } + + return writer.Finish(); + } + + private static LzwDecodeResult ExpandLzw(ReadOnlySpan source, int outputLength, DecoderState state) { + var reader = new LsbBitReader(source); + var output = new byte[outputLength]; + var outOffset = 0; + var entry = state.Entry; + var bitWidth = state.BitWidth; + var mask = (1 << bitWidth) - 1; + + while (outOffset < output.Length) { + var code = reader.Read(bitWidth); + if (entry + 1 == mask) { + bitWidth++; + mask = (mask << 1) | 1; + } + + if (code == ClearCode) { + entry = FirstCode - 1; + bitWidth = 9; + mask = (1 << bitWidth) - 1; + continue; + } + if (code > entry) + throw new InvalidDataException($"NuLZW stream references future dictionary code 0x{code:X3} (next 0x{entry + 1:X3})."); + + var depth = state.Depth[code]; + if (outOffset + depth >= output.Length) + throw new InvalidDataException("NuLZW LZW expansion exceeds the declared post-RLE length."); + + var write = outOffset + depth; + var current = code; + byte first = 0; + while (write >= outOffset) { + first = state.Final[current]; + output[write--] = first; + current = state.Parent[current]; + } + + state.Final[entry] = first; + depth++; + outOffset += depth; + entry++; + if (entry >= TableSize) + throw new InvalidDataException("NuLZW LZW dictionary exceeded 4096 entries."); + + state.Depth[entry] = depth; + state.Final[entry] = first; + state.Parent[entry] = code; + } + + state.Entry = entry; + state.BitWidth = bitWidth; + return new LzwDecodeResult(output, reader.BytesConsumed); + } + + private static void WriteUInt16(Stream output, ushort value) { + Span buffer = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(buffer, value); + output.Write(buffer); + } + + private sealed class EncoderState { + public Dictionary Dictionary { get; } = new(); + public int NextCode { get; set; } + public int BitWidth { get; set; } + public bool NeedInitialClear { get; set; } + + public EncoderState() => this.Reset(); + + public void Reset() { + this.Dictionary.Clear(); + this.NextCode = FirstCode; + this.BitWidth = 9; + this.NeedInitialClear = false; + } + } + + private sealed class DecoderState { + public int[] Parent { get; } = new int[TableSize]; + public byte[] Final { get; } = new byte[TableSize]; + public int[] Depth { get; } = new int[TableSize]; + public int Entry { get; set; } + public int BitWidth { get; set; } + + public DecoderState() { + for (var i = 0; i < FirstCode; i++) + this.Final[i] = (byte)i; + this.Reset(); + } + + public void Reset() { + this.Entry = FirstCode - 1; + this.BitWidth = 9; + } + } + + private sealed class LsbBitWriter { + private readonly List _bytes = []; + private ulong _bits; + private int _bitCount; + + public void Write(int value, int width) { + this._bits |= (ulong)(uint)value << this._bitCount; + this._bitCount += width; + while (this._bitCount >= 8) { + this._bytes.Add((byte)this._bits); + this._bits >>= 8; + this._bitCount -= 8; + } + } + + public byte[] Finish() { + if (this._bitCount > 0) + this._bytes.Add((byte)this._bits); + this._bits = 0; + this._bitCount = 0; + return this._bytes.ToArray(); + } + } + + private ref struct LsbBitReader { + private readonly ReadOnlySpan _source; + private int _bitPosition; + + public LsbBitReader(ReadOnlySpan source) { + this._source = source; + this._bitPosition = 0; + } + + public readonly int BytesConsumed => (this._bitPosition + 7) >> 3; + + public int Read(int width) { + if (this._bitPosition + width > this._source.Length * 8) + throw new InvalidDataException("NuLZW LZW bitstream is truncated."); + + var byteOffset = this._bitPosition >> 3; + var bitOffset = this._bitPosition & 7; + uint value = 0; + for (var i = 0; i < 3 && byteOffset + i < this._source.Length; i++) + value |= (uint)this._source[byteOffset + i] << (8 * i); + this._bitPosition += width; + return (int)((value >> bitOffset) & ((1u << width) - 1)); + } + } + + private readonly record struct LzwDecodeResult(byte[] Data, int BytesConsumed); +} + +/// +/// Benchmarkable raw building block for GS/ShrinkIt LZW/2. +/// +/// +/// Native NuLZW streams omit the expanded length, so the building-block envelope prefixes a +/// little-endian 32-bit expanded length. itself reads and writes the +/// native bytes used by NuFX archives. +/// +public sealed class NuLzwBuildingBlock : IBuildingBlock { + /// + public string Id => "BB_NuLzw"; + /// + public string DisplayName => "NuLZW (ShrinkIt LZW/2)"; + /// + public string Description => "Apple II GS/ShrinkIt 4 KiB RLE + early-change 9-12 bit LZW/2"; + /// + public AlgorithmFamily Family => AlgorithmFamily.Dictionary; + + /// + public byte[] Compress(ReadOnlySpan data) { + var native = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2); + var result = new byte[4 + native.Length]; + BinaryPrimitives.WriteInt32LittleEndian(result, data.Length); + native.CopyTo(result.AsSpan(4)); + return result; + } + + /// + public byte[] Decompress(ReadOnlySpan data) { + if (data.Length < 4) + throw new InvalidDataException("NuLZW building-block envelope is truncated."); + var length = BinaryPrimitives.ReadInt32LittleEndian(data); + if (length < 0) + throw new InvalidDataException("NuLZW building-block envelope has a negative expanded length."); + return NuLzwCodec.Decompress(data[4..], NuLzwVariant.Lzw2, length); + } +} \ No newline at end of file diff --git a/Compression.Core/README.md b/Compression.Core/README.md index 977e66dfc..c1659410a 100644 --- a/Compression.Core/README.md +++ b/Compression.Core/README.md @@ -204,7 +204,7 @@ Use the concrete version you intend to consume; this document does not predict a -Every public and protected member of all 537 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Compression.Core/REFERENCE.md). +Every public and protected member of all 540 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Compression.Core/REFERENCE.md). diff --git a/Compression.Core/REFERENCE.md b/Compression.Core/REFERENCE.md index 6db8bbfff..2b5ee7bb1 100644 --- a/Compression.Core/REFERENCE.md +++ b/Compression.Core/REFERENCE.md @@ -2100,7 +2100,7 @@ Decompresses data produced by `LzvnCompressor`. ### Namespace `Compression.Core.Dictionary.Lzw` -[`LzwBuildingBlock`](#lzwbuildingblock) · [`LzwCompressionLevel`](#lzwcompressionlevel) · [`LzwDecoder`](#lzwdecoder) · [`LzwEncoder`](#lzwencoder) +[`LzwBuildingBlock`](#lzwbuildingblock) · [`LzwCompressionLevel`](#lzwcompressionlevel) · [`LzwDecoder`](#lzwdecoder) · [`LzwEncoder`](#lzwencoder) · [`NuLzwBuildingBlock`](#nulzwbuildingblock) · [`NuLzwCodec`](#nulzwcodec) · [`NuLzwVariant`](#nulzwvariant) #### `LzwBuildingBlock` @@ -2148,6 +2148,41 @@ Encodes data using the LZW (Lempel-Ziv-Welch) algorithm with variable-width code | `StopCode` | `int StopCode { get; }` | Gets the stop code value, or -1 if stop codes are disabled. | | `Encode` | `void Encode(ReadOnlySpan data)` | Encodes the input data and writes compressed LZW codes to the output stream. | +#### `NuLzwBuildingBlock` + +Benchmarkable raw building block for GS/ShrinkIt LZW/2. + +Implements `IBuildingBlock`. + +| Member | Signature | Summary | +| --- | --- | --- | +| `NuLzwBuildingBlock` | `NuLzwBuildingBlock()` | | +| `Description` | `string Description { get; }` | | +| `DisplayName` | `string DisplayName { get; }` | | +| `Family` | `AlgorithmFamily Family { get; }` | | +| `Id` | `string Id { get; }` | | +| `Compress` | `byte[] Compress(ReadOnlySpan data)` | | +| `Decompress` | `byte[] Decompress(ReadOnlySpan data)` | | + +#### `NuLzwCodec` + +Apple II NuFX/ShrinkIt RLE + LZW codec. + +| Member | Signature | Summary | +| --- | --- | --- | +| `Compress` | `static byte[] Compress(ReadOnlySpan data, NuLzwVariant variant, byte volumeNumber = 254, byte rleDelimiter = 219)` | Compresses a native ShrinkIt LZW/1 or LZW/2 stream. | +| `Crc16Xmodem` | `static ushort Crc16Xmodem(ReadOnlySpan data, ushort seed = 0)` | Computes CRC-16/XMODEM (poly 0x1021, refin=false, refout=false) from an arbitrary seed. | +| `Decompress` | `static byte[] Decompress(ReadOnlySpan data, NuLzwVariant variant, int expandedLength)` | Expands a native ShrinkIt stream to exactly `expandedLength` logical bytes. | + +#### `NuLzwVariant` + +NuFX/ShrinkIt LZW dialect. + +| Value | Numeric | Summary | +| --- | --- | --- | +| `Lzw1` | `0` | Original ProDOS ShrinkIt LZW/1: dictionary resets for every 4096-byte chunk and the stream carries CRC-16/XMODEM. | +| `Lzw2` | `1` | GS/ShrinkIt LZW/2: dictionary may persist between 4096-byte chunks and integrity is supplied by the NuFX thread header. | + ### Namespace `Compression.Core.Dictionary.Lzwl` [`LzwlBuildingBlock`](#lzwlbuildingblock) diff --git a/Compression.Tests/BuildingBlocks/NuLzwTests.cs b/Compression.Tests/BuildingBlocks/NuLzwTests.cs new file mode 100644 index 000000000..90d3c74a5 --- /dev/null +++ b/Compression.Tests/BuildingBlocks/NuLzwTests.cs @@ -0,0 +1,132 @@ +using System.Buffers.Binary; +using System.Text; +using Compression.Core.Dictionary.Lzw; + +namespace Compression.Tests.BuildingBlocks; + +[TestFixture] +public sealed class NuLzwTests { + [Test] + public void Crc16Xmodem_MatchesCheckValue() { + var crc = NuLzwCodec.Crc16Xmodem(Encoding.ASCII.GetBytes("123456789")); + Assert.That(crc, Is.EqualTo(0x31C3)); + } + + [TestCase(NuLzwVariant.Lzw1)] + [TestCase(NuLzwVariant.Lzw2)] + public void EmptyStream_HasCanonicalHeaderAndRoundTrips(NuLzwVariant variant) { + var packed = NuLzwCodec.Compress([], variant); + Assert.That(packed, Is.EqualTo(variant == NuLzwVariant.Lzw1 + ? new byte[] { 0x00, 0x00, 0xFE, 0xDB } + : new byte[] { 0xFE, 0xDB })); + Assert.That(NuLzwCodec.Decompress(packed, variant, 0), Is.Empty); + } + + [TestCase(NuLzwVariant.Lzw1, 1)] + [TestCase(NuLzwVariant.Lzw1, 4095)] + [TestCase(NuLzwVariant.Lzw1, 4096)] + [TestCase(NuLzwVariant.Lzw1, 4097)] + [TestCase(NuLzwVariant.Lzw1, 16385)] + [TestCase(NuLzwVariant.Lzw2, 1)] + [TestCase(NuLzwVariant.Lzw2, 4095)] + [TestCase(NuLzwVariant.Lzw2, 4096)] + [TestCase(NuLzwVariant.Lzw2, 4097)] + [TestCase(NuLzwVariant.Lzw2, 16385)] + public void RoundTrip_RunHeavyAndPartialChunks(NuLzwVariant variant, int length) { + var data = Enumerable.Range(0, length) + .Select(i => (byte)((i / 37) % 11 == 0 ? 0xDB : (i / 113) % 7)) + .ToArray(); + + var packed = NuLzwCodec.Compress(data, variant); + var unpacked = NuLzwCodec.Decompress(packed, variant, data.Length); + + Assert.That(unpacked, Is.EqualTo(data)); + } + + [TestCase(NuLzwVariant.Lzw1)] + [TestCase(NuLzwVariant.Lzw2)] + public void RoundTrip_IncompressibleChunks(NuLzwVariant variant) { + var data = new byte[12289]; + var state = 0x12345678u; + for (var i = 0; i < data.Length; i++) { + state = state * 1664525u + 1013904223u; + data[i] = (byte)(state >> 24); + } + + var packed = NuLzwCodec.Compress(data, variant); + Assert.That(NuLzwCodec.Decompress(packed, variant, data.Length), Is.EqualTo(data)); + } + + [Test] + public void Lzw2_PersistentDictionaryCrossesManyChunksAndCodeWidths() { + var data = new byte[96 * 1024 + 321]; + var state = 0xCAFEBABEu; + for (var i = 0; i < data.Length; i++) { + state = state * 1103515245u + 12345u; + data[i] = (byte)((state >> 24) & 0x1F); + } + + var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2); + var unpacked = NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw2, data.Length); + + Assert.That(unpacked, Is.EqualTo(data)); + Assert.That(packed.Length, Is.LessThan(data.Length)); + } + + [Test] + public void Lzw1_CrcCoversZeroPaddedFinalChunk() { + var data = Enumerable.Repeat((byte)0x41, 5000).ToArray(); + var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw1); + packed[0] ^= 0x01; + + Assert.Throws(() => + NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw1, data.Length)); + } + + [Test] + public void Lzw2_IgnoresTrailingShrinkItPadByte() { + var data = Enumerable.Range(0, 9000).Select(i => (byte)(i % 13)).ToArray(); + var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2); + var padded = packed.Concat(new byte[] { 0x00 }).ToArray(); + + Assert.That(NuLzwCodec.Decompress(padded, NuLzwVariant.Lzw2, data.Length), Is.EqualTo(data)); + } + + [Test] + public void Lzw2_IgnoresBogusCompressedLengthHintFromBadMacArchives() { + var data = Enumerable.Range(0, 4096).Select(i => (byte)(i % 7)).ToArray(); + var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2); + + var postRle = BinaryPrimitives.ReadUInt16LittleEndian(packed.AsSpan(2, 2)); + Assert.That(postRle & 0x8000, Is.Not.Zero, "Fixture must select the LZW/2 chunk form."); + + // The LZW/2 word at +4 is only a recovery hint. Historical Macintosh-created + // archives exist with this value byte-swapped or otherwise wrong. ShrinkIt-compatible + // decoding stops when the declared expanded output has been produced, not at this hint. + BinaryPrimitives.WriteUInt16LittleEndian(packed.AsSpan(4, 2), 1); + + Assert.That(NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw2, data.Length), Is.EqualTo(data)); + } + + [Test] + public void BuildingBlock_EnvelopeCarriesExpandedLength() { + var block = new NuLzwBuildingBlock(); + var data = Enumerable.Range(0, 10000).Select(i => (byte)((i * 7) & 0x3F)).ToArray(); + + var packed = block.Compress(data); + var unpacked = block.Decompress(packed); + + Assert.That(block.Id, Is.EqualTo("BB_NuLzw")); + Assert.That(unpacked, Is.EqualTo(data)); + } + + [Test] + public void TruncatedBitstreamIsRejected() { + var data = Enumerable.Range(0, 8192).Select(i => (byte)(i % 17)).ToArray(); + var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2); + Array.Resize(ref packed, packed.Length - 7); + + Assert.Throws(() => + NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw2, data.Length)); + } +} \ No newline at end of file