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
diff --git a/Compression.Tests/NuFx/NuFxSqueezeTests.cs b/Compression.Tests/NuFx/NuFxSqueezeTests.cs
new file mode 100644
index 000000000..203075e4a
--- /dev/null
+++ b/Compression.Tests/NuFx/NuFxSqueezeTests.cs
@@ -0,0 +1,58 @@
+using System.Buffers.Binary;
+using Compression.Registry;
+using FileFormat.NuFx;
+
+namespace Compression.Tests.NuFx;
+
+[TestFixture]
+public sealed class NuFxSqueezeTests {
+ [Test]
+ public void SqueezeThread_IsHeaderlessAndRoundTripsRleEdgeCases() {
+ var data = new List();
+ data.AddRange(Enumerable.Repeat((byte)'A', 255));
+ data.AddRange(Enumerable.Repeat((byte)'A', 17));
+ data.AddRange(Enumerable.Repeat((byte)0x90, 8));
+ data.AddRange(Enumerable.Range(0, 256).Select(i => (byte)i));
+ var expected = data.ToArray();
+
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = new MemoryStream();
+ descriptor.Create(archive, [ArchiveInputInfo.InMemory("A", expected)],
+ new FormatCreateOptions { MethodName = "squeeze" });
+
+ var bytes = archive.ToArray();
+ const int recordStart = 48;
+ var attribCount = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(recordStart + 6, 2));
+ var threadCount = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(recordStart + 0x0A, 4));
+ Assert.That(threadCount, Is.EqualTo(2u));
+
+ var filenameThread = recordStart + attribCount;
+ var filenameFieldLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(filenameThread + 12, 4));
+ var compressedStart = checked(recordStart + attribCount + (int)threadCount * 16 + (int)filenameFieldLength);
+
+ // NuFX thread format 1 starts directly with the Squeeze node count. A standalone
+ // Squeeze file would start 76 FF and is not legal in this thread representation.
+ Assert.That(bytes.AsSpan(compressedStart, 2).SequenceEqual(new byte[] { 0x76, 0xFF }), Is.False);
+ var nodeCount = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(compressedStart, 2));
+ Assert.That(nodeCount, Is.InRange(1, 256));
+
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "A", null), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void SqueezeThread_EmptyPayloadUsesZeroNodeTree() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = new MemoryStream();
+ descriptor.Create(archive, [ArchiveInputInfo.InMemory("EMPTY", Array.Empty())],
+ new FormatCreateOptions { MethodName = "squeeze" });
+
+ archive.Position = 0;
+ var entry = descriptor.List(archive, null).Single();
+ Assert.That(entry.Method, Is.EqualTo("Squeeze"));
+ Assert.That(entry.CompressedSize, Is.EqualTo(2));
+
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "EMPTY", null), Is.Empty);
+ }
+}
diff --git a/Compression.Tests/NuFx/NuFxTests.cs b/Compression.Tests/NuFx/NuFxTests.cs
new file mode 100644
index 000000000..95f3ca2b1
--- /dev/null
+++ b/Compression.Tests/NuFx/NuFxTests.cs
@@ -0,0 +1,224 @@
+using System.Buffers.Binary;
+using Compression.Core.Dictionary.Lzw;
+using Compression.Registry;
+using FileFormat.NuFx;
+
+namespace Compression.Tests.NuFx;
+
+[TestFixture]
+public sealed class NuFxTests {
+ private static readonly byte[] SampleA =
+ "NuFX/ShrinkIt interoperability test data. NuFX/ShrinkIt interoperability test data."u8.ToArray();
+ private static readonly byte[] SampleB =
+ Enumerable.Range(0, 9000).Select(i => (byte)((i * 37 + i / 11) & 0xFF)).ToArray();
+
+ [Test]
+ public void Descriptor_AdvertisesTrueReadWriteAndSupportedMethods() {
+ var descriptor = new NuFxFormatDescriptor();
+ Assert.That(descriptor.Capabilities.HasFlag(FormatCapabilities.CanCreate), Is.True);
+ Assert.That(descriptor.Capabilities.HasFlag(FormatCapabilities.CanModify), Is.True);
+ Assert.That(descriptor.Methods.Select(m => m.Name),
+ Is.EquivalentTo(new[] { "stored", "squeeze", "nulzw1", "nulzw2", "auto" }));
+ Assert.That(descriptor.Extensions, Does.Contain(".shk"));
+ Assert.That(descriptor.Extensions, Does.Contain(".sdk"));
+ }
+
+ [TestCase("stored")]
+ [TestCase("squeeze")]
+ [TestCase("nulzw1")]
+ [TestCase("nulzw2")]
+ [TestCase("auto")]
+ public void Create_RoundTripsEveryWritableCompressionMethod(string method) {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = new MemoryStream();
+ descriptor.Create(archive, [
+ ArchiveInputInfo.InMemory("DOCS/ÄPFEL.TXT", SampleA),
+ ArchiveInputInfo.InMemory("BIN/SECOND.BIN", SampleB),
+ ], new FormatCreateOptions { MethodName = method });
+
+ archive.Position = 0;
+ var entries = descriptor.List(archive, null);
+ Assert.That(entries.Select(e => e.Name), Is.EqualTo(new[] { "DOCS/ÄPFEL.TXT", "BIN/SECOND.BIN" }));
+
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "DOCS/ÄPFEL.TXT", null), Is.EqualTo(SampleA));
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "BIN/SECOND.BIN", null), Is.EqualTo(SampleB));
+
+ archive.Position = 0;
+ var integrity = descriptor.ValidateIntegrity(archive);
+ Assert.That(integrity.IsValid, Is.True);
+ Assert.That(integrity.ValidEntries, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void Create_DiskImageModeProducesSdkStyleDiskThread() {
+ var descriptor = new NuFxFormatDescriptor();
+ var disk = new byte[143360];
+ for (var i = 0; i < disk.Length; i++)
+ disk[i] = (byte)(i * 13);
+
+ using var archive = new MemoryStream();
+ descriptor.Create(archive, [ArchiveInputInfo.InMemory("DISK140K", disk)],
+ new FormatCreateOptions {
+ MethodName = "nulzw2",
+ FormatSpecific = new Dictionary { ["Mode"] = "DiskImage" },
+ });
+
+ archive.Position = 0;
+ var entry = descriptor.List(archive, null).Single();
+ Assert.That(entry.Kind, Is.EqualTo("disk-image"));
+ Assert.That(entry.OriginalSize, Is.EqualTo(disk.Length));
+
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "DISK140K", null), Is.EqualTo(disk));
+ }
+
+ [Test]
+ public void Create_DiskImageModeRejectsNonSectorMultiple() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = new MemoryStream();
+ var options = new FormatCreateOptions {
+ FormatSpecific = new Dictionary { ["Mode"] = "DiskImage" },
+ };
+ Assert.Throws(() =>
+ descriptor.Create(archive, [ArchiveInputInfo.InMemory("BAD", new byte[513])], options));
+ }
+
+ [Test]
+ public void DirectAdd_PatchesCountEofAndMasterCrcBeforeNextEdit() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = CreateArchive(descriptor, ("ONE", SampleA));
+
+ descriptor.Add(archive, [
+ ArchiveInputInfo.InMemory("TWO", SampleB),
+ ArchiveInputInfo.InMemory("THREE", "three"u8),
+ ]);
+
+ var master = archive.ToArray().AsSpan(0, 48);
+ Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(master.Slice(8, 4)), Is.EqualTo(3u));
+ Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(master.Slice(0x26, 4)), Is.EqualTo((uint)archive.Length));
+ Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(master.Slice(6, 2)),
+ Is.EqualTo(NuLzwCodec.Crc16Xmodem(master.Slice(8), 0)));
+
+ archive.Position = 0;
+ Assert.That(descriptor.List(archive, null).Select(e => e.Name),
+ Is.EqualTo(new[] { "ONE", "TWO", "THREE" }));
+ }
+
+ [Test]
+ public void DirectReplace_ChangesOneRecordWithoutReencodingFollowingRecord() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = CreateArchive(descriptor, ("ONE", SampleA), ("TWO", SampleB));
+
+ var before = archive.ToArray();
+ var secondSignature = FindNth(before, new byte[] { 0x4E, 0xF5, 0x46, 0xD8 }, 2);
+ Assert.That(secondSignature, Is.GreaterThan(0));
+ var secondRecordBefore = before.AsSpan(secondSignature).ToArray();
+
+ var replacement = Enumerable.Repeat((byte)0xA5, 12000).ToArray();
+ descriptor.Add(archive, [ArchiveInputInfo.InMemory("ONE", replacement)]);
+
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "ONE", null), Is.EqualTo(replacement));
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "TWO", null), Is.EqualTo(SampleB));
+
+ var after = archive.ToArray();
+ var secondAfter = FindNth(after, new byte[] { 0x4E, 0xF5, 0x46, 0xD8 }, 2);
+ Assert.That(secondAfter, Is.GreaterThan(0));
+ Assert.That(after.AsSpan(secondAfter).ToArray(), Is.EqualTo(secondRecordBefore));
+ }
+
+ [Test]
+ public void DirectRemove_ClosesExtentAndRepairsMaster() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = CreateArchive(descriptor, ("ONE", SampleA), ("TWO", SampleB), ("THREE", "3"u8.ToArray()));
+ var oldLength = archive.Length;
+
+ descriptor.Remove(archive, ["TWO"]);
+
+ Assert.That(archive.Length, Is.LessThan(oldLength));
+ var master = archive.ToArray().AsSpan(0, 48);
+ Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(master.Slice(8, 4)), Is.EqualTo(2u));
+ Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(master.Slice(0x26, 4)), Is.EqualTo((uint)archive.Length));
+
+ archive.Position = 0;
+ Assert.That(descriptor.List(archive, null).Select(e => e.Name), Is.EqualTo(new[] { "ONE", "THREE" }));
+ }
+
+ [Test]
+ public void Defragment_TrimsShrinkItFilenameReserveWithoutChangingPayload() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = CreateArchive(descriptor, ("A", SampleA), ("B", SampleB));
+ var before = archive.Length;
+
+ descriptor.Defragment(archive);
+
+ Assert.That(archive.Length, Is.LessThan(before));
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "A", null), Is.EqualTo(SampleA));
+ archive.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(archive, "B", null), Is.EqualTo(SampleB));
+ }
+
+ [Test]
+ public void Shrink_UsesMetadataPreservingCompaction() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var source = CreateArchive(descriptor, ("A", SampleA));
+ using var shrunk = new MemoryStream();
+
+ descriptor.Shrink(source, shrunk);
+
+ Assert.That(shrunk.Length, Is.LessThan(source.Length));
+ shrunk.Position = 0;
+ Assert.That(descriptor.ExtractEntryToMemory(shrunk, "A", null), Is.EqualTo(SampleA));
+ }
+
+ [Test]
+ public void Validator_RejectsMasterCrcCorruption() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var archive = CreateArchive(descriptor, ("ONE", SampleA));
+ var bytes = archive.ToArray();
+ bytes[0x20] ^= 0x80;
+ using var damaged = new MemoryStream(bytes);
+
+ var result = descriptor.ValidateStructure(damaged);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Health, Is.EqualTo(FormatHealth.Damaged));
+ }
+
+ [Test]
+ public void Create_RejectsEncryptionAndUnsafePath() {
+ var descriptor = new NuFxFormatDescriptor();
+ using var encrypted = new MemoryStream();
+ Assert.Throws(() => descriptor.Create(encrypted,
+ [ArchiveInputInfo.InMemory("A", SampleA)], new FormatCreateOptions { Password = "secret" }));
+
+ var unsafeInput = ArchiveInputInfo.InMemory("../EVIL", SampleA);
+ Assert.That(descriptor.CanAccept(unsafeInput, out _), Is.False);
+ }
+
+ private static MemoryStream CreateArchive(NuFxFormatDescriptor descriptor,
+ params (string Name, byte[] Data)[] files) {
+ var stream = new MemoryStream();
+ descriptor.Create(stream,
+ files.Select(f => ArchiveInputInfo.InMemory(f.Name, f.Data)).ToList(),
+ new FormatCreateOptions { MethodName = "nulzw2" });
+ stream.Position = 0;
+ return stream;
+ }
+
+ private static int FindNth(byte[] haystack, byte[] needle, int occurrence) {
+ var found = 0;
+ for (var i = 0; i <= haystack.Length - needle.Length; i++) {
+ if (!haystack.AsSpan(i, needle.Length).SequenceEqual(needle))
+ continue;
+ found++;
+ if (found == occurrence)
+ return i;
+ }
+ return -1;
+ }
+}
diff --git a/FileFormats/FileFormat.Squeeze/NuFx/NuFxFormatDescriptor.cs b/FileFormats/FileFormat.Squeeze/NuFx/NuFxFormatDescriptor.cs
new file mode 100644
index 000000000..5f5617511
--- /dev/null
+++ b/FileFormats/FileFormat.Squeeze/NuFx/NuFxFormatDescriptor.cs
@@ -0,0 +1,962 @@
+using System.Buffers.Binary;
+using System.Text;
+using Compression.Core.Dictionary.Lzw;
+using Compression.Registry;
+using Compression.Registry.Streaming;
+using FileFormat.Squeeze;
+using static Compression.Registry.FormatHelpers;
+
+namespace FileFormat.NuFx;
+
+///
+/// NuFX / ShrinkIt archive descriptor for Apple II and Apple IIgs archives.
+/// Supports plain SHK/SDK archives, native Stored/Squeeze/NuLZW1/NuLZW2 creation,
+/// record-preserving direct add/replace/remove, and slack-compacting rebuilds.
+///
+public sealed class NuFxFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations,
+ IArchiveCreatable, IArchiveModifiable, IArchiveDefragmentable, IArchiveShrinkable,
+ IArchiveLayoutMap, IFormatOptionsSchema, IArchiveWriteConstraints, IFormatValidator {
+ public string Id => "NuFx";
+ public string DisplayName => "NuFX / ShrinkIt";
+ public FormatCategory Category => FormatCategory.Archive;
+ public FormatCapabilities Capabilities =>
+ FormatCapabilities.CanList | FormatCapabilities.CanExtract |
+ FormatCapabilities.CanCreate | FormatCapabilities.CanModify |
+ FormatCapabilities.CanTest | FormatCapabilities.SupportsMultipleEntries;
+ public string DefaultExtension => ".shk";
+ public IReadOnlyList Extensions => [".shk", ".sdk", ".bxy"];
+ public IReadOnlyList CompoundExtensions => [];
+ public IReadOnlyList MagicSignatures => [
+ new(NuFxArchive.MasterSignature, Offset: 0, Confidence: 0.98f),
+ ];
+ public IReadOnlyList Methods => [
+ new("nulzw2", "ShrinkIt LZW/2"),
+ new("nulzw1", "ShrinkIt LZW/1"),
+ new("squeeze", "Squeeze"),
+ new("stored", "Stored"),
+ new("auto", "Auto (smallest)"),
+ ];
+ public string? TarCompressionFormatId => null;
+ public AlgorithmFamily Family => AlgorithmFamily.Archive;
+ public string Description =>
+ "Apple II/IIgs NuFX (ShrinkIt) archive — SHK/SDK read/write with Stored, Squeeze, LZW/1 and LZW/2 threads.";
+
+ public IReadOnlyList OptionsSchema { get; } = [
+ new("Mode", "Archive mode", FormatOptionKind.Enum, "Files", ["Files", "DiskImage"],
+ "Files creates a normal .shk archive. DiskImage creates the single disk-image record used by .sdk."),
+ new("FileType", "ProDOS file type", FormatOptionKind.Integer, "0", null,
+ "Default ProDOS file type for newly created ordinary file records (0-255)."),
+ new("AuxType", "ProDOS aux type", FormatOptionKind.Integer, "0", null,
+ "Default ProDOS auxiliary type for newly created ordinary file records (0-65535)."),
+ new("Access", "ProDOS access flags", FormatOptionKind.Integer, "227", null,
+ "Default ProDOS access byte for newly created records. 227 (0xE3) is an unlocked file."),
+ ];
+
+ public long? MaxTotalArchiveSize => uint.MaxValue;
+ public long? MinTotalArchiveSize => NuFxArchive.MasterHeaderLength;
+ public string AcceptedInputsDescription =>
+ "Regular files with slash-separated paths; SDK disk-image mode accepts exactly one file whose size is a multiple of 512 bytes.";
+
+ public bool CanAccept(ArchiveInputInfo input, out string? reason) {
+ ArgumentNullException.ThrowIfNull(input);
+ if (input.IsDirectory) {
+ reason = "NuFX has a directory-control thread, but ShrinkIt did not use it; empty directories cannot be represented portably.";
+ return false;
+ }
+ var name = input.ArchiveName.Replace('\\', '/').Trim('/');
+ if (name.Length == 0) {
+ reason = "NuFX entries require a non-empty pathname.";
+ return false;
+ }
+ if (name.Split('/').Any(p => p is "" or "." or "..")) {
+ reason = "NuFX path components may not be empty, '.' or '..'.";
+ return false;
+ }
+ if (NuFxArchive.EncodeMacRoman(name.Replace('/', ':')).Length > ushort.MaxValue) {
+ reason = "NuFX pathnames are limited to 65535 encoded bytes.";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public List List(Stream stream, string? password) {
+ RejectPassword(password);
+ var archive = NuFxArchive.Parse(stream);
+ return archive.Records.Select((record, index) => new ArchiveEntryInfo(
+ index,
+ record.Name,
+ record.LogicalLength,
+ record.DataThread?.CompressedLength ?? 0,
+ NuFxArchive.MethodName(record.DataThread?.Format ?? 0),
+ false,
+ false,
+ null,
+ record.IsDiskImage ? "disk-image" : "file"
+ )).ToList();
+ }
+
+ public void Extract(Stream stream, string outputDir, string? password, string[]? files) {
+ RejectPassword(password);
+ var archive = NuFxArchive.Parse(stream);
+ foreach (var record in archive.Records) {
+ if (files != null && !MatchesFilter(record.Name, files))
+ continue;
+ WriteFile(outputDir, record.Name, NuFxArchive.ExtractRecord(stream, record));
+ }
+ }
+
+ public Stream OpenEntry(Stream archive, string entryName, string? password) {
+ RejectPassword(password);
+ var parsed = NuFxArchive.Parse(archive);
+ var record = FindRecord(parsed, entryName);
+ if (record == null)
+ return new BoundedEntryStream(new MemoryStream([], writable: false), 0, leaveOpen: false);
+ var data = NuFxArchive.ExtractRecord(archive, record);
+ return new BoundedEntryStream(new MemoryStream(data, writable: false), data.Length, leaveOpen: false);
+ }
+
+ public byte[] ExtractEntryToMemory(Stream archive, string entryName, string? password) {
+ using var input = this.OpenEntry(archive, entryName, password);
+ using var output = new MemoryStream();
+ input.CopyTo(output);
+ return output.ToArray();
+ }
+
+ public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(inputs);
+ ArgumentNullException.ThrowIfNull(options);
+ RejectEncryption(options);
+
+ var mode = GetOption(options, "Mode", "Files");
+ var method = NormalizeMethod(options.MethodName);
+ var fileType = checked((byte)ParseBoundedInt(GetOption(options, "FileType", "0"), 0, 255, "FileType"));
+ var auxType = checked((ushort)ParseBoundedInt(GetOption(options, "AuxType", "0"), 0, 65535, "AuxType"));
+ var access = checked((byte)ParseBoundedInt(GetOption(options, "Access", "227"), 0, 255, "Access"));
+
+ var files = inputs.Where(i => !i.IsDirectory).ToList();
+ if (mode.Equals("DiskImage", StringComparison.OrdinalIgnoreCase)) {
+ if (files.Count != 1)
+ throw new InvalidDataException("NuFX SDK disk-image mode requires exactly one input file.");
+ var bytes = files[0].ReadContent();
+ if ((bytes.Length & 511) != 0)
+ throw new InvalidDataException("NuFX SDK disk images must be a multiple of 512 bytes.");
+ NuFxArchive.Create(output, [
+ NuFxArchive.BuildNewRecord(files[0].ArchiveName, bytes, method, true, fileType, auxType, access)
+ ]);
+ return;
+ }
+
+ var records = new List(files.Count);
+ foreach (var input in files) {
+ if (!this.CanAccept(input, out var reason))
+ throw new InvalidDataException(reason);
+ records.Add(NuFxArchive.BuildNewRecord(input.ArchiveName, input.ReadContent(), method, false, fileType, auxType, access));
+ }
+ NuFxArchive.Create(output, records);
+ }
+
+ public void Add(Stream archive, IReadOnlyList inputs) {
+ ArgumentNullException.ThrowIfNull(archive);
+ ArgumentNullException.ThrowIfNull(inputs);
+ NuFxArchive.RequireWritablePlainArchive(archive);
+
+ foreach (var input in inputs.Where(i => !i.IsDirectory)) {
+ if (!this.CanAccept(input, out var reason))
+ throw new InvalidDataException(reason);
+
+ var parsed = NuFxArchive.Parse(archive);
+ var existing = FindRecord(parsed, input.ArchiveName);
+ var bytes = input.ReadContent();
+ if (existing != null) {
+ var replacement = NuFxArchive.ReplaceDataForkPreservingRecord(archive, existing, bytes);
+ NuFxArchive.ReplaceRange(archive, existing.StartOffset, existing.RecordLength, replacement);
+ NuFxArchive.PatchMaster(archive, parsed.RecordCount, checked(parsed.NuFxLength - existing.RecordLength + replacement.LongLength));
+ } else {
+ var record = NuFxArchive.BuildNewRecord(input.ArchiveName, bytes, "nulzw2", false, 0, 0, 0xE3);
+ var insertAt = checked(parsed.StartOffset + parsed.NuFxLength);
+ NuFxArchive.ReplaceRange(archive, insertAt, 0, record);
+ NuFxArchive.PatchMaster(archive, checked(parsed.RecordCount + 1), checked(parsed.NuFxLength + record.LongLength));
+ }
+ }
+ }
+
+ public void Remove(Stream archive, string[] entryNames) {
+ ArgumentNullException.ThrowIfNull(archive);
+ entryNames ??= [];
+ NuFxArchive.RequireWritablePlainArchive(archive);
+
+ foreach (var requested in entryNames) {
+ while (true) {
+ var parsed = NuFxArchive.Parse(archive);
+ var record = FindRecord(parsed, requested);
+ if (record == null)
+ break;
+ NuFxArchive.ReplaceRange(archive, record.StartOffset, record.RecordLength, []);
+ NuFxArchive.PatchMaster(archive, checked(parsed.RecordCount - 1), checked(parsed.NuFxLength - record.RecordLength));
+ }
+ }
+ }
+
+ public void Defragment(Stream archive)
+ => this.Defragment(archive, new DefragOptions { Mode = DefragMode.ConsolidateAtStart });
+
+ public void Defragment(Stream archive, DefragOptions options) {
+ ArgumentNullException.ThrowIfNull(options);
+ NuFxArchive.RequireWritablePlainArchive(archive);
+ if (options.Mode != DefragMode.ConsolidateAtStart)
+ throw new NotSupportedException("NuFX compaction supports ConsolidateAtStart; records already form one contiguous sequence.");
+
+ var parsed = NuFxArchive.Parse(archive);
+ var records = parsed.Records.Select(record => NuFxArchive.CompactRecord(archive, record)).ToList();
+ using var rebuilt = new MemoryStream();
+ NuFxArchive.Create(rebuilt, records);
+ NuFxArchive.ReplaceRange(archive, parsed.StartOffset, parsed.NuFxLength, rebuilt.ToArray());
+ }
+
+ public IEnumerable EnumerateLayout(Stream archive) {
+ var parsed = NuFxArchive.Parse(archive);
+ yield return new DefragBlockInfo(parsed.StartOffset, NuFxArchive.MasterHeaderLength, DefragBlockKind.Used, FileName: "");
+ foreach (var record in parsed.Records)
+ yield return new DefragBlockInfo(record.StartOffset, record.RecordLength, DefragBlockKind.Used, FileName: record.Name);
+ }
+
+ public void Shrink(Stream input, Stream output) {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
+ var parsed = NuFxArchive.Parse(input);
+ if (parsed.StartOffset != 0 || parsed.NuFxLength != input.Length) {
+ input.Position = 0;
+ output.Position = 0;
+ output.SetLength(0);
+ input.CopyTo(output);
+ return;
+ }
+
+ var records = parsed.Records.Select(record => NuFxArchive.CompactRecord(input, record)).ToList();
+ using var rebuilt = new MemoryStream();
+ NuFxArchive.Create(rebuilt, records);
+
+ output.Position = 0;
+ output.SetLength(0);
+ if (rebuilt.Length < input.Length) {
+ rebuilt.Position = 0;
+ rebuilt.CopyTo(output);
+ } else {
+ input.Position = 0;
+ input.CopyTo(output);
+ }
+ }
+
+ public ValidationResult ValidateHeader(ReadOnlySpan header, long fileSize) {
+ var issues = new List();
+ if (header.Length < NuFxArchive.MasterHeaderLength) {
+ issues.Add(new ValidationIssue(ValidationLevel.Header, IssueSeverity.Error, "NUFX_SHORT_HEADER",
+ "NuFX master header is shorter than 48 bytes."));
+ return Validation(false, 0.10, FormatHealth.Uncertain, ValidationLevel.Header, issues);
+ }
+ if (!header[..NuFxArchive.MasterSignature.Length].SequenceEqual(NuFxArchive.MasterSignature)) {
+ issues.Add(new ValidationIssue(ValidationLevel.Header, IssueSeverity.Error, "NUFX_BAD_MAGIC",
+ "NuFX master signature is missing."));
+ return Validation(false, 0.05, FormatHealth.Uncertain, ValidationLevel.Header, issues);
+ }
+
+ var stored = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(6, 2));
+ var calculated = NuLzwCodec.Crc16Xmodem(header.Slice(8, NuFxArchive.MasterHeaderLength - 8), 0);
+ if (stored != calculated)
+ issues.Add(new ValidationIssue(ValidationLevel.Header, IssueSeverity.Error, "NUFX_MASTER_CRC",
+ $"Master CRC mismatch: stored 0x{stored:X4}, calculated 0x{calculated:X4}.", 6));
+
+ var version = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(0x1C, 2));
+ var eof = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x26, 4));
+ if (version > 2)
+ issues.Add(new ValidationIssue(ValidationLevel.Header, IssueSeverity.Warning, "NUFX_MASTER_VERSION",
+ $"Unknown NuFX master version {version}.", 0x1C));
+ if (version > 0 && eof != 0 && eof > fileSize)
+ issues.Add(new ValidationIssue(ValidationLevel.Header, IssueSeverity.Error, "NUFX_MASTER_EOF",
+ $"Master EOF {eof} exceeds physical file size {fileSize}.", 0x26));
+
+ var valid = issues.All(i => i.Severity != IssueSeverity.Error);
+ return Validation(valid, valid ? 0.99 : 0.85,
+ valid ? (issues.Count == 0 ? FormatHealth.Perfect : FormatHealth.Good) : FormatHealth.Damaged,
+ ValidationLevel.Header, issues);
+ }
+
+ public ValidationResult ValidateStructure(Stream stream) {
+ try {
+ var parsed = NuFxArchive.Parse(stream);
+ return Validation(true, 1.0, FormatHealth.Perfect, ValidationLevel.Structure, [],
+ parsed.Records.Count, parsed.Records.Count);
+ } catch (Exception ex) when (ex is InvalidDataException or NotSupportedException or EndOfStreamException) {
+ return Validation(false, 0.95, FormatHealth.Damaged, ValidationLevel.Structure, [
+ new ValidationIssue(ValidationLevel.Structure, IssueSeverity.Error, "NUFX_STRUCTURE", ex.Message)
+ ]);
+ }
+ }
+
+ public ValidationResult ValidateIntegrity(Stream stream) {
+ try {
+ var parsed = NuFxArchive.Parse(stream);
+ var issues = new List();
+ var validEntries = 0;
+ foreach (var record in parsed.Records) {
+ var format = record.DataThread?.Format ?? (ushort)0;
+ if (format > 3) {
+ issues.Add(new ValidationIssue(ValidationLevel.Integrity, IssueSeverity.Warning,
+ "NUFX_UNCHECKED_METHOD", $"'{record.Name}' uses compression format {format}, which is structurally preserved but not decoded by this implementation.",
+ record.StartOffset));
+ continue;
+ }
+ _ = NuFxArchive.ExtractRecord(stream, record);
+ validEntries++;
+ }
+ return Validation(true, 1.0, issues.Count == 0 ? FormatHealth.Perfect : FormatHealth.Degraded,
+ ValidationLevel.Integrity, issues, validEntries, parsed.Records.Count);
+ } catch (Exception ex) when (ex is InvalidDataException or NotSupportedException or EndOfStreamException) {
+ return Validation(false, 1.0, FormatHealth.Damaged, ValidationLevel.Integrity, [
+ new ValidationIssue(ValidationLevel.Integrity, IssueSeverity.Error, "NUFX_INTEGRITY", ex.Message)
+ ]);
+ }
+ }
+
+ private static ValidationResult Validation(bool valid, double confidence, FormatHealth health,
+ ValidationLevel level, IReadOnlyList issues, int? validEntries = null, int? totalEntries = null)
+ => new() {
+ IsValid = valid,
+ Confidence = confidence,
+ Health = health,
+ Level = level,
+ Issues = issues,
+ ValidEntries = validEntries,
+ TotalEntries = totalEntries,
+ };
+
+ private static NuFxRecord? FindRecord(NuFxParsedArchive archive, string name) {
+ var normalized = NuFxArchive.NormalizePath(name);
+ var exact = archive.Records.FirstOrDefault(r => r.Name.Equals(normalized, StringComparison.OrdinalIgnoreCase));
+ if (exact != null || normalized.Contains('/'))
+ return exact;
+ return archive.Records.FirstOrDefault(r =>
+ Path.GetFileName(r.Name).Equals(normalized, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static void RejectPassword(string? password) {
+ if (!string.IsNullOrEmpty(password))
+ throw new NotSupportedException("NuFX does not define password encryption.");
+ }
+
+ private static void RejectEncryption(FormatCreateOptions options) {
+ if (!string.IsNullOrEmpty(options.Password) || options.EncryptFilenames ||
+ !string.IsNullOrEmpty(options.EncryptionMethod))
+ throw new NotSupportedException("NuFX does not define password encryption.");
+ }
+
+ private static string GetOption(FormatCreateOptions options, string key, string fallback)
+ => options.FormatSpecific != null && options.FormatSpecific.TryGetValue(key, out var value) ? value : fallback;
+
+ private static int ParseBoundedInt(string text, int min, int max, string name) {
+ if (!int.TryParse(text, out var value) || value < min || value > max)
+ throw new InvalidDataException($"{name} must be in the range {min}..{max}.");
+ return value;
+ }
+
+ private static string NormalizeMethod(string? method) {
+ if (string.IsNullOrWhiteSpace(method))
+ return "nulzw2";
+ var normalized = method.Trim().ToLowerInvariant();
+ return normalized switch {
+ "stored" or "store" => "stored",
+ "squeeze" or "sq" => "squeeze",
+ "nulzw1" or "lzw1" => "nulzw1",
+ "nulzw2" or "lzw2" => "nulzw2",
+ "auto" => "auto",
+ _ => throw new NotSupportedException($"NuFX creation method '{method}' is not supported."),
+ };
+ }
+}
+
+internal sealed record NuFxParsedArchive(long StartOffset, long NuFxLength, uint RecordCount, IReadOnlyList Records);
+
+internal sealed record NuFxThread(
+ ushort Class,
+ ushort Format,
+ ushort Kind,
+ ushort Crc,
+ uint UncompressedLength,
+ uint CompressedLength,
+ int HeaderOffset,
+ long DataOffset
+);
+
+internal sealed record NuFxRecord(
+ long StartOffset,
+ long RecordLength,
+ ushort Version,
+ byte FileSystemSeparator,
+ uint FileType,
+ uint ExtraType,
+ ushort StorageType,
+ string Name,
+ bool IsDiskImage,
+ long LogicalLength,
+ byte[] RawHeader,
+ IReadOnlyList Threads,
+ NuFxThread? DataThread
+);
+
+internal static class NuFxArchive {
+ internal const int MasterHeaderLength = 48;
+ private const int FixedRecordHeaderLength = 56;
+ private const int ThreadHeaderLength = 16;
+ private const ushort RecordVersion = 3;
+ private const ushort ProDosFileSystem = 1;
+ private const ushort ThreadClassMessage = 0;
+ private const ushort ThreadClassData = 2;
+ private const ushort ThreadClassFilename = 3;
+ private const ushort KindDataFork = 0;
+ private const ushort KindDiskImage = 1;
+ private const ushort KindResourceFork = 2;
+ private const ushort KindComment = 1;
+ private const int MaxRecordThreads = 4096;
+ private static readonly byte[] RecordSignature = [0x4E, 0xF5, 0x46, 0xD8];
+
+ internal static readonly byte[] MasterSignature = [0x4E, 0xF5, 0x46, 0xE9, 0x6C, 0xE5];
+
+ private const string MacRomanHigh =
+ "ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€‹›fifl‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ";
+
+ internal static NuFxParsedArchive Parse(Stream stream) {
+ ArgumentNullException.ThrowIfNull(stream);
+ if (!stream.CanRead || !stream.CanSeek)
+ throw new NotSupportedException("NuFX parsing requires a readable, seekable stream.");
+
+ var start = FindMaster(stream);
+ if (start < 0)
+ throw new InvalidDataException("NuFX master signature not found.");
+
+ stream.Position = start;
+ var master = ReadExactly(stream, MasterHeaderLength);
+ var storedMasterCrc = BinaryPrimitives.ReadUInt16LittleEndian(master.AsSpan(6, 2));
+ var calculatedMasterCrc = NuLzwCodec.Crc16Xmodem(master.AsSpan(8), 0);
+ if (storedMasterCrc != calculatedMasterCrc)
+ throw new InvalidDataException($"NuFX master CRC mismatch: stored 0x{storedMasterCrc:X4}, calculated 0x{calculatedMasterCrc:X4}.");
+
+ var recordCount = BinaryPrimitives.ReadUInt32LittleEndian(master.AsSpan(8, 4));
+ var masterVersion = BinaryPrimitives.ReadUInt16LittleEndian(master.AsSpan(0x1C, 2));
+ var declaredLength = BinaryPrimitives.ReadUInt32LittleEndian(master.AsSpan(0x26, 4));
+ var nufxLength = masterVersion > 0 && declaredLength >= MasterHeaderLength
+ ? declaredLength
+ : checked(stream.Length - start);
+ if (start + nufxLength > stream.Length)
+ throw new InvalidDataException("NuFX master EOF extends beyond the physical stream.");
+
+ var records = new List(checked((int)Math.Min(recordCount, 100000u)));
+ stream.Position = start + MasterHeaderLength;
+ for (uint index = 0; index < recordCount; index++) {
+ if (stream.Position >= start + nufxLength)
+ throw new InvalidDataException("NuFX archive ended before the declared record count.");
+ records.Add(ReadRecord(stream, start + nufxLength));
+ }
+
+ if (stream.Position > start + nufxLength)
+ throw new InvalidDataException("NuFX records extend beyond the master EOF.");
+ return new NuFxParsedArchive(start, nufxLength, recordCount, records);
+ }
+
+ internal static void Create(Stream output, IReadOnlyList records) {
+ ArgumentNullException.ThrowIfNull(output);
+ if (!output.CanWrite || !output.CanSeek)
+ throw new NotSupportedException("NuFX creation requires a writable, seekable output stream.");
+
+ output.Position = 0;
+ output.SetLength(0);
+ output.Position = MasterHeaderLength;
+ foreach (var record in records)
+ output.Write(record);
+ var length = output.Position;
+ if (length > uint.MaxValue)
+ throw new InvalidDataException("NuFX archives are limited by the 32-bit master EOF field.");
+ PatchMaster(output, checked((uint)records.Count), length);
+ output.Position = length;
+ output.SetLength(length);
+ }
+
+ internal static byte[] BuildNewRecord(string name, byte[] data, string method, bool diskImage,
+ byte fileType, ushort auxType, byte access) {
+ ArgumentNullException.ThrowIfNull(data);
+ var storedPath = NormalizePath(name).Replace('/', ':');
+ var nameBytes = EncodeMacRoman(storedPath);
+ if (nameBytes.Length == 0 || nameBytes.Length > ushort.MaxValue)
+ throw new InvalidDataException("NuFX filename length is invalid.");
+
+ var selected = CompressBest(data, method);
+ var filenameFieldLength = Math.Max(nameBytes.Length, 32);
+ const int attribCount = FixedRecordHeaderLength + 4;
+ const int threadCount = 2;
+ var headerLength = checked(attribCount + threadCount * ThreadHeaderLength);
+ var header = new byte[headerLength];
+
+ RecordSignature.CopyTo(header, 0);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(6, 2), attribCount);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(8, 2), RecordVersion);
+ BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x0A, 4), threadCount);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x0E, 2), ProDosFileSystem);
+ header[0x10] = (byte)':';
+ header[0x12] = access;
+ BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x16, 4), diskImage ? 0u : fileType);
+ BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x1A, 4),
+ diskImage ? checked((uint)(data.Length / 512)) : auxType);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1E, 2), diskImage ? (ushort)512 : (ushort)1);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x38, 2), 0);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x3A, 2), 0);
+
+ var threadOffset = attribCount;
+ WriteThreadHeader(header.AsSpan(threadOffset, ThreadHeaderLength),
+ ThreadClassFilename, 0, 0, 0, checked((uint)nameBytes.Length), checked((uint)filenameFieldLength));
+ threadOffset += ThreadHeaderLength;
+ var threadCrc = NuLzwCodec.Crc16Xmodem(data, 0xFFFF);
+ WriteThreadHeader(header.AsSpan(threadOffset, ThreadHeaderLength),
+ ThreadClassData, selected.Format, diskImage ? KindDiskImage : KindDataFork, threadCrc,
+ diskImage ? 0u : checked((uint)data.Length), checked((uint)selected.Bytes.Length));
+
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(4, 2), NuLzwCodec.Crc16Xmodem(header.AsSpan(6), 0));
+
+ using var result = new MemoryStream(header.Length + filenameFieldLength + selected.Bytes.Length);
+ result.Write(header);
+ result.Write(nameBytes);
+ if (filenameFieldLength > nameBytes.Length)
+ result.Write(new byte[filenameFieldLength - nameBytes.Length]);
+ result.Write(selected.Bytes);
+ return result.ToArray();
+ }
+
+ internal static byte[] ExtractRecord(Stream archive, NuFxRecord record) {
+ var thread = record.DataThread;
+ if (thread == null || thread.CompressedLength == 0)
+ return [];
+ archive.Position = thread.DataOffset;
+ var compressed = ReadExactly(archive, checked((int)thread.CompressedLength));
+ var logicalLength = checked((int)record.LogicalLength);
+ byte[] expanded = thread.Format switch {
+ 0 => compressed.Length == logicalLength ? compressed : compressed.AsSpan(0, Math.Min(compressed.Length, logicalLength)).ToArray(),
+ 1 => DecompressSqueeze(compressed),
+ 2 => NuLzwCodec.Decompress(compressed, NuLzwVariant.Lzw1, logicalLength),
+ 3 => NuLzwCodec.Decompress(compressed, NuLzwVariant.Lzw2, logicalLength),
+ _ => throw new NotSupportedException($"NuFX thread compression format {thread.Format} is not supported for extraction."),
+ };
+
+ if (expanded.Length < logicalLength)
+ throw new InvalidDataException($"NuFX entry '{record.Name}' expanded to {expanded.Length} bytes, expected {logicalLength}.");
+ if (expanded.Length != logicalLength)
+ expanded = expanded.AsSpan(0, logicalLength).ToArray();
+
+ if (record.Version == 3) {
+ var actual = NuLzwCodec.Crc16Xmodem(expanded, 0xFFFF);
+ if (actual != thread.Crc)
+ throw new InvalidDataException($"NuFX thread CRC mismatch for '{record.Name}': stored 0x{thread.Crc:X4}, calculated 0x{actual:X4}.");
+ }
+ return expanded;
+ }
+
+ internal static byte[] ReplaceDataForkPreservingRecord(Stream archive, NuFxRecord record, byte[] newData) {
+ if (record.Version == 2)
+ throw new NotSupportedException("Direct replacement of rare NuFX v2 records is refused because v2 thread CRC semantics differ.");
+ var target = record.DataThread;
+ if (target == null)
+ return AddDataForkPreservingRecord(archive, record, newData);
+ if (record.IsDiskImage && (newData.Length & 511) != 0)
+ throw new InvalidDataException("Replacing an SDK disk image requires a multiple-of-512 byte payload.");
+
+ var method = target.Format switch {
+ 0 => "stored",
+ 1 => "squeeze",
+ 2 => "nulzw1",
+ 3 => "nulzw2",
+ _ => "stored",
+ };
+ var selected = CompressBest(newData, method);
+ var header = (byte[])record.RawHeader.Clone();
+ var threadHeader = header.AsSpan(target.HeaderOffset, ThreadHeaderLength);
+ BinaryPrimitives.WriteUInt16LittleEndian(threadHeader.Slice(2, 2), selected.Format);
+ var crc = record.Version == 3 ? NuLzwCodec.Crc16Xmodem(newData, 0xFFFF) : (ushort)0;
+ BinaryPrimitives.WriteUInt16LittleEndian(threadHeader.Slice(6, 2), crc);
+ BinaryPrimitives.WriteUInt32LittleEndian(threadHeader.Slice(8, 4),
+ record.IsDiskImage ? 0u : checked((uint)newData.Length));
+ BinaryPrimitives.WriteUInt32LittleEndian(threadHeader.Slice(12, 4), checked((uint)selected.Bytes.Length));
+
+ if (record.IsDiskImage) {
+ BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x1A, 4), checked((uint)(newData.Length / 512)));
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1E, 2), 512);
+ }
+
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(4, 2), NuLzwCodec.Crc16Xmodem(header.AsSpan(6), 0));
+ return AssembleRecord(archive, record, header, target, selected.Bytes, trimSlack: false);
+ }
+
+ private static byte[] AddDataForkPreservingRecord(Stream archive, NuFxRecord record, byte[] newData) {
+ var selected = CompressBest(newData, "nulzw2");
+ var oldHeader = record.RawHeader;
+ var header = new byte[checked(oldHeader.Length + ThreadHeaderLength)];
+ oldHeader.CopyTo(header, 0);
+ BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x0A, 4), checked((uint)record.Threads.Count + 1));
+
+ var newThreadOffset = oldHeader.Length;
+ var crc = record.Version == 3 ? NuLzwCodec.Crc16Xmodem(newData, 0xFFFF) : (ushort)0;
+ WriteThreadHeader(header.AsSpan(newThreadOffset, ThreadHeaderLength),
+ ThreadClassData, selected.Format, KindDataFork, crc,
+ checked((uint)newData.Length), checked((uint)selected.Bytes.Length));
+
+ if (record.Threads.Any(t => t.Class == ThreadClassData && t.Kind == KindResourceFork))
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1E, 2), 5);
+ else if (record.StorageType == 0)
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1E, 2), 1);
+
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(4, 2), NuLzwCodec.Crc16Xmodem(header.AsSpan(6), 0));
+
+ using var output = new MemoryStream();
+ output.Write(header);
+ foreach (var thread in record.Threads) {
+ if (thread.CompressedLength == 0)
+ continue;
+ archive.Position = thread.DataOffset;
+ CopyExactly(archive, output, thread.CompressedLength);
+ }
+ output.Write(selected.Bytes);
+ return output.ToArray();
+ }
+
+ internal static byte[] CompactRecord(Stream archive, NuFxRecord record) {
+ var header = (byte[])record.RawHeader.Clone();
+ var changed = false;
+ foreach (var thread in record.Threads) {
+ if (!IsSlackThread(thread) || thread.CompressedLength <= thread.UncompressedLength)
+ continue;
+ var span = header.AsSpan(thread.HeaderOffset, ThreadHeaderLength);
+ BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12, 4), thread.UncompressedLength);
+ changed = true;
+ }
+ if (!changed)
+ return ReadRange(archive, record.StartOffset, record.RecordLength);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(4, 2), NuLzwCodec.Crc16Xmodem(header.AsSpan(6), 0));
+ return AssembleRecord(archive, record, header, null, null, trimSlack: true);
+ }
+
+ internal static void RequireWritablePlainArchive(Stream archive) {
+ if (!archive.CanRead || !archive.CanWrite || !archive.CanSeek)
+ throw new NotSupportedException("NuFX direct mutation requires a readable, writable, seekable stream.");
+ var parsed = Parse(archive);
+ if (parsed.StartOffset != 0)
+ throw new NotSupportedException("Direct mutation of wrapped BXY/SEA NuFX archives is not enabled; plain SHK/SDK archives are fully R/W.");
+ }
+
+ internal static void PatchMaster(Stream stream, uint count, long nufxLength) {
+ if (nufxLength is < MasterHeaderLength or > uint.MaxValue)
+ throw new InvalidDataException("NuFX master EOF is outside its 32-bit representable range.");
+ const long start = 0;
+
+ var master = new byte[MasterHeaderLength];
+ MasterSignature.CopyTo(master, 0);
+ if (stream.Length >= start + MasterHeaderLength) {
+ stream.Position = start;
+ var old = ReadExactly(stream, MasterHeaderLength);
+ old.CopyTo(master, 0);
+ MasterSignature.CopyTo(master, 0);
+ }
+ BinaryPrimitives.WriteUInt32LittleEndian(master.AsSpan(8, 4), count);
+ BinaryPrimitives.WriteUInt16LittleEndian(master.AsSpan(0x1C, 2), 2);
+ BinaryPrimitives.WriteUInt32LittleEndian(master.AsSpan(0x26, 4), checked((uint)nufxLength));
+ BinaryPrimitives.WriteUInt16LittleEndian(master.AsSpan(6, 2), NuLzwCodec.Crc16Xmodem(master.AsSpan(8), 0));
+ stream.Position = start;
+ stream.Write(master);
+ }
+
+ internal static void ReplaceRange(Stream stream, long offset, long oldLength, ReadOnlySpan replacement) {
+ if (!stream.CanRead || !stream.CanWrite || !stream.CanSeek)
+ throw new NotSupportedException("NuFX mutation requires a readable, writable, seekable stream.");
+ if (offset < 0 || oldLength < 0 || offset + oldLength > stream.Length)
+ throw new ArgumentOutOfRangeException(nameof(offset));
+
+ var replacementLength = replacement.Length;
+ var delta = replacementLength - oldLength;
+ var tailStart = offset + oldLength;
+ var originalLength = stream.Length;
+ var buffer = new byte[64 * 1024];
+
+ if (delta > 0) {
+ stream.SetLength(checked(originalLength + delta));
+ var remaining = originalLength - tailStart;
+ while (remaining > 0) {
+ var chunk = (int)Math.Min(buffer.Length, remaining);
+ var readAt = tailStart + remaining - chunk;
+ stream.Position = readAt;
+ stream.ReadExactly(buffer.AsSpan(0, chunk));
+ stream.Position = readAt + delta;
+ stream.Write(buffer, 0, chunk);
+ remaining -= chunk;
+ }
+ } else if (delta < 0) {
+ var readAt = tailStart;
+ var writeAt = offset + replacementLength;
+ while (readAt < originalLength) {
+ var chunk = (int)Math.Min(buffer.Length, originalLength - readAt);
+ stream.Position = readAt;
+ stream.ReadExactly(buffer.AsSpan(0, chunk));
+ stream.Position = writeAt;
+ stream.Write(buffer, 0, chunk);
+ readAt += chunk;
+ writeAt += chunk;
+ }
+ Array.Clear(buffer);
+ var wipeAt = originalLength + delta;
+ var wipeRemaining = -delta;
+ while (wipeRemaining > 0) {
+ var chunk = (int)Math.Min(buffer.Length, wipeRemaining);
+ stream.Position = wipeAt;
+ stream.Write(buffer, 0, chunk);
+ wipeAt += chunk;
+ wipeRemaining -= chunk;
+ }
+ stream.SetLength(originalLength + delta);
+ }
+
+ stream.Position = offset;
+ if (!replacement.IsEmpty)
+ stream.Write(replacement);
+ }
+
+ internal static string MethodName(ushort format) => format switch {
+ 0 => "Stored",
+ 1 => "Squeeze",
+ 2 => "NuLZW/1",
+ 3 => "NuLZW/2",
+ 4 => "LZC-12",
+ 5 => "LZC-16",
+ _ => $"NuFX-{format}",
+ };
+
+ internal static string NormalizePath(string path)
+ => path.Replace('\\', '/').Trim('/');
+
+ internal static byte[] EncodeMacRoman(string text) {
+ using var output = new MemoryStream(text.Length);
+ foreach (var ch in text) {
+ if (ch <= 0x7F) {
+ output.WriteByte((byte)ch);
+ continue;
+ }
+ var index = MacRomanHigh.IndexOf(ch);
+ output.WriteByte(index >= 0 ? checked((byte)(0x80 + index)) : (byte)'?');
+ }
+ return output.ToArray();
+ }
+
+ private static string DecodeMacRoman(ReadOnlySpan bytes) {
+ var sb = new StringBuilder(bytes.Length);
+ foreach (var value in bytes)
+ sb.Append(value < 0x80 ? (char)value : MacRomanHigh[value - 0x80]);
+ return sb.ToString();
+ }
+
+ private static long FindMaster(Stream stream) {
+ var original = stream.Position;
+ try {
+ var max = (int)Math.Min(1024, Math.Max(0, stream.Length - MasterSignature.Length));
+ var probe = new byte[max + MasterSignature.Length];
+ stream.Position = 0;
+ var read = stream.Read(probe, 0, probe.Length);
+ for (var offset = 0; offset <= read - MasterSignature.Length; offset++) {
+ if (probe.AsSpan(offset, MasterSignature.Length).SequenceEqual(MasterSignature))
+ return offset;
+ }
+ return -1;
+ } finally {
+ stream.Position = original;
+ }
+ }
+
+ private static NuFxRecord ReadRecord(Stream stream, long archiveEnd) {
+ var start = stream.Position;
+ var fixedHeader = ReadExactly(stream, FixedRecordHeaderLength);
+ if (!fixedHeader.AsSpan(0, 4).SequenceEqual(RecordSignature))
+ throw new InvalidDataException($"NuFX record signature missing at 0x{start:X}.");
+
+ var storedCrc = BinaryPrimitives.ReadUInt16LittleEndian(fixedHeader.AsSpan(4, 2));
+ var attribCount = BinaryPrimitives.ReadUInt16LittleEndian(fixedHeader.AsSpan(6, 2));
+ var version = BinaryPrimitives.ReadUInt16LittleEndian(fixedHeader.AsSpan(8, 2));
+ var threadCount = BinaryPrimitives.ReadUInt32LittleEndian(fixedHeader.AsSpan(0x0A, 4));
+ // attrib_count is a 16-bit field, so its own width is the upper bound; the
+ // megabyte ceiling it was compared against could never be reached.
+ if (attribCount < FixedRecordHeaderLength + 2)
+ throw new InvalidDataException($"NuFX record attribute count {attribCount} is invalid.");
+ if (threadCount > MaxRecordThreads)
+ throw new InvalidDataException($"NuFX record thread count {threadCount} is unreasonable.");
+ if (version > 3)
+ throw new NotSupportedException($"NuFX record version {version} is not supported.");
+
+ var variable = ReadExactly(stream, attribCount - FixedRecordHeaderLength);
+ var deprecatedNameLength = BinaryPrimitives.ReadUInt16LittleEndian(variable.AsSpan(variable.Length - 2, 2));
+ var deprecatedName = ReadExactly(stream, deprecatedNameLength);
+ var threadHeadersLength = checked((int)threadCount * ThreadHeaderLength);
+ var threadHeaders = ReadExactly(stream, threadHeadersLength);
+
+ var rawHeader = new byte[checked(attribCount + deprecatedNameLength + threadHeadersLength)];
+ fixedHeader.CopyTo(rawHeader, 0);
+ variable.CopyTo(rawHeader, FixedRecordHeaderLength);
+ deprecatedName.CopyTo(rawHeader, attribCount);
+ threadHeaders.CopyTo(rawHeader, attribCount + deprecatedNameLength);
+
+ var calculatedCrc = NuLzwCodec.Crc16Xmodem(rawHeader.AsSpan(6), 0);
+ if (storedCrc != calculatedCrc)
+ throw new InvalidDataException($"NuFX record CRC mismatch at 0x{start:X}: stored 0x{storedCrc:X4}, calculated 0x{calculatedCrc:X4}.");
+
+ var separator = fixedHeader[0x10] == 0 ? (byte)':' : fixedHeader[0x10];
+ var fileType = BinaryPrimitives.ReadUInt32LittleEndian(fixedHeader.AsSpan(0x16, 4));
+ var extraType = BinaryPrimitives.ReadUInt32LittleEndian(fixedHeader.AsSpan(0x1A, 4));
+ var storageType = BinaryPrimitives.ReadUInt16LittleEndian(fixedHeader.AsSpan(0x1E, 2));
+
+ var dataStart = checked(start + rawHeader.LongLength);
+ var dataOffset = dataStart;
+ var threads = new List(checked((int)threadCount));
+ for (var index = 0; index < threadCount; index++) {
+ var headerOffset = checked(attribCount + deprecatedNameLength + (int)index * ThreadHeaderLength);
+ var span = rawHeader.AsSpan(headerOffset, ThreadHeaderLength);
+ var thread = new NuFxThread(
+ BinaryPrimitives.ReadUInt16LittleEndian(span),
+ BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(2, 2)),
+ BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(4, 2)),
+ BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(6, 2)),
+ BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(8, 4)),
+ BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(12, 4)),
+ headerOffset,
+ dataOffset
+ );
+ threads.Add(thread);
+ dataOffset = checked(dataOffset + thread.CompressedLength);
+ if (dataOffset > archiveEnd)
+ throw new InvalidDataException($"NuFX record at 0x{start:X} extends past the master EOF.");
+ }
+
+ byte[] nameBytes = deprecatedName;
+ var filenameThread = threads.FirstOrDefault(t => t.Class == ThreadClassFilename && t.Kind == 0);
+ if (filenameThread != null && filenameThread.UncompressedLength > 0) {
+ if (filenameThread.UncompressedLength > filenameThread.CompressedLength)
+ throw new InvalidDataException("NuFX filename thread logical length exceeds its allocated field.");
+ stream.Position = filenameThread.DataOffset;
+ nameBytes = ReadExactly(stream, checked((int)filenameThread.UncompressedLength));
+ }
+
+ var storedName = DecodeMacRoman(nameBytes);
+ if (separator != 0)
+ storedName = storedName.Replace((char)separator, '/');
+ var name = NormalizePath(storedName);
+ var dataThread = threads.FirstOrDefault(t =>
+ t.Class == ThreadClassData && (t.Kind == KindDataFork || t.Kind == KindDiskImage));
+ var diskImage = dataThread?.Kind == KindDiskImage;
+ var logicalLength = dataThread == null
+ ? 0
+ : diskImage
+ ? checked((long)extraType * 512)
+ : dataThread.UncompressedLength;
+ var recordEnd = dataOffset;
+ stream.Position = recordEnd;
+ return new NuFxRecord(start, recordEnd - start, version, separator, fileType, extraType,
+ storageType, name, diskImage, logicalLength, rawHeader, threads, dataThread);
+ }
+
+ private static byte[] AssembleRecord(Stream archive, NuFxRecord record, byte[] header,
+ NuFxThread? replacementThread, byte[]? replacementBytes, bool trimSlack) {
+ using var output = new MemoryStream();
+ output.Write(header);
+ foreach (var thread in record.Threads) {
+ if (replacementThread != null && ReferenceEquals(thread, replacementThread)) {
+ output.Write(replacementBytes!);
+ continue;
+ }
+ var length = thread.CompressedLength;
+ if (trimSlack && IsSlackThread(thread))
+ length = Math.Min(thread.CompressedLength, thread.UncompressedLength);
+ if (length == 0)
+ continue;
+ archive.Position = thread.DataOffset;
+ CopyExactly(archive, output, length);
+ }
+ return output.ToArray();
+ }
+
+ private static bool IsSlackThread(NuFxThread thread)
+ => (thread.Class == ThreadClassFilename && thread.Kind == 0) ||
+ (thread.Class == ThreadClassMessage && thread.Kind == KindComment);
+
+ private static (ushort Format, byte[] Bytes) CompressBest(byte[] data, string method) {
+ if (method == "stored")
+ return (0, data);
+ if (method == "squeeze")
+ return (1, CompressSqueeze(data));
+ if (method == "nulzw1")
+ return (2, NuLzwCodec.Compress(data, NuLzwVariant.Lzw1));
+ if (method == "nulzw2")
+ return (3, NuLzwCodec.Compress(data, NuLzwVariant.Lzw2));
+ if (method != "auto")
+ throw new NotSupportedException($"Unsupported NuFX method '{method}'.");
+
+ var candidates = new (ushort Format, byte[] Bytes)[] {
+ (0, data),
+ (1, CompressSqueeze(data)),
+ (2, NuLzwCodec.Compress(data, NuLzwVariant.Lzw1)),
+ (3, NuLzwCodec.Compress(data, NuLzwVariant.Lzw2)),
+ };
+ return candidates.OrderBy(c => c.Bytes.Length).ThenBy(c => c.Format).First();
+ }
+
+ private static byte[] CompressSqueeze(byte[] data) {
+ using var input = new MemoryStream(data, writable: false);
+ using var output = new MemoryStream();
+ SqueezeStream.Compress(input, output, string.Empty);
+ return output.ToArray();
+ }
+
+ private static byte[] DecompressSqueeze(byte[] data) {
+ using var input = new MemoryStream(data, writable: false);
+ using var output = new MemoryStream();
+ SqueezeStream.Decompress(input, output);
+ return output.ToArray();
+ }
+
+ private static void WriteThreadHeader(Span destination, ushort cls, ushort format, ushort kind,
+ ushort crc, uint eof, uint compressedEof) {
+ BinaryPrimitives.WriteUInt16LittleEndian(destination, cls);
+ BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(2, 2), format);
+ BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(4, 2), kind);
+ BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(6, 2), crc);
+ BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(8, 4), eof);
+ BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(12, 4), compressedEof);
+ }
+
+ private static byte[] ReadExactly(Stream stream, int length) {
+ if (length < 0)
+ throw new InvalidDataException("Negative NuFX field length.");
+ var data = new byte[length];
+ if (length != 0)
+ stream.ReadExactly(data);
+ return data;
+ }
+
+ private static byte[] ReadRange(Stream stream, long offset, long length) {
+ if (length > int.MaxValue)
+ throw new NotSupportedException("NuFX record is too large to materialize.");
+ stream.Position = offset;
+ return ReadExactly(stream, checked((int)length));
+ }
+
+ private static void CopyExactly(Stream input, Stream output, long length) {
+ var buffer = new byte[64 * 1024];
+ var remaining = length;
+ while (remaining > 0) {
+ var count = (int)Math.Min(buffer.Length, remaining);
+ input.ReadExactly(buffer.AsSpan(0, count));
+ output.Write(buffer, 0, count);
+ remaining -= count;
+ }
+ }
+}
diff --git a/FileFormats/FileFormat.Squeeze/NuFx/NuFxSqueezeStream.cs b/FileFormats/FileFormat.Squeeze/NuFx/NuFxSqueezeStream.cs
new file mode 100644
index 000000000..5f860ffe5
--- /dev/null
+++ b/FileFormats/FileFormat.Squeeze/NuFx/NuFxSqueezeStream.cs
@@ -0,0 +1,268 @@
+using System.Buffers.Binary;
+
+namespace FileFormat.NuFx;
+
+///
+/// Headerless Richard Greenlaw Squeeze stream used by NuFX thread format 1.
+///
+///
+/// Standalone Squeeze files prepend magic/checksum/filename fields. NuFX deliberately omits
+/// that outer header and stores only the node table followed by the LSB-first Huffman stream.
+/// The input is first transformed by the historical 0x90 run-length stage. NuFX v3 supplies
+/// integrity through the thread CRC, so there is no standalone Squeeze checksum here.
+///
+internal static class SqueezeStream {
+ private const byte RleDelimiter = 0x90;
+ private const int EofSymbol = 256;
+ private const int SymbolCount = 257;
+
+ public static void Compress(Stream input, Stream output, string originalFilename = "") {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
+ _ = originalFilename;
+
+ using var raw = new MemoryStream();
+ input.CopyTo(raw);
+ var rle = EncodeRle(raw.ToArray());
+
+ // The historical representation of an empty stream is a zero-node tree with
+ // no bitstream at all. EOF is implicit in that special case.
+ if (rle.Length == 0) {
+ WriteUInt16(output, 0);
+ return;
+ }
+
+ var used = new bool[SymbolCount];
+ foreach (var value in rle)
+ used[value] = true;
+ used[EofSymbol] = true;
+ var symbols = Enumerable.Range(0, SymbolCount).Where(i => used[i]).ToArray();
+
+ var root = BuildBalancedTree(symbols, 0, symbols.Length);
+ var nodes = new List();
+ _ = SerializeTree(root, nodes);
+ if (nodes.Count > ushort.MaxValue)
+ throw new InvalidDataException("Squeeze tree exceeds the 16-bit node-count field.");
+
+ WriteUInt16(output, checked((ushort)nodes.Count));
+ foreach (var node in nodes) {
+ WriteInt16(output, checked((short)node.Left));
+ WriteInt16(output, checked((short)node.Right));
+ }
+
+ var codes = new Code[SymbolCount];
+ BuildCodes(root, 0, 0, codes);
+ var bits = new LsbWriter(output);
+ foreach (var value in rle) {
+ var code = codes[value];
+ bits.Write(code.Bits, code.Length);
+ }
+ var eof = codes[EofSymbol];
+ bits.Write(eof.Bits, eof.Length);
+ bits.FinishWithGuardByte();
+ }
+
+ public static void Decompress(Stream input, Stream output) {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
+
+ var nodeCount = ReadUInt16(input);
+ if (nodeCount == 0)
+ return;
+ if (nodeCount > 256)
+ throw new InvalidDataException($"NuFX Squeeze node count {nodeCount} exceeds the 257-symbol tree limit.");
+
+ var nodes = new SerializedNode[nodeCount];
+ for (var i = 0; i < nodes.Length; i++)
+ nodes[i] = new SerializedNode(ReadInt16(input), ReadInt16(input));
+
+ var reader = new LsbReader(input);
+ var sawDelimiter = false;
+ var haveLast = false;
+ byte last = 0;
+
+ while (true) {
+ var symbol = DecodeSymbol(nodes, reader);
+ if (symbol == EofSymbol)
+ break;
+ if (symbol is < 0 or > 255)
+ throw new InvalidDataException($"NuFX Squeeze tree produced invalid symbol {symbol}.");
+
+ var value = (byte)symbol;
+ if (sawDelimiter) {
+ if (value == 0) {
+ output.WriteByte(RleDelimiter);
+ last = RleDelimiter;
+ haveLast = true;
+ } else {
+ if (!haveLast)
+ throw new InvalidDataException("NuFX Squeeze RLE count appears before a literal value.");
+ // The first copy of the run was already emitted before the delimiter.
+ for (var i = 1; i < value; i++)
+ output.WriteByte(last);
+ }
+ sawDelimiter = false;
+ continue;
+ }
+
+ if (value == RleDelimiter) {
+ sawDelimiter = true;
+ continue;
+ }
+
+ output.WriteByte(value);
+ last = value;
+ haveLast = true;
+ }
+
+ if (sawDelimiter)
+ throw new InvalidDataException("NuFX Squeeze stream ends in an incomplete RLE escape.");
+ }
+
+ private static byte[] EncodeRle(ReadOnlySpan source) {
+ using var output = new MemoryStream(source.Length);
+ var offset = 0;
+ while (offset < source.Length) {
+ var value = source[offset];
+ if (value == RleDelimiter) {
+ output.WriteByte(RleDelimiter);
+ output.WriteByte(0);
+ offset++;
+ continue;
+ }
+
+ var count = 1;
+ while (offset + count < source.Length && source[offset + count] == value && count < 255)
+ count++;
+
+ output.WriteByte(value);
+ if (count == 2)
+ output.WriteByte(value);
+ else if (count >= 3) {
+ output.WriteByte(RleDelimiter);
+ output.WriteByte((byte)count);
+ }
+ offset += count;
+ }
+ return output.ToArray();
+ }
+
+ private static TreeNode BuildBalancedTree(int[] symbols, int start, int count) {
+ if (count == 1)
+ return new TreeNode(symbols[start], null, null);
+ var leftCount = count / 2;
+ return new TreeNode(null,
+ BuildBalancedTree(symbols, start, leftCount),
+ BuildBalancedTree(symbols, start + leftCount, count - leftCount));
+ }
+
+ private static int SerializeTree(TreeNode node, List nodes) {
+ if (node.Symbol.HasValue)
+ return -(node.Symbol.Value + 1);
+
+ var index = nodes.Count;
+ nodes.Add(default);
+ var left = SerializeTree(node.Left!, nodes);
+ var right = SerializeTree(node.Right!, nodes);
+ nodes[index] = new SerializedNode(left, right);
+ return index;
+ }
+
+ private static void BuildCodes(TreeNode node, uint bits, int depth, Code[] codes) {
+ if (node.Symbol.HasValue) {
+ if (depth is < 1 or > 16)
+ throw new InvalidDataException($"Squeeze code length {depth} is outside the historical 1..16-bit range.");
+ codes[node.Symbol.Value] = new Code(bits, depth);
+ return;
+ }
+ BuildCodes(node.Left!, bits, depth + 1, codes);
+ BuildCodes(node.Right!, bits | (1u << depth), depth + 1, codes);
+ }
+
+ private static int DecodeSymbol(SerializedNode[] nodes, LsbReader reader) {
+ var node = 0;
+ var guard = 0;
+ while (true) {
+ if ((uint)node >= (uint)nodes.Length)
+ throw new InvalidDataException($"NuFX Squeeze tree references invalid node {node}.");
+ if (++guard > nodes.Length + 1)
+ throw new InvalidDataException("NuFX Squeeze tree contains a cycle.");
+
+ var child = reader.ReadBit() == 0 ? nodes[node].Left : nodes[node].Right;
+ if (child < 0)
+ return -(child + 1);
+ node = child;
+ }
+ }
+
+ private static ushort ReadUInt16(Stream input) {
+ Span bytes = stackalloc byte[2];
+ input.ReadExactly(bytes);
+ return BinaryPrimitives.ReadUInt16LittleEndian(bytes);
+ }
+
+ private static short ReadInt16(Stream input) {
+ Span bytes = stackalloc byte[2];
+ input.ReadExactly(bytes);
+ return BinaryPrimitives.ReadInt16LittleEndian(bytes);
+ }
+
+ private static void WriteUInt16(Stream output, ushort value) {
+ Span bytes = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16LittleEndian(bytes, value);
+ output.Write(bytes);
+ }
+
+ private static void WriteInt16(Stream output, short value) {
+ Span bytes = stackalloc byte[2];
+ BinaryPrimitives.WriteInt16LittleEndian(bytes, value);
+ output.Write(bytes);
+ }
+
+ private sealed record TreeNode(int? Symbol, TreeNode? Left, TreeNode? Right);
+ private readonly record struct SerializedNode(int Left, int Right);
+ private readonly record struct Code(uint Bits, int Length);
+
+ private sealed class LsbWriter(Stream output) {
+ private uint _bits;
+ private int _count;
+
+ public void Write(uint value, int width) {
+ this._bits |= value << this._count;
+ this._count += width;
+ while (this._count >= 8) {
+ output.WriteByte((byte)this._bits);
+ this._bits >>= 8;
+ this._count -= 8;
+ }
+ }
+
+ public void FinishWithGuardByte() {
+ if (this._count > 0)
+ output.WriteByte((byte)this._bits);
+ // Original SQ/USQ implementations commonly leave one zero look-ahead byte.
+ // It is harmless after EOF and improves compatibility with decoders that read ahead.
+ output.WriteByte(0);
+ this._bits = 0;
+ this._count = 0;
+ }
+ }
+
+ private sealed class LsbReader(Stream input) {
+ private int _current;
+ private int _bitsLeft;
+
+ public int ReadBit() {
+ if (this._bitsLeft == 0) {
+ this._current = input.ReadByte();
+ if (this._current < 0)
+ throw new InvalidDataException("NuFX Squeeze Huffman bitstream is truncated.");
+ this._bitsLeft = 8;
+ }
+ var bit = this._current & 1;
+ this._current >>= 1;
+ this._bitsLeft--;
+ return bit;
+ }
+ }
+}
diff --git a/Hawkynt.FileFormats.Archives/README.md b/Hawkynt.FileFormats.Archives/README.md
index 6df766d07..29f31f861 100644
--- a/Hawkynt.FileFormats.Archives/README.md
+++ b/Hawkynt.FileFormats.Archives/README.md
@@ -615,7 +615,7 @@ This package is built against the repository's shared Core version. Consume a mu
-Every public and protected member of all 968 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.Archives/REFERENCE.md).
+Every public and protected member of all 969 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.Archives/REFERENCE.md).
diff --git a/Hawkynt.FileFormats.Archives/REFERENCE.md b/Hawkynt.FileFormats.Archives/REFERENCE.md
index 713901e72..70f92e60e 100644
--- a/Hawkynt.FileFormats.Archives/REFERENCE.md
+++ b/Hawkynt.FileFormats.Archives/REFERENCE.md
@@ -10582,6 +10582,51 @@ Writes a minimal NSIS-formatted file. No PE stub is emitted — the reader's `Sc
| `AddFile` | `void AddFile(string name, byte[] data)` | |
| `WriteTo` | `void WriteTo(Stream output)` | |
+### Namespace `FileFormat.NuFx`
+
+[`NuFxFormatDescriptor`](#nufxformatdescriptor)
+
+#### `NuFxFormatDescriptor`
+
+NuFX / ShrinkIt archive descriptor for Apple II and Apple IIgs archives. Supports plain SHK/SDK archives, native Stored/Squeeze/NuLZW1/NuLZW2 creation, record-preserving direct add/replace/remove, and slack-compacting rebuilds.
+
+Implements `IArchiveCreatable`, `IArchiveDefragmentable`, `IArchiveFormatOperations`, `IArchiveLayoutMap`, `IArchiveModifiable`, `IArchiveShrinkable`, `IArchiveWriteConstraints`, `IFormatDescriptor`, `IFormatOptionsSchema`, `IFormatValidator`.
+
+| Member | Signature | Summary |
+| --- | --- | --- |
+| `NuFxFormatDescriptor` | `NuFxFormatDescriptor()` | |
+| `AcceptedInputsDescription` | `string AcceptedInputsDescription { get; }` | |
+| `Capabilities` | `FormatCapabilities Capabilities { get; }` | |
+| `Category` | `FormatCategory Category { get; }` | |
+| `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | |
+| `DefaultExtension` | `string DefaultExtension { get; }` | |
+| `Description` | `string Description { get; }` | |
+| `DisplayName` | `string DisplayName { get; }` | |
+| `Extensions` | `IReadOnlyList Extensions { get; }` | |
+| `Family` | `AlgorithmFamily Family { get; }` | |
+| `Id` | `string Id { get; }` | |
+| `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | |
+| `MaxTotalArchiveSize` | `long? MaxTotalArchiveSize { get; }` | |
+| `Methods` | `IReadOnlyList Methods { get; }` | |
+| `MinTotalArchiveSize` | `long? MinTotalArchiveSize { get; }` | |
+| `OptionsSchema` | `IReadOnlyList OptionsSchema { get; }` | |
+| `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | |
+| `Add` | `void Add(Stream archive, IReadOnlyList inputs)` | |
+| `CanAccept` | `bool CanAccept(ArchiveInputInfo input, out string reason)` | |
+| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | |
+| `Defragment` | `void Defragment(Stream archive)` | |
+| `Defragment` | `void Defragment(Stream archive, DefragOptions options)` | |
+| `EnumerateLayout` | `IEnumerable EnumerateLayout(Stream archive)` | |
+| `ExtractEntryToMemory` | `byte[] ExtractEntryToMemory(Stream archive, string entryName, string password)` | |
+| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | |
+| `List` | `List List(Stream stream, string password)` | |
+| `OpenEntry` | `Stream OpenEntry(Stream archive, string entryName, string password)` | |
+| `Remove` | `void Remove(Stream archive, string[] entryNames)` | |
+| `Shrink` | `void Shrink(Stream input, Stream output)` | |
+| `ValidateHeader` | `ValidationResult ValidateHeader(ReadOnlySpan header, long fileSize)` | |
+| `ValidateIntegrity` | `ValidationResult ValidateIntegrity(Stream stream)` | |
+| `ValidateStructure` | `ValidationResult ValidateStructure(Stream stream)` | |
+
### Namespace `FileFormat.NuPkg`
[`NuPkgFormatDescriptor`](#nupkgformatdescriptor)