using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.Rendering.Vfx; 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 spatial buckets. Logical live-object identity and /// create-time resources are owned by . /// /// /// /// 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. retains non-persistent live /// entries in that pending bucket so a later reload restores the same identity; /// only dat-static entries are discarded with streaming residence. /// /// /// /// Threading: not thread-safe. All calls must happen on the render thread. /// /// public sealed class GpuWorldState { private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly EntityScriptActivator? _entityScriptActivator; /// /// Phase Post-A.5 #53 (Task 12): optional callback fired before /// zeroes a landblock's entity /// list. Wired by GameWindow to /// EntityClassificationCache.InvalidateLandblock so Tier 1 cache /// entries get swept on LB demote (Near to Far) and unload. Receives /// the canonicalized landblock id (low 16 bits forced to 0xFFFF), /// matching the LandblockHint stored at Populate time. /// Null when the cache isn't relevant (tests). /// private readonly System.Action? _onLandblockUnloaded; public GpuWorldState( LandblockSpawnAdapter? wbSpawnAdapter = null, System.Action? onLandblockUnloaded = null, EntityScriptActivator? entityScriptActivator = null) { _wbSpawnAdapter = wbSpawnAdapter; _onLandblockUnloaded = onLandblockUnloaded; _entityScriptActivator = entityScriptActivator; } 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(); private readonly HashSet _visibleLiveGuids = new(); private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new(); private bool _dispatchingVisibilityTransitions; public IReadOnlyList Entities => _flatEntities; public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys; public event Action? LiveProjectionVisibilityChanged; public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); public bool IsLiveEntityVisible(uint serverGuid) => serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid); /// /// 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. /// /// /// A.5 T17: also yields an AnimatedById dictionary built on the fly /// from the landblock's entity list. This lets /// skip the full entity walk when the landblock is frustum-culled but animated /// entities inside it must still be processed (Change #1). /// Building the dict per-yield is cheap (~132 entities/LB max). A caching /// layer is out of A.5 scope. /// /// public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries => EnumerateLandblockEntries(includeAnimatedIndex: true); /// /// Per-landblock render entries without the animated lookup dictionary. /// Static render passes use this to avoid rebuilding an index they cannot /// consume. /// public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntriesWithoutAnimatedIndex => EnumerateLandblockEntries(includeAnimatedIndex: false); /// /// Lightweight bounds-only enumeration for overlays and diagnostics. /// Does not walk entity lists. /// public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get { foreach (var kvp in _loaded) { if (_aabbs.TryGetValue(kvp.Key, out var aabb)) yield return (kvp.Key, aabb.Min, aabb.Max); else yield return (kvp.Key, Vector3.Zero, Vector3.Zero); } } } private IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> EnumerateLandblockEntries( bool includeAnimatedIndex) { foreach (var kvp in _loaded) { Dictionary? byId = null; if (includeAnimatedIndex) { byId = new Dictionary(kvp.Value.Entities.Count); foreach (var e in kvp.Value.Entities) byId[e.Id] = e; } if (_aabbs.TryGetValue(kvp.Key, out var aabb)) yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId); else yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId); } } /// /// 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 int PendingBucketCount => _pendingByLandblock.Count; public int PendingRescueCount => _persistentRescued.Count; public int PersistentGuidCount => _persistentGuids.Count; public int PendingVisibilityTransitionCount => _visibilityTransitions.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 (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). // LiveEntityRuntime owns activation for live objects. This static-only // filter avoids replaying their defaults when a pending projection is // drained into a newly loaded landblock. if (_entityScriptActivator is not null) { var loadedEntities = _loaded[landblock.LandblockId].Entities; for (int i = 0; i < loadedEntities.Count; i++) { var e = loadedEntities[i]; if (e.ServerGuid == 0) _entityScriptActivator.OnCreate(e); } } 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 RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb) { if (entity.ServerGuid == 0) return; uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu; // Fast path: already drawn in the correct loaded bucket → nothing to do // (avoids per-frame list churn for a settled, stationary entity). if (_loaded.TryGetValue(canonical, out var target)) { foreach (var e in target.Entities) if (ReferenceEquals(e, entity)) return; } // Remove the entity from wherever it currently lives — a loaded bucket // OR a pending bucket — then re-append to its current landblock. // // Scanning _pendingByLandblock is the 2026-07-03 fix for the cold-spawn / // run-out "invisible player" bug: a persistent (server-spawned) entity // that spawned into a not-yet-loaded landblock sits in _pendingByLandblock, // and the old code scanned ONLY _loaded — so it silently no-op'd and left // the player stranded, hidden, even after its landblock finished loading // (the AddLandblock pending-drain had already run empty before the churn // re-parked the player, and the player is excluded from the server-object // re-hydrate — so RebucketLiveEntity was the ONLY path that could recover it, // and it couldn't reach a pending entity). Re-appending routes the entity // to _loaded (drawn) when its landblock is loaded, or back to pending to // await AddLandblock otherwise. // Remove + place is one spatial transaction. Rebuilding between the // two operations exposes a false/true implementation pulse for a // loaded-to-loaded move and lets reentrant visibility callbacks observe // a state that never exists at the LiveEntityRuntime boundary. RemoveEntityFromAllBuckets(entity); PlaceLiveEntityProjection(canonical, entity); } /// /// Remove (by reference) from whichever /// or bucket it /// currently occupies. At most one bucket holds a given entity, so this /// stops after the first hit. Called by before /// re-appending, so a stranded pending entity can be promoted. /// private void RemoveEntityFromAllBuckets(WorldEntity entity) { foreach (var kvp in _loaded) { var entities = kvp.Value.Entities; for (int i = 0; i < entities.Count; i++) { if (ReferenceEquals(entities[i], entity)) { 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); return; } } } foreach (var kvp in _pendingByLandblock) { if (kvp.Value.Remove(entity)) return; } } public void RemoveLandblock(uint landblockId) { uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockUnloaded(canonical); // A logical live object survives streaming residence. Non-persistent // projections move to the pending bucket for this landblock and merge // back as the same WorldEntity when it reloads. Persistent projections // (the local player) are rescued because their current bucket may be a // different landblock by the time the caller reinjects them. var retainedLive = new List(); void RetainOrRescue(WorldEntity entity, string source) { if (entity.ServerGuid == 0) return; if (_persistentGuids.Contains(entity.ServerGuid)) { _persistentRescued.Add(entity); EntityVanishProbe.Log( $"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from={source} lb=0x{canonical:X8}"); return; } if (!retainedLive.Any(existing => ReferenceEquals(existing, entity))) retainedLive.Add(entity); } // Rescue persistent entities before removal. These get appended // to the _persistentRescued list; the caller is responsible for // re-injecting them through LiveEntityRuntime rebucketing into whatever landblock // the player is currently on. if (_loaded.TryGetValue(canonical, out var lb)) { foreach (var entity in lb.Entities) RetainOrRescue(entity, "loaded"); // C.1.5b: stop DefaultScript for each dat-hydrated entity in // the landblock. Server-spawned entities are either being // rescued (script continues at the new LB) or were OnRemove'd // by LiveEntityRuntime logical teardown; leave them alone here. if (_entityScriptActivator is not null) { foreach (var entity in lb.Entities) { if (entity.ServerGuid == 0) _entityScriptActivator.OnRemove(entity); } } } // #138 (secondary): rescue persistent entities sitting in the PENDING // bucket too, not just the loaded list. The player is re-injected via // LiveEntityRuntime rebucketing into its current landblock every frame // (GameWindow's DrainRescued loop); right after a teleport that // landblock often hasn't streamed in yet, so the player lands in // _pendingByLandblock. If that same landblock is then unloaded (a // streaming churn / re-teleport before it finishes loading), the // pending entry was silently dropped here — violating the // "persistent ⇒ survives unload" contract and making the avatar // vanish after a couple round-trips. Rescue them so DrainRescued // re-parks them at the next valid landblock. if (_pendingByLandblock.TryGetValue(canonical, out var pendingForLb)) { foreach (var entity in pendingForLb) RetainOrRescue(entity, "pending"); } _pendingByLandblock.Remove(canonical); if (retainedLive.Count > 0) _pendingByLandblock[canonical] = retainedLive; _aabbs.Remove(canonical); if (_loaded.Remove(canonical)) RebuildFlatView(); } private readonly List _persistentRescued = new(); /// /// Drain entities rescued from unloaded landblocks. The caller should /// re-inject each through LiveEntityRuntime.RebucketLiveEntity 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. Called by LiveEntityRuntime before rebucketing, temporary /// world withdrawal, or logical teardown. It owns no renderer/script /// lifecycle and is safe for a still-live incarnation. /// /// Safe to call with a server guid that's not currently present — no-op. /// public void RemoveLiveEntityProjection(uint serverGuid) { if (serverGuid == 0) return; 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 so rebucketing cannot leave a second slot. foreach (uint landblockId in _pendingByLandblock.Keys.ToArray()) { List bucket = _pendingByLandblock[landblockId]; bucket.RemoveAll(e => e.ServerGuid == serverGuid); if (bucket.Count == 0) _pendingByLandblock.Remove(landblockId); } // A persistent projection may have been rescued by RemoveLandblock // and not yet drained by the next GameWindow frame. Rebucketing or // logical teardown before that drain must remove the stale rescue // reference or it can later re-inject a deleted/duplicated object. _persistentRescued.RemoveAll(e => e.ServerGuid == serverGuid); if (rebuiltLoaded) RebuildFlatView(); } /// /// Removes one exact live projection incarnation. Unlike the GUID overload, /// this cannot detach a replacement that reused the same server GUID from a /// re-entrant logical teardown callback. /// public void RemoveLiveEntityProjection(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); if (entity.ServerGuid == 0) return; bool rebuiltLoaded = false; foreach (var kvp in _loaded.ToArray()) { IReadOnlyList entities = kvp.Value.Entities; if (!entities.Any(candidate => ReferenceEquals(candidate, entity))) continue; _loaded[kvp.Key] = new LoadedLandblock( kvp.Value.LandblockId, kvp.Value.Heightmap, entities.Where(candidate => !ReferenceEquals(candidate, entity)).ToArray()); rebuiltLoaded = true; } foreach (uint landblockId in _pendingByLandblock.Keys.ToArray()) { List bucket = _pendingByLandblock[landblockId]; bucket.RemoveAll(candidate => ReferenceEquals(candidate, entity)); if (bucket.Count == 0) _pendingByLandblock.Remove(landblockId); } _persistentRescued.RemoveAll(candidate => ReferenceEquals(candidate, entity)); if (rebuiltLoaded) RebuildFlatView(); } /// /// Ends all spatial lifetime retained for one server object. Temporary /// rebucketing uses and keeps the /// persistence classification; logical delete also forgets that class so /// a later session/GUID reuse cannot be rescued as the old player. /// public void ForgetLiveEntity(uint serverGuid) { RemoveLiveEntityProjection(serverGuid); _persistentGuids.Remove(serverGuid); _persistentInFlatProbe.Remove(serverGuid); } /// Clears session-scoped persistence and undrained rescues. public void ClearLiveEntityLifetimeState() { _persistentGuids.Clear(); _persistentRescued.Clear(); _persistentInFlatProbe.Clear(); _visibilityTransitions.Clear(); _visibleLiveGuids.Clear(); } /// /// Place an already-registered live entity in a landblock slot whose /// terrain 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 PlaceLiveEntityProjection(uint landblockId, WorldEntity entity) { // Spatial placement only. LiveEntityRuntime has already registered // this incarnation's renderer and script resources exactly once. uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; bool probePersistent = EntityVanishProbe.Enabled && entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid); 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); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)"); 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); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); RebuildFlatView(); } /// /// 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. /// /// /// Only dat-static entity layers demote. Live server projections retain /// their exact WorldEntity and bucket while terrain remains resident; no /// logical or spatial lifetime callback is replayed. /// /// public void RemoveEntitiesFromLandblock(uint landblockId) { // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. // Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this // protects against future callers that mirror live projection placement'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(canonical); // Phase Post-A.5 #53 (Task 12): invalidate the EntityClassificationCache // for this landblock BEFORE we drop the entity list. The cache stores // canonical landblock ids (the dispatcher's _walkScratch carries // entry.LandblockId from GpuWorldState.LandblockEntries, whose keys are // canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b. _onLandblockUnloaded?.Invoke(canonical); // C.1.5b: stop DefaultScript for each dat-hydrated entity about to // be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0 // per this method's class doc-comment); the filter is belt-and-suspenders. if (_entityScriptActivator is not null) { foreach (var entity in lb.Entities) { if (entity.ServerGuid == 0) _entityScriptActivator.OnRemove(entity); } } WorldEntity[] retainedLive = lb.Entities .Where(entity => entity.ServerGuid != 0) .ToArray(); _loaded[canonical] = new LoadedLandblock( lb.LandblockId, lb.Heightmap, retainedLive); if (_pendingByLandblock.TryGetValue(canonical, out var pending)) { pending.RemoveAll(entity => entity.ServerGuid == 0); if (pending.Count == 0) _pendingByLandblock.Remove(canonical); } RebuildFlatView(); } /// /// Merge entities into an existing-loaded landblock. Used by two-tier /// streaming for the Far→Near promotion case (terrain already loaded; /// entity layer streaming in). Falls back to the pending bucket if the /// 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, IReadOnlyList entities) { // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (!_loaded.TryGetValue(canonical, out var lb)) { // Park as pending — same pattern as live projections for not-yet-loaded LBs. if (!_pendingByLandblock.TryGetValue(canonical, out var bucket)) { bucket = new List(); _pendingByLandblock[canonical] = bucket; } bucket.AddRange(entities); return; } var merged = new List(lb.Entities.Count + entities.Count); merged.AddRange(lb.Entities); merged.AddRange(entities); _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); // C.1.5b: fire DefaultScript for each promoted dat-hydrated entity. // All entities arriving via this path are atlas-tier by construction // (the promotion path streams in dat-static scenery + EnvCell statics // + stabs per the method's class doc-comment). if (_entityScriptActivator is not null) { for (int i = 0; i < entities.Count; i++) _entityScriptActivator.OnCreate(entities[i]); } RebuildFlatView(); } private void RebuildFlatView() { _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); var nowVisible = new HashSet( _flatEntities .Where(entity => entity.ServerGuid != 0) .Select(entity => entity.ServerGuid)); uint[] becameHidden = _visibleLiveGuids .Where(guid => !nowVisible.Contains(guid)) .ToArray(); uint[] becameVisible = nowVisible .Where(guid => !_visibleLiveGuids.Contains(guid)) .ToArray(); // Commit the new spatial truth before notifying observers. A // visibility callback may synchronously rebucket/withdraw the same // object (deferred teleport rollback); those nested transitions must // compare against this committed state rather than the stale prior set. _visibleLiveGuids.Clear(); _visibleLiveGuids.UnionWith(nowVisible); foreach (uint guid in becameHidden) _visibilityTransitions.Enqueue((guid, false)); foreach (uint guid in becameVisible) _visibilityTransitions.Enqueue((guid, true)); DrainVisibilityTransitions(); if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions(); } private void DrainVisibilityTransitions() { if (_dispatchingVisibilityTransitions) return; List? failures = null; _dispatchingVisibilityTransitions = true; try { while (_visibilityTransitions.TryDequeue(out var transition)) { Delegate[] subscribers = LiveProjectionVisibilityChanged? .GetInvocationList() ?? Array.Empty(); for (int i = 0; i < subscribers.Length; i++) { try { ((Action)subscribers[i])( transition.ServerGuid, transition.Visible); } catch (Exception error) { (failures ??= new List()).Add(error); } } } } finally { _dispatchingVisibilityTransitions = false; } if (failures is not null) { throw new AggregateException( "One or more live projection visibility observers failed.", failures); } } // TEMP (#138-B): persistent guids currently present in the drawn flat // view, for EntityVanishProbe transition logging. Strip with the probe. private readonly HashSet _persistentInFlatProbe = new(); // TEMP (#138-B): log when a persistent (player) entity enters/leaves the // drawn flat view. Transition-gated → low volume (fires at teleport // boundaries, not every rebuild). Strip with EntityVanishProbe. private void ProbeFlatViewTransitions() { var now = new HashSet(); foreach (var e in _flatEntities) if (e.ServerGuid != 0 && _persistentGuids.Contains(e.ServerGuid)) now.Add(e.ServerGuid); foreach (var g in now) if (!_persistentInFlatProbe.Contains(g)) EntityVanishProbe.Log($"[ent] DRAWSET guid=0x{g:X8} -> PRESENT (flatCount={_flatEntities.Count})"); foreach (var g in _persistentInFlatProbe) if (!now.Contains(g)) EntityVanishProbe.Log($"[ent] DRAWSET guid=0x{g:X8} -> ABSENT (flatCount={_flatEntities.Count})"); _persistentInFlatProbe.Clear(); foreach (var g in now) _persistentInFlatProbe.Add(g); } }