acdream/src/AcDream.Content/Pak/PakFormat.cs
Erik 75664805f8 feat(rendering): port retail one-pass detail material
Replace the building and EnvCell detail replay with retail's exact single-pass stage result, including authored surface opacity, squared detail alpha, final-alpha clipping, and the original subset pipeline/order. Arm the ordered walk command in place to close #471, delete the replay pipelines/shaders, and advance prepared content to recipe 9.

Mutation witnesses (each restored before commit):
- X=a*qA: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 174, missing materialAlpha * detail.a * detail.a.
- X*=base alpha: same test line 175, forbidden baseTexel.a found.
- CLIP against base alpha: EnvCellAlphaDrawSourceTests.ClipShaders_UseGreaterEqualForThePerRangeReference line 260, final-X conditional missing.
- second detail draw: EnvCellAlphaDrawSourceTests.DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity line 105, Assert.Single saw 2 MDI calls.
- straight-alpha substitution: WalkStaticStreamPopulatorTests.ImmediateBuildingDetail_RetainsOriginalFramebufferFamily line 1244, Additive first failed (only wb-mesh-alpha-1x recorded; InvAlpha also failed).
- omit ordered arm: OrderPreservingSubmitterTests.PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace line 305, expected (77,3.5), got (0,0).
- omit atmospheric combine: RetailDetailTextureContractTests.BothShaderFamiliesUseTheSharedOnePassSourceAndDebugPrecedesDetailSample line 173, atmospheric shared include missing.
- drop serialized opacity: ObjectMeshDataSerializerTests.SurfaceOpacity_RoundTripsBitExactlyAndDeterministically line 288, first reported 0.5 bits 1056964608 vs 1065353216.
- stale detail arm: ordered adjacency test line 307, expected following ordinary (0,0), got (77,3.5).
- per-frame surface map: EnvCellAlphaDrawSourceTests.ProductionWholeLeaf_WarmedScanSubmitRhiAndFilteredReplayDoNotAllocate line 178, expected 0 B, got 147456 B.

Verification before commit: shader compiler 23/23; focused App 213/213; Content 75/75; Core Wb 10/10; launcher migration 6/6; Release solution build 0 warnings / 0 errors; git diff --check clean.
2026-09-05 02:03:13 +02:00

185 lines
8.7 KiB
C#

using System;
using System.Buffers.Binary;
using System.IO;
namespace AcDream.Content.Pak;
/// <summary>
/// Pak format constants shared by writer and reader.
/// </summary>
public static class PakFormat {
/// <summary>
/// The one format version this build writes and reads. PakWriter stamps
/// it unconditionally (callers cannot override it via the header
/// template); PakReader refuses to open any other version. Bump ONLY
/// with an accompanying reader migration path.
/// </summary>
public const uint CurrentFormatVersion = 2;
/// <summary>
/// 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. Version 4
/// adds complete immutable flat collision and EnvCell-topology payloads.
/// Version 5 (#426, 2026-08-23) extracts untextured (solid-colour)
/// positive faces that versions &lt;=4 dropped — every GfxObj polygon
/// whose Stippling carries NoPos (NO_POS_UVS) previously extracted to
/// zero vertices on its positive side, so any pak baked by an older tool
/// is missing those faces (e.g. the Holtburg windmill axle 0x010010CE,
/// 8 polygons all NoPos + Base1Solid, extracted to a 0-vertex mesh). The
/// binary format remains version 1. Version 6 introduces pak format 2:
/// globally shared texture payloads plus independently Brotli-compressed
/// blobs with raw fallback. Mesh geometry and source texture bytes remain
/// exact; unedited DXT surfaces now retain their native BC blocks. Version
/// 7 replaces the synthetic vertex-AABB GfxObj view sphere with retail's
/// authored DrawingBSP root sphere. The binary format remains version 2,
/// but every prepared GfxObj render record must be regenerated because
/// the sphere participates in portal-view admission. Version 8 (OH2/S1,
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md)
/// replaces CellStruct/EnvCell extraction with retail's exact
/// surface-array-index subset construction: side candidates come only
/// from sides_type (not NoPos/NoNeg), the subset/material owner is the
/// source surface-array index rather than TextureKey/stippling/sides,
/// and built-EnvCell admission is
/// (Surface.Type &amp; (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 applied after
/// surface resolution. Version 9 appends authored surface opacity to
/// every prepared mesh texture batch. The binary format remains version
/// 2, but every prepared render record must be regenerated.
/// </summary>
public const uint CurrentBakeToolVersion = 9;
}
/// <summary>
/// Fixed 64-byte pak file header. Layout (all integers little-endian):
/// <code>
/// offset size field
/// 0 4 magic 'ACPK' (0x4B504341)
/// 4 4 formatVersion = 2
/// 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
/// 40 24 reserved (zero)
/// </code>
/// Format 1 is specified by docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md.
/// Format 2 retains this header and adds the TOC compression flag plus global
/// texture payload entries documented in docs/plans/2026-08-27-pak-v2-resource-campaign.md.
/// </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, lengthAndFlags 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 const uint CompressionFlag = 0x8000_0000u;
public const uint StoredLengthMask = 0x7FFF_FFFFu;
public ulong Key;
public ulong Offset;
public uint Length;
public uint Crc32;
/// <summary>
/// Format-2 entries use the high bit of <see cref="Length"/> to mark an
/// independently Brotli-compressed blob. The remaining 31 bits are the
/// exact bytes stored in the file (including the compressed blob's
/// four-byte decoded-length prefix). Raw entries retain their historical
/// length representation and require no second copy on read.
/// </summary>
public readonly bool IsCompressed => (Length & CompressionFlag) != 0;
public readonly uint StoredLength => Length & StoredLengthMask;
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]),
};
}
}