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>
125 lines
4.1 KiB
C#
125 lines
4.1 KiB
C#
using System.Linq;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Streaming;
|
|
|
|
public class GpuWorldStateTwoTierTests
|
|
{
|
|
private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
|
|
=> new(canonicalId, new LandBlock(), entities);
|
|
|
|
private static WorldEntity MakeStubEntity(uint id)
|
|
=> new()
|
|
{
|
|
Id = id,
|
|
SourceGfxObjOrSetupId = 0x01000001u,
|
|
Position = System.Numerics.Vector3.Zero,
|
|
Rotation = System.Numerics.Quaternion.Identity,
|
|
MeshRefs = System.Array.Empty<MeshRef>(),
|
|
};
|
|
|
|
[Fact]
|
|
public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities()
|
|
{
|
|
var state = new GpuWorldState();
|
|
var lb = MakeStubLandblock(0xAAAAFFFFu,
|
|
MakeStubEntity(1),
|
|
MakeStubEntity(2));
|
|
state.AddLandblock(lb);
|
|
Assert.Equal(2, state.Entities.Count);
|
|
|
|
state.RemoveEntitiesFromLandblock(0xAAAAFFFFu);
|
|
|
|
Assert.Empty(state.Entities);
|
|
Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident
|
|
}
|
|
|
|
[Fact]
|
|
public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
|
|
{
|
|
var state = new GpuWorldState();
|
|
var lb = MakeStubLandblock(0xAAAAFFFFu, MakeStubEntity(1));
|
|
state.AddLandblock(lb);
|
|
|
|
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
|
|
{
|
|
MakeStubEntity(2),
|
|
MakeStubEntity(3),
|
|
});
|
|
|
|
Assert.Equal(3, state.Entities.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddEntitiesToExistingLandblock_LandblockNotYetLoaded_ParksInPending()
|
|
{
|
|
var state = new GpuWorldState();
|
|
|
|
// Landblock not loaded yet.
|
|
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
|
|
{
|
|
MakeStubEntity(1),
|
|
MakeStubEntity(2),
|
|
});
|
|
|
|
// Nothing in the flat view yet.
|
|
Assert.Empty(state.Entities);
|
|
Assert.Equal(2, state.PendingLiveEntityCount);
|
|
|
|
// Now load the landblock — pending entities should merge in.
|
|
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu));
|
|
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);
|
|
}
|
|
}
|