Both #280 review lenses returned FAIL on the same defect, and both were
right. IsRenderNeighborhoodResident's widened outer arm requires
IsRenderReady out to FarRadius, justified by "a Far-tier landblock
registers with an empty mesh set and is therefore render-ready." That held
only for a landblock that ARRIVED as Far. The second, equally first-class
way to be Far tier is a Near->Far DEMOTE:
DemoteLandblock -> EnqueueNearLayerRetirement
-> LandblockRetirementStage.MeshReferences
-> GpuWorldState.ReleaseLandblockMeshReferences
-> LandblockSpawnAdapter.OnLandblockUnloaded => WantsLoaded = false
while DetachNearLayer deliberately keeps the landblock loaded, terrain-mesh
resident, terrain-collision resident and DRAWN. Nothing re-publishes an
already-loaded landblock, so the demoted member satisfied NEITHER arm of
the gate, permanently: wormhole tunnel plus centered "In Portal Space -
Please Wait..." forever, no recovery short of relog.
Reachable by ordinary play. Two consecutive recalls to the same landblock
with walking in between makes ChangesStreamingCenter false, so there is no
origin recenter and the region recentres through the ordinary demote diff.
Also reachable via a mid-hold quality-preset drop -- ironically the exact
scenario ReconcileDestinationReservationRadius was added to support. The
pre-#280 radius-1 gate never touched that band, because nothing inside the
Near ring can demote.
FIX SHAPE. Make the two routes genuinely equivalent rather than teaching
the predicate to tolerate the difference. ReleaseLandblockMeshReferences
becomes "reconcile the registration to the post-retirement tier": after the
release converges, if the landblock is still loaded AND still Far tier,
re-assert the empty registration -- the identical OnLandblockLoaded(lb,
empty) a PublicationKind.Far activation makes. It is empty by construction:
DetachNearLayer retains only live server projections, which the adapter's
atlas-tier filter skips. A full retirement is unaffected (DetachLandblock
clears both _loaded and _tierByLandblock), and a throwing release still
retries because the re-assert is only reached after the adapter converged.
The alternative -- "|| (IsFarTier && IsLoaded)" at the gate -- was
rejected: it fixes one caller while leaving IsRenderReady meaning two
different things, which is precisely how this defect arose. After this
change the predicate reads "drawable at its current tier" for every caller,
with no knowledge of how the landblock got there.
WHY THE TESTS MISSED IT, fixed here too:
- Proof obligation P2 was discharged against RESIDENCY (the FarRadius+2
eviction threshold) rather than against IsRenderReady, the gate's actual
atom. The contract now carries the correction and the restated
obligation: no transition may REVOKE IsRenderReady from a landblock that
stays inside FarRadius.
- WorldRevealDerivedWindowIntegrationTests advertised itself as end-to-end
against the real GpuWorldState but constructed it with no spawn adapter,
so its IsRenderReady degenerated to IsLoaded via the "?? true". The
single most load-bearing predicate in the change was stubbed out by a
null in the test named after it -- the same shape as C5b's D3 and #276's
three settler tests. Every fixture in that file now owns a real
LandblockSpawnAdapter.
- The P1 test's comment described its subject as "a Near-shaped completion
the streaming window has since DEMOTED to Far". It is not; it is a fresh
PublishAsFar, the case that does hold. Corrected, since a future reader
would have taken it as demote coverage.
Four new regression tests, all driving the real GpuWorldState +
LandblockSpawnAdapter + LandblockPresentationPipeline through an actual
demote, and all sabotage-verified in both directions (fail with the
production change reverted, pass with it):
NearToFarDemote_LeavesTheLandblockRenderReadyThroughTheRealPipeline
NearToFarDemote_LeavesTheLandblockRenderReadyUnderBudgetedRetirement
TieredWindow_StaysResidentAfterAnOuterRingDemote
OutdoorReveal_SurvivesAnOuterRingDemoteDuringTheHold
The budgeted variant exists because production composes
LandblockRetirementCoordinator.CreateBudgeted, whose MeshReferences stage
is a separate call site from the legacy pipeline's.
SECONDARY, same commit:
- R-1: ACDREAM_PROBE_REVEAL_RADIUS=0 was parser-accepted and
Runtime-rejected -- it yields far = 0 for an outdoor destination, which
fails invalid-readiness-shape on every acknowledgement, hanging the very
A/B route the probe exists to measure. Parser floor raised to 1, with a
7-case table test.
- R-2: the composite-warmup TRIGGER had silently moved onto the far
window's critical path. Pre-#280 the gate and the composite domain were
the same radius-1 square; #280 widened the gate without widening the
domain, so every composite upload serialised behind the last outer-ring
landblock for no readiness benefit. Warmup now starts once the NEAR
sub-window is published -- trigger scope == domain scope, as before. The
reveal gate is untouched: Evaluate still requires the full window AND
composite readiness.
- AP-150 filed: acdream's RetailWaitCueDelay = 5 s arming is NOT retail's
trigger, and #280's commit message got this wrong on both clauses. Retail
emits the notice unconditionally per tunnel rotation segment, in the else
arm of the segment-expiry test at 0x004D6FCD; segment duration is
RandDouble(0.6, 1.8) s, byte-decoded at 0x004D6FE6. The 5.0 constant at
VA 0x007991B0 is CellManager::CheckPrefetchStatus's prefetch RETRY
cadence and has nothing to do with the cue. acdream's own 0.6/1.8 segment
constants already match retail exactly; only the arming is wrong.
Adopting retail's unconditional emit is filed as #329 rather than folded
in here -- it is a user-visible presentation change and wants the user's
eyes.
- AP-151 filed: the gate is materially STRICTER than retail on the
mesh-build/GPU-upload axis. Retail's LScape::PreFetchCells blocks on DAT
RESIDENCY only -- no geometry construction, no upload; that work is lazy
at draw. acdream requires a DAT read, terrain mesh build, render-thread
upload, spatial commit, collision admission and spawn-adapter activation
per member of a 625-member window, metered at MaxCompletionsPerFrame.
Nothing bounds the hold. This is the OPPOSITE asymmetry from AP-149; both
are live at once, on different axes.
- AD-2's amendment stated the false Far-tier readiness assumption verbatim;
corrected, along with the same error in
claude-memory/reference_two_tier_streaming.md, which now carries an
explicit DO-NOT-RETRY on the special-case-the-predicate shape.
- AP-115 scope-noted (it covers the cue's presentation, not its arming).
- #326's SmartBox::set_mid_radius citation corrected: the entry is
0x00453180; 0x004531D0 is the mid-function re-arm branch.
Blast radius: GpuWorldState, LandblockSpawnAdapter,
WorldRevealReadinessBarrier and StreamingDiagnostics are all App-internal;
AcDream.Headless and AcDream.Runtime reference none of them outside
comments. Headless tests run green as part of the gate below, per C5b's
lesson about surveys that skip the no-window host.
Gates: Release build 0 errors, 18 pre-existing xUnit analyzer warnings.
Complete suite "dotnet test AcDream.slnx -c Release -m:1" with
ACDREAM_PAK_PATH set: 11,192 passed / 4 skipped / 0 failed, from a clean
rebuild (a prior session's deleted probe file had been compiled into a
stale test DLL). Baseline at fafc0b65 was 11,179 / 4 / 0; the +13 delta
reconciles exactly to this commit's additions -- 3 readiness tests, 1
integration test, 7 parser table cases, 2 warmup-trigger tests. None of the
known flakes #302/#308/#321 surfaced, and none is conflated with the
finding above.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2103 lines
85 KiB
C#
2103 lines
85 KiB
C#
using System.Collections.Generic;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime.Entities;
|
|
|
|
namespace AcDream.App.Streaming;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal sealed record GpuLandblockSpatialPublication(
|
|
uint LandblockId,
|
|
LoadedLandblock Landblock,
|
|
IReadOnlyList<ulong> AdditionalRenderIds,
|
|
IReadOnlyList<WorldEntity> StaticEntities,
|
|
bool RequiresActivation = true,
|
|
uint RenderTraversalOrder = 0);
|
|
|
|
/// <summary>
|
|
/// Render-thread-owned registry of currently-loaded landblocks and their
|
|
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
|
|
/// on the render thread; the renderer reads <see cref="Entities"/> once per
|
|
/// frame.
|
|
///
|
|
/// <para>
|
|
/// Replaces the pre-streaming flat <c>_entities</c> 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 <see cref="AcDream.App.World.LiveEntityRuntime"/>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Pending live entities.</b> Live <c>CreateObject</c> spawns can race
|
|
/// against streaming: the server may send a spawn for landblock X before
|
|
/// X is loaded into <see cref="_loaded"/> (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, <see cref="PlaceLiveEntityProjection"/> stores
|
|
/// orphaned spawns in a per-landblock pending bucket. When
|
|
/// <see cref="AddLandblock"/> later loads the landblock, the matching
|
|
/// pending entries are merged into the loaded record before the flat
|
|
/// view rebuild. <see cref="RemoveLandblock"/> 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.
|
|
/// </para>
|
|
///
|
|
/// <remarks>
|
|
/// Threading: not thread-safe. All calls must happen on the render thread.
|
|
/// </remarks>
|
|
/// </summary>
|
|
public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|
{
|
|
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
|
|
private readonly EntityScriptActivator? _entityScriptActivator;
|
|
private readonly IWorldGenerationAvailability _availability;
|
|
|
|
/// <summary>
|
|
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
|
|
/// <see cref="RemoveEntitiesFromLandblock"/> zeroes a landblock's entity
|
|
/// list. Wired by <c>GameWindow</c> to
|
|
/// <c>EntityClassificationCache.InvalidateLandblock</c> 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 <c>0xFFFF</c>),
|
|
/// matching the <c>LandblockHint</c> stored at <c>Populate</c> time.
|
|
/// Null when the cache isn't relevant (tests).
|
|
/// </summary>
|
|
private readonly System.Action<uint>? _onLandblockUnloaded;
|
|
|
|
public GpuWorldState(
|
|
LandblockSpawnAdapter? wbSpawnAdapter = null,
|
|
System.Action<uint>? onLandblockUnloaded = null,
|
|
EntityScriptActivator? entityScriptActivator = null,
|
|
IWorldGenerationAvailability? availability = null)
|
|
{
|
|
_wbSpawnAdapter = wbSpawnAdapter;
|
|
_onLandblockUnloaded = onLandblockUnloaded;
|
|
_entityScriptActivator = entityScriptActivator;
|
|
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
|
}
|
|
|
|
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
|
// The old renderer consumes landblocks in Dictionary entry-slot order.
|
|
// Make that formerly implicit ordering explicit so both the accepted path
|
|
// and the retained render scene share one stable source. Slots are reused
|
|
// LIFO, matching Dictionary's remove/add behavior, while enumeration skips
|
|
// retired holes.
|
|
private readonly List<uint> _renderTraversalLandblockSlots = [];
|
|
private readonly Stack<int> _freeRenderTraversalLandblockSlots = [];
|
|
private readonly Dictionary<uint, int> _renderTraversalSlotByLandblock = [];
|
|
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
|
|
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
|
private readonly Dictionary<uint, AnimatedEntityIndex> _animatedIndexByLandblock = new();
|
|
// H-a4: synchronous render borrows. The former yield properties created
|
|
// fresh iterator state at every per-frame call site.
|
|
private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
|
IReadOnlyList<WorldEntity> Entities,
|
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>
|
|
_landblockEntriesView = [];
|
|
private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
|
IReadOnlyList<WorldEntity> Entities,
|
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>
|
|
_landblockEntriesWithoutAnimatedIndexView = [];
|
|
private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)>
|
|
_landblockBoundsView = [];
|
|
private bool _landblockEntriesViewDirty = true;
|
|
private bool _landblockEntriesWithoutAnimatedIndexViewDirty = true;
|
|
private bool _landblockBoundsViewDirty = true;
|
|
|
|
private sealed record AnimatedEntityIndex(
|
|
LoadedLandblock Source,
|
|
IReadOnlyDictionary<uint, WorldEntity> EntitiesById);
|
|
|
|
/// <summary>
|
|
/// Per-landblock buffer of live entities awaiting their landblock's
|
|
/// arrival. Keyed by canonical landblock id (<c>0xAAAA0xFFFF</c>).
|
|
/// 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).
|
|
/// On RemoveLandblock, these are rescued and re-parked as pending for their
|
|
/// current canonical landblock.
|
|
/// </summary>
|
|
private readonly HashSet<uint> _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<WorldEntity> _flatEntities = new();
|
|
private readonly Dictionary<WorldEntity, int> _flatEntityIndices =
|
|
new(ReferenceEqualityComparer.Instance);
|
|
private readonly Dictionary<WorldEntity, ProjectionLocation> _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<uint, HashSet<WorldEntity>> _loadedLiveByLandblock = new();
|
|
// RuntimeEntityKey is the complete materialized identity. Local IDs remain
|
|
// stable through retryable teardown, while the incarnation prevents a
|
|
// delayed spatial edge from reaching a later reuse of the same local ID.
|
|
private readonly Dictionary<RuntimeEntityKey, WorldEntity> _liveProjectionByKey = new();
|
|
private readonly Dictionary<RuntimeEntityKey, int> _visibleLiveProjectionCounts = new();
|
|
private readonly Dictionary<RuntimeEntityKey, bool> _visibilityBeforeMutation = new();
|
|
private readonly Queue<(RuntimeEntityKey Key, bool Visible)> _visibilityTransitions = new();
|
|
private readonly List<WorldEntity> _guidRemovalScratch = new();
|
|
private bool _dispatchingVisibilityTransitions;
|
|
private int _mutationDepth;
|
|
private long _visibilityCommitCount;
|
|
private ulong _flatViewGeneration;
|
|
private bool _flatMembershipDirty;
|
|
|
|
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
|
|
public ulong FlatViewGeneration => _flatViewGeneration;
|
|
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
|
|
public event Action<RuntimeEntityKey, 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);
|
|
/// <summary>
|
|
/// True when the exact Runtime-issued local projection belongs to a loaded
|
|
/// spatial bucket. This deliberately ignores the world-generation
|
|
/// presentation gate: destination objects must acquire their render
|
|
/// resources while portal/login reveal keeps normal world consumers closed.
|
|
/// </summary>
|
|
public bool IsLiveEntityProjectionResident(RuntimeEntityKey key) =>
|
|
key.LocalEntityId != 0
|
|
&& _visibleLiveProjectionCounts.ContainsKey(key);
|
|
|
|
public bool IsLiveEntityVisible(RuntimeEntityKey key) =>
|
|
_availability.IsWorldAvailable
|
|
&& IsLiveEntityProjectionResident(key);
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb)
|
|
{
|
|
if (_loaded.TryGetValue(landblockId, out var found))
|
|
{
|
|
lb = found;
|
|
return true;
|
|
}
|
|
lb = null;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store the axis-aligned bounding box for a loaded landblock. Called from
|
|
/// the render thread after the terrain mesh is built and uploaded.
|
|
/// </summary>
|
|
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
|
|
{
|
|
_aabbs[landblockId] = (min, max);
|
|
InvalidateLandblockRenderViews(entries: true, bounds: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-landblock iteration with AABB data for use by the frustum-culling
|
|
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
|
|
/// for both corners, which the culler will conservatively treat as visible.
|
|
///
|
|
/// <para>
|
|
/// A.5 T17: also yields an <c>AnimatedById</c> dictionary derived from the
|
|
/// landblock's entity list. This lets <see cref="WbDrawDispatcher"/>
|
|
/// 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 <see cref="LoadedLandblock"/> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
|
IReadOnlyList<WorldEntity> Entities,
|
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
|
|
=> GetLandblockEntriesView(includeAnimatedIndex: true);
|
|
|
|
/// <summary>
|
|
/// Per-landblock render entries without the animated lookup dictionary.
|
|
/// Static render passes use this to avoid rebuilding an index they cannot
|
|
/// consume.
|
|
/// </summary>
|
|
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
|
IReadOnlyList<WorldEntity> Entities,
|
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
|
|
=> GetLandblockEntriesView(includeAnimatedIndex: false);
|
|
|
|
/// <summary>
|
|
/// Lightweight bounds-only enumeration for overlays and diagnostics.
|
|
/// Does not walk entity lists.
|
|
/// </summary>
|
|
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
|
|
{
|
|
get
|
|
{
|
|
if (!_availability.IsWorldAvailable)
|
|
return Array.Empty<(uint, Vector3, Vector3)>();
|
|
if (!_landblockBoundsViewDirty)
|
|
return _landblockBoundsView;
|
|
|
|
_landblockBoundsView.Clear();
|
|
for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
|
|
{
|
|
uint landblockId = _renderTraversalLandblockSlots[slot];
|
|
if (landblockId == 0)
|
|
continue;
|
|
if (_aabbs.TryGetValue(landblockId, out var aabb))
|
|
_landblockBoundsView.Add((landblockId, aabb.Min, aabb.Max));
|
|
else
|
|
_landblockBoundsView.Add(
|
|
(landblockId, Vector3.Zero, Vector3.Zero));
|
|
}
|
|
|
|
_landblockBoundsViewDirty = false;
|
|
return _landblockBoundsView;
|
|
}
|
|
}
|
|
|
|
private IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
|
IReadOnlyList<WorldEntity> Entities,
|
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> GetLandblockEntriesView(
|
|
bool includeAnimatedIndex)
|
|
{
|
|
if (!_availability.IsWorldAvailable)
|
|
{
|
|
return Array.Empty<(uint, Vector3, Vector3,
|
|
IReadOnlyList<WorldEntity>,
|
|
IReadOnlyDictionary<uint, WorldEntity>?)>();
|
|
}
|
|
|
|
List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
|
IReadOnlyList<WorldEntity> Entities,
|
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> view =
|
|
includeAnimatedIndex
|
|
? _landblockEntriesView
|
|
: _landblockEntriesWithoutAnimatedIndexView;
|
|
bool dirty = includeAnimatedIndex
|
|
? _landblockEntriesViewDirty
|
|
: _landblockEntriesWithoutAnimatedIndexViewDirty;
|
|
if (!dirty)
|
|
return view;
|
|
|
|
view.Clear();
|
|
|
|
for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
|
|
{
|
|
uint landblockId = _renderTraversalLandblockSlots[slot];
|
|
if (landblockId == 0)
|
|
continue;
|
|
LoadedLandblock landblock = _loaded[landblockId];
|
|
IReadOnlyDictionary<uint, WorldEntity>? byId = null;
|
|
if (includeAnimatedIndex)
|
|
{
|
|
if (!_animatedIndexByLandblock.TryGetValue(landblockId, out var cached)
|
|
|| !ReferenceEquals(cached.Source, landblock))
|
|
{
|
|
var rebuilt = new Dictionary<uint, WorldEntity>(landblock.Entities.Count);
|
|
foreach (var entity in landblock.Entities)
|
|
rebuilt[entity.Id] = entity;
|
|
|
|
cached = new AnimatedEntityIndex(landblock, rebuilt);
|
|
_animatedIndexByLandblock[landblockId] = cached;
|
|
}
|
|
|
|
byId = cached.EntitiesById;
|
|
}
|
|
|
|
if (_aabbs.TryGetValue(landblockId, out var aabb))
|
|
view.Add(
|
|
(landblockId, aabb.Min, aabb.Max, landblock.Entities, byId));
|
|
else
|
|
view.Add(
|
|
(landblockId, Vector3.Zero, Vector3.Zero, landblock.Entities, byId));
|
|
}
|
|
|
|
if (includeAnimatedIndex)
|
|
_landblockEntriesViewDirty = false;
|
|
else
|
|
_landblockEntriesWithoutAnimatedIndexViewDirty = false;
|
|
return view;
|
|
}
|
|
|
|
private void InvalidateLandblockRenderViews(bool entries, bool bounds)
|
|
{
|
|
if (entries)
|
|
{
|
|
_landblockEntriesViewDirty = true;
|
|
_landblockEntriesWithoutAnimatedIndexViewDirty = true;
|
|
}
|
|
if (bounds)
|
|
_landblockBoundsViewDirty = true;
|
|
}
|
|
|
|
internal bool TryGetRenderTraversalSortKey(
|
|
WorldEntity entity,
|
|
out RenderSortKey sortKey)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entity);
|
|
if (_projectionLocations.TryGetValue(
|
|
entity,
|
|
out ProjectionLocation location)
|
|
&& location.IsLoaded
|
|
&& _renderTraversalSlotByLandblock.TryGetValue(
|
|
location.LandblockId,
|
|
out int landblockSlot))
|
|
{
|
|
sortKey = RenderTraversalSortKey.Compose(
|
|
checked((uint)landblockSlot + 1),
|
|
location.BucketIndex);
|
|
return true;
|
|
}
|
|
|
|
sortKey = default;
|
|
return false;
|
|
}
|
|
|
|
private uint GetRenderTraversalOrder(uint landblockId)
|
|
{
|
|
if (!_renderTraversalSlotByLandblock.TryGetValue(
|
|
landblockId,
|
|
out int slot))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Loaded landblock 0x{landblockId:X8} has no render traversal slot.");
|
|
}
|
|
|
|
return checked((uint)slot + 1);
|
|
}
|
|
|
|
private void AddLoadedLandblock(
|
|
uint landblockId,
|
|
LoadedLandblock landblock)
|
|
{
|
|
if (!_loaded.TryAdd(landblockId, landblock))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Landblock 0x{landblockId:X8} is already loaded.");
|
|
}
|
|
|
|
int slot;
|
|
if (_freeRenderTraversalLandblockSlots.TryPop(out int freeSlot))
|
|
{
|
|
slot = freeSlot;
|
|
if (_renderTraversalLandblockSlots[slot] != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Render traversal slot {slot} was not free.");
|
|
}
|
|
_renderTraversalLandblockSlots[slot] = landblockId;
|
|
}
|
|
else
|
|
{
|
|
slot = _renderTraversalLandblockSlots.Count;
|
|
_renderTraversalLandblockSlots.Add(landblockId);
|
|
}
|
|
|
|
if (!_renderTraversalSlotByLandblock.TryAdd(landblockId, slot))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Landblock 0x{landblockId:X8} already owns a render traversal slot.");
|
|
}
|
|
InvalidateLandblockRenderViews(entries: true, bounds: true);
|
|
}
|
|
|
|
private bool RemoveLoadedLandblock(
|
|
uint landblockId,
|
|
[NotNullWhen(true)]
|
|
out LoadedLandblock? landblock)
|
|
{
|
|
if (!_loaded.Remove(landblockId, out landblock))
|
|
return false;
|
|
if (!_renderTraversalSlotByLandblock.Remove(
|
|
landblockId,
|
|
out int slot)
|
|
|| _renderTraversalLandblockSlots[slot] != landblockId)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Loaded landblock 0x{landblockId:X8} has inconsistent render traversal state.");
|
|
}
|
|
|
|
_renderTraversalLandblockSlots[slot] = 0;
|
|
_freeRenderTraversalLandblockSlots.Push(slot);
|
|
InvalidateLandblockRenderViews(entries: true, bounds: true);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
internal int OriginRecenterSpatialOperationCount =>
|
|
Math.Max(
|
|
1,
|
|
_loaded.Count
|
|
+ _pendingByLandblock.Count
|
|
+ _pendingRenderIdsByLandblock.Count
|
|
+ _pendingNearTierLandblocks.Count
|
|
+ _projectionLocations.Count);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public MutationBatch BeginMutationBatch()
|
|
{
|
|
_mutationDepth++;
|
|
return new MutationBatch(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copy live server projections from the bounded landblock neighborhood
|
|
/// around <paramref name="centerCellOrLandblockId"/>. This is the spatial
|
|
/// candidate seam for short-range consumers such as retail radar; they
|
|
/// still apply their own Hidden/classification/range rules.
|
|
/// </summary>
|
|
public void CopyLiveEntitiesNearLandblock(
|
|
uint centerCellOrLandblockId,
|
|
int landblockRadius,
|
|
List<KeyValuePair<uint, WorldEntity>> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
|
|
destination.Clear();
|
|
if (!_availability.IsWorldAvailable)
|
|
return;
|
|
|
|
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<WorldEntity>? liveEntities))
|
|
continue;
|
|
|
|
foreach (WorldEntity entity in liveEntities)
|
|
destination.Add(new KeyValuePair<uint, WorldEntity>(entity.ServerGuid, entity));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies every published projection from a bounded destination
|
|
/// neighborhood for presentation-readiness work. Unlike gameplay-facing
|
|
/// spatial queries, this remains available while the world generation is
|
|
/// quiesced: the reveal barrier must prepare destination resources before
|
|
/// it makes that generation observable.
|
|
/// </summary>
|
|
internal void CopyPublishedEntitiesNearLandblockForReadiness(
|
|
uint centerCellOrLandblockId,
|
|
int landblockRadius,
|
|
List<WorldEntity> 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 (!_loaded.TryGetValue(
|
|
landblockId,
|
|
out LoadedLandblock? landblock))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (int i = 0; i < landblock.Entities.Count; i++)
|
|
destination.Add(landblock.Entities[i]);
|
|
}
|
|
}
|
|
|
|
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,
|
|
RuntimeEntityKey Key);
|
|
|
|
private void EndMutationBatch()
|
|
{
|
|
if (_mutationDepth <= 0)
|
|
throw new InvalidOperationException("GpuWorldState mutation scope underflow.");
|
|
if (--_mutationDepth != 0)
|
|
return;
|
|
|
|
foreach ((RuntimeEntityKey key, bool wasVisible) in _visibilityBeforeMutation)
|
|
{
|
|
bool isVisible = _visibleLiveProjectionCounts.ContainsKey(key);
|
|
if (wasVisible != isVisible)
|
|
_visibilityTransitions.Enqueue((key, isVisible));
|
|
}
|
|
_visibilityBeforeMutation.Clear();
|
|
_visibilityCommitCount++;
|
|
if (_flatMembershipDirty)
|
|
{
|
|
_flatViewGeneration++;
|
|
_flatMembershipDirty = false;
|
|
}
|
|
|
|
DrainVisibilityTransitions();
|
|
if (EntityVanishProbe.Enabled)
|
|
ProbeFlatViewTransitions();
|
|
}
|
|
|
|
private void AdjustVisibleLiveProjection(
|
|
RuntimeEntityKey key,
|
|
WorldEntity entity,
|
|
int delta)
|
|
{
|
|
if (entity.ServerGuid == 0 || key.LocalEntityId == 0 || delta == 0)
|
|
return;
|
|
if (key.LocalEntityId != entity.Id)
|
|
throw new InvalidOperationException(
|
|
"A live visibility key must match its WorldEntity local ID.");
|
|
|
|
_visibleLiveProjectionCounts.TryGetValue(key, out int priorCount);
|
|
if (!_visibilityBeforeMutation.ContainsKey(key))
|
|
_visibilityBeforeMutation.Add(key, priorCount > 0);
|
|
|
|
int nextCount = checked(priorCount + delta);
|
|
if (nextCount < 0)
|
|
throw new InvalidOperationException(
|
|
$"Live projection visibility count underflow for local " +
|
|
$"0x{key.LocalEntityId:X8}/{key.Incarnation} / " +
|
|
$"server 0x{entity.ServerGuid:X8}.");
|
|
|
|
if (nextCount == 0)
|
|
{
|
|
_visibleLiveProjectionCounts.Remove(key);
|
|
}
|
|
else
|
|
{
|
|
_visibleLiveProjectionCounts[key] = nextCount;
|
|
}
|
|
}
|
|
|
|
private static LoadedLandblock NormalizeLoadedLandblock(
|
|
LoadedLandblock source,
|
|
List<WorldEntity>? entities = null) =>
|
|
new(
|
|
source.LandblockId,
|
|
source.Heightmap,
|
|
entities ?? new List<WorldEntity>(source.Entities),
|
|
PhysicsDatBundle.Empty);
|
|
|
|
private static List<WorldEntity> MutableEntities(LoadedLandblock landblock) =>
|
|
(List<WorldEntity>)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,
|
|
RuntimeEntityKey? requestedKey = null)
|
|
{
|
|
if (entity.ServerGuid == 0)
|
|
return;
|
|
|
|
bool isNew = !_projectionLocations.TryGetValue(
|
|
entity,
|
|
out ProjectionLocation priorLocation);
|
|
RuntimeEntityKey key = isNew
|
|
? requestedKey ?? new RuntimeEntityKey(entity.Id, 0)
|
|
: priorLocation.Key;
|
|
if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The exact spatial projection key must match the WorldEntity local ID.");
|
|
}
|
|
if (!isNew && requestedKey is { } requested && requested != key)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A live spatial projection cannot change exact Runtime identity.");
|
|
}
|
|
if (!isNew
|
|
&& priorLocation.IsLoaded
|
|
&& (!isLoaded || priorLocation.LandblockId != landblockId))
|
|
{
|
|
RemoveLoadedLiveIndex(priorLocation.LandblockId, entity);
|
|
}
|
|
_projectionLocations[entity] = new ProjectionLocation(
|
|
landblockId,
|
|
isLoaded,
|
|
bucketIndex,
|
|
key);
|
|
if (isLoaded
|
|
&& (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId))
|
|
{
|
|
AddLoadedLiveIndex(landblockId, entity);
|
|
}
|
|
if (!isNew)
|
|
return;
|
|
|
|
if (!_liveProjectionByKey.TryAdd(key, entity))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime projection {key} is already spatially owned.");
|
|
}
|
|
}
|
|
|
|
private void RemoveProjectionLocation(WorldEntity entity)
|
|
{
|
|
if (!_projectionLocations.Remove(entity, out ProjectionLocation location)
|
|
|| entity.ServerGuid == 0)
|
|
return;
|
|
if (location.IsLoaded)
|
|
RemoveLoadedLiveIndex(location.LandblockId, entity);
|
|
|
|
if (!_liveProjectionByKey.Remove(
|
|
location.Key,
|
|
out WorldEntity? indexed))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live projection {location.Key} had no exact-key index entry.");
|
|
}
|
|
if (!ReferenceEquals(indexed, entity))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime projection {location.Key} resolved a different owner.");
|
|
}
|
|
}
|
|
|
|
private void AddLoadedLiveIndex(uint landblockId, WorldEntity entity)
|
|
{
|
|
if (!_loadedLiveByLandblock.TryGetValue(
|
|
landblockId,
|
|
out HashSet<WorldEntity>? entities))
|
|
{
|
|
entities = new HashSet<WorldEntity>(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<WorldEntity>? 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,
|
|
RuntimeEntityKey? requestedKey = null)
|
|
{
|
|
LoadedLandblock landblock = _loaded[landblockId];
|
|
List<WorldEntity> entities = MutableEntities(landblock);
|
|
int bucketIndex = entities.Count;
|
|
entities.Add(entity);
|
|
_animatedIndexByLandblock.Remove(landblockId);
|
|
InvalidateLandblockRenderViews(entries: true, bounds: false);
|
|
AddFlatEntity(entity);
|
|
if (entity.ServerGuid != 0)
|
|
{
|
|
SetProjectionLocation(
|
|
entity,
|
|
landblockId,
|
|
isLoaded: true,
|
|
bucketIndex,
|
|
requestedKey);
|
|
AdjustVisibleLiveProjection(
|
|
_projectionLocations[entity].Key,
|
|
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<WorldEntity> 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);
|
|
InvalidateLandblockRenderViews(entries: true, bounds: false);
|
|
RemoveFlatEntity(entity);
|
|
RemoveProjectionLocation(entity);
|
|
AdjustVisibleLiveProjection(location.Key, entity, -1);
|
|
}
|
|
|
|
private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock)
|
|
{
|
|
foreach (WorldEntity entity in landblock.Entities)
|
|
{
|
|
RemoveFlatEntity(entity);
|
|
if (entity.ServerGuid != 0)
|
|
{
|
|
RuntimeEntityKey key = _projectionLocations[entity].Key;
|
|
RemoveProjectionLocation(entity);
|
|
AdjustVisibleLiveProjection(key, 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);
|
|
if (entity.ServerGuid != 0)
|
|
{
|
|
AdjustVisibleLiveProjection(
|
|
_projectionLocations[entity].Key,
|
|
entity,
|
|
+1);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void AddLandblock(
|
|
LoadedLandblock landblock,
|
|
IEnumerable<ulong>? additionalRenderIds = null,
|
|
LandblockStreamTier tier = LandblockStreamTier.Near)
|
|
{
|
|
using MutationBatch mutation = BeginMutationBatch();
|
|
GpuLandblockSpatialPublication publication =
|
|
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
|
|
ActivateLandblockPresentation(publication);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits only canonical buckets, indexes, pending-live merge, tier, and
|
|
/// readiness-id ownership. The caller holds one outer
|
|
/// <see cref="MutationBatch"/> through the subsequent
|
|
/// <see cref="ActivateLandblockPresentation"/> call so live visibility
|
|
/// observers retain the shipped commit boundary.
|
|
/// </summary>
|
|
internal GpuLandblockSpatialPublication CommitLandblockSpatial(
|
|
LoadedLandblock landblock,
|
|
IEnumerable<ulong>? additionalRenderIds,
|
|
LandblockStreamTier tier) =>
|
|
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
|
|
|
|
private GpuLandblockSpatialPublication CommitLandblockSpatialCore(
|
|
LoadedLandblock landblock,
|
|
IEnumerable<ulong>? 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<ulong>(),
|
|
Array.Empty<WorldEntity>(),
|
|
RequiresActivation: false,
|
|
RenderTraversalOrder:
|
|
GetRenderTraversalOrder(retained.LandblockId));
|
|
}
|
|
|
|
// 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<WorldEntity>(landblock.Entities.Count + pending.Count);
|
|
merged.AddRange(landblock.Entities);
|
|
merged.AddRange(pending);
|
|
landblock = NormalizeLoadedLandblock(landblock, merged);
|
|
_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;
|
|
}
|
|
|
|
if (RemoveLoadedLandblock(landblock.LandblockId, out displaced))
|
|
{
|
|
RemoveLoadedLandblockFromFlatView(displaced);
|
|
_animatedIndexByLandblock.Remove(landblock.LandblockId);
|
|
}
|
|
|
|
AddLoadedLandblock(landblock.LandblockId, landblock);
|
|
AddLoadedLandblockToFlatView(landblock);
|
|
_tierByLandblock[landblock.LandblockId] = tier;
|
|
return CreateSpatialPublication(
|
|
_loaded[landblock.LandblockId],
|
|
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>(),
|
|
GetRenderTraversalOrder(landblock.LandblockId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void ReconcileRetainedStaticEntityScripts(
|
|
LoadedLandblock displaced,
|
|
LoadedLandblock replacement)
|
|
{
|
|
if (_entityScriptActivator is null)
|
|
return;
|
|
|
|
var replacementsById = new Dictionary<uint, WorldEntity>();
|
|
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<ulong> additionalRenderIds,
|
|
uint renderTraversalOrder)
|
|
{
|
|
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,
|
|
RenderTraversalOrder: renderTraversalOrder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mark a server-GUID as persistent — this entity survives landblock unloads
|
|
/// and gets re-parked as pending for its current canonical landblock.
|
|
/// </summary>
|
|
public void MarkPersistent(uint serverGuid)
|
|
{
|
|
_persistentGuids.Add(serverGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entity);
|
|
RuntimeEntityKey key = _projectionLocations.TryGetValue(
|
|
entity,
|
|
out ProjectionLocation existing)
|
|
? existing.Key
|
|
: new RuntimeEntityKey(entity.Id, 0);
|
|
RebucketLiveEntity(key, entity, newCanonicalLb);
|
|
}
|
|
|
|
public void RebucketLiveEntity(
|
|
RuntimeEntityKey key,
|
|
WorldEntity entity,
|
|
uint newCanonicalLb)
|
|
{
|
|
if (entity.ServerGuid == 0) return;
|
|
if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id)
|
|
throw new InvalidOperationException(
|
|
"The exact rebucket key must match the WorldEntity local ID.");
|
|
|
|
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).
|
|
bool hasCurrent = _projectionLocations.TryGetValue(
|
|
entity,
|
|
out ProjectionLocation current);
|
|
if (hasCurrent && current.Key != key)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The live spatial projection is owned by another Runtime incarnation.");
|
|
}
|
|
if (hasCurrent
|
|
&& 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(key, canonical, entity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove <paramref name="entity"/> (by reference) from whichever
|
|
/// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it
|
|
/// currently occupies. At most one bucket holds a given entity, so this
|
|
/// stops after the first hit. Called by <see cref="RebucketLiveEntity"/> before
|
|
/// re-appending, so a stranded pending entity can be promoted.
|
|
/// </summary>
|
|
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<WorldEntity>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="LandblockRetirementCoordinator"/> after the old state has
|
|
/// become unreachable.
|
|
/// </summary>
|
|
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<WorldEntity> detachedEntities =
|
|
_loaded.TryGetValue(canonical, out LoadedLandblock? residentLandblock)
|
|
? residentLandblock.Entities
|
|
: Array.Empty<WorldEntity>();
|
|
if (_pendingByLandblock.TryGetValue(canonical, out List<WorldEntity>? detachedPending)
|
|
&& detachedPending.Count > 0)
|
|
{
|
|
if (detachedEntities.Count == 0)
|
|
{
|
|
detachedEntities = detachedPending;
|
|
}
|
|
else
|
|
{
|
|
var combined = new List<WorldEntity>(
|
|
detachedEntities.Count + detachedPending.Count);
|
|
var seen = new HashSet<WorldEntity>(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<WorldEntity>();
|
|
var retainedLiveSet = new HashSet<WorldEntity>(ReferenceEqualityComparer.Instance);
|
|
var retainedLiveKeys = new Dictionary<WorldEntity, RuntimeEntityKey>(
|
|
ReferenceEqualityComparer.Instance);
|
|
void RetainOrRescue(WorldEntity entity, string source)
|
|
{
|
|
if (entity.ServerGuid == 0)
|
|
return;
|
|
if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live projection 0x{entity.Id:X8} has no exact spatial owner.");
|
|
}
|
|
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);
|
|
retainedLiveKeys.Add(entity, location.Key);
|
|
}
|
|
}
|
|
|
|
// 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 (RemoveLoadedLandblock(canonical, out LoadedLandblock? removed))
|
|
RemoveLoadedLandblockFromFlatView(removed);
|
|
|
|
for (int i = 0; i < retainedLive.Count; i++)
|
|
{
|
|
WorldEntity entity = retainedLive[i];
|
|
SetProjectionLocation(
|
|
entity,
|
|
canonical,
|
|
isLoaded: false,
|
|
i,
|
|
retainedLiveKeys[entity]);
|
|
}
|
|
|
|
return new GpuLandblockRetirement(
|
|
canonical,
|
|
LandblockRetirementKind.Full,
|
|
detachedEntities);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Atomically withdraws the complete old spatial generation before a
|
|
/// shared-origin change. The origin is common to every resident transform,
|
|
/// so admitting hundreds of independent landblock detaches across frames
|
|
/// only prolongs portal transit; it does not expose a useful intermediate
|
|
/// state. This operation swaps the spatial indexes as one transaction,
|
|
/// retains live logical objects, and returns exact landblock receipts so
|
|
/// renderer/physics/script destruction can remain frame-budgeted.
|
|
/// </summary>
|
|
internal GpuWorldRecenterRetirement DetachAllForOriginRecenter()
|
|
{
|
|
var ids = new List<uint>(
|
|
_loaded.Count
|
|
+ _pendingByLandblock.Count
|
|
+ _pendingRenderIdsByLandblock.Count);
|
|
var seenIds = new HashSet<uint>();
|
|
|
|
void AddId(uint id)
|
|
{
|
|
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
|
if (seenIds.Add(canonical))
|
|
ids.Add(canonical);
|
|
}
|
|
|
|
// Preserve the accepted renderer traversal order for already-resident
|
|
// landblocks, then append presentation-only and pending-only owners in
|
|
// their stable insertion order.
|
|
for (int i = 0; i < _renderTraversalLandblockSlots.Count; i++)
|
|
{
|
|
uint id = _renderTraversalLandblockSlots[i];
|
|
if (id != 0u)
|
|
AddId(id);
|
|
}
|
|
// A pending-only bucket owns live spatial projections, not a loaded
|
|
// landblock presentation generation. Those projections are retained
|
|
// below through _projectionLocations, but there is no terrain,
|
|
// collision, static-script, or renderer owner to retire. In
|
|
// particular, DetachLandblock deliberately parks surviving live
|
|
// projections here while its existing exact retirement receipt is
|
|
// still advancing. Emitting another full receipt during a recenter
|
|
// would give the same retired generation two cleanup owners.
|
|
foreach (uint id in _pendingRenderIdsByLandblock.Keys)
|
|
AddId(id);
|
|
foreach (uint id in _pendingNearTierLandblocks)
|
|
AddId(id);
|
|
foreach (uint id in _tierByLandblock.Keys)
|
|
AddId(id);
|
|
foreach (uint id in _aabbs.Keys)
|
|
AddId(id);
|
|
|
|
var retirements = new List<GpuLandblockRetirement>(ids.Count);
|
|
for (int i = 0; i < ids.Count; i++)
|
|
{
|
|
uint id = ids[i];
|
|
IReadOnlyList<WorldEntity> loaded =
|
|
_loaded.TryGetValue(id, out LoadedLandblock? landblock)
|
|
? landblock.Entities
|
|
: Array.Empty<WorldEntity>();
|
|
IReadOnlyList<WorldEntity> pending =
|
|
_pendingByLandblock.TryGetValue(id, out List<WorldEntity>? bucket)
|
|
? bucket
|
|
: Array.Empty<WorldEntity>();
|
|
IReadOnlyList<WorldEntity> detached = loaded;
|
|
if (pending.Count != 0)
|
|
{
|
|
if (loaded.Count == 0)
|
|
{
|
|
detached = pending;
|
|
}
|
|
else
|
|
{
|
|
var combined = new List<WorldEntity>(
|
|
loaded.Count + pending.Count);
|
|
var seenEntities = new HashSet<WorldEntity>(
|
|
ReferenceEqualityComparer.Instance);
|
|
for (int entityIndex = 0;
|
|
entityIndex < loaded.Count;
|
|
entityIndex++)
|
|
{
|
|
WorldEntity entity = loaded[entityIndex];
|
|
if (seenEntities.Add(entity))
|
|
combined.Add(entity);
|
|
}
|
|
for (int entityIndex = 0;
|
|
entityIndex < pending.Count;
|
|
entityIndex++)
|
|
{
|
|
WorldEntity entity = pending[entityIndex];
|
|
if (seenEntities.Add(entity))
|
|
combined.Add(entity);
|
|
}
|
|
detached = combined;
|
|
}
|
|
}
|
|
|
|
retirements.Add(new GpuLandblockRetirement(
|
|
id,
|
|
LandblockRetirementKind.Full,
|
|
detached));
|
|
}
|
|
|
|
// Capture live projections from their small dedicated index rather
|
|
// than walking every DAT-static entity in the 25x25 world window.
|
|
var retainedByLandblock =
|
|
new Dictionary<uint, List<(
|
|
WorldEntity Entity,
|
|
int BucketIndex,
|
|
RuntimeEntityKey Key)>>();
|
|
var rescued = new HashSet<WorldEntity>(
|
|
_persistentRescued,
|
|
ReferenceEqualityComparer.Instance);
|
|
foreach ((WorldEntity entity, ProjectionLocation location) in
|
|
_projectionLocations)
|
|
{
|
|
if (_persistentGuids.Contains(entity.ServerGuid))
|
|
{
|
|
if (rescued.Add(entity))
|
|
{
|
|
_persistentRescued.Add(entity);
|
|
EntityVanishProbe.Log(
|
|
$"[ent] RESCUE guid=0x{entity.ServerGuid:X8} " +
|
|
$"from=recenter lb=0x{location.LandblockId:X8}");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!retainedByLandblock.TryGetValue(
|
|
location.LandblockId,
|
|
out List<(
|
|
WorldEntity Entity,
|
|
int BucketIndex,
|
|
RuntimeEntityKey Key)>? retained))
|
|
{
|
|
retained = [];
|
|
retainedByLandblock.Add(location.LandblockId, retained);
|
|
}
|
|
retained.Add((entity, location.BucketIndex, location.Key));
|
|
}
|
|
|
|
int spatialOperationCount = OriginRecenterSpatialOperationCount;
|
|
Exception? observerFailure = null;
|
|
MutationBatch mutation = BeginMutationBatch();
|
|
try
|
|
{
|
|
foreach ((RuntimeEntityKey key, int count) in _visibleLiveProjectionCounts)
|
|
{
|
|
if (count > 0
|
|
&& !_visibilityBeforeMutation.ContainsKey(key))
|
|
{
|
|
_visibilityBeforeMutation.Add(key, true);
|
|
}
|
|
}
|
|
_visibleLiveProjectionCounts.Clear();
|
|
|
|
if (_flatEntities.Count != 0)
|
|
_flatMembershipDirty = true;
|
|
_flatEntities.Clear();
|
|
_flatEntityIndices.Clear();
|
|
_projectionLocations.Clear();
|
|
_loadedLiveByLandblock.Clear();
|
|
_liveProjectionByKey.Clear();
|
|
|
|
_loaded.Clear();
|
|
_renderTraversalLandblockSlots.Clear();
|
|
_freeRenderTraversalLandblockSlots.Clear();
|
|
_renderTraversalSlotByLandblock.Clear();
|
|
_tierByLandblock.Clear();
|
|
_aabbs.Clear();
|
|
_animatedIndexByLandblock.Clear();
|
|
_pendingByLandblock.Clear();
|
|
_pendingRenderIdsByLandblock.Clear();
|
|
_pendingNearTierLandblocks.Clear();
|
|
|
|
_landblockEntriesView.Clear();
|
|
_landblockEntriesWithoutAnimatedIndexView.Clear();
|
|
_landblockBoundsView.Clear();
|
|
InvalidateLandblockRenderViews(entries: true, bounds: true);
|
|
|
|
foreach ((uint id, List<(
|
|
WorldEntity Entity,
|
|
int BucketIndex,
|
|
RuntimeEntityKey Key)> retained) in
|
|
retainedByLandblock)
|
|
{
|
|
retained.Sort(static (left, right) =>
|
|
left.BucketIndex.CompareTo(right.BucketIndex));
|
|
var pending = new List<WorldEntity>(retained.Count);
|
|
for (int i = 0; i < retained.Count; i++)
|
|
{
|
|
WorldEntity entity = retained[i].Entity;
|
|
pending.Add(entity);
|
|
SetProjectionLocation(
|
|
entity,
|
|
id,
|
|
isLoaded: false,
|
|
i,
|
|
retained[i].Key);
|
|
}
|
|
_pendingByLandblock.Add(id, pending);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
mutation.Dispose();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
observerFailure = error;
|
|
}
|
|
}
|
|
|
|
return new GpuWorldRecenterRetirement(
|
|
retirements,
|
|
spatialOperationCount,
|
|
observerFailure);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compatibility edge for direct state tests. Production streaming uses
|
|
/// <see cref="LandblockRetirementCoordinator"/> so each presentation owner
|
|
/// has a retryable committed marker.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reconciles the spawn-adapter registration to the landblock's state
|
|
/// AFTER a retirement has committed, which is not the same thing as
|
|
/// "unregister".
|
|
///
|
|
/// <para>
|
|
/// A <b>full</b> retirement removes the landblock from <c>_loaded</c> and
|
|
/// from <c>_tierByLandblock</c>, so releasing every mesh reference and
|
|
/// dropping the registration is the whole job.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// A <b>Near-layer</b> retirement (the Near→Far demote) is different:
|
|
/// <see cref="DetachNearLayer"/> keeps the landblock loaded, keeps its
|
|
/// terrain mesh uploaded and drawn, keeps its terrain collision resident
|
|
/// (<c>PhysicsEngine.DemoteLandblockToTerrain</c>), and flips its tier to
|
|
/// <see cref="LandblockStreamTier.Far"/>. Only the Near entity layer's
|
|
/// mesh references go away. Releasing them leaves
|
|
/// <c>LandblockSpawnAdapter.WantsLoaded == false</c>, so
|
|
/// <see cref="IsRenderReady"/> would report false for a landblock that is
|
|
/// on screen — and nothing ever re-publishes an already-loaded landblock,
|
|
/// so it would stay false for the rest of the window's life.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Re-asserting the empty registration here is what makes the two ways of
|
|
/// reaching Far tier equivalent: a demoted landblock ends up with exactly
|
|
/// the registration a <c>PublicationKind.Far</c> activation installs
|
|
/// (<c>WantsLoaded = true</c>, no ordinary or prepared references, because
|
|
/// the retained entity list holds only live server projections, which the
|
|
/// adapter's atlas-tier filter skips). #280's reveal gate — and every
|
|
/// future <see cref="IsRenderReady"/> caller — can then read the predicate
|
|
/// as "drawable at its current tier" without needing to know how the
|
|
/// landblock got there.
|
|
/// </para>
|
|
/// </summary>
|
|
internal void ReleaseLandblockMeshReferences(uint landblockId)
|
|
{
|
|
if (_wbSpawnAdapter is null)
|
|
return;
|
|
|
|
// A throwing release stays retryable: the re-assertion below is only
|
|
// reached once the adapter has actually converged.
|
|
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
|
|
|
|
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
|
if (!_loaded.TryGetValue(canonical, out LoadedLandblock? retained))
|
|
return;
|
|
if (!_tierByLandblock.TryGetValue(canonical, out LandblockStreamTier tier)
|
|
|| tier != LandblockStreamTier.Far)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_wbSpawnAdapter.OnLandblockLoaded(retained);
|
|
}
|
|
|
|
internal void InvalidateLandblockClassification(uint landblockId) =>
|
|
_onLandblockUnloaded?.Invoke(landblockId);
|
|
|
|
internal void StopStaticEntityScript(WorldEntity entity) =>
|
|
_entityScriptActivator?.OnRemove(entity);
|
|
|
|
private readonly List<WorldEntity> _persistentRescued = new();
|
|
|
|
/// <summary>
|
|
/// Drain entities rescued from unloaded landblocks. The caller should
|
|
/// re-inject each through <c>LiveEntityRuntime.RebucketLiveEntity</c> with its current position.
|
|
/// </summary>
|
|
public List<WorldEntity> DrainRescued()
|
|
{
|
|
if (_persistentRescued.Count == 0) return _persistentRescued;
|
|
var result = new List<WorldEntity>(_persistentRescued);
|
|
_persistentRescued.Clear();
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove every entity with the given <paramref name="serverGuid"/> from
|
|
/// all loaded landblocks AND all pending buckets. Called by
|
|
/// <c>LiveEntityRuntime</c> 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.
|
|
/// </summary>
|
|
public void RemoveLiveEntityProjection(uint serverGuid)
|
|
{
|
|
if (serverGuid == 0) return;
|
|
|
|
using MutationBatch mutation = BeginMutationBatch();
|
|
|
|
// GUID-only removal is reserved for session cleanup paths that do not
|
|
// have an exact projection owner. Ordinary lifecycle operations use
|
|
// the exact-entity overload below.
|
|
_guidRemovalScratch.Clear();
|
|
foreach (WorldEntity projection in _projectionLocations.Keys)
|
|
{
|
|
if (projection.ServerGuid == serverGuid)
|
|
_guidRemovalScratch.Add(projection);
|
|
}
|
|
for (int i = 0; i < _guidRemovalScratch.Count; i++)
|
|
RemoveEntityFromAllBuckets(_guidRemovalScratch[i]);
|
|
_guidRemovalScratch.Clear();
|
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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));
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ends all spatial lifetime retained for one server object. Temporary
|
|
/// rebucketing uses <see cref="RemoveLiveEntityProjection"/> and keeps the
|
|
/// persistence classification; logical delete also forgets that class so
|
|
/// a later session/GUID reuse cannot be rescued as the old player.
|
|
/// </summary>
|
|
public void ForgetLiveEntity(uint serverGuid)
|
|
{
|
|
RemoveLiveEntityProjection(serverGuid);
|
|
_persistentGuids.Remove(serverGuid);
|
|
_persistentInFlatProbe.Remove(serverGuid);
|
|
}
|
|
|
|
/// <summary>Clears session-scoped persistence and undrained rescues.</summary>
|
|
public void ClearLiveEntityLifetimeState()
|
|
{
|
|
_persistentGuids.Clear();
|
|
_persistentRescued.Clear();
|
|
_persistentInFlatProbe.Clear();
|
|
_visibilityTransitions.Clear();
|
|
_visibleLiveProjectionCounts.Clear();
|
|
_visibilityBeforeMutation.Clear();
|
|
_projectionLocations.Clear();
|
|
_loadedLiveByLandblock.Clear();
|
|
_liveProjectionByKey.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Place an already-registered live entity in a landblock slot whose
|
|
/// terrain may or may not be loaded yet.
|
|
///
|
|
/// <para>
|
|
/// The server's <c>landblockId</c> is in cell-resolved form
|
|
/// (<c>0xAAAA00CC</c>: high byte X, second byte Y, low 16 bits cell
|
|
/// index within the landblock). The streaming system stores landblocks
|
|
/// keyed by their canonical <c>0xAAAA0xFFFF</c> form. Canonicalize
|
|
/// on the way in so callers don't have to think about it.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Outcome:
|
|
/// <list type="bullet">
|
|
/// <item>If the landblock is already loaded, the entity is appended
|
|
/// to its landblock and flat render-thread lists in O(1).</item>
|
|
/// <item>If the landblock is not yet loaded, the entity is parked
|
|
/// in <see cref="_pendingByLandblock"/> and will be merged
|
|
/// into the next <see cref="AddLandblock"/> for the same id.</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity) =>
|
|
PlaceLiveEntityProjection(
|
|
new RuntimeEntityKey(entity.Id, 0),
|
|
landblockId,
|
|
entity);
|
|
|
|
public void PlaceLiveEntityProjection(
|
|
RuntimeEntityKey key,
|
|
uint landblockId,
|
|
WorldEntity entity)
|
|
{
|
|
using MutationBatch mutation = BeginMutationBatch();
|
|
|
|
if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id)
|
|
throw new InvalidOperationException(
|
|
"The exact placement key must match the WorldEntity local ID.");
|
|
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, key);
|
|
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<WorldEntity>();
|
|
_pendingByLandblock[canonicalLandblockId] = bucket;
|
|
}
|
|
int bucketIndex = bucket.Count;
|
|
bucket.Add(entity);
|
|
SetProjectionLocation(
|
|
entity,
|
|
canonicalLandblockId,
|
|
isLoaded: false,
|
|
bucketIndex,
|
|
key);
|
|
if (probePersistent)
|
|
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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<WorldEntity>();
|
|
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<WorldEntity>();
|
|
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);
|
|
InvalidateLandblockRenderViews(entries: true, bounds: false);
|
|
_tierByLandblock[canonical] = LandblockStreamTier.Far;
|
|
}
|
|
|
|
return new GpuLandblockRetirement(
|
|
canonical,
|
|
LandblockRetirementKind.NearLayer,
|
|
retiredEntities);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compatibility edge for direct state tests. Production streaming uses
|
|
/// the retryable retirement coordinator.
|
|
/// </summary>
|
|
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]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>
|
|
/// <b>Landblock id is canonicalized</b> (low 16 bits forced to 0xFFFF) —
|
|
/// callers may pass cell-resolved ids and they will key correctly.
|
|
/// </para>
|
|
/// </summary>
|
|
public bool AddEntitiesToExistingLandblock(
|
|
uint landblockId,
|
|
IReadOnlyList<WorldEntity> entities,
|
|
IEnumerable<ulong>? 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<WorldEntity> entities,
|
|
IEnumerable<ulong>? 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<WorldEntity> entities,
|
|
IEnumerable<ulong>? 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<WorldEntity>();
|
|
_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<ulong>();
|
|
_pendingRenderIdsByLandblock[canonical] = renderIds;
|
|
}
|
|
renderIds.UnionWith(additionalRenderIds);
|
|
}
|
|
return null;
|
|
}
|
|
List<WorldEntity> 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(
|
|
_projectionLocations[entity].Key,
|
|
entity,
|
|
+1);
|
|
}
|
|
}
|
|
_animatedIndexByLandblock.Remove(canonical);
|
|
InvalidateLandblockRenderViews(entries: true, bounds: false);
|
|
_tierByLandblock[canonical] = LandblockStreamTier.Near;
|
|
ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty<ulong>();
|
|
WorldEntity[] staticEntities = entities
|
|
.Where(static entity => entity.ServerGuid == 0)
|
|
.ToArray();
|
|
return new GpuLandblockSpatialPublication(
|
|
canonical,
|
|
_loaded[canonical],
|
|
effectiveRenderIds,
|
|
staticEntities,
|
|
RenderTraversalOrder: GetRenderTraversalOrder(canonical));
|
|
}
|
|
|
|
private void DrainVisibilityTransitions()
|
|
{
|
|
if (_dispatchingVisibilityTransitions)
|
|
return;
|
|
|
|
List<Exception>? failures = null;
|
|
_dispatchingVisibilityTransitions = true;
|
|
try
|
|
{
|
|
while (_visibilityTransitions.TryDequeue(out var transition))
|
|
{
|
|
Delegate[] subscribers = LiveProjectionVisibilityChanged?
|
|
.GetInvocationList()
|
|
?? Array.Empty<Delegate>();
|
|
for (int i = 0; i < subscribers.Length; i++)
|
|
{
|
|
try
|
|
{
|
|
((Action<RuntimeEntityKey, bool>)subscribers[i])(
|
|
transition.Key,
|
|
transition.Visible);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).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<uint> _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<uint>();
|
|
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);
|
|
}
|
|
}
|