refactor(streaming): own presentation retirement in pipeline

Move full and near-tier presentation retirement out of GameWindow and into a concrete pipeline-owned stage mapper. Harden the retry ledger against reentrancy and committed visibility observer failures, enforce a single state/resource ownership graph, and split concrete versus legacy controller construction so callbacks cannot be silently ignored.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 22:07:36 +02:00
parent dc39772bf9
commit ea7ffbc186
10 changed files with 860 additions and 140 deletions

View file

@ -79,6 +79,13 @@ public sealed class LandblockPresentationPipeline
_state = state;
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
if (retirementCoordinator is not null
&& !retirementCoordinator.MatchesState(_state))
{
throw new ArgumentException(
"The retirement coordinator must own the pipeline's world state.",
nameof(retirementCoordinator));
}
_retirements = retirementCoordinator
?? LandblockRetirementCoordinator.CreateLegacy(
state,
@ -96,9 +103,9 @@ public sealed class LandblockPresentationPipeline
LandblockPhysicsPublisher physicsPublisher,
LandblockStaticPresentationPublisher staticPublisher,
GpuWorldState state,
LandblockPresentationRetirementOwner retirementOwner,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
{
_renderPublisher = renderPublisher
?? throw new ArgumentNullException(nameof(renderPublisher));
@ -107,19 +114,35 @@ public sealed class LandblockPresentationPipeline
_staticPublisher = staticPublisher
?? throw new ArgumentNullException(nameof(staticPublisher));
_state = state ?? throw new ArgumentNullException(nameof(state));
if (!_renderPublisher.MatchesState(_state))
{
throw new ArgumentException(
"The render publisher must own the pipeline's world state.",
nameof(state));
}
ArgumentNullException.ThrowIfNull(retirementOwner);
if (!retirementOwner.Matches(
_renderPublisher,
_physicsPublisher,
_staticPublisher))
{
throw new ArgumentException(
"The retirement owner must reference the pipeline's concrete publishers.",
nameof(retirementOwner));
}
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
// Until Slice 5F internalizes concrete retirement ownership, a
// concrete publication pipeline must share the production coordinator.
// A legacy no-op retirement suffix would detach spatial state while
// leaking the concrete render/physics/static owners.
_retirements = retirementCoordinator
?? throw new ArgumentNullException(nameof(retirementCoordinator));
_retirements = new LandblockRetirementCoordinator(
state,
retirementOwner.Advance);
}
public int PendingRetirementCount => _retirements.PendingCount;
public int PendingPublicationCount => _publications.Count;
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_state, state);
public bool IsRetirementPending(uint landblockId) =>
_retirements.IsPending(landblockId);

View file

@ -0,0 +1,119 @@
using AcDream.Core.Lighting;
using AcDream.Core.Rendering;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Composes the presentation-owner half of one detach-first landblock
/// retirement. <see cref="LandblockRetirementTicket"/> remains the exact
/// retry ledger; this class only maps each stage to its concrete owner.
/// </summary>
/// <remarks>
/// Retail separates <c>CLandBlock::release_all</c> (0x0052FCF0),
/// <c>CLandBlock::destroy_static_objects</c> (0x0052FA50), and
/// <c>CLandBlock::Destroy</c> (0x0052FAA0). Acdream's asynchronous equivalent
/// detaches spatial reachability first, then advances these concrete owners
/// from the retained ticket without replaying successful stages or entities.
/// </remarks>
public sealed class LandblockPresentationRetirementOwner
{
private readonly LandblockRenderPublisher _render;
private readonly LandblockPhysicsPublisher _physics;
private readonly LandblockStaticPresentationPublisher _staticPresentation;
private readonly LightingHookSink _lighting;
private readonly TranslucencyFadeManager _translucency;
public LandblockPresentationRetirementOwner(
LandblockRenderPublisher render,
LandblockPhysicsPublisher physics,
LandblockStaticPresentationPublisher staticPresentation,
LightingHookSink lighting,
TranslucencyFadeManager translucency)
{
_render = render ?? throw new ArgumentNullException(nameof(render));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_staticPresentation = staticPresentation
?? throw new ArgumentNullException(nameof(staticPresentation));
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
_translucency = translucency
?? throw new ArgumentNullException(nameof(translucency));
if (!_staticPresentation.MatchesResources(_lighting, _translucency))
{
throw new ArgumentException(
"The retirement resources must be those owned by the static " +
"presentation publisher.",
nameof(staticPresentation));
}
}
internal bool Matches(
LandblockRenderPublisher render,
LandblockPhysicsPublisher physics,
LandblockStaticPresentationPublisher staticPresentation) =>
ReferenceEquals(_render, render)
&& ReferenceEquals(_physics, physics)
&& ReferenceEquals(_staticPresentation, staticPresentation)
&& _staticPresentation.MatchesResources(_lighting, _translucency);
public void Advance(LandblockRetirementTicket ticket)
{
ArgumentNullException.ThrowIfNull(ticket);
bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer;
ticket.RunForEachEntity(
LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0,
RemoveLighting);
ticket.RunForEachEntity(
LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0,
RemoveTranslucency);
ticket.RunForEachEntity(
LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0,
_staticPresentation.RemovePluginProjection);
if (ticket.Kind == LandblockRetirementKind.Full)
{
ticket.RunOnce(
LandblockRetirementStage.Terrain,
() => _render.RemoveTerrain(ticket.LandblockId));
}
ticket.RunOnce(
LandblockRetirementStage.Physics,
() =>
{
if (ticket.Kind == LandblockRetirementKind.Full)
_physics.RemoveLandblock(ticket.LandblockId);
else
_physics.DemoteToTerrain(ticket.LandblockId);
});
ticket.RunOnce(
LandblockRetirementStage.CellVisibility,
() => _render.RemoveCellVisibility(ticket.LandblockId));
ticket.RunOnce(
LandblockRetirementStage.BuildingRegistry,
() => _render.RemoveBuildingRegistry(ticket.LandblockId));
ticket.RunOnce(
LandblockRetirementStage.EnvironmentCells,
() => _render.RemoveEnvironmentCells(ticket.LandblockId));
}
private void RemoveLighting(WorldEntity entity)
{
if (entity.ServerGuid == 0)
_staticPresentation.RemoveLighting(entity);
else
_lighting.UnregisterOwner(entity.Id);
}
private void RemoveTranslucency(WorldEntity entity)
{
if (entity.ServerGuid == 0)
_staticPresentation.RemoveTranslucency(entity);
else
_translucency.ClearEntity(entity.Id);
}
}

View file

@ -145,6 +145,9 @@ public sealed class LandblockRenderPublisher
_buildingRegistryRemovalCount,
_envCellRemovalCount);
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_worldState, state);
/// <summary>
/// Publishes the prefix that retail establishes before physics/static
/// activation: terrain, complete visibility cells, and spatial bounds.

View file

@ -136,14 +136,23 @@ public sealed class LandblockRetirementTicket
}
/// <summary>
/// Game-window-lifetime owner for landblock retirement. World-state detachment
/// Pipeline-lifetime owner for landblock retirement. World-state detachment
/// commits first; renderer/cache/script cleanup advances afterward from a
/// per-owner ledger. The coordinator is intentionally shared by replacement
/// <see cref="StreamingController"/> instances so a quality-setting rebuild
/// cannot abandon an unfinished retirement.
/// per-owner ledger. Reentrant begin/advance requests are serialized so an
/// owner callback cannot recursively replay its active stage or mutate the
/// pending-ticket map while it is being enumerated.
/// </summary>
public sealed class LandblockRetirementCoordinator
{
private enum RequestKind : byte
{
BeginFull,
BeginNearLayer,
Advance,
}
private readonly record struct Request(RequestKind Kind, uint LandblockId);
private const LandblockRetirementStage CoreStages =
LandblockRetirementStage.MeshReferences
| LandblockRetirementStage.StaticScripts
@ -165,6 +174,9 @@ public sealed class LandblockRetirementCoordinator
_requiredPresentationStages;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly List<uint> _completedIds = new();
private readonly Queue<Request> _requests = new();
private readonly HashSet<Request> _seenDuringDrain = new();
private bool _drainingRequests;
public LandblockRetirementCoordinator(
GpuWorldState state,
@ -186,13 +198,19 @@ public sealed class LandblockRetirementCoordinator
public bool IsPending(uint landblockId) =>
_pending.ContainsKey(Canonicalize(landblockId));
public void BeginFull(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.Full);
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_state, state);
public void BeginNearLayer(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.NearLayer);
public void BeginFull(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
public void Advance()
public void BeginNearLayer(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginNearLayer, Canonicalize(landblockId)));
public void Advance() => EnqueueRequest(
new Request(RequestKind.Advance, 0u));
private void AdvanceCore()
{
if (_pending.Count == 0)
return;
@ -250,7 +268,7 @@ public sealed class LandblockRetirementCoordinator
return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required);
}
private void Begin(uint landblockId, LandblockRetirementKind kind)
private void BeginCore(uint landblockId, LandblockRetirementKind kind)
{
uint canonical = Canonicalize(landblockId);
if (_pending.TryGetValue(canonical, out List<LandblockRetirementTicket>? existing))
@ -259,20 +277,52 @@ public sealed class LandblockRetirementCoordinator
{
if (existing[i].Kind == kind)
{
AdvanceTicket(existing[i]);
LandblockRetirementTicket existingTicket = existing[i];
AdvanceTicket(existingTicket);
if (existingTicket.IsComplete)
{
existing.RemoveAt(i);
PendingCount--;
if (existing.Count == 0)
_pending.Remove(canonical);
}
return;
}
}
}
GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full
? _state.DetachLandblock(canonical)
: _state.DetachNearLayer(canonical);
if (stateRetirement is null)
return;
LandblockRetirementStage required =
CoreStages | _requiredPresentationStages(kind);
GpuLandblockRetirement? stateRetirement = null;
Exception? detachmentObserverFailure = null;
GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch();
try
{
// The detach methods create a nested batch. Holding this outer
// transaction lets us retain their returned teardown context
// before visibility observers run at the outer commit boundary.
stateRetirement = kind == LandblockRetirementKind.Full
? _state.DetachLandblock(canonical)
: _state.DetachNearLayer(canonical);
}
finally
{
try
{
mutation.Dispose();
}
catch (Exception error)
{
detachmentObserverFailure = error;
}
}
if (stateRetirement is null)
{
if (detachmentObserverFailure is not null)
throw detachmentObserverFailure;
return;
}
var ticket = new LandblockRetirementTicket(stateRetirement, required);
if (existing is null)
{
@ -290,6 +340,13 @@ public sealed class LandblockRetirementCoordinator
if (existing.Count == 0)
_pending.Remove(canonical);
}
// Visibility is a delivered-by-attempt broadcast: every subscriber is
// invoked and failures are aggregated, but failed subscribers are not
// retained as replayable work. Complete the presentation teardown
// from the preserved detach receipt, then surface that committed-edge
// failure to the caller without inventing a one-frame pending fence.
if (detachmentObserverFailure is not null)
throw detachmentObserverFailure;
}
private void AdvanceTicket(LandblockRetirementTicket ticket)
@ -325,4 +382,70 @@ public sealed class LandblockRetirementCoordinator
private static uint Canonicalize(uint id) =>
(id & 0xFFFF0000u) | 0xFFFFu;
private void EnqueueRequest(Request request)
{
if (_drainingRequests)
{
if (_seenDuringDrain.Add(request))
_requests.Enqueue(request);
return;
}
_drainingRequests = true;
_seenDuringDrain.Clear();
_requests.Clear();
_seenDuringDrain.Add(request);
_requests.Enqueue(request);
List<Exception>? failures = null;
try
{
while (_requests.TryDequeue(out Request pending))
{
try
{
switch (pending.Kind)
{
case RequestKind.BeginFull:
BeginCore(
pending.LandblockId,
LandblockRetirementKind.Full);
break;
case RequestKind.BeginNearLayer:
BeginCore(
pending.LandblockId,
LandblockRetirementKind.NearLayer);
break;
case RequestKind.Advance:
AdvanceCore();
break;
default:
throw new InvalidOperationException(
$"Unknown retirement request {pending.Kind}.");
}
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
}
finally
{
_requests.Clear();
_seenDuringDrain.Clear();
_drainingRequests = false;
}
if (failures is { Count: 1 })
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(failures[0])
.Throw();
if (failures is { Count: > 1 })
{
throw new AggregateException(
"One or more serialized landblock retirement requests failed.",
failures);
}
}
}

View file

@ -108,6 +108,12 @@ public sealed class LandblockStaticPresentationPublisher
_activeByLandblock.Count,
_landblockByEntityId.Count);
internal bool MatchesResources(
LightingHookSink lighting,
TranslucencyFadeManager translucency) =>
ReferenceEquals(_lighting, lighting)
&& ReferenceEquals(_translucency, translucency);
public LandblockStaticPresentationPublication PreparePublication(
LandblockPhysicsPublication physicsPublication)
{

View file

@ -115,6 +115,7 @@ public sealed class StreamingController
// deferred-LOAD backlog. A non-zero value during/after a teleport is the
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count;
public int PendingRetirementCount => _presentation.PendingRetirementCount;
/// <summary>
/// True once every in-bounds landblock in the requested Chebyshev ring has
@ -174,8 +175,40 @@ public sealed class StreamingController
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null,
LandblockPresentationPipeline? presentationPipeline = null)
LandblockRetirementCoordinator? retirementCoordinator = null)
: this(
enqueueLoad,
enqueueUnload,
drainCompletions,
state,
nearRadius,
farRadius,
new LandblockPresentationPipeline(
applyTerrain,
state,
onLandblockLoaded,
ensureEnvCellMeshes,
retirementCoordinator,
removeTerrain,
demoteNearLayer),
clearPendingLoads)
{
}
/// <summary>
/// Concrete presentation path. Its pipeline is the sole presentation and
/// retirement owner, so no legacy publication delegate can be supplied or
/// silently ignored.
/// </summary>
public StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
GpuWorldState state,
int nearRadius,
int farRadius,
LandblockPresentationPipeline presentationPipeline,
Action? clearPendingLoads = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
@ -183,14 +216,13 @@ public sealed class StreamingController
_clearPendingLoads = clearPendingLoads;
_state = state;
_presentation = presentationPipeline
?? new LandblockPresentationPipeline(
applyTerrain,
state,
onLandblockLoaded,
ensureEnvCellMeshes,
retirementCoordinator,
removeTerrain,
demoteNearLayer);
?? throw new ArgumentNullException(nameof(presentationPipeline));
if (!_presentation.MatchesState(_state))
{
throw new ArgumentException(
"The presentation pipeline must own the controller's world state.",
nameof(presentationPipeline));
}
NearRadius = nearRadius;
FarRadius = farRadius;
}