using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
///
/// Render-thread-owned registry of currently-loaded landblocks and their
/// entities. All mutation happens in
/// on the render thread; the renderer reads once per
/// frame.
///
///
/// 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.
///
///
public sealed class GpuWorldState
{
private readonly Dictionary _loaded = new();
private readonly Dictionary _aabbs = 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();
///
/// Entities that must survive landblock unloads (e.g. the player character).
/// On RemoveLandblock, these are rescued and re-parked as pending for their
/// current canonical landblock.
///
private readonly HashSet _persistentGuids = 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.
private IReadOnlyList _flatEntities = System.Array.Empty();
public IReadOnlyList Entities => _flatEntities;
public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys;
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
///
/// Store the axis-aligned bounding box for a loaded landblock. Called from
/// the render thread after the terrain mesh is built and uploaded.
///
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
{
_aabbs[landblockId] = (min, max);
}
///
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield
/// for both corners, which the culler will conservatively treat as visible.
///
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> LandblockEntries
{
get
{
foreach (var kvp in _loaded)
{
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
}
}
}
///
/// 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();
}
///
/// Mark a server-GUID as persistent — this entity survives landblock unloads
/// and gets re-parked as pending for its current canonical landblock.
///
public void MarkPersistent(uint serverGuid)
{
_persistentGuids.Add(serverGuid);
}
///
/// Move a persistent entity from its current landblock slot to a new one.
/// Called every frame for the player entity so it stays in the landblock
/// matching its actual position (not its spawn landblock). Without this,
/// the entity stays in the spawn landblock and gets frustum-culled when
/// the player walks away.
///
public void RelocateEntity(WorldEntity entity, uint newCanonicalLb)
{
if (entity.ServerGuid == 0) return;
// Remove from current landblock (find it by scanning)
foreach (var kvp in _loaded)
{
var entities = kvp.Value.Entities;
for (int i = 0; i < entities.Count; i++)
{
if (ReferenceEquals(entities[i], entity))
{
if (kvp.Key == newCanonicalLb) return; // already in the right place
// Remove from old
var newList = new List(entities.Count - 1);
for (int j = 0; j < entities.Count; j++)
if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock(kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
// Add to new (via AppendLiveEntity which handles pending)
AppendLiveEntity(newCanonicalLb, entity);
return;
}
}
}
}
public void RemoveLandblock(uint landblockId)
{
// Rescue persistent entities before removal. These get appended
// to the _persistentRescued list; the caller is responsible for
// re-injecting them (via AppendLiveEntity) into whatever landblock
// the player is currently on.
if (_loaded.TryGetValue(landblockId, out var lb))
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid))
{
_persistentRescued.Add(entity);
}
}
}
_pendingByLandblock.Remove(landblockId);
_aabbs.Remove(landblockId);
if (_loaded.Remove(landblockId))
RebuildFlatView();
}
private readonly List _persistentRescued = new();
///
/// Drain entities rescued from unloaded landblocks. The caller should
/// re-inject each via with its current position.
///
public List DrainRescued()
{
if (_persistentRescued.Count == 0) return _persistentRescued;
var result = new List(_persistentRescued);
_persistentRescued.Clear();
return result;
}
///
/// Append an entity to a specific landblock's slot. Used by the live
/// 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.
///
///
///
/// 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))
{
// 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()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
}
}