using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; using AcDream.App.World; using AcDream.Core.World; namespace AcDream.App.Streaming; /// /// Exact result of one committed landblock bucket mutation. Fallible mesh-pin /// and static-script reconciliation consume this receipt separately, so a /// callback failure cannot force the spatial mutation to replay. /// internal sealed record GpuLandblockSpatialPublication( uint LandblockId, LoadedLandblock Landblock, IReadOnlyList AdditionalRenderIds, IReadOnlyList StaticEntities, bool RequiresActivation = true); /// /// 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 : ILiveEntitySpatialQuery { 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 _tierByLandblock = new(); private readonly Dictionary _aabbs = new(); private readonly Dictionary _animatedIndexByLandblock = new(); private sealed record AnimatedEntityIndex( LoadedLandblock Source, IReadOnlyDictionary EntitiesById); /// /// 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(); // Far-to-near promotion can complete before the base far landblock is // published. Preserve its independently-prepared EnvCell geometry ids // alongside the parked entity layer so the later AddLandblock transaction // cannot open the render gate before those shells upload. private readonly Dictionary> _pendingRenderIdsByLandblock = new(); private readonly HashSet _pendingNearTierLandblocks = 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(); // Render-thread-owned flat view over all entities across loaded landblocks. // The update and render phases are serialized, so mutating this list during // the update phase is both safe and substantially cheaper than rebuilding a // full-world array for every CreateObject in an inbound spawn flood. private readonly List _flatEntities = new(); private readonly Dictionary _flatEntityIndices = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _projectionLocations = new(ReferenceEqualityComparer.Instance); // Short-range consumers must never scan each landblock's DAT-static list. // This index mirrors only loaded live projections and changes at the same // transaction boundary as _projectionLocations. private readonly Dictionary> _loadedLiveByLandblock = new(); // A server GUID normally has exactly one spatial projection. Keep that // allocation-free common case directly in the primary map; the secondary // list exists only for the rare overlap during GUID reuse/re-entrant // lifetime transitions. private readonly Dictionary _primaryProjectionByGuid = new(); private readonly Dictionary> _additionalProjectionsByGuid = new(); private readonly Dictionary _visibleLiveProjectionCounts = new(); private readonly Dictionary _visibilityBeforeMutation = new(); private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new(); private bool _dispatchingVisibilityTransitions; private int _mutationDepth; private long _visibilityCommitCount; private ulong _flatViewGeneration; private bool _flatMembershipDirty; public IReadOnlyList Entities => _flatEntities; public ulong FlatViewGeneration => _flatViewGeneration; public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys; public event Action? LiveProjectionVisibilityChanged; public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); public bool IsNearTier(uint landblockId) => _tierByLandblock.TryGetValue(landblockId, out var tier) && tier == LandblockStreamTier.Near; public bool IsNearTierOrPending(uint landblockId) => IsNearTier(landblockId) || _pendingNearTierLandblocks.Contains(landblockId); public bool IsRenderReady(uint landblockId) => _loaded.ContainsKey(landblockId) && (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true); public bool IsLiveEntityVisible(uint serverGuid) => serverGuid != 0 && _visibleLiveProjectionCounts.ContainsKey(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 derived 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). The lookup is /// cached against the record identity, so a /// stable scene does not allocate one dictionary per landblock per frame. /// Live projection mutations update the mutable resident bucket in place and /// explicitly invalidate exactly the affected index generation. /// /// 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) { IReadOnlyDictionary? byId = null; if (includeAnimatedIndex) { if (!_animatedIndexByLandblock.TryGetValue(kvp.Key, out var cached) || !ReferenceEquals(cached.Source, kvp.Value)) { var rebuilt = new Dictionary(kvp.Value.Entities.Count); foreach (var entity in kvp.Value.Entities) rebuilt[entity.Id] = entity; cached = new AnimatedEntityIndex(kvp.Value, rebuilt); _animatedIndexByLandblock[kvp.Key] = cached; } byId = cached.EntitiesById; } 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; internal long VisibilityCommitCount => _visibilityCommitCount; /// /// Groups spatial mutations into one visibility publication transaction. /// Bucket and flat-view contents become readable immediately on the render /// thread; observer edges are computed from the before/after state and drain /// only when the outermost scope ends. This is what keeps a loaded-to-loaded /// rebucket from exposing an implementation-only hidden/visible pulse and /// lets one inbound network drain commit thousands of spawns without global /// rebuilds between them. /// public MutationBatch BeginMutationBatch() { _mutationDepth++; return new MutationBatch(this); } /// /// Copy live server projections from the bounded landblock neighborhood /// around . This is the spatial /// candidate seam for short-range consumers such as retail radar; they /// still apply their own Hidden/classification/range rules. /// public void CopyLiveEntitiesNearLandblock( uint centerCellOrLandblockId, int landblockRadius, List> destination) { ArgumentNullException.ThrowIfNull(destination); ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius); destination.Clear(); int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu); int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu); for (int dx = -landblockRadius; dx <= landblockRadius; dx++) for (int dy = -landblockRadius; dy <= landblockRadius; dy++) { int x = centerX + dx; int y = centerY + dy; if ((uint)x > 0xFFu || (uint)y > 0xFFu) continue; uint landblockId = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; if (!_loadedLiveByLandblock.TryGetValue( landblockId, out HashSet? liveEntities)) continue; foreach (WorldEntity entity in liveEntities) destination.Add(new KeyValuePair(entity.ServerGuid, entity)); } } public readonly ref struct MutationBatch { private readonly GpuWorldState _owner; public MutationBatch(GpuWorldState owner) => _owner = owner; public void Dispose() => _owner.EndMutationBatch(); } private readonly record struct ProjectionLocation( uint LandblockId, bool IsLoaded, int BucketIndex); private void EndMutationBatch() { if (_mutationDepth <= 0) throw new InvalidOperationException("GpuWorldState mutation scope underflow."); if (--_mutationDepth != 0) return; foreach ((uint guid, bool wasVisible) in _visibilityBeforeMutation) { bool isVisible = _visibleLiveProjectionCounts.ContainsKey(guid); if (wasVisible != isVisible) _visibilityTransitions.Enqueue((guid, isVisible)); } _visibilityBeforeMutation.Clear(); _visibilityCommitCount++; if (_flatMembershipDirty) { _flatViewGeneration++; _flatMembershipDirty = false; } DrainVisibilityTransitions(); if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions(); } private void AdjustVisibleLiveProjection(WorldEntity entity, int delta) { uint guid = entity.ServerGuid; if (guid == 0 || delta == 0) return; _visibleLiveProjectionCounts.TryGetValue(guid, out int priorCount); if (!_visibilityBeforeMutation.ContainsKey(guid)) _visibilityBeforeMutation.Add(guid, priorCount > 0); int nextCount = checked(priorCount + delta); if (nextCount < 0) throw new InvalidOperationException( $"Live projection visibility count underflow for 0x{guid:X8}."); if (nextCount == 0) { _visibleLiveProjectionCounts.Remove(guid); } else { _visibleLiveProjectionCounts[guid] = nextCount; } } private static LoadedLandblock NormalizeLoadedLandblock( LoadedLandblock source, List? entities = null) => new( source.LandblockId, source.Heightmap, entities ?? new List(source.Entities), source.PhysicsDats); private static List MutableEntities(LoadedLandblock landblock) => (List)landblock.Entities; private void AddFlatEntity(WorldEntity entity) { if (!_flatEntityIndices.TryAdd(entity, _flatEntities.Count)) throw new InvalidOperationException( $"Entity 0x{entity.Id:X8} already exists in the flat render view."); _flatEntities.Add(entity); _flatMembershipDirty = true; } private void RemoveFlatEntity(WorldEntity entity) { if (!_flatEntityIndices.Remove(entity, out int index)) throw new InvalidOperationException( $"Loaded entity 0x{entity.Id:X8} was absent from the flat render view."); int lastIndex = _flatEntities.Count - 1; if (index != lastIndex) { WorldEntity moved = _flatEntities[lastIndex]; _flatEntities[index] = moved; _flatEntityIndices[moved] = index; } _flatEntities.RemoveAt(lastIndex); _flatMembershipDirty = true; } private void SetProjectionLocation( WorldEntity entity, uint landblockId, bool isLoaded, int bucketIndex) { if (entity.ServerGuid == 0) return; bool isNew = !_projectionLocations.TryGetValue( entity, out ProjectionLocation priorLocation); if (!isNew && priorLocation.IsLoaded && (!isLoaded || priorLocation.LandblockId != landblockId)) { RemoveLoadedLiveIndex(priorLocation.LandblockId, entity); } _projectionLocations[entity] = new ProjectionLocation( landblockId, isLoaded, bucketIndex); if (isLoaded && (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId)) { AddLoadedLiveIndex(landblockId, entity); } if (!isNew) return; uint guid = entity.ServerGuid; if (_primaryProjectionByGuid.TryAdd(guid, entity)) return; if (!_additionalProjectionsByGuid.TryGetValue(guid, out List? projections)) { projections = new List(1); _additionalProjectionsByGuid.Add(guid, projections); } projections.Add(entity); } private void RemoveProjectionLocation(WorldEntity entity) { if (!_projectionLocations.Remove(entity, out ProjectionLocation location) || entity.ServerGuid == 0) return; if (location.IsLoaded) RemoveLoadedLiveIndex(location.LandblockId, entity); uint guid = entity.ServerGuid; if (!_primaryProjectionByGuid.TryGetValue(guid, out WorldEntity? primary)) throw new InvalidOperationException( $"Live projection 0x{entity.Id:X8} had no server-guid index entry."); if (ReferenceEquals(primary, entity)) { if (_additionalProjectionsByGuid.TryGetValue(guid, out List? projections) && projections.Count > 0) { int lastIndex = projections.Count - 1; _primaryProjectionByGuid[guid] = projections[lastIndex]; projections.RemoveAt(lastIndex); if (projections.Count == 0) _additionalProjectionsByGuid.Remove(guid); } else { _primaryProjectionByGuid.Remove(guid); } return; } if (!_additionalProjectionsByGuid.TryGetValue(guid, out List? additional)) throw new InvalidOperationException( $"Live projection 0x{entity.Id:X8} was absent from its server-guid index."); int index = -1; for (int i = 0; i < additional.Count; i++) { if (!ReferenceEquals(additional[i], entity)) continue; index = i; break; } if (index < 0) throw new InvalidOperationException( $"Live projection 0x{entity.Id:X8} was absent from its server-guid index."); int lastAdditionalIndex = additional.Count - 1; if (index != lastAdditionalIndex) additional[index] = additional[lastAdditionalIndex]; additional.RemoveAt(lastAdditionalIndex); if (additional.Count == 0) _additionalProjectionsByGuid.Remove(guid); } private void AddLoadedLiveIndex(uint landblockId, WorldEntity entity) { if (!_loadedLiveByLandblock.TryGetValue( landblockId, out HashSet? entities)) { entities = new HashSet(ReferenceEqualityComparer.Instance); _loadedLiveByLandblock.Add(landblockId, entities); } if (!entities.Add(entity)) { throw new InvalidOperationException( $"Live entity 0x{entity.Id:X8} already exists in landblock index 0x{landblockId:X8}."); } } private void RemoveLoadedLiveIndex(uint landblockId, WorldEntity entity) { if (!_loadedLiveByLandblock.TryGetValue( landblockId, out HashSet? entities) || !entities.Remove(entity)) { throw new InvalidOperationException( $"Live entity 0x{entity.Id:X8} is absent from landblock index 0x{landblockId:X8}."); } if (entities.Count == 0) _loadedLiveByLandblock.Remove(landblockId); } private void AddLoadedProjection(uint landblockId, WorldEntity entity) { LoadedLandblock landblock = _loaded[landblockId]; List entities = MutableEntities(landblock); int bucketIndex = entities.Count; entities.Add(entity); _animatedIndexByLandblock.Remove(landblockId); AddFlatEntity(entity); SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex); AdjustVisibleLiveProjection(entity, +1); } private void RemoveLoadedProjection(WorldEntity entity) { if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location) || !location.IsLoaded || !_loaded.TryGetValue(location.LandblockId, out LoadedLandblock? landblock)) { throw new InvalidOperationException( $"Live entity 0x{entity.Id:X8} has no loaded projection location."); } List entities = MutableEntities(landblock); int lastIndex = entities.Count - 1; if ((uint)location.BucketIndex >= (uint)entities.Count || !ReferenceEquals(entities[location.BucketIndex], entity)) { throw new InvalidOperationException( $"Loaded projection index for entity 0x{entity.Id:X8} is stale."); } if (location.BucketIndex != lastIndex) { WorldEntity moved = entities[lastIndex]; entities[location.BucketIndex] = moved; if (moved.ServerGuid != 0) { SetProjectionLocation( moved, location.LandblockId, isLoaded: true, location.BucketIndex); } } entities.RemoveAt(lastIndex); _animatedIndexByLandblock.Remove(location.LandblockId); RemoveFlatEntity(entity); RemoveProjectionLocation(entity); AdjustVisibleLiveProjection(entity, -1); } private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock) { foreach (WorldEntity entity in landblock.Entities) { RemoveFlatEntity(entity); if (entity.ServerGuid != 0) RemoveProjectionLocation(entity); AdjustVisibleLiveProjection(entity, -1); } } private void AddLoadedLandblockToFlatView(LoadedLandblock landblock) { for (int i = 0; i < landblock.Entities.Count; i++) { WorldEntity entity = landblock.Entities[i]; AddFlatEntity(entity); if (entity.ServerGuid != 0) SetProjectionLocation(entity, landblock.LandblockId, isLoaded: true, i); AdjustVisibleLiveProjection(entity, +1); } } public void AddLandblock( LoadedLandblock landblock, IEnumerable? additionalRenderIds = null, LandblockStreamTier tier = LandblockStreamTier.Near) { using MutationBatch mutation = BeginMutationBatch(); GpuLandblockSpatialPublication publication = CommitLandblockSpatialCore(landblock, additionalRenderIds, tier); ActivateLandblockPresentation(publication); } /// /// Commits only canonical buckets, indexes, pending-live merge, tier, and /// readiness-id ownership. The caller holds one outer /// through the subsequent /// call so live visibility /// observers retain the shipped commit boundary. /// internal GpuLandblockSpatialPublication CommitLandblockSpatial( LoadedLandblock landblock, IEnumerable? additionalRenderIds, LandblockStreamTier tier) => CommitLandblockSpatialCore(landblock, additionalRenderIds, tier); private GpuLandblockSpatialPublication CommitLandblockSpatialCore( LoadedLandblock landblock, IEnumerable? additionalRenderIds, LandblockStreamTier tier) { ArgumentNullException.ThrowIfNull(landblock); // A stale Far completion must never replace a newer Near entity layer. // StreamingController normally filters it before render-side apply; // keep the state boundary independently monotonic as well. if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId)) { LoadedLandblock retained = _loaded[landblock.LandblockId]; return new GpuLandblockSpatialPublication( retained.LandblockId, retained, Array.Empty(), Array.Empty(), RequiresActivation: false); } // Reconcile the only fallible logical-owner edge before consuming any // pending live/render/tier maps. A retry then sees the exact pending // suffix even when a resolver or owner callback fails partway through. landblock = NormalizeLoadedLandblock(landblock); if (_loaded.TryGetValue(landblock.LandblockId, out LoadedLandblock? displaced)) ReconcileRetainedStaticEntityScripts(displaced, landblock); // If pending live entities have been waiting for this landblock, // merge them into the render-thread-owned mutable entity bucket before // storing. Subsequent live spawns append to that bucket in O(1). 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 = NormalizeLoadedLandblock(landblock, merged); _pendingByLandblock.Remove(landblock.LandblockId); } HashSet? mergedRenderIds = additionalRenderIds is null ? null : new HashSet(additionalRenderIds); if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds)) { mergedRenderIds ??= new HashSet(); mergedRenderIds.UnionWith(pendingRenderIds); } bool pendingNear = _pendingNearTierLandblocks.Remove(landblock.LandblockId); if (pendingNear || (_tierByLandblock.TryGetValue(landblock.LandblockId, out var currentTier) && currentTier == LandblockStreamTier.Near)) { tier = LandblockStreamTier.Near; } if (_loaded.Remove(landblock.LandblockId, out displaced)) { RemoveLoadedLandblockFromFlatView(displaced); _animatedIndexByLandblock.Remove(landblock.LandblockId); } _loaded[landblock.LandblockId] = landblock; AddLoadedLandblockToFlatView(landblock); _tierByLandblock[landblock.LandblockId] = tier; return CreateSpatialPublication( _loaded[landblock.LandblockId], (IEnumerable?)mergedRenderIds ?? Array.Empty()); } /// /// A same-landblock rehydrate is an acdream streaming replacement, not a /// retail logical create/delete edge. Retained static IDs rebind their /// Setup-owned resources without replaying defaults; omitted IDs perform /// the same exact teardown used by ordinary retirement. /// private void ReconcileRetainedStaticEntityScripts( LoadedLandblock displaced, LoadedLandblock replacement) { if (_entityScriptActivator is null) return; var replacementsById = new Dictionary(); for (int i = 0; i < replacement.Entities.Count; i++) { WorldEntity entity = replacement.Entities[i]; if (entity.ServerGuid == 0) replacementsById.Add(entity.Id, entity); } for (int i = 0; i < displaced.Entities.Count; i++) { WorldEntity prior = displaced.Entities[i]; if (prior.ServerGuid != 0) continue; if (replacementsById.TryGetValue(prior.Id, out WorldEntity? retained)) _entityScriptActivator.OnRebindStatic(prior, retained); else _entityScriptActivator.OnRemove(prior); } } internal void ActivateLandblockPresentation( GpuLandblockSpatialPublication publication) { ArgumentNullException.ThrowIfNull(publication); if (!publication.RequiresActivation) return; if (!_loaded.TryGetValue(publication.LandblockId, out LoadedLandblock? current) || !ReferenceEquals(current, publication.Landblock)) { throw new InvalidOperationException( $"Landblock 0x{publication.LandblockId:X8} changed before presentation activation."); } _wbSpawnAdapter?.OnLandblockLoaded( publication.Landblock, publication.AdditionalRenderIds); // C.1.5b: fire DefaultScript for dat-hydrated entities only. // EntityScriptActivator owns a retryable per-static acquisition ledger, // so replaying this activation suffix after a later owner failure does // not replay already-started defaults. if (_entityScriptActivator is not null) { for (int i = 0; i < publication.StaticEntities.Count; i++) _entityScriptActivator.OnCreate(publication.StaticEntities[i]); } } private static GpuLandblockSpatialPublication CreateSpatialPublication( LoadedLandblock landblock, IEnumerable additionalRenderIds) { ulong[] renderIds = additionalRenderIds as ulong[] ?? additionalRenderIds.ToArray(); WorldEntity[] staticEntities = landblock.Entities .Where(static entity => entity.ServerGuid == 0) .ToArray(); return new GpuLandblockSpatialPublication( landblock.LandblockId, landblock, renderIds, staticEntities); } /// /// 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; using MutationBatch mutation = BeginMutationBatch(); 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 (_projectionLocations.TryGetValue(entity, out ProjectionLocation current) && current.IsLoaded && current.LandblockId == canonical) { return; } // Remove the entity from wherever it currently lives — a loaded bucket // OR a pending bucket — then re-append to its current landblock. // // The projection-location index 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). The index now reaches either // residency class in O(1). 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) { if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location)) return; if (location.IsLoaded) { RemoveLoadedProjection(entity); return; } if (!_pendingByLandblock.TryGetValue(location.LandblockId, out List? pending) || (uint)location.BucketIndex >= (uint)pending.Count || !ReferenceEquals(pending[location.BucketIndex], entity)) { throw new InvalidOperationException( $"Indexed live entity 0x{entity.Id:X8} was absent from pending landblock 0x{location.LandblockId:X8}."); } int lastIndex = pending.Count - 1; if (location.BucketIndex != lastIndex) { WorldEntity moved = pending[lastIndex]; pending[location.BucketIndex] = moved; SetProjectionLocation( moved, location.LandblockId, isLoaded: false, location.BucketIndex); } pending.RemoveAt(lastIndex); RemoveProjectionLocation(entity); if (pending.Count == 0) _pendingByLandblock.Remove(location.LandblockId); } /// /// Commits the world-state half of a full landblock retirement and returns /// the exact entity context required by presentation owners. This method /// deliberately performs no renderer, script, or cache callbacks: those /// owners can fail independently and are advanced by /// after the old state has /// become unreachable. /// public GpuLandblockRetirement? DetachLandblock(uint landblockId) { using MutationBatch mutation = BeginMutationBatch(); uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; bool hadState = _loaded.ContainsKey(canonical) || _pendingByLandblock.ContainsKey(canonical) || _pendingRenderIdsByLandblock.ContainsKey(canonical) || _pendingNearTierLandblocks.Contains(canonical) || _tierByLandblock.ContainsKey(canonical) || _aabbs.ContainsKey(canonical); if (!hadState) return null; IReadOnlyList detachedEntities = _loaded.TryGetValue(canonical, out LoadedLandblock? residentLandblock) ? residentLandblock.Entities : Array.Empty(); if (_pendingByLandblock.TryGetValue(canonical, out List? detachedPending) && detachedPending.Count > 0) { if (detachedEntities.Count == 0) { detachedEntities = detachedPending; } else { var combined = new List( detachedEntities.Count + detachedPending.Count); var seen = new HashSet(ReferenceEqualityComparer.Instance); foreach (WorldEntity entity in detachedEntities) if (seen.Add(entity)) combined.Add(entity); foreach (WorldEntity entity in detachedPending) if (seen.Add(entity)) combined.Add(entity); detachedEntities = combined; } } // 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(); var retainedLiveSet = new HashSet(ReferenceEqualityComparer.Instance); 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 (retainedLiveSet.Add(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"); } // #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"); RemoveProjectionLocation(entity); } } _pendingByLandblock.Remove(canonical); _pendingRenderIdsByLandblock.Remove(canonical); _pendingNearTierLandblocks.Remove(canonical); if (retainedLive.Count > 0) _pendingByLandblock[canonical] = retainedLive; _aabbs.Remove(canonical); _animatedIndexByLandblock.Remove(canonical); _tierByLandblock.Remove(canonical); if (_loaded.Remove(canonical, out LoadedLandblock? removed)) RemoveLoadedLandblockFromFlatView(removed); for (int i = 0; i < retainedLive.Count; i++) SetProjectionLocation(retainedLive[i], canonical, isLoaded: false, i); return new GpuLandblockRetirement( canonical, LandblockRetirementKind.Full, detachedEntities); } /// /// Compatibility edge for direct state tests. Production streaming uses /// so each presentation owner /// has a retryable committed marker. /// public void RemoveLandblock(uint landblockId) { GpuLandblockRetirement? retirement = DetachLandblock(landblockId); if (retirement is null) return; ReleaseLandblockMeshReferences(retirement.LandblockId); InvalidateLandblockClassification(retirement.LandblockId); for (int i = 0; i < retirement.Entities.Count; i++) { WorldEntity entity = retirement.Entities[i]; if (entity.ServerGuid == 0) StopStaticEntityScript(entity); } } internal void ReleaseLandblockMeshReferences(uint landblockId) => _wbSpawnAdapter?.OnLandblockUnloaded(landblockId); internal void InvalidateLandblockClassification(uint landblockId) => _onLandblockUnloaded?.Invoke(landblockId); internal void StopStaticEntityScript(WorldEntity entity) => _entityScriptActivator?.OnRemove(entity); 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. 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; using MutationBatch mutation = BeginMutationBatch(); // Canonical live projections are indexed by server GUID and entity // identity, so delete/withdraw never scans every resident landblock. while (_primaryProjectionByGuid.TryGetValue(serverGuid, out WorldEntity? projection)) { RemoveEntityFromAllBuckets(projection); } // 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); } /// /// 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; using MutationBatch mutation = BeginMutationBatch(); RemoveEntityFromAllBuckets(entity); _persistentRescued.RemoveAll(candidate => ReferenceEquals(candidate, entity)); } /// /// 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(); _visibleLiveProjectionCounts.Clear(); _visibilityBeforeMutation.Clear(); _projectionLocations.Clear(); _loadedLiveByLandblock.Clear(); _primaryProjectionByGuid.Clear(); _additionalProjectionsByGuid.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 landblock and flat render-thread lists in O(1). /// 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) { using MutationBatch mutation = BeginMutationBatch(); if (_projectionLocations.ContainsKey(entity)) { throw new InvalidOperationException( $"Live entity 0x{entity.Id:X8} is already spatially projected."); } // 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.ContainsKey(canonicalLandblockId)) { // Hot path — append directly to the render-thread-owned mutable // resident bucket and flat view. AddLoadedProjection(canonicalLandblockId, entity); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)"); 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; } int bucketIndex = bucket.Count; bucket.Add(entity); SetProjectionLocation(entity, canonicalLandblockId, isLoaded: false, bucketIndex); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); } /// /// 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 GpuLandblockRetirement? DetachNearLayer(uint landblockId) { using MutationBatch mutation = BeginMutationBatch(); // 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; // A promotion may have parked its Near layer before the Far base load // exists. Demotion must retire that pending tier just as completely as // an installed tier, while preserving live server projections. bool hadNearLayer = _pendingNearTierLandblocks.Remove(canonical); hadNearLayer |= _pendingRenderIdsByLandblock.Remove(canonical); hadNearLayer |= IsNearTier(canonical); if (!hadNearLayer) return null; var retiredEntities = new List(); if (_pendingByLandblock.TryGetValue(canonical, out var pending)) { int pendingWriteIndex = 0; for (int readIndex = 0; readIndex < pending.Count; readIndex++) { WorldEntity entity = pending[readIndex]; if (entity.ServerGuid == 0) { retiredEntities.Add(entity); continue; } if (pendingWriteIndex != readIndex) pending[pendingWriteIndex] = entity; SetProjectionLocation( entity, canonical, isLoaded: false, pendingWriteIndex); pendingWriteIndex++; } if (pendingWriteIndex < pending.Count) pending.RemoveRange(pendingWriteIndex, pending.Count - pendingWriteIndex); if (pendingWriteIndex == 0) _pendingByLandblock.Remove(canonical); } if (_loaded.TryGetValue(canonical, out var lb)) { // 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. // 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. var retainedLive = new List(); for (int readIndex = 0; readIndex < lb.Entities.Count; readIndex++) { WorldEntity entity = lb.Entities[readIndex]; if (entity.ServerGuid != 0) { int liveWriteIndex = retainedLive.Count; retainedLive.Add(entity); SetProjectionLocation( entity, canonical, isLoaded: true, liveWriteIndex); continue; } retiredEntities.Add(entity); RemoveFlatEntity(entity); } _loaded[canonical] = NormalizeLoadedLandblock(lb, retainedLive); _animatedIndexByLandblock.Remove(canonical); _tierByLandblock[canonical] = LandblockStreamTier.Far; } return new GpuLandblockRetirement( canonical, LandblockRetirementKind.NearLayer, retiredEntities); } /// /// Compatibility edge for direct state tests. Production streaming uses /// the retryable retirement coordinator. /// public void RemoveEntitiesFromLandblock(uint landblockId) { GpuLandblockRetirement? retirement = DetachNearLayer(landblockId); if (retirement is null) return; ReleaseLandblockMeshReferences(retirement.LandblockId); InvalidateLandblockClassification(retirement.LandblockId); for (int i = 0; i < retirement.Entities.Count; i++) StopStaticEntityScript(retirement.Entities[i]); } /// /// 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 bool AddEntitiesToExistingLandblock( uint landblockId, IReadOnlyList entities, IEnumerable? additionalRenderIds = null) { using MutationBatch mutation = BeginMutationBatch(); GpuLandblockSpatialPublication? publication = CommitEntitiesToExistingLandblockSpatialCore( landblockId, entities, additionalRenderIds, parkIfMissing: true); if (publication is null) return false; ActivateLandblockPresentation(publication); return true; } internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial( uint landblockId, IReadOnlyList entities, IEnumerable? additionalRenderIds) { GpuLandblockSpatialPublication? publication = CommitEntitiesToExistingLandblockSpatialCore( landblockId, entities, additionalRenderIds, parkIfMissing: false); return publication ?? throw new InvalidOperationException( $"Landblock 0x{landblockId:X8} is not resident for an accepted promotion."); } private GpuLandblockSpatialPublication? CommitEntitiesToExistingLandblockSpatialCore( uint landblockId, IReadOnlyList entities, IEnumerable? additionalRenderIds, bool parkIfMissing) { ArgumentNullException.ThrowIfNull(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)) { if (!parkIfMissing) return null; // 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; } int firstIndex = bucket.Count; bucket.AddRange(entities); for (int i = 0; i < entities.Count; i++) { WorldEntity entity = entities[i]; if (entity.ServerGuid != 0) { SetProjectionLocation( entity, canonical, isLoaded: false, firstIndex + i); } } _pendingNearTierLandblocks.Add(canonical); if (additionalRenderIds is not null) { if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds)) { renderIds = new HashSet(); _pendingRenderIdsByLandblock[canonical] = renderIds; } renderIds.UnionWith(additionalRenderIds); } return null; } List resident = MutableEntities(lb); for (int i = 0; i < entities.Count; i++) { WorldEntity entity = entities[i]; int bucketIndex = resident.Count; resident.Add(entity); AddFlatEntity(entity); if (entity.ServerGuid != 0) SetProjectionLocation(entity, canonical, isLoaded: true, bucketIndex); AdjustVisibleLiveProjection(entity, +1); } _animatedIndexByLandblock.Remove(canonical); _tierByLandblock[canonical] = LandblockStreamTier.Near; ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty(); WorldEntity[] staticEntities = entities .Where(static entity => entity.ServerGuid == 0) .ToArray(); return new GpuLandblockSpatialPublication( canonical, _loaded[canonical], effectiveRenderIds, staticEntities); } 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); } }