diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 966bf9c..9024047 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -343,14 +343,29 @@ public sealed class GpuWorldState /// 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. + /// + /// + /// Persistent-entity rescue is intentionally omitted (unlike + /// ): demote-tier entities are atlas-tier + /// only (procedural scenery, dat-static stabs/buildings) — they never + /// have ServerGuid != 0 and so can never be in . + /// The local player and other live server-spawned entities live in their + /// landblock via RelocateEntity per frame and are not affected + /// by Near→Far demotion of dat-static landblock layers. + /// /// public void RemoveEntitiesFromLandblock(uint landblockId) { - if (!_loaded.TryGetValue(landblockId, out var lb)) return; + // A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity. + // Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this + // protects against future callers that mirror AppendLiveEntity's + // cell-resolved-id pattern. + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (!_loaded.TryGetValue(canonical, 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); + _wbSpawnAdapter.OnLandblockUnloaded(canonical); + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); + _pendingByLandblock.Remove(canonical); RebuildFlatView(); } @@ -361,16 +376,23 @@ public sealed class GpuWorldState /// landblock isn't loaded yet (handles the rare "promote arrives before /// far load completes" race). /// Per Phase A.5 spec §4.4. + /// + /// + /// Landblock id is canonicalized (low 16 bits forced to 0xFFFF) — + /// callers may pass cell-resolved ids and they will key correctly. + /// /// - public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList entities) + public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList entities) { - if (!_loaded.TryGetValue(landblockId, out var lb)) + // A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity. + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (!_loaded.TryGetValue(canonical, out var lb)) { // Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs. - if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket)) + if (!_pendingByLandblock.TryGetValue(canonical, out var bucket)) { bucket = new List(); - _pendingByLandblock[landblockId] = bucket; + _pendingByLandblock[canonical] = bucket; } bucket.AddRange(entities); return; @@ -378,9 +400,9 @@ public sealed class GpuWorldState 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); + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]); + _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); RebuildFlatView(); } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index a9a8864..ac74ae6 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -25,8 +25,25 @@ public sealed class StreamingController private readonly GpuWorldState _state; private StreamingRegion? _region; - public int NearRadius { get; set; } - public int FarRadius { get; set; } + /// + /// Near-tier radius (LBs from observer that load full detail: terrain + + /// scenery + entities). Set at construction; readable thereafter. + /// + /// + /// Mutating after the first has no effect — the + /// internal snapshots both radii on its + /// constructor. Treat as init-only post-Tick. + /// + public int NearRadius { get; } + + /// + /// Far-tier radius (LBs from observer that load terrain only). Set at + /// construction; readable thereafter. + /// + /// + /// Mutating after the first has no effect — see . + /// + public int FarRadius { get; } /// /// Cap on completions drained per call. The cap is diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index bc18249..7b0de6c 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -35,4 +35,90 @@ public class StreamingControllerTwoTierTests Assert.Equal(9, nearCount); // 3x3 inner ring (radius=1) Assert.Equal(40, farCount); // 7x7 - 3x3 outer ring (radius=3) } + + [Fact] + public void Tick_PlayerWalksOutOfNear_ToDemoteRoutesToRemoveEntities() + { + // Setup: bootstrap region at (100,100) with near=1, far=3. + // The bootstrap puts LB (100,100) in the near tier. + // Walking 4+ east drops LB (100,100) past the near-hysteresis + // threshold (NearRadius+2 = 3); ToDemote should fire. + + var loads = new List<(uint, LandblockStreamJobKind)>(); + var unloads = new List(); + var state = new GpuWorldState(); + + // Pre-load LB (100,100) so RemoveEntitiesFromLandblock has something + // to find. The actual entity content doesn't matter for routing. + var lb100 = new LoadedLandblock( + (100u << 24) | (100u << 16) | 0xFFFFu, + Heightmap: null!, + Entities: new[] { new WorldEntity { Id = 1, MeshRefs = System.Array.Empty() } }); + state.AddLandblock(lb100); + Assert.Equal(1, state.Entities.Count); + + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => System.Array.Empty(), + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 1, + farRadius: 3); + + ctrl.Tick(observerCx: 100, observerCy: 100); // bootstrap + loads.Clear(); + + // Walk 4 east — LB (100,100) is now Chebyshev distance 4 from new + // center (104,100). NearRadius+2 = 3, so 4 > 3 fires the demote. + ctrl.Tick(observerCx: 104, observerCy: 100); + + // ToDemote runs synchronously on the render thread (no enqueue). + // The visible effect is RemoveEntitiesFromLandblock dropping the entity. + Assert.Empty(state.Entities); + // Terrain stays loaded (demote != unload). + Assert.True(state.IsLoaded((100u << 24) | (100u << 16) | 0xFFFFu)); + } + + [Fact] + public void Tick_DrainingPromoted_RoutesToAddEntitiesToExisting() + { + var loads = new List<(uint, LandblockStreamJobKind)>(); + var unloads = new List(); + var state = new GpuWorldState(); + + // Pre-load a far-tier-style LB record (terrain only, no entities). + uint lbId = 0x32320FFFu; + var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty()); + state.AddLandblock(lb); + Assert.Empty(state.Entities); + + // Streamer pushes a Promoted result carrying the entity layer. + var promoted = new LandblockStreamResult.Promoted( + lbId, + new[] { new WorldEntity { Id = 7, MeshRefs = System.Array.Empty() } }); + var queue = new Queue(); + queue.Enqueue(promoted); + + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && queue.Count > 0) batch.Add(queue.Dequeue()); + return batch; + }, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 2, + farRadius: 2); + + ctrl.Tick(50, 50); // drains the Promoted result + + // Promoted routes to AddEntitiesToExistingLandblock — the entity is now + // merged into the existing LB record. + Assert.Equal(1, state.Entities.Count); + Assert.Equal(7u, state.Entities[0].Id); + } }