feat(pipeline): MP1b - PakWriter + mmap PakReader

TDD: PakRoundTripTests written first, confirmed a compile failure
against the not-yet-existing PakWriter/PakReader types.

PakWriter streams [header placeholder][64-byte-aligned blobs][TOC sorted
by key], then seeks back and finalizes the header once TocOffset/TocCount
are known (plan: "TOC last" so the writer doesn't need blob count
up front). PakReader mmaps the whole file once, loads the TOC into a
sorted array for O(log n) binary-search lookup, and verifies each blob's
CRC-32 LAZILY on first access (cached per-index so a corrupt blob logs
exactly once and is thereafter always treated as missing rather than
handing back garbage bytes). No locks anywhere — the mapped memory is
immutable for the reader's lifetime by construction.

CRC-32 implemented directly (standard IEEE 802.3 table-driven variant)
rather than adding a System.IO.Hashing package reference, keeping
AcDream.Content's dependency surface minimal per its existing
"no GL binaries" intent.

9 tests green: header round-trip, every-key-found, blob deep-equality
via the Task 3 comparator, missing-key returns false, corrupted-blob
(single flipped byte) is detected and treated as missing while leaving
sibling blobs unaffected, 64-byte blob alignment, and TOC binary search
cross-checked against a linear scan for both present and absent keys.
This commit is contained in:
Erik 2026-07-05 21:21:58 +02:00
parent a5ba435839
commit 981025d58b
4 changed files with 495 additions and 0 deletions

View file

@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace AcDream.Content.Pak;
/// <summary>
/// Streams pak blobs to disk: header placeholder -> 64-byte-aligned blobs ->
/// TOC (sorted by key) -> seek back and finalize the header once
/// tocOffset/tocCount are known. This lets the writer stream blobs without
/// knowing the final count up front (plan: "TOC last").
/// </summary>
public sealed class PakWriter : IDisposable {
private const int Alignment = 64;
private readonly FileStream _stream;
private readonly PakHeader _headerTemplate;
private readonly List<PakTocEntry> _tocEntries = new();
private readonly HashSet<ulong> _seenKeys = new();
private bool _finished;
public PakWriter(string path, PakHeader headerTemplate) {
_stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
_headerTemplate = headerTemplate;
// Write a header placeholder now; finalized in Finish() once TocOffset/TocCount are known.
_headerTemplate.WriteTo(_stream);
PadToAlignment();
}
/// <summary>
/// Adds one blob (an <see cref="ObjectMeshData"/> serialized via
/// <see cref="ObjectMeshDataSerializer"/>) under <paramref name="key"/>.
/// Blobs are written immediately at the current (64-byte-aligned) stream
/// position; the writer pads after each blob so the NEXT blob also
/// starts aligned.
/// </summary>
public void AddBlob(ulong key, ObjectMeshData data) {
ThrowIfFinished();
if (!_seenKeys.Add(key)) {
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
}
long offset = _stream.Position;
using var ms = new MemoryStream();
ObjectMeshDataSerializer.Write(data, ms);
var bytes = ms.ToArray();
_stream.Write(bytes);
PadToAlignment();
_tocEntries.Add(new PakTocEntry {
Key = key,
Offset = (ulong)offset,
Length = (uint)bytes.Length,
Crc32 = Content.Pak.Crc32.Compute(bytes),
});
}
/// <summary>Writes the sorted TOC and finalizes the header. Must be called exactly once.</summary>
public void Finish() {
ThrowIfFinished();
_finished = true;
var sorted = _tocEntries.OrderBy(e => e.Key).ToList();
ulong tocOffset = (ulong)_stream.Position;
foreach (var entry in sorted) {
entry.WriteTo(_stream);
}
var finalHeader = _headerTemplate;
finalHeader.TocOffset = tocOffset;
finalHeader.TocCount = (uint)sorted.Count;
_stream.Position = 0;
finalHeader.WriteTo(_stream);
_stream.Flush();
}
private void PadToAlignment() {
long pos = _stream.Position;
long remainder = pos % Alignment;
if (remainder == 0) return;
long pad = Alignment - remainder;
Span<byte> zeros = stackalloc byte[(int)pad];
zeros.Clear();
_stream.Write(zeros);
}
private void ThrowIfFinished() {
if (_finished) throw new InvalidOperationException("PakWriter.Finish() has already been called.");
}
public void Dispose() {
if (!_finished) {
// Best-effort finalize so a forgotten Finish() call doesn't leave a
// half-written file with a placeholder header claiming 0 entries
// while blobs are on disk — surfaced loudly instead via Finish()'s
// own invariants on next use, but here we simply flush what exists.
try { Finish(); } catch (InvalidOperationException) { /* already finished */ }
}
_stream.Dispose();
}
}