diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index a256d26..966bf9c 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -339,6 +339,51 @@ public sealed class GpuWorldState bucket.Add(entity); } + /// + /// Drop all entities from a landblock without removing the terrain. Used + /// by two-tier streaming when a landblock crosses Near→Far hysteresis. + /// Per Phase A.5 spec §4.4. + /// + public void RemoveEntitiesFromLandblock(uint landblockId) + { + if (!_loaded.TryGetValue(landblockId, out var lb)) return; + if (_wbSpawnAdapter is not null) + _wbSpawnAdapter.OnLandblockUnloaded(landblockId); + _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); + _pendingByLandblock.Remove(landblockId); + RebuildFlatView(); + } + + /// + /// Merge entities into an existing-loaded landblock. Used by two-tier + /// streaming for the Far→Near promotion case (terrain already loaded; + /// entity layer streaming in). Falls back to the pending bucket if the + /// landblock isn't loaded yet (handles the rare "promote arrives before + /// far load completes" race). + /// Per Phase A.5 spec §4.4. + /// + public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList entities) + { + if (!_loaded.TryGetValue(landblockId, out var lb)) + { + // Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs. + if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket)) + { + bucket = new List(); + _pendingByLandblock[landblockId] = bucket; + } + bucket.AddRange(entities); + return; + } + var merged = new List(lb.Entities.Count + entities.Count); + merged.AddRange(lb.Entities); + merged.AddRange(entities); + _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); + if (_wbSpawnAdapter is not null) + _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]); + RebuildFlatView(); + } + private void RebuildFlatView() { _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs new file mode 100644 index 0000000..11ab0c5 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs @@ -0,0 +1,76 @@ +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(), + }; + + [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); + } +}