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);
+ }
}