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

@ -150,7 +150,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Phase A.1: streaming fields replacing the one-shot _entities list. // Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new(); private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
private AcDream.App.Streaming.LandblockStaticPresentationPublisher? private AcDream.App.Streaming.LandblockStaticPresentationPublisher?
@ -2208,11 +2207,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
wbSpawnAdapter, wbSpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock, onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator); entityScriptActivator: entityScriptActivator);
_landblockRetirements =
new AcDream.App.Streaming.LandblockRetirementCoordinator(
_worldState,
AdvanceLandblockPresentationRetirement);
_liveEntities = new AcDream.App.World.LiveEntityRuntime( _liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState, _worldState,
new AcDream.App.World.CompositeLiveEntityResourceLifecycle( new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
@ -2529,17 +2523,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
}); });
_streamer.Start(); _streamer.Start();
var landblockRetirementOwner =
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
_landblockRenderPublisher!,
_landblockPhysicsPublisher!,
_landblockStaticPresentationPublisher!,
_lightingSink!,
_translucencyFades);
var landblockPresentationPipeline = var landblockPresentationPipeline =
new AcDream.App.Streaming.LandblockPresentationPipeline( new AcDream.App.Streaming.LandblockPresentationPipeline(
_landblockRenderPublisher!, _landblockRenderPublisher!,
_landblockPhysicsPublisher!, _landblockPhysicsPublisher!,
_landblockStaticPresentationPublisher!, _landblockStaticPresentationPublisher!,
_worldState, _worldState,
landblockRetirementOwner,
onLandblockLoaded: loadedLandblockId => onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes: ensureEnvCellMeshes:
_landblockRenderPublisher!.PrepareAfterRenderPins, _landblockRenderPublisher!.PrepareAfterRenderPins);
retirementCoordinator: _landblockRetirements);
_streamingController = new AcDream.App.Streaming.StreamingController( _streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
new AcDream.App.Streaming.LandblockBuildRequest( new AcDream.App.Streaming.LandblockBuildRequest(
@ -2551,18 +2552,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_liveCenterY))), _liveCenterY))),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions, drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState, state: _worldState,
nearRadius: _nearRadius, nearRadius: _nearRadius,
farRadius: _farRadius, farRadius: _farRadius,
clearPendingLoads: _streamer.ClearPendingLoads, clearPendingLoads: _streamer.ClearPendingLoads,
// #138: restore retained server objects when a landblock reloads // Retained-object recovery runs after each
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the // (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it thinks we still know, so we re-project them ourselves. // objects it still considers known.
onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins,
retirementCoordinator: _landblockRetirements,
presentationPipeline: landblockPresentationPipeline); presentationPipeline: landblockPresentationPipeline);
// A.5 T22.5: apply max-completions from resolved quality. // A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -3805,65 +3801,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
$"live: AdminEnvirons sound cue = {name} " + $"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending"); $"(0x{environChangeType:X2}) — audio binding pending");
} }
private void AdvanceLandblockPresentationRetirement(
AcDream.App.Streaming.LandblockRetirementTicket ticket)
{
bool staticOnly =
ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.NearLayer;
ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0,
entity =>
{
if (entity.ServerGuid == 0)
_landblockStaticPresentationPublisher!.RemoveLighting(entity);
else
_lightingSink?.UnregisterOwner(entity.Id);
});
ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0,
entity =>
{
if (entity.ServerGuid == 0)
_landblockStaticPresentationPublisher!.RemoveTranslucency(entity);
else
_translucencyFades.ClearEntity(entity.Id);
});
ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0,
entity => _landblockStaticPresentationPublisher!
.RemovePluginProjection(entity));
if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full)
{
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.Terrain,
() => _landblockRenderPublisher?.RemoveTerrain(ticket.LandblockId));
}
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.Physics,
() =>
{
if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full)
_landblockPhysicsPublisher!.RemoveLandblock(ticket.LandblockId);
else
_landblockPhysicsPublisher!.DemoteToTerrain(ticket.LandblockId);
});
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.CellVisibility,
() => _landblockRenderPublisher?.RemoveCellVisibility(ticket.LandblockId));
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.BuildingRegistry,
() => _landblockRenderPublisher?.RemoveBuildingRegistry(ticket.LandblockId));
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.EnvironmentCells,
() => _landblockRenderPublisher?.RemoveEnvironmentCells(ticket.LandblockId));
}
/// <summary> /// <summary>
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick /// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
/// whenever a new landblock's terrain + entities are ready for GPU upload. /// whenever a new landblock's terrain + entities are ready for GPU upload.
@ -5961,7 +5898,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
LiveEntities: _liveEntities?.Count ?? 0, LiveEntities: _liveEntities?.Count ?? 0,
MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0, MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0,
PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0, PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0,
PendingLandblockRetirements: _landblockRetirements?.PendingCount ?? 0, PendingLandblockRetirements:
_streamingController?.PendingRetirementCount ?? 0,
ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0, ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0,
Particles: _particleSystem?.ActiveParticleCount ?? 0, Particles: _particleSystem?.ActiveParticleCount ?? 0,
ParticleBindings: _particleSink?.ActiveBindingCount ?? 0, ParticleBindings: _particleSink?.ActiveBindingCount ?? 0,

View file

@ -79,6 +79,13 @@ public sealed class LandblockPresentationPipeline
_state = state; _state = state;
_onLandblockLoaded = onLandblockLoaded; _onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes; _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 _retirements = retirementCoordinator
?? LandblockRetirementCoordinator.CreateLegacy( ?? LandblockRetirementCoordinator.CreateLegacy(
state, state,
@ -96,9 +103,9 @@ public sealed class LandblockPresentationPipeline
LandblockPhysicsPublisher physicsPublisher, LandblockPhysicsPublisher physicsPublisher,
LandblockStaticPresentationPublisher staticPublisher, LandblockStaticPresentationPublisher staticPublisher,
GpuWorldState state, GpuWorldState state,
LandblockPresentationRetirementOwner retirementOwner,
Action<uint>? onLandblockLoaded = null, Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null, Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
LandblockRetirementCoordinator? retirementCoordinator = null)
{ {
_renderPublisher = renderPublisher _renderPublisher = renderPublisher
?? throw new ArgumentNullException(nameof(renderPublisher)); ?? throw new ArgumentNullException(nameof(renderPublisher));
@ -107,19 +114,35 @@ public sealed class LandblockPresentationPipeline
_staticPublisher = staticPublisher _staticPublisher = staticPublisher
?? throw new ArgumentNullException(nameof(staticPublisher)); ?? throw new ArgumentNullException(nameof(staticPublisher));
_state = state ?? throw new ArgumentNullException(nameof(state)); _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; _onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes; _ensureEnvCellMeshes = ensureEnvCellMeshes;
// Until Slice 5F internalizes concrete retirement ownership, a _retirements = new LandblockRetirementCoordinator(
// concrete publication pipeline must share the production coordinator. state,
// A legacy no-op retirement suffix would detach spatial state while retirementOwner.Advance);
// leaking the concrete render/physics/static owners.
_retirements = retirementCoordinator
?? throw new ArgumentNullException(nameof(retirementCoordinator));
} }
public int PendingRetirementCount => _retirements.PendingCount; public int PendingRetirementCount => _retirements.PendingCount;
public int PendingPublicationCount => _publications.Count; public int PendingPublicationCount => _publications.Count;
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_state, state);
public bool IsRetirementPending(uint landblockId) => public bool IsRetirementPending(uint landblockId) =>
_retirements.IsPending(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, _buildingRegistryRemovalCount,
_envCellRemovalCount); _envCellRemovalCount);
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_worldState, state);
/// <summary> /// <summary>
/// Publishes the prefix that retail establishes before physics/static /// Publishes the prefix that retail establishes before physics/static
/// activation: terrain, complete visibility cells, and spatial bounds. /// activation: terrain, complete visibility cells, and spatial bounds.

View file

@ -136,14 +136,23 @@ public sealed class LandblockRetirementTicket
} }
/// <summary> /// <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 /// commits first; renderer/cache/script cleanup advances afterward from a
/// per-owner ledger. The coordinator is intentionally shared by replacement /// per-owner ledger. Reentrant begin/advance requests are serialized so an
/// <see cref="StreamingController"/> instances so a quality-setting rebuild /// owner callback cannot recursively replay its active stage or mutate the
/// cannot abandon an unfinished retirement. /// pending-ticket map while it is being enumerated.
/// </summary> /// </summary>
public sealed class LandblockRetirementCoordinator public sealed class LandblockRetirementCoordinator
{ {
private enum RequestKind : byte
{
BeginFull,
BeginNearLayer,
Advance,
}
private readonly record struct Request(RequestKind Kind, uint LandblockId);
private const LandblockRetirementStage CoreStages = private const LandblockRetirementStage CoreStages =
LandblockRetirementStage.MeshReferences LandblockRetirementStage.MeshReferences
| LandblockRetirementStage.StaticScripts | LandblockRetirementStage.StaticScripts
@ -165,6 +174,9 @@ public sealed class LandblockRetirementCoordinator
_requiredPresentationStages; _requiredPresentationStages;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new(); private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly List<uint> _completedIds = new(); private readonly List<uint> _completedIds = new();
private readonly Queue<Request> _requests = new();
private readonly HashSet<Request> _seenDuringDrain = new();
private bool _drainingRequests;
public LandblockRetirementCoordinator( public LandblockRetirementCoordinator(
GpuWorldState state, GpuWorldState state,
@ -186,13 +198,19 @@ public sealed class LandblockRetirementCoordinator
public bool IsPending(uint landblockId) => public bool IsPending(uint landblockId) =>
_pending.ContainsKey(Canonicalize(landblockId)); _pending.ContainsKey(Canonicalize(landblockId));
public void BeginFull(uint landblockId) => internal bool MatchesState(GpuWorldState state) =>
Begin(landblockId, LandblockRetirementKind.Full); ReferenceEquals(_state, state);
public void BeginNearLayer(uint landblockId) => public void BeginFull(uint landblockId) => EnqueueRequest(
Begin(landblockId, LandblockRetirementKind.NearLayer); 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) if (_pending.Count == 0)
return; return;
@ -250,7 +268,7 @@ public sealed class LandblockRetirementCoordinator
return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required); return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required);
} }
private void Begin(uint landblockId, LandblockRetirementKind kind) private void BeginCore(uint landblockId, LandblockRetirementKind kind)
{ {
uint canonical = Canonicalize(landblockId); uint canonical = Canonicalize(landblockId);
if (_pending.TryGetValue(canonical, out List<LandblockRetirementTicket>? existing)) if (_pending.TryGetValue(canonical, out List<LandblockRetirementTicket>? existing))
@ -259,20 +277,52 @@ public sealed class LandblockRetirementCoordinator
{ {
if (existing[i].Kind == kind) 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; return;
} }
} }
} }
GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full
? _state.DetachLandblock(canonical)
: _state.DetachNearLayer(canonical);
if (stateRetirement is null)
return;
LandblockRetirementStage required = LandblockRetirementStage required =
CoreStages | _requiredPresentationStages(kind); 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); var ticket = new LandblockRetirementTicket(stateRetirement, required);
if (existing is null) if (existing is null)
{ {
@ -290,6 +340,13 @@ public sealed class LandblockRetirementCoordinator
if (existing.Count == 0) if (existing.Count == 0)
_pending.Remove(canonical); _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) private void AdvanceTicket(LandblockRetirementTicket ticket)
@ -325,4 +382,70 @@ public sealed class LandblockRetirementCoordinator
private static uint Canonicalize(uint id) => private static uint Canonicalize(uint id) =>
(id & 0xFFFF0000u) | 0xFFFFu; (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, _activeByLandblock.Count,
_landblockByEntityId.Count); _landblockByEntityId.Count);
internal bool MatchesResources(
LightingHookSink lighting,
TranslucencyFadeManager translucency) =>
ReferenceEquals(_lighting, lighting)
&& ReferenceEquals(_translucency, translucency);
public LandblockStaticPresentationPublication PreparePublication( public LandblockStaticPresentationPublication PreparePublication(
LandblockPhysicsPublication physicsPublication) 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 // deferred-LOAD backlog. A non-zero value during/after a teleport is the
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count; public int DeferredApplyBacklog => _deferredApply.Count;
public int PendingRetirementCount => _presentation.PendingRetirementCount;
/// <summary> /// <summary>
/// True once every in-bounds landblock in the requested Chebyshev ring has /// True once every in-bounds landblock in the requested Chebyshev ring has
@ -174,8 +175,40 @@ public sealed class StreamingController
Action? clearPendingLoads = null, Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null, Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null, Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null, LandblockRetirementCoordinator? retirementCoordinator = null)
LandblockPresentationPipeline? presentationPipeline = 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; _enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload; _enqueueUnload = enqueueUnload;
@ -183,14 +216,13 @@ public sealed class StreamingController
_clearPendingLoads = clearPendingLoads; _clearPendingLoads = clearPendingLoads;
_state = state; _state = state;
_presentation = presentationPipeline _presentation = presentationPipeline
?? new LandblockPresentationPipeline( ?? throw new ArgumentNullException(nameof(presentationPipeline));
applyTerrain, if (!_presentation.MatchesState(_state))
state, {
onLandblockLoaded, throw new ArgumentException(
ensureEnvCellMeshes, "The presentation pipeline must own the controller's world state.",
retirementCoordinator, nameof(presentationPipeline));
removeTerrain, }
demoteNearLayer);
NearRadius = nearRadius; NearRadius = nearRadius;
FarRadius = farRadius; FarRadius = farRadius;
} }

View file

@ -40,8 +40,9 @@ public sealed class LandblockConcretePresentationPipelineTests
fixture.Physics, fixture.Physics,
fixture.Static, fixture.Static,
fixture.State, fixture.State,
fixture.RetirementOwner,
onLandblockLoaded: _ => calls.Add("live-recovery"), onLandblockLoaded: _ => calls.Add("live-recovery"),
retirementCoordinator: fixture.Retirements); ensureEnvCellMeshes: null);
LandblockBuild build = Build(Entity(0x80A9B401u)); LandblockBuild build = Build(Entity(0x80A9B401u));
pipeline.PublishLoaded(Result(build)); pipeline.PublishLoaded(Result(build));
@ -77,7 +78,7 @@ public sealed class LandblockConcretePresentationPipelineTests
fixture.Physics, fixture.Physics,
fixture.Static, fixture.Static,
fixture.State, fixture.State,
retirementCoordinator: fixture.Retirements); fixture.RetirementOwner);
LandblockBuild build = Build(Entity(0x80A9B401u)); LandblockBuild build = Build(Entity(0x80A9B401u));
LandblockStreamResult.Loaded result = Result(build); LandblockStreamResult.Loaded result = Result(build);
@ -113,7 +114,7 @@ public sealed class LandblockConcretePresentationPipelineTests
fixture.Physics, fixture.Physics,
fixture.Static, fixture.Static,
fixture.State, fixture.State,
retirementCoordinator: fixture.Retirements); fixture.RetirementOwner);
WorldEntity first = Entity(0x80A9B401u); WorldEntity first = Entity(0x80A9B401u);
LandblockBuild build = Build(first, Entity(first.Id)); LandblockBuild build = Build(first, Entity(first.Id));
LandblockStreamResult.Loaded result = Result(build); LandblockStreamResult.Loaded result = Result(build);
@ -140,7 +141,7 @@ public sealed class LandblockConcretePresentationPipelineTests
fixture.Physics, fixture.Physics,
fixture.Static, fixture.Static,
fixture.State, fixture.State,
retirementCoordinator: fixture.Retirements); fixture.RetirementOwner);
WorldEntity first = Entity(0x80A9B401u); WorldEntity first = Entity(0x80A9B401u);
pipeline.PublishLoaded(Result(Build(first))); pipeline.PublishLoaded(Result(Build(first)));
long renderBegins = fixture.Render.Diagnostics.BeginCount; long renderBegins = fixture.Render.Diagnostics.BeginCount;
@ -161,7 +162,7 @@ public sealed class LandblockConcretePresentationPipelineTests
} }
[Fact] [Fact]
public void ConcreteConstructor_RequiresSharedRetirementCoordinator() public void ConcreteConstructor_RequiresMatchingRetirementOwner()
{ {
ConcreteFixture fixture = Fixture(new List<string>()); ConcreteFixture fixture = Fixture(new List<string>());
@ -171,7 +172,51 @@ public sealed class LandblockConcretePresentationPipelineTests
fixture.Physics, fixture.Physics,
fixture.Static, fixture.Static,
fixture.State, fixture.State,
retirementCoordinator: null)); retirementOwner: null!));
var foreignRender = new LandblockRenderPublisher(
static (_, _, _) => { },
static _ => { },
new CellVisibility(),
fixture.State);
var foreignOwner = new LandblockPresentationRetirementOwner(
foreignRender,
fixture.Physics,
fixture.Static,
fixture.Lighting,
fixture.Translucency);
Assert.Throws<ArgumentException>(() =>
new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
foreignOwner));
Assert.Throws<ArgumentException>(() =>
new LandblockPresentationRetirementOwner(
fixture.Render,
fixture.Physics,
fixture.Static,
new LightingHookSink(
new LightManager(),
new NullPoseSource()),
fixture.Translucency));
Assert.Throws<ArgumentException>(() =>
new LandblockPresentationRetirementOwner(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.Lighting,
new TranslucencyFadeManager()));
Assert.Throws<ArgumentException>(() =>
new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
new GpuWorldState(),
fixture.RetirementOwner));
} }
[Fact] [Fact]
@ -183,7 +228,7 @@ public sealed class LandblockConcretePresentationPipelineTests
fixture.Physics, fixture.Physics,
fixture.Static, fixture.Static,
fixture.State, fixture.State,
retirementCoordinator: fixture.Retirements); fixture.RetirementOwner);
WorldEntity first = Entity(0x80A9B401u); WorldEntity first = Entity(0x80A9B401u);
WorldEntity replacement = Entity(first.Id); WorldEntity replacement = Entity(first.Id);
@ -205,7 +250,7 @@ public sealed class LandblockConcretePresentationPipelineTests
fixture.Physics, fixture.Physics,
fixture.Static, fixture.Static,
fixture.State, fixture.State,
retirementCoordinator: fixture.Retirements); fixture.RetirementOwner);
WorldEntity entity = Entity(0x80A9B401u); WorldEntity entity = Entity(0x80A9B401u);
pipeline.PublishLoaded(Result(Build(entity))); pipeline.PublishLoaded(Result(Build(entity)));
Assert.Equal(1, fixture.Runner.ActiveOwnerCount); Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
@ -217,9 +262,158 @@ public sealed class LandblockConcretePresentationPipelineTests
Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _)); Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _));
} }
[Fact]
public void FullRetirement_DetachesFirstAndBalancesEveryConcreteOwner()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(calls);
var pipeline = Pipeline(fixture);
WorldEntity staticEntity = Entity(0x80A9B401u);
WorldEntity liveEntity = Entity(
7u,
serverGuid: 0x80000007u);
pipeline.PublishLoaded(Result(Build(staticEntity, liveEntity)));
fixture.Lighting.RegisterOwnedLight(new LightSource
{
OwnerId = staticEntity.Id,
Kind = LightKind.Point,
});
fixture.Lighting.RegisterOwnedLight(new LightSource
{
OwnerId = liveEntity.Id,
Kind = LightKind.Point,
});
fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f);
fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f);
calls.Clear();
pipeline.BeginFullRetirement(LandblockId);
Assert.False(fixture.State.IsLoaded(LandblockId));
Assert.Equal(0, pipeline.PendingRetirementCount);
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _));
Assert.Equal(0, fixture.Lights.RegisteredCount);
Assert.False(fixture.Translucency.TryGetCurrentValue(
staticEntity.Id,
0u,
out _));
Assert.False(fixture.Translucency.TryGetCurrentValue(
liveEntity.Id,
0u,
out _));
Assert.Empty(fixture.World.Entities);
Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount);
Assert.Equal(0, fixture.Physics.Diagnostics.DemotionCount);
Assert.Equal(1, fixture.Render.Diagnostics.TerrainRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.CellVisibilityRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.BuildingRegistryRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount);
Assert.Contains("unpin", calls);
Assert.Contains("terrain-remove", calls);
Assert.Contains("envcell-remove", calls);
}
[Fact]
public void NearRetirement_KeepsTerrainAndLivePresentationButRetiresNearOwners()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(calls);
var pipeline = Pipeline(fixture);
WorldEntity staticEntity = Entity(0x80A9B401u);
WorldEntity liveEntity = Entity(
7u,
serverGuid: 0x80000007u);
pipeline.PublishLoaded(Result(Build(staticEntity, liveEntity)));
fixture.Lighting.RegisterOwnedLight(new LightSource
{
OwnerId = staticEntity.Id,
Kind = LightKind.Point,
});
fixture.Lighting.RegisterOwnedLight(new LightSource
{
OwnerId = liveEntity.Id,
Kind = LightKind.Point,
});
fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f);
fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f);
calls.Clear();
pipeline.BeginNearLayerRetirement(LandblockId);
Assert.True(fixture.State.IsLoaded(LandblockId));
Assert.False(fixture.State.IsNearTier(LandblockId));
Assert.Single(fixture.State.Entities);
Assert.Same(liveEntity, fixture.State.Entities[0]);
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _));
Assert.Single(fixture.Lighting.GetOwnedLights(liveEntity.Id)!);
Assert.Null(fixture.Lighting.GetOwnedLights(staticEntity.Id));
Assert.False(fixture.Translucency.TryGetCurrentValue(
staticEntity.Id,
0u,
out _));
Assert.True(fixture.Translucency.TryGetCurrentValue(
liveEntity.Id,
0u,
out _));
Assert.Empty(fixture.World.Entities);
Assert.Equal(0, fixture.Physics.Diagnostics.FullRemovalCount);
Assert.Equal(1, fixture.Physics.Diagnostics.DemotionCount);
Assert.Equal(0, fixture.Render.Diagnostics.TerrainRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.CellVisibilityRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.BuildingRegistryRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount);
Assert.DoesNotContain("terrain-remove", calls);
}
[Fact]
public void FullRetirementFailure_RetriesOnlyUnfinishedConcreteStage()
{
int terrainAttempts = 0;
var calls = new List<string>();
ConcreteFixture fixture = Fixture(
calls,
removeTerrain: _ =>
{
terrainAttempts++;
if (terrainAttempts == 1)
throw new InvalidOperationException("injected terrain removal failure");
});
var pipeline = Pipeline(fixture);
pipeline.PublishLoaded(Result(Build(Entity(0x80A9B401u))));
pipeline.BeginFullRetirement(LandblockId);
Assert.False(fixture.State.IsLoaded(LandblockId));
Assert.Equal(1, pipeline.PendingRetirementCount);
Assert.Equal(1, terrainAttempts);
Assert.Equal(0, fixture.Render.Diagnostics.TerrainRemovalCount);
Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount);
pipeline.AdvanceRetirements();
Assert.Equal(0, pipeline.PendingRetirementCount);
Assert.Equal(2, terrainAttempts);
Assert.Equal(1, fixture.Render.Diagnostics.TerrainRemovalCount);
Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount);
Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount);
}
private static LandblockPresentationPipeline Pipeline(
ConcreteFixture fixture) => new(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner);
private static ConcreteFixture Fixture( private static ConcreteFixture Fixture(
List<string> calls, List<string> calls,
Action<EnvCellLandblockBuild>? commitEnvCells = null) Action<EnvCellLandblockBuild>? commitEnvCells = null,
Action<uint>? removeTerrain = null,
Action<uint>? removeEnvCells = null)
{ {
var poses = new EntityEffectPoseRegistry(); var poses = new EntityEffectPoseRegistry();
var particleSink = new ParticleHookSink( var particleSink = new ParticleHookSink(
@ -246,33 +440,44 @@ public sealed class LandblockConcretePresentationPipelineTests
var engine = new PhysicsEngine { DataCache = cache }; var engine = new PhysicsEngine { DataCache = cache };
var render = new LandblockRenderPublisher( var render = new LandblockRenderPublisher(
(_, _, _) => calls.Add("terrain"), (_, _, _) => calls.Add("terrain"),
static _ => { }, removeTerrain ?? (_ => calls.Add("terrain-remove")),
new CellVisibility(), new CellVisibility(),
state, state,
commitEnvCells: commitEnvCells); commitEnvCells: commitEnvCells,
removeEnvCells: removeEnvCells ?? (_ => calls.Add("envcell-remove")));
var physics = new LandblockPhysicsPublisher(engine, new float[256]); var physics = new LandblockPhysicsPublisher(engine, new float[256]);
var lights = new LightManager();
var lighting = new LightingHookSink( var lighting = new LightingHookSink(
new LightManager(), lights,
new NullPoseSource()); new NullPoseSource());
var translucency = new TranslucencyFadeManager();
var world = new WorldGameState();
var events = new WorldEvents(); var events = new WorldEvents();
var retirements = new LandblockRetirementCoordinator( var staticPresentation = new LandblockStaticPresentationPublisher(
state, lighting,
static _ => { }, translucency,
static _ => LandblockRetirementStage.None); world,
events);
var retirementOwner = new LandblockPresentationRetirementOwner(
render,
physics,
staticPresentation,
lighting,
translucency);
return new ConcreteFixture( return new ConcreteFixture(
state, state,
engine, engine,
render, render,
physics, physics,
new LandblockStaticPresentationPublisher( staticPresentation,
lighting,
new TranslucencyFadeManager(),
new WorldGameState(),
events),
events, events,
retirements, retirementOwner,
runner, runner,
poses); poses,
lights,
lighting,
translucency,
world);
} }
private static LandblockStreamResult.Loaded Result(LandblockBuild build) => private static LandblockStreamResult.Loaded Result(LandblockBuild build) =>
@ -316,9 +521,11 @@ public sealed class LandblockConcretePresentationPipelineTests
private static WorldEntity Entity( private static WorldEntity Entity(
uint id, uint id,
uint sourceId = 0x01000001u) => new() uint sourceId = 0x01000001u,
uint serverGuid = 0u) => new()
{ {
Id = id, Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = sourceId, SourceGfxObjOrSetupId = sourceId,
Position = Vector3.Zero, Position = Vector3.Zero,
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
@ -336,9 +543,13 @@ public sealed class LandblockConcretePresentationPipelineTests
LandblockPhysicsPublisher Physics, LandblockPhysicsPublisher Physics,
LandblockStaticPresentationPublisher Static, LandblockStaticPresentationPublisher Static,
WorldEvents Events, WorldEvents Events,
LandblockRetirementCoordinator Retirements, LandblockPresentationRetirementOwner RetirementOwner,
PhysicsScriptRunner Runner, PhysicsScriptRunner Runner,
EntityEffectPoseRegistry Poses); EntityEffectPoseRegistry Poses,
LightManager Lights,
LightingHookSink Lighting,
TranslucencyFadeManager Translucency,
WorldGameState World);
private sealed class NullHookSink : IAnimationHookSink private sealed class NullHookSink : IAnimationHookSink
{ {
@ -355,9 +566,7 @@ public sealed class LandblockConcretePresentationPipelineTests
public void PinPreparedRenderData(ulong id) => calls.Add("pin"); public void PinPreparedRenderData(ulong id) => calls.Add("pin");
public void DecrementRefCount(ulong id) public void DecrementRefCount(ulong id) => calls.Add("unpin");
{
}
} }
private sealed class NullPoseSource : IEntityEffectPoseSource private sealed class NullPoseSource : IEntityEffectPoseSource

View file

@ -12,6 +12,63 @@ namespace AcDream.App.Tests.Streaming;
public sealed class LandblockPresentationPipelineTests public sealed class LandblockPresentationPipelineTests
{ {
[Fact]
public void ControllerRejectsPresentationPipelineFromForeignWorldState()
{
var controllerState = new GpuWorldState();
var foreignState = new GpuWorldState();
var foreignPipeline = new LandblockPresentationPipeline(
static (_, _) => { },
foreignState);
Assert.Throws<ArgumentException>(() => new StreamingController(
enqueueLoad: static (_, _, _) => { },
enqueueUnload: static (_, _) => { },
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
controllerState,
nearRadius: 0,
farRadius: 0,
presentationPipeline: foreignPipeline));
}
[Fact]
public void LegacyPipelineRejectsRetirementCoordinatorFromForeignWorldState()
{
var pipelineState = new GpuWorldState();
var foreignCoordinator = new LandblockRetirementCoordinator(
new GpuWorldState(),
static _ => { });
Assert.Throws<ArgumentException>(() =>
new LandblockPresentationPipeline(
static (_, _) => { },
pipelineState,
retirementCoordinator: foreignCoordinator));
}
[Fact]
public void ControllerConcreteConstructorHasNoLegacyPresentationParameters()
{
string[] legacyNames =
[
"applyTerrain",
"removeTerrain",
"demoteNearLayer",
"onLandblockLoaded",
"ensureEnvCellMeshes",
"retirementCoordinator",
];
System.Reflection.ConstructorInfo concrete = Assert.Single(
typeof(StreamingController).GetConstructors(),
constructor => constructor.GetParameters().Any(
parameter => parameter.Name == "presentationPipeline"));
string[] names = concrete.GetParameters()
.Select(parameter => parameter.Name!)
.ToArray();
Assert.DoesNotContain(names, legacyNames.Contains);
}
[Fact] [Fact]
public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder() public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder()
{ {

View file

@ -137,6 +137,203 @@ public sealed class LandblockRetirementCoordinatorTests
Assert.False(coordinator.IsPending(landblockId)); Assert.False(coordinator.IsPending(landblockId));
} }
[Fact]
public void ReentrantSameIdBegin_DoesNotReplaySuccessfulStage()
{
const uint landblockId = 0x4040FFFFu;
var state = StateWith(landblockId, Entity(1));
int lightingCalls = 0;
LandblockRetirementCoordinator? coordinator = null;
coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
lighting: _ =>
{
lightingCalls++;
coordinator!.BeginFull(landblockId);
}));
coordinator.BeginFull(landblockId);
Assert.Equal(1, lightingCalls);
Assert.Equal(0, coordinator.PendingCount);
Assert.False(coordinator.IsPending(landblockId));
}
[Fact]
public void ReentrantAdvance_RetriesFailedStageWithoutRecursion()
{
const uint landblockId = 0x4141FFFFu;
var state = StateWith(landblockId, Entity(1));
int lightingCalls = 0;
LandblockRetirementCoordinator? coordinator = null;
coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
lighting: _ =>
{
lightingCalls++;
coordinator!.Advance();
if (lightingCalls == 1)
throw new InvalidOperationException("injected first failure");
}));
coordinator.BeginFull(landblockId);
Assert.Equal(2, lightingCalls);
Assert.Equal(0, coordinator.PendingCount);
Assert.False(coordinator.IsPending(landblockId));
}
[Fact]
public void ReentrantDifferentIdBegin_IsDeferredUntilActiveAdvanceCompletes()
{
const uint firstId = 0x4242FFFFu;
const uint secondId = 0x4343FFFFu;
var state = StateWith(firstId);
state.AddLandblock(new LoadedLandblock(
secondId,
new LandBlock(),
Array.Empty<WorldEntity>()));
int firstAttempts = 0;
int secondAttempts = 0;
LandblockRetirementCoordinator? coordinator = null;
coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
terrain: () =>
{
if (ticket.LandblockId == firstId)
{
firstAttempts++;
if (firstAttempts == 1)
throw new InvalidOperationException("leave first pending");
coordinator!.BeginFull(secondId);
}
else
{
secondAttempts++;
}
}));
coordinator.BeginFull(firstId);
Assert.Equal(1, coordinator.PendingCount);
coordinator.Advance();
Assert.Equal(2, firstAttempts);
Assert.Equal(1, secondAttempts);
Assert.False(state.IsLoaded(secondId));
Assert.Equal(0, coordinator.PendingCount);
}
[Fact]
public void RepeatedBegin_RemovesTicketWhenRetryCompletes()
{
const uint landblockId = 0x4444FFFFu;
var state = StateWith(landblockId);
int terrainAttempts = 0;
int requiredStageCalls = 0;
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
terrain: () =>
{
terrainAttempts++;
if (terrainAttempts == 1)
throw new InvalidOperationException("injected first failure");
}),
_ =>
{
requiredStageCalls++;
return LandblockRetirementCoordinator.ProductionPresentationStages;
});
coordinator.BeginFull(landblockId);
Assert.Equal(1, coordinator.PendingCount);
coordinator.BeginFull(landblockId);
Assert.Equal(2, terrainAttempts);
Assert.Equal(1, requiredStageCalls);
Assert.Equal(0, coordinator.PendingCount);
Assert.False(coordinator.IsPending(landblockId));
}
[Fact]
public void VisibilityObserverFailure_CompletesReceiptThenRethrowsCommittedEdge()
{
const uint landblockId = 0x4545FFFFu;
WorldEntity live = Entity(1, serverGuid: 0x70000001u);
var state = StateWith(landblockId, live);
state.LiveProjectionVisibilityChanged += (_, _) =>
throw new InvalidOperationException("injected visibility failure");
int terrainCalls = 0;
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
terrain: () => terrainCalls++));
Assert.Throws<AggregateException>(() =>
coordinator.BeginFull(landblockId));
Assert.False(state.IsLoaded(landblockId));
Assert.Equal(1, terrainCalls);
Assert.Equal(0, coordinator.PendingCount);
Assert.False(coordinator.IsPending(landblockId));
}
[Fact]
public void RequiredStageFailure_HappensBeforeSpatialDetachment()
{
const uint landblockId = 0x4646FFFFu;
var state = StateWith(landblockId);
var coordinator = new LandblockRetirementCoordinator(
state,
static _ => { },
static _ => throw new InvalidOperationException(
"injected required-stage failure"));
Assert.Throws<InvalidOperationException>(() =>
coordinator.BeginFull(landblockId));
Assert.True(state.IsLoaded(landblockId));
Assert.Equal(0, coordinator.PendingCount);
}
[Fact]
public void ObserverFailure_DrainsReentrantDifferentIdBeginBeforeRethrow()
{
const uint firstId = 0x4747FFFFu;
const uint secondId = 0x4848FFFFu;
WorldEntity live = Entity(1, serverGuid: 0x70000002u);
var state = StateWith(firstId, live);
state.AddLandblock(new LoadedLandblock(
secondId,
new LandBlock(),
Array.Empty<WorldEntity>()));
LandblockRetirementCoordinator? coordinator = null;
coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(ticket));
state.LiveProjectionVisibilityChanged += (_, _) =>
{
coordinator.BeginFull(secondId);
throw new InvalidOperationException("injected visibility failure");
};
Assert.Throws<AggregateException>(() =>
coordinator.BeginFull(firstId));
Assert.False(state.IsLoaded(firstId));
Assert.False(state.IsLoaded(secondId));
Assert.Equal(0, coordinator.PendingCount);
}
private static StreamingController Controller( private static StreamingController Controller(
GpuWorldState state, GpuWorldState state,
LandblockRetirementCoordinator coordinator, LandblockRetirementCoordinator coordinator,
@ -187,15 +384,28 @@ public sealed class LandblockRetirementCoordinatorTests
ticket.RunOnce(LandblockRetirementStage.EnvironmentCells, static () => { }); ticket.RunOnce(LandblockRetirementStage.EnvironmentCells, static () => { });
} }
private static WorldEntity Entity(uint id) => new() private static WorldEntity Entity(uint id, uint serverGuid = 0u) => new()
{ {
Id = id, Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x01000000u + id, SourceGfxObjOrSetupId = 0x01000000u + id,
Position = Vector3.Zero, Position = Vector3.Zero,
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
private static GpuWorldState StateWith(
uint landblockId,
params WorldEntity[] entities)
{
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
entities));
return state;
}
private static LandblockMeshData EmptyMesh() => new( private static LandblockMeshData EmptyMesh() => new(
Array.Empty<TerrainVertex>(), Array.Empty<TerrainVertex>(),
Array.Empty<uint>()); Array.Empty<uint>());