diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 0d5b647..aa88be9 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -11,20 +11,41 @@ namespace AcDream.App.Streaming;
/// frame.
///
///
-/// Replaces GameWindow._entities, which was a flat list updated in
-/// multiple places. This class is the single point of truth for "what's in
-/// the world right now" and the only thing that mutates it.
+/// Replaces the pre-streaming flat _entities list. This class is the
+/// single point of truth for "what's in the world right now" and the only
+/// thing that mutates it.
+///
+///
+///
+/// Pending live entities. Live CreateObject spawns can race
+/// against streaming: the server may send a spawn for landblock X before
+/// X is loaded into (frequently true on the first
+/// frame after login, where the entire post-login spawn flood drains
+/// before the streaming controller has finished loading the visible
+/// window). To survive this race, stores
+/// orphaned spawns in a per-landblock pending bucket. When
+/// later loads the landblock, the matching
+/// pending entries are merged into the loaded record before the flat
+/// view rebuild. drops pending entries for
+/// the same landblock — if the landblock just left the visible window,
+/// any spawns that came with it are no longer relevant.
///
///
///
/// Threading: not thread-safe. All calls must happen on the render thread.
-/// The streaming worker never touches this type.
///
///
public sealed class GpuWorldState
{
private readonly Dictionary _loaded = new();
+ ///
+ /// Per-landblock buffer of live entities awaiting their landblock's
+ /// arrival. Keyed by canonical landblock id (0xAAAA0xFFFF).
+ /// Drained into in .
+ ///
+ private readonly Dictionary> _pendingByLandblock = new();
+
// Cached flat view over all entities across all loaded landblocks,
// rebuilt on each add/remove. The renderer holds a reference to this
// list, so rebuilding it replaces the reference atomically.
@@ -35,48 +56,94 @@ public sealed class GpuWorldState
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
+ ///
+ /// Total live entities currently parked in the pending bucket waiting
+ /// for their landblock to arrive. Useful diagnostic for verifying the
+ /// pending path is doing its job.
+ ///
+ public int PendingLiveEntityCount => _pendingByLandblock.Values.Sum(list => list.Count);
+
public void AddLandblock(LoadedLandblock landblock)
{
+ // If pending live entities have been waiting for this landblock,
+ // merge them into the LoadedLandblock record before storing. The
+ // record's Entities field is IReadOnlyList; we replace the whole
+ // list rather than try to mutate in place.
+ if (_pendingByLandblock.TryGetValue(landblock.LandblockId, out var pending) && pending.Count > 0)
+ {
+ var merged = new List(landblock.Entities.Count + pending.Count);
+ merged.AddRange(landblock.Entities);
+ merged.AddRange(pending);
+ landblock = new LoadedLandblock(landblock.LandblockId, landblock.Heightmap, merged);
+ _pendingByLandblock.Remove(landblock.LandblockId);
+ }
+
_loaded[landblock.LandblockId] = landblock;
RebuildFlatView();
}
public void RemoveLandblock(uint landblockId)
{
+ // Drop pending entries for the same landblock — if the landblock
+ // is being unloaded the player has moved away from it, and any
+ // pending spawns that arrived for it are no longer relevant. The
+ // server will resend them via CreateObject when the player returns.
+ _pendingByLandblock.Remove(landblockId);
+
if (_loaded.Remove(landblockId))
RebuildFlatView();
}
///
/// Append an entity to a specific landblock's slot. Used by the live
- /// CreateObject path where the server spawns entities into an already-
- /// loaded landblock after the initial hydration pass.
+ /// CreateObject path where the server spawns entities at a server-side
+ /// position whose landblock may or may not be loaded yet.
///
///
/// The server's landblockId is in cell-resolved form
/// (0xAAAA00CC: high byte X, second byte Y, low 16 bits cell
/// index within the landblock). The streaming system stores landblocks
- /// keyed by their canonical 0xAAAA0xFFFF form. Canonicalize on
- /// the way in so callers don't have to think about it — masking with
- /// 0xFFFF0000u | 0xFFFFu drops the cell index and selects the
- /// LandBlock dat terminator.
+ /// keyed by their canonical 0xAAAA0xFFFF form. Canonicalize
+ /// on the way in so callers don't have to think about it.
+ ///
+ ///
+ ///
+ /// Outcome:
+ ///
+ /// - If the landblock is already loaded, the entity is appended
+ /// to its Entities list and the flat view is rebuilt
+ /// immediately.
+ /// - If the landblock is not yet loaded, the entity is parked
+ /// in and will be merged
+ /// into the next for the same id.
+ ///
///
///
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
{
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
- if (!_loaded.TryGetValue(canonicalLandblockId, out var lb))
- return;
- // LoadedLandblock.Entities is an IReadOnlyList. Rebuild the
- // landblock record with the new entity appended. We accept the
- // allocation here because live spawns are rare compared to frame
- // iteration.
- var newEntities = new List(lb.Entities.Count + 1);
- newEntities.AddRange(lb.Entities);
- newEntities.Add(entity);
- _loaded[canonicalLandblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newEntities);
- RebuildFlatView();
+ if (_loaded.TryGetValue(canonicalLandblockId, out var lb))
+ {
+ // Hot path — landblock is already loaded. Rebuild the record
+ // with the new entity appended.
+ var newEntities = new List(lb.Entities.Count + 1);
+ newEntities.AddRange(lb.Entities);
+ newEntities.Add(entity);
+ _loaded[canonicalLandblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newEntities);
+ RebuildFlatView();
+ return;
+ }
+
+ // Cold path — landblock not yet loaded. Park the entity in the
+ // pending bucket; AddLandblock will pick it up when the streamer
+ // delivers the matching landblock.
+ if (!_pendingByLandblock.TryGetValue(canonicalLandblockId, out var bucket))
+ {
+ bucket = new List();
+ _pendingByLandblock[canonicalLandblockId] = bucket;
+ }
+ bucket.Add(entity);
}
private void RebuildFlatView()
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index c5e8050..35362a6 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -24,7 +24,28 @@ public sealed class StreamingController
private StreamingRegion? _region;
public int Radius { get; set; }
- public int MaxCompletionsPerFrame { get; set; } = 4;
+
+ ///
+ /// Cap on completions drained per call. Defaults to
+ /// effectively unlimited because the current LandblockStreamer
+ /// is synchronous — every EnqueueLoad writes to the outbox on
+ /// the same thread, so by the time we drain there's no backlog
+ /// to spread, and the cap only serves to *delay* applying landblocks
+ /// the user is already trying to look at.
+ ///
+ ///
+ /// The original async design used a small cap (4) to limit per-frame
+ /// GPU upload spikes. That reasoning becomes relevant again if/when
+ /// the streamer moves back to async loading; lower this knob then.
+ /// Crucially, dropping completions to a lower frame is what was
+ /// silently breaking live spawns: the post-login spawn flood would
+ /// arrive on a frame where only 4 of the 25 visible-window landblocks
+ /// had been applied, the spawns for the other 21 hit
+ /// AppendLiveEntity with no matching loaded slot, and got
+ /// dropped (now: parked in the pending bucket).
+ ///
+ ///
+ public int MaxCompletionsPerFrame { get; set; } = int.MaxValue;
public StreamingController(
Action enqueueLoad,
diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs
new file mode 100644
index 0000000..814d2d5
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs
@@ -0,0 +1,140 @@
+using System;
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+///
+/// Covers 's pending-spawn behavior — the path
+/// that survives the race where the server delivers a CreateObject for a
+/// landblock that hasn't been streamed in yet. This was the bug that
+/// silently dropped 40+ NPCs on the first frame after live login during
+/// Phase A.1 visual verification.
+///
+public class GpuWorldStateTests
+{
+ private static LoadedLandblock MakeStubLandblock(uint canonicalId)
+ => new(canonicalId, new LandBlock(), Array.Empty());
+
+ private static WorldEntity MakeStubEntity(uint id)
+ => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x01000001u,
+ Position = System.Numerics.Vector3.Zero,
+ Rotation = System.Numerics.Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ [Fact]
+ public void AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately()
+ {
+ var state = new GpuWorldState();
+ state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
+
+ // Server sends a spawn at landblock 0xA9B40011 — same landblock,
+ // cell index 0x0011. Canonicalize drops the cell index, lookup
+ // succeeds, entity lands in the loaded landblock immediately.
+ state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
+
+ Assert.Single(state.Entities);
+ Assert.Equal(0u, (uint)state.PendingLiveEntityCount);
+ }
+
+ [Fact]
+ public void AppendLiveEntity_LandblockNotLoaded_ParksInPending()
+ {
+ var state = new GpuWorldState();
+
+ // No landblock loaded — the spawn must survive instead of being
+ // silently dropped (the bug from Phase A.1's first live run).
+ state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
+
+ Assert.Empty(state.Entities); // not visible yet
+ Assert.Equal(1, state.PendingLiveEntityCount);
+ }
+
+ [Fact]
+ public void AddLandblock_DrainsPendingEntriesForThatLandblock()
+ {
+ var state = new GpuWorldState();
+
+ // Three spawns arrive before the landblock loads.
+ state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
+ state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
+ state.AppendLiveEntity(0xA9B40033u, MakeStubEntity(3));
+ Assert.Equal(3, state.PendingLiveEntityCount);
+ Assert.Empty(state.Entities);
+
+ // Now the landblock streams in.
+ state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
+
+ // The three pending entities are now visible, and the pending
+ // bucket for that landblock is empty.
+ Assert.Equal(3, state.Entities.Count);
+ Assert.Equal(0, state.PendingLiveEntityCount);
+ }
+
+ [Fact]
+ public void AddLandblock_DoesNotDrainPendingForADifferentLandblock()
+ {
+ var state = new GpuWorldState();
+
+ state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
+ state.AppendLiveEntity(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF
+
+ // Loading 0xA9B4FFFF only drains its own bucket.
+ state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
+
+ Assert.Single(state.Entities);
+ Assert.Equal(1, state.PendingLiveEntityCount); // 0xAAAAFFFF entry still parked
+ }
+
+ [Fact]
+ public void RemoveLandblock_DropsPendingForThatLandblock()
+ {
+ var state = new GpuWorldState();
+
+ // Spawns for landblock 0xA9B4FFFF arrive while it's pending.
+ state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
+ state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2));
+ Assert.Equal(2, state.PendingLiveEntityCount);
+
+ // Player moves away — the streamer says "this landblock is no
+ // longer in the visible window, drop it." The pending entries
+ // for it are dropped too because they came along with that
+ // landblock and are no longer relevant.
+ state.RemoveLandblock(0xA9B4FFFFu);
+
+ Assert.Equal(0, state.PendingLiveEntityCount);
+ Assert.Empty(state.Entities);
+ }
+
+ [Fact]
+ public void RemoveLandblock_LoadedThenRemoved_DropsItsEntities()
+ {
+ var state = new GpuWorldState();
+
+ state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
+ state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
+ Assert.Single(state.Entities);
+
+ state.RemoveLandblock(0xA9B4FFFFu);
+
+ Assert.Empty(state.Entities);
+ }
+
+ [Fact]
+ public void IsLoaded_ReturnsTrueForLoaded_FalseForPendingOnly()
+ {
+ var state = new GpuWorldState();
+
+ state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
+ Assert.False(state.IsLoaded(0xA9B4FFFFu)); // pending doesn't count
+
+ state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
+ Assert.True(state.IsLoaded(0xA9B4FFFFu));
+ }
+}