From 932f904e00925ea3fb087e61dec59654ecb5d1aa Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 20:39:42 +0200 Subject: [PATCH] fix(pipeline): MP1a - sink delegate restores immediate side-stage enqueue (exception-path faithfulness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinator-directed follow-up. The buffer-and-drain seam diverged from the original on the exception path: pre-MP1a, CollectEmittersFromScript enqueued particle-preload meshes DIRECTLY into _stagedMeshData mid-Prepare, so preloads staged before a later throw in the same Prepare* call (reachable via PrepareEnvCellMeshData side-staging during its StaticObjects loop, then PrepareCellStructMeshData throwing on a malformed-dat texture decode) were already safely enqueued. The drain version only flushed after a successful return — on throw, entries stranded on the shared extractor until an unrelated successful call flushed them, and were silently dropped on dispose. Fix: MeshExtractor takes an Action? sideStagedSink constructor parameter; the two CollectEmittersFromScript sites become _sideStagedSink?.Invoke(meshData) — the original code shape (immediate hand-off) at those exact lines. ObjectMeshManager wires the sink to _stagedMeshData.Enqueue, restoring the original immediate-enqueue semantics including on mid-Prepare throw. _sideStaged buffer, DrainSideStaged(), and the ProcessQueueAsync drain loop are deleted. The MP1b bake tool passes its own collector. Inventory doc updated: MP1a note now records the sink seam and the Content-owned upload enums, so its no-behavior-change claim is accurate. dotnet build green; full test suite 4059 passed / 0 failed / 4 skipped. Co-Authored-By: Claude Fable 5 --- docs/architecture/worldbuilder-inventory.md | 18 ++++++++ .../Rendering/Wb/ObjectMeshManager.cs | 13 +++--- src/AcDream.Content/MeshExtractor.cs | 45 +++++++------------ 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 4eabea66..be58ad63 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -53,8 +53,26 @@ behavior change, no divergence-register row. - `src/AcDream.Content/TextureKey.cs` — the atlas dedup key, lifted out of the GL-owning `TextureAtlasManager` (which stays in App and now references the lifted struct). +- `src/AcDream.Content/UploadFormats.cs` — Content-owned + `UploadPixelFormat`/`UploadPixelType` enums carried by + `MeshBatchData`/`TextureBatchData` instead of + `Silk.NET.OpenGL.PixelFormat`/`PixelType` (Content must stay + Silk.NET-free — the bake tool must not ship GL binaries). Underlying + values are the GL ABI constants, numerically identical to the Silk.NET + members; App casts at its single upload boundary (the `AddTexture` call + in `UploadGfxObjMeshData`) via a lifted nullable enum conversion — + value- and null-preserving. - `src/AcDream.Content/IDatReaderWriter.cs`, `EdgeLineBuilder.cs` — GL-free dependencies of the extractor, moved (namespace-only) alongside it. +- **Side-stage sink seam:** `CollectEmittersFromScript` pre-loads particle + GfxObj meshes mid-extraction and, pre-MP1a, enqueued them directly onto + `ObjectMeshManager._stagedMeshData`. The extractor now takes an + `Action? sideStagedSink` constructor parameter; App wires + it to `_stagedMeshData.Enqueue`, preserving the original + immediate-enqueue semantics exactly — including on a mid-`Prepare*` + throw (preloads staged before a malformed-dat texture-decode exception + survive, as they always did). The MP1b bake tool passes its own + collector. - **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (now a thin wrapper owning the staged-queue/worker-pool/Dispose-quiesce lifecycle and all GL upload — constructs one `MeshExtractor` and delegates every diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index ec92af48..27d6d0fc 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -167,7 +167,12 @@ namespace AcDream.App.Rendering.Wb { _graphicsDevice = graphicsDevice; _dats = dats; _logger = logger; - _extractor = new MeshExtractor(_dats, _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. + _extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data)); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL); @@ -475,12 +480,6 @@ namespace AcDream.App.Rendering.Wb { // If it's a direct setup or gfxobj, make sure background loads don't abort half-way data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None); } - // MP1a: drain any mesh data the extractor staged as a side effect - // (particle emitter GfxObj preloads inside CollectEmittersFromScript) - // and enqueue it the same way the top-level result is enqueued below. - foreach (var sideStaged in _extractor.DrainSideStaged()) { - _stagedMeshData.Enqueue(sideStaged); - } if (data != null) { lock (_cpuMeshCache) { if (_cpuMeshCache.Count >= _maxCpuCacheSize) { diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index fa5f1d46..5117cb85 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -39,38 +39,23 @@ public sealed class MeshExtractor { private readonly ThreadLocal _bcDecoder = new(() => new BcDecoder()); /// - /// MP1a mechanical seam: retail's particle-preload side effect (see - /// ) used to enqueue directly onto - /// ObjectMeshManager's runtime staged-upload queue (_stagedMeshData). - /// That queue is a GL-upload-lifecycle concern and stays in App per the - /// plan's binding rules, so the extractor collects the same side-effect - /// output here instead; ObjectMeshManager drains it via - /// immediately after each Prepare* call and - /// enqueues the results itself. No behavior change — same objects reach - /// the same queue, just via an explicit hand-off instead of a shared field. - /// ConcurrentQueue because one MeshExtractor is shared by up to - /// MaxParallelLoads (4) decode workers — the original enqueued to the - /// thread-safe _stagedMeshData, so the hand-off buffer must be - /// thread-safe too. + /// MP1a mechanical seam: receives particle-preload meshes staged + /// mid-extraction (see ). The App + /// wires this to its staged-upload queue, restoring the original + /// immediate-enqueue semantics — entries must survive a subsequent throw + /// in the same Prepare* call (retail's code enqueued directly onto + /// ObjectMeshManager's _stagedMeshData mid-Prepare, so preloads + /// staged before a malformed-dat texture-decode throw were already safe). + /// The MP1b bake tool passes its own collector. The sink must be + /// thread-safe: one MeshExtractor is shared by up to MaxParallelLoads (4) + /// decode workers. /// - private readonly ConcurrentQueue _sideStaged = new(); + private readonly Action? _sideStagedSink; - public MeshExtractor(IDatReaderWriter dats, ILogger logger) { + public MeshExtractor(IDatReaderWriter dats, ILogger logger, Action? sideStagedSink = null) { _dats = dats; _logger = logger; - } - - /// - /// Drains and returns any mesh data staged as a side effect of the most - /// recent Prepare* call (particle emitter GfxObj preloads). See - /// . - /// - public List DrainSideStaged() { - var result = new List(); - while (_sideStaged.TryDequeue(out var item)) { - result.Add(item); - } - return result; + _sideStagedSink = sideStagedSink; } /// @@ -176,13 +161,13 @@ public sealed class MeshExtractor { if (emitter.HwGfxObjId.DataId != 0) { var meshData = PrepareMeshData(emitter.HwGfxObjId.DataId, false, ct); if (meshData != null) { - _sideStaged.Enqueue(meshData); + _sideStagedSink?.Invoke(meshData); } } if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct); if (meshData != null) { - _sideStaged.Enqueue(meshData); + _sideStagedSink?.Invoke(meshData); } } }