From 894a91d6b052cfa3399d5576e0311ecbdc712ab3 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sat, 29 Aug 2026 21:07:39 +0200 Subject: [PATCH] + read and write NuFX ShrinkIt archives Records, their threads, and the LZW/1 and LZW/2 dialects ShrinkIt stores them in, with the CRC each thread carries checked on the way back out. Two things stopped it building: the encoder held its running LZW prefix in a byte, which cannot hold a code past 255, and a record's 16-bit attribute count was bounded against a megabyte, which it can never reach. --- Compression.Core/Dictionary/Lzw/NuLzwCodec.cs | 505 +++++++++ Compression.Core/README.md | 2 +- Compression.Core/REFERENCE.md | 37 +- .../BuildingBlocks/NuLzwTests.cs | 132 +++ Compression.Tests/NuFx/NuFxSqueezeTests.cs | 58 ++ Compression.Tests/NuFx/NuFxTests.cs | 224 ++++ .../NuFx/NuFxFormatDescriptor.cs | 962 ++++++++++++++++++ .../NuFx/NuFxSqueezeStream.cs | 268 +++++ Hawkynt.FileFormats.Archives/README.md | 2 +- Hawkynt.FileFormats.Archives/REFERENCE.md | 45 + 10 files changed, 2232 insertions(+), 3 deletions(-) create mode 100644 Compression.Core/Dictionary/Lzw/NuLzwCodec.cs create mode 100644 Compression.Tests/BuildingBlocks/NuLzwTests.cs create mode 100644 Compression.Tests/NuFx/NuFxSqueezeTests.cs create mode 100644 Compression.Tests/NuFx/NuFxTests.cs create mode 100644 FileFormats/FileFormat.Squeeze/NuFx/NuFxFormatDescriptor.cs create mode 100644 FileFormats/FileFormat.Squeeze/NuFx/NuFxSqueezeStream.cs 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)