diff --git a/docs/plans/2026-07-24-modern-runtime-slice-c-prepared-assets.md b/docs/plans/2026-07-24-modern-runtime-slice-c-prepared-assets.md index 18c69102..1a038aa2 100644 --- a/docs/plans/2026-07-24-modern-runtime-slice-c-prepared-assets.md +++ b/docs/plans/2026-07-24-modern-runtime-slice-c-prepared-assets.md @@ -2,7 +2,14 @@ **Date:** 2026-07-24 -**Status:** active +**Status:** active — C1 and C2 landed; C3 next + +**Checkpoint ledger:** + +- C1 typed prepared-source boundary — `c42f93b3` +- C2 production renderer injection — implementation complete and gated +- C3 activation probes and lookup precedence — next +- C4 connected equivalence and performance closeout — pending **Parent:** [`2026-07-24-modern-runtime-architecture.md`](2026-07-24-modern-runtime-architecture.md), Slice C / MP1c @@ -198,6 +205,9 @@ Implement: - `WorldRenderComposition` and `WbMeshAdapter` injection; - `ObjectMeshManager` worker reads through `IPreparedAssetSource`; - source Cell DID propagation for EnvCell aliases; +- exact `TranslucencyKind` persisted per prepared texture batch, removing the + render-thread `GfxObjMesh.Build` metadata reconstruction (bake-tool identity + advances to version 3; the pak container remains format version 1); - exact terminal-failure and cancellation semantics; - current CPU cache, staging, upload, retry, ownership, and shutdown behavior retained; diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index 85fb9e38..429c5ef7 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -25,6 +25,7 @@ internal sealed record ContentAudioGraph( internal sealed record ContentEffectsAudioResult( IDatReaderWriter Dats, + IPreparedAssetSource PreparedAssets, MagicCatalog MagicCatalog, IAnimationLoader AnimationLoader, LiveEntityCollisionBuilder CollisionBuilder, @@ -41,6 +42,7 @@ internal sealed record ContentEffectsAudioResult( internal sealed record ContentEffectsAudioDependencies( string DatDirectory, + string PreparedAssetPath, PhysicsDataCache PhysicsDataCache, bool DumpMotionEnabled, Spellbook SpellBook, @@ -56,6 +58,7 @@ internal sealed record ContentEffectsAudioDependencies( internal interface IGameWindowContentEffectsAudioPublication { void PublishDatCollection(IDatReaderWriter value); + void PublishPreparedAssetSource(IPreparedAssetSource value); void PublishMagicCatalog(MagicCatalog value); void PublishAnimationLoader(IAnimationLoader value); void PublishLiveEntityCollisionBuilder(LiveEntityCollisionBuilder value); @@ -74,6 +77,10 @@ internal interface IGameWindowContentEffectsAudioPublication internal interface IContentEffectsAudioCompositionFactory { IDatReaderWriter OpenDatCollection(string datDirectory); + IPreparedAssetSource OpenPreparedAssetSource( + string path, + IDatReaderWriter dats, + Action diagnostic); MagicCatalog LoadMagicCatalog(IDatReaderWriter dats); void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog); int GetSpellCount(MagicCatalog catalog); @@ -116,6 +123,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory public IDatReaderWriter OpenDatCollection(string datDirectory) => RuntimeDatCollectionFactory.OpenReadOnly(datDirectory); + public IPreparedAssetSource OpenPreparedAssetSource( + string path, + IDatReaderWriter dats, + Action diagnostic) => + new PakPreparedAssetSource(path, dats, diagnostic); + public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => MagicCatalog.Load(dats); @@ -196,6 +209,7 @@ internal sealed class RetailContentEffectsAudioCompositionFactory internal enum ContentEffectsAudioCompositionPoint { DatCollectionPublished, + PreparedAssetSourcePublished, MagicCatalogPublished, SpellMetadataInstalled, AnimationLoaderPublished, @@ -268,6 +282,18 @@ internal sealed class ContentEffectsAudioCompositionPhase : _publication.PublishDatCollection); Fault(ContentEffectsAudioCompositionPoint.DatCollectionPublished); + IPreparedAssetSource preparedAssets = mainScope.Acquire( + "prepared asset source", + () => _factory.OpenPreparedAssetSource( + _dependencies.PreparedAssetPath, + dats, + _dependencies.Error), + static value => value.Dispose()).Publish( + _publication.PublishPreparedAssetSource); + Fault(ContentEffectsAudioCompositionPoint.PreparedAssetSourcePublished); + _dependencies.Log( + $"prepared assets: opened '{_dependencies.PreparedAssetPath}'"); + MagicCatalog magic = _factory.LoadMagicCatalog(dats); _publication.PublishMagicCatalog(magic); Fault(ContentEffectsAudioCompositionPoint.MagicCatalogPublished); @@ -354,6 +380,7 @@ internal sealed class ContentEffectsAudioCompositionPhase : mainScope.Complete(); return new ContentEffectsAudioResult( dats, + preparedAssets, magic, animations, collision, diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index 346360a5..9000e9ba 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -100,6 +100,7 @@ internal interface IWorldRenderCompositionFactory WbMeshAdapter CreateMeshAdapter( GL gl, IDatReaderWriter dats, + IPreparedAssetSource preparedAssets, IGpuResourceRetirementQueue retirement); TextureCache CreateTextureCache( GL gl, @@ -261,10 +262,12 @@ internal sealed class RetailWorldRenderCompositionFactory public WbMeshAdapter CreateMeshAdapter( GL gl, IDatReaderWriter dats, + IPreparedAssetSource preparedAssets, IGpuResourceRetirementQueue retirement) => new( gl, dats, + preparedAssets, NullLogger.Instance, retirement); @@ -430,6 +433,7 @@ internal sealed class WorldRenderCompositionPhase () => _factory.CreateMeshAdapter( gl, content.Dats, + content.PreparedAssets, _dependencies.ResourceRetirement), _publication.PublishWbMeshAdapter, WorldRenderCompositionPoint.MeshAdapterPublished); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8c598e60..8a8bba8a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -46,6 +46,7 @@ public sealed class GameWindow : private Shader? _terrainModernShader; private CameraController? _cameraController; private IDatReaderWriter? _dats; + private IPreparedAssetSource? _preparedAssets; private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new(); private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput; private Shader? _meshShader; @@ -711,6 +712,13 @@ public sealed class GameWindow : IDatReaderWriter value) => PublishCompositionOwner(ref _dats, value, "DAT collection"); + void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource( + IPreparedAssetSource value) => + PublishCompositionOwner( + ref _preparedAssets, + value, + "prepared asset source"); + void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog( AcDream.App.Spells.MagicCatalog value) => PublishCompositionOwner(ref _magicCatalog, value, "magic catalog"); @@ -1132,6 +1140,7 @@ public sealed class GameWindow : new ContentEffectsAudioCompositionPhase( new ContentEffectsAudioDependencies( _datDir, + _options.PreparedAssetPath, _physicsDataCache, _animationDiagnostics.DumpMotionEnabled, SpellBook, @@ -1612,6 +1621,7 @@ public sealed class GameWindow : _glConstructionCleanup), new PlatformShutdownRoots( _dats, + _preparedAssets, _input, _gl)); private void OnFocusChanged(bool focused) diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index a39fc60a..2ebecb3c 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -120,6 +120,7 @@ internal sealed record RenderShutdownRoots( internal sealed record PlatformShutdownRoots( IDatReaderWriter? Dats, + IPreparedAssetSource? PreparedAssets, IInputContext? Input, GL? Gl); @@ -460,6 +461,7 @@ internal static class GameWindowShutdownManifest ]), new ResourceShutdownStage("content mappings", [ + Hard("prepared asset source", () => platform.PreparedAssets?.Dispose()), Hard("DAT collection", () => platform.Dats?.Dispose()), ]), new ResourceShutdownStage("input context", diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs index 6c5c1d27..613b3e74 100644 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ b/src/AcDream.App/Rendering/RenderBootstrap.cs @@ -171,7 +171,11 @@ public static class RenderBootstrap // --- WbMeshAdapter (GameWindow ~2286-2287) --- var wbLogger = NullLogger.Instance; - var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger, frameFlights); + var meshAdapter = Wb.WbMeshAdapter.CreateWithLiveDatPreparedAssets( + gl, + dats, + wbLogger, + frameFlights); // --- SequencerFactory (GameWindow ~2306-2334) --- var capturedDats = dats; diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs deleted file mode 100644 index 4e6e325c..00000000 --- a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AcDream.Core.Meshing; - -namespace AcDream.App.Rendering.Wb; - -/// -/// AC-specific surface render metadata that WB's MeshBatchData -/// doesn't carry. Computed at mesh-extraction time and looked up by the -/// draw dispatcher to drive translucency / sky-pass / fog behavior. -/// -/// -/// All fields mirror those on today's so -/// behavior is preserved bit-for-bit through the migration. -/// -/// -public sealed record AcSurfaceMetadata( - TranslucencyKind Translucency, - float Luminosity, - float Diffuse, - float SurfOpacity, - bool NeedsUvRepeat, - bool DisableFog); diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs deleted file mode 100644 index f741dace..00000000 --- a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Concurrent; - -namespace AcDream.App.Rendering.Wb; - -/// -/// Thread-safe side-table mapping (gfxObjId, surfaceIdx) to -/// . Populated when a GfxObj's mesh data -/// is extracted; queried at draw time. -/// -/// -/// Keyed by (gfxObjId, surfaceIdx) not by WB's runtime batch -/// identity because batch objects can be evicted and re-loaded by WB's -/// LRU; the (gfxObj, surface) pair is stable across cycles. -/// -/// -public sealed class AcSurfaceMetadataTable -{ - private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new(); - - public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta) - => _table[(gfxObjId, surfaceIdx)] = meta; - - public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta) - => _table.TryGetValue((gfxObjId, surfaceIdx), out meta!); - - public void Remove(ulong gfxObjId) - { - foreach ((ulong Id, int SurfaceIndex) key in _table.Keys) - { - if (key.Id == gfxObjId) - _table.TryRemove(key, out _); - } - } - - public void Clear() => _table.Clear(); -} diff --git a/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs index 0bc14a6c..725c0137 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs @@ -20,6 +20,7 @@ public static class EnvCellMeshPreparationScheduler continue; _ = meshManager.PrepareEnvCellGeomMeshDataAsync( shell.GeometryId, + shell.CellId, shell.EnvironmentId, shell.CellStructure, new List(shell.Surfaces)); diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 93527b81..aec922b1 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -84,6 +84,7 @@ namespace AcDream.App.Rendering.Wb public uint SurfaceId { get; set; } public TextureKey Key { get; set; } public DatReaderWriter.Enums.CullMode CullMode { get; set; } + public AcDream.Core.Meshing.TranslucencyKind Translucency { get; set; } public bool IsTransparent { get; set; } public bool IsAdditive { get; set; } public bool HasWrappingUVs { get; set; } @@ -102,18 +103,15 @@ namespace AcDream.App.Rendering.Wb public class ObjectMeshManager : IDisposable { private readonly OpenGLGraphicsDevice _graphicsDevice; - private readonly IDatReaderWriter _dats; + private readonly IPreparedAssetSource _preparedAssets; private readonly ILogger _logger; /// - /// MP1a (2026-07-05): the GL-free CPU extraction half, verbatim-moved to - /// AcDream.Content so the MP1b bake tool can run it without a GL context. - /// Owns the dat read → mesh build → inline texture decode pipeline; this - /// class keeps the queue/worker lifecycle and all GL upload. + /// The immutable prepared-payload source is injected by composition. + /// Production uses the validated pak; UI Studio explicitly supplies the + /// live-DAT tooling adapter. This class owns queue/worker lifecycle, + /// bounded CPU staging, and all GL upload. /// - private readonly MeshExtractor _extractor; - - internal IDatReaderWriter Dats => _dats; public bool IsDisposed { get; private set; } private readonly object _disposeGate = new(); @@ -141,7 +139,6 @@ namespace AcDream.App.Rendering.Wb private readonly Dictionary _uploadRollbacks = new(); private readonly Queue _uploadRollbackQueue = new(); private readonly MeshOwnershipCounter _ownership = new(); - private readonly ConcurrentDictionary _boundsCache = new(); private readonly ConcurrentDictionary> _preparationTasks = new(); // LRU Cache for Unused objects @@ -280,12 +277,11 @@ namespace AcDream.App.Rendering.Wb internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats; /// Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review). - internal CacheStats DecodedTextureCacheStats => _extractor.DecodedTextureCacheStats; + internal CacheStats DecodedTextureCacheStats => + _preparedAssets.DecodedTextureCacheStats; private sealed class PreparationRequest( - ulong id, - bool isSetup, - EnvCellGeomRequest? envCell, + PreparedAssetRequest asset, ObjectMeshData? cachedData, TaskCompletionSource completion, CancellationTokenSource cancellation) @@ -296,9 +292,8 @@ namespace AcDream.App.Rendering.Wb private bool _disposeRequested; private bool _cancellationDisposed; - public ulong Id { get; } = id; - public bool IsSetup { get; } = isSetup; - public EnvCellGeomRequest? EnvCell { get; } = envCell; + public PreparedAssetRequest Asset { get; } = asset; + public ulong Id => Asset.RuntimeObjectId; public ObjectMeshData? CachedData { get; } = cachedData; public TaskCompletionSource Completion { get; } = completion; public CancellationTokenSource Cancellation { get; } = cancellation; @@ -402,26 +397,18 @@ namespace AcDream.App.Rendering.Wb public bool IsQueued { get; set; } } - public ObjectMeshManager(OpenGLGraphicsDevice graphicsDevice, IDatReaderWriter dats, ILogger logger) + public ObjectMeshManager( + OpenGLGraphicsDevice graphicsDevice, + IPreparedAssetSource preparedAssets, + ILogger logger) { - _graphicsDevice = graphicsDevice; - _dats = dats; - _logger = logger; - // Side-stage sink: particle-preload meshes staged mid-extraction go - // straight onto the staged-upload queue, exactly as the pre-MP1a code - // did — immediate enqueue, surviving a later throw in the same - // Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the - // extractor's up-to-4 concurrent decode workers. + _graphicsDevice = graphicsDevice + ?? throw new ArgumentNullException(nameof(graphicsDevice)); + _preparedAssets = preparedAssets + ?? throw new ArgumentNullException(nameof(preparedAssets)); + _logger = logger + ?? throw new ArgumentNullException(nameof(logger)); _cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize); - _extractor = new MeshExtractor(_dats, _logger, data => - { - // Side-staged particle/setup dependencies obey the same bounded - // producer policy as primary results. Keeping them in the CPU - // cache makes a later explicit owner able to re-arm upload. - _cpuMeshCache.Store(data); - if (_ownership.IsOwned(data.ObjectId)) - _stagedMeshData.Stage(data); - }); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; if (_useModernRendering) { @@ -809,6 +796,7 @@ namespace AcDream.App.Rendering.Wb public struct EnvCellGeomRequest { + public uint SourceCellId; public uint EnvironmentId; public ushort CellStructure; public List Surfaces; @@ -817,12 +805,19 @@ namespace AcDream.App.Rendering.Wb /// /// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry. /// - public Task PrepareEnvCellGeomMeshDataAsync(ulong geomId, uint environmentId, ushort cellStructure, List surfaces, CancellationToken ct = default) + public Task PrepareEnvCellGeomMeshDataAsync( + ulong geomId, + uint sourceCellId, + uint environmentId, + ushort cellStructure, + List surfaces, + CancellationToken ct = default) { if (IsDisposed || HasRenderData(geomId)) return Task.FromResult(null); var envCell = new EnvCellGeomRequest { + SourceCellId = sourceCellId, EnvironmentId = environmentId, CellStructure = cellStructure, Surfaces = surfaces @@ -874,9 +869,12 @@ namespace AcDream.App.Rendering.Wb var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); _preparationTasks[geomId] = task; var request = new PreparationRequest( - geomId, - false, - envCell, + PreparedAssetRequest.EnvCellGeometry( + sourceCellId, + geomId, + environmentId, + cellStructure, + surfaces), deferredCachedData, tcs, cancellation); @@ -933,10 +931,18 @@ namespace AcDream.App.Rendering.Wb EnvCellGeomRequest? envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor) ? descriptor : null; + PreparedAssetRequest asset = envCell is { } env + ? PreparedAssetRequest.EnvCellGeometry( + env.SourceCellId, + id, + env.EnvironmentId, + env.CellStructure, + env.Surfaces) + : isSetup + ? PreparedAssetRequest.Setup(checked((uint)id)) + : PreparedAssetRequest.GfxObj(checked((uint)id)); var request = new PreparationRequest( - id, - isSetup, - envCell, + asset, deferredCachedData, tcs, cancellation); @@ -956,7 +962,7 @@ namespace AcDream.App.Rendering.Wb lock (_pendingRequests) { // IsDisposed re-check lets Dispose cancel and join every - // tracked worker before the DAT mappings are released. + // tracked worker before prepared content mappings are released. // Exit WITHOUT resetting the shared manual-reset event: // Dispose sets it once to wake all four persistent workers. // If the first worker reset it, the remaining three slept @@ -991,7 +997,6 @@ namespace AcDream.App.Rendering.Wb } ulong id = request.Id; - bool isSetup = request.IsSetup; TaskCompletionSource tcs = request.Completion; CancellationToken ct = request.Cancellation.Token; @@ -1010,38 +1015,21 @@ namespace AcDream.App.Rendering.Wb { ObjectMeshData? data = request.CachedData; - if (data is null && request.EnvCell is { } req) + if (data is null) { - uint envId = 0x0D000000u | req.EnvironmentId; - if (_dats.Portal.TryGet(envId, out var environment)) + PreparedAssetReadResult read = + _preparedAssets.Read(request.Asset, ct); + data = read.Data; + if (read.Status != PreparedAssetReadStatus.Loaded) { - if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) - { - data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, ct); - // TEMP diagnostic #105 (strip with fix): a null prep here means - // this deduplicated cell geometry will NEVER render anywhere. - if (data == null) - Console.WriteLine($"[geom-null] prepare-null geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}"); - } - else - { - Console.WriteLine($"[geom-null] cellstruct-missing geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}"); - } + _logger.LogError( + "Prepared mesh {Status} for {Type} source " + + "0x{SourceId:X8}, runtime 0x{RuntimeId:X10}", + read.Status, + request.Asset.Type, + request.Asset.SourceFileId, + request.Asset.RuntimeObjectId); } - else - { - Console.WriteLine($"[geom-null] env-read-failed geom=0x{id:X10} env=0x{envId:X8}"); - } - } - else if (data is null) - { - // TEMP diagnostic #105 (strip with fix): an EnvCell geom id (bit 33) - // whose pending request vanished gets misrouted to the generic path, - // where its hash-derived low bits resolve to nothing -> silent null. - if ((id & 0x2_0000_0000UL) != 0) - Console.WriteLine($"[geom-misroute] envcell geom 0x{id:X10} had no pending request — generic path will null it"); - // If it's a direct setup or gfxobj, make sure background loads don't abort half-way - data = _extractor.PrepareMeshData(id, isSetup, ct); } if (ct.IsCancellationRequested) { @@ -1168,7 +1156,7 @@ namespace AcDream.App.Rendering.Wb /// Readiness barrier used by the streaming publisher. Missing owned /// data is re-armed through its retained schema, so synthetic EnvCell /// IDs can never fall into generic GfxObj decoding after cancellation. - /// A deterministic DAT failure is retained until the next explicit + /// A deterministic prepared-payload failure is retained until the next explicit /// ownership schedule instead of being retried every render frame. /// internal bool EnsureRenderDataReady(ulong id) @@ -1194,6 +1182,7 @@ namespace AcDream.App.Rendering.Wb { _ = PrepareEnvCellGeomMeshDataAsync( id, + req.SourceCellId, req.EnvironmentId, req.CellStructure, req.Surfaces); @@ -1206,16 +1195,16 @@ namespace AcDream.App.Rendering.Wb } /// - /// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT. - /// This loads vertices, indices, and texture data but creates NO GPU resources. - /// Thread-safe: only reads from DAT files. - /// - /// MP1a (2026-07-05): delegates to , - /// the verbatim-moved GL-free extraction dispatcher. See . + /// Synchronously reads an immutable prepared payload without creating + /// GPU resources. The production source is the validated pak; explicit + /// tooling adapters may use live DAT extraction. /// public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) { - return _extractor.PrepareMeshData(id, isSetup, ct); + PreparedAssetRequest request = isSetup + ? PreparedAssetRequest.Setup(checked((uint)id)) + : PreparedAssetRequest.GfxObj(checked((uint)id)); + return _preparedAssets.Read(request, ct).Data; } /// @@ -1709,86 +1698,6 @@ namespace AcDream.App.Rendering.Wb } } - /// - /// Gets bounding box for an object (for frustum culling). - /// - public (Vector3 Min, Vector3 Max)? GetBounds(ulong id, bool isSetup) - { - if (_boundsCache.TryGetValue(id, out var cachedBounds)) - { - return cachedBounds; - } - - try - { - (Vector3 Min, Vector3 Max)? result = null; - uint datId = (uint)(id & 0xFFFFFFFFu); - var resolutions = _dats.ResolveId(datId).ToList(); - var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); - if (selectedResolution == null) return null; - - var type = selectedResolution.Type; - var db = selectedResolution.Database; - - if (type == DBObjType.Setup) - { - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - bool hasBounds = false; - var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - - _extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); - result = hasBounds ? (min, max) : null; - } - else if (type == DBObjType.EnvCell) - { - if (!db.TryGet(datId, out var envCell)) return null; - - // If bit 32 is set, this is a request for the cell's synthetic geometry only - if ((id & 0x1_0000_0000UL) != 0) - { - uint envId = 0x0D000000u | envCell.EnvironmentId; - if (_dats.Portal.TryGet(envId, out var environment)) - { - if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) - { - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - foreach (var vert in cellStruct.VertexArray.Vertices.Values) - { - min = Vector3.Min(min, vert.Origin); - max = Vector3.Max(max, vert.Origin); - } - result = (min, max); - } - } - } - else - { - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - bool hasBounds = false; - var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - - _extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); - result = hasBounds ? (min, max) : null; - } - } - else - { - if (!db.TryGet(datId, out var gfxObj)) return null; - result = _extractor.ComputeBounds(gfxObj, Vector3.One); - } - _boundsCache[id] = result; - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error computing bounds for 0x{Id:X8}", id); - return null; - } - } - #region Private: Background Preparation /// @@ -1981,6 +1890,7 @@ namespace AcDream.App.Rendering.Wb TextureIndex = textureIndex, TextureSize = (format.Width, format.Height), TextureFormat = format.Format, + Translucency = batch.Translucency, IsTransparent = batch.IsTransparent, IsAdditive = batch.IsAdditive, HasWrappingUVs = batch.HasWrappingUVs, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index f46d47fe..f35597d9 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -16,8 +16,8 @@ namespace AcDream.App.Rendering.Wb; /// /// Draws entities using WB's (a single global /// VAO/VBO/IBO under modern rendering) with acdream's -/// for bindless texture resolution and for -/// translucency classification. +/// for bindless texture resolution. Exact pass classification travels with +/// each immutable prepared mesh batch. /// /// /// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's @@ -1359,7 +1359,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable foreach (InstanceGroup group in _groups.Values) group.ClearPerInstanceData(); - var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; // Project the 5-tuple enumerable into LandblockEntry records for WalkEntities. @@ -1787,7 +1786,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0 } - if (!ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose, opacityMultiplier, collector)) + if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector)) currentEntityIncomplete = true; _selectionSink?.AddVisiblePart( entity, @@ -1817,7 +1816,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (!fullyInvisible) { var model = meshRef.PartTransform * entityWorld; - if (!ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector)) + if (!ClassifyBatches(renderData, model, entity, meshRef, paletteIdentity, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector)) currentEntityIncomplete = true; _selectionSink?.AddVisiblePart( entity, @@ -3239,12 +3238,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private bool ClassifyBatches( ObjectRenderData renderData, - ulong gfxObjId, Matrix4x4 model, WorldEntity entity, MeshRef meshRef, PaletteCompositeIdentity paletteIdentity, - AcSurfaceMetadataTable metaTable, Matrix4x4 restPose, float opacityMultiplier = 1.0f, List? collector = null) @@ -3254,17 +3251,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { var batch = renderData.Batches[batchIdx]; - TranslucencyKind translucency; - if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta)) - { - translucency = meta.Translucency; - } - else - { - translucency = batch.IsAdditive ? TranslucencyKind.Additive - : batch.IsTransparent ? TranslucencyKind.AlphaBlend - : TranslucencyKind.Opaque; - } + TranslucencyKind translucency = batch.Translucency; // #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap // must route through the alpha-blend pass so mesh_modern.frag's diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index ee3ddf10..36adc599 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; using AcDream.Content; -using AcDream.Core.Meshing; using AcDream.Core.Rendering; using DatReaderWriter; -using DatReaderWriter.DBObjs; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Silk.NET.OpenGL; @@ -48,9 +46,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter private readonly OpenGLGraphicsDevice? _graphicsDevice; private readonly ObjectMeshManager? _meshManager; private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement; - private readonly IDatReaderWriter? _dats; - private readonly AcSurfaceMetadataTable _metadataTable = new(); - private readonly HashSet _metadataPopulated = new(); + private readonly IPreparedAssetSource? _ownedPreparedAssets; private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits( MaximumUploadsPerFrame, MaximumUploadBytesPerFrame, @@ -95,35 +91,72 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _meshManager?.CpuCacheDiagnostics ?? default; /// - /// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded - /// DAT facade → ObjectMeshManager. + /// Constructs the UI-Studio/tooling WB pipeline. Production composition + /// supplies the validated pak source through the internal overload below; + /// this explicit tooling seam retains live-DAT extraction. /// /// Active Silk.NET GL context. Must be bound to the current /// thread (construction runs GL queries; call from OnLoad). - /// acdream's shared runtime DAT facade, used to populate the surface - /// metadata side-table via GfxObjMesh.Build. Shares file handles with - /// the rest of the client; read-only access from the render thread. + /// acdream's shared runtime DAT facade. Tooling uses it + /// through an explicitly owned . /// Logger for the adapter; ObjectMeshManager uses /// NullLogger internally. public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger logger) - : this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance) + : this( + gl, + dats, + preparedAssets: null, + logger, + AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, + ownsPreparedAssets: true) { } + internal static WbMeshAdapter CreateWithLiveDatPreparedAssets( + GL gl, + IDatReaderWriter dats, + ILogger logger, + AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) => + new( + gl, + dats, + preparedAssets: null, + logger, + resourceRetirement, + ownsPreparedAssets: true); + internal WbMeshAdapter( GL gl, IDatReaderWriter dats, + IPreparedAssetSource preparedAssets, ILogger logger, AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) + : this( + gl, + dats, + preparedAssets, + logger, + resourceRetirement, + ownsPreparedAssets: false) + { + } + + private WbMeshAdapter( + GL gl, + IDatReaderWriter dats, + IPreparedAssetSource? preparedAssets, + ILogger logger, + AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement, + bool ownsPreparedAssets) { ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(logger); - _dats = dats; _resourceRetirement = resourceRetirement; var resources = new AcDream.App.Rendering.ResourceCleanupGroup(); OpenGLGraphicsDevice? graphicsDevice = null; + IPreparedAssetSource? resolvedPreparedAssets = preparedAssets; ObjectMeshManager? meshManager = null; try { @@ -145,11 +178,20 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter } }); resources.Add("WB graphics device", graphicsDeviceRelease.Run); + if (resolvedPreparedAssets is null) + { + resolvedPreparedAssets = new DatPreparedAssetSource( + dats, + new ConsoleErrorLogger()); + resources.Add( + "WB tooling prepared asset source", + resolvedPreparedAssets.Dispose); + } // ConsoleErrorLogger surfaces WB's silently-caught exceptions // (ObjectMeshManager.PrepareMeshData try/catch at line ~589). meshManager = new ObjectMeshManager( graphicsDevice, - dats, + resolvedPreparedAssets, new ConsoleErrorLogger()); resources.Add("WB object mesh manager", meshManager.Dispose); resources.TransferAll(); @@ -163,6 +205,9 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _graphicsDevice = graphicsDevice; _meshManager = meshManager; + _ownedPreparedAssets = ownsPreparedAssets + ? resolvedPreparedAssets + : null; } /// @@ -213,13 +258,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter /// underlying mesh manager. Public methods are all no-ops. public static WbMeshAdapter CreateUninitialized() => new(); - /// - /// The surface metadata side-table populated on each first - /// . Queried by the draw dispatcher - /// to determine translucency, luminosity, and fog behavior per batch. - /// - public AcSurfaceMetadataTable MetadataTable => _metadataTable; - /// /// Phase A8 (2026-05-28): exposes the underlying /// so EnvCellRenderer can share the same global mesh buffer (VAO/VBO/IBO). @@ -265,13 +303,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter if (_isUninitialized || _meshManager is null) return; _meshManager.IncrementRefCount(id); - bool metadataClaimed = false; try { - metadataClaimed = _metadataPopulated.Add(id); - if (metadataClaimed) - PopulateMetadata(id); - // WB's IncrementRefCount alone only bumps a usage counter; it does // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync // so the background workers actually decode the GfxObj. The result @@ -281,9 +314,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // skips the entity — standard streaming flicker. // // #128 (2026-06-11): Prepare must RE-ARM whenever the id has no render - // data — NOT only on the first-ever registration. The old - // first-ever-only gate (`if (_metadataPopulated.Add(id))`) permanently - // lost any id whose initial decode was cancelled before completing + // data — NOT only on the first-ever registration. A first-only gate + // permanently lost any id whose initial read was cancelled before completing // (landblock unload → CancelStagedUploads during login/teleport // churn) or whose upload was later LRU-evicted: every subsequent // registration skipped Prepare, so the mesh stayed invisible for the @@ -299,19 +331,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // isSetup: false — acdream's MeshRefs already carry expanded // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is // unused. - if (metadataClaimed || _meshManager.TryGetRenderData(id) is null) + if (_meshManager.TryGetRenderData(id) is null) _meshManager.PrepareMeshDataAsync(id, isSetup: false); } catch (Exception acquireFailure) { - if (metadataClaimed) - { - _metadataPopulated.Remove(id); - _metadataTable.Remove(id); - } - - // IncrementRefCount is a public ownership boundary. Metadata/DAT - // preparation happens after ObjectMeshManager commits its count, + // IncrementRefCount is a public ownership boundary. Prepared-data + // acquisition happens after ObjectMeshManager commits its count, // so compensate before reporting failure. ObjectMeshManager's // decrement has a strong exception guarantee; if compensation // itself fails, expose that the increment remains committed so a @@ -612,21 +638,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter $" (arraysBefore={before.ArraysWithPending})"); } - private void PopulateMetadata(ulong id) - { - if (_dats is null) return; - if (!_dats.Portal.TryGet((uint)id, out var gfxObj)) return; - - var subMeshes = GfxObjMesh.Build(gfxObj, _dats); - for (int i = 0; i < subMeshes.Count; i++) - { - var sm = subMeshes[i]; - _metadataTable.Add(id, i, new AcSurfaceMetadata( - sm.Translucency, sm.Luminosity, sm.Diffuse, - sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog)); - } - } - /// public void Dispose() { @@ -646,6 +657,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter frameFlights.WaitForSubmittedWork(); }, () => _meshManager?.Dispose(), + () => _ownedPreparedAssets?.Dispose(), () => DrainGraphicsQueue("publishing mesh resource retirements"), () => { diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs index 6b46beea..ae03882f 100644 --- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -128,7 +128,7 @@ public sealed class LandblockBuildFactory // Hydrate the stabs: same logic as the old OnLoad preload. Each stab // entity from LandblockLoader carries a SourceGfxObjOrSetupId that we - // expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build. + // expand into per-part MeshRefs via SetupMesh.Flatten. // GPU upload happens later through the render-thread presentation // pipeline, never in this worker-side build. var hydrated = new List(baseLoaded.Entities.Count); @@ -364,9 +364,6 @@ public sealed class LandblockBuildFactory if (gfx is not null) { _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); - // Sub-meshes are CPU-built here; the presentation pipeline - // defers upload to the render thread. - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); @@ -384,7 +381,6 @@ public sealed class LandblockBuildFactory var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); // Compose: part's own transform, then the spawn's scale. var partXf = mr.PartTransform * scaleMat; var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); @@ -692,7 +688,6 @@ public sealed class LandblockBuildFactory if (gfx is not null) { _physicsDataCache.CacheGfxObj(stab.Id, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); @@ -733,7 +728,6 @@ public sealed class LandblockBuildFactory continue; } _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value); meshRefs.Add(mr); diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index 4bfe3cea..e6b84501 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -1,4 +1,5 @@ using AcDream.Core.Rendering.Wb; +using AcDream.Core.Meshing; using AcDream.Content.Vfx; using BCnEncoder.Decoder; using BCnEncoder.ImageSharp; @@ -569,6 +570,9 @@ public sealed class MeshExtractor { TextureData = textureData!, UploadPixelFormat = uploadPixelFormat, UploadPixelType = uploadPixelType, + Translucency = + TranslucencyKindExtensions.FromSurfaceType( + surface.Type), IsTransparent = isTransparent, IsAdditive = isAdditive }; @@ -919,6 +923,9 @@ public sealed class MeshExtractor { TextureData = textureData!, UploadPixelFormat = uploadPixelFormat, UploadPixelType = uploadPixelType, + Translucency = + TranslucencyKindExtensions.FromSurfaceType( + surface.Type), IsTransparent = isTransparent, IsAdditive = isAdditive }; diff --git a/src/AcDream.Content/ObjectMeshData.cs b/src/AcDream.Content/ObjectMeshData.cs index 8622108f..7fce14fc 100644 --- a/src/AcDream.Content/ObjectMeshData.cs +++ b/src/AcDream.Content/ObjectMeshData.cs @@ -1,5 +1,6 @@ using Chorizite.Core.Lib; using Chorizite.Core.Render.Enums; +using AcDream.Core.Meshing; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; using System; @@ -149,6 +150,8 @@ public class TextureBatchData { public UploadPixelType? UploadPixelType { get; set; } public List Indices { get; set; } = new(); public DatReaderWriter.Enums.CullMode CullMode { get; set; } + public TranslucencyKind Translucency { get; set; } = + TranslucencyKind.Opaque; public bool IsTransparent { get; set; } public bool IsAdditive { get; set; } public bool HasWrappingUVs { get; set; } diff --git a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs index 622f64cd..0fe0732d 100644 --- a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs +++ b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs @@ -168,6 +168,7 @@ public static class ObjectMeshDataSerializer { WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0); WriteUInt16List(w, batch.Indices); w.Write((int)batch.CullMode); + w.Write((int)batch.Translucency); w.Write(batch.IsTransparent); w.Write(batch.IsAdditive); w.Write(batch.HasWrappingUVs); @@ -182,6 +183,8 @@ public static class ObjectMeshDataSerializer { batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); batch.Indices = ReadUInt16List(r); batch.CullMode = (CullMode)r.ReadInt32(); + batch.Translucency = + (AcDream.Core.Meshing.TranslucencyKind)r.ReadInt32(); batch.IsTransparent = r.ReadBoolean(); batch.IsAdditive = r.ReadBoolean(); batch.HasWrappingUVs = r.ReadBoolean(); diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs index d5487210..94e2d9a1 100644 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -18,10 +18,12 @@ public static class PakFormat { /// /// Identity of the bake algorithm that produced the payloads. Version 2 - /// introduces content-identity EnvCell blobs with ordinary TOC aliases. + /// 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. /// The binary format remains version 1. /// - public const uint CurrentBakeToolVersion = 2; + public const uint CurrentBakeToolVersion = 3; } /// diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index bfa350d7..ab324782 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -36,6 +36,9 @@ public sealed class ContentEffectsAudioCompositionTests Enum.GetValues(), fixture.Points); Assert.Same(fixture.Publication.Dats, result.Dats); + Assert.Same(fixture.Publication.PreparedAssets, result.PreparedAssets); + Assert.Equal("test.pak", fixture.Factory.PreparedAssetPath); + Assert.Same(result.Dats, fixture.Factory.PreparedAssetDats); Assert.Same(fixture.Publication.Magic, result.MagicCatalog); Assert.Same(fixture.Publication.Animations, result.AnimationLoader); Assert.Same(fixture.Publication.Particles, result.ParticleSystem); @@ -167,6 +170,70 @@ public sealed class ContentEffectsAudioCompositionTests StringComparison.Ordinal); } + [Fact] + public void ProductionRendererConsumesOnlyThePublishedPreparedAssetSource() + { + string root = FindRepoRoot(); + string contentPhase = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "ContentEffectsAudioComposition.cs")); + string worldPhase = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "WorldRenderComposition.cs")); + string manager = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "Wb", + "ObjectMeshManager.cs")); + string adapter = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "Wb", + "WbMeshAdapter.cs")); + string lifetime = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "GameWindowLifetime.cs")); + + Assert.Contains("new PakPreparedAssetSource(path, dats, diagnostic)", + contentPhase, StringComparison.Ordinal); + Assert.Contains("content.PreparedAssets", worldPhase, + StringComparison.Ordinal); + Assert.Contains("_preparedAssets.Read(request.Asset, ct)", manager, + StringComparison.Ordinal); + Assert.DoesNotContain("MeshExtractor", manager, + StringComparison.Ordinal); + Assert.DoesNotContain("IDatReaderWriter", manager, + StringComparison.Ordinal); + Assert.DoesNotContain("GfxObjMesh.Build", adapter, + StringComparison.Ordinal); + + int meshStage = lifetime.IndexOf( + "new ResourceShutdownStage(\"mesh adapter\"", + StringComparison.Ordinal); + int preparedRelease = lifetime.IndexOf( + "Hard(\"prepared asset source\"", + StringComparison.Ordinal); + int datRelease = lifetime.IndexOf( + "Hard(\"DAT collection\"", + StringComparison.Ordinal); + Assert.True(meshStage >= 0); + Assert.True(preparedRelease > meshStage); + Assert.True(datRelease > preparedRelease); + } + private sealed class Fixture : IDisposable { private readonly ContentEffectsAudioCompositionPoint? _failurePoint; @@ -185,6 +252,7 @@ public sealed class ContentEffectsAudioCompositionTests typeof(HostInputCameraResult)); Dependencies = new ContentEffectsAudioDependencies( "test-dat", + "test.pak", new PhysicsDataCache(), false, new Spellbook(), @@ -227,6 +295,7 @@ public sealed class ContentEffectsAudioCompositionTests IDisposable { public IDatReaderWriter? Dats { get; private set; } + public IPreparedAssetSource? PreparedAssets { get; private set; } public MagicCatalog? Magic { get; private set; } public IAnimationLoader? Animations { get; private set; } public LiveEntityCollisionBuilder? Collision { get; private set; } @@ -242,6 +311,8 @@ public sealed class ContentEffectsAudioCompositionTests public ContentAudioGraph? Audio { get; private set; } public void PublishDatCollection(IDatReaderWriter value) => Dats = Once(Dats, value); + public void PublishPreparedAssetSource(IPreparedAssetSource value) => + PreparedAssets = Once(PreparedAssets, value); public void PublishMagicCatalog(MagicCatalog value) => Magic = Once(Magic, value); public void PublishAnimationLoader(IAnimationLoader value) => Animations = Once(Animations, value); @@ -273,6 +344,8 @@ public sealed class ContentEffectsAudioCompositionTests Registrations = null; Audio?.Engine.Dispose(); Audio = null; + PreparedAssets?.Dispose(); + PreparedAssets = null; Dats?.Dispose(); Dats = null; } @@ -292,11 +365,24 @@ public sealed class ContentEffectsAudioCompositionTests private readonly MagicCatalog _magic = (MagicCatalog)RuntimeHelpers.GetUninitializedObject(typeof(MagicCatalog)); private readonly IAnimationLoader _animations = new NullAnimationLoader(); + private readonly IPreparedAssetSource _preparedAssets = + new NullPreparedAssetSource(); public int AudioFactoryCalls { get; private set; } public OpenAlAudioEngine? LastAudioEngine { get; private set; } + public string? PreparedAssetPath { get; private set; } + public IDatReaderWriter? PreparedAssetDats { get; private set; } public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats; + public IPreparedAssetSource OpenPreparedAssetSource( + string path, + IDatReaderWriter dats, + Action diagnostic) + { + PreparedAssetPath = path; + PreparedAssetDats = dats; + return _preparedAssets; + } public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic; public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { } public int GetSpellCount(MagicCatalog catalog) => 0; @@ -369,6 +455,26 @@ public sealed class ContentEffectsAudioCompositionTests public Animation? LoadAnimation(uint id) => null; } + private sealed class NullPreparedAssetSource : IPreparedAssetSource + { + public PreparedAssetSourceStats Stats => default; + public CacheStats DecodedTextureCacheStats => default; + + public PreparedAssetPresence Probe( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + PreparedAssetPresence.Missing; + + public PreparedAssetReadResult Read( + in PreparedAssetRequest request, + CancellationToken cancellationToken = default) => + PreparedAssetReadResult.Missing; + + public void Dispose() + { + } + } + private sealed class AudioApiFactory(IOpenAlResourceApi api) : IOpenAlResourceApiFactory { diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 11e06dca..64213137 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -265,6 +265,7 @@ public sealed class WorldRenderCompositionTests public WbMeshAdapter CreateMeshAdapter( GL gl, IDatReaderWriter dats, + IPreparedAssetSource preparedAssets, IGpuResourceRetirementQueue retirement) => Resource("WB mesh adapter"); diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs index 717f048c..e93cd8f2 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs @@ -95,6 +95,8 @@ public static class ObjectMeshDataEquality { Assert.True(expected.Indices.SequenceEqual(actual.Indices), $"{path}.Indices: expected [{string.Join(",", expected.Indices)}], got [{string.Join(",", actual.Indices)}]"); Assert.True(expected.CullMode == actual.CullMode, $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}"); + Assert.True(expected.Translucency == actual.Translucency, + $"{path}.Translucency: expected {expected.Translucency}, got {actual.Translucency}"); Assert.True(expected.IsTransparent == actual.IsTransparent, $"{path}.IsTransparent: expected {expected.IsTransparent}, got {actual.IsTransparent}"); Assert.True(expected.IsAdditive == actual.IsAdditive, $"{path}.IsAdditive: expected {expected.IsAdditive}, got {actual.IsAdditive}"); Assert.True(expected.HasWrappingUVs == actual.HasWrappingUVs, $"{path}.HasWrappingUVs: expected {expected.HasWrappingUVs}, got {actual.HasWrappingUVs}"); diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs index 8df8a0c6..6702ef6c 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Numerics; using AcDream.Content.Pak; +using AcDream.Core.Meshing; using Chorizite.Core.Lib; using Chorizite.Core.Render.Enums; using DatReaderWriter.DBObjs; @@ -59,6 +60,7 @@ public class ObjectMeshDataSerializerTests { UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, Indices = new List { 0, 1, 2, 2, 3, 0 }, CullMode = CullMode.CounterClockwise, + Translucency = TranslucencyKind.InvAlpha, IsTransparent = true, IsAdditive = false, HasWrappingUVs = true, diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs deleted file mode 100644 index 23aa231e..00000000 --- a/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using AcDream.App.Rendering.Wb; -using AcDream.Core.Meshing; - -namespace AcDream.Core.Tests.Rendering.Wb; - -public sealed class AcSurfaceMetadataTableTests -{ - [Fact] - public void Add_ThenLookup_RoundTripsSameMetadata() - { - var table = new AcSurfaceMetadataTable(); - var meta = new AcSurfaceMetadata( - Translucency: TranslucencyKind.AlphaBlend, - Luminosity: 0.5f, - Diffuse: 0.8f, - SurfOpacity: 0.7f, - NeedsUvRepeat: true, - DisableFog: false); - - table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta); - - Assert.True(table.TryLookup(0x01000123ul, 2, out var got)); - Assert.Equal(meta, got); - } - - [Fact] - public void Lookup_MissingKey_ReturnsFalse() - { - var table = new AcSurfaceMetadataTable(); - Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _)); - } - - [Fact] - public void Add_OverwritesPreviousMetadata() - { - var table = new AcSurfaceMetadataTable(); - var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false); - var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true); - - table.Add(0xAAAA, 0, first); - table.Add(0xAAAA, 0, second); - - Assert.True(table.TryLookup(0xAAAA, 0, out var got)); - Assert.Equal(second, got); - } - - [Fact] - public void Add_FromMultipleThreads_IsThreadSafe() - { - var table = new AcSurfaceMetadataTable(); - var threads = new System.Threading.Tasks.Task[8]; - for (int t = 0; t < 8; t++) - { - int threadIdx = t; - threads[t] = System.Threading.Tasks.Task.Run(() => - { - for (int i = 0; i < 1000; i++) - { - ulong key = (ulong)(threadIdx * 1000 + i); - table.Add(key, 0, new AcSurfaceMetadata( - TranslucencyKind.Opaque, 0f, 1f, 1f, false, false)); - } - }); - } - System.Threading.Tasks.Task.WaitAll(threads); - - // 8000 entries should be present. - for (int t = 0; t < 8; t++) - for (int i = 0; i < 1000; i++) - Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _)); - } -}