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; namespace AcDream.App.Rendering.Wb; /// /// Single seam between acdream and WB's render pipeline. Owns the /// ObjectMeshManager instance and exposes a stable acdream-shaped API /// so the rest of the renderer doesn't need to know about WB's types directly. /// /// /// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared /// facade — the separate /// DefaultDatReaderWriter file-handle set has been removed. /// /// public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter { internal const int MaximumUploadsPerFrame = 8; internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024; internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024; internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024; internal const int MaximumNewArraysPerFrame = 1; internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024; // A GL buffer store is indivisible. The active vertex arena's explicit // supported ceiling is therefore the truthful one-allocation bound; only // its prefix copy is staged across frames. internal const long MaximumBufferAllocationBytesPerFrame = GlobalMeshBuffer.MaximumVertexBufferBytes; internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024; internal const int MaximumNewBuffersPerFrame = 1; internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024; internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024; internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024; internal const int MaximumSingleNewArrays = 16; internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024; internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame; internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024; internal const int MaximumStaleDiscardsPerFrame = 64; 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 MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits( MaximumUploadsPerFrame, MaximumUploadBytesPerFrame, MaximumArrayAllocationBytesPerFrame, MaximumMipmapBytesPerFrame, MaximumNewArraysPerFrame, MaximumBufferUploadBytesPerFrame, MaximumBufferAllocationBytesPerFrame, MaximumBufferCopyBytesPerFrame, MaximumNewBuffersPerFrame, MaximumSingleUploadBytes, MaximumSingleArrayAllocationBytes, MaximumSingleMipmapBytes, MaximumSingleNewArrays, MaximumSingleBufferUploadBytes)); private readonly HashSet _mipmapsBudgeted = new(); /// /// True when this instance was created via ; /// all public methods no-op when uninitialized. /// private readonly bool _isUninitialized; private bool _disposed; private AcDream.App.Rendering.OrderedResourceTeardown? _teardown; internal int LastUploadCount { get; private set; } internal long LastUploadBytes { get; private set; } internal int LastStaleDiscardCount { get; private set; } internal long LastArrayAllocationBytes { get; private set; } internal long LastPlannedMipmapBytes { get; private set; } internal int LastNewArrayCount { get; private set; } internal long LastBufferUploadBytes { get; private set; } internal long LastBufferAllocationBytes { get; private set; } internal long LastBufferCopyBytes { get; private set; } internal int LastNewBufferCount { get; private set; } internal int LastMipmapArrayCount { get; private set; } internal long LastMipmapBytes { get; private set; } internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0; internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0; internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false; internal (int Count, long Bytes) CpuMeshCacheDiagnostics => _meshManager?.CpuCacheDiagnostics ?? default; /// /// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded /// DAT facade → ObjectMeshManager. /// /// 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. /// 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) { } internal WbMeshAdapter( GL gl, IDatReaderWriter dats, ILogger logger, AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) { ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(logger); _dats = dats; _resourceRetirement = resourceRetirement; _graphicsDevice = new OpenGLGraphicsDevice( gl, logger, new DebugRenderSettings(), resourceRetirement); _graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice); // ConsoleErrorLogger surfaces WB's silently-caught exceptions // (ObjectMeshManager.PrepareMeshData try/catch at line ~589). _meshManager = new ObjectMeshManager( _graphicsDevice, dats, new ConsoleErrorLogger()); } /// /// Minimal Console-backed logger that fires only on /// and above. Format: /// [wb-error] <message> /// [wb-error] <ExceptionType>: <ExceptionMessage> /// [wb-error] at <frame> (up to 5 frames) /// Used to surface WB's silently-caught exceptions in /// ObjectMeshManager.PrepareMeshData. /// private sealed class ConsoleErrorLogger : ILogger { public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (!IsEnabled(logLevel)) return; var message = formatter(state, exception); Console.WriteLine($"[wb-error] {message}"); if (exception is not null) { Console.WriteLine($"[wb-error] {exception.GetType().Name}: {exception.Message}"); var stack = (exception.StackTrace ?? "") .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Take(5); foreach (var s in stack) Console.WriteLine($"[wb-error] {s.Trim()}"); } } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } private WbMeshAdapter() { // Uninitialized constructor — only for tests / flag-off cases where // the caller wants a Dispose-safe no-op instance. _isUninitialized = true; } /// Test/init helper — produces a Dispose-safe instance with no /// 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). /// Returns null when the adapter is uninitialized. /// public ObjectMeshManager? MeshManager => _meshManager; /// /// Returns the WB render data for , or null if not /// yet uploaded or if this adapter is uninitialized. Increments WB's /// internal usage counter — use for /// render-loop lookups that should not affect lifecycle. /// public ObjectRenderData? GetRenderData(ulong id) { if (_isUninitialized || _meshManager is null) return null; return _meshManager.GetRenderData(id); } /// /// Returns the WB render data for without /// modifying reference counts. Returns null if the mesh is not yet /// uploaded. Safe for render-loop lookups. /// public ObjectRenderData? TryGetRenderData(ulong id) { if (_isUninitialized || _meshManager is null) return null; return _meshManager.TryGetRenderData(id); } /// public bool IsRenderDataReady(ulong id) { // An uninitialized adapter owns no render pipeline, so it must not // manufacture a readiness wait that can never complete. The modern // production path is always initialized at startup. return _isUninitialized || (_meshManager?.EnsureRenderDataReady(id) ?? false); } /// public void IncrementRefCount(ulong id) { 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 // auto-enqueues into _stagedMeshData (ObjectMeshManager line 510), // which Tick() drains onto the GPU. Until that completes, // TryGetRenderData(id) returns null and the dispatcher silently // 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 // (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 // session with zero log output — the dispatcher's slow path just // counted meshMissing forever (issue #55's 1.45M/5s mountain was this // bug's heartbeat). User-visible: the AAB3 tower staircase rendering // partially or not at all depending on the session's landblock // load/unload interleaving (#119/#128 "broken stairs"). Safe to call // unconditionally when data is absent: PrepareMeshDataAsync early-outs // on existing render data, returns the in-flight task when already // pending, and dedups via _preparationTasks. // // 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) _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, // 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 // higher-level transactional owner can retain its held marker. try { _meshManager.DecrementRefCount(id); } catch (Exception rollbackFailure) { throw new MeshReferenceMutationException( $"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.", mutationCommitted: true, new AggregateException(acquireFailure, rollbackFailure)); } throw; } } /// public void DecrementRefCount(ulong id) { if (_isUninitialized || _meshManager is null) return; _meshManager.DecrementRefCount(id); } /// public void PinPreparedRenderData(ulong id) { if (_isUninitialized || _meshManager is null) return; // EnvCell geometry has its own schema-aware preparation request. // Only establish lifecycle ownership here; IncrementRefCount's normal // generic GfxObj preparation would decode the synthetic id incorrectly. _meshManager.IncrementRefCount(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 /// mesh data queue grows unbounded (memory leak) and queued GL actions /// never execute. /// /// /// Order matters: ProcessGLQueue runs first to apply any pending GL /// state changes (e.g., texture uploads queued by background workers /// during mesh prep). Then we drain staged mesh data, calling /// UploadMeshData on each item to materialize the actual GL VAO / /// VBO / IBO resources. After Tick, GetRenderData for any id /// previously passed to IncrementRefCount may return non-null. /// /// /// /// No-op when the adapter is uninitialized (e.g., flag is off and the /// adapter was constructed via CreateUninitialized). /// /// public void Tick() { if (_isUninitialized) return; if (_disposed) return; ObjectMeshManager meshManager = _meshManager!; _graphicsDevice!.ProcessGLQueue(); // #125: drain staged uploads; a FAILED upload (UploadMeshData returned // null from its catch) is re-staged for a LATER frame, not dropped. The // re-stages are collected and re-enqueued AFTER the loop — re-enqueuing // inside the while would let a deterministic failure spin the queue in a // single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so // a genuine defect surfaces loudly instead of the old silent sticky drop. List? requeue = null; _uploadBudget.Reset(); _mipmapsBudgeted.Clear(); int staleDiscardCount = 0; bool arenaBackpressured = false; GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer; // A backing-store migration is real GL work, not a cost estimate. One // bounded copy chunk runs per frame while the old VAO remains active. // Uploads stay queued until the atomic final swap has completed. if (globalBuffer?.IsMigrationInProgress == true) { GlobalMeshMaintenanceStep maintenance = globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame); _uploadBudget.RecordBufferMaintenance( maintenance.AllocationBytes, maintenance.CopyBytes, maintenance.NewBufferCount); arenaBackpressured = !maintenance.Completed; } while (globalBuffer?.IsMigrationInProgress != true && _uploadBudget.BufferCopyBytes == 0 && _uploadBudget.ObjectCount < MaximumUploadsPerFrame) { staleDiscardCount += meshManager.DiscardUnownedStagedPrefix( MaximumStaleDiscardsPerFrame - staleDiscardCount); if (staleDiscardCount >= MaximumStaleDiscardsPerFrame) break; if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next)) break; GlobalMeshCapacityResult capacity; GlobalMeshMaintenanceStep maintenance; try { capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance); } catch (NotSupportedException error) { RejectUnsupportedHead(meshManager, next, error); throw; } if (capacity == GlobalMeshCapacityResult.MigrationStarted) { _uploadBudget.RecordBufferMaintenance( maintenance.AllocationBytes, maintenance.CopyBytes, maintenance.NewBufferCount); arenaBackpressured = true; break; } if (capacity == GlobalMeshCapacityResult.MigrationInProgress) { arenaBackpressured = true; break; } if (capacity == GlobalMeshCapacityResult.NeedsReclamation) { // Do not evict another batch while the frame-fence queue is // already returning ranges or retiring an old backing store. // Waiting for real allocator state prevents three frames of // duplicate eviction before the first release becomes reusable. if (globalBuffer?.HasPendingReclamation != true) { var reclaimed = meshManager.ReclaimUnusedResources( MaximumReclaimedMeshesPerFrame, MaximumReclaimedMeshBytesPerFrame, forceArenaReclamation: true); if (reclaimed.Count == 0) { throw new NotSupportedException( $"The live global-mesh working set cannot fit the supported " + $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena " + $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10})."); } } arenaBackpressured = true; break; } MeshUploadCost cost; try { cost = meshManager.PlanUploadCost( next.Data, _mipmapsBudgeted, next.Generation); if (!_uploadBudget.TryAdmit(cost)) break; } catch (NotSupportedException error) { RejectUnsupportedHead(meshManager, next, error); throw; } if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData)) break; if (meshData.Generation != next.Generation) throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission."); if (meshManager.UploadOrRequeue(meshData)) (requeue ??= new()).Add(meshData); meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted); } if (requeue is not null) foreach (var m in requeue) meshManager.RequeueStagedMeshData(m); meshManager.SetArenaBackpressure(arenaBackpressured); bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled; var pendingBefore = texProbe ? meshManager.GetPendingTextureUpdateStats() : default; // #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES // immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the // actual TexSubImage3D copies + mipmap regeneration happen in // ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop // (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the // opaque pass). That call site lived in the GameScene file the N.4/O-T4 // extraction replaced with GameWindow, so the driver was silently dropped: // staged updates never reached TexSubImage3D, leaving undefined // TexStorage3D content behind valid resident bindless handles — the // intermittent white indoor walls (#105). Pre-fix evidence: 126 updates // stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick() // runs before all draw passes (GameWindow OnRender), so this is the exact // WB-equivalent position. (LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps(); if (globalBuffer?.HasPendingReclamation != true) { meshManager.ReclaimUnusedResources( MaximumReclaimedMeshesPerFrame, MaximumReclaimedMeshBytesPerFrame); } meshManager.EvictOneEmptyAtlas(); // Arena maintenance is capacity-driven and uses genuinely idle upload // frames. It never competes with destination materialization, and the // GlobalMeshBuffer policy retains enough hysteresis that portal-route // revisits do not alternate shrink/grow every frame. if (_uploadBudget.ObjectCount == 0 && LastMipmapArrayCount == 0 && meshManager.StagedMeshCount == 0 && globalBuffer?.IsMigrationInProgress == false && globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim)) { _uploadBudget.RecordBufferMaintenance( trim.AllocationBytes, trim.CopyBytes, trim.NewBufferCount); meshManager.SetArenaBackpressure(true); } LastUploadCount = _uploadBudget.ObjectCount; LastUploadBytes = _uploadBudget.SourceBytes; LastStaleDiscardCount = staleDiscardCount; LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes; LastPlannedMipmapBytes = _uploadBudget.MipmapBytes; LastNewArrayCount = _uploadBudget.NewArrayCount; LastBufferUploadBytes = _uploadBudget.BufferUploadBytes; LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes; LastBufferCopyBytes = _uploadBudget.BufferCopyBytes; LastNewBufferCount = _uploadBudget.NewBufferCount; if (texProbe) EmitTexFlushProbe(pendingBefore); } private static void RejectUnsupportedHead( ObjectMeshManager meshManager, MeshUploadQueueItem expected, NotSupportedException error) { if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected) || rejected.Generation != expected.Generation) { throw new InvalidOperationException( "The staged mesh FIFO head changed while rejecting an unsupported generation.", error); } meshManager.RejectUnsupportedStagedUpload(rejected, error); } // #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled. private int _lastTexFlushBefore = -1; private int _texFlushHeartbeat; /// /// #105 apparatus: one [tex-flush] line on change of the staged-texture /// pending picture (plus a ~10 s heartbeat while anything is stuck). A healthy /// frame ends with after=0; before==after>0 persisting at /// standstill is the white-walls mechanism live (staged uploads never applied). /// private void EmitTexFlushProbe((int PendingUpdates, int ArraysWithPending, int TotalArrays) before) { var after = _meshManager!.GetPendingTextureUpdateStats(); bool changed = before.PendingUpdates != _lastTexFlushBefore; bool flushed = after.PendingUpdates != before.PendingUpdates; bool heartbeat = after.PendingUpdates > 0 && ++_texFlushHeartbeat >= 600; if (!changed && !flushed && !heartbeat) return; _texFlushHeartbeat = 0; _lastTexFlushBefore = before.PendingUpdates; Console.WriteLine( $"[tex-flush] before={before.PendingUpdates} after={after.PendingUpdates}" + $" arrays={after.ArraysWithPending}/{after.TotalArrays}" + $" (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() { if (_disposed) return; // These are dependency edges, not merely a best-effort cleanup list. // In particular, the sampler objects owned by the device must remain // alive until every resident texture-array handle has been retired. _teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown( () => { // The current global arena is still directly owned by the mesh // manager. Fence every submitted draw before manager teardown // can delete that arena's VAO/VBO/IBO. if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights) frameFlights.WaitForSubmittedWork(); }, () => _meshManager?.Dispose(), () => DrainGraphicsQueue("publishing mesh resource retirements"), () => { if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights) frameFlights.WaitForSubmittedWork(); }, () => DrainGraphicsQueue("releasing retired mesh resources"), () => _graphicsDevice?.Dispose(), () => DrainGraphicsQueue("deleting mesh graphics-device resources")); _teardown.Advance(); _disposed = _teardown.IsComplete; } private void DrainGraphicsQueue(string operation) { if (_graphicsDevice is null) return; _graphicsDevice.ProcessGLQueue(); if (_graphicsDevice.HasPendingGLWork) { throw new InvalidOperationException( $"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage."); } } }