From 2025720a37de7a4e12675ac23f4138a265088b64 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:54:03 +0200 Subject: [PATCH 01/13] + real Quake PAK entry model --- FileFormats/FileFormat.Pak/PakEntry.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 FileFormats/FileFormat.Pak/PakEntry.cs diff --git a/FileFormats/FileFormat.Pak/PakEntry.cs b/FileFormats/FileFormat.Pak/PakEntry.cs new file mode 100644 index 000000000..3a15e1edf --- /dev/null +++ b/FileFormats/FileFormat.Pak/PakEntry.cs @@ -0,0 +1,13 @@ +namespace FileFormat.Pak; + +/// One directory entry in a Quake PACK archive. +public sealed class PakEntry { + /// Gets the archive-relative file name. + public string FileName { get; init; } = ""; + + /// Gets the absolute byte offset of the stored payload. + public int FileOffset { get; init; } + + /// Gets the stored payload length in bytes. + public int Size { get; init; } +} From df52c73124fac86451b882326ad36706ff92f02f Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:54:24 +0200 Subject: [PATCH 02/13] * read the actual Quake PACK directory --- FileFormats/FileFormat.Pak/PakReader.cs | 95 +++++++++++++++++++++---- 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/FileFormats/FileFormat.Pak/PakReader.cs b/FileFormats/FileFormat.Pak/PakReader.cs index 120dca21f..6eac6ea28 100644 --- a/FileFormats/FileFormat.Pak/PakReader.cs +++ b/FileFormats/FileFormat.Pak/PakReader.cs @@ -1,25 +1,94 @@ -using FileFormat.Arc; +using System.Buffers.Binary; +using System.Text; namespace FileFormat.Pak; /// -/// Reads PAK archives. PAK is an ARC-compatible format (same binary layout). -/// Delegates to for all operations. +/// Reads id Software Quake PACK archives: a 12-byte PACK header followed +/// by stored file payloads and a 64-byte-per-entry directory referenced by the +/// header. The directory may be physically anywhere in a readable archive; the +/// in-place modifier deliberately requires the canonical trailing-directory form. /// public sealed class PakReader : IDisposable { - private readonly ArcReader _inner; + internal const int HeaderSize = 12; + internal const int DirectoryEntrySize = 64; + internal const int NameFieldSize = 56; - /// - /// Reads a PAK archive from a stream. - /// - public PakReader(Stream stream) => this._inner = new ArcReader(stream); + private readonly Stream _stream; + private readonly List _entries = []; + private int _nextIndex; + private PakEntry? _current; - /// Gets the next entry, or null if no more entries. - public ArcEntry? GetNextEntry() => this._inner.GetNextEntry(); + /// Gets the byte offset of the PACK directory. + public int DirectoryOffset { get; } - /// Reads the data of the current entry. - public byte[] ReadEntryData() => this._inner.ReadEntryData(); + /// Gets the directory length in bytes. + public int DirectoryLength { get; } + + /// Gets all directory entries in on-disk order. + public IReadOnlyList Entries => this._entries; + + /// Reads a PAK archive from a seekable stream. + public PakReader(Stream stream) { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanRead || !stream.CanSeek) + throw new NotSupportedException("Quake PAK reading requires a seekable, readable stream."); + this._stream = stream; + + if (stream.Length < HeaderSize) + throw new InvalidDataException("Quake PAK is shorter than its 12-byte PACK header."); + + Span header = stackalloc byte[HeaderSize]; + stream.Position = 0; + stream.ReadExactly(header); + if (!header[..4].SequenceEqual("PACK"u8)) + throw new InvalidDataException("Not a Quake PAK archive: missing PACK magic."); + + var directoryOffset = BinaryPrimitives.ReadInt32LittleEndian(header[4..8]); + var directoryLength = BinaryPrimitives.ReadInt32LittleEndian(header[8..12]); + if (directoryOffset < HeaderSize || directoryLength < 0 || directoryLength % DirectoryEntrySize != 0) + throw new InvalidDataException("Quake PAK has an invalid directory offset or length."); + if ((long)directoryOffset + directoryLength > stream.Length) + throw new InvalidDataException("Quake PAK directory extends beyond end of stream."); + + this.DirectoryOffset = directoryOffset; + this.DirectoryLength = directoryLength; + + var count = directoryLength / DirectoryEntrySize; + var record = new byte[DirectoryEntrySize]; + stream.Position = directoryOffset; + for (var i = 0; i < count; ++i) { + stream.ReadExactly(record); + var terminator = Array.IndexOf(record, (byte)0, 0, NameFieldSize); + var nameLength = terminator >= 0 ? terminator : NameFieldSize; + var name = Encoding.ASCII.GetString(record, 0, nameLength); + var fileOffset = BinaryPrimitives.ReadInt32LittleEndian(record.AsSpan(56, 4)); + var fileLength = BinaryPrimitives.ReadInt32LittleEndian(record.AsSpan(60, 4)); + if (fileOffset < 0 || fileLength < 0 || (long)fileOffset + fileLength > stream.Length) + throw new InvalidDataException($"Quake PAK entry '{name}' points outside the archive."); + this._entries.Add(new PakEntry { FileName = name, FileOffset = fileOffset, Size = fileLength }); + } + } + + /// Gets the next directory entry, or null after the last one. + public PakEntry? GetNextEntry() { + if (this._nextIndex >= this._entries.Count) { + this._current = null; + return null; + } + this._current = this._entries[this._nextIndex++]; + return this._current; + } + + /// Reads the stored bytes of the current entry. + public byte[] ReadEntryData() { + var entry = this._current ?? throw new InvalidOperationException("Call GetNextEntry() before ReadEntryData()."); + var result = new byte[entry.Size]; + this._stream.Position = entry.FileOffset; + this._stream.ReadExactly(result); + return result; + } /// - public void Dispose() => this._inner.Dispose(); + public void Dispose() { } } From fe655e0fd75e5120c76d72ac390c1248174f4498 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:54:48 +0200 Subject: [PATCH 03/13] * write canonical Quake PACK payloads and trailing directory --- FileFormats/FileFormat.Pak/PakWriter.cs | 100 +++++++++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/FileFormats/FileFormat.Pak/PakWriter.cs b/FileFormats/FileFormat.Pak/PakWriter.cs index 2cdb26bf3..0b16c806a 100644 --- a/FileFormats/FileFormat.Pak/PakWriter.cs +++ b/FileFormats/FileFormat.Pak/PakWriter.cs @@ -1,25 +1,99 @@ -using FileFormat.Arc; +using System.Buffers.Binary; +using System.Text; namespace FileFormat.Pak; /// -/// Creates PAK archives. PAK is ARC-compatible (same binary layout). -/// Delegates to for all operations. +/// Writes canonical Quake PACK archives: payloads first, one trailing directory, +/// then patches the 12-byte header with the directory offset and length. +/// Entries are stored verbatim; Quake PAK defines no per-entry compression. /// public sealed class PakWriter : IDisposable { - private readonly ArcWriter _inner; + private readonly Stream _stream; + private readonly List<(string Name, byte[] NameBytes, int Offset, int Length)> _entries = []; + private readonly HashSet _names = new(StringComparer.OrdinalIgnoreCase); + private bool _finished; - /// - /// Creates a new PAK archive writer. - /// - public PakWriter(Stream stream) => this._inner = new ArcWriter(stream); + /// Creates a new PACK archive on a seekable writable stream. + public PakWriter(Stream stream) { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanWrite || !stream.CanSeek) + throw new NotSupportedException("Quake PAK writing requires a seekable, writable stream."); + this._stream = stream; + stream.Position = 0; + stream.SetLength(0); + Span header = stackalloc byte[PakReader.HeaderSize]; + header.Clear(); + "PACK"u8.CopyTo(header); + stream.Write(header); + } - /// Adds a file entry. - public void AddEntry(string fileName, byte[] data) => this._inner.AddEntry(fileName, data); + /// Adds one stored file payload. + public void AddEntry(string fileName, byte[] data) { + if (this._finished) + throw new InvalidOperationException("The PAK directory has already been written."); + ArgumentNullException.ThrowIfNull(data); + var nameBytes = EncodeName(fileName); + if (!this._names.Add(fileName)) + throw new ArgumentException($"Duplicate Quake PAK entry '{fileName}'.", nameof(fileName)); + if (this._stream.Position > int.MaxValue || data.LongLength > int.MaxValue || this._stream.Position + data.LongLength > int.MaxValue) + throw new NotSupportedException("Quake PAK uses signed 32-bit file offsets and lengths."); - /// Writes the archive end marker. - public void Finish() => this._inner.Finish(); + var offset = checked((int)this._stream.Position); + this._stream.Write(data); + this._entries.Add((fileName, nameBytes, offset, data.Length)); + } + + /// Writes the trailing directory and patches the PACK header. + public void Finish() { + if (this._finished) + return; + this._finished = true; + + if (this._stream.Position > int.MaxValue) + throw new NotSupportedException("Quake PAK directory offset exceeds the signed 32-bit format field."); + var directoryOffset = checked((int)this._stream.Position); + var directoryLength = checked(this._entries.Count * PakReader.DirectoryEntrySize); + if ((long)directoryOffset + directoryLength > int.MaxValue) + throw new NotSupportedException("Quake PAK exceeds the signed 32-bit format limit."); + + Span record = stackalloc byte[PakReader.DirectoryEntrySize]; + foreach (var entry in this._entries) { + record.Clear(); + entry.NameBytes.CopyTo(record); + BinaryPrimitives.WriteInt32LittleEndian(record[56..60], entry.Offset); + BinaryPrimitives.WriteInt32LittleEndian(record[60..64], entry.Length); + this._stream.Write(record); + } + + Span directoryFields = stackalloc byte[8]; + BinaryPrimitives.WriteInt32LittleEndian(directoryFields[..4], directoryOffset); + BinaryPrimitives.WriteInt32LittleEndian(directoryFields[4..], directoryLength); + this._stream.Position = 4; + this._stream.Write(directoryFields); + this._stream.SetLength((long)directoryOffset + directoryLength); + this._stream.Flush(); + } + + internal static byte[] EncodeName(string fileName) { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + var normalized = fileName.Replace('\\', '/').TrimStart('/'); + if (normalized.Length == 0 || normalized.EndsWith('/')) + throw new ArgumentException("Quake PAK entry name must identify a file.", nameof(fileName)); + foreach (var part in normalized.Split('/')) { + if (part.Length == 0 || part is "." or "..") + throw new ArgumentException("Unsafe Quake PAK entry path.", nameof(fileName)); + } + if (normalized.Any(c => c is '\0' or > '\x7F')) + throw new ArgumentException("Quake PAK names are 7-bit archive paths.", nameof(fileName)); + var bytes = Encoding.ASCII.GetBytes(normalized); + if (bytes.Length >= PakReader.NameFieldSize) + throw new ArgumentException("Quake PAK entry names are limited to 55 bytes plus NUL.", nameof(fileName)); + var field = new byte[PakReader.NameFieldSize]; + bytes.CopyTo(field, 0); + return field; + } /// - public void Dispose() => this._inner.Dispose(); + public void Dispose() { } } From 3b8b700e2915605cc433035e46aed323fa5a7218 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:56:05 +0200 Subject: [PATCH 04/13] * edit Quake PAK by rewriting its real trailing directory --- .../FileFormat.Pak/PakInPlaceModifier.cs | 278 +++++++++++++++++- 1 file changed, 263 insertions(+), 15 deletions(-) diff --git a/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs b/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs index 925cfaff9..de035ea70 100644 --- a/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs +++ b/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs @@ -1,29 +1,277 @@ #pragma warning disable CS1591 -using FileFormat.Arc; +using System.Buffers.Binary; +using System.Text; namespace FileFormat.Pak; /// -/// Random-access in-place modifier for Quake PAK archives. PAK shares the -/// ARC binary layout (chain of entry blocks terminated by a 2-byte -/// 0x1A 0x00 end-of-archive marker), so this wrapper delegates straight to -/// . Add overwrites the old EOA marker with a new -/// Stored entry plus a fresh EOA — bytes before the old EOA are untouched. -/// Remove walks the entry chain, locates the target, and shifts trailing -/// bytes forward to compact (no central directory). +/// Changed-byte editor for canonical Quake PACK archives whose directory is the +/// exact physical trailer. New/replacement payload bytes reuse the old directory +/// position and a regenerated directory is appended after them. Removal rewrites +/// only that directory and optionally wipes payload ranges no surviving entry +/// references. Untouched payloads never move. /// public static class PakInPlaceModifier { + private sealed record DirectoryEntry(byte[] NameBytes, string Name, int Offset, int Length); + private sealed record State(int DirectoryOffset, int DirectoryLength, List Entries); + private readonly record struct Range(int Offset, int Length); - /// - /// Appends a Stored entry to a PAK archive. Bytes before the old - /// end-of-archive marker are not modified. - /// + /// Adds or same-name replaces one entry. public static void AddFile(Stream pak, string name, byte[] data) - => ArcModifier.AddFile(pak, name, data); + => AddFiles(pak, [(name, data)]); /// - /// Removes the named entry. Returns true if found. + /// Adds or same-name replaces entries in one trailer rewrite. All structural + /// validation and directory serialization complete before the first archive write. /// + public static void AddFiles( + Stream pak, + IReadOnlyList<(string Name, byte[] Data)> files, + bool wipeReplacedData = true) { + ValidateWritable(pak); + ArgumentNullException.ThrowIfNull(files); + if (files.Count == 0) + return; + + var requests = new List<(string Name, byte[] NameBytes, byte[] Data)>(files.Count); + var requestNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (name, data) in files) { + ArgumentNullException.ThrowIfNull(data); + var nameBytes = PakWriter.EncodeName(name); + var normalized = DecodeName(nameBytes); + if (!requestNames.Add(normalized)) + throw new ArgumentException($"Duplicate PAK mutation name '{normalized}'.", nameof(files)); + requests.Add((normalized, nameBytes, data)); + } + + var state = ReadCanonicalState(pak); + var planned = new List(state.Entries); + var byName = BuildUniqueNameIndex(state.Entries, requests.Select(request => request.Name)); + var wipeCandidates = new List(); + var appendOffset = state.DirectoryOffset; + + foreach (var request in requests) { + if (request.Data.LongLength > int.MaxValue || (long)appendOffset + request.Data.Length > int.MaxValue) + throw new NotSupportedException("Quake PAK uses signed 32-bit file offsets and lengths."); + + if (byName.TryGetValue(request.Name, out var existingIndex)) { + var old = planned[existingIndex]; + if (wipeReplacedData && old.Length > 0) + wipeCandidates.Add(new Range(old.Offset, old.Length)); + planned[existingIndex] = new DirectoryEntry(request.NameBytes, request.Name, appendOffset, request.Data.Length); + } else { + byName.Add(request.Name, planned.Count); + planned.Add(new DirectoryEntry(request.NameBytes, request.Name, appendOffset, request.Data.Length)); + } + appendOffset = checked(appendOffset + request.Data.Length); + } + + var newDirectoryOffset = appendOffset; + var directoryBytes = SerializeDirectory(planned); + var newLength = checked((long)newDirectoryOffset + directoryBytes.Length); + if (newLength > int.MaxValue) + throw new NotSupportedException("Quake PAK exceeds its signed 32-bit layout limit."); + var safeWipes = PlanSafeWipes(wipeCandidates, planned); + var headerPatch = BuildHeaderPatch(newDirectoryOffset, directoryBytes.Length); + + // Commit. The old directory is dead space by definition, so new payload bytes + // can start exactly there without touching any existing payload. + pak.Position = state.DirectoryOffset; + foreach (var request in requests) + if (request.Data.Length > 0) + pak.Write(request.Data); + pak.Write(directoryBytes); + pak.Position = 4; + pak.Write(headerPatch); + pak.SetLength(newLength); + ZeroRanges(pak, safeWipes); + pak.Flush(); + } + + /// Removes one named entry. Returns false without writing if absent. public static bool RemoveFile(Stream pak, string name, bool wipeData = true) - => ArcModifier.RemoveFile(pak, name, wipeData); + => RemoveFiles(pak, [name], wipeData) > 0; + + /// + /// Removes all requested full-path or leaf-name matches in one directory rewrite. + /// Payloads are left in place; unreferenced removed ranges are zeroed when requested. + /// + public static int RemoveFiles(Stream pak, IReadOnlyCollection names, bool wipeData = true) { + ValidateWritable(pak); + ArgumentNullException.ThrowIfNull(names); + if (names.Count == 0) + return 0; + + var requested = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var name in names) { + if (string.IsNullOrWhiteSpace(name)) + continue; + requested.Add(name.Replace('\\', '/').TrimStart('/')); + } + if (requested.Count == 0) + return 0; + + var state = ReadCanonicalState(pak); + var kept = new List(state.Entries.Count); + var wipeCandidates = new List(); + var removed = 0; + foreach (var entry in state.Entries) { + if (!Matches(entry.Name, requested)) { + kept.Add(entry); + continue; + } + ++removed; + if (wipeData && entry.Length > 0) + wipeCandidates.Add(new Range(entry.Offset, entry.Length)); + } + if (removed == 0) + return 0; + + var directoryBytes = SerializeDirectory(kept); + var newLength = checked((long)state.DirectoryOffset + directoryBytes.Length); + var safeWipes = PlanSafeWipes(wipeCandidates, kept); + var headerPatch = BuildHeaderPatch(state.DirectoryOffset, directoryBytes.Length); + + pak.Position = state.DirectoryOffset; + pak.Write(directoryBytes); + pak.Position = 4; + pak.Write(headerPatch); + pak.SetLength(newLength); + ZeroRanges(pak, safeWipes); + pak.Flush(); + return removed; + } + + private static State ReadCanonicalState(Stream pak) { + if (pak.Length < PakReader.HeaderSize || pak.Length > int.MaxValue) + throw new NotSupportedException("Quake PAK tail editing requires a <=2 GiB canonical archive."); + + Span header = stackalloc byte[PakReader.HeaderSize]; + pak.Position = 0; + pak.ReadExactly(header); + if (!header[..4].SequenceEqual("PACK"u8)) + throw new InvalidDataException("Not a Quake PAK archive: missing PACK magic."); + var directoryOffset = BinaryPrimitives.ReadInt32LittleEndian(header[4..8]); + var directoryLength = BinaryPrimitives.ReadInt32LittleEndian(header[8..12]); + if (directoryOffset < PakReader.HeaderSize || directoryLength < 0 || directoryLength % PakReader.DirectoryEntrySize != 0) + throw new InvalidDataException("Quake PAK has an invalid directory offset or length."); + if ((long)directoryOffset + directoryLength != pak.Length) + throw new NotSupportedException("Changed-byte PAK editing requires the directory to be the exact physical trailer."); + + var entries = new List(directoryLength / PakReader.DirectoryEntrySize); + var record = new byte[PakReader.DirectoryEntrySize]; + pak.Position = directoryOffset; + for (var i = 0; i < directoryLength / PakReader.DirectoryEntrySize; ++i) { + pak.ReadExactly(record); + var rawName = record.AsSpan(0, PakReader.NameFieldSize).ToArray(); + var name = DecodeName(rawName); + var offset = BinaryPrimitives.ReadInt32LittleEndian(record.AsSpan(56, 4)); + var length = BinaryPrimitives.ReadInt32LittleEndian(record.AsSpan(60, 4)); + if (offset < PakReader.HeaderSize || length < 0 || (long)offset + length > directoryOffset) + throw new NotSupportedException( + $"Changed-byte PAK editing requires payload '{name}' to lie wholly before the trailing directory."); + entries.Add(new DirectoryEntry(rawName, name, offset, length)); + } + return new State(directoryOffset, directoryLength, entries); + } + + private static Dictionary BuildUniqueNameIndex( + IReadOnlyList entries, + IEnumerable namesBeingChanged) { + var targets = new HashSet(namesBeingChanged, StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < entries.Count; ++i) { + if (!targets.Contains(entries[i].Name)) + continue; + if (!result.TryAdd(entries[i].Name, i)) + throw new NotSupportedException( + $"PAK contains duplicate directory entries named '{entries[i].Name}'; replacement semantics are ambiguous."); + } + return result; + } + + private static byte[] SerializeDirectory(IReadOnlyList entries) { + var result = new byte[checked(entries.Count * PakReader.DirectoryEntrySize)]; + for (var i = 0; i < entries.Count; ++i) { + var offset = i * PakReader.DirectoryEntrySize; + entries[i].NameBytes.CopyTo(result, offset); + BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(offset + 56, 4), entries[i].Offset); + BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(offset + 60, 4), entries[i].Length); + } + return result; + } + + private static byte[] BuildHeaderPatch(int directoryOffset, int directoryLength) { + var patch = new byte[8]; + BinaryPrimitives.WriteInt32LittleEndian(patch.AsSpan(0, 4), directoryOffset); + BinaryPrimitives.WriteInt32LittleEndian(patch.AsSpan(4, 4), directoryLength); + return patch; + } + + private static List PlanSafeWipes(IEnumerable candidates, IReadOnlyList survivors) { + var safe = new List(); + foreach (var candidate in candidates) { + var overlapsLive = survivors.Any(entry => entry.Length > 0 && Overlaps(candidate, new Range(entry.Offset, entry.Length))); + if (!overlapsLive) + safe.Add(candidate); + } + if (safe.Count < 2) + return safe; + + safe.Sort((a, b) => a.Offset.CompareTo(b.Offset)); + var merged = new List(); + var current = safe[0]; + for (var i = 1; i < safe.Count; ++i) { + var next = safe[i]; + var currentEnd = (long)current.Offset + current.Length; + if (next.Offset <= currentEnd) { + var end = Math.Max(currentEnd, (long)next.Offset + next.Length); + current = new Range(current.Offset, checked((int)(end - current.Offset))); + } else { + merged.Add(current); + current = next; + } + } + merged.Add(current); + return merged; + } + + private static bool Overlaps(Range left, Range right) + => left.Length > 0 && right.Length > 0 && + left.Offset < (long)right.Offset + right.Length && + right.Offset < (long)left.Offset + left.Length; + + private static void ZeroRanges(Stream pak, IReadOnlyList ranges) { + if (ranges.Count == 0) + return; + var zeroes = new byte[64 * 1024]; + foreach (var range in ranges) { + pak.Position = range.Offset; + var remaining = range.Length; + while (remaining > 0) { + var count = Math.Min(remaining, zeroes.Length); + pak.Write(zeroes, 0, count); + remaining -= count; + } + } + } + + private static bool Matches(string path, HashSet requested) { + if (requested.Contains(path)) + return true; + var slash = path.LastIndexOf('/'); + return requested.Contains(slash >= 0 ? path[(slash + 1)..] : path); + } + + private static string DecodeName(byte[] nameBytes) { + var terminator = Array.IndexOf(nameBytes, (byte)0); + var count = terminator >= 0 ? terminator : nameBytes.Length; + return Encoding.ASCII.GetString(nameBytes, 0, count); + } + + private static void ValidateWritable(Stream pak) { + ArgumentNullException.ThrowIfNull(pak); + if (!pak.CanRead || !pak.CanWrite || !pak.CanSeek) + throw new NotSupportedException("Changed-byte Quake PAK editing requires a seekable, readable, writable stream."); + } } From 8290c3e7be77e63cddf07976ed4e9a19f16ab1b4 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:56:27 +0200 Subject: [PATCH 05/13] * normalize PAK names before duplicate checks --- FileFormats/FileFormat.Pak/PakWriter.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/FileFormats/FileFormat.Pak/PakWriter.cs b/FileFormats/FileFormat.Pak/PakWriter.cs index 0b16c806a..82a2ca5ed 100644 --- a/FileFormats/FileFormat.Pak/PakWriter.cs +++ b/FileFormats/FileFormat.Pak/PakWriter.cs @@ -34,14 +34,16 @@ public void AddEntry(string fileName, byte[] data) { throw new InvalidOperationException("The PAK directory has already been written."); ArgumentNullException.ThrowIfNull(data); var nameBytes = EncodeName(fileName); - if (!this._names.Add(fileName)) - throw new ArgumentException($"Duplicate Quake PAK entry '{fileName}'.", nameof(fileName)); + var terminator = Array.IndexOf(nameBytes, (byte)0); + var normalized = Encoding.ASCII.GetString(nameBytes, 0, terminator >= 0 ? terminator : nameBytes.Length); + if (!this._names.Add(normalized)) + throw new ArgumentException($"Duplicate Quake PAK entry '{normalized}'.", nameof(fileName)); if (this._stream.Position > int.MaxValue || data.LongLength > int.MaxValue || this._stream.Position + data.LongLength > int.MaxValue) throw new NotSupportedException("Quake PAK uses signed 32-bit file offsets and lengths."); var offset = checked((int)this._stream.Position); this._stream.Write(data); - this._entries.Add((fileName, nameBytes, offset, data.Length)); + this._entries.Add((normalized, nameBytes, offset, data.Length)); } /// Writes the trailing directory and patches the PACK header. From c93936b01515d4fe996cd47b9c2f53dddf31ecf9 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:57:15 +0200 Subject: [PATCH 06/13] * wire the PAK descriptor to Quake PACK rather than ARC --- .../FileFormat.Pak/PakFormatDescriptor.cs | 251 ++++++++++-------- 1 file changed, 133 insertions(+), 118 deletions(-) diff --git a/FileFormats/FileFormat.Pak/PakFormatDescriptor.cs b/FileFormats/FileFormat.Pak/PakFormatDescriptor.cs index f21beccfc..a754a4d2a 100644 --- a/FileFormats/FileFormat.Pak/PakFormatDescriptor.cs +++ b/FileFormats/FileFormat.Pak/PakFormatDescriptor.cs @@ -5,11 +5,13 @@ namespace FileFormat.Pak; /// -/// id Software Quake PAK resource archive ('PACK' header + 64-byte-entry directory). +/// id Software Quake PAK resource archive: a 12-byte PACK header, +/// verbatim file payloads, and a 64-byte-per-entry directory referenced by the +/// header. Canonical archives place that directory at EOF. /// /// References: /// -/// https://github.com/id-Software/Quake — released Quake source — the pakfile code is the canonical definition +/// https://github.com/id-Software/Quake — released Quake source; dpackheader_t/dpackfile_t are the canonical definition /// Unofficial Quake Specs (Olivier Montanuy et al.) — long-standing community format documentation /// /// @@ -23,7 +25,7 @@ public void Defragment(Stream archive) public void Defragment(Stream archive, DefragOptions options) { DefragRebuilder.Rebuild(archive, options, readEntries: stream => { - var r = new PakReader(stream); + using var r = new PakReader(stream); var list = new List<(string, byte[])>(); while (r.GetNextEntry() is { } e) list.Add((e.FileName, r.ReadEntryData())); @@ -31,167 +33,180 @@ public void Defragment(Stream archive, DefragOptions options) { }, buildImage: files => { using var ms = new MemoryStream(); - var w = new PakWriter(ms); - foreach (var (n, d) in files) w.AddEntry(n, d); + using var w = new PakWriter(ms); + foreach (var (name, data) in files) + w.AddEntry(name, data); w.Finish(); return ms.ToArray(); }); } - /// public IEnumerable EnumerateLayout(Stream archive) { - archive.Position = 0; - var r = new Arc.ArcReader(archive); - while (r.GetNextEntry() is { } e) { - var headerSize = e.Method >= Arc.ArcConstants.MethodStored ? Arc.ArcConstants.NewHeaderSize : Arc.ArcConstants.OldHeaderSize; - var dataStart = archive.Position; - var headerStart = dataStart - headerSize; - yield return new DefragBlockInfo(headerStart, headerSize, DefragBlockKind.MetadataReserved, FileName: "Header: " + e.FileName); - if (e.CompressedSize > 0) - yield return new DefragBlockInfo(dataStart, e.CompressedSize, DefragBlockKind.Used, FileName: e.FileName); - archive.Position = dataStart + e.CompressedSize; + PakReader reader; + try { + archive.Position = 0; + reader = new PakReader(archive); + } catch { + yield break; } - var eoaPos = archive.Position - 2; - if (eoaPos >= 0) - yield return new DefragBlockInfo(eoaPos, 2, DefragBlockKind.MetadataReserved, FileName: "End-of-archive"); + + yield return new DefragBlockInfo(0, PakReader.HeaderSize, DefragBlockKind.MetadataReserved, FileName: "PACK header"); + foreach (var entry in reader.Entries) + if (entry.Size > 0) + yield return new DefragBlockInfo(entry.FileOffset, entry.Size, DefragBlockKind.Used, FileName: entry.FileName); + if (reader.DirectoryLength > 0) + yield return new DefragBlockInfo(reader.DirectoryOffset, reader.DirectoryLength, DefragBlockKind.MetadataReserved, FileName: "PACK directory"); } /// - /// Gets the id. + /// Adds or same-name replaces files. Canonical trailing-directory archives use + /// : new bytes overwrite the old directory, + /// then a regenerated directory is appended. Unsupported non-canonical layouts + /// fall back to the verified rebuild. /// - public string Id => "Pak"; + public void Add(Stream archive, IReadOnlyList inputs) { + ArgumentNullException.ThrowIfNull(archive); + ArgumentNullException.ThrowIfNull(inputs); + var files = FilesOnly(inputs).ToList(); + if (files.Count == 0) + return; + + try { + archive.Position = 0; + PakInPlaceModifier.AddFiles(archive, files); + return; + } catch (NotSupportedException) { + if (archive.CanSeek) + archive.Position = 0; + } + + RebuildVerb.EditViaRebuild(archive, this, this, tmpDir => { + foreach (var (name, data) in files) { + var destination = Path.Combine(tmpDir, name.Replace('/', Path.DirectorySeparatorChar)); + var directory = Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + File.WriteAllBytes(destination, data); + } + }); + } + /// - /// Gets the display name. + /// Removes named files by rewriting only the trailing directory and wiping + /// unreferenced removed payload ranges. Non-canonical layouts rebuild. /// + public void Remove(Stream archive, string[] entryNames) { + ArgumentNullException.ThrowIfNull(archive); + ArgumentNullException.ThrowIfNull(entryNames); + if (entryNames.Length == 0) + return; + + try { + archive.Position = 0; + PakInPlaceModifier.RemoveFiles(archive, entryNames, wipeData: true); + return; + } catch (NotSupportedException) { + if (archive.CanSeek) + archive.Position = 0; + } + + var skip = new HashSet(entryNames, StringComparer.OrdinalIgnoreCase); + RebuildVerb.EditViaRebuild(archive, this, this, tmpDir => { + foreach (var file in Directory.GetFiles(tmpDir, "*", SearchOption.AllDirectories)) { + var relative = Path.GetRelativePath(tmpDir, file).Replace('\\', '/'); + if (skip.Contains(relative) || skip.Contains(Path.GetFileName(relative))) + File.Delete(file); + } + }); + } + + /// Gets the id. + public string Id => "Pak"; + + /// Gets the display name. public string DisplayName => "PAK"; - /// - /// Gets the category. - /// + + /// Gets the category. public FormatCategory Category => FormatCategory.Archive; - /// - /// Gets the capabilities. - /// + + /// Gets the capabilities. public FormatCapabilities Capabilities => FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate | FormatCapabilities.CanModify | FormatCapabilities.CanTest | FormatCapabilities.SupportsMultipleEntries; - /// - /// Adds (or replaces by name) files inside an existing PAK archive. - /// PAK shares the ARC binary layout so this delegates to - /// , which itself wraps - /// . Add overwrites only the trailing - /// end-of-archive marker; Remove walks the entry chain and shifts - /// trailing bytes (no central directory). - /// - public void Add(Stream archive, IReadOnlyList inputs) { - foreach (var (name, data) in FilesOnly(inputs)) { - PakInPlaceModifier.RemoveFile(archive, name, wipeData: true); - PakInPlaceModifier.AddFile(archive, name, data); - } - } - - /// Removes named entries via . - public void Remove(Stream archive, string[] entryNames) { - foreach (var name in entryNames) - PakInPlaceModifier.RemoveFile(archive, name, wipeData: true); - } - /// - /// Gets the default extension. - /// + /// Gets the default extension. public string DefaultExtension => ".pak"; - /// - /// Gets the extensions. - /// + + /// Gets the extensions. public IReadOnlyList Extensions => [".pak"]; - /// - /// Gets the compound extensions. - /// + + /// Gets the compound extensions. public IReadOnlyList CompoundExtensions => []; - /// - /// Gets the magic signatures. - /// - public IReadOnlyList MagicSignatures => []; - /// - /// Gets the methods. - /// - public IReadOnlyList Methods => [new("pak", "PAK")]; - /// - /// Gets the tar compression format id. - /// + + /// Gets the magic signatures. + public IReadOnlyList MagicSignatures => [new("PACK"u8.ToArray(), Confidence: 0.95)]; + + /// Gets the methods. + public IReadOnlyList Methods => [new("stored", "Stored")]; + + /// Gets the tar compression format id. public string? TarCompressionFormatId => null; - /// - /// Gets the family. - /// + + /// Gets the family. public AlgorithmFamily Family => AlgorithmFamily.Archive; - /// - /// Gets the description. - /// - public string Description => "Quake PAK game resource archive"; - /// - /// Lists the entries in the supplied container. - /// + /// Gets the description. + public string Description => "Quake PACK game resource archive"; + + /// Lists the entries in the supplied container. public List List(Stream stream, string? password) { - var r = new PakReader(stream); - var entries = new List(); - var i = 0; - while (r.GetNextEntry() is { } e) - entries.Add(new(i++, e.FileName, e.OriginalSize, e.CompressedSize, - $"Method {e.Method}", false, false, e.LastModified.DateTime)); - return entries; + using var r = new PakReader(stream); + return r.Entries.Select((entry, index) => + new ArchiveEntryInfo(index, entry.FileName, entry.Size, entry.Size, "Stored", false, false, null)).ToList(); } - /// - /// Decodes the supplied input. - /// + /// Extracts matching entries. public void Extract(Stream stream, string outputDir, string? password, string[]? files) { - var r = new PakReader(stream); - while (r.GetNextEntry() is { } e) { - if (files != null && !MatchesFilter(e.FileName, files)) continue; - WriteFile(outputDir, e.FileName, r.ReadEntryData()); + using var r = new PakReader(stream); + while (r.GetNextEntry() is { } entry) { + if (files != null && !MatchesFilter(entry.FileName, files)) + continue; + WriteFile(outputDir, entry.FileName, r.ReadEntryData()); } } - /// - /// Opens a single PAK entry as a bounded read-only stream. PAK shares the - /// ARC binary layout: a forward-iterating reader produces per-entry bytes - /// (decompressed if the entry was stored compressed). The bytes are - /// wrapped in a - /// sized - /// to the entry's original length — adjacent entries and trailing padding - /// are physically unreachable. - /// + /// Opens one PAK entry as a bounded read-only stream. public Stream OpenEntry(Stream archive, string entryName, string? password) { ArgumentNullException.ThrowIfNull(archive); ArgumentNullException.ThrowIfNull(entryName); - if (archive.CanSeek) archive.Position = 0; - var r = new PakReader(archive); - while (r.GetNextEntry() is { } e) { - if (!string.Equals(e.FileName, entryName, StringComparison.OrdinalIgnoreCase)) continue; + if (archive.CanSeek) + archive.Position = 0; + using var r = new PakReader(archive); + while (r.GetNextEntry() is { } entry) { + if (!string.Equals(entry.FileName, entryName, StringComparison.OrdinalIgnoreCase)) + continue; var bytes = r.ReadEntryData(); return new Compression.Registry.Streaming.BoundedEntryStream( new MemoryStream(bytes, writable: false), bytes.Length, leaveOpen: false); } return new Compression.Registry.Streaming.BoundedEntryStream( - new MemoryStream(System.Array.Empty(), writable: false), 0, leaveOpen: false); + new MemoryStream(Array.Empty(), writable: false), 0, leaveOpen: false); } - /// Native in-memory single-entry extraction routed through the bounded . + /// Native in-memory single-entry extraction routed through . public byte[] ExtractEntryToMemory(Stream archive, string entryName, string? password) { - using var s = this.OpenEntry(archive, entryName, password); - using var memoryStream = new MemoryStream(); - s.CopyTo(memoryStream); - return memoryStream.ToArray(); + using var stream = this.OpenEntry(archive, entryName, password); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); } - /// - /// Performs the create operation. - /// + /// Creates a canonical Quake PACK archive. public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) { - var w = new PakWriter(output); - foreach (var (name, data) in FormatHelpers.FlatFiles(inputs)) - w.AddEntry(name, data); - w.Finish(); + using var writer = new PakWriter(output); + foreach (var (name, data) in FlatFiles(inputs)) + writer.AddEntry(name, data); + writer.Finish(); } } From 29db4b3e92f3781a94eb917b2611eb19527d23f6 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:57:26 +0200 Subject: [PATCH 07/13] - the false ARC dependency from Quake PAK --- FileFormats/FileFormat.Pak/FileFormat.Pak.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/FileFormats/FileFormat.Pak/FileFormat.Pak.csproj b/FileFormats/FileFormat.Pak/FileFormat.Pak.csproj index 77540a53a..aa640e63d 100644 --- a/FileFormats/FileFormat.Pak/FileFormat.Pak.csproj +++ b/FileFormats/FileFormat.Pak/FileFormat.Pak.csproj @@ -5,7 +5,6 @@ - From be80889364a7f459c63252b0b9b87bcbbc496559 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:58:12 +0200 Subject: [PATCH 08/13] + independent Quake PACK wire-format vectors --- Compression.Tests/Pak/PakDescriptorTests.cs | 188 +++++++++++--------- 1 file changed, 99 insertions(+), 89 deletions(-) diff --git a/Compression.Tests/Pak/PakDescriptorTests.cs b/Compression.Tests/Pak/PakDescriptorTests.cs index 1ca8ef6c1..49db93492 100644 --- a/Compression.Tests/Pak/PakDescriptorTests.cs +++ b/Compression.Tests/Pak/PakDescriptorTests.cs @@ -1,3 +1,8 @@ +using System.Buffers.Binary; +using System.Text; +using Compression.Registry; +using FileFormat.Pak; + namespace Compression.Tests.Pak; [TestFixture] @@ -5,12 +10,63 @@ public class PakDescriptorTests { [Test, Category("HappyPath")] public void Descriptor_Properties() { - var desc = new FileFormat.Pak.PakFormatDescriptor(); + var desc = new PakFormatDescriptor(); + + Assert.Multiple(() => { + Assert.That(desc.Id, Is.EqualTo("Pak")); + Assert.That(desc.DefaultExtension, Is.EqualTo(".pak")); + Assert.That(desc.Category, Is.EqualTo(FormatCategory.Archive)); + Assert.That(desc.Description, Does.Contain("Quake")); + Assert.That(desc.MagicSignatures.Single().Bytes, Is.EqualTo("PACK"u8.ToArray())); + }); + } + + [Test, Category("KnownAnswer")] + public void Reader_ParsesCanonicalPackVector_NotArc() { + // Independent minimal PACK image: + // header: "PACK", directory @ 15, one 64-byte record + // payload @ 12: "abc" + var image = new byte[12 + 3 + 64]; + "PACK"u8.CopyTo(image); + BinaryPrimitives.WriteInt32LittleEndian(image.AsSpan(4, 4), 15); + BinaryPrimitives.WriteInt32LittleEndian(image.AsSpan(8, 4), 64); + "abc"u8.CopyTo(image.AsSpan(12, 3)); + Encoding.ASCII.GetBytes("maps/test.bsp").CopyTo(image, 15); + BinaryPrimitives.WriteInt32LittleEndian(image.AsSpan(15 + 56, 4), 12); + BinaryPrimitives.WriteInt32LittleEndian(image.AsSpan(15 + 60, 4), 3); + + using var stream = new MemoryStream(image, writable: false); + using var reader = new PakReader(stream); + var entry = reader.GetNextEntry(); + + Assert.Multiple(() => { + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.FileName, Is.EqualTo("maps/test.bsp")); + Assert.That(entry.FileOffset, Is.EqualTo(12)); + Assert.That(entry.Size, Is.EqualTo(3)); + Assert.That(reader.ReadEntryData(), Is.EqualTo("abc"u8.ToArray())); + Assert.That(reader.GetNextEntry(), Is.Null); + }); + } - Assert.That(desc.Id, Is.EqualTo("Pak")); - Assert.That(desc.DefaultExtension, Is.EqualTo(".pak")); - Assert.That(desc.Category, Is.EqualTo(Compression.Registry.FormatCategory.Archive)); - Assert.That(desc.Description, Does.Contain("Quake")); + [Test, Category("KnownAnswer")] + public void Writer_EmitsPackHeaderPayloadThenTrailingDirectory() { + using var stream = new MemoryStream(); + using (var writer = new PakWriter(stream)) { + writer.AddEntry("test.txt", "xyz"u8.ToArray()); + writer.Finish(); + } + var bytes = stream.ToArray(); + + Assert.Multiple(() => { + Assert.That(bytes.AsSpan(0, 4).ToArray(), Is.EqualTo("PACK"u8.ToArray())); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4, 4)), Is.EqualTo(15)); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(8, 4)), Is.EqualTo(64)); + Assert.That(bytes.AsSpan(12, 3).ToArray(), Is.EqualTo("xyz"u8.ToArray())); + Assert.That(Encoding.ASCII.GetString(bytes, 15, 8), Is.EqualTo("test.txt")); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(15 + 56, 4)), Is.EqualTo(12)); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(15 + 60, 4)), Is.EqualTo(3)); + }); } [Test, Category("HappyPath")] @@ -22,25 +78,18 @@ public void RoundTrip_ViaInterface() { var data = "Hello PAK archive!"u8.ToArray(); File.WriteAllBytes(tmpFile, data); - var desc = new FileFormat.Pak.PakFormatDescriptor(); - var ops = desc; - - // Create + var desc = new PakFormatDescriptor(); using var ms = new MemoryStream(); - ops.Create(ms, [new Compression.Registry.ArchiveInputInfo(tmpFile, "test.txt", false)], - new Compression.Registry.FormatCreateOptions()); + desc.Create(ms, [new ArchiveInputInfo(tmpFile, "test.txt", false)], new FormatCreateOptions()); - // List ms.Position = 0; - var entries = ops.List(ms, null); + var entries = desc.List(ms, null); Assert.That(entries, Has.Count.EqualTo(1)); Assert.That(entries[0].Name, Is.EqualTo("test.txt")); - // Extract ms.Position = 0; - ops.Extract(ms, tmpDir, null, null); - var extracted = File.ReadAllBytes(Path.Combine(tmpDir, "test.txt")); - Assert.That(extracted, Is.EqualTo(data)); + desc.Extract(ms, tmpDir, null, null); + Assert.That(File.ReadAllBytes(Path.Combine(tmpDir, "test.txt")), Is.EqualTo(data)); } finally { File.Delete(tmpFile); if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true); @@ -49,89 +98,50 @@ public void RoundTrip_ViaInterface() { [Test, Category("HappyPath")] public void RoundTrip_MultipleFiles() { - var tmpFiles = new string[3]; - var tmpDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(tmpDir); - try { - var data1 = "First file content"u8.ToArray(); - var data2 = "Second file content"u8.ToArray(); - var data3 = "Third file content"u8.ToArray(); - - tmpFiles[0] = Path.GetTempFileName(); - tmpFiles[1] = Path.GetTempFileName(); - tmpFiles[2] = Path.GetTempFileName(); - File.WriteAllBytes(tmpFiles[0], data1); - File.WriteAllBytes(tmpFiles[1], data2); - File.WriteAllBytes(tmpFiles[2], data3); - - var desc = new FileFormat.Pak.PakFormatDescriptor(); - var ops = desc; - - using var ms = new MemoryStream(); - ops.Create(ms, [ - new Compression.Registry.ArchiveInputInfo(tmpFiles[0], "a.txt", false), - new Compression.Registry.ArchiveInputInfo(tmpFiles[1], "b.txt", false), - new Compression.Registry.ArchiveInputInfo(tmpFiles[2], "c.txt", false), - ], new Compression.Registry.FormatCreateOptions()); - - // List - ms.Position = 0; - var entries = ops.List(ms, null); - Assert.That(entries, Has.Count.EqualTo(3)); - Assert.That(entries[0].Name, Is.EqualTo("a.txt")); - Assert.That(entries[1].Name, Is.EqualTo("b.txt")); - Assert.That(entries[2].Name, Is.EqualTo("c.txt")); - - // Extract all and verify - ms.Position = 0; - ops.Extract(ms, tmpDir, null, null); - Assert.That(File.ReadAllBytes(Path.Combine(tmpDir, "a.txt")), Is.EqualTo(data1)); - Assert.That(File.ReadAllBytes(Path.Combine(tmpDir, "b.txt")), Is.EqualTo(data2)); - Assert.That(File.ReadAllBytes(Path.Combine(tmpDir, "c.txt")), Is.EqualTo(data3)); - } finally { - foreach (var f in tmpFiles) if (f != null) File.Delete(f); - if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true); - } + var data1 = "First file content"u8.ToArray(); + var data2 = "Second file content"u8.ToArray(); + var data3 = "Third file content"u8.ToArray(); + var desc = new PakFormatDescriptor(); + using var ms = new MemoryStream(); + desc.Create(ms, [ + ArchiveInputInfo.InMemory("a.txt", data1), + ArchiveInputInfo.InMemory("b.txt", data2), + ArchiveInputInfo.InMemory("c.txt", data3), + ], new FormatCreateOptions()); + + ms.Position = 0; + var entries = desc.List(ms, null); + Assert.That(entries.Select(entry => entry.Name), Is.EqualTo(new[] { "a.txt", "b.txt", "c.txt" })); + + ms.Position = 0; + Assert.That(desc.ExtractEntryToMemory(ms, "a.txt", null), Is.EqualTo(data1)); + ms.Position = 0; + Assert.That(desc.ExtractEntryToMemory(ms, "b.txt", null), Is.EqualTo(data2)); + ms.Position = 0; + Assert.That(desc.ExtractEntryToMemory(ms, "c.txt", null), Is.EqualTo(data3)); } [Test, Category("HappyPath")] public void Extract_WithFilter() { - var tmpFiles = new string[3]; + var desc = new PakFormatDescriptor(); + using var archive = new MemoryStream(); + desc.Create(archive, [ + ArchiveInputInfo.InMemory("alpha.txt", "Alpha"u8.ToArray()), + ArchiveInputInfo.InMemory("bravo.txt", "Bravo"u8.ToArray()), + ArchiveInputInfo.InMemory("charlie.txt", "Charlie"u8.ToArray()), + ], new FormatCreateOptions()); + var tmpDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(tmpDir); try { - var data1 = "Alpha"u8.ToArray(); - var data2 = "Bravo"u8.ToArray(); - var data3 = "Charlie"u8.ToArray(); - - tmpFiles[0] = Path.GetTempFileName(); - tmpFiles[1] = Path.GetTempFileName(); - tmpFiles[2] = Path.GetTempFileName(); - File.WriteAllBytes(tmpFiles[0], data1); - File.WriteAllBytes(tmpFiles[1], data2); - File.WriteAllBytes(tmpFiles[2], data3); - - var desc = new FileFormat.Pak.PakFormatDescriptor(); - var ops = desc; - - using var ms = new MemoryStream(); - ops.Create(ms, [ - new Compression.Registry.ArchiveInputInfo(tmpFiles[0], "alpha.txt", false), - new Compression.Registry.ArchiveInputInfo(tmpFiles[1], "bravo.txt", false), - new Compression.Registry.ArchiveInputInfo(tmpFiles[2], "charlie.txt", false), - ], new Compression.Registry.FormatCreateOptions()); - - // Extract only bravo.txt - ms.Position = 0; - ops.Extract(ms, tmpDir, null, ["bravo.txt"]); - + archive.Position = 0; + desc.Extract(archive, tmpDir, null, ["bravo.txt"]); Assert.That(File.Exists(Path.Combine(tmpDir, "bravo.txt")), Is.True); - Assert.That(File.ReadAllBytes(Path.Combine(tmpDir, "bravo.txt")), Is.EqualTo(data2)); + Assert.That(File.ReadAllBytes(Path.Combine(tmpDir, "bravo.txt")), Is.EqualTo("Bravo"u8.ToArray())); Assert.That(File.Exists(Path.Combine(tmpDir, "alpha.txt")), Is.False); Assert.That(File.Exists(Path.Combine(tmpDir, "charlie.txt")), Is.False); } finally { - foreach (var f in tmpFiles) if (f != null) File.Delete(f); - if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true); + Directory.Delete(tmpDir, true); } } } From 05b870923f88c9a9b9784af7481fadf381e34821 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 16:59:28 +0200 Subject: [PATCH 09/13] * test real PAK trailer edits and I/O budgets --- .../Pak/PakInPlaceModifyTests.cs | 329 +++++++++++------- 1 file changed, 200 insertions(+), 129 deletions(-) diff --git a/Compression.Tests/Pak/PakInPlaceModifyTests.cs b/Compression.Tests/Pak/PakInPlaceModifyTests.cs index bcafc7d79..3a205473a 100644 --- a/Compression.Tests/Pak/PakInPlaceModifyTests.cs +++ b/Compression.Tests/Pak/PakInPlaceModifyTests.cs @@ -1,170 +1,241 @@ #pragma warning disable CS1591 +using System.Buffers.Binary; using Compression.Registry; using FileFormat.Pak; namespace Compression.Tests.Pak; /// -/// Locks the in-place contract for : PAK -/// shares the ARC binary layout (entry-chain terminated by a 2-byte -/// end-of-archive marker), so Add overwrites only the old EOA marker and -/// re-writes a fresh one after the new entry. Bytes before the old EOA -/// are byte-identical after the operation. +/// Changed-byte tests for real Quake PACK archives. The physical contract is a +/// trailing directory, not ARC's entry-chain/EOA layout. /// [TestFixture] public class PakInPlaceModifyTests { - - // ARC/PAK end-of-archive marker: 0x1A 0x00. - private const int EoaMarkerBytes = 2; + private const long IoBudget = 128 * 1024; [Test, Category("ByteIdentity")] - public void AddFile_PreservesBytesBeforeOldEoaMarker() { - var seed = BuildSeedPak(("seed.txt", "seed-content"u8.ToArray())); - var oldBytes = seed.ToArray(); - var oldEoaOffset = oldBytes.Length - EoaMarkerBytes; - - using var ms = new MemoryStream(); - ms.Write(oldBytes); - PakInPlaceModifier.AddFile(ms, "added.txt", "appended"u8.ToArray()); - - var newBytes = ms.ToArray(); - Assert.That(newBytes.Length, Is.GreaterThan(oldBytes.Length), - "Add must grow the archive (new entry + fresh EOA)."); - - AssertBytesEqual(oldBytes.AsSpan(0, oldEoaOffset), - newBytes.AsSpan(0, oldEoaOffset), - "Bytes before the old end-of-archive marker must be untouched."); + public void AddFile_WritesPayloadAtOldDirectoryOffset_AndKeepsExistingOffset() { + var keep = Pattern(8192, 17); + var original = BuildSeedPak(("keep.bin", keep)); + var oldDirectoryOffset = DirectoryOffset(original); + var oldKeepOffset = EntryMap(original)["keep.bin"].FileOffset; + + using var stream = Load(original); + PakInPlaceModifier.AddFile(stream, "new.bin", "small"u8.ToArray()); + var result = stream.ToArray(); + var entries = EntryMap(result); + + Assert.Multiple(() => { + Assert.That(entries["new.bin"].FileOffset, Is.EqualTo(oldDirectoryOffset)); + Assert.That(entries["keep.bin"].FileOffset, Is.EqualTo(oldKeepOffset)); + Assert.That(ReadEntry(result, "keep.bin"), Is.EqualTo(keep)); + Assert.That(ReadEntry(result, "new.bin"), Is.EqualTo("small"u8.ToArray())); + }); } - [Test, Category("ByteIdentity")] - public void AddFile_MultipleAppends_PreservesAllPriorEntryBytes() { - var seed = BuildSeedPak( - ("one.txt", "first"u8.ToArray()), - ("two.txt", "second"u8.ToArray())); - var oldBytes = seed.ToArray(); - var oldEoaOffset = oldBytes.Length - EoaMarkerBytes; - - using var ms = new MemoryStream(); - ms.Write(oldBytes); - PakInPlaceModifier.AddFile(ms, "three.txt", "third"u8.ToArray()); - - var afterFirst = ms.ToArray(); - AssertBytesEqual(oldBytes.AsSpan(0, oldEoaOffset), - afterFirst.AsSpan(0, oldEoaOffset), - "First Add must not touch any pre-existing entry bytes."); - - var midEoaOffset = afterFirst.Length - EoaMarkerBytes; - PakInPlaceModifier.AddFile(ms, "four.txt", "fourth"u8.ToArray()); - - var afterSecond = ms.ToArray(); - AssertBytesEqual(afterFirst.AsSpan(0, midEoaOffset), - afterSecond.AsSpan(0, midEoaOffset), - "Second Add must not touch any bytes written by the first Add."); + [Test, Category("Performance")] + public void DescriptorAdd_LeavesFourMiBUntouched_AndStaysUnderIoBudget() { + var keep = Pattern(4 * 1024 * 1024, 23); + var original = BuildSeedPak(("large/keep.bin", keep), ("small.txt", "seed"u8.ToArray())); + var oldOffset = EntryMap(original)["large/keep.bin"].FileOffset; + + using var inner = Load(original); + using var counted = new CountingStream(inner); + new PakFormatDescriptor().Add(counted, [ArchiveInputInfo.InMemory("added.txt", "tiny"u8.ToArray())]); + var reads = counted.BytesRead; + var writes = counted.BytesWritten; + var result = inner.ToArray(); + + Assert.Multiple(() => { + Assert.That(reads, Is.LessThan(IoBudget), $"add read {reads} archive bytes"); + Assert.That(writes, Is.LessThan(IoBudget), $"add wrote {writes} archive bytes"); + Assert.That(EntryMap(result)["large/keep.bin"].FileOffset, Is.EqualTo(oldOffset)); + Assert.That(ReadEntry(result, "large/keep.bin"), Is.EqualTo(keep)); + Assert.That(ReadEntry(result, "added.txt"), Is.EqualTo("tiny"u8.ToArray())); + }); } - [Test, Category("RoundTrip")] - public void AddFile_ReadsBack() { - using var ms = BuildSeedPak(("seed.txt", "seed-content"u8.ToArray())); - PakInPlaceModifier.AddFile(ms, "added.txt", "hello-pak"u8.ToArray()); + [Test, Category("Performance")] + public void DescriptorReplace_DoesNotReadLargeSibling_AndWipesOldPayload() { + var keep = Pattern(4 * 1024 * 1024, 31); + var victim = Pattern(4096, 7); + var original = BuildSeedPak(("keep.bin", keep), ("victim.bin", victim)); + var before = EntryMap(original); + var oldKeepOffset = before["keep.bin"].FileOffset; + var oldVictim = before["victim.bin"]; + + using var inner = Load(original); + using var counted = new CountingStream(inner); + new PakFormatDescriptor().Add(counted, [ArchiveInputInfo.InMemory("victim.bin", "replacement"u8.ToArray())]); + var reads = counted.BytesRead; + var writes = counted.BytesWritten; + var result = inner.ToArray(); - ms.Position = 0; - var entries = ReadAll(ms); - Assert.That(entries["added.txt"], Is.EqualTo("hello-pak")); - Assert.That(entries["seed.txt"], Is.EqualTo("seed-content")); + Assert.Multiple(() => { + Assert.That(reads, Is.LessThan(IoBudget)); + Assert.That(writes, Is.LessThan(IoBudget)); + Assert.That(EntryMap(result)["keep.bin"].FileOffset, Is.EqualTo(oldKeepOffset)); + Assert.That(ReadEntry(result, "keep.bin"), Is.EqualTo(keep)); + Assert.That(ReadEntry(result, "victim.bin"), Is.EqualTo("replacement"u8.ToArray())); + Assert.That(result.AsSpan(oldVictim.FileOffset, oldVictim.Size).ToArray(), Is.All.EqualTo((byte)0)); + }); } - [Test, Category("RoundTrip")] - public void RemoveFile_DropsEntry() { - using var ms = BuildSeedPak(("seed.txt", "seed-content"u8.ToArray())); - PakInPlaceModifier.AddFile(ms, "victim.txt", "delete-me"u8.ToArray()); - PakInPlaceModifier.AddFile(ms, "keeper.txt", "keep-me"u8.ToArray()); - Assert.That(PakInPlaceModifier.RemoveFile(ms, "victim.txt"), Is.True); - - ms.Position = 0; - var entries = ReadAll(ms); - Assert.That(entries.ContainsKey("victim.txt"), Is.False); - Assert.That(entries.ContainsKey("keeper.txt"), Is.True); - Assert.That(entries["keeper.txt"], Is.EqualTo("keep-me")); - Assert.That(entries["seed.txt"], Is.EqualTo("seed-content")); + [Test, Category("Performance")] + public void DescriptorRemove_RewritesOnlyDirectoryAndRemovedPayload() { + var keep = Pattern(4 * 1024 * 1024, 43); + var victim = Pattern(4096, 11); + var original = BuildSeedPak(("keep.bin", keep), ("victim.bin", victim)); + var before = EntryMap(original); + var oldKeepOffset = before["keep.bin"].FileOffset; + var oldVictim = before["victim.bin"]; + + using var inner = Load(original); + using var counted = new CountingStream(inner); + new PakFormatDescriptor().Remove(counted, ["victim.bin"]); + var reads = counted.BytesRead; + var writes = counted.BytesWritten; + var result = inner.ToArray(); + + Assert.Multiple(() => { + Assert.That(reads, Is.LessThan(IoBudget)); + Assert.That(writes, Is.LessThan(IoBudget)); + Assert.That(EntryMap(result).ContainsKey("victim.bin"), Is.False); + Assert.That(EntryMap(result)["keep.bin"].FileOffset, Is.EqualTo(oldKeepOffset)); + Assert.That(ReadEntry(result, "keep.bin"), Is.EqualTo(keep)); + Assert.That(result.AsSpan(oldVictim.FileOffset, oldVictim.Size).ToArray(), Is.All.EqualTo((byte)0)); + }); } - [Test, Category("RoundTrip")] - public void RemoveFile_NotFound_ReturnsFalse() { - using var ms = BuildSeedPak(("seed.txt", "seed-content"u8.ToArray())); - Assert.That(PakInPlaceModifier.RemoveFile(ms, "ghost.txt"), Is.False); + [Test, Category("EdgeCase")] + public void RemoveMissingName_PerformsZeroWrites() { + var original = BuildSeedPak(("keep.bin", Pattern(1024, 5))); + using var inner = Load(original); + using var counted = new CountingStream(inner); + + Assert.That(PakInPlaceModifier.RemoveFile(counted, "ghost.bin"), Is.False); + Assert.That(counted.BytesWritten, Is.Zero); + Assert.That(inner.ToArray(), Is.EqualTo(original)); } - [Test, Category("RoundTrip")] - public void MutateThenExtract_PreservesCallerPayload() { - using var ms = BuildSeedPak(("seed.txt", "seed-content"u8.ToArray())); - var payload = new byte[1024]; - for (var i = 0; i < payload.Length; ++i) payload[i] = (byte)((i * 11 + 1) & 0xFF); - PakInPlaceModifier.AddFile(ms, "payload.bin", payload); - - ms.Position = 0; - var entries = ReadAll(ms); - Assert.That(entries.ContainsKey("payload.bin"), Is.True); - Assert.That(System.Text.Encoding.Latin1.GetBytes(entries["payload.bin"]), Is.EqualTo(payload)); + [Test, Category("EdgeCase")] + public void RemovingAlias_DoesNotWipeSharedPayload() { + var original = BuildAliasedPak(); + var before = EntryMap(original); + Assert.That(before["a.bin"].FileOffset, Is.EqualTo(before["b.bin"].FileOffset)); + + using var stream = Load(original); + Assert.That(PakInPlaceModifier.RemoveFile(stream, "a.bin", wipeData: true), Is.True); + var result = stream.ToArray(); + + Assert.Multiple(() => { + Assert.That(EntryMap(result).ContainsKey("a.bin"), Is.False); + Assert.That(ReadEntry(result, "b.bin"), Is.EqualTo("shared-payload"u8.ToArray())); + }); } [Test, Category("HappyPath")] public void Descriptor_AdvertisesCanModify_AndImplementsIArchiveModifiable() { - var d = new PakFormatDescriptor(); + var descriptor = new PakFormatDescriptor(); Assert.Multiple(() => { - Assert.That(d.Capabilities & FormatCapabilities.CanModify, Is.EqualTo(FormatCapabilities.CanModify), - "Descriptor must advertise CanModify."); - Assert.That(d, Is.InstanceOf(), - "Descriptor must implement IArchiveModifiable."); + Assert.That(descriptor.Capabilities.HasFlag(FormatCapabilities.CanModify), Is.True); + Assert.That(descriptor, Is.InstanceOf()); }); } - [Test, Category("HappyPath")] - public void Descriptor_AddViaInterface_UsesInPlacePath() { - using var ms = BuildSeedPak(("seed.txt", "seed-content"u8.ToArray())); - var tmp = Path.GetTempFileName(); - try { - File.WriteAllBytes(tmp, "via-if"u8.ToArray()); - ((IArchiveModifiable)new PakFormatDescriptor()).Add(ms, - [new ArchiveInputInfo(tmp, "viaif.txt", false)]); - - ms.Position = 0; - var entries = ReadAll(ms); - Assert.That(entries["viaif.txt"], Is.EqualTo("via-if")); - } finally { File.Delete(tmp); } + private static byte[] BuildSeedPak(params (string Name, byte[] Data)[] entries) { + using var stream = new MemoryStream(); + using (var writer = new PakWriter(stream)) { + foreach (var (name, data) in entries) + writer.AddEntry(name, data); + writer.Finish(); + } + return stream.ToArray(); } - // ── Helpers ──────────────────────────────────────────────────────── - - private static MemoryStream BuildSeedPak(params (string Name, byte[] Data)[] entries) { - var ms = new MemoryStream(); - var w = new PakWriter(ms); - foreach (var (name, data) in entries) - w.AddEntry(name, data); - w.Finish(); - ms.Position = 0; - var copy = new MemoryStream(); - ms.CopyTo(copy); - copy.Position = 0; - return copy; + private static byte[] BuildAliasedPak() { + var bytes = BuildSeedPak(("a.bin", "shared-payload"u8.ToArray())); + var directoryOffset = DirectoryOffset(bytes); + var originalLength = bytes.Length; + Array.Resize(ref bytes, originalLength + PakReader.DirectoryEntrySize); + bytes.AsSpan(directoryOffset, PakReader.DirectoryEntrySize) + .CopyTo(bytes.AsSpan(directoryOffset + PakReader.DirectoryEntrySize, PakReader.DirectoryEntrySize)); + bytes.AsSpan(directoryOffset + PakReader.DirectoryEntrySize, PakReader.NameFieldSize).Clear(); + "b.bin"u8.CopyTo(bytes.AsSpan(directoryOffset + PakReader.DirectoryEntrySize, PakReader.NameFieldSize)); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(8, 4), 2 * PakReader.DirectoryEntrySize); + return bytes; } - private static Dictionary ReadAll(Stream s) { - s.Position = 0; - var r = new PakReader(s); - var result = new Dictionary(); - while (r.GetNextEntry() is { } e) { - var data = r.ReadEntryData(); - result[e.FileName] = System.Text.Encoding.Latin1.GetString(data); - } + private static int DirectoryOffset(byte[] archive) + => BinaryPrimitives.ReadInt32LittleEndian(archive.AsSpan(4, 4)); + + private static Dictionary EntryMap(byte[] archive) { + using var stream = new MemoryStream(archive, writable: false); + using var reader = new PakReader(stream); + return reader.Entries.ToDictionary(entry => entry.FileName, StringComparer.OrdinalIgnoreCase); + } + + private static byte[] ReadEntry(byte[] archive, string name) { + using var stream = new MemoryStream(archive, writable: false); + using var reader = new PakReader(stream); + while (reader.GetNextEntry() is { } entry) + if (string.Equals(entry.FileName, name, StringComparison.OrdinalIgnoreCase)) + return reader.ReadEntryData(); + throw new AssertionException($"Entry '{name}' not found."); + } + + private static byte[] Pattern(int size, int seed) { + var result = new byte[size]; + for (var i = 0; i < result.Length; ++i) + result[i] = (byte)((i * 131 + seed) & 0xFF); return result; } - private static void AssertBytesEqual(ReadOnlySpan expected, ReadOnlySpan actual, string message) { - if (expected.Length != actual.Length) - Assert.Fail($"{message} (length: expected {expected.Length}, got {actual.Length})"); - for (var i = 0; i < expected.Length; ++i) { - if (expected[i] != actual[i]) - Assert.Fail($"{message} (first difference at offset {i}: expected 0x{expected[i]:X2}, got 0x{actual[i]:X2})"); + private static MemoryStream Load(byte[] bytes) { + var stream = new MemoryStream(); + stream.Write(bytes); + stream.Position = 0; + return stream; + } + + private sealed class CountingStream(Stream inner) : Stream { + public long BytesRead { get; private set; } + public long BytesWritten { get; private set; } + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + public override long Position { get => inner.Position; set => inner.Position = value; } + public override void Flush() => inner.Flush(); + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => inner.SetLength(value); + public override int Read(byte[] buffer, int offset, int count) { + var read = inner.Read(buffer, offset, count); + this.BytesRead += read; + return read; + } + public override int Read(Span buffer) { + var read = inner.Read(buffer); + this.BytesRead += read; + return read; + } + public override int ReadByte() { + var value = inner.ReadByte(); + if (value >= 0) ++this.BytesRead; + return value; + } + public override void Write(byte[] buffer, int offset, int count) { + inner.Write(buffer, offset, count); + this.BytesWritten += count; + } + public override void Write(ReadOnlySpan buffer) { + inner.Write(buffer); + this.BytesWritten += buffer.Length; + } + public override void WriteByte(byte value) { + inner.WriteByte(value); + ++this.BytesWritten; } + protected override void Dispose(bool disposing) { } } } From 0050ec4bf6c9c13c17adbd1d45794bc495003cac Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 17:02:01 +0200 Subject: [PATCH 10/13] * expose Quake PAK geometry as format constants --- FileFormats/FileFormat.Pak/PakReader.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/FileFormats/FileFormat.Pak/PakReader.cs b/FileFormats/FileFormat.Pak/PakReader.cs index 6eac6ea28..8de5b338e 100644 --- a/FileFormats/FileFormat.Pak/PakReader.cs +++ b/FileFormats/FileFormat.Pak/PakReader.cs @@ -10,9 +10,12 @@ namespace FileFormat.Pak; /// in-place modifier deliberately requires the canonical trailing-directory form. /// public sealed class PakReader : IDisposable { - internal const int HeaderSize = 12; - internal const int DirectoryEntrySize = 64; - internal const int NameFieldSize = 56; + /// Size of the fixed PACK header in bytes. + public const int HeaderSize = 12; + /// Size of one PACK directory record in bytes. + public const int DirectoryEntrySize = 64; + /// Size of the NUL-padded file-name field in one directory record. + public const int NameFieldSize = 56; private readonly Stream _stream; private readonly List _entries = []; From ea9559fd4b5fd3717a5314c8d80452d1db74b05d Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 17:02:50 +0200 Subject: [PATCH 11/13] * enforce the canonical Quake 2048-entry limit --- FileFormats/FileFormat.Pak/PakReader.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/FileFormats/FileFormat.Pak/PakReader.cs b/FileFormats/FileFormat.Pak/PakReader.cs index 8de5b338e..7209446d7 100644 --- a/FileFormats/FileFormat.Pak/PakReader.cs +++ b/FileFormats/FileFormat.Pak/PakReader.cs @@ -16,6 +16,8 @@ public sealed class PakReader : IDisposable { public const int DirectoryEntrySize = 64; /// Size of the NUL-padded file-name field in one directory record. public const int NameFieldSize = 56; + /// Maximum directory entries accepted by the original Quake engine. + public const int MaxEntries = 2048; private readonly Stream _stream; private readonly List _entries = []; @@ -54,10 +56,13 @@ public PakReader(Stream stream) { if ((long)directoryOffset + directoryLength > stream.Length) throw new InvalidDataException("Quake PAK directory extends beyond end of stream."); + var count = directoryLength / DirectoryEntrySize; + if (count > MaxEntries) + throw new NotSupportedException($"Quake PAK contains {count} entries; the original engine limit is {MaxEntries}."); + this.DirectoryOffset = directoryOffset; this.DirectoryLength = directoryLength; - var count = directoryLength / DirectoryEntrySize; var record = new byte[DirectoryEntrySize]; stream.Position = directoryOffset; for (var i = 0; i < count; ++i) { From c944d8c327379ee1a87367537f6416e0738befea Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 17:03:10 +0200 Subject: [PATCH 12/13] * keep emitted PAKs within Quake's directory limit --- FileFormats/FileFormat.Pak/PakWriter.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/FileFormats/FileFormat.Pak/PakWriter.cs b/FileFormats/FileFormat.Pak/PakWriter.cs index 82a2ca5ed..0a8323e5a 100644 --- a/FileFormats/FileFormat.Pak/PakWriter.cs +++ b/FileFormats/FileFormat.Pak/PakWriter.cs @@ -32,6 +32,8 @@ public PakWriter(Stream stream) { public void AddEntry(string fileName, byte[] data) { if (this._finished) throw new InvalidOperationException("The PAK directory has already been written."); + if (this._entries.Count >= PakReader.MaxEntries) + throw new NotSupportedException($"Quake PAK supports at most {PakReader.MaxEntries} directory entries."); ArgumentNullException.ThrowIfNull(data); var nameBytes = EncodeName(fileName); var terminator = Array.IndexOf(nameBytes, (byte)0); @@ -86,7 +88,7 @@ internal static byte[] EncodeName(string fileName) { if (part.Length == 0 || part is "." or "..") throw new ArgumentException("Unsafe Quake PAK entry path.", nameof(fileName)); } - if (normalized.Any(c => c is '\0' or > '\x7F')) + if (normalized.Any(c => c == '\0' || c > '\x7F')) throw new ArgumentException("Quake PAK names are 7-bit archive paths.", nameof(fileName)); var bytes = Encoding.ASCII.GetBytes(normalized); if (bytes.Length >= PakReader.NameFieldSize) From 5df1c790f03154d9ad2f3e8c9792a162c6463fec Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Sun, 6 Sep 2026 17:03:55 +0200 Subject: [PATCH 13/13] * enforce Quake's entry limit during PAK mutation --- FileFormats/FileFormat.Pak/PakInPlaceModifier.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs b/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs index de035ea70..438938ae1 100644 --- a/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs +++ b/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs @@ -47,6 +47,10 @@ public static void AddFiles( var state = ReadCanonicalState(pak); var planned = new List(state.Entries); var byName = BuildUniqueNameIndex(state.Entries, requests.Select(request => request.Name)); + var newNames = requests.Count(request => !byName.ContainsKey(request.Name)); + if (planned.Count + newNames > PakReader.MaxEntries) + throw new NotSupportedException($"Quake PAK supports at most {PakReader.MaxEntries} directory entries."); + var wipeCandidates = new List(); var appendOffset = state.DirectoryOffset; @@ -158,10 +162,14 @@ private static State ReadCanonicalState(Stream pak) { if ((long)directoryOffset + directoryLength != pak.Length) throw new NotSupportedException("Changed-byte PAK editing requires the directory to be the exact physical trailer."); - var entries = new List(directoryLength / PakReader.DirectoryEntrySize); + var entryCount = directoryLength / PakReader.DirectoryEntrySize; + if (entryCount > PakReader.MaxEntries) + throw new NotSupportedException($"Quake PAK contains {entryCount} entries; the original engine limit is {PakReader.MaxEntries}."); + + var entries = new List(entryCount); var record = new byte[PakReader.DirectoryEntrySize]; pak.Position = directoryOffset; - for (var i = 0; i < directoryLength / PakReader.DirectoryEntrySize; ++i) { + for (var i = 0; i < entryCount; ++i) { pak.ReadExactly(record); var rawName = record.AsSpan(0, PakReader.NameFieldSize).ToArray(); var name = DecodeName(rawName);