diff --git a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs index 43bb303d..46192423 100644 --- a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs +++ b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs @@ -8,6 +8,7 @@ internal enum MeshStageResult Staged, AlreadyStaged, HighWater, + NotOwned, } /// @@ -282,7 +283,7 @@ internal sealed class CpuMeshUploadCache // review). Read via Stats from the diagnostics/checkpoint thread // without taking _data's lock, so plain fields with Interlocked // increments — the 4 concurrent preparation workers all consult - // TryGetAndStage. + // TryGetAndStageOwned. private long _hits; private long _misses; private long _evictions; @@ -307,12 +308,15 @@ internal sealed class CpuMeshUploadCache internal int Capacity => _capacity; internal long ByteCapacity => _byteCapacity; - public bool TryGetAndStage( + public bool TryGetAndStageOwned( ulong id, MeshUploadStagingQueue staging, + MeshOwnershipCounter ownership, out ObjectMeshData? data, out MeshStageResult stageResult) { + ArgumentNullException.ThrowIfNull(staging); + ArgumentNullException.ThrowIfNull(ownership); lock (_data) { if (!_data.TryGetValue(id, out data)) { @@ -323,7 +327,13 @@ internal sealed class CpuMeshUploadCache Interlocked.Increment(ref _hits); _lru.Remove(id); _lru.AddLast(id); - stageResult = staging.TryStage(data); + // Prepared payload residency is deliberately independent from + // live render ownership. A point-of-use lookup from a retiring + // world generation must never recreate GPU staging work after + // that generation released its final mesh reference. + stageResult = ownership.IsOwned(id) + ? staging.TryStage(data) + : MeshStageResult.NotOwned; return true; } } diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 45cd3b64..d3d97c4f 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -876,9 +876,10 @@ namespace AcDream.App.Rendering.Wb } ObjectMeshData? deferredCachedData = null; - if (_cpuMeshCache.TryGetAndStage( + if (_cpuMeshCache.TryGetAndStageOwned( geomId, _stagedMeshData, + _ownership, out ObjectMeshData? cachedData, out MeshStageResult cacheStage)) { @@ -936,9 +937,10 @@ namespace AcDream.App.Rendering.Wb _terminalPreparationFailures.Remove(id); ObjectMeshData? deferredCachedData = null; - if (_cpuMeshCache.TryGetAndStage( + if (_cpuMeshCache.TryGetAndStageOwned( id, _stagedMeshData, + _ownership, out ObjectMeshData? cachedData, out MeshStageResult cacheStage)) { diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 519d8780..556157be 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -153,10 +153,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery public bool IsRenderReady(uint landblockId) => _loaded.ContainsKey(landblockId) && (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true); + /// + /// True when at least one projection for the server object belongs to a + /// loaded spatial bucket. This deliberately ignores the world-generation + /// presentation gate: destination objects must acquire their render + /// resources while portal/login reveal keeps normal world consumers closed. + /// + public bool IsLiveEntityProjectionResident(uint serverGuid) => + serverGuid != 0 + && _visibleLiveProjectionCounts.ContainsKey(serverGuid); + public bool IsLiveEntityVisible(uint serverGuid) => _availability.IsWorldAvailable - && serverGuid != 0 - && _visibleLiveProjectionCounts.ContainsKey(serverGuid); + && IsLiveEntityProjectionResident(serverGuid); /// /// Try to grab the loaded record for a landblock — useful for callers diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 3871d87e..a62d5579 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -843,7 +843,7 @@ public sealed class LiveEntityRuntime runtimeNotificationFailure: null); return false; } - bool visible = _spatial.IsLiveEntityVisible(serverGuid); + bool visible = _spatial.IsLiveEntityProjectionResident(serverGuid); record.IsSpatiallyVisible = visible; if (record.ProjectionKind is LiveEntityProjectionKind.World) _materializedWorldEntitiesByGuid[serverGuid] = entity; @@ -2584,7 +2584,7 @@ public sealed class LiveEntityRuntime // an older queued edge drains. Apply an edge only while it still // matches current spatial truth; otherwise it belongs to the displaced // projection and must not mutate the replacement generation. - if (_spatial.IsLiveEntityVisible(serverGuid) != visible) + if (_spatial.IsLiveEntityProjectionResident(serverGuid) != visible) return; if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is not { } entity) diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs index 10cc66a8..e62efe24 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs @@ -11,6 +11,7 @@ public sealed class MeshUploadCachesTests { var staging = new MeshUploadStagingQueue(); var cache = new CpuMeshUploadCache(capacity: 2); + var ownership = Owned(0x01000010ul); byte[] textureBytes = [1, 2, 3, 4]; var data = new ObjectMeshData { ObjectId = 0x01000010ul, UploadAttempts = 2 }; data.TextureBatches[(1, 1, TextureFormat.RGBA8)] = @@ -25,11 +26,13 @@ public sealed class MeshUploadCachesTests staging.Complete(initial); // GPU upload completed, then was later evicted. data.UploadAttempts = 3; - Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached, out _)); + Assert.True(cache.TryGetAndStageOwned( + data.ObjectId, staging, ownership, out var cached, out _)); Assert.Same(data, cached); Assert.Equal(0, data.UploadAttempts); Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData); - Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _, out _)); + Assert.True(cache.TryGetAndStageOwned( + data.ObjectId, staging, ownership, out _, out _)); Assert.True(staging.TryDequeue(out var restaged)); Assert.Same(data, restaged.Data); Assert.False(staging.TryDequeue(out _)); @@ -89,19 +92,22 @@ public sealed class MeshUploadCachesTests { var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64); var cache = new CpuMeshUploadCache(capacity: 2); + var ownership = Owned(2); ObjectMeshData queued = DataWithTexture(1, 40); ObjectMeshData cached = DataWithTexture(2, 40); cache.Store(cached); Assert.True(staging.Stage(queued)); - Assert.True(cache.TryGetAndStage(2, staging, out ObjectMeshData? found, out MeshStageResult result)); + Assert.True(cache.TryGetAndStageOwned( + 2, staging, ownership, out ObjectMeshData? found, out MeshStageResult result)); Assert.Same(cached, found); Assert.Equal(MeshStageResult.HighWater, result); Assert.Equal(1, staging.Count); Assert.True(staging.TryDequeue(out MeshUploadQueueItem queuedItem)); staging.Complete(queuedItem); - Assert.True(cache.TryGetAndStage(2, staging, out _, out result)); + Assert.True(cache.TryGetAndStageOwned( + 2, staging, ownership, out _, out result)); Assert.Equal(MeshStageResult.Staged, result); Assert.True(staging.TryDequeue(out MeshUploadQueueItem staged)); Assert.Same(cached, staged.Data); @@ -142,23 +148,60 @@ public sealed class MeshUploadCachesTests Assert.Equal(0, staging.Count); ownership.Acquire(id); - Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? rearmed, out _)); + Assert.True(cache.TryGetAndStageOwned( + id, staging, ownership, out ObjectMeshData? rearmed, out _)); Assert.Same(data, rearmed); Assert.Equal(0, staging.DiscardUnownedPrefix(ownership, maximum: 1)); Assert.True(staging.TryPeek(out MeshUploadQueueItem queued)); Assert.Same(data, queued.Data); } + [Fact] + public void CpuCacheHitWithoutOwnerDoesNotRecreateStaleUploadWork() + { + const ulong id = 0x01000010; + var staging = new MeshUploadStagingQueue(); + var cache = new CpuMeshUploadCache(capacity: 2); + var ownership = new MeshOwnershipCounter(); + ObjectMeshData data = DataWithTexture(id, 16); + cache.Store(data); + + Assert.True(cache.TryGetAndStageOwned( + id, + staging, + ownership, + out ObjectMeshData? cached, + out MeshStageResult result)); + + Assert.Same(data, cached); + Assert.Equal(MeshStageResult.NotOwned, result); + Assert.Equal(0, staging.Count); + Assert.Equal(0, staging.ClaimCount); + + ownership.Acquire(id); + Assert.True(cache.TryGetAndStageOwned( + id, + staging, + ownership, + out _, + out result)); + Assert.Equal(MeshStageResult.Staged, result); + Assert.Equal(1, staging.Count); + } + [Fact] public void CpuCacheUsesByteBudgetInAdditionToEntryCount() { var staging = new MeshUploadStagingQueue(); var cache = new CpuMeshUploadCache(capacity: 10, byteCapacity: 64); + var ownership = Owned(1, 2); cache.Store(DataWithTexture(1, 40)); cache.Store(DataWithTexture(2, 40)); - Assert.False(cache.TryGetAndStage(1, staging, out _, out _)); - Assert.True(cache.TryGetAndStage(2, staging, out _, out _)); + Assert.False(cache.TryGetAndStageOwned( + 1, staging, ownership, out _, out _)); + Assert.True(cache.TryGetAndStageOwned( + 2, staging, ownership, out _, out _)); Assert.Equal(1, cache.Count); Assert.Equal(40, cache.ResidentBytes); } @@ -168,6 +211,7 @@ public sealed class MeshUploadCachesTests { var staging = new MeshUploadStagingQueue(maximumBytes: 256); var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64); + var ownership = Owned(1, 2); ObjectMeshData retained = DataWithTexture(1, 40); ObjectMeshData oversized = DataWithTexture(2, 80); @@ -176,8 +220,10 @@ public sealed class MeshUploadCachesTests Assert.Equal(1, cache.Count); Assert.Equal(40, cache.ResidentBytes); - Assert.False(cache.TryGetAndStage(2, staging, out _, out _)); - Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _)); + Assert.False(cache.TryGetAndStageOwned( + 2, staging, ownership, out _, out _)); + Assert.True(cache.TryGetAndStageOwned( + 1, staging, ownership, out ObjectMeshData? found, out _)); Assert.Same(retained, found); } @@ -186,11 +232,13 @@ public sealed class MeshUploadCachesTests { var staging = new MeshUploadStagingQueue(maximumBytes: 256); var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64); + var ownership = Owned(1); ObjectMeshData retained = DataWithTexture(1, 40); Assert.True(cache.Store(retained)); Assert.False(cache.Store(DataWithTexture(1, 80))); - Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _)); + Assert.True(cache.TryGetAndStageOwned( + 1, staging, ownership, out ObjectMeshData? found, out _)); Assert.Same(retained, found); Assert.Equal(40, cache.ResidentBytes); } @@ -211,7 +259,8 @@ public sealed class MeshUploadCachesTests // The new owner cache-hits while the dequeued generation still owns // staging's id claim, so its direct Stage attempt cannot enqueue yet. ownership.Acquire(id); - Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _)); + Assert.True(cache.TryGetAndStageOwned( + id, staging, ownership, out ObjectMeshData? cached, out _)); Assert.Same(data, cached); Assert.Equal(0, staging.Count); @@ -237,7 +286,8 @@ public sealed class MeshUploadCachesTests Assert.Equal(0, staging.Count); ownership.Acquire(id); - Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _)); + Assert.True(cache.TryGetAndStageOwned( + id, staging, ownership, out ObjectMeshData? cached, out _)); Assert.Same(data, cached); Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff)); Assert.Same(data, handedOff.Data); @@ -305,10 +355,13 @@ public sealed class MeshUploadCachesTests // is auditable from an artifact alone. var staging = new MeshUploadStagingQueue(); var cache = new CpuMeshUploadCache(capacity: 1); + var ownership = Owned(1); - Assert.False(cache.TryGetAndStage(1, staging, out _, out _)); // miss + Assert.False(cache.TryGetAndStageOwned( + 1, staging, ownership, out _, out _)); // miss cache.Store(DataWithTexture(1, 4)); - Assert.True(cache.TryGetAndStage(1, staging, out _, out _)); // hit + Assert.True(cache.TryGetAndStageOwned( + 1, staging, ownership, out _, out _)); // hit cache.Store(DataWithTexture(2, 4)); // capacity=1 evicts id 1 CacheStats stats = cache.Stats; @@ -317,6 +370,14 @@ public sealed class MeshUploadCachesTests Assert.Equal(1, stats.Evictions); } + private static MeshOwnershipCounter Owned(params ulong[] ids) + { + var ownership = new MeshOwnershipCounter(); + foreach (ulong id in ids) + ownership.Acquire(id); + return ownership; + } + private static ObjectMeshData DataWithTexture(ulong id, int bytes) { var data = new ObjectMeshData { ObjectId = id }; diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs index a2a07967..1de2c8a4 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs @@ -435,6 +435,43 @@ public sealed class GpuWorldStateVisibilityTests Assert.Equal(0, state.PendingVisibilityTransitionCount); } + [Fact] + public void QuiescedDestinationProjection_AcquiresSpatialPresentationBeforeWorldReveal() + { + const uint landblock = 0x0101FFFFu; + const uint cell = 0x01010001u; + const uint guid = 0x70000021u; + var availability = new WorldGenerationAvailabilityState(); + availability.Begin(7); + var state = new GpuWorldState(availability: availability); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + var runtime = new LiveEntityRuntime( + state, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + WorldSession.EntitySpawn spawn = Spawn(guid, cell); + runtime.RegisterLiveEntity(spawn); + var edges = new List(); + runtime.ProjectionVisibilityChanged += (_, visible) => edges.Add(visible); + + runtime.MaterializeLiveEntity( + guid, + cell, + id => Entity(id, guid)); + + Assert.False(state.IsLiveEntityVisible(guid)); + Assert.True(state.IsLiveEntityProjectionResident(guid)); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.True(record.IsSpatiallyVisible); + Assert.Equal([true], edges); + + Assert.True(availability.End(7)); + Assert.True(state.IsLiveEntityVisible(guid)); + Assert.True(record.IsSpatiallyVisible); + } + [Fact] public void CopyLiveEntitiesNearLandblock_UsesBoundedNeighborhoodAndSkipsStatics() {