fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -11,12 +11,11 @@ namespace AcDream.App.Rendering.Wb;
/// <para>
/// <b>Key composition:</b> entries are keyed by the tuple
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
/// static-entity hydration paths: scenery (<c>0x80XXYY00 + localIndex</c>)
/// and interior cells (<c>0x40XXYY00 + localCounter</c>, X-byte fixed
/// 2026-06-11 — it used to be discarded entirely, #119) overflow at >256
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
/// uncovered that older hydration IDs were not globally unique across all
/// static-entity paths: their former byte-aligned counters overflowed at
/// more than 256 items per landblock and wrapped into the <c>lbY</c> byte.
/// The current <c>0x8XXYYIII</c> scenery and <c>0x4XXYYIII</c> interior
/// allocators reserve 12-bit counters and fail before aliasing. Keying
/// by the tuple is correct-by-construction ONLY when the hint identifies the
/// entity's OWNING landblock — callers must derive it via
/// <c>WbDrawDispatcher.ResolveCacheLandblockHint</c> (the entity's
@ -64,8 +63,8 @@ internal sealed class EntityClassificationCache
/// <summary>
/// Look up an entity's cached classification. Keyed by both
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
/// disambiguate entities whose Ids collide across landblocks (e.g.,
/// scenery's <c>0x80LLBB00 + localIndex</c> overflow at >256 items/LB).
/// preserve defensive isolation if a future hydration path introduces an
/// entity-id collision across landblocks.
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
/// <paramref name="entry"/> set to <c>null</c> on miss.
/// </summary>

View file

@ -9,4 +9,19 @@ public interface IWbMeshAdapter
{
void IncrementRefCount(ulong id);
void DecrementRefCount(ulong id);
/// <summary>
/// Pins render data whose CPU preparation is owned by a specialized
/// pipeline (for example synthetic EnvCell geometry). Unlike ordinary
/// registration, production implementations must not start a generic
/// GfxObj decode for this id.
/// </summary>
void PinPreparedRenderData(ulong id) => IncrementRefCount(id);
/// <summary>
/// True once the mesh has crossed the render-thread upload barrier and is
/// available to draw. The default keeps lifecycle-only test doubles
/// source-compatible; production adapters override it.
/// </summary>
bool IsRenderDataReady(ulong id) => true;
}

View file

@ -15,13 +15,14 @@ namespace AcDream.App.Rendering.Wb;
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
/// unload can match the load 1:1.
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
/// id without starting generic GfxObj decode. Snapshots both id-sets per
/// landblock so unload can match the load 1:1.
/// </para>
///
/// <para>
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
@ -34,11 +35,9 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
/// uses <c>ConcurrentDictionary</c>, so the streaming worker thread may call
/// this safely. The internal snapshot dictionary is NOT thread-safe and must
/// be called from a single streaming thread (the same thread that fires
/// AddLandblock / RemoveLandblock events).
/// Thread safety: the internal snapshots are intentionally not synchronized.
/// <see cref="AcDream.App.Streaming.GpuWorldState"/> invokes every load, unload, and readiness query
/// on the owning render/update thread.
/// </para>
/// </summary>
public sealed class LandblockSpawnAdapter
@ -48,6 +47,10 @@ public sealed class LandblockSpawnAdapter
// Maps landblock id → unique GfxObj ids registered for that landblock.
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
// EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather
// than generic GfxObj loading, but still require explicit lifetime pins.
// Keep their synthetic ids separate so registration uses the no-decode pin.
private readonly Dictionary<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{
@ -61,7 +64,9 @@ public sealed class LandblockSpawnAdapter
/// unique atlas-tier GfxObj id that has not already been registered for
/// this landblock.
/// </summary>
public void OnLandblockLoaded(LoadedLandblock landblock)
public void OnLandblockLoaded(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalReadinessIds = null)
{
System.ArgumentNullException.ThrowIfNull(landblock);
@ -80,14 +85,51 @@ public sealed class LandblockSpawnAdapter
{
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
return;
}
else
{
foreach (var id in unique)
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
}
}
foreach (var id in unique)
if (!_additionalReadinessIdsByLandblock.TryGetValue(
landblock.LandblockId,
out var additional))
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
additional = new HashSet<ulong>();
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional;
}
if (additionalReadinessIds is not null)
{
foreach (var id in additionalReadinessIds)
if (additional.Add(id))
_adapter.PinPreparedRenderData(id);
}
}
/// <summary>
/// True only after every render mesh required by this published landblock
/// is drawable. This includes both ref-counted static GfxObjs and EnvCell
/// shell geometry prepared by the independent indoor pipeline.
/// </summary>
public bool IsLandblockRenderReady(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var registered)
|| !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
{
return false;
}
foreach (var id in registered)
if (!_adapter.IsRenderDataReady(id))
return false;
foreach (var id in additional)
if (!_adapter.IsRenderDataReady(id))
return false;
return true;
}
/// <summary>
@ -99,6 +141,9 @@ public sealed class LandblockSpawnAdapter
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
foreach (var id in additional) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
_additionalReadinessIdsByLandblock.Remove(landblockId);
}
}

View file

@ -0,0 +1,117 @@
using System.Collections.Concurrent;
using AcDream.Content;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
/// in-flight at a time; a retry retains ownership until upload succeeds or
/// exhausts its retry budget.
/// </summary>
internal sealed class MeshUploadStagingQueue
{
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new();
public bool Stage(ObjectMeshData data)
{
if (!_ownedIds.TryAdd(data.ObjectId, 0))
return false;
data.UploadAttempts = 0;
_queue.Enqueue(data);
return true;
}
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data);
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data);
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _);
}
/// <summary>
/// Logical render-data ownership, deliberately independent from GPU upload
/// existence. Completing or repeating an upload never manufactures an owner;
/// only a live entity/landblock pin changes this count.
/// </summary>
internal sealed class MeshOwnershipCounter
{
private readonly ConcurrentDictionary<ulong, int> _counts = new();
public int Acquire(ulong id) => _counts.AddOrUpdate(id, 1, (_, count) => count + 1);
public int Release(ulong id) => _counts.AddOrUpdate(id, 0, (_, count) => Math.Max(0, count - 1));
public int Count(ulong id) => _counts.TryGetValue(id, out int count) ? count : 0;
public bool IsOwned(ulong id) => Count(id) > 0;
/// <summary>
/// Reports whether freshly uploaded data has a live owner. This is a query,
/// not an acquire: upload existence and logical ownership are independent.
/// </summary>
public bool MarkUploadComplete(ulong id) => IsOwned(id);
public bool Remove(ulong id) => _counts.TryRemove(id, out _);
}
/// <summary>
/// Bounded prepared-mesh cache. A cache hit is not merely a CPU return value:
/// when GPU render data is absent it re-stages that mesh for upload.
/// </summary>
internal sealed class CpuMeshUploadCache
{
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
private readonly LinkedList<ulong> _lru = new();
private readonly int _capacity;
public CpuMeshUploadCache(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
}
public bool TryGetAndStage(
ulong id,
MeshUploadStagingQueue staging,
out ObjectMeshData? data)
{
lock (_data)
{
if (!_data.TryGetValue(id, out data))
return false;
_lru.Remove(id);
_lru.AddLast(id);
staging.Stage(data);
return true;
}
}
public void Store(ObjectMeshData data)
{
lock (_data)
{
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity)
{
ulong oldest = _lru.First!.Value;
_lru.RemoveFirst();
_data.Remove(oldest);
}
_data[data.ObjectId] = data;
_lru.Remove(data.ObjectId);
_lru.AddLast(data.ObjectId);
}
}
public void Clear()
{
lock (_data)
{
_data.Clear();
_lru.Clear();
}
}
}

View file

@ -105,7 +105,7 @@ namespace AcDream.App.Rendering.Wb {
public bool IsDisposed { get; private set; }
private readonly ConcurrentDictionary<ulong, ObjectRenderData> _renderData = new();
private readonly ConcurrentDictionary<ulong, int> _usageCount = new();
private readonly MeshOwnershipCounter _ownership = new();
private readonly ConcurrentDictionary<ulong, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
@ -119,12 +119,10 @@ namespace AcDream.App.Rendering.Wb {
private readonly Dictionary<(int Width, int Height, TextureFormat Format), List<TextureAtlasManager>> _globalAtlases = new();
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
private readonly Dictionary<ulong, ObjectMeshData> _cpuMeshCache = new();
private readonly LinkedList<ulong> _cpuLruList = new();
private readonly int _maxCpuCacheSize = 100;
private readonly CpuMeshUploadCache _cpuMeshCache;
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
private readonly MeshUploadStagingQueue _stagedMeshData = new();
/// <summary>#125: how many times a failed GL upload is re-staged before
/// giving up loudly. Small — a transient GL error clears on the next
@ -141,17 +139,28 @@ namespace AcDream.App.Rendering.Wb {
/// on re-prepare) and gives up loudly past <see cref="MaxUploadRetries"/>.
/// </summary>
public bool UploadOrRequeue(ObjectMeshData meshData) {
if (UploadMeshData(meshData) is not null)
if (UploadMeshData(meshData) is not null) {
_stagedMeshData.Complete(meshData.ObjectId);
return false; // success (incl. legitimate 0-vertex → empty render data)
if (HasRenderData(meshData.ObjectId))
}
if (HasRenderData(meshData.ObjectId)) {
_stagedMeshData.Complete(meshData.ObjectId);
return false; // raced to present by another path
}
meshData.UploadAttempts++;
if (meshData.UploadAttempts < MaxUploadRetries)
return true; // re-stage for next frame
_stagedMeshData.Complete(meshData.ObjectId);
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 ObjectMeshData? data) =>
_stagedMeshData.TryDequeue(out data);
internal void RequeueStagedMeshData(ObjectMeshData data) =>
_stagedMeshData.Requeue(data);
public GlobalMeshBuffer? GlobalBuffer { get; }
private readonly bool _useModernRendering;
@ -167,7 +176,8 @@ namespace AcDream.App.Rendering.Wb {
// did — immediate enqueue, surviving a later throw in the same
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
// extractor's up-to-4 concurrent decode workers.
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data));
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize);
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Stage(data));
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering) {
GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL);
@ -180,7 +190,7 @@ namespace AcDream.App.Rendering.Wb {
/// </summary>
public ObjectRenderData? GetRenderData(ulong id) {
if (_renderData.TryGetValue(id, out var data)) {
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
_ownership.Acquire(id);
if (data.IsSetup) {
foreach (var (partId, _) in data.SetupParts) {
@ -223,7 +233,7 @@ namespace AcDream.App.Rendering.Wb {
/// Increment reference count for an object (e.g. when a landblock starts using it).
/// </summary>
public void IncrementRefCount(ulong id) {
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
_ownership.Acquire(id);
lock (_lruList) {
_lruList.Remove(id);
}
@ -258,7 +268,7 @@ namespace AcDream.App.Rendering.Wb {
/// Decrement reference count and unload GPU resources if no longer needed.
/// </summary>
public void DecrementRefCount(ulong id) {
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
var newCount = _ownership.Release(id);
if (newCount <= 0) {
// Instead of unloading, move to LRU
lock (_lruList) {
@ -272,8 +282,8 @@ namespace AcDream.App.Rendering.Wb {
/// Decrement reference count and unload if no longer needed.
/// </summary>
public void ReleaseRenderData(ulong id) {
if (_usageCount.TryGetValue(id, out var count) && count > 0) {
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
if (_ownership.IsOwned(id)) {
var newCount = _ownership.Release(id);
if (newCount <= 0) {
// Instead of unloading, move to LRU
lock (_lruList) {
@ -291,9 +301,9 @@ namespace AcDream.App.Rendering.Wb {
var idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
if (!_ownership.IsOwned(idToEvict)) {
UnloadObject(idToEvict);
_usageCount.TryRemove(idToEvict, out _);
_ownership.Remove(idToEvict);
}
}
}
@ -309,18 +319,15 @@ namespace AcDream.App.Rendering.Wb {
var idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
if (!_ownership.IsOwned(idToEvict)) {
UnloadObject(idToEvict);
_usageCount.TryRemove(idToEvict, out _);
_ownership.Remove(idToEvict);
}
}
}
// Also clear CPU mesh cache
lock (_cpuMeshCache) {
_cpuMeshCache.Clear();
_cpuLruList.Clear();
}
_cpuMeshCache.Clear();
}
public struct EnvCellGeomRequest {
@ -338,13 +345,8 @@ namespace AcDream.App.Rendering.Wb {
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) {
_cpuLruList.Remove(geomId);
_cpuLruList.AddLast(geomId);
return Task.FromResult<ObjectMeshData?>(cachedData);
}
}
if (_cpuMeshCache.TryGetAndStage(geomId, _stagedMeshData, out var cachedData))
return Task.FromResult(cachedData);
// Return existing task if already running or queued
if (_preparationTasks.TryGetValue(geomId, out var existing)) {
@ -381,13 +383,8 @@ namespace AcDream.App.Rendering.Wb {
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
if (_cpuMeshCache.TryGetValue(id, out var cachedData)) {
_cpuLruList.Remove(id);
_cpuLruList.AddLast(id);
return Task.FromResult<ObjectMeshData?>(cachedData);
}
}
if (_cpuMeshCache.TryGetAndStage(id, _stagedMeshData, out var cachedData))
return Task.FromResult(cachedData);
// Return existing task if already running or queued
if (_preparationTasks.TryGetValue(id, out var existing)) {
@ -476,16 +473,8 @@ namespace AcDream.App.Rendering.Wb {
data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None);
}
if (data != null) {
lock (_cpuMeshCache) {
if (_cpuMeshCache.Count >= _maxCpuCacheSize) {
var oldest = _cpuLruList.First!.Value;
_cpuLruList.RemoveFirst();
_cpuMeshCache.Remove(oldest);
}
_cpuMeshCache[id] = data;
_cpuLruList.AddLast(id);
}
_stagedMeshData.Enqueue(data);
_cpuMeshCache.Store(data);
_stagedMeshData.Stage(data);
}
tcs.TrySetResult(data);
}
@ -538,26 +527,7 @@ namespace AcDream.App.Rendering.Wb {
try {
if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) {
_preparationTasks.TryRemove(meshData.ObjectId, out _);
if (existing.IsSetup) {
foreach (var (partId, _) in existing.SetupParts) {
IncrementRefCount(partId);
lock (_lruList) {
_lruList.Remove(partId);
}
}
}
else {
// Increment ref counts for all textures in this GfxObj
foreach (var batch in existing.Batches) {
if (batch.Atlas != null) {
batch.Atlas.AddTexture(batch.Key, Array.Empty<byte>());
}
}
}
IncrementRefCount(meshData.ObjectId);
lock (_lruList) {
_lruList.Remove(meshData.ObjectId);
}
UpdateLruAfterUpload(meshData.ObjectId);
return existing;
}
@ -588,7 +558,6 @@ namespace AcDream.App.Rendering.Wb {
MemorySize = 1024 // Small overhead for the setup itself
};
_renderData.TryAdd(meshData.ObjectId, data);
IncrementRefCount(meshData.ObjectId);
_currentGpuMemory += data.MemorySize;
// Increment ref counts for all parts
@ -596,6 +565,8 @@ namespace AcDream.App.Rendering.Wb {
IncrementRefCount(partId);
}
UpdateLruAfterUpload(meshData.ObjectId);
return data;
}
@ -619,15 +590,12 @@ namespace AcDream.App.Rendering.Wb {
renderData.DIDDegrade = meshData.DIDDegrade;
renderData.SelectionSphere = meshData.SelectionSphere;
_renderData.TryAdd(meshData.ObjectId, renderData);
IncrementRefCount(meshData.ObjectId);
_currentGpuMemory += renderData.MemorySize;
UpdateLruAfterUpload(meshData.ObjectId);
// Clear texture data after upload to save RAM
foreach (var batchList in meshData.TextureBatches.Values) {
foreach (var batch in batchList) {
batch.TextureData = Array.Empty<byte>();
}
}
// 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) {
@ -636,6 +604,14 @@ namespace AcDream.App.Rendering.Wb {
}
}
private void UpdateLruAfterUpload(ulong id) {
lock (_lruList) {
_lruList.Remove(id);
if (!_ownership.MarkUploadComplete(id))
_lruList.AddLast(id);
}
}
/// <summary>
/// Gets bounding box for an object (for frustum culling).
/// </summary>

View file

@ -151,6 +151,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
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?.TryGetRenderData(id) is not null;
}
/// <inheritdoc/>
public void IncrementRefCount(ulong id)
{
@ -199,6 +208,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_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
@ -252,14 +271,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<ObjectMeshData>? requeue = null;
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
while (_meshManager!.TryDequeueStagedMeshData(out var meshData))
{
if (meshData is null)
continue;
if (_meshManager.UploadOrRequeue(meshData))
(requeue ??= new()).Add(meshData);
}
if (requeue is not null)
foreach (var m in requeue)
_meshManager.StagedMeshData.Enqueue(m);
_meshManager.RequeueStagedMeshData(m);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe