acdream/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
Erik 0c5057c9ff fix #435 (part 1): delete 17 probes that outlived their closed investigations
Each of these was temporary apparatus added to chase one bug, and each was
supposed to be deleted in the commit that fixed it. Fourteen closed issues
later they were still here: #337's support/wire-mesh trio, #171's sticky
timeline, #119's viewer and entity dumps, #113's phantom probe, and a dozen
more. 3,493 lines removed; the client now reads 144 environment variables
instead of 161, and 47 temporary probes remain instead of 64.

This is not only tidying. Every probe leaves a branch on its hot path when
unset, several re-read the environment per call rather than caching, and
the volume buries the diagnostics that are actually load-bearing. It is
also a headless correctness matter: HeadlessStaticStateAudit reflects over
PhysicsDiagnostics' flags to refuse a multi-session host when any is set,
and cannot see probes that live outside that owner.

Four files went entirely — WalkMissDiagnostic.cs, CollisionMeshWireframe.cs
and two test files whose only subject was a deleted probe.
TransitionTypes.SetContactPlane also sheds its CallerMemberName /
CallerLineNumber parameters, which existed solely for #337's cpSrc=
attribution and carried the instruction to strip them with the probe
family; no call site passed them, so no behavior changes. F2's collision
overlay survives and reverts to its proxy-cylinder form, which is what
removing the ACDREAM_WIRE_MESH upgrade means.

LaunchOptionsDocumentationTests earned its keep here: it refused the
deletion until docs/launch-options.md moved the 17 rows into Retired and
the frozen direct-read counts came down (PhysicsEngine.cs to zero,
TransitionTypes.cs 3 to 2). The documentation could not drift during a
cleanup this wide.

The 14 probes that name no owning issue are deliberately NOT deleted.
Nothing records when they became safe to remove, and guessing is how a
future investigation loses apparatus it needed; #435 stays open for their
attribution.

Full hermetic suite 15,321 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:41:20 +02:00

711 lines
32 KiB
C#

using System;
using System.Collections.Generic;
using AcDream.Content;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Rendering;
using DatReaderWriter;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Single seam between acdream and WB's render pipeline. Owns the
/// <c>ObjectMeshManager</c> instance and exposes a stable acdream-shaped API
/// so the rest of the renderer doesn't need to know about WB's types directly.
///
/// <para>
/// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared
/// <see cref="IDatReaderWriter"/> facade — the separate
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
/// </para>
/// </summary>
public sealed class WbMeshAdapter
: IDisposable,
IWbMeshAdapter
{
internal const int MaximumUploadsPerFrame = 8;
internal const int DestinationRevealMaximumUploadsPerFrame = 64;
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 IMeshPipelineDevice? _graphicsDevice;
private readonly ObjectMeshManager? _meshManager;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IPreparedAssetSource? _ownedPreparedAssets;
private readonly MeshUploadFrameBudget _ordinaryUploadBudget =
CreateUploadBudget(MaximumUploadsPerFrame);
private readonly MeshUploadFrameBudget _destinationRevealUploadBudget =
CreateUploadBudget(DestinationRevealMaximumUploadsPerFrame);
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
private bool _destinationRevealUploadPriority;
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
/// all public methods no-op when uninitialized.
/// </summary>
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;
internal void SetDestinationRevealUploadPriority(bool enabled) =>
_destinationRevealUploadPriority = enabled;
private static MeshUploadFrameBudget CreateUploadBudget(
int maximumObjects) =>
new(new MeshUploadBudgetLimits(
maximumObjects,
MaximumUploadBytesPerFrame,
MaximumArrayAllocationBytesPerFrame,
MaximumMipmapBytesPerFrame,
MaximumNewArraysPerFrame,
MaximumBufferUploadBytesPerFrame,
MaximumBufferAllocationBytesPerFrame,
MaximumBufferCopyBytesPerFrame,
MaximumNewBuffersPerFrame,
MaximumSingleUploadBytes,
MaximumSingleArrayAllocationBytes,
MaximumSingleMipmapBytes,
MaximumSingleNewArrays,
MaximumSingleBufferUploadBytes));
/// <summary>
/// Constructs the UI-Studio/tooling WB pipeline. Production composition
/// supplies the validated pak source through the internal overload below;
/// this explicit tooling seam retains live-DAT extraction.
///
/// internal, not public, since Campaign V slice V4b: the shared mesh arena
/// allocates its backing stores from <c>IGpuDevice</c>, which is an internal
/// type by the pinned RHI contract. Every caller already lives inside
/// AcDream.App or its InternalsVisibleTo test assemblies.
/// </summary>
/// <param name="gpuDevice">The one process RHI device. Supplies the mesh
/// arena's vertex/index buffers.</param>
/// <param name="dats">acdream's shared runtime DAT facade. Tooling uses it
/// through an explicitly owned <see cref="DatPreparedAssetSource"/>.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param>
internal WbMeshAdapter(
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger)
: this(
gpuDevice,
dats,
preparedAssets: null,
logger,
AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance,
ownsPreparedAssets: true,
ResidencyBudgetOptions.Default)
{
}
internal static WbMeshAdapter CreateWithLiveDatPreparedAssets(
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) =>
new(
gpuDevice,
dats,
preparedAssets: null,
logger,
resourceRetirement,
ownsPreparedAssets: true,
ResidencyBudgetOptions.Default);
internal WbMeshAdapter(
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
ResidencyBudgetOptions? budgets = null)
: this(
gpuDevice,
dats,
preparedAssets,
logger,
resourceRetirement,
ownsPreparedAssets: false,
budgets ?? ResidencyBudgetOptions.Default)
{
}
private WbMeshAdapter(
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IDatReaderWriter dats,
IPreparedAssetSource? preparedAssets,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool ownsPreparedAssets,
ResidencyBudgetOptions budgets)
{
ArgumentNullException.ThrowIfNull(gpuDevice);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(budgets);
_resourceRetirement = resourceRetirement;
var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
IMeshPipelineDevice? graphicsDevice = null;
IPreparedAssetSource? resolvedPreparedAssets = preparedAssets;
ObjectMeshManager? meshManager = null;
try
{
// Campaign V slice V6i-3: WHICH mesh-pipeline device is decided
// here, once, and it is the only place in the mesh pipeline that
// names a backend. The raw-GL arm (OpenGLGraphicsDevice) was
// deleted at Campaign V slice V11; the RHI arm's queue is empty by
// construction because Vulkan resource work is recorded or
// retirement-queued rather than deferred onto a context-owning
// thread.
var rhiDevice = new AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice(
resourceRetirement);
graphicsDevice = rhiDevice;
resources.Add("WB graphics device", rhiDevice.Dispose);
if (resolvedPreparedAssets is null)
{
resolvedPreparedAssets = new DatPreparedAssetSource(
dats,
new ConsoleErrorLogger<ObjectMeshManager>());
resources.Add(
"WB tooling prepared asset source",
resolvedPreparedAssets.Dispose);
}
// ConsoleErrorLogger surfaces WB's silently-caught exceptions
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
meshManager = new ObjectMeshManager(
graphicsDevice,
gpuDevice,
resolvedPreparedAssets,
new ConsoleErrorLogger<ObjectMeshManager>(),
budgets);
resources.Add("WB object mesh manager", meshManager.Dispose);
resources.TransferAll();
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"WbMeshAdapter construction failed and its WB/GL prefix did not cleanly roll back.",
constructionFailure);
}
_graphicsDevice = graphicsDevice;
_meshManager = meshManager;
_ownedPreparedAssets = ownsPreparedAssets
? resolvedPreparedAssets
: null;
}
internal void RegisterResidencySources(ResidencyManager manager)
{
ArgumentNullException.ThrowIfNull(manager);
ObjectMeshManager meshManager = _meshManager
?? throw new InvalidOperationException(
"An initialized mesh adapter is required for residency diagnostics.");
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.ObjectMeshes,
meshManager.CaptureObjectMeshResidency));
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.PreparedMeshCpu,
meshManager.CapturePreparedMeshResidency));
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.MeshStaging,
meshManager.CaptureStagingResidency));
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.GlobalMeshArena,
meshManager.CaptureGlobalArenaResidency));
}
/// <summary>
/// Minimal Console-backed logger that fires only on
/// <see cref="LogLevel.Error"/> and above. Format:
/// <code>[wb-error] &lt;message&gt;
/// [wb-error] &lt;ExceptionType&gt;: &lt;ExceptionMessage&gt;
/// [wb-error] at &lt;frame&gt; (up to 5 frames)</code>
/// Used to surface WB's silently-caught exceptions in
/// <c>ObjectMeshManager.PrepareMeshData</c>.
/// </summary>
private sealed class ConsoleErrorLogger<T> : ILogger<T>
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> 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;
}
/// <summary>Test/init helper — produces a Dispose-safe instance with no
/// underlying mesh manager. Public methods are all no-ops.</summary>
public static WbMeshAdapter CreateUninitialized() => new();
/// <summary>
/// Phase A8 (2026-05-28): exposes the underlying <see cref="ObjectMeshManager"/>
/// so <c>EnvCellRenderer</c> can share the same global mesh buffer (VAO/VBO/IBO).
/// Returns null when the adapter is uninitialized.
/// </summary>
public ObjectMeshManager? MeshManager => _meshManager;
/// <summary>
/// Returns the WB render data for <paramref name="id"/>, or null if not
/// yet uploaded or if this adapter is uninitialized. Increments WB's
/// internal usage counter — use <see cref="TryGetRenderData"/> for
/// render-loop lookups that should not affect lifecycle.
/// </summary>
public ObjectRenderData? GetRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return null;
return _meshManager.GetRenderData(id);
}
/// <summary>
/// Returns the WB render data for <paramref name="id"/> without
/// modifying reference counts. Returns null if the mesh is not yet
/// uploaded. Safe for render-loop lookups.
/// </summary>
public ObjectRenderData? TryGetRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return null;
return _meshManager.TryGetRenderData(id);
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public void IncrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
try
{
// 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. A first-only gate
// permanently lost any id whose initial read 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 (_meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
catch (Exception acquireFailure)
{
// IncrementRefCount is a public ownership boundary. Prepared-data
// acquisition 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;
}
}
/// <inheritdoc/>
public void DecrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.DecrementRefCount(id);
}
/// <inheritdoc/>
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);
}
/// <summary>
/// #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.
/// </summary>
public void EnsureLoaded(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
/// <summary>
/// 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.
///
/// <para>
/// Order matters: <c>ProcessGLQueue</c> 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
/// <c>UploadMeshData</c> on each item to materialize the actual GL VAO /
/// VBO / IBO resources. After Tick, <c>GetRenderData</c> for any id
/// previously passed to <c>IncrementRefCount</c> may return non-null.
/// </para>
///
/// <para>
/// No-op when the adapter is uninitialized (e.g., flag is off and the
/// adapter was constructed via <c>CreateUninitialized</c>).
/// </para>
/// </summary>
public void Tick()
{
if (_isUninitialized) return;
if (_disposed) return;
ObjectMeshManager meshManager = _meshManager!;
_graphicsDevice!.ProcessQueue();
// #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<MeshUploadQueueItem>? requeue = null;
MeshUploadFrameBudget uploadBudget =
_destinationRevealUploadPriority
? _destinationRevealUploadBudget
: _ordinaryUploadBudget;
int maximumUploads =
_destinationRevealUploadPriority
? DestinationRevealMaximumUploadsPerFrame
: MaximumUploadsPerFrame;
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 < maximumUploads)
{
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);
// #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;
}
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);
}
/// <inheritdoc/>
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.
//
// Campaign V slice V11: this used to pattern-match the deleted
// GL GpuFrameFlightController, which VulkanFrameFlightController
// replaced at V6a without this site being updated — so the wait
// was dead on every Vulkan run since then. Fixed, not just
// renamed: VulkanFrameFlightController is IGpuResourceRetirementQueue
// AND exposes the same WaitForSubmittedWork() the GL type did.
if (_resourceRetirement is AcDream.App.Rendering.Gpu.Vk.VulkanFrameFlightController frameFlights)
frameFlights.WaitForSubmittedWork();
},
() => _meshManager?.Dispose(),
() => _ownedPreparedAssets?.Dispose(),
() => DrainGraphicsQueue("publishing mesh resource retirements"),
() =>
{
if (_resourceRetirement is AcDream.App.Rendering.Gpu.Vk.VulkanFrameFlightController 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.ProcessQueue();
if (_graphicsDevice.HasPendingWork)
{
throw new InvalidOperationException(
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
}
}
}