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:
parent
dc39772bf9
commit
ea7ffbc186
10 changed files with 860 additions and 140 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue