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. + } + } + } +}