diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2ea6f5d..c3bba03 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1612,7 +1612,14 @@ public sealed class GameWindow : IDisposable var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( _textureCache!, SequencerFactory, _wbMeshAdapter!); _wbEntitySpawnAdapter = wbEntitySpawnAdapter; - _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter); + // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock + // so Tier 1 cache entries get swept on LB demote (Near to Far) and unload. + // Per spec §5.3 W3b. The callback receives the canonical landblock id + // matching the LandblockHint stored at Populate time. + _worldState = new AcDream.App.Streaming.GpuWorldState( + wbSpawnAdapter, + wbEntitySpawnAdapter, + onLandblockUnloaded: _classificationCache.InvalidateLandblock); _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index b0ad321..2965b24 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -42,12 +42,26 @@ public sealed class GpuWorldState private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; + /// + /// Phase Post-A.5 #53 (Task 12): optional callback fired before + /// zeroes a landblock's entity + /// list. Wired by GameWindow to + /// EntityClassificationCache.InvalidateLandblock so Tier 1 cache + /// entries get swept on LB demote (Near to Far) and unload. Receives + /// the canonicalized landblock id (low 16 bits forced to 0xFFFF), + /// matching the LandblockHint stored at Populate time. + /// Null when the cache isn't relevant (tests). + /// + private readonly System.Action? _onLandblockUnloaded; + public GpuWorldState( LandblockSpawnAdapter? wbSpawnAdapter = null, - EntitySpawnAdapter? wbEntitySpawnAdapter = null) + EntitySpawnAdapter? wbEntitySpawnAdapter = null, + System.Action? onLandblockUnloaded = null) { _wbSpawnAdapter = wbSpawnAdapter; _wbEntitySpawnAdapter = wbEntitySpawnAdapter; + _onLandblockUnloaded = onLandblockUnloaded; } private readonly Dictionary _loaded = new(); @@ -380,6 +394,14 @@ public sealed class GpuWorldState if (!_loaded.TryGetValue(canonical, out var lb)) return; if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockUnloaded(canonical); + + // Phase Post-A.5 #53 (Task 12): invalidate the EntityClassificationCache + // for this landblock BEFORE we drop the entity list. The cache stores + // canonical landblock ids (the dispatcher's _walkScratch carries + // entry.LandblockId from GpuWorldState.LandblockEntries, whose keys are + // canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b. + _onLandblockUnloaded?.Invoke(canonical); + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); _pendingByLandblock.Remove(canonical); RebuildFlatView(); diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs index 11ab0c5..24950fd 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs @@ -73,4 +73,53 @@ public class GpuWorldStateTwoTierTests state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu)); Assert.Equal(2, state.Entities.Count); } + + /// + /// Phase Post-A.5 #53 (Task 12): the optional onLandblockUnloaded + /// callback fires once when + /// drops a landblock's entity list, and is passed the canonicalized + /// landblock id (matching the LandblockHint the cache stored at + /// Populate time). + /// + [Fact] + public void RemoveEntitiesFromLandblock_FiresUnloadCallbackWithCanonicalId() + { + uint? observed = null; + int callCount = 0; + var state = new GpuWorldState( + wbSpawnAdapter: null, + wbEntitySpawnAdapter: null, + onLandblockUnloaded: id => { observed = id; callCount++; }); + + state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, MakeStubEntity(1))); + + // Pass a cell-resolved id (low 16 bits non-FFFF) — the callback must + // receive the canonical (0xFFFF-tail) form, matching what the + // dispatcher's _walkScratch carries from GpuWorldState.LandblockEntries. + state.RemoveEntitiesFromLandblock(0xA9B40042u); + + Assert.Equal(1, callCount); + Assert.Equal(0xA9B4FFFFu, observed); + Assert.Empty(state.Entities); + } + + /// + /// Phase Post-A.5 #53 (Task 12): the callback must NOT fire when the + /// landblock isn't loaded — early return path. Symmetric with the + /// existing _wbSpawnAdapter.OnLandblockUnloaded guard. + /// + [Fact] + public void RemoveEntitiesFromLandblock_NotLoaded_DoesNotFireCallback() + { + int callCount = 0; + var state = new GpuWorldState( + wbSpawnAdapter: null, + wbEntitySpawnAdapter: null, + onLandblockUnloaded: _ => callCount++); + + // Landblock never loaded. + state.RemoveEntitiesFromLandblock(0xA9B4FFFFu); + + Assert.Equal(0, callCount); + } }