feat(pipeline): MP1b - pak key + header/TOC primitives
TDD: PakKeyTests + PakFormatTests written first (confirmed a compile failure against the not-yet-existing AcDream.Content.Pak namespace), then PakKey (64-bit type:u8|fileId:u32|reserved:u24 compose/decompose) and PakFormat (64-byte PakHeader, 24-byte PakTocEntry) implemented to the normative layout in the MP1b plan. 21 tests green, including a key- ordering test proving ascending numeric key order equals ascending (type, fileId) tuple order (the TOC binary-search precondition) and an explicit byte-offset test for both structs.
This commit is contained in:
parent
f29255a45c
commit
8248abe9d4
4 changed files with 371 additions and 8 deletions
124
src/AcDream.Content/Pak/PakFormat.cs
Normal file
124
src/AcDream.Content/Pak/PakFormat.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
|
||||
namespace AcDream.Content.Pak;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed 64-byte pak file header. Layout (all integers little-endian):
|
||||
/// <code>
|
||||
/// 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)
|
||||
/// </code>
|
||||
/// Spec: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1 (normative)".
|
||||
/// </summary>
|
||||
public struct PakHeader {
|
||||
public const int Size = 64;
|
||||
public const uint MagicValue = 0x4B504341u; // 'ACPK' little-endian
|
||||
|
||||
/// <summary>Always <see cref="MagicValue"/> after <see cref="ReadFrom(ReadOnlySpan{byte})"/>; not settable by callers building a header to write.</summary>
|
||||
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<byte> 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<byte> buf = stackalloc byte[Size];
|
||||
WriteTo(buf);
|
||||
stream.Write(buf);
|
||||
}
|
||||
|
||||
public static PakHeader ReadFrom(ReadOnlySpan<byte> 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<byte> buf = stackalloc byte[Size];
|
||||
stream.ReadExactly(buf);
|
||||
return ReadFrom((ReadOnlySpan<byte>)buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One 24-byte TOC entry: <c>key u64, offset u64, length u32, crc32 u32</c>.
|
||||
/// Entries in a pak's TOC are sorted ascending by <see cref="Key"/> to allow
|
||||
/// binary-search lookup. <see cref="Crc32"/> is a corruption tripwire computed
|
||||
/// over the blob bytes; the reader verifies lazily on first access.
|
||||
/// </summary>
|
||||
public struct PakTocEntry {
|
||||
public const int Size = 24;
|
||||
|
||||
public ulong Key;
|
||||
public ulong Offset;
|
||||
public uint Length;
|
||||
public uint Crc32;
|
||||
|
||||
public void WriteTo(Span<byte> 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<byte> buf = stackalloc byte[Size];
|
||||
WriteTo(buf);
|
||||
stream.Write(buf);
|
||||
}
|
||||
|
||||
public static PakTocEntry ReadFrom(ReadOnlySpan<byte> 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]),
|
||||
};
|
||||
}
|
||||
}
|
||||
33
src/AcDream.Content/Pak/PakKey.cs
Normal file
33
src/AcDream.Content/Pak/PakKey.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
namespace AcDream.Content.Pak;
|
||||
|
||||
/// <summary>
|
||||
/// MP1b pak asset-type discriminant — the top byte of a <see cref="PakKey"/>.
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public enum PakAssetType : byte {
|
||||
GfxObjMesh = 1,
|
||||
SetupMesh = 2,
|
||||
EnvCellMesh = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes/decomposes the 64-bit pak asset key: <c>type:u8 | fileId:u32 | reserved:u24</c>.
|
||||
/// Layout: <c>((ulong)type << 56) | ((ulong)fileId << 24)</c> — 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue