feat(render #53): wire EntityClassificationCache.InvalidateLandblock at LB demote/unload

GpuWorldState.RemoveEntitiesFromLandblock now invokes an optional
Action<uint> callback before zeroing the entity list. GameWindow wires
this to EntityClassificationCache.InvalidateLandblock so cache entries
get swept on LB demote (Near to Far) and unload. Per spec section 5.3 W3b.

The callback receives the canonicalized landblock id (low 16 bits forced
to 0xFFFF), matching the LandblockHint stored at Populate time. Trace:
GpuWorldState._loaded keys are canonical (set by AppendLiveEntity),
LandblockEntries yields kvp.Key as LandblockId, WalkEntitiesInto
propagates entry.LandblockId into _walkScratch, the dispatcher's
populateLandblockId reads that tuple and stores it as LandblockHint.

Phase 3 (invalidation hooks) complete. The cache now stays correct across
all spec-identified mutation events: despawn, ObjDescEvent (despawn+
respawn), LB demote, LB unload.

Two integration tests added:
- RemoveEntitiesFromLandblock_FiresUnloadCallbackWithCanonicalId asserts
  the callback fires once with the canonical id even when called with a
  cell-resolved input (low 16 bits non-FFFF).
- RemoveEntitiesFromLandblock_NotLoaded_DoesNotFireCallback asserts the
  early-return path doesn't fire the callback for unknown landblocks.

Tests: 1706 passed / 8 failed (baseline). Sentinel: 110/110.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 19:32:16 +02:00
parent 1d1afcd562
commit 489174f21c
3 changed files with 80 additions and 2 deletions

View file

@ -1612,7 +1612,14 @@ public sealed class GameWindow : IDisposable
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!); _textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter; _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( _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,

View file

@ -42,12 +42,26 @@ public sealed class GpuWorldState
private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
/// <summary>
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
/// <see cref="RemoveEntitiesFromLandblock"/> zeroes a landblock's entity
/// list. Wired by <c>GameWindow</c> to
/// <c>EntityClassificationCache.InvalidateLandblock</c> 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 <c>0xFFFF</c>),
/// matching the <c>LandblockHint</c> stored at <c>Populate</c> time.
/// Null when the cache isn't relevant (tests).
/// </summary>
private readonly System.Action<uint>? _onLandblockUnloaded;
public GpuWorldState( public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null, LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null) EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null)
{ {
_wbSpawnAdapter = wbSpawnAdapter; _wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter; _wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
} }
private readonly Dictionary<uint, LoadedLandblock> _loaded = new(); private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
@ -380,6 +394,14 @@ public sealed class GpuWorldState
if (!_loaded.TryGetValue(canonical, out var lb)) return; if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null) if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical); _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<WorldEntity>()); _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(canonical); _pendingByLandblock.Remove(canonical);
RebuildFlatView(); RebuildFlatView();

View file

@ -73,4 +73,53 @@ public class GpuWorldStateTwoTierTests
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu)); state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu));
Assert.Equal(2, state.Entities.Count); Assert.Equal(2, state.Entities.Count);
} }
/// <summary>
/// Phase Post-A.5 #53 (Task 12): the optional <c>onLandblockUnloaded</c>
/// callback fires once when <see cref="GpuWorldState.RemoveEntitiesFromLandblock"/>
/// drops a landblock's entity list, and is passed the canonicalized
/// landblock id (matching the <c>LandblockHint</c> the cache stored at
/// <c>Populate</c> time).
/// </summary>
[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);
}
/// <summary>
/// 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 <c>_wbSpawnAdapter.OnLandblockUnloaded</c> guard.
/// </summary>
[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);
}
} }