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

@ -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;

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()

View file

@ -0,0 +1,299 @@
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Render-thread transaction coordinator for one accepted landblock streaming
/// result. Streaming policy (desired tier, generation, priority, and apply
/// budget) remains in <see cref="StreamingController"/>; spatial buckets remain
/// in <see cref="GpuWorldState"/>. 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.
/// </summary>
/// <remarks>
/// Retail establishes cells/buildings before static objects in
/// <c>LScape::grab_visible_cells @ 0x00504EC0</c>. Its release path separates
/// <c>CLandBlock::release_all @ 0x0052FCF0</c> (objects and visible cells) from
/// later destruction in <c>CLandBlock::Destroy @ 0x0052FAA0</c> (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.
/// </remarks>
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<LandblockBuild, LandblockMeshData> _publishBeforeSpatialCommit;
private readonly GpuWorldState _state;
private readonly Action<uint>? _onLandblockLoaded;
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
private readonly LandblockRetirementCoordinator _retirements;
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
new(ReferenceEqualityComparer.Instance);
public LandblockPresentationPipeline(
Action<LandblockBuild, LandblockMeshData> publishBeforeSpatialCommit,
GpuWorldState state,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null,
Action<uint>? removeTerrain = null,
Action<uint>? 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<LandblockStreamResult> 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<WorldEntity>(),
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<ulong>? 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);
}
}

View file

@ -21,24 +21,9 @@ public sealed class StreamingController
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
private readonly Action? _clearPendingLoads;
private readonly LandblockRetirementCoordinator _retirements;
private readonly LandblockPresentationPipeline _presentation;
/// <summary>
/// #138: fired after a landblock's entity layer (re)loads — both the full
/// <see cref="LandblockStreamResult.Loaded"/> path (initial / dungeon-exit
/// expand) and the <see cref="LandblockStreamResult.Promoted"/> path
/// (Far→Near). <c>GameWindow</c> 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.
/// </summary>
private readonly Action<uint>? _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<EnvCellLandblockBuild>? _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<LandblockStreamResult> 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;
});
}
}
/// <summary>
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
/// 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
/// </summary>
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
/// </summary>
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<uint>(_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);
}
/// <summary>
@ -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
/// </summary>
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<WorldEntity>(),
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);
/// <summary>
/// Returns the landblock id associated with <paramref name="result"/>.