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);
}
}
}
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) { }
}
}
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 @@
-
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; }
+}
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();
}
}
diff --git a/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs b/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs
index 925cfaff9..438938ae1 100644
--- a/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs
+++ b/FileFormats/FileFormat.Pak/PakInPlaceModifier.cs
@@ -1,29 +1,285 @@
#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 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;
+
+ 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 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 < entryCount; ++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.");
+ }
}
diff --git a/FileFormats/FileFormat.Pak/PakReader.cs b/FileFormats/FileFormat.Pak/PakReader.cs
index 120dca21f..7209446d7 100644
--- a/FileFormats/FileFormat.Pak/PakReader.cs
+++ b/FileFormats/FileFormat.Pak/PakReader.cs
@@ -1,25 +1,102 @@
-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;
+ /// 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;
+ /// Maximum directory entries accepted by the original Quake engine.
+ public const int MaxEntries = 2048;
- ///
- /// 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.");
+
+ 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 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() { }
}
diff --git a/FileFormats/FileFormat.Pak/PakWriter.cs b/FileFormats/FileFormat.Pak/PakWriter.cs
index 2cdb26bf3..0a8323e5a 100644
--- a/FileFormats/FileFormat.Pak/PakWriter.cs
+++ b/FileFormats/FileFormat.Pak/PakWriter.cs
@@ -1,25 +1,103 @@
-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.");
+ 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);
+ 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.");
- /// 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((normalized, 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 == '\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)
+ 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() { }
}