From 0dee14765b63efb53308ddbc10d36f4f0888dd36 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 13:33:53 +0200 Subject: [PATCH] feat(content): add physical blob aliases to pak writer --- src/AcDream.Content/Pak/PakReader.cs | 7 + src/AcDream.Content/Pak/PakWriter.cs | 30 +++- tests/AcDream.Content.Tests/PakAliasTests.cs | 148 +++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.Content.Tests/PakAliasTests.cs 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)), + ], + }; +}