refactor(streaming): own landblock publication transactions

This commit is contained in:
Erik 2026-07-21 19:55:24 +02:00
parent 4001251064
commit 4f965d0699
5 changed files with 1301 additions and 122 deletions

View file

@ -8,6 +8,18 @@ using AcDream.Core.World;
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);
/// <summary>
/// Render-thread-owned registry of currently-loaded landblocks and their
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
@ -619,12 +631,44 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
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))
return;
{
LoadedLandblock retained = _loaded[landblock.LandblockId];
return new GpuLandblockSpatialPublication(
retained.LandblockId,
retained,
Array.Empty<ulong>(),
Array.Empty<WorldEntity>(),
RequiresActivation: false);
}
// If pending live entities have been waiting for this landblock,
// merge them into the render-thread-owned mutable entity bucket before
@ -668,26 +712,53 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_loaded[landblock.LandblockId] = landblock;
AddLoadedLandblockToFlatView(landblock);
_tierByLandblock[landblock.LandblockId] = tier;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(
_loaded[landblock.LandblockId],
mergedRenderIds);
return CreateSpatialPublication(
_loaded[landblock.LandblockId],
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>());
}
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// LiveEntityRuntime owns activation for live objects. This static-only
// filter avoids replaying their defaults when a pending projection is
// drained into a newly loaded landblock.
if (_entityScriptActivator is not null)
internal void ActivateLandblockPresentation(
GpuLandblockSpatialPublication publication)
{
ArgumentNullException.ThrowIfNull(publication);
if (!publication.RequiresActivation)
return;
if (!_loaded.TryGetValue(publication.LandblockId, out LoadedLandblock? current)
|| !ReferenceEquals(current, publication.Landblock))
{
var loadedEntities = _loaded[landblock.LandblockId].Entities;
for (int i = 0; i < loadedEntities.Count; i++)
{
var e = loadedEntities[i];
if (e.ServerGuid == 0)
_entityScriptActivator.OnCreate(e);
}
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)
{
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);
}
/// <summary>
@ -1228,11 +1299,50 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
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))
{
@ -1263,7 +1373,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
}
renderIds.UnionWith(additionalRenderIds);
}
return false;
return null;
}
List<WorldEntity> resident = MutableEntities(lb);
for (int i = 0; i < entities.Count; i++)
@ -1278,20 +1388,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
}
_animatedIndexByLandblock.Remove(canonical);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
if (_wbSpawnAdapter is not null)
_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
// (the promotion path streams in dat-static scenery + EnvCell statics
// + stabs per the method's class doc-comment).
if (_entityScriptActivator is not null)
{
for (int i = 0; i < entities.Count; i++)
_entityScriptActivator.OnCreate(entities[i]);
}
return true;
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);
}
private void DrainVisibilityTransitions()