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