diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs new file mode 100644 index 00000000..f871d451 --- /dev/null +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -0,0 +1,124 @@ +using System; +using System.Buffers.Binary; +using System.IO; + +namespace AcDream.Content.Pak; + +/// +/// Fixed 64-byte pak file header. Layout (all integers little-endian): +/// +/// offset size field +/// 0 4 magic 'ACPK' (0x4B504341) +/// 4 4 formatVersion = 1 +/// 8 4 portalIteration (DatCollection.Portal.Iteration) +/// 12 4 cellIteration +/// 16 4 highResIteration +/// 20 4 languageIteration +/// 24 8 tocOffset (u64) +/// 32 4 tocCount (u32) +/// 36 4 bakeToolVersion = 1 +/// 40 24 reserved (zero) +/// +/// Spec: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1 (normative)". +/// +public struct PakHeader { + public const int Size = 64; + public const uint MagicValue = 0x4B504341u; // 'ACPK' little-endian + + /// Always after ; not settable by callers building a header to write. + public uint Magic { get; private set; } = MagicValue; + + public uint FormatVersion; + public uint PortalIteration; + public uint CellIteration; + public uint HighResIteration; + public uint LanguageIteration; + public ulong TocOffset; + public uint TocCount; + public uint BakeToolVersion; + + public PakHeader() { } + + public void WriteTo(Span dest) { + if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest)); + BinaryPrimitives.WriteUInt32LittleEndian(dest[0..4], MagicValue); + BinaryPrimitives.WriteUInt32LittleEndian(dest[4..8], FormatVersion); + BinaryPrimitives.WriteUInt32LittleEndian(dest[8..12], PortalIteration); + BinaryPrimitives.WriteUInt32LittleEndian(dest[12..16], CellIteration); + BinaryPrimitives.WriteUInt32LittleEndian(dest[16..20], HighResIteration); + BinaryPrimitives.WriteUInt32LittleEndian(dest[20..24], LanguageIteration); + BinaryPrimitives.WriteUInt64LittleEndian(dest[24..32], TocOffset); + BinaryPrimitives.WriteUInt32LittleEndian(dest[32..36], TocCount); + BinaryPrimitives.WriteUInt32LittleEndian(dest[36..40], BakeToolVersion); + dest[40..64].Clear(); // reserved, zero + } + + public void WriteTo(Stream stream) { + Span buf = stackalloc byte[Size]; + WriteTo(buf); + stream.Write(buf); + } + + public static PakHeader ReadFrom(ReadOnlySpan src) { + if (src.Length < Size) throw new ArgumentException($"source must be at least {Size} bytes", nameof(src)); + var magic = BinaryPrimitives.ReadUInt32LittleEndian(src[0..4]); + if (magic != MagicValue) { + throw new InvalidDataException($"pak header magic mismatch: expected 0x{MagicValue:X8}, got 0x{magic:X8}"); + } + return new PakHeader { + FormatVersion = BinaryPrimitives.ReadUInt32LittleEndian(src[4..8]), + PortalIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[8..12]), + CellIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[12..16]), + HighResIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[16..20]), + LanguageIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[20..24]), + TocOffset = BinaryPrimitives.ReadUInt64LittleEndian(src[24..32]), + TocCount = BinaryPrimitives.ReadUInt32LittleEndian(src[32..36]), + BakeToolVersion = BinaryPrimitives.ReadUInt32LittleEndian(src[36..40]), + }; + } + + public static PakHeader ReadFrom(Stream stream) { + Span buf = stackalloc byte[Size]; + stream.ReadExactly(buf); + return ReadFrom((ReadOnlySpan)buf); + } +} + +/// +/// One 24-byte TOC entry: key u64, offset u64, length u32, crc32 u32. +/// Entries in a pak's TOC are sorted ascending by to allow +/// binary-search lookup. is a corruption tripwire computed +/// over the blob bytes; the reader verifies lazily on first access. +/// +public struct PakTocEntry { + public const int Size = 24; + + public ulong Key; + public ulong Offset; + public uint Length; + public uint Crc32; + + public void WriteTo(Span dest) { + if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest)); + BinaryPrimitives.WriteUInt64LittleEndian(dest[0..8], Key); + BinaryPrimitives.WriteUInt64LittleEndian(dest[8..16], Offset); + BinaryPrimitives.WriteUInt32LittleEndian(dest[16..20], Length); + BinaryPrimitives.WriteUInt32LittleEndian(dest[20..24], Crc32); + } + + public void WriteTo(Stream stream) { + Span buf = stackalloc byte[Size]; + WriteTo(buf); + stream.Write(buf); + } + + public static PakTocEntry ReadFrom(ReadOnlySpan src) { + if (src.Length < Size) throw new ArgumentException($"source must be at least {Size} bytes", nameof(src)); + return new PakTocEntry { + Key = BinaryPrimitives.ReadUInt64LittleEndian(src[0..8]), + Offset = BinaryPrimitives.ReadUInt64LittleEndian(src[8..16]), + Length = BinaryPrimitives.ReadUInt32LittleEndian(src[16..20]), + Crc32 = BinaryPrimitives.ReadUInt32LittleEndian(src[20..24]), + }; + } +} diff --git a/src/AcDream.Content/Pak/PakKey.cs b/src/AcDream.Content/Pak/PakKey.cs new file mode 100644 index 00000000..da1a8753 --- /dev/null +++ b/src/AcDream.Content/Pak/PakKey.cs @@ -0,0 +1,33 @@ +namespace AcDream.Content.Pak; + +/// +/// MP1b pak asset-type discriminant — the top byte of a . +/// Numeric values are a WIRE FORMAT (persisted in every pak's TOC): never +/// renumber existing members, only append. +/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.2; +/// plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1". +/// +public enum PakAssetType : byte { + GfxObjMesh = 1, + SetupMesh = 2, + EnvCellMesh = 3, +} + +/// +/// Composes/decomposes the 64-bit pak asset key: type:u8 | fileId:u32 | reserved:u24. +/// Layout: ((ulong)type << 56) | ((ulong)fileId << 24) — the low 24 bits are +/// reserved (variant/zero in v1). Ascending numeric key order equals ascending +/// (type, fileId) tuple order, which is what makes the pak TOC's binary search +/// over raw u64 keys valid. +/// +public static class PakKey { + public static ulong Compose(PakAssetType type, uint fileId) { + return ((ulong)type << 56) | ((ulong)fileId << 24); + } + + public static (PakAssetType Type, uint FileId) Decompose(ulong key) { + var type = (PakAssetType)(byte)(key >> 56); + var fileId = (uint)((key >> 24) & 0xFFFFFFFFu); + return (type, fileId); + } +} diff --git a/tests/AcDream.Content.Tests/PakFormatTests.cs b/tests/AcDream.Content.Tests/PakFormatTests.cs new file mode 100644 index 00000000..4eb1181d --- /dev/null +++ b/tests/AcDream.Content.Tests/PakFormatTests.cs @@ -0,0 +1,145 @@ +using System.IO; +using AcDream.Content.Pak; + +namespace AcDream.Content.Tests; + +public class PakFormatTests { + [Fact] + public void Header_Size_Is64Bytes() { + Assert.Equal(64, PakHeader.Size); + } + + [Fact] + public void TocEntry_Size_Is24Bytes() { + Assert.Equal(24, PakTocEntry.Size); + } + + [Fact] + public void Header_Magic_IsAcpkLittleEndian() { + // 'A'=0x41 'C'=0x43 'P'=0x50 'K'=0x4B — little-endian dword reads + // back as 0x4B504341 per the normative spec. + Assert.Equal(0x4B504341u, PakHeader.MagicValue); + } + + [Fact] + public void Header_WriteThenRead_RoundTripsEveryField() { + var header = new PakHeader { + FormatVersion = 1, + PortalIteration = 111, + CellIteration = 222, + HighResIteration = 333, + LanguageIteration = 444, + TocOffset = 0x1_0000_0002UL, + TocCount = 777, + BakeToolVersion = 1, + }; + + using var ms = new MemoryStream(); + header.WriteTo(ms); + Assert.Equal(PakHeader.Size, ms.Length); + + ms.Position = 0; + var readBack = PakHeader.ReadFrom(ms); + + Assert.Equal(PakHeader.MagicValue, readBack.Magic); + Assert.Equal(header.FormatVersion, readBack.FormatVersion); + Assert.Equal(header.PortalIteration, readBack.PortalIteration); + Assert.Equal(header.CellIteration, readBack.CellIteration); + Assert.Equal(header.HighResIteration, readBack.HighResIteration); + Assert.Equal(header.LanguageIteration, readBack.LanguageIteration); + Assert.Equal(header.TocOffset, readBack.TocOffset); + Assert.Equal(header.TocCount, readBack.TocCount); + Assert.Equal(header.BakeToolVersion, readBack.BakeToolVersion); + } + + [Fact] + public void Header_ReadFrom_Span_RoundTrips() { + var header = new PakHeader { + FormatVersion = 1, + PortalIteration = 5, + CellIteration = 6, + HighResIteration = 7, + LanguageIteration = 8, + TocOffset = 999, + TocCount = 3, + BakeToolVersion = 1, + }; + Span buf = stackalloc byte[PakHeader.Size]; + header.WriteTo(buf); + var readBack = PakHeader.ReadFrom((ReadOnlySpan)buf); + Assert.Equal(header.CellIteration, readBack.CellIteration); + Assert.Equal(header.TocOffset, readBack.TocOffset); + } + + [Fact] + public void Header_ReservedBytes_AreZero() { + var header = new PakHeader { FormatVersion = 1, BakeToolVersion = 1 }; + Span buf = stackalloc byte[PakHeader.Size]; + header.WriteTo(buf); + // offset 40, length 24 per the normative layout + for (int i = 40; i < 64; i++) { + Assert.Equal(0, buf[i]); + } + } + + [Fact] + public void Header_FieldOffsets_MatchNormativeLayout() { + var header = new PakHeader { + FormatVersion = 0x11111111, + PortalIteration = 0x22222222, + CellIteration = 0x33333333, + HighResIteration = 0x44444444, + LanguageIteration = 0x55555555, + TocOffset = 0x6666666677777777UL, + TocCount = 0x88888888, + BakeToolVersion = 0x99999999, + }; + Span buf = stackalloc byte[PakHeader.Size]; + header.WriteTo(buf); + + Assert.Equal(PakHeader.MagicValue, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[0..4])); + Assert.Equal(0x11111111u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[4..8])); + Assert.Equal(0x22222222u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[8..12])); + Assert.Equal(0x33333333u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[12..16])); + Assert.Equal(0x44444444u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[16..20])); + Assert.Equal(0x55555555u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[20..24])); + Assert.Equal(0x6666666677777777UL, System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buf[24..32])); + Assert.Equal(0x88888888u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[32..36])); + Assert.Equal(0x99999999u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[36..40])); + } + + [Fact] + public void TocEntry_WriteThenRead_RoundTrips() { + var entry = new PakTocEntry { + Key = PakKey.Compose(PakAssetType.GfxObjMesh, 0x010002B4u), + Offset = 0x4000, + Length = 12345, + Crc32 = 0xDEADBEEF, + }; + Span buf = stackalloc byte[PakTocEntry.Size]; + entry.WriteTo(buf); + var readBack = PakTocEntry.ReadFrom((ReadOnlySpan)buf); + + Assert.Equal(entry.Key, readBack.Key); + Assert.Equal(entry.Offset, readBack.Offset); + Assert.Equal(entry.Length, readBack.Length); + Assert.Equal(entry.Crc32, readBack.Crc32); + } + + [Fact] + public void TocEntry_FieldOffsets_MatchNormativeLayout() { + var entry = new PakTocEntry { + Key = 0x1111111122222222UL, + Offset = 0x3333333344444444UL, + Length = 0x55555555, + Crc32 = 0x66666666, + }; + Span buf = stackalloc byte[PakTocEntry.Size]; + entry.WriteTo(buf); + + Assert.Equal(0x1111111122222222UL, System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buf[0..8])); + Assert.Equal(0x3333333344444444UL, System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buf[8..16])); + Assert.Equal(0x55555555u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[16..20])); + Assert.Equal(0x66666666u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[20..24])); + } +} diff --git a/tests/AcDream.Content.Tests/PakKeyTests.cs b/tests/AcDream.Content.Tests/PakKeyTests.cs index 8b6b5bfa..ba527ac5 100644 --- a/tests/AcDream.Content.Tests/PakKeyTests.cs +++ b/tests/AcDream.Content.Tests/PakKeyTests.cs @@ -1,14 +1,75 @@ +using AcDream.Content.Pak; + namespace AcDream.Content.Tests; -/// -/// Task 1 scaffold smoke test — becomes the real PakKey test suite in Task 2. -/// public class PakKeyTests { + [Theory] + [InlineData(PakAssetType.GfxObjMesh, 0u)] + [InlineData(PakAssetType.GfxObjMesh, 0x01000001u)] + [InlineData(PakAssetType.SetupMesh, 0x020019FFu)] + [InlineData(PakAssetType.EnvCellMesh, 0xA9B40100u)] + [InlineData(PakAssetType.GfxObjMesh, 0xFFFFFFFFu)] // max fileId + [InlineData(PakAssetType.EnvCellMesh, 0xFFFFFFFFu)] + public void ComposeDecompose_RoundTrips(PakAssetType type, uint fileId) { + ulong key = PakKey.Compose(type, fileId); + var (decodedType, decodedFileId) = PakKey.Decompose(key); + Assert.Equal(type, decodedType); + Assert.Equal(fileId, decodedFileId); + } + [Fact] - public void Smoke_ProjectReferencesContentAssembly() { - // Any type from AcDream.Content resolves — proves the ProjectReference - // and test-project wiring are correct before Task 2 adds real coverage. - var key = new TextureKey(); - Assert.Equal(0u, key.SurfaceId); + public void Compose_LowFourBytesReserved() { + // Low 24 bits are reserved (zero in v1) — fileId << 24 must not spill + // into them, and the reserved region must actually read back as zero. + ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, 0xFFFFFFFFu); + Assert.Equal(0u, (uint)(key & 0xFFFFFFu)); + } + + [Fact] + public void Compose_TypeOccupiesTopByte() { + ulong key = PakKey.Compose(PakAssetType.SetupMesh, 0u); + Assert.Equal((byte)PakAssetType.SetupMesh, (byte)(key >> 56)); + } + + [Theory] + [InlineData(PakAssetType.GfxObjMesh, 1)] + [InlineData(PakAssetType.SetupMesh, 2)] + [InlineData(PakAssetType.EnvCellMesh, 3)] + public void AssetType_NumericValuesAreStable(PakAssetType type, byte expected) { + // These values are a wire format — pin them so a future refactor can't + // silently renumber the enum and corrupt existing paks. + Assert.Equal(expected, (byte)type); + } + + [Fact] + public void KeyOrdering_MatchesTypeThenFileIdOrdering() { + // Ascending key order must equal ascending (type, fileId) tuple order — + // this is what makes the TOC's binary search over raw u64 keys valid. + var pairs = new (PakAssetType Type, uint FileId)[] { + (PakAssetType.GfxObjMesh, 0u), + (PakAssetType.GfxObjMesh, 1u), + (PakAssetType.GfxObjMesh, 0xFFFFFFFFu), + (PakAssetType.SetupMesh, 0u), + (PakAssetType.SetupMesh, 0x020019FFu), + (PakAssetType.EnvCellMesh, 0u), + (PakAssetType.EnvCellMesh, 0xA9B40100u), + }; + + var keys = pairs.Select(p => PakKey.Compose(p.Type, p.FileId)).ToArray(); + + // keys[] as generated must already be strictly ascending since pairs[] + // is in ascending tuple order. + for (int i = 1; i < keys.Length; i++) { + Assert.True(keys[i] > keys[i - 1], + $"key[{i}]=0x{keys[i]:X16} should be > key[{i - 1}]=0x{keys[i - 1]:X16} " + + $"for tuple order ({pairs[i - 1]}) < ({pairs[i]})"); + } + + // Sorting the keys numerically must reproduce the same order as sorting + // the tuples lexicographically by (Type, FileId). + var numericSorted = keys.OrderBy(k => k).ToArray(); + var tupleSorted = pairs.OrderBy(p => (byte)p.Type).ThenBy(p => p.FileId) + .Select(p => PakKey.Compose(p.Type, p.FileId)).ToArray(); + Assert.Equal(tupleSorted, numericSorted); } }