using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AcDream.Core.Meshing; using AcDream.Core.Rendering; using Chorizite.OpenGLSDLBackend; using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter; using DatReaderWriter.DBObjs; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Silk.NET.OpenGL; using WorldBuilder.Shared.Models; using WorldBuilder.Shared.Services; 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. /// /// /// The adapter constructs its own DefaultDatReaderWriter internally; it /// does NOT share file handles with our DatCollection. This duplicates /// index-cache memory (~50–100 MB) but keeps the two subsystems fully decoupled. /// Acceptable for Phase N.4 foundation work (plan Adjustment 1). /// /// public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter { private readonly OpenGLGraphicsDevice? _graphicsDevice; private readonly DefaultDatReaderWriter? _wbDats; private readonly ObjectMeshManager? _meshManager; private readonly DatCollection? _dats; private readonly AcSurfaceMetadataTable _metadataTable = new(); private readonly HashSet _metadataPopulated = new(); /// /// EnvCell ids we've requested via PrepareMeshDataAsync but not yet /// seen completion for in Tick(). Used by the [indoor-upload] probe /// to log requested + completed pairs. Cleared per completion; /// missing completions after a few seconds indicate WB silently /// returned null (hypothesis H1 in the design spec). /// private readonly HashSet _pendingEnvCellRequests = new(); /// /// True when this instance was created via ; /// all public methods no-op when uninitialized. /// private readonly bool _isUninitialized; private bool _disposed; /// /// Constructs the full WB pipeline: OpenGLGraphicsDevice → DefaultDatReaderWriter /// → ObjectMeshManager. /// /// Active Silk.NET GL context. Must be bound to the current /// thread (construction runs GL queries; call from OnLoad). /// Path to the dat directory (same as the one supplied /// to our DatCollection). DefaultDatReaderWriter opens its own file handles. /// acdream's DatCollection, 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, string datDir, DatCollection dats, ILogger logger) { ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(datDir); ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(logger); _dats = dats; _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); _wbDats = new DefaultDatReaderWriter(datDir); // Phase 2 diagnostic — replace NullLogger with a Console-backed // logger so WB's internal catch block at ObjectMeshManager.cs:589 // (and similar) surfaces its swallowed exceptions instead of // dropping them. ConsoleErrorLogger filters to LogLevel.Error+ // so successful operations stay quiet. _meshManager = new ObjectMeshManager( _graphicsDevice, _wbDats, 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; /// /// 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 void IncrementRefCount(ulong id) { if (_isUninitialized || _meshManager is null) return; _meshManager.IncrementRefCount(id); if (_metadataPopulated.Add(id)) { 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. // // isSetup: false — acdream's MeshRefs already carry expanded // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is // unused. var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false); // [indoor-upload] requested probe — only for EnvCell ids. if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled) { bool hadRenderDataAtRequest = _meshManager.HasRenderData(id); _pendingEnvCellRequests.Add(id); Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8} hadRenderData={hadRenderDataAtRequest}"); // Phase 2 — surface what WB's catch block silently swallows. // ObjectMeshManager.PrepareMeshData has a try/catch at line 589 // that calls _logger.LogError(ex, ...) — but we construct // ObjectMeshManager with NullLogger.Instance so the log is // dropped. This continuation captures the same data scoped to // EnvCell ids only. Runs on ThreadPool; non-blocking. Zero cost // when the probe is off. ulong cellId = id; _ = prepTask.ContinueWith(t => { if (t.IsFaulted && t.Exception is not null) { var ex = t.Exception.InnerException ?? t.Exception; var stack = (ex.StackTrace ?? "").Split('\n') .Take(3).Select(s => s.Trim()).Where(s => s.Length > 0); Console.WriteLine( $"[indoor-upload] FAILED cellId=0x{cellId:X8} " + $"exception={ex.GetType().Name}: {ex.Message} " + $"stack=[{string.Join(" | ", stack)}]"); } else if (t.IsCompletedSuccessfully && t.Result is null) { // Phase 2 cause-narrowing: WB's PrepareMeshData can return // null for several reasons (ResolveId empty / TryGet // failed / type Unknown). Cross-check against acdream's own // DatCollection — if WE find the cell but WB doesn't, the // divergence is between dat readers, not a missing record. bool ourCellFound = false; try { ourCellFound = _dats?.Cell.TryGet( (uint)cellId, out _) ?? false; } catch { /* swallow — this is best-effort diagnostic */ } int wbResolveCount = -1; string wbSelectedType = "none"; bool wbDbTryGetEnvCell = false; bool wbDbIsPortal = false; try { var wbResolutions = _wbDats?.ResolveId((uint)cellId).ToList(); wbResolveCount = wbResolutions?.Count ?? -1; if (wbResolutions is not null && wbResolutions.Count > 0) { var selected = wbResolutions .OrderByDescending(r => r.Database == _wbDats!.Portal) .First(); wbSelectedType = selected.Type.ToString(); wbDbIsPortal = selected.Database == _wbDats!.Portal; try { wbDbTryGetEnvCell = selected.Database.TryGet((uint)cellId, out _); } catch {} } } catch { /* swallow — best-effort */ } Console.WriteLine( $"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8} " + $"ourCellDb.TryGet={ourCellFound} " + $"wbResolveId.Count={wbResolveCount} " + $"wbSelectedType={wbSelectedType} " + $"wbDbIsPortal={wbDbIsPortal} " + $"wbDbTryGet={wbDbTryGetEnvCell}"); } }, TaskScheduler.Default); } } } /// public void DecrementRefCount(ulong id) { if (_isUninitialized || _meshManager is null) return; _meshManager.DecrementRefCount(id); } /// /// 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; _graphicsDevice!.ProcessGLQueue(); while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) { // [indoor-upload] completed probe — check BEFORE upload so we // see what WB actually produced (vertex counts, parts) before // any post-upload mutation. bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled && _pendingEnvCellRequests.Remove(meshData.ObjectId); var renderData = _meshManager.UploadMeshData(meshData); if (isPendingEnvCell) { int parts = meshData.SetupParts?.Count ?? 0; bool hasGeom = meshData.EnvCellGeometry is not null; int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0; bool uploadOk = renderData is not null; Console.WriteLine( $"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " + $"isSetup={meshData.IsSetup} parts={parts} " + $"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " + $"uploadOk={uploadOk}"); } } } 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; _disposed = true; _meshManager?.Dispose(); _wbDats?.Dispose(); _graphicsDevice?.Dispose(); } }