From 7bbb169c6c4303b5a8bab716b8bac4d98e08b2f1 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 20:30:56 +0200 Subject: [PATCH] #128 STRUCTURAL: missing meshes re-request their load at the POINT OF USE - permanent invisibility becomes impossible The registration-time re-arm was insufficient and the user proved it (ran back from the lifestone -> broken stairs + exposed barrel again): a preparation cancelled by landblock churn AFTER the last registration event has no later event to re-fire it - crossing blocks loads/unloads them repeatedly behind the player, so the cancel-after-last-register window is routinely hit on any cross-country run. The structural fix: the draw dispatcher touches every missing-but-referenced mesh every frame (the meshMissing slow path) - THAT is the one site a retry can never be missed from. Both miss paths (per-MeshRef and per-Setup-part) now call WbMeshAdapter.EnsureLoaded (idempotent passthrough to PrepareMeshDataAsync, which early-outs on existing data and dedups pending tasks), deduped per Draw pass. Retail-equivalence: retail loads synchronously - geometry is never permanently absent; this converges the async pipeline to the same guarantee regardless of cancellation/eviction timing. Also fixes the #53-one-level-deeper hole found en route: a missing SETUP PART did not mark the entity incomplete, so a partial batch set could cache permanently for Setup-shaped render data. New apparatus: [mesh-miss] once-per-id line under ACDREAM_WB_DIAG=1 - any future missing mesh names itself instead of needing a live repro. Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294. Co-Authored-By: Claude Fable 5 --- .../Rendering/Wb/WbDrawDispatcher.cs | 39 ++++++++++++++++++- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 20 ++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 34c70af0..17a4e259 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -244,6 +244,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private int _instancesIssued; private long _lastLogTick; + // #128 self-heal: per-Draw dedup of point-of-use load re-requests + // (PrepareMeshDataAsync is idempotent while pending — the dedup just + // avoids redundant dictionary probes within one pass) + the once-per-id + // [mesh-miss] diagnostic set (never cleared; diag-gated emission). + private readonly HashSet _missRequested = new(); + private readonly HashSet _missLogged = new(); + // CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1. private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new(); private readonly long[] _cpuSamples = new long[256]; // microseconds @@ -728,6 +735,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); + // #128 self-heal: fresh re-request dedup per Draw pass. + _missRequested.Clear(); + bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal); if (diag && !_gpuQueriesInitialized) @@ -1054,6 +1064,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // the populate fires with the complete batch set. currentEntityIncomplete = true; if (diag) _meshesMissing++; + // #128 self-heal: a missing-but-referenced mesh re-requests + // its load HERE — the one site that touches it every frame — + // so a preparation lost to landblock churn (cancelled after + // the last registration event) can never stay lost. Deduped + // per Draw; PrepareMeshDataAsync is idempotent while pending. + if (_missRequested.Add(gfxObjId)) + { + _meshAdapter.EnsureLoaded(gfxObjId); + if (diag && _missLogged.Add(gfxObjId)) + Console.WriteLine($"[mesh-miss] 0x{gfxObjId:X10} re-requested at point of use"); + } continue; } if (anyVao == 0) anyVao = renderData.VAO; @@ -1070,7 +1091,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) { var partData = _meshAdapter.TryGetRenderData(partGfxObjId); - if (partData is null) continue; + if (partData is null) + { + // #128 self-heal + #53: a missing Setup PART must mark + // the entity incomplete (else a partial batch set + // caches permanently — the same bug class one level + // deeper) and re-request its load like the MeshRef + // path above. + currentEntityIncomplete = true; + if (diag) _meshesMissing++; + if (_missRequested.Add(partGfxObjId)) + { + _meshAdapter.EnsureLoaded(partGfxObjId); + if (diag && _missLogged.Add(partGfxObjId)) + Console.WriteLine($"[mesh-miss] 0x{partGfxObjId:X10} (setup part) re-requested at point of use"); + } + continue; + } var model = ComposePartWorldMatrix( entityWorld, meshRef.PartTransform, partTransform); diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index c3188813..af2940ec 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -198,6 +198,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _meshManager.DecrementRefCount(id); } + /// + /// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF + /// USE. Registration-time re-arming was insufficient — a preparation + /// cancelled by landblock churn AFTER the last registration event + /// (running across blocks loads/unloads them repeatedly) left the mesh + /// permanently unloadable with no later event to re-fire it. The draw + /// dispatcher touches every missing-but-referenced mesh every frame (the + /// meshMissing slow path) — that is the one place a retry can never be + /// missed. Cheap and idempotent: PrepareMeshDataAsync early-outs on + /// existing render data and returns the in-flight task when pending. + /// Retail-equivalence: retail loads content synchronously — geometry is + /// never permanently absent; this converges our async pipeline to the + /// same guarantee. + /// + public void EnsureLoaded(ulong id) + { + if (_isUninitialized || _meshManager is null) return; + _meshManager.PrepareMeshDataAsync(id, isSetup: false); + } + /// /// Per-frame drain of the WB pipeline's main-thread work queues. MUST be /// called once per frame from the render thread. Without this, the staged