fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -67,6 +67,7 @@ public sealed class GpuWorldState
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
/// <summary>
@ -75,6 +76,12 @@ public sealed class GpuWorldState
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
/// </summary>
private readonly Dictionary<uint, List<WorldEntity>> _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<uint, HashSet<ulong>> _pendingRenderIdsByLandblock = new();
private readonly HashSet<uint> _pendingNearTierLandblocks = new();
/// <summary>
/// Entities that must survive landblock unloads (e.g. the player character).
@ -96,6 +103,14 @@ public sealed class GpuWorldState
public event Action<uint, bool>? 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 && _visibleLiveGuids.Contains(serverGuid);
@ -204,8 +219,17 @@ public sealed class GpuWorldState
public int PersistentGuidCount => _persistentGuids.Count;
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
public void AddLandblock(LoadedLandblock landblock)
public void AddLandblock(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalRenderIds = null,
LandblockStreamTier tier = LandblockStreamTier.Near)
{
// 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))
return;
// 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
@ -222,9 +246,29 @@ public sealed class GpuWorldState
_pendingByLandblock.Remove(landblock.LandblockId);
}
HashSet<ulong>? mergedRenderIds = additionalRenderIds is null
? null
: new HashSet<ulong>(additionalRenderIds);
if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds))
{
mergedRenderIds ??= new HashSet<ulong>();
mergedRenderIds.UnionWith(pendingRenderIds);
}
bool pendingNear = _pendingNearTierLandblocks.Remove(landblock.LandblockId);
if (pendingNear
|| (_tierByLandblock.TryGetValue(landblock.LandblockId, out var currentTier)
&& currentTier == LandblockStreamTier.Near))
{
tier = LandblockStreamTier.Near;
}
_loaded[landblock.LandblockId] = landblock;
_tierByLandblock[landblock.LandblockId] = tier;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
_wbSpawnAdapter.OnLandblockLoaded(
_loaded[landblock.LandblockId],
mergedRenderIds);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// LiveEntityRuntime owns activation for live objects. This static-only
@ -397,10 +441,13 @@ public sealed class GpuWorldState
}
_pendingByLandblock.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
_pendingNearTierLandblocks.Remove(canonical);
if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
_tierByLandblock.Remove(canonical);
if (_loaded.Remove(canonical))
RebuildFlatView();
}
@ -612,6 +659,18 @@ public sealed class GpuWorldState
// 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.
_pendingNearTierLandblocks.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
@ -642,12 +701,7 @@ public sealed class GpuWorldState
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);
}
_tierByLandblock[canonical] = LandblockStreamTier.Far;
RebuildFlatView();
}
@ -664,7 +718,10 @@ public sealed class GpuWorldState
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
public bool AddEntitiesToExistingLandblock(
uint landblockId,
IReadOnlyList<WorldEntity> entities,
IEnumerable<ulong>? additionalRenderIds = null)
{
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
@ -677,14 +734,25 @@ public sealed class GpuWorldState
_pendingByLandblock[canonical] = bucket;
}
bucket.AddRange(entities);
return;
_pendingNearTierLandblocks.Add(canonical);
if (additionalRenderIds is not null)
{
if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds))
{
renderIds = new HashSet<ulong>();
_pendingRenderIdsByLandblock[canonical] = renderIds;
}
renderIds.UnionWith(additionalRenderIds);
}
return false;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds);
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
// All entities arriving via this path are atlas-tier by construction
@ -697,6 +765,7 @@ public sealed class GpuWorldState
}
RebuildFlatView();
return true;
}
private void RebuildFlatView()