using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.Rendering.Wb; 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 LandblockSpawnAdapter? _wbSpawnAdapter; private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; public GpuWorldState( LandblockSpawnAdapter? wbSpawnAdapter = null, EntitySpawnAdapter? wbEntitySpawnAdapter = null) { _wbSpawnAdapter = wbSpawnAdapter; _wbEntitySpawnAdapter = wbEntitySpawnAdapter; } 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); /// /// Try to grab the loaded record for a landblock — useful for callers /// that need to enumerate entities before the landblock is dropped /// (e.g. unregistering dynamic lights on a RemoveLandblock). /// public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb) { if (_loaded.TryGetValue(landblockId, out var found)) { lb = found; return true; } lb = null; return false; } /// /// 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; if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); 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) { if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockUnloaded(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; } /// /// Remove every entity with the given from /// all loaded landblocks AND all pending buckets, then rebuild the flat /// view. Used by the live CreateObject handler to de-duplicate /// when the server re-sends a spawn (visibility refresh, landblock /// crossing, etc.). Without this, multiple copies of the same NPC /// accumulate in the renderer, each with its own PaletteOverride /// and MeshRefs — producing "NPC clothing flickers as I turn the /// camera" because the depth test picks different duplicates frame-to-frame. /// /// Safe to call with a server guid that's not currently present — no-op. /// public void RemoveEntityByServerGuid(uint serverGuid) { if (serverGuid == 0) return; // Phase N.4 Task 17: release per-instance state for server-spawned // entities. No-op for atlas-tier entities (never registered). _wbEntitySpawnAdapter?.OnRemove(serverGuid); bool rebuiltLoaded = false; // Scan loaded landblocks. ToArray() so we can mutate _loaded inside. foreach (var kvp in _loaded.ToArray()) { var lb = kvp.Value; int foundCount = 0; for (int i = 0; i < lb.Entities.Count; i++) if (lb.Entities[i].ServerGuid == serverGuid) foundCount++; if (foundCount == 0) continue; var newList = new List(lb.Entities.Count - foundCount); foreach (var e in lb.Entities) if (e.ServerGuid != serverGuid) newList.Add(e); _loaded[kvp.Key] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newList); rebuiltLoaded = true; } // Scrub pending buckets too — a duplicate CreateObject may arrive // while the landblock is still loading. foreach (var kvp in _pendingByLandblock) kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid); if (rebuiltLoaded) RebuildFlatView(); } /// /// 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) { // Phase N.4 Task 17: route server-spawned entities through the // per-instance adapter. Atlas-tier entities (ServerGuid == 0) are // skipped by OnCreate — it returns null immediately for those. _wbEntitySpawnAdapter?.OnCreate(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(); } }