feat(content): bake and read flat collision assets

Append strict collision/topology payloads to the existing prepared package so later physics cutover can drop parsed DAT graphs without adding a second mapping or changing traversal behavior. The full 2,232,170-key catalog is deterministic across worker counts, exact-byte aliased, corruption-isolated, and cancellation-safe.
This commit is contained in:
Erik 2026-07-25 15:22:08 +02:00
parent d9300c7854
commit 9cd42417a8
29 changed files with 2868 additions and 63 deletions

View file

@ -20,10 +20,11 @@ public static class PakFormat {
/// Identity of the bake algorithm that produced the payloads. Version 2
/// introduced content-identity EnvCell blobs with ordinary TOC aliases;
/// version 3 embeds exact render-pass translucency in each texture batch
/// so production never rebuilds surface metadata from live DAT.
/// so production never rebuilds surface metadata from live DAT. Version 4
/// adds complete immutable flat collision and EnvCell-topology payloads.
/// The binary format remains version 1.
/// </summary>
public const uint CurrentBakeToolVersion = 3;
public const uint CurrentBakeToolVersion = 4;
}
/// <summary>

View file

@ -11,6 +11,10 @@ public enum PakAssetType : byte {
GfxObjMesh = 1,
SetupMesh = 2,
EnvCellMesh = 3,
GfxObjCollision = 4,
SetupCollision = 5,
CellStructureCollision = 6,
EnvCellTopology = 7,
}
/// <summary>

View file

@ -145,6 +145,44 @@ public sealed class PakReader : IDisposable {
ulong key,
out ObjectMeshData? data) {
data = null;
PakAssetType type = PakKey.Decompose(key).Type;
if (type is not (
PakAssetType.GfxObjMesh or
PakAssetType.SetupMesh or
PakAssetType.EnvCellMesh)) {
return PakObjectReadStatus.Missing;
}
PakObjectReadStatus status = ReadBlobBytes(key, out byte[]? bytes);
if (status != PakObjectReadStatus.Loaded || bytes is null)
return status;
try {
data = ObjectMeshDataSerializer.Read(bytes);
return PakObjectReadStatus.Loaded;
}
catch (Exception ex) {
// Structurally malformed blob behind a valid CRC (bake-side bug or
// a tamper that recomputed the CRC). External-file input: demote
// to missing, log once — never propagate from a lookup.
data = null;
MarkPayloadCorrupt(
key,
$"deserialization failed despite matching CRC: " +
$"{ex.GetType().Name}: {ex.Message}");
return PakObjectReadStatus.Corrupt;
}
}
/// <summary>
/// Copies and CRC-verifies one typed blob without interpreting its schema.
/// Content-layer typed sources deserialize the returned bytes and call
/// <see cref="MarkPayloadCorrupt"/> on structural failure.
/// </summary>
internal PakObjectReadStatus ReadBlobBytes(
ulong key,
out byte[]? bytes) {
bytes = null;
int index = BinarySearch(key);
if (index < 0) return PakObjectReadStatus.Missing;
@ -156,7 +194,7 @@ public sealed class PakReader : IDisposable {
// Single pass (review finding 4): ONE copy out of the map; CRC and
// deserialization both run over this same buffer.
ref readonly var entry = ref _toc[index];
var bytes = new byte[entry.Length];
bytes = new byte[entry.Length];
_accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length);
if (!judged) {
@ -164,24 +202,21 @@ public sealed class PakReader : IDisposable {
if (actualCrc != entry.Crc32) {
_entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, $"crc mismatch (expected 0x{entry.Crc32:X8}, got 0x{actualCrc:X8})");
bytes = null;
return PakObjectReadStatus.Corrupt;
}
_entryVerdictByTocIndex[index] = 1;
}
try {
data = ObjectMeshDataSerializer.Read(bytes);
return PakObjectReadStatus.Loaded;
}
catch (Exception ex) {
// Structurally malformed blob behind a valid CRC (bake-side bug or
// a tamper that recomputed the CRC). External-file input: demote
// to missing, log once — never propagate from a lookup.
data = null;
_entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, $"deserialization failed despite matching CRC: {ex.GetType().Name}: {ex.Message}");
return PakObjectReadStatus.Corrupt;
}
return PakObjectReadStatus.Loaded;
}
internal void MarkPayloadCorrupt(ulong key, string reason) {
int index = BinarySearch(key);
if (index < 0)
return;
_entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, reason);
}
/// <summary>Returns the blob's file offset for alignment assertions in tests.</summary>
@ -198,6 +233,20 @@ public sealed class PakReader : IDisposable {
return _toc[index];
}
/// <summary>
/// Counts one typed catalog partition without touching payload bytes.
/// Used by the offline publisher to prove package completeness.
/// </summary>
public int CountEntries(PakAssetType type) {
byte rawType = (byte)type;
int count = 0;
for (int i = 0; i < _toc.Length; i++) {
if ((byte)(_toc[i].Key >> 56) == rawType)
count++;
}
return count;
}
/// <summary>
/// Validates the complete immutable TOC before an offline bake publishes
/// it. This is intentionally stricter than runtime lookup: key order and

View file

@ -46,28 +46,51 @@ public sealed class PakWriter : IDisposable {
/// </summary>
public void AddBlob(ulong key, ObjectMeshData data) {
ThrowIfFinished();
if (!_seenKeys.Add(key)) {
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
}
ReserveKey(key);
long offset = _stream.Position;
using var ms = new MemoryStream();
ObjectMeshDataSerializer.Write(data, ms);
var bytes = ms.ToArray();
AddReservedBlob(key, bytes);
}
/// <summary>
/// Adds an already serialized immutable payload. This is the package seam
/// used by typed non-render assets such as flat collision records.
/// </summary>
public void AddBlob(ulong key, ReadOnlySpan<byte> bytes) {
ThrowIfFinished();
ReserveKey(key);
AddReservedBlob(key, bytes);
}
private void AddReservedBlob(ulong key, ReadOnlySpan<byte> bytes) {
if ((ulong)bytes.Length > uint.MaxValue) {
throw new ArgumentException(
"one pak payload cannot exceed UInt32.MaxValue bytes",
nameof(bytes));
}
long offset = _stream.Position;
_stream.Write(bytes);
PadToAlignment();
var receipt = new PakTocEntry {
Key = key,
Offset = (ulong)offset,
Length = (uint)bytes.Length,
Length = checked((uint)bytes.Length),
Crc32 = Content.Pak.Crc32.Compute(bytes),
};
_tocEntries.Add(receipt);
_receiptByKey.Add(key, receipt);
}
private void ReserveKey(ulong key) {
if (!_seenKeys.Add(key)) {
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
}
}
/// <summary>
/// Adds a second independently addressable key for an existing physical
/// blob. The alias receives a normal TOC row with the source blob's exact