diff --git a/src/AcDream.Content/Pak/PakReader.cs b/src/AcDream.Content/Pak/PakReader.cs
index 9a7549d5..842883aa 100644
--- a/src/AcDream.Content/Pak/PakReader.cs
+++ b/src/AcDream.Content/Pak/PakReader.cs
@@ -170,6 +170,13 @@ public sealed class PakReader : IDisposable {
return (long)_toc[index].Offset;
}
+ /// Returns the immutable TOC receipt for alias/layout assertions in tests.
+ public PakTocEntry GetTocEntryForTest(ulong key) {
+ int index = BinarySearch(key);
+ if (index < 0) throw new KeyNotFoundException($"pak key 0x{key:X16} not found");
+ return _toc[index];
+ }
+
/// O(n) linear scan, used only to cross-check the binary search in tests.
public bool DebugLinearScanContainsKey(ulong key) {
for (int i = 0; i < _toc.Length; i++) {
diff --git a/src/AcDream.Content/Pak/PakWriter.cs b/src/AcDream.Content/Pak/PakWriter.cs
index 2a745669..326dd9bf 100644
--- a/src/AcDream.Content/Pak/PakWriter.cs
+++ b/src/AcDream.Content/Pak/PakWriter.cs
@@ -18,6 +18,7 @@ public sealed class PakWriter : IDisposable {
private readonly PakHeader _headerTemplate;
private readonly List _tocEntries = new();
private readonly HashSet _seenKeys = new();
+ private readonly Dictionary _receiptByKey = new();
private bool _finished;
public PakWriter(string path, PakHeader headerTemplate) {
@@ -56,12 +57,37 @@ public sealed class PakWriter : IDisposable {
_stream.Write(bytes);
PadToAlignment();
- _tocEntries.Add(new PakTocEntry {
+ var receipt = new PakTocEntry {
Key = key,
Offset = (ulong)offset,
Length = (uint)bytes.Length,
Crc32 = Content.Pak.Crc32.Compute(bytes),
- });
+ };
+ _tocEntries.Add(receipt);
+ _receiptByKey.Add(key, receipt);
+ }
+
+ ///
+ /// Adds a second independently addressable key for an existing physical
+ /// blob. The alias receives a normal TOC row with the source blob's exact
+ /// offset, length, and CRC; no payload bytes are written again.
+ ///
+ public void AddAlias(ulong aliasKey, ulong existingKey) {
+ ThrowIfFinished();
+ if (aliasKey == existingKey) {
+ throw new ArgumentException("an alias key must differ from its source key", nameof(aliasKey));
+ }
+ if (!_receiptByKey.TryGetValue(existingKey, out var source)) {
+ throw new KeyNotFoundException($"source pak key 0x{existingKey:X16} has not been written");
+ }
+ if (!_seenKeys.Add(aliasKey)) {
+ throw new ArgumentException($"duplicate pak key 0x{aliasKey:X16}", nameof(aliasKey));
+ }
+
+ var alias = source;
+ alias.Key = aliasKey;
+ _tocEntries.Add(alias);
+ _receiptByKey.Add(aliasKey, alias);
}
/// Writes the sorted TOC and finalizes the header. Must be called exactly once.
diff --git a/tests/AcDream.Content.Tests/PakAliasTests.cs b/tests/AcDream.Content.Tests/PakAliasTests.cs
new file mode 100644
index 00000000..c5ba0c3a
--- /dev/null
+++ b/tests/AcDream.Content.Tests/PakAliasTests.cs
@@ -0,0 +1,148 @@
+using AcDream.Content.Pak;
+
+namespace AcDream.Content.Tests;
+
+public sealed class PakAliasTests : IDisposable
+{
+ private readonly string _path =
+ Path.Combine(Path.GetTempPath(), $"acdream-pak-alias-{Guid.NewGuid():N}.pak");
+
+ public void Dispose()
+ {
+ try
+ {
+ if (File.Exists(_path))
+ File.Delete(_path);
+ }
+ catch
+ {
+ // Best-effort test cleanup.
+ }
+ }
+
+ [Fact]
+ public void Alias_HasIndependentKeyAndSharedPhysicalReceipt()
+ {
+ var primaryKey = PakKey.Compose(PakAssetType.EnvCellMesh, 0xA9B4_0100);
+ var aliasKey = PakKey.Compose(PakAssetType.EnvCellMesh, 0xA9B4_0101);
+ var expected = MakeData(0x2_1234_5678);
+
+ using (var writer = NewWriter())
+ {
+ writer.AddBlob(primaryKey, expected);
+ writer.AddAlias(aliasKey, primaryKey);
+ writer.Finish();
+ }
+
+ using var reader = new PakReader(_path);
+ Assert.True(reader.TryReadObjectMeshData(primaryKey, out var primary));
+ Assert.True(reader.TryReadObjectMeshData(aliasKey, out var alias));
+ Assert.NotNull(primary);
+ Assert.NotNull(alias);
+ Assert.Equal(expected.ObjectId, primary.ObjectId);
+ Assert.Equal(expected.ObjectId, alias.ObjectId);
+
+ var primaryReceipt = reader.GetTocEntryForTest(primaryKey);
+ var aliasReceipt = reader.GetTocEntryForTest(aliasKey);
+ Assert.Equal(primaryReceipt.Offset, aliasReceipt.Offset);
+ Assert.Equal(primaryReceipt.Length, aliasReceipt.Length);
+ Assert.Equal(primaryReceipt.Crc32, aliasReceipt.Crc32);
+ Assert.Equal(2u, reader.Header.TocCount);
+ }
+
+ [Fact]
+ public void Alias_CorruptSharedPayload_MakesBothKeysMissing()
+ {
+ var primaryKey = PakKey.Compose(PakAssetType.EnvCellMesh, 1);
+ var aliasKey = PakKey.Compose(PakAssetType.EnvCellMesh, 2);
+
+ using (var writer = NewWriter())
+ {
+ writer.AddBlob(primaryKey, MakeData(0x2_1234_5678));
+ writer.AddAlias(aliasKey, primaryKey);
+ writer.Finish();
+ }
+
+ long offset;
+ using (var reader = new PakReader(_path))
+ offset = reader.GetBlobOffsetForTest(primaryKey);
+
+ using (var stream = new FileStream(_path, FileMode.Open, FileAccess.ReadWrite))
+ {
+ stream.Position = offset;
+ int value = stream.ReadByte();
+ stream.Position = offset;
+ stream.WriteByte((byte)(value ^ 0xFF));
+ }
+
+ using var corruptReader = new PakReader(_path);
+ Assert.False(corruptReader.TryReadObjectMeshData(primaryKey, out _));
+ Assert.False(corruptReader.TryReadObjectMeshData(aliasKey, out _));
+ }
+
+ [Fact]
+ public void Alias_RejectsMissingSourceWithoutReservingAliasKey()
+ {
+ var sourceKey = PakKey.Compose(PakAssetType.EnvCellMesh, 1);
+ var aliasKey = PakKey.Compose(PakAssetType.EnvCellMesh, 2);
+
+ using var writer = NewWriter();
+ Assert.Throws(() => writer.AddAlias(aliasKey, sourceKey));
+
+ writer.AddBlob(aliasKey, MakeData(2));
+ writer.Finish();
+ }
+
+ [Fact]
+ public void Alias_RejectsSelfAndDuplicateKeys()
+ {
+ var primaryKey = PakKey.Compose(PakAssetType.EnvCellMesh, 1);
+ var aliasKey = PakKey.Compose(PakAssetType.EnvCellMesh, 2);
+
+ using var writer = NewWriter();
+ writer.AddBlob(primaryKey, MakeData(1));
+ Assert.Throws(() => writer.AddAlias(primaryKey, primaryKey));
+ writer.AddAlias(aliasKey, primaryKey);
+ Assert.Throws(() => writer.AddAlias(aliasKey, primaryKey));
+ Assert.Throws(() => writer.AddBlob(aliasKey, MakeData(2)));
+ writer.Finish();
+ }
+
+ [Fact]
+ public void Alias_RejectsMutationAfterFinish()
+ {
+ var primaryKey = PakKey.Compose(PakAssetType.EnvCellMesh, 1);
+ var aliasKey = PakKey.Compose(PakAssetType.EnvCellMesh, 2);
+
+ using var writer = NewWriter();
+ writer.AddBlob(primaryKey, MakeData(1));
+ writer.Finish();
+
+ Assert.Throws(() => writer.AddAlias(aliasKey, primaryKey));
+ }
+
+ private PakWriter NewWriter() =>
+ new(
+ _path,
+ new PakHeader
+ {
+ PortalIteration = 1,
+ CellIteration = 2,
+ HighResIteration = 3,
+ LanguageIteration = 4,
+ BakeToolVersion = 1,
+ });
+
+ private static ObjectMeshData MakeData(ulong objectId) =>
+ new()
+ {
+ ObjectId = objectId,
+ Vertices =
+ [
+ new(
+ new System.Numerics.Vector3(1, 2, 3),
+ System.Numerics.Vector3.UnitZ,
+ new System.Numerics.Vector2(0.25f, 0.75f)),
+ ],
+ };
+}