using Chorizite.Core.Lib; using Chorizite.Core.Render; using Chorizite.Core.Render.Enums; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using CullMode = DatReaderWriter.Enums.CullMode; using DatReaderWriter.Types; using Microsoft.Extensions.Logging; using Silk.NET.OpenGL; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using AcDream.Content; using AcDream.App.Rendering.Residency; using AcDream.Core.Rendering.Wb; using PixelFormat = Silk.NET.OpenGL.PixelFormat; using BoundingBox = Chorizite.Core.Lib.BoundingBox; namespace AcDream.App.Rendering.Wb { /// /// GPU-side render data created on the main thread. /// public class ObjectRenderData { public uint VAO { get; set; } public uint VBO { get; set; } public int VertexCount { get; set; } public List Batches { get; set; } = new(); internal GlobalMeshAllocation? GlobalAllocation { get; set; } public bool IsSetup { get; set; } public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new(); /// Particle emitters from physics scripts. public List ParticleEmitters { get; set; } = new(); /// CPU-side vertex positions for raycasting. public Vector3[] CPUPositions { get; set; } = Array.Empty(); /// CPU-side indices for raycasting. public ushort[] CPUIndices { get; set; } = Array.Empty(); /// CPU-side edge line vertices for Environment wireframe rendering. public Vector3[] CPUEdgeLines { get; set; } = Array.Empty(); /// Local bounding box. public BoundingBox BoundingBox { get; set; } /// Approximate center point used for depth sorting / transparency ordering. public Vector3 SortCenter { get; set; } /// DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo. public uint DIDDegrade { get; set; } /// Sphere used for mouse selection. public Sphere? SelectionSphere { get; set; } /// Estimated GPU memory usage in bytes. public long MemorySize { get; set; } /// /// Physical bytes owned outside . Modern /// vertex/index ranges are deliberately excluded because the arena's /// backing-store capacity is accounted once at its owner. /// internal long NonArenaGpuBytes { get; set; } } /// /// A single GPU draw batch: IBO + texture array layer. /// public class ObjectRenderBatch { public uint IBO { get; set; } public int IndexCount { get; set; } public TextureAtlasManager Atlas { get; set; } = null!; public int TextureIndex { get; set; } public (int Width, int Height) TextureSize { get; set; } public TextureFormat TextureFormat { get; set; } 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; } // Modern rendering path fields public uint FirstIndex { get; set; } public uint BaseVertex { get; set; } public ulong BindlessTextureHandle { get; set; } } /// /// Manages scenery mesh loading, GPU resource creation, and reference counting. /// Key design: mesh data is prepared on background threads via PrepareMeshData(), /// then GPU resources are created on the main thread via UploadMeshData(). /// public class ObjectMeshManager : IDisposable { private readonly OpenGLGraphicsDevice _graphicsDevice; private readonly IPreparedAssetSource _preparedAssets; private readonly ILogger _logger; /// /// 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. /// public bool IsDisposed { get; private set; } private readonly object _disposeGate = new(); private bool _disposeCompleted; private bool _disposeRunning; private bool _workersQuiesced; private bool _workSignalDisposed; private readonly ConcurrentDictionary _renderData = new(); // Bumped whenever TryGetRenderData's answer can change for any id // (publish, pending-release hide, release completion, teardown). // EnvCellRenderer's prepare gate keys its visibility snapshot on this: // the snapshot bakes per-cell transparency from TryGetRenderData, so a // skipped rebuild must be provably input-identical. private long _renderDataAvailabilityVersion; // A render-data entry remains published until every one of its physical // resources has either released or reported a committed exceptional // outcome. Accessors hide entries in this map because a partially // retired mesh is no longer drawable, while retaining the entry keeps // the unfinished resources reachable for an exact later retry. private readonly Dictionary _objectReleases = new(); private readonly Queue _objectReleaseQueue = new(); // Failed upload rollback owns resources which were never published. // Keep its per-resource ledger by object id so the bounded upload retry // cannot allocate another copy until the prior rollback converges. private readonly Dictionary _uploadRollbacks = new(); private readonly Queue _uploadRollbackQueue = new(); private readonly MeshOwnershipCounter _ownership = new(); private readonly ConcurrentDictionary> _preparationTasks = new(); // LRU Cache for Unused objects private readonly LinkedList _lruList = new(); private readonly long _maxGpuMemory; private readonly int _maxCachedObjects; private long _currentNonArenaGpuMemory; // Shared atlases grouped by (Width, Height, Format) private readonly Dictionary<(int Width, int Height, TextureFormat Format), List> _globalAtlases = new(); // Render-thread-owned set of arrays whose base layer changed since the // last flush. Walking every atlas every frame (and after every uploaded // object) made steady-state CPU cost grow with every area ever visited. private readonly HashSet _dirtyAtlases = new(); private long _atlasUseSequence; private const long RetainedEmptyAtlasBudgetBytes = 64L * 1024 * 1024; private const int RetainedEmptyAtlasCountLimit = 32; private readonly AcDream.App.Rendering.BoundedUnownedResourceCache _safeEmptyAtlases = new(RetainedEmptyAtlasBudgetBytes, RetainedEmptyAtlasCountLimit); // Arrays removed from the reusable atlas families remain physical GPU // allocations until their frame-fenced release completes. Retaining // the owners here makes that overlap both retryable and observable. private readonly List _retiringAtlases = []; // CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT) private readonly CpuMeshUploadCache _cpuMeshCache; private readonly MeshUploadStagingQueue _stagedMeshData; private volatile bool _arenaBackpressured; /// #125: how many times a failed GL upload is re-staged before /// giving up loudly. Small — a transient GL error clears on the next /// frame; anything that fails this many times is a genuine defect to /// surface, not retry forever. See . public const int MaxUploadRetries = 3; /// /// #125: drain one staged upload, returning whether it should be /// re-staged for a later frame. The caller (the per-frame Tick drain) /// collects the re-stages and re-enqueues them AFTER the drain loop — /// never inside it — so a deterministic failure can't spin the queue in /// a single frame. increments the mesh /// data's own counter only when new upload work actually starts (not /// while a prior rollback waits); this drain gives up loudly past /// . /// internal bool UploadOrRequeue(MeshUploadQueueItem item) { ObjectMeshData meshData = item.Data; if (!_ownership.IsOwned(meshData.ObjectId)) { _stagedMeshData.CompleteOrRestageIfOwned(item, _ownership); return false; } if (UploadMeshData(meshData) is not null) { _stagedMeshData.Complete(item); return false; // success (incl. legitimate 0-vertex → empty render data) } if (HasRenderData(meshData.ObjectId)) { _stagedMeshData.Complete(item); return false; // raced to present by another path } if (_objectReleases.ContainsKey(meshData.ObjectId) || _uploadRollbacks.ContainsKey(meshData.ObjectId)) { // Cleanup is a separately owned transaction, not another GL // upload attempt. Keep this generation staged while its exact // old resources retry one bounded pass per frame. return true; } if (meshData.UploadAttempts < MaxUploadRetries) return true; // re-stage for next frame _stagedMeshData.Complete(item); Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)"); return false; } internal bool TryDequeueStagedMeshData(out MeshUploadQueueItem item) => _stagedMeshData.TryDequeue(out item); internal bool TryPeekStagedMeshData(out MeshUploadQueueItem item) => _stagedMeshData.TryPeek(out item); internal int StagedMeshCount => _stagedMeshData.ClaimCount; internal long StagedMeshBytes => _stagedMeshData.ClaimedBytes; internal bool StagingAtHighWater => _stagedMeshData.IsAtHighWater; internal int DiscardUnownedStagedPrefix(int maximum) => _stagedMeshData.DiscardUnownedPrefix(_ownership, maximum); internal bool IsOwned(ulong id) => _ownership.IsOwned(id); internal void RequeueStagedMeshData(MeshUploadQueueItem item) => _stagedMeshData.Requeue(item); internal void RejectUnsupportedStagedUpload( MeshUploadQueueItem item, NotSupportedException error) { ArgumentNullException.ThrowIfNull(error); _stagedMeshData.Complete(item); lock (_pendingRequests) _terminalPreparationFailures.Add(item.Data.ObjectId); _logger.LogError( error, "Mesh 0x{Id:X10} generation {Generation} exceeds an explicit GPU upload limit", item.Data.ObjectId, item.Generation); } internal void SetArenaBackpressure(bool enabled) { _arenaBackpressured = enabled; if (!enabled) ResumePreparationWorkers(); } public GlobalMeshBuffer? GlobalBuffer { get; } private readonly bool _useModernRendering; internal (int RenderData, int AtlasArrays, int UnusedLru, long EstimatedBytes) Diagnostics { get { int atlasArrays = 0; foreach (List atlases in _globalAtlases.Values) atlasArrays += atlases.Count; long atlasBytes = CalculateAtlasBytes(_globalAtlases.Values); long retiringAtlasBytes = CalculateAtlasBytes(_retiringAtlases); long physicalBytes = CalculateTrackedGpuBytes( checked( _currentNonArenaGpuMemory + atlasBytes + retiringAtlasBytes), GlobalBuffer?.PhysicalCapacityBytes ?? 0); return (_renderData.Count, atlasArrays, _lruList.Count, physicalBytes); } } internal (int Count, long Bytes) CpuCacheDiagnostics => (_cpuMeshCache.Count, _cpuMeshCache.ResidentBytes); /// CPU prepared-mesh cache hit/miss/eviction counts (2026-07-24 measurement-tooling review). internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats; /// Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review). internal CacheStats DecodedTextureCacheStats => _preparedAssets.DecodedTextureCacheStats; private sealed class PreparationRequest( PreparedAssetRequest asset, ObjectMeshData? cachedData, TaskCompletionSource completion, CancellationTokenSource cancellation) { private readonly object _cancellationGate = new(); private bool _cancelStarted; private bool _cancelInProgress; private bool _disposeRequested; private bool _cancellationDisposed; 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; // Cancellation callbacks are user-extensible and run synchronously. // Never invoke them while ObjectMeshManager's queue lock is held. // The small request-local protocol also prevents the worker's // terminal Dispose from racing the detached cancellation call. public void Cancel() { lock (_cancellationGate) { if (_cancellationDisposed || _cancelStarted) return; _cancelStarted = true; _cancelInProgress = true; } try { Cancellation.Cancel(); } finally { bool dispose; lock (_cancellationGate) { _cancelInProgress = false; dispose = _disposeRequested && !_cancellationDisposed; if (dispose) _cancellationDisposed = true; } if (dispose) Cancellation.Dispose(); } } public void DisposeCancellation() { lock (_cancellationGate) { if (_cancellationDisposed) return; if (_cancelInProgress) { _disposeRequested = true; return; } _cancellationDisposed = true; } Cancellation.Dispose(); } } // LIFO preserves destination locality, while the id->node index makes // release/cancellation O(1). The former List.FindIndex hot path was // O(N) for every missing-mesh lookup and became O(N^2) per frame when // portal streaming reached backpressure. private readonly LinkedList _pendingRequests = new(); private readonly Dictionary> _pendingRequestById = new(); private readonly Dictionary _activePreparationById = new(); private readonly Dictionary _envCellDescriptors = new(); private readonly HashSet _terminalPreparationFailures = new(); private readonly HashSet _workerTasks = new(); private readonly ManualResetEventSlim _preparationWorkAvailable = new(false); private const int MaxParallelLoads = 4; internal enum PreparationWorkerWakeAction { Process, ResetAndWait, Exit, } internal static PreparationWorkerWakeAction DecidePreparationWorkerWake( bool isDisposed, bool hasPendingRequests, bool stagingAtHighWater, bool arenaBackpressured) { // Shutdown has priority over every ordinary idle/backpressure state. // Dispose sets one shared manual-reset signal for all persistent // workers; no worker may reset that signal before its peers wake. if (isDisposed) return PreparationWorkerWakeAction.Exit; if (!hasPendingRequests || stagingAtHighWater || arenaBackpressured) return PreparationWorkerWakeAction.ResetAndWait; return PreparationWorkerWakeAction.Process; } private sealed class ObjectReleaseTicket( ulong id, ObjectRenderData data, long reclaimableBytes, RetryableResourceReleaseLedger resources) { public ulong Id { get; } = id; public ObjectRenderData Data { get; } = data; public long ReclaimableBytes { get; } = reclaimableBytes; public RetryableResourceReleaseLedger Resources { get; } = resources; public bool IsQueued { get; set; } } public ObjectMeshManager( OpenGLGraphicsDevice graphicsDevice, IPreparedAssetSource preparedAssets, ILogger logger, ResidencyBudgetOptions? budgets = null) { budgets ??= ResidencyBudgetOptions.Default; _graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice)); _preparedAssets = preparedAssets ?? throw new ArgumentNullException(nameof(preparedAssets)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _maxGpuMemory = budgets.ObjectMeshGpuBytes; _maxCachedObjects = budgets.ObjectMeshUnownedEntries; _cpuMeshCache = new CpuMeshUploadCache( budgets.PreparedMeshCpuEntries, budgets.PreparedMeshCpuBytes); _stagedMeshData = new MeshUploadStagingQueue( budgets.MeshStagingEntries, budgets.MeshStagingBytes); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer( _graphicsDevice.GL, _graphicsDevice.ResourceRetirement); } } /// /// Get existing GPU render data for an object, or null if not yet uploaded. /// Increments reference count. /// public ObjectRenderData? GetRenderData(ulong id) { if (!_objectReleases.ContainsKey(id) && _renderData.TryGetValue(id, out var data)) { IncrementRefCount(id); return data; } return null; } /// /// Check if GPU render data exists for an object. /// public bool HasRenderData(ulong id) => !_objectReleases.ContainsKey(id) && _renderData.ContainsKey(id); /// /// Get existing GPU render data without modifying reference count. /// Use this for render-loop lookups where you don't want to affect lifecycle. /// public ObjectRenderData? TryGetRenderData(ulong id) { return !_objectReleases.ContainsKey(id) && _renderData.TryGetValue(id, out var data) ? data : null; } /// /// Monotonic counter that changes whenever /// can answer differently for any id. Consumers that bake availability /// into a cached product (EnvCellRenderer's visibility snapshot) rebuild /// when this moves. /// public long RenderDataAvailabilityVersion => Volatile.Read(ref _renderDataAvailabilityVersion); private void MarkRenderDataAvailabilityChanged() => Interlocked.Increment(ref _renderDataAvailabilityVersion); /// /// Increment reference count for an object (e.g. when a landblock starts using it). /// public void IncrementRefCount(ulong id) { lock (_pendingRequests) { _ownership.Acquire(id); lock (_lruList) { _lruList.Remove(id); } } } public (int Arrays, long Bytes) GenerateMipmaps() { int generatedArrays = 0; long generatedBytes = 0; foreach (TextureAtlasManager atlas in _dirtyAtlases) { long bytes = atlas.TextureArray.ProcessDirtyUpdates(); if (bytes > 0) { generatedArrays++; generatedBytes = checked(generatedBytes + bytes); } } _dirtyAtlases.Clear(); return (generatedArrays, generatedBytes); } /// /// Retains a small LRU of empty arrays so recurring texture size classes /// reuse their immutable storage and resident sampler handles. /// Once that idle pool exceeds its byte/count budget, retires at most /// one GPU-safe array per frame. Logical emptiness alone is insufficient: /// every returned layer /// must first pass its frame fence. /// internal bool EvictOneEmptyAtlas() { RetryRetiringAtlasDisposals(); if (!_safeEmptyAtlases.TryTakeOldestOverBudget(out TextureAtlasManager victim)) return false; var key = (victim.Width, victim.Height, victim.Format); if (_globalAtlases.TryGetValue(key, out List? list)) { list.Remove(victim); if (list.Count == 0) _globalAtlases.Remove(key); } _dirtyAtlases.Remove(victim); _retiringAtlases.Add(victim); victim.Dispose(); RemoveCompletedAtlasRetirements(); return true; } private void RetryRetiringAtlasDisposals() { for (int i = 0; i < _retiringAtlases.Count; i++) _retiringAtlases[i].Dispose(); RemoveCompletedAtlasRetirements(); } private void RemoveCompletedAtlasRetirements() { for (int i = _retiringAtlases.Count - 1; i >= 0; i--) { if (_retiringAtlases[i].IsPhysicalRetirementComplete) _retiringAtlases.RemoveAt(i); } } private void OnAtlasGpuSafeEmpty(TextureAtlasManager atlas) { if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas)) return; _safeEmptyAtlases.MarkUnowned(atlas, atlas.AllocatedBytes); } private void MarkAtlasActive(TextureAtlasManager atlas) => _safeEmptyAtlases.MarkOwned(atlas); /// /// #105 diagnostic: counts staged-but-unflushed texture layer updates across all /// shared atlases (see ). /// Render thread only — _globalAtlases is render-thread-owned. /// public (int PendingUpdates, int ArraysWithPending, int TotalArrays) GetPendingTextureUpdateStats() { int pending = 0, arraysWith = 0, total = 0; foreach (var atlasList in _globalAtlases.Values) { foreach (var atlas in atlasList) { total++; int p = atlas.TextureArray.PendingUpdateCount; if (p > 0) { arraysWith++; pending += p; } } } return (pending, arraysWith, total); } /// /// Decrement reference count and unload GPU resources if no longer needed. /// public void DecrementRefCount(ulong id) { (PreparationRequest? Pending, PreparationRequest? Active) canceled = default; bool finalOwner; lock (_pendingRequests) { finalOwner = _ownership.Count(id) <= 1; if (finalOwner) canceled = DetachPendingPreparationLocked(id); } // Cancellation callbacks are arbitrary synchronous code and can // throw. Run them before committing the reference decrement. A // failure therefore leaves ownership unchanged and makes the same // DecrementRefCount call safe to retry; the detached cancellation // protocol is idempotent for that retry. if (finalOwner) CancelDetachedPreparation(canceled); lock (_pendingRequests) { int newCount = _ownership.Release(id); if (newCount > 0) return; _envCellDescriptors.Remove(id); _terminalPreparationFailures.Remove(id); if (_renderData.ContainsKey(id)) { // Instead of unloading, move resident data to LRU. lock (_lruList) { _lruList.Remove(id); _lruList.AddLast(id); } } else { _ownership.Remove(id); } } } /// /// Decrement reference count and unload if no longer needed. /// public void ReleaseRenderData(ulong id) { (PreparationRequest? Pending, PreparationRequest? Active) canceled = default; lock (_pendingRequests) { if (_ownership.IsOwned(id)) { var newCount = _ownership.Release(id); if (newCount <= 0) { _envCellDescriptors.Remove(id); _terminalPreparationFailures.Remove(id); canceled = DetachPendingPreparationLocked(id); if (_renderData.ContainsKey(id)) { lock (_lruList) { _lruList.Remove(id); _lruList.AddLast(id); } } else { _ownership.Remove(id); } } } } CancelDetachedPreparation(canceled); } internal (int Count, long Bytes) ReclaimUnusedResources( int maximumCount, long maximumBytes, bool forceArenaReclamation = false) { ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1); ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); RetryRetiringAtlasDisposals(); RetryPendingAtlasRetirements(); // Rollbacks own unpublished resources and therefore have no LRU // node to wake them. Advance a bounded snapshot every render tick // even if the requesting owner disappeared after the upload failed. AdvancePendingUploadRollbacks(maximumCount); (int reclaimedCount, long reclaimedBytes) = AdvancePendingObjectReleases(maximumCount, maximumBytes); int candidateBudget; lock (_lruList) candidateBudget = _lruList.Count; int attemptedCandidates = 0; long trackedAtlasBytes = checked( CalculateAtlasBytes(_globalAtlases.Values) + CalculateAtlasBytes(_retiringAtlases)); while (reclaimedCount < maximumCount && attemptedCandidates < candidateBudget) { ulong idToEvict; lock (_lruList) { long physicalArenaBytes = GlobalBuffer?.PhysicalCapacityBytes ?? 0; long nonArenaAndAtlasBytes = checked( _currentNonArenaGpuMemory + trackedAtlasBytes); if (!forceArenaReclamation && IsWithinGpuCacheBudget( nonArenaAndAtlasBytes, physicalArenaBytes, _maxGpuMemory) && _lruList.Count <= _maxCachedObjects) { break; } // Stale owned nodes are bookkeeping-only and safe to discard // while searching. Real destruction is bounded independently // by both object count and bytes so admission of up to eight // meshes cannot outrun a one-object reclamation service. if (_lruList.Count == 0) break; idToEvict = _lruList.First!.Value; _lruList.RemoveFirst(); } attemptedCandidates++; lock (_pendingRequests) { if (!_ownership.IsOwned(idToEvict)) { long bytes = GetObjectReclaimableBytes(idToEvict); if (!FitsReclamationBudget(bytes, reclaimedBytes, maximumBytes)) { lock (_lruList) _lruList.AddLast(idToEvict); // This candidate is indivisible within the current // byte allowance, but a smaller later object may // still fit. Rotate it and inspect each original // LRU node at most once so one large mesh cannot // starve all reclamation forever. continue; } if (TryAdvanceObjectRelease(idToEvict, out long completedBytes)) { reclaimedBytes = checked(reclaimedBytes + completedBytes); reclaimedCount++; } // An unfinished release moves from the ordinary LRU to // _objectReleases. That dedicated queue advances once // at the start of a later frame, including when a new // logical owner acquired the id in the meantime. } } } return (reclaimedCount, reclaimedBytes); } internal static bool FitsReclamationBudget( long candidateBytes, long alreadyReclaimedBytes, long maximumBytes) { ArgumentOutOfRangeException.ThrowIfNegative(candidateBytes); ArgumentOutOfRangeException.ThrowIfNegative(alreadyReclaimedBytes); ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); return candidateBytes <= maximumBytes - Math.Min(alreadyReclaimedBytes, maximumBytes); } private void RetryPendingAtlasRetirements() { List? failures = null; foreach (List atlases in _globalAtlases.Values) { for (int i = 0; i < atlases.Count; i++) { try { atlases[i].RetryPendingRetirements(); } catch (Exception error) { (failures ??= []).Add(error); } } } if (failures is not null) { throw new AggregateException( "One or more texture-atlas layer retirements could not be published.", failures); } } internal bool EvictOneOldResource() => ReclaimUnusedResources(1, long.MaxValue).Count != 0; private long GetReclaimableBytes(ObjectRenderData data) { if (_useModernRendering && data.GlobalAllocation is { } allocation) { return checked( (long)allocation.Vertices.Length * VertexPositionNormalTexture.Size + (long)allocation.Indices.Length * sizeof(ushort)); } return Math.Max(0, data.NonArenaGpuBytes); } /// /// Force evict all unused objects from the cache. /// Use this when navigating away from a view or changing filters to free memory. /// public void EvictAllUnused() { AdvancePendingUploadRollbacks(Math.Max(1, _uploadRollbackQueue.Count)); AdvancePendingObjectReleases( Math.Max(1, _objectReleaseQueue.Count), long.MaxValue); int candidateBudget; lock (_lruList) candidateBudget = _lruList.Count; for (int attempted = 0; attempted < candidateBudget; attempted++) { ulong idToEvict; lock (_lruList) { if (_lruList.Count == 0) break; idToEvict = _lruList.First!.Value; _lruList.RemoveFirst(); } lock (_pendingRequests) { if (!_ownership.IsOwned(idToEvict)) { TryAdvanceObjectRelease(idToEvict, out _); } } } // Also clear CPU mesh cache _cpuMeshCache.Clear(); } public struct EnvCellGeomRequest { public uint SourceCellId; public uint EnvironmentId; public ushort CellStructure; public List Surfaces; } /// /// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry. /// 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 }; lock (_pendingRequests) { _envCellDescriptors[geomId] = envCell; // An explicit schema-bearing schedule is a new opportunity to // prepare this immutable DAT object (for example, a later // landblock sharing geometry after an earlier failure). _terminalPreparationFailures.Remove(geomId); } ObjectMeshData? deferredCachedData = null; if (_cpuMeshCache.TryGetAndStage( geomId, _stagedMeshData, out ObjectMeshData? cachedData, out MeshStageResult cacheStage)) { if (cacheStage != MeshStageResult.HighWater) return Task.FromResult(cachedData); deferredCachedData = cachedData; } lock (_pendingRequests) { if (_preparationTasks.TryGetValue(geomId, out Task? existing) && !existing.IsFaulted && !existing.IsCanceled) { bool canceledActiveGeneration = _activePreparationById.TryGetValue(geomId, out PreparationRequest? active) && active.Cancellation.IsCancellationRequested && ReferenceEquals(existing, active.Completion.Task); if (!canceledActiveGeneration) return existing; } _preparationTasks.TryRemove(geomId, out _); var tcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); Task task = tcs.Task; if (IsDisposed) { tcs.TrySetCanceled(); return task; } var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); _preparationTasks[geomId] = task; var request = new PreparationRequest( PreparedAssetRequest.EnvCellGeometry( sourceCellId, geomId, environmentId, cellStructure, surfaces), deferredCachedData, tcs, cancellation); _pendingRequestById.Add(geomId, _pendingRequests.AddLast(request)); StartPreparationWorkersLocked(); return task; } } public Task PrepareMeshDataAsync(ulong id, bool isSetup, CancellationToken ct = default) { if (IsDisposed || HasRenderData(id)) return Task.FromResult(null); lock (_pendingRequests) _terminalPreparationFailures.Remove(id); ObjectMeshData? deferredCachedData = null; if (_cpuMeshCache.TryGetAndStage( id, _stagedMeshData, out ObjectMeshData? cachedData, out MeshStageResult cacheStage)) { if (cacheStage != MeshStageResult.HighWater) return Task.FromResult(cachedData); deferredCachedData = cachedData; } lock (_pendingRequests) { if (_preparationTasks.TryGetValue(id, out Task? existing) && !existing.IsFaulted && !existing.IsCanceled) { bool canceledActiveGeneration = _activePreparationById.TryGetValue(id, out PreparationRequest? active) && active.Cancellation.IsCancellationRequested && ReferenceEquals(existing, active.Completion.Task); if (!canceledActiveGeneration) return existing; } _preparationTasks.TryRemove(id, out _); var tcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); Task task = tcs.Task; if (IsDisposed) { tcs.TrySetCanceled(); return task; } var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); _preparationTasks[id] = task; 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( asset, deferredCachedData, tcs, cancellation); _pendingRequestById.Add(id, _pendingRequests.AddLast(request)); StartPreparationWorkersLocked(); return task; } } private void ProcessQueue() { while (true) { _preparationWorkAvailable.Wait(); PreparationRequest request; lock (_pendingRequests) { // IsDisposed re-check lets Dispose cancel and join every // 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 // forever and graceful client shutdown deadlocked. PreparationWorkerWakeAction wakeAction = DecidePreparationWorkerWake( IsDisposed, _pendingRequests.Count != 0, _stagedMeshData.IsAtHighWater, _arenaBackpressured); if (wakeAction == PreparationWorkerWakeAction.Exit) return; if (wakeAction == PreparationWorkerWakeAction.ResetAndWait) { _preparationWorkAvailable.Reset(); continue; } // LIFO: pick the most recently requested destination // mesh without scanning/reordering the whole queue. LinkedListNode? node = _pendingRequests.Last; while (node is not null && _activePreparationById.ContainsKey(node.Value.Id)) node = node.Previous; if (node is null) { _preparationWorkAvailable.Reset(); continue; } request = node.Value; _pendingRequests.Remove(node); _pendingRequestById.Remove(request.Id); _activePreparationById.Add(request.Id, request); } ulong id = request.Id; TaskCompletionSource tcs = request.Completion; CancellationToken ct = request.Cancellation.Token; ObjectMeshData? completionResult = null; Exception? completionError = null; bool completionCanceled = false; CancellationToken completionCancellation = ct; try { if (ct.IsCancellationRequested) { completionCanceled = true; } else { ObjectMeshData? data = request.CachedData; if (data is null) { PreparedAssetReadResult read = _preparedAssets.Read(request.Asset, ct); data = read.Data; if (read.Status != PreparedAssetReadStatus.Loaded) { _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); } } if (ct.IsCancellationRequested) { completionCanceled = true; } else if (data != null) { // Preserve completed work in the bounded CPU // cache even if its last owner disappeared // during this at-most-four-worker decode. _cpuMeshCache.Store(data); } if (!completionCanceled && data != null && _ownership.IsOwned(id)) { // A decoder that started before the watermark may // finish after it. Keep its immutable payload in // the bounded CPU cache above, but do not let four // concurrent workers punch an unbounded byte hole // through the staging queue. Point-of-use rearming // stages the cache entry once consumer space exists. _stagedMeshData.TryStage(data); } if (!completionCanceled) completionResult = data; } } catch (OperationCanceledException ex) { completionCanceled = true; completionCancellation = ex.CancellationToken; } catch (Exception ex) { _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X8}", id); completionError = ex; } finally { lock (_pendingRequests) { if (_activePreparationById.TryGetValue(id, out PreparationRequest? active) && ReferenceEquals(active, request)) { _activePreparationById.Remove(id); } // Publish only after removing this generation from // the active map. A replacement can never race into // an id still occupied by the terminal generation. if (completionError is not null) tcs.TrySetException(completionError); else if (completionCanceled) tcs.TrySetCanceled(completionCancellation.IsCancellationRequested ? completionCancellation : default); else tcs.TrySetResult(completionResult); if (_preparationTasks.TryGetValue(id, out Task? current) && ReferenceEquals(current, tcs.Task)) { _preparationTasks.TryRemove(id, out _); } if (!completionCanceled && (completionError is not null || completionResult is null)) _terminalPreparationFailures.Add(id); else if (completionResult is not null) _terminalPreparationFailures.Remove(id); if (!IsDisposed && _pendingRequests.Count != 0 && !_stagedMeshData.IsAtHighWater && !_arenaBackpressured) { _preparationWorkAvailable.Set(); } } request.DisposeCancellation(); } } } private void StartPreparationWorkersLocked() { if (IsDisposed) return; // Keep a fixed, sleeping worker set once preparation begins. The // former high-water path destroyed and recreated up to four // Task.Run workers on every upload/drain cycle during portals, // producing needless thread-pool and GC churn. while (_workerTasks.Count < MaxParallelLoads) { Task worker = Task.Run(ProcessQueue); _workerTasks.Add(worker); _ = worker.ContinueWith( OnPreparationWorkerCompleted, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } if (_pendingRequests.Count != 0 && !_stagedMeshData.IsAtHighWater && !_arenaBackpressured) _preparationWorkAvailable.Set(); } private void OnPreparationWorkerCompleted(Task worker) { if (worker.IsFaulted) _logger.LogError(worker.Exception, "Mesh preparation worker terminated unexpectedly."); lock (_pendingRequests) { _workerTasks.Remove(worker); StartPreparationWorkersLocked(); } } internal void ResumePreparationWorkers() { lock (_pendingRequests) StartPreparationWorkersLocked(); } /// /// 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 prepared-payload failure is retained until the next explicit /// ownership schedule instead of being retried every render frame. /// internal bool EnsureRenderDataReady(ulong id) { if (HasRenderData(id)) return true; EnvCellGeomRequest? envCell; lock (_pendingRequests) { if (IsDisposed || !_ownership.IsOwned(id) || _terminalPreparationFailures.Contains(id)) { return false; } envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor) ? descriptor : null; } if (envCell is { } req) { _ = PrepareEnvCellGeomMeshDataAsync( id, req.SourceCellId, req.EnvironmentId, req.CellStructure, req.Surfaces); } else { _ = PrepareMeshDataAsync(id, isSetup: false); } return false; } /// /// 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) { PreparedAssetRequest request = isSetup ? PreparedAssetRequest.Setup(checked((uint)id)) : PreparedAssetRequest.GfxObj(checked((uint)id)); return _preparedAssets.Read(request, ct).Data; } internal ResidencyDomainSnapshot CaptureObjectMeshResidency() { GlobalMeshBuffer? arena = GlobalBuffer; long nonArenaRetiring = 0; foreach (ObjectReleaseTicket ticket in _objectReleases.Values) { nonArenaRetiring = checked( nonArenaRetiring + Math.Max(0, ticket.Data.NonArenaGpuBytes)); } nonArenaRetiring = Math.Min( nonArenaRetiring, _currentNonArenaGpuMemory); long arenaResident = arena?.ResidentCapacityBytes ?? 0; long arenaRequested = arena?.RequestedMigrationBytes ?? 0; long arenaRetiring = checked( (arena?.PendingRangeRetirementBytes ?? 0) + (arena?.RetiredBackingBytes ?? 0)); long atlasResident = CalculateAtlasBytes(_globalAtlases.Values); long atlasRetiring = CalculateAtlasBytes(_retiringAtlases); return new ResidencyDomainSnapshot( ResidencyDomain.ObjectMeshes, EntryCount: checked( _renderData.Count + _globalAtlases.Values.Sum(static atlases => atlases.Count) + _retiringAtlases.Count), OwnerCount: _ownership.TotalReferenceCount, Charges: new ResidencyCharges( GpuRequestedBytes: arenaRequested, GpuResidentBytes: checked( _currentNonArenaGpuMemory - nonArenaRetiring + arenaResident + atlasResident), RetiringBytes: checked( nonArenaRetiring + arenaRetiring + atlasRetiring)), BudgetBytes: _maxGpuMemory); } internal static long CalculateAtlasBytes( IEnumerable> atlasFamilies) { ArgumentNullException.ThrowIfNull(atlasFamilies); long bytes = 0; foreach (IReadOnlyCollection family in atlasFamilies) { foreach (TextureAtlasManager atlas in family) bytes = checked(bytes + atlas.AllocatedBytes); } return bytes; } internal static long CalculateAtlasBytes( IEnumerable atlases) { ArgumentNullException.ThrowIfNull(atlases); long bytes = 0; foreach (TextureAtlasManager atlas in atlases) bytes = checked(bytes + atlas.AllocatedBytes); return bytes; } internal ResidencyDomainSnapshot CapturePreparedMeshResidency() => new( ResidencyDomain.PreparedMeshCpu, EntryCount: _cpuMeshCache.Count, OwnerCount: 0, Charges: new ResidencyCharges( CpuPreparedBytes: _cpuMeshCache.ResidentBytes), BudgetBytes: _cpuMeshCache.ByteCapacity); internal ResidencyDomainSnapshot CaptureStagingResidency() => new( ResidencyDomain.MeshStaging, EntryCount: _stagedMeshData.ClaimCount, OwnerCount: 0, Charges: new ResidencyCharges( StagingBytes: _stagedMeshData.ClaimedBytes), BudgetBytes: _stagedMeshData.MaximumBytes); internal ResidencyDomainSnapshot CaptureGlobalArenaResidency() { GlobalMeshBuffer? arena = GlobalBuffer; return new ResidencyDomainSnapshot( ResidencyDomain.GlobalMeshArena, EntryCount: arena is null ? 0 : 2, OwnerCount: 0, // Physical arena bytes are already charged once by the // ObjectMeshes aggregate. This row exposes allocator facts. Charges: ResidencyCharges.Zero, BudgetBytes: GlobalMeshBuffer.MaximumPhysicalArenaBytes, CapacityBytes: arena?.CapacityBytes ?? 0, UsedBytes: arena?.UsedBytes ?? 0, LargestFreeBytes: arena?.LargestFreeBytes ?? 0); } /// /// Cancel preparation tasks for IDs that are no longer needed. /// public void CancelStagedUploads(IEnumerable ids) { foreach (ulong id in ids) CancelPendingPreparation(id); } private void CancelPendingPreparation(ulong id) { (PreparationRequest? Pending, PreparationRequest? Active) canceled; lock (_pendingRequests) canceled = DetachPendingPreparationLocked(id); CancelDetachedPreparation(canceled); } private (PreparationRequest? Pending, PreparationRequest? Active) DetachPendingPreparationLocked(ulong id) { PreparationRequest? pending = null; if (_pendingRequestById.Remove(id, out LinkedListNode? node)) { _pendingRequests.Remove(node); pending = node.Value; if (_preparationTasks.TryGetValue(id, out Task? current) && ReferenceEquals(current, pending.Completion.Task)) { _preparationTasks.TryRemove(id, out _); } } // Stop obsolete destination work as soon as its final owner leaves. // A same-id reacquisition sees this still-active task until its // terminal publication, then the point-of-use retry starts a fresh // generation; no replacement can collide in the active map. _activePreparationById.TryGetValue(id, out PreparationRequest? active); return (pending, active); } private static void CancelDetachedPreparation( (PreparationRequest? Pending, PreparationRequest? Active) canceled) { List? failures = null; void Attempt(Action action) { try { action(); } catch (Exception ex) { (failures ??= []).Add(ex); } } if (canceled.Pending is { } pending) { Attempt(pending.Cancel); pending.Completion.TrySetCanceled(pending.Cancellation.Token); Attempt(pending.DisposeCancellation); } if (canceled.Active is { } active) Attempt(active.Cancel); if (failures is not null) throw new AggregateException("Mesh preparation cancellation failed.", failures); } /// /// Phase 2 (Main Thread): Upload prepared mesh data to GPU. /// Creates VAO, VBO, IBOs, and texture arrays. /// Must be called from the GL thread. /// public ObjectRenderData? UploadMeshData(ObjectMeshData meshData) { bool uploadAttempted = false; try { // A failed eviction or failed rollback owns physical resources // for this id. Resume those exact ledgers before admitting a // retry; allocating another copy here would compound the leak. if (_objectReleases.ContainsKey(meshData.ObjectId) && !TryAdvanceObjectRelease(meshData.ObjectId, out _)) { return null; } if (!TryAdvanceUploadRollback(meshData.ObjectId)) return null; if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) { UpdateLruAfterUpload(meshData.ObjectId); return existing; } uploadAttempted = true; if (meshData.IsSetup) { // Upload EnvCell geometry if present to ensure it's in _renderData if (meshData.EnvCellGeometry != null) { if (UploadMeshData(meshData.EnvCellGeometry) is null) { throw new InvalidOperationException( $"Nested EnvCell geometry 0x{meshData.EnvCellGeometry.ObjectId:X10} " + $"failed while uploading setup 0x{meshData.ObjectId:X10}."); } } // Setup objects are multi-part - each part needs its own render data var data = new ObjectRenderData { IsSetup = true, SetupParts = meshData.SetupParts, ParticleEmitters = meshData.ParticleEmitters, Batches = new List(), BoundingBox = meshData.BoundingBox, SortCenter = meshData.SortCenter, DIDDegrade = meshData.DIDDegrade, SelectionSphere = meshData.SelectionSphere, MemorySize = 1024 // Small overhead for the setup itself }; var acquiredParts = new List(meshData.SetupParts.Count); try { // Acquire and schedule every dependency before publishing // the setup. A partial schedule must not become a sticky, // permanently incomplete render-data cache entry. foreach (var (partId, _) in meshData.SetupParts) { IncrementRefCount(partId); acquiredParts.Add(partId); _ = PrepareMeshDataAsync(partId, isSetup: false); } if (!_renderData.TryAdd(meshData.ObjectId, data)) throw new InvalidOperationException( $"Setup 0x{meshData.ObjectId:X10} was published concurrently."); MarkRenderDataAvailabilityChanged(); _currentNonArenaGpuMemory = checked( _currentNonArenaGpuMemory + data.NonArenaGpuBytes); } catch (Exception setupFailure) { RetryableResourceReleaseLedger rollback = CreateSetupPartRollback(acquiredParts, DecrementRefCount); ResourceReleaseAttempt attempt = rollback.Advance(); if (!rollback.IsComplete) { _uploadRollbacks[meshData.ObjectId] = rollback; _uploadRollbackQueue.Enqueue(meshData.ObjectId); } if (!attempt.HasFailures) throw; throw new AggregateException( $"Setup 0x{meshData.ObjectId:X10} upload and dependency rollback failed.", setupFailure, attempt.ToException( $"Setup 0x{meshData.ObjectId:X10} retained unfinished part references.")); } UpdateLruAfterUpload(meshData.ObjectId); return data; } var renderData = UploadGfxObjMeshData(meshData); if (renderData == null) { // 0-vertex mesh: every polygon was gated out at extraction. #119 // (2026-06-11) dat-verified this is LEGITIMATE for all-no-draw // models (all polys NoPos + Base1Solid surfaces — retail's // skipNoTexture never draws them either; 0x010002B4/0x010008A8 // are this class, Issue119UpNullGfxObjDumpTests). The empty // cache is the correct terminal state for those. The line stays // as a tripwire for the OTHER way to get here (extraction // dropped textured polys — a real defect; dat-verify with the // dump test before treating as one). Console.WriteLine($"[up-null] 0x{meshData.ObjectId:X10} produced a 0-vertex mesh — caching empty render data (legitimate for all-no-draw models; dat-verify via Issue119UpNullGfxObjDumpTests)"); renderData = new ObjectRenderData(); } renderData.BoundingBox = meshData.BoundingBox; renderData.SortCenter = meshData.SortCenter; renderData.DIDDegrade = meshData.DIDDegrade; renderData.SelectionSphere = meshData.SelectionSphere; _renderData.TryAdd(meshData.ObjectId, renderData); MarkRenderDataAvailabilityChanged(); _currentNonArenaGpuMemory = checked( _currentNonArenaGpuMemory + renderData.NonArenaGpuBytes); UpdateLruAfterUpload(meshData.ObjectId); // Keep the bounded CPU cache's texture payload intact. GPU LRU // eviction may need to upload this same prepared mesh again; // clearing these bytes made a cache hit produce blank textures. return renderData; } catch (Exception ex) { if (uploadAttempted) meshData.UploadAttempts++; _logger.LogError(ex, "Error uploading mesh data for 0x{Id:X8}", meshData.ObjectId); return null; } } /// /// Conservative main-thread upload work estimate used by the per-frame /// staging budget. Texture bytes are intentionally counted even when a /// shared atlas may deduplicate them; overestimating delays work by one /// frame, whereas underestimating can recreate the destination spike. /// internal static long EstimateUploadBytes(ObjectMeshData meshData) { ArgumentNullException.ThrowIfNull(meshData); return meshData.GetEstimatedUploadBytes(); } internal static long CalculateNonArenaGeometryBytes( bool usesGlobalArena, long geometryBytes) { ArgumentOutOfRangeException.ThrowIfNegative(geometryBytes); return usesGlobalArena ? 0 : geometryBytes; } internal static long CalculateTrackedGpuBytes( long nonArenaBytes, long physicalArenaBytes) { ArgumentOutOfRangeException.ThrowIfNegative(nonArenaBytes); ArgumentOutOfRangeException.ThrowIfNegative(physicalArenaBytes); return checked(nonArenaBytes + physicalArenaBytes); } internal static bool IsWithinGpuCacheBudget( long nonArenaBytes, long physicalArenaBytes, long maximumBytes) { ArgumentOutOfRangeException.ThrowIfNegative(nonArenaBytes); ArgumentOutOfRangeException.ThrowIfNegative(physicalArenaBytes); ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); return nonArenaBytes <= maximumBytes - Math.Min(maximumBytes, physicalArenaBytes); } private sealed class UploadAtlasPlan { public TextureAtlasManager? Existing { get; init; } public required int Capacity { get; init; } public required long TotalArrayBytes { get; init; } public int AvailableSlots { get; set; } public bool Touched { get; set; } public HashSet PlannedKeys { get; } = new(); public bool HasTexture(TextureKey key) => PlannedKeys.Contains(key) || Existing?.HasTexture(key) == true; } /// /// Plans the actual GL work the next object would trigger against the /// current atlas inventory. This includes array storage, global-buffer /// growth/copies, and one full mip generation per newly-dirtied array—not merely the /// source byte arrays held by ObjectMeshData. /// internal MeshUploadCost PlanUploadCost( ObjectMeshData meshData, IReadOnlySet mipmapsAlreadyBudgeted, ulong queueGeneration) { ArgumentNullException.ThrowIfNull(meshData); ArgumentNullException.ThrowIfNull(mipmapsAlreadyBudgeted); if (HasRenderData(meshData.ObjectId)) return default; var plans = new Dictionary< (int Width, int Height, TextureFormat Format), List>(); long arrayAllocationBytes = 0; long mipmapBytes = 0; int newArrayCount = 0; PlanTextureWork( meshData, plans, mipmapsAlreadyBudgeted, ref arrayAllocationBytes, ref mipmapBytes, ref newArrayCount); GlobalMeshUploadPlan bufferPlan = PlanGlobalBufferWork(meshData); return new MeshUploadCost( EstimateUploadBytes(meshData), arrayAllocationBytes, mipmapBytes, newArrayCount, bufferPlan.UploadBytes, bufferPlan.AllocationBytes, bufferPlan.CopyBytes, bufferPlan.NewBufferCount, queueGeneration); } private GlobalMeshUploadPlan PlanGlobalBufferWork(ObjectMeshData meshData) { (int vertexCount, int indexCount) = GetGlobalMeshElementCounts(meshData); if (vertexCount == 0 || indexCount == 0 || GlobalBuffer is null) return default; return GlobalBuffer.PlanUpload(vertexCount, indexCount); } internal GlobalMeshCapacityResult EnsureGlobalBufferCapacity( MeshUploadQueueItem item, out GlobalMeshMaintenanceStep step) { if (GlobalBuffer is null) { step = default; return GlobalMeshCapacityResult.Ready; } (int vertexCount, int indexCount) = GetGlobalMeshElementCounts(item.Data); return GlobalBuffer.EnsureUploadCapacity(vertexCount, indexCount, out step); } private (int Vertices, int Indices) GetGlobalMeshElementCounts(ObjectMeshData meshData) { if (meshData.IsSetup) { return meshData.EnvCellGeometry is { } nested && !HasRenderData(nested.ObjectId) ? GetGlobalMeshElementCounts(nested) : default; } if (meshData.Vertices.Length == 0) return default; int indexCount = 0; foreach (List batches in meshData.TextureBatches.Values) { foreach (TextureBatchData batch in batches) indexCount = checked(indexCount + batch.Indices.Count); } return (meshData.Vertices.Length, indexCount); } private void PlanTextureWork( ObjectMeshData meshData, Dictionary<(int Width, int Height, TextureFormat Format), List> plans, IReadOnlySet mipmapsAlreadyBudgeted, ref long arrayAllocationBytes, ref long mipmapBytes, ref int newArrayCount) { if (meshData.IsSetup) { if (meshData.EnvCellGeometry is { } nested && !HasRenderData(nested.ObjectId)) { PlanTextureWork( nested, plans, mipmapsAlreadyBudgeted, ref arrayAllocationBytes, ref mipmapBytes, ref newArrayCount); } return; } if (meshData.Vertices.Length == 0) return; foreach (var (format, batches) in meshData.TextureBatches) { if (!plans.TryGetValue(format, out List? atlasPlans)) { atlasPlans = new List(); if (_globalAtlases.TryGetValue(format, out List? existingAtlases)) { foreach (TextureAtlasManager existing in existingAtlases) { atlasPlans.Add(new UploadAtlasPlan { Existing = existing, Capacity = existing.TotalSlots, AvailableSlots = existing.AvailableSlots, TotalArrayBytes = existing.TextureArray.TotalSizeInBytes, }); } } plans.Add(format, atlasPlans); } foreach (TextureBatchData batch in batches) { if (batch.Indices.Count == 0) continue; UploadAtlasPlan? selected = null; for (int i = 0; i < atlasPlans.Count; i++) { UploadAtlasPlan candidate = atlasPlans[i]; if (candidate.HasTexture(batch.Key)) { selected = candidate; break; } } if (selected is null) { for (int i = 0; i < atlasPlans.Count; i++) { UploadAtlasPlan candidate = atlasPlans[i]; if (candidate.AvailableSlots > 0) { selected = candidate; break; } } } if (selected is null) { int capacity = TextureAtlasManager.CalculateInitialCapacity( format.Width, format.Height, format.Format); long totalArrayBytes = TextureAtlasManager.CalculateArrayBytes( format.Width, format.Height, format.Format); selected = new UploadAtlasPlan { Capacity = capacity, AvailableSlots = capacity, TotalArrayBytes = totalArrayBytes, }; atlasPlans.Add(selected); arrayAllocationBytes = checked(arrayAllocationBytes + totalArrayBytes); newArrayCount++; } if (selected.HasTexture(batch.Key)) continue; selected.PlannedKeys.Add(batch.Key); selected.AvailableSlots--; if (!selected.Touched) { bool mipAlreadyBudgeted = selected.Existing is not null && mipmapsAlreadyBudgeted.Contains(selected.Existing); if (!mipAlreadyBudgeted) mipmapBytes = checked(mipmapBytes + selected.TotalArrayBytes); selected.Touched = true; } } } } internal static MeshUploadCost CalculateNewAtlasFirstUploadCost( int width, int height, TextureFormat format, int uploadBytes, long sourceBytes = 0) => CalculateNewAtlasUploadCost(width, height, format, [uploadBytes], sourceBytes); internal static MeshUploadCost CalculateNewAtlasUploadCost( int width, int height, TextureFormat format, IReadOnlyList uploadBytes, long sourceBytes = 0) { ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); ArgumentNullException.ThrowIfNull(uploadBytes); ArgumentOutOfRangeException.ThrowIfNegative(sourceBytes); long arrayBytes = TextureAtlasManager.CalculateArrayBytes(width, height, format); for (int i = 0; i < uploadBytes.Count; i++) ArgumentOutOfRangeException.ThrowIfNegative(uploadBytes[i]); return new MeshUploadCost(sourceBytes, arrayBytes, arrayBytes, 1); } internal void AddDirtyAtlasesTo(ISet destination) { ArgumentNullException.ThrowIfNull(destination); destination.UnionWith(_dirtyAtlases); } private void UpdateLruAfterUpload(ulong id) { lock (_lruList) { _lruList.Remove(id); if (!_ownership.MarkUploadComplete(id)) _lruList.AddLast(id); } } #region Private: Background Preparation /// /// #113: the set of polygon ids referenced by the GfxObj's drawing BSP — /// the polys retail actually renders (D3DPolyRender traverses the BSP; /// dictionary-orphaned polys are physics/no-draw geometry). Returns null /// when the model has no drawing BSP (caller draws everything). /// internal static HashSet? CollectDrawingBspPolygonIds(GfxObj gfxObj) { if (gfxObj.DrawingBSP?.Root is null) return null; var ids = new HashSet(); CollectDrawingBspPolygonIds(gfxObj.DrawingBSP.Root, ids); return ids; } private static void CollectDrawingBspPolygonIds(DatReaderWriter.Types.DrawingBSPNode node, HashSet ids) { if (node.Polygons is not null) foreach (var pid in node.Polygons) ids.Add((ushort)pid); if (node.PosNode is not null) CollectDrawingBspPolygonIds(node.PosNode, ids); if (node.NegNode is not null) CollectDrawingBspPolygonIds(node.NegNode, ids); } #endregion #region Private: GPU Upload private unsafe ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData) { if (meshData.Vertices.Length == 0) return null; var gl = _graphicsDevice.GL; uint vao = 0, vbo = 0; var modernIndexBatches = meshData.TextureBatches.Values .SelectMany(batches => batches) .Where(batch => batch.Indices.Count != 0) .Select(batch => batch.Indices.ToArray()) .ToArray(); GlobalMeshAllocation? globalAllocation = null; var renderBatches = new List(); var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>(); var legacyIndexBuffers = new List<(uint Name, int Bytes)>(); try { if (_useModernRendering) { // One mesh owns one vertex range and one contiguous index // range. The former append path duplicated the full vertex // array per material and never reclaimed evicted ranges. vao = GlobalBuffer!.VAO; vbo = GlobalBuffer!.VBO; } else { gl.GenVertexArrays(1, out vao); gl.BindVertexArray(vao); gl.GenBuffers(1, out vbo); gl.BindBuffer(GLEnum.ArrayBuffer, vbo); fixed (VertexPositionNormalTexture* ptr = meshData.Vertices) { gl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw); } GpuMemoryTracker.TrackAllocation(meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); int stride = VertexPositionNormalTexture.Size; // Position (location 0) gl.EnableVertexAttribArray(0); gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); // Normal (location 1) gl.EnableVertexAttribArray(1); gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); // TexCoord (location 2) gl.EnableVertexAttribArray(2); gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); // Instance data (shared VBO) gl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO); for (uint i = 0; i < 4; i++) { var loc = 3 + i; gl.EnableVertexAttribArray(loc); gl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16)); gl.VertexAttribDivisor(loc, 1); } gl.EnableVertexAttribArray(8); gl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64); gl.VertexAttribDivisor(8, 1); } // Allocate the shared vertex/index range before acquiring texture // references. A buffer-growth failure therefore leaves every atlas // untouched; later failures still roll this allocation back below. if (_useModernRendering && modernIndexBatches.Length != 0) globalAllocation = GlobalBuffer!.UploadMesh(meshData.Vertices, modernIndexBatches); foreach (var (format, batches) in meshData.TextureBatches) { foreach (var batch in batches) { if (batch.Indices.Count == 0) continue; uint ibo = 0; TextureAtlasManager? atlasManager = null; int textureIndex = 0; uint firstIndex = 0; int batchBaseVertex = 0; // Find or create a shared atlas with free space if (!_globalAtlases.TryGetValue(format, out var atlasList)) { atlasList = new List(); _globalAtlases[format] = atlasList; } // Existing-key lookup must win across the entire atlas // family. Choosing an earlier reclaimed slot first // duplicates a layer already resident in a later array // every time portal churn revisits that texture. atlasManager = atlasList.FirstOrDefault(a => a.HasTexture(batch.Key)) ?? atlasList.FirstOrDefault(a => a.AvailableSlots > 0); if (atlasManager == null) { atlasManager = new TextureAtlasManager( _graphicsDevice, format.Width, format.Height, format.Format, OnAtlasGpuSafeEmpty); atlasList.Add(atlasManager); } atlasManager.LastUseSequence = ++_atlasUseSequence; // MP1a: AcDream.Content is Silk.NET-free — the extraction records // carry Content-owned UploadPixelFormat/UploadPixelType enums whose // underlying values are the GL ABI constants (numerically identical // to Silk.NET.OpenGL.PixelFormat/PixelType), so this lifted nullable // cast is value- and null-preserving. bool uploadsNewLayer = !atlasManager.HasTexture(batch.Key); try { textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, (PixelFormat?)batch.UploadPixelFormat, (PixelType?)batch.UploadPixelType); } catch { if (atlasManager.IsGpuSafeEmpty) OnAtlasGpuSafeEmpty(atlasManager); throw; } acquiredTextures.Add((atlasManager, batch.Key)); // AddTexture may first publish a previously failed // layer return, whose empty-atlas observer marks this // array unowned. Reassert active ownership only after // the new acquisition commits so that callback cannot // leave a live atlas in the empty-array eviction LRU. MarkAtlasActive(atlasManager); if (uploadsNewLayer) _dirtyAtlases.Add(atlasManager); if (_useModernRendering) { ibo = GlobalBuffer!.IBO; } else { gl.GenBuffers(1, out ibo); gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); var indexArray = batch.Indices.ToArray(); fixed (ushort* iptr = indexArray) { gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw); } GpuMemoryTracker.TrackAllocation(indexArray.Length * sizeof(ushort), GpuResourceType.Buffer); legacyIndexBuffers.Add((ibo, indexArray.Length * sizeof(ushort))); } ulong bindlessHandle = batch.HasWrappingUVs ? atlasManager.TextureArray.BindlessWrapHandle : atlasManager.TextureArray.BindlessClampHandle; renderBatches.Add(new ObjectRenderBatch { IBO = ibo, IndexCount = batch.Indices.Count, Atlas = atlasManager!, TextureIndex = textureIndex, TextureSize = (format.Width, format.Height), TextureFormat = format.Format, Translucency = batch.Translucency, IsTransparent = batch.IsTransparent, IsAdditive = batch.IsAdditive, HasWrappingUVs = batch.HasWrappingUVs, Key = batch.Key, CullMode = batch.CullMode, FirstIndex = firstIndex, BaseVertex = (uint)batchBaseVertex, BindlessTextureHandle = bindlessHandle, }); } } if (_useModernRendering && globalAllocation is not null) { if (renderBatches.Count != globalAllocation.BatchFirstIndices.Count) { throw new InvalidOperationException("Global mesh batch allocation count mismatch."); } for (int i = 0; i < renderBatches.Count; i++) { renderBatches[i].BaseVertex = (uint)globalAllocation.Vertices.Offset; renderBatches[i].FirstIndex = (uint)globalAllocation.BatchFirstIndices[i]; } } long geometryBytes = checked( (long)meshData.Vertices.Length * VertexPositionNormalTexture.Size + renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort))); var renderData = new ObjectRenderData { VAO = vao, VBO = vbo, VertexCount = meshData.Vertices.Length, Batches = renderBatches, GlobalAllocation = globalAllocation, ParticleEmitters = meshData.ParticleEmitters, DIDDegrade = meshData.DIDDegrade, CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(), CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(), CPUEdgeLines = meshData.EdgeLines, MemorySize = geometryBytes, NonArenaGpuBytes = CalculateNonArenaGeometryBytes( _useModernRendering, geometryBytes), }; if (!_useModernRendering) { gl.BindVertexArray(0); } return renderData; } catch (Exception uploadFailure) { RetryableResourceReleaseLedger rollback = CreateUploadRollback( meshData, gl, vao, vbo, globalAllocation, acquiredTextures, legacyIndexBuffers); ResourceReleaseAttempt attempt = rollback.Advance(); if (!rollback.IsComplete) { _uploadRollbacks[meshData.ObjectId] = rollback; _uploadRollbackQueue.Enqueue(meshData.ObjectId); } if (!attempt.HasFailures) throw; throw new AggregateException( $"Mesh 0x{meshData.ObjectId:X10} upload and rollback failed.", uploadFailure, attempt.ToException( $"Mesh 0x{meshData.ObjectId:X10} upload rollback had unfinished resources.")); } } private RetryableResourceReleaseLedger CreateUploadRollback( ObjectMeshData meshData, GL gl, uint vao, uint vbo, GlobalMeshAllocation? globalAllocation, IReadOnlyList<(TextureAtlasManager Atlas, TextureKey Key)> acquiredTextures, IReadOnlyList<(uint Name, int Bytes)> legacyIndexBuffers) { var releases = new List<(string Name, Action Release)>(); if (globalAllocation is not null) { releases.Add(( "global-index-range", () => GlobalBuffer!.AbortIndexRange(globalAllocation))); releases.Add(( "global-vertex-range", () => GlobalBuffer!.AbortVertexRange(globalAllocation))); } // AddTexture is a reference-counted acquisition even when the // pixels already existed. Each acquisition owns an independent // marker because one texture-release failure must not skip the // remaining layers or replay successful decrements later. for (int i = acquiredTextures.Count - 1; i >= 0; i--) { int releaseIndex = i; releases.Add(( $"atlas-texture-{releaseIndex}", () => ReleaseAtlasTexture( acquiredTextures[releaseIndex].Atlas, acquiredTextures[releaseIndex].Key))); } if (!_useModernRendering) { for (int i = 0; i < legacyIndexBuffers.Count; i++) { int bufferIndex = i; releases.Add(( $"legacy-index-buffer-{bufferIndex}-delete", () => gl.DeleteBuffer(legacyIndexBuffers[bufferIndex].Name))); releases.Add(( $"legacy-index-buffer-{bufferIndex}-accounting", () => GpuMemoryTracker.TrackDeallocation( legacyIndexBuffers[bufferIndex].Bytes, GpuResourceType.Buffer))); } if (vbo != 0) { releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(vbo))); releases.Add(( "legacy-vertex-buffer-accounting", () => GpuMemoryTracker.TrackDeallocation( meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer))); } if (vao != 0) releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(vao))); } return new RetryableResourceReleaseLedger(releases); } internal static RetryableResourceReleaseLedger CreateSetupPartRollback( IReadOnlyList acquiredParts, Action releasePart) { ArgumentNullException.ThrowIfNull(acquiredParts); ArgumentNullException.ThrowIfNull(releasePart); var releases = new List<(string Name, Action Release)>(acquiredParts.Count); for (int i = acquiredParts.Count - 1; i >= 0; i--) { int releaseIndex = i; releases.Add(( $"setup-part-{releaseIndex}", () => releasePart(acquiredParts[releaseIndex]))); } return new RetryableResourceReleaseLedger(releases); } #endregion #region Private: Utilities #region Raycasting public bool IntersectMesh(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, out float distance, out Vector3 normal) { return IntersectMeshInternal(renderData, transform, rayOrigin, rayDirection, 0, out distance, out normal); } private bool IntersectMeshInternal(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, int depth, out float distance, out Vector3 normal) { distance = float.MaxValue; normal = Vector3.UnitZ; bool hit = false; if (depth > 32) return false; // Prevent stack overflow from circular setups if (renderData.IsSetup) { foreach (var part in renderData.SetupParts) { var partData = TryGetRenderData(part.GfxObjId); if (partData != null) { if (IntersectMeshInternal(partData, part.Transform * transform, rayOrigin, rayDirection, depth + 1, out float d, out Vector3 n)) { if (d < distance) { distance = d; normal = n; hit = true; } } } } return hit; } if (renderData.CPUPositions.Length == 0 || renderData.CPUIndices.Length == 0) { // Fallback to sphere if no CPU mesh data if (renderData.SelectionSphere != null && renderData.SelectionSphere.Radius > 0.001f) { var worldOrigin = Vector3.Transform(renderData.SelectionSphere.Origin, transform); float radius = renderData.SelectionSphere.Radius * transform.Translation.Length(); // Rough scale if (GeometryUtils.RayIntersectsSphere(rayOrigin, rayDirection, worldOrigin, radius, out distance)) { normal = Vector3.Normalize(rayOrigin + rayDirection * distance - worldOrigin); return true; } } return false; } // Transform ray to local space if (!Matrix4x4.Invert(transform, out var invTransform)) return false; Vector3 localOrigin = Vector3.Transform(rayOrigin, invTransform); Vector3 localDirection = Vector3.Normalize(Vector3.TransformNormal(rayDirection, invTransform)); // Iterate through triangles for (int i = 0; i < renderData.CPUIndices.Length; i += 3) { Vector3 v0 = renderData.CPUPositions[renderData.CPUIndices[i]]; Vector3 v1 = renderData.CPUPositions[renderData.CPUIndices[i + 1]]; Vector3 v2 = renderData.CPUPositions[renderData.CPUIndices[i + 2]]; if (GeometryUtils.RayIntersectsTriangle(localOrigin, localDirection, v0, v1, v2, out float t)) { // Convert t back to world space distance Vector3 hitPointLocal = localOrigin + localDirection * t; Vector3 hitPointWorld = Vector3.Transform(hitPointLocal, transform); float worldDist = Vector3.Distance(rayOrigin, hitPointWorld); if (worldDist < distance) { distance = worldDist; // Calculate normal in local space and transform to world space Vector3 localNormal = Vector3.Normalize(Vector3.Cross(v1 - v0, v2 - v0)); normal = Vector3.Normalize(Vector3.TransformNormal(localNormal, transform)); // Ensure normal faces the ray if (Vector3.Dot(normal, rayDirection) > 0) { normal = -normal; } hit = true; } } } return hit; } #endregion private long GetObjectReclaimableBytes(ulong key) { if (_objectReleases.TryGetValue(key, out ObjectReleaseTicket? release)) return release.ReclaimableBytes; return _renderData.TryGetValue(key, out ObjectRenderData? data) ? GetReclaimableBytes(data) : 0; } private ObjectReleaseTicket? GetOrCreateObjectRelease(ulong key) { if (_objectReleases.TryGetValue(key, out ObjectReleaseTicket? existing)) return existing; if (!_renderData.TryGetValue(key, out ObjectRenderData? data)) return null; var releases = new List<(string Name, Action Release)>(); GL gl = _graphicsDevice.GL; if (_useModernRendering) { if (data.GlobalAllocation is { } allocation) { releases.Add(( "global-index-range", () => GlobalBuffer!.ReleaseIndexRange(allocation))); releases.Add(( "global-vertex-range", () => GlobalBuffer!.ReleaseVertexRange(allocation))); } } else { if (data.VAO != 0) releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(data.VAO))); if (data.VBO != 0) { releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(data.VBO))); releases.Add(( "legacy-vertex-buffer-accounting", () => GpuMemoryTracker.TrackDeallocation( data.VertexCount * VertexPositionNormalTexture.Size, GpuResourceType.Buffer))); } for (int i = 0; i < data.Batches.Count; i++) { int batchIndex = i; ObjectRenderBatch batch = data.Batches[batchIndex]; if (batch.IBO == 0) continue; releases.Add(( $"legacy-index-buffer-{batchIndex}-delete", () => gl.DeleteBuffer(data.Batches[batchIndex].IBO))); releases.Add(( $"legacy-index-buffer-{batchIndex}-accounting", () => GpuMemoryTracker.TrackDeallocation( data.Batches[batchIndex].IndexCount * sizeof(ushort), GpuResourceType.Buffer))); } } for (int i = 0; i < data.Batches.Count; i++) { int batchIndex = i; ObjectRenderBatch batch = data.Batches[batchIndex]; if (batch.Atlas is null) continue; releases.Add(( $"atlas-texture-{batchIndex}", () => ReleaseAtlasTexture( data.Batches[batchIndex].Atlas, data.Batches[batchIndex].Key))); } if (data.IsSetup) { for (int i = 0; i < data.SetupParts.Count; i++) { int partIndex = i; releases.Add(( $"setup-part-{partIndex}", () => DecrementRefCount(data.SetupParts[partIndex].GfxObjId))); } } releases.Add(( "non-arena-memory-accounting", () => _currentNonArenaGpuMemory = checked( _currentNonArenaGpuMemory - data.NonArenaGpuBytes))); var ticket = new ObjectReleaseTicket( key, data, GetReclaimableBytes(data), new RetryableResourceReleaseLedger(releases)); _objectReleases.Add(key, ticket); MarkRenderDataAvailabilityChanged(); return ticket; } private bool TryAdvanceObjectRelease(ulong key, out long reclaimedBytes) { reclaimedBytes = 0; ObjectReleaseTicket? ticket = GetOrCreateObjectRelease(key); if (ticket is null) { if (!_ownership.IsOwned(key)) _ownership.Remove(key); return false; } ResourceReleaseAttempt attempt = ticket.Resources.Advance(); if (!ticket.Resources.IsComplete) { if (!ticket.IsQueued) { ticket.IsQueued = true; _objectReleaseQueue.Enqueue(key); } LogReleaseFailure( key, "resource release", ticket.Resources.RemainingCount, attempt); return false; } if (_renderData.TryGetValue(key, out ObjectRenderData? current) && ReferenceEquals(current, ticket.Data)) { _renderData.TryRemove(key, out _); } _objectReleases.Remove(key); MarkRenderDataAvailabilityChanged(); if (!_ownership.IsOwned(key)) _ownership.Remove(key); lock (_lruList) _lruList.Remove(key); reclaimedBytes = ticket.ReclaimableBytes; LogReleaseFailure(key, "resource release", 0, attempt); return true; } private static void ReleaseAtlasTexture( TextureAtlasManager atlas, TextureKey key) { try { atlas.ReleaseTexture(key); } catch (Exception error) when (!atlas.HasTexture(key)) { // TextureAtlasManager removes the logical key before it asks // the frame-fence owner to recycle the physical layer. An // observer/publication failure can therefore throw after the // decrement committed. Translate that observable state into // the shared committed-outcome contract so no later cleanup // decrements the same acquisition again. throw new MeshReferenceMutationException( $"Texture {key} was released from atlas slot {atlas.Slot}, but its retirement callback failed.", mutationCommitted: true, error); } } private bool TryAdvanceUploadRollback(ulong key) { if (!_uploadRollbacks.TryGetValue(key, out RetryableResourceReleaseLedger? rollback)) return true; ResourceReleaseAttempt attempt = rollback.Advance(); if (!rollback.IsComplete) { LogReleaseFailure( key, "upload rollback", rollback.RemainingCount, attempt); return false; } _uploadRollbacks.Remove(key); LogReleaseFailure(key, "upload rollback", 0, attempt); return true; } private void LogReleaseFailure( ulong key, string operation, int remaining, ResourceReleaseAttempt attempt) { if (!attempt.HasFailures) return; _logger.LogError( attempt.ToException( $"Mesh 0x{key:X10} {operation} reported exceptional resource outcomes."), "Mesh 0x{Id:X10} {Operation} completed {Completed} of {Attempted} attempted stages; {Remaining} remain", key, operation, attempt.CompletedCount, attempt.AttemptedCount, remaining); } private (int Count, long Bytes) AdvancePendingObjectReleases( int maximumCount, long maximumBytes) { int count = 0; long bytes = 0; int attempts = Math.Min(maximumCount, _objectReleaseQueue.Count); for (int i = 0; i < attempts; i++) { ulong key = _objectReleaseQueue.Dequeue(); if (!_objectReleases.TryGetValue(key, out ObjectReleaseTicket? ticket)) continue; ticket.IsQueued = false; if (!FitsReclamationBudget( ticket.ReclaimableBytes, bytes, maximumBytes)) { ticket.IsQueued = true; _objectReleaseQueue.Enqueue(key); continue; } if (!TryAdvanceObjectRelease(key, out long completedBytes)) continue; bytes = checked(bytes + completedBytes); count++; } return (count, bytes); } private void AdvancePendingUploadRollbacks(int maximumCount) { int attempts = Math.Min(maximumCount, _uploadRollbackQueue.Count); for (int i = 0; i < attempts; i++) { ulong key = _uploadRollbackQueue.Dequeue(); if (!_uploadRollbacks.ContainsKey(key)) continue; try { TryAdvanceUploadRollback(key); } finally { if (_uploadRollbacks.ContainsKey(key)) _uploadRollbackQueue.Enqueue(key); } } } #endregion public void Dispose() { lock (_disposeGate) { if (_disposeCompleted) return; if (_disposeRunning) return; _disposeRunning = true; try { DisposeCore(); _disposeCompleted = true; } finally { _disposeRunning = false; } } } private void DisposeCore() { // Quiesce the background decode workers BEFORE returning: the owner // disposes the DatCollection right after this adapter chain, which // unmaps the dats' memory-mapped views. A worker still inside // MemoryMappedBlockAllocator.ReadBlock at that point dereferences the // dead view pointer — an uncatchable, process-fatal AccessViolation // (dat-race investigation 2026-06-09). Setting IsDisposed under the // queue lock publishes it to workers, which re-check it before every // dequeue; draining the queue means each worker exits after at most // its current (millisecond-scale) item. List? failures = null; static void Capture(ref List? failures, Action action) { try { action(); } catch (Exception ex) { (failures ??= []).Add(ex); } } if (!_workersQuiesced) { PreparationRequest[] pendingToCancel; PreparationRequest[] activeToCancel; lock (_pendingRequests) { IsDisposed = true; pendingToCancel = _pendingRequests.ToArray(); activeToCancel = _activePreparationById.Values.ToArray(); foreach (PreparationRequest request in pendingToCancel) _preparationTasks.TryRemove(request.Id, out _); _pendingRequests.Clear(); _pendingRequestById.Clear(); _envCellDescriptors.Clear(); _terminalPreparationFailures.Clear(); } foreach (PreparationRequest request in pendingToCancel) { Capture(ref failures, request.Cancel); request.Completion.TrySetCanceled(request.Cancellation.Token); Capture(ref failures, request.DisposeCancellation); } foreach (PreparationRequest request in activeToCancel) Capture(ref failures, request.Cancel); // Release every persistent worker from its zero-CPU wait so it can // observe IsDisposed and terminate before DAT mappings are freed. Capture(ref failures, _preparationWorkAvailable.Set); Task[] workers; lock (_pendingRequests) workers = _workerTasks.ToArray(); Capture(ref failures, () => Task.WhenAll(workers).GetAwaiter().GetResult()); lock (_pendingRequests) { _workerTasks.RemoveWhere(worker => worker.IsCompleted); if (_workerTasks.Count != 0) { (failures ??= []).Add( new InvalidOperationException("Mesh workers remained live after their join completed.")); } else { _workersQuiesced = true; } } } // First converge every exact per-object and failed-upload ledger. // Atlas arrays and the global arena are backing stores for these // entries and cannot be destroyed until no child release remains. ulong[] objectIds = _renderData.Keys .Concat(_objectReleases.Keys) .Distinct() .ToArray(); for (int i = 0; i < objectIds.Length; i++) { ulong id = objectIds[i]; try { if (!TryAdvanceObjectRelease(id, out _)) (failures ??= []).Add(new InvalidOperationException( $"Mesh 0x{id:X10} still has unfinished resource-release stages.")); } catch (Exception error) { (failures ??= []).Add(error); } } ulong[] rollbackIds = _uploadRollbacks.Keys.ToArray(); for (int i = 0; i < rollbackIds.Length; i++) { ulong id = rollbackIds[i]; try { if (!TryAdvanceUploadRollback(id)) (failures ??= []).Add(new InvalidOperationException( $"Mesh 0x{id:X10} still has unfinished upload-rollback stages.")); } catch (Exception error) { (failures ??= []).Add(error); } } if (_objectReleases.Count != 0 || _uploadRollbacks.Count != 0) { throw new AggregateException( "One or more mesh resource transactions remain unfinished.", failures ?? [new InvalidOperationException("Mesh resource teardown did not converge.")]); } // Every layer release has now committed logically. Publish any // callback which previously failed before queue acceptance. Capture(ref failures, RetryPendingAtlasRetirements); Capture(ref failures, RetryRetiringAtlasDisposals); bool atlasFailure = false; foreach (List atlasList in _globalAtlases.Values) { foreach (TextureAtlasManager atlas in atlasList) { try { atlas.Dispose(); } catch (Exception error) { atlasFailure = true; (failures ??= []).Add(error); } } } if (atlasFailure) throw new AggregateException( "One or more texture atlases could not be disposed.", failures!); if (_useModernRendering && GlobalBuffer is not null) Capture(ref failures, GlobalBuffer.Dispose); if (failures is not null) throw new AggregateException("One or more mesh-manager teardown operations failed.", failures); _renderData.Clear(); _objectReleases.Clear(); MarkRenderDataAvailabilityChanged(); _objectReleaseQueue.Clear(); _uploadRollbacks.Clear(); _uploadRollbackQueue.Clear(); _globalAtlases.Clear(); _retiringAtlases.Clear(); _dirtyAtlases.Clear(); _safeEmptyAtlases.Clear(); _currentNonArenaGpuMemory = 0; _cpuMeshCache.Clear(); lock (_lruList) _lruList.Clear(); if (!_workSignalDisposed) { _preparationWorkAvailable.Dispose(); _workSignalDisposed = true; } } } }