diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
index 32e502eb..70069e4a 100644
--- a/src/AcDream.App/RuntimeOptions.cs
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -1,5 +1,6 @@
using System;
using System.Globalization;
+using System.IO;
namespace AcDream.App;
@@ -26,6 +27,7 @@ namespace AcDream.App;
///
public sealed record RuntimeOptions(
string DatDir,
+ string PreparedAssetPath,
bool LiveMode,
string LiveHost,
int LivePort,
@@ -72,6 +74,8 @@ public sealed record RuntimeOptions(
return new RuntimeOptions(
DatDir: datDir,
+ PreparedAssetPath: NullIfEmpty(env("ACDREAM_PAK_PATH"))
+ ?? Path.Combine(datDir, "acdream.pak"),
LiveMode: IsExactlyOne(env("ACDREAM_LIVE")),
LiveHost: env("ACDREAM_TEST_HOST") ?? "127.0.0.1",
LivePort: TryParseInt(env("ACDREAM_TEST_PORT")) ?? 9000,
diff --git a/src/AcDream.Content/IPreparedAssetSource.cs b/src/AcDream.Content/IPreparedAssetSource.cs
new file mode 100644
index 00000000..cdc603b4
--- /dev/null
+++ b/src/AcDream.Content/IPreparedAssetSource.cs
@@ -0,0 +1,431 @@
+using System.Collections.Concurrent;
+using System.Threading;
+using AcDream.Content.Pak;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using Microsoft.Extensions.Logging;
+
+namespace AcDream.Content;
+
+public enum PreparedAssetPresence
+{
+ Missing,
+ Available,
+ Corrupt,
+}
+
+public enum PreparedAssetReadStatus
+{
+ Missing,
+ Loaded,
+ Corrupt,
+}
+
+///
+/// Schema retained only for the live-DAT tooling source. Pak aliases use the
+/// source Cell DID as their lookup identity and carry the already-prepared
+/// deduplicated geometry as their payload.
+///
+public sealed record PreparedEnvCellSchema(
+ uint EnvironmentId,
+ ushort CellStructure,
+ IReadOnlyList Surfaces);
+
+///
+/// One typed prepared-payload request. selects the
+/// persisted pak key; is the identity published
+/// to the renderer. They differ for deduplicated EnvCell geometry.
+///
+public readonly record struct PreparedAssetRequest(
+ PakAssetType Type,
+ uint SourceFileId,
+ ulong RuntimeObjectId,
+ PreparedEnvCellSchema? EnvCell = null)
+{
+ public static PreparedAssetRequest GfxObj(uint fileId) =>
+ new(PakAssetType.GfxObjMesh, fileId, fileId);
+
+ public static PreparedAssetRequest Setup(uint fileId) =>
+ new(PakAssetType.SetupMesh, fileId, fileId);
+
+ public static PreparedAssetRequest EnvCellGeometry(
+ uint sourceCellId,
+ ulong runtimeGeometryId,
+ uint environmentId,
+ ushort cellStructure,
+ IReadOnlyList surfaces) =>
+ new(
+ PakAssetType.EnvCellMesh,
+ sourceCellId,
+ runtimeGeometryId,
+ new PreparedEnvCellSchema(
+ environmentId,
+ cellStructure,
+ surfaces));
+}
+
+public readonly record struct PreparedAssetReadResult(
+ PreparedAssetReadStatus Status,
+ ObjectMeshData? Data)
+{
+ public static PreparedAssetReadResult Missing =>
+ new(PreparedAssetReadStatus.Missing, null);
+
+ public static PreparedAssetReadResult Corrupt =>
+ new(PreparedAssetReadStatus.Corrupt, null);
+
+ public static PreparedAssetReadResult Loaded(ObjectMeshData data) =>
+ new(PreparedAssetReadStatus.Loaded, data);
+}
+
+public readonly record struct PreparedAssetSourceStats(
+ long Probes,
+ long Reads,
+ long Loaded,
+ long Missing,
+ long Corrupt);
+
+public readonly record struct PreparedAssetCatalogIdentity(
+ uint PortalIteration,
+ uint CellIteration,
+ uint HighResIteration,
+ uint LanguageIteration,
+ uint BakeToolVersion)
+{
+ public static PreparedAssetCatalogIdentity From(IDatReaderWriter dats)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+ return new(
+ checked((uint)dats.PortalIteration),
+ checked((uint)dats.CellIteration),
+ checked((uint)dats.HighResIteration),
+ checked((uint)dats.LanguageIteration),
+ PakFormat.CurrentBakeToolVersion);
+ }
+}
+
+///
+/// Immutable CPU-side render payload source. Implementations perform no GL
+/// work and may be called concurrently by the renderer's decode workers.
+///
+public interface IPreparedAssetSource : IDisposable
+{
+ PreparedAssetPresence Probe(PakAssetType type, uint sourceFileId);
+
+ PreparedAssetReadResult Read(
+ in PreparedAssetRequest request,
+ CancellationToken cancellationToken = default);
+
+ PreparedAssetSourceStats Stats { get; }
+
+ CacheStats DecodedTextureCacheStats { get; }
+}
+
+internal static class PreparedAssetRequestContract
+{
+ public static void ValidateType(PakAssetType type)
+ {
+ if (!Enum.IsDefined(type))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(type),
+ type,
+ "unknown prepared asset type");
+ }
+ }
+
+ public static void Validate(in PreparedAssetRequest request)
+ {
+ ValidateType(request.Type);
+ if (request.Type == PakAssetType.EnvCellMesh
+ && request.EnvCell is null)
+ {
+ throw new ArgumentException(
+ "EnvCell prepared requests must retain their source schema.",
+ nameof(request));
+ }
+ }
+
+ public static bool Matches(
+ in PreparedAssetRequest request,
+ ObjectMeshData data)
+ {
+ bool expectedSetup = request.Type == PakAssetType.SetupMesh;
+ return data.ObjectId == request.RuntimeObjectId
+ && data.IsSetup == expectedSetup;
+ }
+}
+
+///
+/// Production prepared source backed by one immutable memory-mapped pak.
+/// There is intentionally no DAT fallback.
+///
+public sealed class PakPreparedAssetSource : IPreparedAssetSource
+{
+ private readonly PakReader _reader;
+ private readonly Action? _diagnosticSink;
+ private readonly ConcurrentDictionary _loggedIdentityFaults = new();
+ private long _probes;
+ private long _reads;
+ private long _loaded;
+ private long _missing;
+ private long _corrupt;
+
+ public PakPreparedAssetSource(
+ string path,
+ PreparedAssetCatalogIdentity expected,
+ Action? diagnosticSink = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ _diagnosticSink = diagnosticSink;
+ _reader = new PakReader(path);
+ try
+ {
+ ValidateHeader(path, _reader.Header, expected);
+ }
+ catch
+ {
+ _reader.Dispose();
+ throw;
+ }
+ }
+
+ public PakPreparedAssetSource(
+ string path,
+ IDatReaderWriter dats,
+ Action? diagnosticSink = null)
+ : this(
+ path,
+ PreparedAssetCatalogIdentity.From(dats),
+ diagnosticSink)
+ {
+ }
+
+ public PreparedAssetSourceStats Stats =>
+ new(
+ Volatile.Read(ref _probes),
+ Volatile.Read(ref _reads),
+ Volatile.Read(ref _loaded),
+ Volatile.Read(ref _missing),
+ Volatile.Read(ref _corrupt));
+
+ public CacheStats DecodedTextureCacheStats => default;
+
+ public PreparedAssetPresence Probe(
+ PakAssetType type,
+ uint sourceFileId)
+ {
+ PreparedAssetRequestContract.ValidateType(type);
+ Interlocked.Increment(ref _probes);
+ ulong key = PakKey.Compose(type, sourceFileId);
+ return _reader.ProbeEntry(key) switch
+ {
+ PakEntryState.Available => PreparedAssetPresence.Available,
+ PakEntryState.Corrupt => PreparedAssetPresence.Corrupt,
+ _ => PreparedAssetPresence.Missing,
+ };
+ }
+
+ public PreparedAssetReadResult Read(
+ in PreparedAssetRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ PreparedAssetRequestContract.Validate(request);
+ cancellationToken.ThrowIfCancellationRequested();
+ Interlocked.Increment(ref _reads);
+
+ ulong key = PakKey.Compose(request.Type, request.SourceFileId);
+ PakObjectReadStatus status =
+ _reader.ReadObjectMeshData(key, out ObjectMeshData? data);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (status == PakObjectReadStatus.Missing)
+ {
+ Interlocked.Increment(ref _missing);
+ return PreparedAssetReadResult.Missing;
+ }
+ if (status == PakObjectReadStatus.Corrupt || data is null)
+ {
+ Interlocked.Increment(ref _corrupt);
+ return PreparedAssetReadResult.Corrupt;
+ }
+
+ if (!PreparedAssetRequestContract.Matches(request, data))
+ {
+ Interlocked.Increment(ref _corrupt);
+ LogIdentityFaultOnce(
+ key,
+ $"prepared payload identity mismatch for key 0x{key:X16}: " +
+ $"expected runtime=0x{request.RuntimeObjectId:X16}, " +
+ $"type={request.Type}; payload runtime=0x{data.ObjectId:X16}, " +
+ $"isSetup={data.IsSetup}");
+ return PreparedAssetReadResult.Corrupt;
+ }
+
+ Interlocked.Increment(ref _loaded);
+ return PreparedAssetReadResult.Loaded(data);
+ }
+
+ private static void ValidateHeader(
+ string path,
+ PakHeader actual,
+ PreparedAssetCatalogIdentity expected)
+ {
+ var mismatches = new List();
+ if (actual.BakeToolVersion != expected.BakeToolVersion)
+ mismatches.Add(
+ $"bake tool {actual.BakeToolVersion} != {expected.BakeToolVersion}");
+ if (actual.PortalIteration != expected.PortalIteration)
+ mismatches.Add(
+ $"portal iteration {actual.PortalIteration} != {expected.PortalIteration}");
+ if (actual.CellIteration != expected.CellIteration)
+ mismatches.Add(
+ $"cell iteration {actual.CellIteration} != {expected.CellIteration}");
+ if (actual.HighResIteration != expected.HighResIteration)
+ mismatches.Add(
+ $"high-res iteration {actual.HighResIteration} != {expected.HighResIteration}");
+ if (actual.LanguageIteration != expected.LanguageIteration)
+ mismatches.Add(
+ $"language iteration {actual.LanguageIteration} != {expected.LanguageIteration}");
+
+ if (mismatches.Count != 0)
+ {
+ throw new InvalidDataException(
+ $"prepared asset package '{path}' does not match the installed DAT set: " +
+ string.Join("; ", mismatches) +
+ ". Re-bake acdream.pak with this client build and DAT install.");
+ }
+ }
+
+ private void LogIdentityFaultOnce(ulong key, string message)
+ {
+ if (_loggedIdentityFaults.TryAdd(key, 0))
+ (_diagnosticSink ?? Console.Error.WriteLine)(message);
+ }
+
+ public void Dispose() => _reader.Dispose();
+}
+
+///
+/// Explicit live-DAT adapter for bake/equivalence and UI Studio tooling.
+/// Production GameWindow composition never constructs this implementation.
+///
+public sealed class DatPreparedAssetSource : IPreparedAssetSource
+{
+ private readonly IDatReaderWriter _dats;
+ private readonly MeshExtractor _extractor;
+ private long _probes;
+ private long _reads;
+ private long _loaded;
+ private long _missing;
+
+ public DatPreparedAssetSource(
+ IDatReaderWriter dats,
+ ILogger logger,
+ Action? sideStagedSink = null)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+ ArgumentNullException.ThrowIfNull(logger);
+ _dats = dats;
+ _extractor = new MeshExtractor(dats, logger, sideStagedSink);
+ }
+
+ public PreparedAssetSourceStats Stats =>
+ new(
+ Volatile.Read(ref _probes),
+ Volatile.Read(ref _reads),
+ Volatile.Read(ref _loaded),
+ Volatile.Read(ref _missing),
+ 0);
+
+ public CacheStats DecodedTextureCacheStats =>
+ _extractor.DecodedTextureCacheStats;
+
+ public PreparedAssetPresence Probe(
+ PakAssetType type,
+ uint sourceFileId)
+ {
+ PreparedAssetRequestContract.ValidateType(type);
+ Interlocked.Increment(ref _probes);
+ DBObjType expected = type switch
+ {
+ PakAssetType.GfxObjMesh => DBObjType.GfxObj,
+ PakAssetType.SetupMesh => DBObjType.Setup,
+ PakAssetType.EnvCellMesh => DBObjType.EnvCell,
+ _ => DBObjType.Unknown,
+ };
+ if (expected == DBObjType.Unknown)
+ return PreparedAssetPresence.Missing;
+ return _dats.ResolveId(sourceFileId).Any(r => r.Type == expected)
+ ? PreparedAssetPresence.Available
+ : PreparedAssetPresence.Missing;
+ }
+
+ public PreparedAssetReadResult Read(
+ in PreparedAssetRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ PreparedAssetRequestContract.Validate(request);
+ cancellationToken.ThrowIfCancellationRequested();
+ Interlocked.Increment(ref _reads);
+
+ ObjectMeshData? data;
+ if (request.Type == PakAssetType.EnvCellMesh)
+ {
+ PreparedEnvCellSchema schema = request.EnvCell
+ ?? throw new ArgumentException(
+ "EnvCell prepared requests must retain their source schema.",
+ nameof(request));
+ uint environmentId = 0x0D00_0000u | schema.EnvironmentId;
+ if (!_dats.Portal.TryGet(
+ environmentId,
+ out DatReaderWriter.DBObjs.Environment? environment)
+ || environment is null
+ || !environment.Cells.TryGetValue(
+ schema.CellStructure,
+ out DatReaderWriter.Types.CellStruct? cellStruct)
+ || cellStruct is null)
+ {
+ Interlocked.Increment(ref _missing);
+ return PreparedAssetReadResult.Missing;
+ }
+
+ data = _extractor.PrepareCellStructMeshData(
+ request.RuntimeObjectId,
+ cellStruct,
+ schema.Surfaces,
+ System.Numerics.Matrix4x4.Identity,
+ cancellationToken);
+ }
+ else
+ {
+ data = _extractor.PrepareMeshData(
+ request.SourceFileId,
+ request.Type == PakAssetType.SetupMesh,
+ cancellationToken);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ if (data is null)
+ {
+ Interlocked.Increment(ref _missing);
+ return PreparedAssetReadResult.Missing;
+ }
+ if (!PreparedAssetRequestContract.Matches(request, data))
+ {
+ throw new InvalidDataException(
+ $"live-DAT prepared payload identity mismatch: expected " +
+ $"runtime=0x{request.RuntimeObjectId:X16}, type={request.Type}; " +
+ $"payload runtime=0x{data.ObjectId:X16}, isSetup={data.IsSetup}");
+ }
+
+ Interlocked.Increment(ref _loaded);
+ return PreparedAssetReadResult.Loaded(data);
+ }
+
+ public void Dispose()
+ {
+ // The caller owns the shared DAT collection. MeshExtractor owns only
+ // managed caches and thread-local decoders.
+ }
+}
diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs
index 95b8dbda..4bfe3cea 100644
--- a/src/AcDream.Content/MeshExtractor.cs
+++ b/src/AcDream.Content/MeshExtractor.cs
@@ -130,8 +130,7 @@ public sealed class MeshExtractor {
return null;
}
catch (OperationCanceledException) {
- // Ignore
- return null;
+ throw;
}
catch (Exception ex) {
_logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
diff --git a/src/AcDream.Content/Pak/PakReadStatus.cs b/src/AcDream.Content/Pak/PakReadStatus.cs
new file mode 100644
index 00000000..50d9c9c7
--- /dev/null
+++ b/src/AcDream.Content/Pak/PakReadStatus.cs
@@ -0,0 +1,24 @@
+namespace AcDream.Content.Pak;
+
+///
+/// TOC-only state for one exact typed pak key. means
+/// the immutable TOC row exists and has a structurally valid file range; CRC
+/// and payload structure are deliberately not faulted in by a probe.
+///
+public enum PakEntryState
+{
+ Missing,
+ Available,
+ Corrupt,
+}
+
+///
+/// Result of reading and deserializing one pak payload. This preserves the
+/// distinction between an absent key and a present-but-damaged external file.
+///
+public enum PakObjectReadStatus
+{
+ Missing,
+ Loaded,
+ Corrupt,
+}
diff --git a/src/AcDream.Content/Pak/PakReader.cs b/src/AcDream.Content/Pak/PakReader.cs
index c7b01aff..03accd0b 100644
--- a/src/AcDream.Content/Pak/PakReader.cs
+++ b/src/AcDream.Content/Pak/PakReader.cs
@@ -112,21 +112,45 @@ public sealed class PakReader : IDisposable {
return VerdictFor(index) == 1;
}
+ ///
+ /// Probes the immutable TOC only. No payload bytes are copied, CRC-checked,
+ /// or deserialized. A structurally invalid entry is reported as corrupt;
+ /// a CRC or payload-structure fault becomes visible after the first read.
+ ///
+ public PakEntryState ProbeEntry(ulong key) {
+ int index = BinarySearch(key);
+ if (index < 0)
+ return PakEntryState.Missing;
+ return _entryVerdictByTocIndex.TryGetValue(index, out int verdict)
+ && verdict == 0
+ ? PakEntryState.Corrupt
+ : PakEntryState.Available;
+ }
+
///
/// Reads and deserializes the stored under
/// . Returns false (data null) if the key is absent
/// OR the blob fails any tripwire (bounds, CRC, structural deserialization
/// failure) — logged once per entry, per the corrupt-=-missing contract.
///
- public bool TryReadObjectMeshData(ulong key, out ObjectMeshData? data) {
+ public bool TryReadObjectMeshData(ulong key, out ObjectMeshData? data) =>
+ ReadObjectMeshData(key, out data) == PakObjectReadStatus.Loaded;
+
+ ///
+ /// Reads one payload while preserving absent-versus-corrupt state for the
+ /// production prepared-asset boundary.
+ ///
+ public PakObjectReadStatus ReadObjectMeshData(
+ ulong key,
+ out ObjectMeshData? data) {
data = null;
int index = BinarySearch(key);
- if (index < 0) return false;
+ if (index < 0) return PakObjectReadStatus.Missing;
// Previously judged bad (bounds at open, or an earlier CRC/structure
// failure): missing, no re-read, no re-log.
bool judged = _entryVerdictByTocIndex.TryGetValue(index, out var verdict);
- if (judged && verdict == 0) return false;
+ if (judged && verdict == 0) return PakObjectReadStatus.Corrupt;
// Single pass (review finding 4): ONE copy out of the map; CRC and
// deserialization both run over this same buffer.
@@ -139,14 +163,14 @@ 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})");
- return false;
+ return PakObjectReadStatus.Corrupt;
}
_entryVerdictByTocIndex[index] = 1;
}
try {
data = ObjectMeshDataSerializer.Read(bytes);
- return true;
+ return PakObjectReadStatus.Loaded;
}
catch (Exception ex) {
// Structurally malformed blob behind a valid CRC (bake-side bug or
@@ -155,7 +179,7 @@ public sealed class PakReader : IDisposable {
data = null;
_entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, $"deserialization failed despite matching CRC: {ex.GetType().Name}: {ex.Message}");
- return false;
+ return PakObjectReadStatus.Corrupt;
}
}
diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
index 86898d81..83245e35 100644
--- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs
@@ -24,6 +24,9 @@ public sealed class RuntimeOptionsTests
var opts = RuntimeOptions.Parse(AnyDatDir, EmptyEnv());
Assert.Equal(AnyDatDir, opts.DatDir);
+ Assert.Equal(
+ Path.Combine(AnyDatDir, "acdream.pak"),
+ opts.PreparedAssetPath);
Assert.False(opts.LiveMode);
Assert.Equal("127.0.0.1", opts.LiveHost);
Assert.Equal(9000, opts.LivePort);
@@ -49,6 +52,19 @@ public sealed class RuntimeOptionsTests
Assert.False(opts.HasLiveCredentials);
}
+ [Fact]
+ public void PreparedAssetPath_DefaultsBesideDats_AndAllowsOneOverride()
+ {
+ Assert.Equal(
+ Path.Combine(AnyDatDir, "acdream.pak"),
+ RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).PreparedAssetPath);
+
+ var overridden = RuntimeOptions.Parse(
+ AnyDatDir,
+ Env(new() { ["ACDREAM_PAK_PATH"] = "D:/prepared/acdream.pak" }));
+ Assert.Equal("D:/prepared/acdream.pak", overridden.PreparedAssetPath);
+ }
+
[Fact]
public void LiveMode_Set_ExactlyByValue1()
{
diff --git a/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs
new file mode 100644
index 00000000..5cd7f242
--- /dev/null
+++ b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs
@@ -0,0 +1,244 @@
+using System.Numerics;
+using AcDream.Content.Pak;
+
+namespace AcDream.Content.Tests;
+
+public sealed class PreparedAssetSourceTests : IDisposable
+{
+ private static readonly PreparedAssetCatalogIdentity Identity =
+ new(10, 20, 30, 40, PakFormat.CurrentBakeToolVersion);
+
+ private readonly List _paths = [];
+
+ [Fact]
+ public void Probe_IsTypedAndDoesNotFaultPayloadBytes()
+ {
+ string path = WritePak(
+ (PakAssetType.GfxObjMesh, 0x0100_0001u, Mesh(0x0100_0001u)));
+
+ using var source = new PakPreparedAssetSource(path, Identity);
+ Assert.Equal(
+ PreparedAssetPresence.Available,
+ source.Probe(PakAssetType.GfxObjMesh, 0x0100_0001u));
+ Assert.Equal(
+ PreparedAssetPresence.Missing,
+ source.Probe(PakAssetType.SetupMesh, 0x0100_0001u));
+ Assert.Equal(2, source.Stats.Probes);
+ Assert.Equal(0, source.Stats.Reads);
+ }
+
+ [Fact]
+ public void Read_PreservesMissingLoadedAndCorruptStates()
+ {
+ const uint fileId = 0x0100_0001u;
+ string path = WritePak(
+ (PakAssetType.GfxObjMesh, fileId, Mesh(fileId)));
+
+ using (var source = new PakPreparedAssetSource(path, Identity))
+ {
+ PreparedAssetReadResult loaded =
+ source.Read(PreparedAssetRequest.GfxObj(fileId));
+ Assert.Equal(PreparedAssetReadStatus.Loaded, loaded.Status);
+ Assert.NotNull(loaded.Data);
+
+ PreparedAssetReadResult missing =
+ source.Read(PreparedAssetRequest.GfxObj(0x0100_FFFFu));
+ Assert.Equal(PreparedAssetReadStatus.Missing, missing.Status);
+ Assert.Null(missing.Data);
+ }
+
+ FlipFirstBlobByte(path);
+ using var corruptSource = new PakPreparedAssetSource(path, Identity);
+ Assert.Equal(
+ PreparedAssetPresence.Available,
+ corruptSource.Probe(PakAssetType.GfxObjMesh, fileId));
+ PreparedAssetReadResult corrupt =
+ corruptSource.Read(PreparedAssetRequest.GfxObj(fileId));
+ Assert.Equal(PreparedAssetReadStatus.Corrupt, corrupt.Status);
+ Assert.Null(corrupt.Data);
+ Assert.Equal(
+ PreparedAssetPresence.Corrupt,
+ corruptSource.Probe(PakAssetType.GfxObjMesh, fileId));
+ }
+
+ [Fact]
+ public void Read_EnvCellAliasUsesSourceKeyAndRuntimeGeometryIdentity()
+ {
+ const uint primaryCell = 0x1234_0101u;
+ const uint aliasCell = 0x1234_0102u;
+ const ulong geometryId = 0x2_0000_1234UL;
+ string path = NewPath();
+ using (var writer = new PakWriter(path, Header()))
+ {
+ writer.AddBlob(
+ PakKey.Compose(PakAssetType.EnvCellMesh, primaryCell),
+ Mesh(geometryId));
+ writer.AddAlias(
+ PakKey.Compose(PakAssetType.EnvCellMesh, aliasCell),
+ PakKey.Compose(PakAssetType.EnvCellMesh, primaryCell));
+ writer.Finish();
+ }
+
+ using var source = new PakPreparedAssetSource(path, Identity);
+ var request = PreparedAssetRequest.EnvCellGeometry(
+ aliasCell,
+ geometryId,
+ environmentId: 7,
+ cellStructure: 3,
+ surfaces: [1, 2, 3]);
+
+ PreparedAssetReadResult result = source.Read(request);
+ Assert.Equal(PreparedAssetReadStatus.Loaded, result.Status);
+ Assert.Equal(geometryId, result.Data!.ObjectId);
+ }
+
+ [Fact]
+ public void Read_RejectsPayloadIdentityOrTypeMismatchAsCorruption()
+ {
+ const uint setupId = 0x0200_0001u;
+ string path = WritePak(
+ (PakAssetType.SetupMesh, setupId, Mesh(setupId)));
+ var diagnostics = new List();
+
+ using var source = new PakPreparedAssetSource(
+ path,
+ Identity,
+ diagnostics.Add);
+ PreparedAssetReadResult result =
+ source.Read(PreparedAssetRequest.Setup(setupId));
+
+ Assert.Equal(PreparedAssetReadStatus.Corrupt, result.Status);
+ Assert.Single(diagnostics);
+ Assert.Contains("identity mismatch", diagnostics[0]);
+ }
+
+ [Fact]
+ public void Constructor_RejectsEveryCatalogIdentityMismatch()
+ {
+ string path = WritePak(
+ (PakAssetType.GfxObjMesh, 1u, Mesh(1u)));
+ var mismatches = new[]
+ {
+ Identity with { PortalIteration = 11 },
+ Identity with { CellIteration = 21 },
+ Identity with { HighResIteration = 31 },
+ Identity with { LanguageIteration = 41 },
+ Identity with { BakeToolVersion = Identity.BakeToolVersion + 1 },
+ };
+
+ foreach (PreparedAssetCatalogIdentity expected in mismatches)
+ {
+ InvalidDataException error = Assert.Throws(
+ () => new PakPreparedAssetSource(path, expected));
+ Assert.Contains("does not match the installed DAT set", error.Message);
+ Assert.Contains("Re-bake", error.Message);
+ }
+ }
+
+ [Fact]
+ public void Read_PreCanceledTokenPropagatesWithoutStartingRead()
+ {
+ const uint fileId = 0x0100_0001u;
+ string path = WritePak(
+ (PakAssetType.GfxObjMesh, fileId, Mesh(fileId)));
+ using var source = new PakPreparedAssetSource(path, Identity);
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ Assert.Throws(
+ () => source.Read(
+ PreparedAssetRequest.GfxObj(fileId),
+ cancellation.Token));
+ Assert.Equal(0, source.Stats.Reads);
+ }
+
+ [Fact]
+ public void Dispose_ReleasesMappedPackage()
+ {
+ string path = WritePak(
+ (PakAssetType.GfxObjMesh, 1u, Mesh(1u)));
+ var source = new PakPreparedAssetSource(path, Identity);
+ source.Dispose();
+
+ using var exclusive = new FileStream(
+ path,
+ FileMode.Open,
+ FileAccess.ReadWrite,
+ FileShare.None);
+ Assert.True(exclusive.CanWrite);
+ }
+
+ private string WritePak(
+ params (PakAssetType Type, uint FileId, ObjectMeshData Data)[] entries)
+ {
+ string path = NewPath();
+ using var writer = new PakWriter(path, Header());
+ foreach ((PakAssetType type, uint fileId, ObjectMeshData data) in entries)
+ {
+ writer.AddBlob(PakKey.Compose(type, fileId), data);
+ }
+ writer.Finish();
+ return path;
+ }
+
+ private string NewPath()
+ {
+ string path = Path.Combine(
+ Path.GetTempPath(),
+ $"acdream-prepared-{Guid.NewGuid():N}.pak");
+ _paths.Add(path);
+ return path;
+ }
+
+ private static PakHeader Header() =>
+ new()
+ {
+ PortalIteration = Identity.PortalIteration,
+ CellIteration = Identity.CellIteration,
+ HighResIteration = Identity.HighResIteration,
+ LanguageIteration = Identity.LanguageIteration,
+ BakeToolVersion = Identity.BakeToolVersion,
+ };
+
+ private static ObjectMeshData Mesh(ulong objectId) =>
+ new()
+ {
+ ObjectId = objectId,
+ Vertices =
+ [
+ new(
+ new Vector3(1, 2, 3),
+ Vector3.UnitZ,
+ new Vector2(0.25f, 0.75f)),
+ ],
+ };
+
+ private static void FlipFirstBlobByte(string path)
+ {
+ using var stream = new FileStream(
+ path,
+ FileMode.Open,
+ FileAccess.ReadWrite,
+ FileShare.None);
+ stream.Position = PakHeader.Size + 4;
+ int value = stream.ReadByte();
+ Assert.NotEqual(-1, value);
+ stream.Position = PakHeader.Size + 4;
+ stream.WriteByte((byte)(value ^ 0xFF));
+ }
+
+ public void Dispose()
+ {
+ foreach (string path in _paths)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch
+ {
+ // Best-effort test cleanup.
+ }
+ }
+ }
+}