diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 2e522bc7..50b6db03 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -4608,7 +4608,26 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
if (_frameDiag)
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
- ApplyLoadedTerrainLocked(build, meshData);
+ try
+ {
+ ApplyLoadedTerrainLocked(build, meshData);
+ }
+ catch (AcDream.App.Streaming.StreamingMutationException)
+ {
+ throw;
+ }
+ catch (Exception error)
+ {
+ // This legacy body spans multiple concrete owners and cannot yet
+ // identify an exact failed suffix. Conservatively report that a
+ // prefix may have committed so the transaction coordinator never
+ // replays terrain/cells/collision/lights/plugin events. Slice-5
+ // checkpoints C-E replace this wrapper with per-owner receipts.
+ throw new AcDream.App.Streaming.StreamingMutationException(
+ $"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.",
+ mutationCommitted: true,
+ error);
+ }
if (_frameDiag)
{
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 23a05e2f..354e8392 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -8,6 +8,18 @@ using AcDream.Core.World;
namespace AcDream.App.Streaming;
+///
+/// Exact result of one committed landblock bucket mutation. Fallible mesh-pin
+/// and static-script reconciliation consume this receipt separately, so a
+/// callback failure cannot force the spatial mutation to replay.
+///
+internal sealed record GpuLandblockSpatialPublication(
+ uint LandblockId,
+ LoadedLandblock Landblock,
+ IReadOnlyList AdditionalRenderIds,
+ IReadOnlyList StaticEntities,
+ bool RequiresActivation = true);
+
///
/// Render-thread-owned registry of currently-loaded landblocks and their
/// entities. All mutation happens in
@@ -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);
+ }
+
+ ///
+ /// Commits only canonical buckets, indexes, pending-live merge, tier, and
+ /// readiness-id ownership. The caller holds one outer
+ /// through the subsequent
+ /// call so live visibility
+ /// observers retain the shipped commit boundary.
+ ///
+ internal GpuLandblockSpatialPublication CommitLandblockSpatial(
+ LoadedLandblock landblock,
+ IEnumerable? additionalRenderIds,
+ LandblockStreamTier tier) =>
+ CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
+
+ private GpuLandblockSpatialPublication CommitLandblockSpatialCore(
+ LoadedLandblock landblock,
+ IEnumerable? additionalRenderIds,
+ LandblockStreamTier tier)
+ {
+ ArgumentNullException.ThrowIfNull(landblock);
// A stale Far completion must never replace a newer Near entity layer.
// StreamingController normally filters it before render-side apply;
// keep the state boundary independently monotonic as well.
if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId))
- return;
+ {
+ LoadedLandblock retained = _loaded[landblock.LandblockId];
+ return new GpuLandblockSpatialPublication(
+ retained.LandblockId,
+ retained,
+ Array.Empty(),
+ Array.Empty(),
+ 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?)mergedRenderIds ?? Array.Empty());
+ }
- // 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 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);
}
///
@@ -1228,11 +1299,50 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
IEnumerable? additionalRenderIds = null)
{
using MutationBatch mutation = BeginMutationBatch();
+ GpuLandblockSpatialPublication? publication =
+ CommitEntitiesToExistingLandblockSpatialCore(
+ landblockId,
+ entities,
+ additionalRenderIds,
+ parkIfMissing: true);
+ if (publication is null)
+ return false;
+
+ ActivateLandblockPresentation(publication);
+ return true;
+ }
+
+ internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial(
+ uint landblockId,
+ IReadOnlyList entities,
+ IEnumerable? additionalRenderIds)
+ {
+ GpuLandblockSpatialPublication? publication =
+ CommitEntitiesToExistingLandblockSpatialCore(
+ landblockId,
+ entities,
+ additionalRenderIds,
+ parkIfMissing: false);
+ return publication
+ ?? throw new InvalidOperationException(
+ $"Landblock 0x{landblockId:X8} is not resident for an accepted promotion.");
+ }
+
+ private GpuLandblockSpatialPublication? CommitEntitiesToExistingLandblockSpatialCore(
+ uint landblockId,
+ IReadOnlyList entities,
+ IEnumerable? additionalRenderIds,
+ bool parkIfMissing)
+ {
+ ArgumentNullException.ThrowIfNull(entities);
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb))
{
+ if (!parkIfMissing)
+ return null;
+
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
@@ -1263,7 +1373,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
}
renderIds.UnionWith(additionalRenderIds);
}
- return false;
+ return null;
}
List 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();
+ WorldEntity[] staticEntities = entities
+ .Where(static entity => entity.ServerGuid == 0)
+ .ToArray();
+ return new GpuLandblockSpatialPublication(
+ canonical,
+ _loaded[canonical],
+ effectiveRenderIds,
+ staticEntities);
}
private void DrainVisibilityTransitions()
diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
new file mode 100644
index 00000000..24fb3888
--- /dev/null
+++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
@@ -0,0 +1,299 @@
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Terrain;
+using AcDream.Core.World;
+
+namespace AcDream.App.Streaming;
+
+///
+/// Render-thread transaction coordinator for one accepted landblock streaming
+/// result. Streaming policy (desired tier, generation, priority, and apply
+/// budget) remains in ; spatial buckets remain
+/// in . This owner fixes the presentation ordering
+/// independently of those policies so the concrete render, physics, and static
+/// publishers can be extracted behind it without returning callbacks to the
+/// game window.
+///
+///
+/// Retail establishes cells/buildings before static objects in
+/// LScape::grab_visible_cells @ 0x00504EC0. Its release path separates
+/// CLandBlock::release_all @ 0x0052FCF0 (objects and visible cells) from
+/// later destruction in CLandBlock::Destroy @ 0x0052FAA0 (static objects
+/// and buildings). Acdream's detach-first retry ledger is the existing
+/// asynchronous lifetime adaptation. The injected publication operation
+/// currently contains the already-shipped production transaction; later
+/// Slice-5 checkpoints replace it with focused publishers.
+///
+public sealed class LandblockPresentationPipeline
+{
+ private enum PublicationKind : byte
+ {
+ Loaded,
+ PromoteExisting,
+ PromoteSelfContained,
+ Far,
+ }
+
+ private sealed class PublicationTransaction
+ {
+ public required PublicationKind Kind;
+ public required LandblockBuild Build;
+ public required LandblockMeshData MeshData;
+ public required uint LandblockId;
+ public LandblockStreamTier Tier;
+ public bool PresentationCommitted;
+ public bool SpatialCommitted;
+ public GpuLandblockSpatialPublication? SpatialPublication;
+ public bool SpatialPresentationCommitted;
+ public bool EnvCellReplayCommitted;
+ public bool LiveRecoveryCommitted;
+ }
+
+ private readonly Action _publishBeforeSpatialCommit;
+ private readonly GpuWorldState _state;
+ private readonly Action? _onLandblockLoaded;
+ private readonly Action? _ensureEnvCellMeshes;
+ private readonly LandblockRetirementCoordinator _retirements;
+ private readonly Dictionary _publications =
+ new(ReferenceEqualityComparer.Instance);
+
+ public LandblockPresentationPipeline(
+ Action publishBeforeSpatialCommit,
+ GpuWorldState state,
+ Action? onLandblockLoaded = null,
+ Action? ensureEnvCellMeshes = null,
+ LandblockRetirementCoordinator? retirementCoordinator = null,
+ Action? removeTerrain = null,
+ Action? demoteNearLayer = null)
+ {
+ ArgumentNullException.ThrowIfNull(publishBeforeSpatialCommit);
+ ArgumentNullException.ThrowIfNull(state);
+
+ _publishBeforeSpatialCommit = publishBeforeSpatialCommit;
+ _state = state;
+ _onLandblockLoaded = onLandblockLoaded;
+ _ensureEnvCellMeshes = ensureEnvCellMeshes;
+ _retirements = retirementCoordinator
+ ?? LandblockRetirementCoordinator.CreateLegacy(
+ state,
+ removeTerrain,
+ demoteNearLayer);
+ }
+
+ public int PendingRetirementCount => _retirements.PendingCount;
+ public int PendingPublicationCount => _publications.Count;
+
+ public bool IsRetirementPending(uint landblockId) =>
+ _retirements.IsPending(landblockId);
+
+ public bool HasPendingPublication(LandblockStreamResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ return _publications.ContainsKey(result);
+ }
+
+ public IReadOnlyList GetPendingPublicationResults() =>
+ _publications.Keys.ToArray();
+
+ public void ResumePublication(LandblockStreamResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ if (!_publications.TryGetValue(result, out PublicationTransaction? transaction))
+ return;
+ Advance(result, transaction);
+ }
+
+ public void AdvanceRetirements() => _retirements.Advance();
+
+ public void BeginFullRetirement(uint landblockId) =>
+ _retirements.BeginFull(landblockId);
+
+ public void BeginNearLayerRetirement(uint landblockId) =>
+ _retirements.BeginNearLayer(landblockId);
+
+ public void PublishLoaded(LandblockStreamResult.Loaded loaded)
+ {
+ ArgumentNullException.ThrowIfNull(loaded);
+ PublicationTransaction transaction = GetOrCreate(
+ loaded,
+ PublicationKind.Loaded,
+ loaded.Build,
+ loaded.MeshData,
+ loaded.LandblockId,
+ loaded.Tier);
+ Advance(loaded, transaction);
+ }
+
+ public void PublishPromoted(
+ LandblockStreamResult.Promoted promoted,
+ bool mergeIntoExistingLandblock)
+ {
+ ArgumentNullException.ThrowIfNull(promoted);
+ PublicationKind kind = mergeIntoExistingLandblock
+ ? PublicationKind.PromoteExisting
+ : PublicationKind.PromoteSelfContained;
+ PublicationTransaction transaction = GetOrCreate(
+ promoted,
+ kind,
+ promoted.Build,
+ promoted.MeshData,
+ promoted.LandblockId,
+ LandblockStreamTier.Near);
+ Advance(promoted, transaction);
+ }
+
+ public void PublishAsFar(
+ LandblockStreamResult acceptedResult,
+ LandblockBuild completedBuild,
+ LandblockMeshData meshData)
+ {
+ ArgumentNullException.ThrowIfNull(acceptedResult);
+ ArgumentNullException.ThrowIfNull(completedBuild);
+ ArgumentNullException.ThrowIfNull(meshData);
+
+ if (!_publications.TryGetValue(acceptedResult, out PublicationTransaction? transaction))
+ {
+ LoadedLandblock completed = completedBuild.Landblock;
+ var farLandblock = new LoadedLandblock(
+ completed.LandblockId,
+ completed.Heightmap,
+ Array.Empty(),
+ PhysicsDatBundle.Empty);
+ transaction = new PublicationTransaction
+ {
+ Kind = PublicationKind.Far,
+ Build = new LandblockBuild(farLandblock),
+ MeshData = meshData,
+ LandblockId = farLandblock.LandblockId,
+ Tier = LandblockStreamTier.Far,
+ };
+ _publications.Add(acceptedResult, transaction);
+ }
+ else if (transaction.Kind != PublicationKind.Far)
+ {
+ throw new InvalidOperationException(
+ $"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending.");
+ }
+
+ Advance(acceptedResult, transaction);
+ }
+
+ private PublicationTransaction GetOrCreate(
+ LandblockStreamResult result,
+ PublicationKind kind,
+ LandblockBuild build,
+ LandblockMeshData meshData,
+ uint landblockId,
+ LandblockStreamTier tier)
+ {
+ if (_publications.TryGetValue(result, out PublicationTransaction? existing))
+ {
+ if (existing.Kind != kind
+ && !(existing.Kind is PublicationKind.PromoteExisting
+ or PublicationKind.PromoteSelfContained
+ && kind is PublicationKind.PromoteExisting
+ or PublicationKind.PromoteSelfContained))
+ {
+ throw new InvalidOperationException(
+ $"Landblock publication 0x{existing.LandblockId:X8} changed kind while pending.");
+ }
+ return existing;
+ }
+
+ var created = new PublicationTransaction
+ {
+ Kind = kind,
+ Build = build,
+ MeshData = meshData,
+ LandblockId = landblockId,
+ Tier = tier,
+ };
+ _publications.Add(result, created);
+ return created;
+ }
+
+ private void Advance(
+ LandblockStreamResult result,
+ PublicationTransaction transaction)
+ {
+ if (!transaction.PresentationCommitted)
+ {
+ try
+ {
+ _publishBeforeSpatialCommit(transaction.Build, transaction.MeshData);
+ }
+ catch (Exception error)
+ {
+ // The legacy GameWindow callback still spans several concrete
+ // owners and cannot report a committed prefix. Until Slice-5
+ // checkpoints C-E replace it with individually ledgered
+ // publishers, its production wrapper conservatively reports a
+ // committed mutation. Test/compatibility callbacks retain the
+ // established strong-exception contract unless they explicitly
+ // report otherwise.
+ if (error is StreamingMutationException { MutationCommitted: true })
+ _publications.Remove(result);
+ throw;
+ }
+ transaction.PresentationCommitted = true;
+ }
+
+ if (!transaction.SpatialPresentationCommitted)
+ {
+ // Keep live-projection visibility publication at the shipped
+ // boundary: bucket mutation, pin/script reconciliation, then the
+ // outer spatial observer commit. If pinning throws, the receipt is
+ // retained and only that suffix retries.
+ using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch();
+ if (!transaction.SpatialCommitted)
+ {
+ IEnumerable? renderIds =
+ transaction.Build.EnvCells?.Shells.Select(static shell => shell.GeometryId);
+ transaction.SpatialPublication = transaction.Kind switch
+ {
+ PublicationKind.Loaded
+ or PublicationKind.PromoteSelfContained
+ or PublicationKind.Far =>
+ _state.CommitLandblockSpatial(
+ transaction.Build.Landblock,
+ renderIds,
+ transaction.Tier),
+ PublicationKind.PromoteExisting =>
+ _state.CommitEntitiesToExistingLandblockSpatial(
+ transaction.LandblockId,
+ transaction.Build.Landblock.Entities,
+ renderIds),
+ _ => throw new InvalidOperationException(
+ $"Unknown landblock publication kind {transaction.Kind}."),
+ };
+ transaction.SpatialCommitted = true;
+ }
+
+ _state.ActivateLandblockPresentation(
+ transaction.SpatialPublication
+ ?? throw new InvalidOperationException(
+ "A committed spatial publication has no activation receipt."));
+ transaction.SpatialPresentationCommitted = true;
+ }
+
+ if (!transaction.EnvCellReplayCommitted)
+ {
+ if (transaction.Kind != PublicationKind.Far
+ && transaction.Build.EnvCells is { Shells.Length: > 0 } envCells)
+ {
+ _ensureEnvCellMeshes?.Invoke(envCells);
+ }
+ transaction.EnvCellReplayCommitted = true;
+ }
+
+ if (!transaction.LiveRecoveryCommitted)
+ {
+ // Retained live projections can materialize only after the
+ // canonical bucket exists. Landblock load never reconstructs their
+ // identity or replays create-time resources.
+ _onLandblockLoaded?.Invoke(transaction.LandblockId);
+ transaction.LiveRecoveryCommitted = true;
+ }
+
+ _publications.Remove(result);
+ }
+}
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 69bb8760..9805d96f 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -21,24 +21,9 @@ public sealed class StreamingController
private readonly Action _enqueueLoad;
private readonly Action _enqueueUnload;
private readonly Func> _drainCompletions;
- private readonly Action _applyTerrain;
private readonly Action? _clearPendingLoads;
- private readonly LandblockRetirementCoordinator _retirements;
+ private readonly LandblockPresentationPipeline _presentation;
- ///
- /// #138: fired after a landblock's entity layer (re)loads — both the full
- /// path (initial / dungeon-exit
- /// expand) and the path
- /// (Far→Near). GameWindow wires it to re-hydrate server objects whose
- /// render entities were dropped by the collapse/demote but whose parsed
- /// spawns it still retains. Receives the canonical landblock id. Null in
- /// tests that don't exercise re-hydration.
- ///
- private readonly Action? _onLandblockLoaded;
- // Invoked only after LandblockSpawnAdapter has pinned every synthetic
- // EnvCell geometry id. This re-arms the schema-aware preparation path if
- // an unowned cached mesh was evicted between worker schedule and publish.
- private readonly Action? _ensureEnvCellMeshes;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
private RadiiReconfiguration? _pendingRadiiReconfiguration;
@@ -194,16 +179,16 @@ public sealed class StreamingController
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
- _applyTerrain = applyTerrain;
_clearPendingLoads = clearPendingLoads;
- _onLandblockLoaded = onLandblockLoaded;
- _ensureEnvCellMeshes = ensureEnvCellMeshes;
_state = state;
- _retirements = retirementCoordinator
- ?? LandblockRetirementCoordinator.CreateLegacy(
- state,
- removeTerrain,
- demoteNearLayer);
+ _presentation = new LandblockPresentationPipeline(
+ applyTerrain,
+ state,
+ onLandblockLoaded,
+ ensureEnvCellMeshes,
+ retirementCoordinator,
+ removeTerrain,
+ demoteNearLayer);
NearRadius = nearRadius;
FarRadius = farRadius;
}
@@ -249,6 +234,12 @@ public sealed class StreamingController
if (nearRadius == NearRadius && farRadius == FarRadius)
return;
+ // A staged old-window publication must either finish or fail before
+ // the desired-tier snapshot below is computed. Otherwise a landblock
+ // that becomes spatially resident during convergence is absent from
+ // the reconfiguration mutation ledger.
+ ConvergePendingPublications();
+
if (_collapsed || _region is null)
{
NearRadius = nearRadius;
@@ -348,14 +339,44 @@ public sealed class StreamingController
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
- _retirements.BeginNearLayer(canonical);
+ _presentation.BeginNearLayerRetirement(canonical);
}
private void AdvanceGeneration()
{
+ ConvergePendingPublications();
_generation = unchecked(_generation + 1);
}
+ private void ConvergePendingPublications()
+ {
+ IReadOnlyList pending =
+ _presentation.GetPendingPublicationResults();
+ if (pending.Count == 0)
+ return;
+
+ try
+ {
+ for (int i = 0; i < pending.Count; i++)
+ _presentation.ResumePublication(pending[i]);
+ }
+ finally
+ {
+ // A completed staged result can still be present in the deferred
+ // FIFO that originally retained it. Remove only those exact
+ // objects; unrelated accepted completions keep their order.
+ _deferredApply.RemoveAll(result =>
+ {
+ for (int i = 0; i < pending.Count; i++)
+ {
+ if (ReferenceEquals(result, pending[i]))
+ return !_presentation.HasPendingPublication(result);
+ }
+ return false;
+ });
+ }
+ }
+
///
/// Advance one frame. /
/// are landblock coordinates (0..255) of the current viewer — the camera
@@ -382,7 +403,11 @@ public sealed class StreamingController
// Presentation-owner failures never roll back spatial state. Resume
// unfinished owner ledgers before accepting replacement publications.
- _retirements.Advance();
+ _presentation.AdvanceRetirements();
+ // Complete an admitted publication before this frame can mutate its
+ // desired tier or spatial residence. A later recenter may then demote
+ // or retire the fully known owner set through the normal ledger.
+ ConvergePendingPublications();
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
@@ -616,11 +641,12 @@ public sealed class StreamingController
///
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
{
- if (!_collapsed || _collapsedCenter != centerId)
+ bool logTransition = !_collapsed || _collapsedCenter != centerId;
+ if (logTransition)
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
+ AdvanceGeneration();
_collapsed = true;
_collapsedCenter = centerId;
- AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
_region = null;
@@ -685,8 +711,8 @@ public sealed class StreamingController
Console.WriteLine(
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
$"(was collapsed on 0x{_collapsedCenter:X8})");
- _collapsed = false;
AdvanceGeneration();
+ _collapsed = false;
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
foreach (var id in _state.LoadedLandblockIds)
@@ -715,19 +741,20 @@ public sealed class StreamingController
///
public void ForceReloadWindow()
{
- _collapsed = false;
AdvanceGeneration();
+ _collapsed = false;
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Commit the old region boundary before any presentation owner is
- // advanced. New same-id publications are fenced by _retirements.
+ // advanced. New same-id publications are fenced by the presentation
+ // pipeline's retryable retirement ledger.
_region = null;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
foreach (var id in ids)
- _retirements.BeginFull(id);
+ _presentation.BeginFullRetirement(id);
}
///
@@ -757,18 +784,41 @@ public sealed class StreamingController
{
var chunk = _drainCompletions(MaxCompletionsPerFrame);
if (chunk.Count == 0) break;
- foreach (var result in chunk)
+ for (int chunkIndex = 0; chunkIndex < chunk.Count; chunkIndex++)
{
+ LandblockStreamResult result = chunk[chunkIndex];
// Reject obsolete hard-window work before it can occupy the
// metered deferred queue. ApplyResult repeats this invariant
// for direct callers and future drain paths.
- if (IsStaleGeneration(result))
+ if (IsStaleGeneration(result)
+ && !_presentation.HasPendingPublication(result))
continue;
if (IsPublicationBlockedByRetirement(result))
_deferredApply.Add(result);
else if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
- ApplyResult(result); // free (unload) or behind-the-fade near ring
+ {
+ try
+ {
+ ApplyResult(result); // free (unload) or behind-the-fade near ring
+ }
+ catch
+ {
+ // The presentation pipeline retains its exact committed
+ // stage after the coarse presentation callback commits.
+ // Preserve it and every untouched completion from this
+ // already-drained chunk in original FIFO order. A
+ // presentation-stage failure is deliberately fail-fast
+ // until the focused publishers replace that callback;
+ // its result is not retryable, but the untouched suffix
+ // must still survive.
+ if (_presentation.HasPendingPublication(result))
+ _deferredApply.Add(result);
+ for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
+ _deferredApply.Add(chunk[suffix]);
+ throw;
+ }
+ }
else
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
}
@@ -798,9 +848,24 @@ public sealed class StreamingController
// Advance read only after the apply commits. If it throws, the
// finally block removes prior committed entries but retains this
// exact result and the untouched tail for retry.
- ApplyResult(result);
- read++;
- applied++;
+ try
+ {
+ ApplyResult(result);
+ read++;
+ applied++;
+ }
+ catch
+ {
+ // A suffix-stage failure keeps its pipeline receipt and
+ // must remain at this exact FIFO position. The still-
+ // monolithic presentation callback deliberately removes
+ // its receipt on failure because its internal committed
+ // prefix is unknown; consume only that failed result so a
+ // later frame cannot replay it automatically.
+ if (!_presentation.HasPendingPublication(result))
+ read++;
+ throw;
+ }
}
}
finally
@@ -821,6 +886,13 @@ public sealed class StreamingController
///
private void ApplyResult(LandblockStreamResult result)
{
+ if (_presentation.HasPendingPublication(result))
+ {
+ _presentation.ResumePublication(result);
+ ReconcileCompletedPendingPublication(result.LandblockId);
+ return;
+ }
+
if (IsStaleGeneration(result))
{
// A worker can finish one old load/unload after a hard recenter.
@@ -853,7 +925,9 @@ public sealed class StreamingController
bool isNearCompletion = result is LandblockStreamResult.Promoted
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
- if (isNearCompletion && _state.IsNearTier(result.LandblockId))
+ if (isNearCompletion
+ && _state.IsNearTier(result.LandblockId)
+ && !_presentation.HasPendingPublication(result))
{
// Normal recenter can enqueue a second high-priority Near job
// while an earlier one is already in flight. The streamer
@@ -878,10 +952,10 @@ public sealed class StreamingController
switch (result)
{
case LandblockStreamResult.Loaded loaded:
- ApplyAsFar(loaded.Build, loaded.MeshData);
+ _presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData);
break;
case LandblockStreamResult.Promoted promoted:
- ApplyAsFar(promoted.Build, promoted.MeshData);
+ _presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData);
break;
}
}
@@ -900,16 +974,7 @@ public sealed class StreamingController
// layer that now owns the landblock.
break;
}
- _applyTerrain(loaded.Build, loaded.MeshData);
- _state.AddLandblock(
- loaded.Landblock,
- loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId),
- loaded.Tier);
- EnsureEnvCellMeshesAfterPin(loaded.Build);
- // #138: after the landblock is in _loaded (so live projection
- // hot-paths), restore any retained server objects ACE won't
- // re-send. Fired AFTER AddLandblock, never before.
- _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
+ _presentation.PublishLoaded(loaded);
break;
case LandblockStreamResult.Promoted promoted:
// PromoteToNear carries a complete build and mesh because the
@@ -917,28 +982,12 @@ public sealed class StreamingController
// that Far job never started, publish this as the real Near
// landblock; if the base is already resident, merge only the
// Near layer so existing live projections retain identity.
- _applyTerrain(promoted.Build, promoted.MeshData);
- var promotedRenderIds =
- promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId);
- if (_state.IsLoaded(promoted.LandblockId))
- {
- _state.AddEntitiesToExistingLandblock(
- promoted.LandblockId,
- promoted.Entities,
- promotedRenderIds);
- }
- else
- {
- _state.AddLandblock(
- promoted.Landblock,
- promotedRenderIds,
- LandblockStreamTier.Near);
- }
- EnsureEnvCellMeshesAfterPin(promoted.Build);
- _onLandblockLoaded?.Invoke(promoted.LandblockId);
+ _presentation.PublishPromoted(
+ promoted,
+ mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
break;
case LandblockStreamResult.Unloaded unloaded:
- _retirements.BeginFull(unloaded.LandblockId);
+ _presentation.BeginFullRetirement(unloaded.LandblockId);
break;
case LandblockStreamResult.Failed failed:
Console.WriteLine(
@@ -951,25 +1000,21 @@ public sealed class StreamingController
}
}
- private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData)
+ private void ReconcileCompletedPendingPublication(uint landblockId)
{
- var completed = completedBuild.Landblock;
- var farLandblock = new LoadedLandblock(
- completed.LandblockId,
- completed.Heightmap,
- Array.Empty(),
- PhysicsDatBundle.Empty);
- var farBuild = new LandblockBuild(farLandblock);
+ if (_region is null
+ || !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
+ {
+ if (_state.IsLoaded(landblockId))
+ _presentation.BeginFullRetirement(landblockId);
+ return;
+ }
- _applyTerrain(farBuild, meshData);
- _state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far);
- _onLandblockLoaded?.Invoke(farLandblock.LandblockId);
- }
-
- private void EnsureEnvCellMeshesAfterPin(LandblockBuild build)
- {
- if (build.EnvCells is { Shells.Length: > 0 } envCells)
- _ensureEnvCellMeshes?.Invoke(envCells);
+ if (desiredTier == LandblockStreamTier.Far
+ && _state.IsNearTier(landblockId))
+ {
+ _presentation.BeginNearLayerRetirement(landblockId);
+ }
}
private bool IsStaleGeneration(LandblockStreamResult result) =>
@@ -978,7 +1023,7 @@ public sealed class StreamingController
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
- && _retirements.IsPending(result.LandblockId);
+ && _presentation.IsRetirementPending(result.LandblockId);
///
/// Returns the landblock id associated with .
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
new file mode 100644
index 00000000..fc89089f
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
@@ -0,0 +1,711 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Streaming;
+using AcDream.Core.Terrain;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using Xunit;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class LandblockPresentationPipelineTests
+{
+ [Fact]
+ public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder()
+ {
+ const uint landblockId = 0x2020FFFFu;
+ var calls = new List();
+ var meshes = new RecordingMeshAdapter(calls);
+ var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
+ LandblockBuild build = BuildWithShell(landblockId);
+ var pipeline = new LandblockPresentationPipeline(
+ publishBeforeSpatialCommit: (accepted, _) =>
+ {
+ Assert.Same(build, accepted);
+ Assert.False(state.IsLoaded(landblockId));
+ calls.Add("presentation");
+ },
+ state,
+ onLandblockLoaded: id =>
+ {
+ Assert.Equal(landblockId, id);
+ Assert.True(state.IsLoaded(id));
+ calls.Add("live-recovery");
+ },
+ ensureEnvCellMeshes: envCells =>
+ {
+ Assert.Same(build.EnvCells, envCells);
+ Assert.True(state.IsLoaded(landblockId));
+ calls.Add("mesh-replay-after-pin");
+ });
+
+ pipeline.PublishLoaded(new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ build,
+ EmptyMesh()));
+
+ Assert.Equal(
+ ["presentation", "pin", "mesh-replay-after-pin", "live-recovery"],
+ calls);
+ Assert.True(state.IsNearTier(landblockId));
+ }
+
+ [Fact]
+ public void Promoted_ExistingFar_MergesNearLayerBeforeReplayAndRecovery()
+ {
+ const uint landblockId = 0x3030FFFFu;
+ var calls = new List();
+ var meshes = new RecordingMeshAdapter(calls);
+ var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
+ state.AddLandblock(
+ new LoadedLandblock(
+ landblockId,
+ new LandBlock(),
+ Array.Empty(),
+ PhysicsDatBundle.Empty),
+ tier: LandblockStreamTier.Far);
+ LandblockBuild build = BuildWithShell(landblockId, Entity(7));
+ var pipeline = new LandblockPresentationPipeline(
+ publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"),
+ state,
+ onLandblockLoaded: _ =>
+ {
+ Assert.True(state.IsNearTier(landblockId));
+ Assert.Contains(state.Entities, entity => entity.Id == 7);
+ calls.Add("live-recovery");
+ },
+ ensureEnvCellMeshes: _ =>
+ {
+ Assert.True(state.IsNearTier(landblockId));
+ Assert.Contains(state.Entities, entity => entity.Id == 7);
+ calls.Add("mesh-replay-after-pin");
+ });
+
+ pipeline.PublishPromoted(new LandblockStreamResult.Promoted(
+ landblockId,
+ build,
+ EmptyMesh()),
+ mergeIntoExistingLandblock: true);
+
+ Assert.Equal(
+ ["presentation", "pin", "mesh-replay-after-pin", "live-recovery"],
+ calls);
+ }
+
+ [Fact]
+ public void PublishAsFar_StripsNearPayloadAndSkipsEnvCellReplay()
+ {
+ const uint landblockId = 0x4040FFFFu;
+ var state = new GpuWorldState();
+ LandblockBuild source = BuildWithShell(landblockId, Entity(9));
+ LandblockBuild? published = null;
+ int replayCount = 0;
+ int recoveryCount = 0;
+ var pipeline = new LandblockPresentationPipeline(
+ publishBeforeSpatialCommit: (build, _) => published = build,
+ state,
+ onLandblockLoaded: _ => recoveryCount++,
+ ensureEnvCellMeshes: _ => replayCount++);
+
+ var accepted = new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ source,
+ EmptyMesh());
+ pipeline.PublishAsFar(accepted, source, accepted.MeshData);
+
+ Assert.NotNull(published);
+ Assert.Empty(published.Landblock.Entities);
+ Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats);
+ Assert.Null(published.EnvCells);
+ Assert.True(state.IsLoaded(landblockId));
+ Assert.False(state.IsNearTier(landblockId));
+ Assert.Equal(0, replayCount);
+ Assert.Equal(1, recoveryCount);
+ }
+
+ [Fact]
+ public void EnvCellReplayFailure_ResumesSuffixWithoutReplayingCommittedPrefix()
+ {
+ const uint landblockId = 0x5151FFFFu;
+ var calls = new List();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(
+ new RecordingMeshAdapter(calls)));
+ bool failReplayOnce = true;
+ var pipeline = new LandblockPresentationPipeline(
+ publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"),
+ state,
+ onLandblockLoaded: _ => calls.Add("live-recovery"),
+ ensureEnvCellMeshes: _ =>
+ {
+ calls.Add("mesh-replay");
+ if (failReplayOnce)
+ {
+ failReplayOnce = false;
+ throw new InvalidOperationException("injected replay failure");
+ }
+ });
+ LandblockBuild build = BuildWithShell(landblockId);
+ var result = new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ build,
+ EmptyMesh());
+
+ Assert.Throws(() => pipeline.PublishLoaded(result));
+ Assert.True(state.IsNearTier(landblockId));
+ Assert.True(pipeline.HasPendingPublication(result));
+
+ pipeline.PublishLoaded(result);
+
+ Assert.Equal(
+ ["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"],
+ calls);
+ Assert.False(pipeline.HasPendingPublication(result));
+ }
+
+ [Fact]
+ public void PromotionReplayFailure_RetainsOriginalSelfContainedModeAcrossRetry()
+ {
+ const uint landblockId = 0x5252FFFFu;
+ var calls = new List();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(
+ new RecordingMeshAdapter(calls)));
+ bool failReplayOnce = true;
+ var pipeline = new LandblockPresentationPipeline(
+ publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"),
+ state,
+ onLandblockLoaded: _ => calls.Add("live-recovery"),
+ ensureEnvCellMeshes: _ =>
+ {
+ calls.Add("mesh-replay");
+ if (failReplayOnce)
+ {
+ failReplayOnce = false;
+ throw new InvalidOperationException("injected replay failure");
+ }
+ });
+ LandblockBuild build = BuildWithShell(landblockId, Entity(17));
+ var result = new LandblockStreamResult.Promoted(
+ landblockId,
+ build,
+ EmptyMesh());
+
+ Assert.Throws(() => pipeline.PublishPromoted(
+ result,
+ mergeIntoExistingLandblock: false));
+ Assert.True(state.IsNearTier(landblockId));
+
+ // Current residency changed after the first attempt. The transaction
+ // must retain the original self-contained publication command rather
+ // than switching policy mid-retry.
+ pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true);
+
+ Assert.Equal(
+ ["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"],
+ calls);
+ Assert.Single(state.Entities, entity => entity.Id == 17);
+ }
+
+ [Fact]
+ public void SpatialPinFailure_ControllerRetriesExactCompletionWithoutReplayingPresentation()
+ {
+ const uint landblockId = 0x6060FFFFu;
+ var meshes = new FailFirstPreparedPinAdapter();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
+ var outbox = new Queue();
+ LandblockBuild build = BuildWithShell(landblockId);
+ outbox.Enqueue(new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ build,
+ EmptyMesh()));
+ int presentationCalls = 0;
+ int replayCalls = 0;
+ int liveRecoveryCalls = 0;
+ var controller = new StreamingController(
+ enqueueLoad: (_, _) => { },
+ enqueueUnload: _ => { },
+ drainCompletions: max => Drain(outbox, max),
+ applyTerrain: (_, _) => presentationCalls++,
+ state,
+ nearRadius: 0,
+ farRadius: 0,
+ onLandblockLoaded: _ => liveRecoveryCalls++,
+ ensureEnvCellMeshes: _ => replayCalls++);
+
+ Assert.Throws(() => controller.Tick(0x60, 0x60));
+ Assert.True(state.IsNearTier(landblockId));
+ Assert.Equal(1, presentationCalls);
+ Assert.Equal(0, replayCalls);
+ Assert.Equal(0, liveRecoveryCalls);
+
+ controller.Tick(0x60, 0x60);
+
+ Assert.Equal(1, presentationCalls);
+ Assert.Equal(2, meshes.PinAttempts);
+ Assert.Equal(1, replayCalls);
+ Assert.Equal(1, liveRecoveryCalls);
+ }
+
+ [Fact]
+ public void PromotionPinFailure_RetriesActivationWithoutAppendingEntitiesAgain()
+ {
+ const uint landblockId = 0x6161FFFFu;
+ var meshes = new FailFirstPreparedPinAdapter();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
+ state.AddLandblock(
+ new LoadedLandblock(
+ landblockId,
+ new LandBlock(),
+ Array.Empty()),
+ tier: LandblockStreamTier.Far);
+ int presentationCalls = 0;
+ var pipeline = new LandblockPresentationPipeline(
+ publishBeforeSpatialCommit: (_, _) => presentationCalls++,
+ state);
+ WorldEntity promotedEntity = Entity(31);
+ LandblockBuild build = BuildWithShell(landblockId, promotedEntity);
+ var result = new LandblockStreamResult.Promoted(
+ landblockId,
+ build,
+ EmptyMesh());
+
+ Assert.Throws(() => pipeline.PublishPromoted(
+ result,
+ mergeIntoExistingLandblock: true));
+ Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity));
+
+ pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true);
+
+ Assert.Equal(1, presentationCalls);
+ Assert.Equal(2, meshes.PinAttempts);
+ Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity));
+ Assert.False(pipeline.HasPendingPublication(result));
+ }
+
+ [Fact]
+ public void LoadedPinFailure_RetryPreservesPendingLiveProjectionMergedAtSpatialCommit()
+ {
+ const uint landblockId = 0x6262FFFFu;
+ var meshes = new FailFirstPreparedPinAdapter();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
+ var pendingLive = new WorldEntity
+ {
+ Id = 77,
+ ServerGuid = 0x80000077u,
+ SourceGfxObjOrSetupId = 0x01000077u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = [new MeshRef(0x01000077u, Matrix4x4.Identity)],
+ };
+ state.PlaceLiveEntityProjection(0x62620100u, pendingLive);
+ LandblockBuild build = BuildWithShell(landblockId, Entity(32));
+ var result = new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ build,
+ EmptyMesh());
+ var pipeline = new LandblockPresentationPipeline(
+ static (_, _) => { },
+ state);
+
+ Assert.Throws(() => pipeline.PublishLoaded(result));
+ Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive));
+ Assert.Equal(0, state.PendingLiveEntityCount);
+
+ pipeline.PublishLoaded(result);
+
+ Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive));
+ Assert.Single(state.Entities, entity => ReferenceEquals(entity, pendingLive));
+ Assert.Equal(0, state.PendingLiveEntityCount);
+ Assert.Equal(2, meshes.PinAttempts);
+ }
+
+ [Fact]
+ public void PriorityFailure_PreservesFailingResultAndUntouchedDrainedSuffix()
+ {
+ const uint priorityId = 0x7070FFFFu;
+ const uint laterId = 0x7071FFFFu;
+ const uint unloadId = 0x1010FFFFu;
+ var meshes = new FailFirstPreparedPinAdapter();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
+ state.AddLandblock(new LoadedLandblock(
+ unloadId,
+ new LandBlock(),
+ Array.Empty()));
+ var outbox = new Queue(
+ [
+ new LandblockStreamResult.Loaded(
+ priorityId,
+ LandblockStreamTier.Near,
+ BuildWithShell(priorityId),
+ EmptyMesh()),
+ new LandblockStreamResult.Loaded(
+ laterId,
+ LandblockStreamTier.Near,
+ BuildWithShell(laterId),
+ EmptyMesh()),
+ new LandblockStreamResult.Unloaded(unloadId),
+ ]);
+ var calls = new List();
+ var controller = new StreamingController(
+ enqueueLoad: (_, _) => { },
+ enqueueUnload: _ => { },
+ drainCompletions: max => Drain(outbox, max),
+ applyTerrain: (build, _) => calls.Add($"publish:{build.LandblockId:X8}"),
+ state,
+ nearRadius: 0,
+ farRadius: 2,
+ removeTerrain: id => calls.Add($"unload:{id:X8}"));
+ controller.PriorityLandblockId = priorityId;
+
+ Assert.Throws(() => controller.Tick(0x70, 0x70));
+ Assert.Empty(outbox);
+ Assert.False(state.IsLoaded(laterId));
+ Assert.True(state.IsLoaded(unloadId));
+
+ controller.PriorityLandblockId = 0;
+ controller.Tick(0x70, 0x70);
+
+ Assert.True(state.IsLoaded(priorityId));
+ Assert.True(state.IsLoaded(laterId));
+ Assert.False(state.IsLoaded(unloadId));
+ Assert.Equal(
+ [
+ $"publish:{priorityId:X8}",
+ $"publish:{laterId:X8}",
+ $"unload:{unloadId:X8}",
+ ],
+ calls);
+ }
+
+ [Fact]
+ public void HardRecenter_ConvergesPendingSpatialPublicationBeforeRetiringIt()
+ {
+ const uint landblockId = 0x8080FFFFu;
+ var meshes = new FailFirstPreparedPinAdapter();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
+ var outbox = new Queue();
+ var result = new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ BuildWithShell(landblockId),
+ EmptyMesh());
+ outbox.Enqueue(result);
+ int presentationCalls = 0;
+ int replayCalls = 0;
+ int recoveryCalls = 0;
+ var controller = new StreamingController(
+ enqueueLoad: (_, _) => { },
+ enqueueUnload: _ => { },
+ drainCompletions: max => Drain(outbox, max),
+ applyTerrain: (_, _) => presentationCalls++,
+ state,
+ nearRadius: 0,
+ farRadius: 0,
+ onLandblockLoaded: _ => recoveryCalls++,
+ ensureEnvCellMeshes: _ => replayCalls++);
+
+ Assert.Throws(() => controller.Tick(0x80, 0x80));
+ Assert.True(state.IsNearTier(landblockId));
+
+ controller.ForceReloadWindow();
+
+ Assert.False(state.IsLoaded(landblockId));
+ Assert.Equal(1, presentationCalls);
+ Assert.Equal(1, replayCalls);
+ Assert.Equal(1, recoveryCalls);
+ Assert.Equal(2, meshes.PinAttempts);
+ }
+
+ [Fact]
+ public void DesiredNearToFarChurn_ResumesOriginalPublicationThenDemotes()
+ {
+ const uint landblockId = 0x9090FFFFu;
+ var calls = new List();
+ var state = new GpuWorldState(new LandblockSpawnAdapter(
+ new RecordingMeshAdapter(calls)));
+ var outbox = new Queue();
+ var result = new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ BuildWithShell(landblockId, Entity(23)),
+ EmptyMesh());
+ outbox.Enqueue(result);
+ bool failReplayOnce = true;
+ var controller = new StreamingController(
+ enqueueLoad: (_, _) => { },
+ enqueueUnload: _ => { },
+ drainCompletions: max => Drain(outbox, max),
+ applyTerrain: (_, _) => calls.Add("presentation"),
+ state,
+ nearRadius: 0,
+ farRadius: 2,
+ demoteNearLayer: _ => calls.Add("demote"),
+ ensureEnvCellMeshes: _ =>
+ {
+ calls.Add("mesh-replay");
+ if (failReplayOnce)
+ {
+ failReplayOnce = false;
+ throw new InvalidOperationException("injected replay failure");
+ }
+ });
+
+ Assert.Throws(() => controller.Tick(0x90, 0x90));
+ Assert.True(state.IsNearTier(landblockId));
+
+ controller.Tick(0x90, 0x93);
+
+ Assert.True(state.IsLoaded(landblockId));
+ Assert.False(state.IsNearTier(landblockId));
+ Assert.DoesNotContain(state.Entities, entity => entity.Id == 23);
+ Assert.Equal(1, calls.Count(call => call == "presentation"));
+ Assert.Equal(2, calls.Count(call => call == "mesh-replay"));
+ Assert.Contains("demote", calls);
+ }
+
+ [Fact]
+ public void CoarsePresentationFailure_IsFailFastAndNotRetainedForAutomaticReplay()
+ {
+ const uint landblockId = 0xA0A0FFFFu;
+ var state = new GpuWorldState();
+ int presentationCalls = 0;
+ var pipeline = new LandblockPresentationPipeline(
+ publishBeforeSpatialCommit: (_, _) =>
+ {
+ presentationCalls++;
+ throw new StreamingMutationException(
+ "injected committed coarse failure",
+ mutationCommitted: true);
+ },
+ state);
+ var result = new LandblockStreamResult.Loaded(
+ landblockId,
+ LandblockStreamTier.Near,
+ BuildWithShell(landblockId),
+ EmptyMesh());
+
+ Assert.Throws(() => pipeline.PublishLoaded(result));
+
+ Assert.Equal(1, presentationCalls);
+ Assert.False(pipeline.HasPendingPublication(result));
+ Assert.Equal(0, pipeline.PendingPublicationCount);
+ Assert.False(state.IsLoaded(landblockId));
+ }
+
+ [Fact]
+ public void DeferredCommittedPresentationFailure_DropsOnlyFailedResultAndPreservesTail()
+ {
+ const uint failedId = 0xB0B0FFFFu;
+ const uint tailId = 0xB0B1FFFFu;
+ var outbox = new Queue(
+ [
+ new LandblockStreamResult.Loaded(
+ failedId,
+ LandblockStreamTier.Near,
+ new LandblockBuild(new LoadedLandblock(
+ failedId,
+ new LandBlock(),
+ Array.Empty())),
+ EmptyMesh()),
+ new LandblockStreamResult.Loaded(
+ tailId,
+ LandblockStreamTier.Near,
+ new LandblockBuild(new LoadedLandblock(
+ tailId,
+ new LandBlock(),
+ Array.Empty())),
+ EmptyMesh()),
+ ]);
+ int failedCalls = 0;
+ int tailCalls = 0;
+ var state = new GpuWorldState();
+ var controller = new StreamingController(
+ enqueueLoad: (_, _) => { },
+ enqueueUnload: _ => { },
+ drainCompletions: max => Drain(outbox, max),
+ applyTerrain: (build, _) =>
+ {
+ if (build.LandblockId == failedId)
+ {
+ failedCalls++;
+ throw new StreamingMutationException(
+ "injected committed presentation failure",
+ mutationCommitted: true);
+ }
+ tailCalls++;
+ },
+ state,
+ nearRadius: 0,
+ farRadius: 2);
+
+ Assert.Throws(() => controller.Tick(0xB0, 0xB0));
+ Assert.Equal(1, failedCalls);
+ Assert.Equal(0, tailCalls);
+ Assert.Equal(1, controller.DeferredApplyBacklog);
+
+ controller.Tick(0xB0, 0xB0);
+
+ Assert.Equal(1, failedCalls);
+ Assert.Equal(1, tailCalls);
+ Assert.False(state.IsLoaded(failedId));
+ Assert.True(state.IsLoaded(tailId));
+ Assert.Equal(0, controller.DeferredApplyBacklog);
+ }
+
+ [Fact]
+ public void RetirementFacade_DetachesFirstAndReportsPendingLedger()
+ {
+ const uint landblockId = 0x5050FFFFu;
+ var state = new GpuWorldState();
+ state.AddLandblock(new LoadedLandblock(
+ landblockId,
+ new LandBlock(),
+ Array.Empty()));
+ bool failOnce = true;
+ var retirements = new LandblockRetirementCoordinator(
+ state,
+ ticket =>
+ {
+ ticket.RunOnce(
+ LandblockRetirementStage.EntityLighting,
+ () =>
+ {
+ if (failOnce)
+ {
+ failOnce = false;
+ throw new InvalidOperationException("injected failure");
+ }
+ });
+ },
+ _ => LandblockRetirementStage.EntityLighting);
+ var pipeline = new LandblockPresentationPipeline(
+ static (_, _) => { },
+ state,
+ retirementCoordinator: retirements);
+
+ pipeline.BeginFullRetirement(landblockId);
+
+ Assert.False(state.IsLoaded(landblockId));
+ Assert.True(pipeline.IsRetirementPending(landblockId));
+ Assert.Equal(1, pipeline.PendingRetirementCount);
+
+ pipeline.AdvanceRetirements();
+
+ Assert.False(pipeline.IsRetirementPending(landblockId));
+ Assert.Equal(0, pipeline.PendingRetirementCount);
+ }
+
+ [Fact]
+ public void Pipeline_DoesNotOwnStreamingPolicyOrLiveIdentityMaps()
+ {
+ string[] forbiddenFragments =
+ [
+ "generation",
+ "desiredtier",
+ "streamingregion",
+ "serverguid",
+ "liveentity",
+ ];
+
+ string[] fields = typeof(LandblockPresentationPipeline)
+ .GetFields(System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.NonPublic)
+ .Select(field => $"{field.Name}:{field.FieldType.FullName}")
+ .ToArray();
+
+ foreach (string field in fields)
+ foreach (string forbidden in forbiddenFragments)
+ Assert.DoesNotContain(forbidden, field, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static LandblockBuild BuildWithShell(
+ uint landblockId,
+ params WorldEntity[] entities)
+ {
+ uint cellId = (landblockId & 0xFFFF0000u) | 0x0100u;
+ var shell = new EnvCellShellPlacement(
+ cellId,
+ 0x2_0000_0001UL,
+ 0x0D000001u,
+ 1,
+ ImmutableArray.Empty,
+ Vector3.Zero,
+ Quaternion.Identity,
+ Matrix4x4.Identity,
+ new WbBoundingBox(Vector3.Zero, Vector3.One),
+ new WbBoundingBox(Vector3.Zero, Vector3.One));
+ var envCells = new EnvCellLandblockBuild(
+ landblockId,
+ Array.Empty(),
+ [shell]);
+ return new LandblockBuild(
+ new LoadedLandblock(
+ landblockId,
+ new LandBlock(),
+ entities,
+ PhysicsDatBundle.Empty),
+ envCells);
+ }
+
+ private static WorldEntity Entity(uint id) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x01000000u + id,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ private static LandblockMeshData EmptyMesh() => new(
+ Array.Empty(),
+ Array.Empty());
+
+ private static IReadOnlyList Drain(
+ Queue queue,
+ int max)
+ {
+ var drained = new List(max);
+ while (drained.Count < max && queue.TryDequeue(out LandblockStreamResult? result))
+ drained.Add(result);
+ return drained;
+ }
+
+ private sealed class RecordingMeshAdapter(List calls) : IWbMeshAdapter
+ {
+ public void IncrementRefCount(ulong id)
+ {
+ }
+
+ public void PinPreparedRenderData(ulong id) => calls.Add("pin");
+
+ public void DecrementRefCount(ulong id)
+ {
+ }
+ }
+
+ private sealed class FailFirstPreparedPinAdapter : IWbMeshAdapter
+ {
+ public int PinAttempts { get; private set; }
+
+ public void IncrementRefCount(ulong id)
+ {
+ }
+
+ public void PinPreparedRenderData(ulong id)
+ {
+ PinAttempts++;
+ if (PinAttempts == 1)
+ throw new InvalidOperationException("injected pin failure");
+ }
+
+ public void DecrementRefCount(ulong id)
+ {
+ }
+ }
+}