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
|
|
@ -40,8 +40,9 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
fixture.Physics,
|
||||
fixture.Static,
|
||||
fixture.State,
|
||||
fixture.RetirementOwner,
|
||||
onLandblockLoaded: _ => calls.Add("live-recovery"),
|
||||
retirementCoordinator: fixture.Retirements);
|
||||
ensureEnvCellMeshes: null);
|
||||
LandblockBuild build = Build(Entity(0x80A9B401u));
|
||||
|
||||
pipeline.PublishLoaded(Result(build));
|
||||
|
|
@ -77,7 +78,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
fixture.Physics,
|
||||
fixture.Static,
|
||||
fixture.State,
|
||||
retirementCoordinator: fixture.Retirements);
|
||||
fixture.RetirementOwner);
|
||||
LandblockBuild build = Build(Entity(0x80A9B401u));
|
||||
LandblockStreamResult.Loaded result = Result(build);
|
||||
|
||||
|
|
@ -113,7 +114,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
fixture.Physics,
|
||||
fixture.Static,
|
||||
fixture.State,
|
||||
retirementCoordinator: fixture.Retirements);
|
||||
fixture.RetirementOwner);
|
||||
WorldEntity first = Entity(0x80A9B401u);
|
||||
LandblockBuild build = Build(first, Entity(first.Id));
|
||||
LandblockStreamResult.Loaded result = Result(build);
|
||||
|
|
@ -140,7 +141,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
fixture.Physics,
|
||||
fixture.Static,
|
||||
fixture.State,
|
||||
retirementCoordinator: fixture.Retirements);
|
||||
fixture.RetirementOwner);
|
||||
WorldEntity first = Entity(0x80A9B401u);
|
||||
pipeline.PublishLoaded(Result(Build(first)));
|
||||
long renderBegins = fixture.Render.Diagnostics.BeginCount;
|
||||
|
|
@ -161,7 +162,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcreteConstructor_RequiresSharedRetirementCoordinator()
|
||||
public void ConcreteConstructor_RequiresMatchingRetirementOwner()
|
||||
{
|
||||
ConcreteFixture fixture = Fixture(new List<string>());
|
||||
|
||||
|
|
@ -171,7 +172,51 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
fixture.Physics,
|
||||
fixture.Static,
|
||||
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]
|
||||
|
|
@ -183,7 +228,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
fixture.Physics,
|
||||
fixture.Static,
|
||||
fixture.State,
|
||||
retirementCoordinator: fixture.Retirements);
|
||||
fixture.RetirementOwner);
|
||||
WorldEntity first = Entity(0x80A9B401u);
|
||||
WorldEntity replacement = Entity(first.Id);
|
||||
|
||||
|
|
@ -205,7 +250,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
fixture.Physics,
|
||||
fixture.Static,
|
||||
fixture.State,
|
||||
retirementCoordinator: fixture.Retirements);
|
||||
fixture.RetirementOwner);
|
||||
WorldEntity entity = Entity(0x80A9B401u);
|
||||
pipeline.PublishLoaded(Result(Build(entity)));
|
||||
Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
|
||||
|
|
@ -217,9 +262,158 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
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(
|
||||
List<string> calls,
|
||||
Action<EnvCellLandblockBuild>? commitEnvCells = null)
|
||||
Action<EnvCellLandblockBuild>? commitEnvCells = null,
|
||||
Action<uint>? removeTerrain = null,
|
||||
Action<uint>? removeEnvCells = null)
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
var particleSink = new ParticleHookSink(
|
||||
|
|
@ -246,33 +440,44 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
var render = new LandblockRenderPublisher(
|
||||
(_, _, _) => calls.Add("terrain"),
|
||||
static _ => { },
|
||||
removeTerrain ?? (_ => calls.Add("terrain-remove")),
|
||||
new CellVisibility(),
|
||||
state,
|
||||
commitEnvCells: commitEnvCells);
|
||||
commitEnvCells: commitEnvCells,
|
||||
removeEnvCells: removeEnvCells ?? (_ => calls.Add("envcell-remove")));
|
||||
var physics = new LandblockPhysicsPublisher(engine, new float[256]);
|
||||
var lights = new LightManager();
|
||||
var lighting = new LightingHookSink(
|
||||
new LightManager(),
|
||||
lights,
|
||||
new NullPoseSource());
|
||||
var translucency = new TranslucencyFadeManager();
|
||||
var world = new WorldGameState();
|
||||
var events = new WorldEvents();
|
||||
var retirements = new LandblockRetirementCoordinator(
|
||||
state,
|
||||
static _ => { },
|
||||
static _ => LandblockRetirementStage.None);
|
||||
var staticPresentation = new LandblockStaticPresentationPublisher(
|
||||
lighting,
|
||||
translucency,
|
||||
world,
|
||||
events);
|
||||
var retirementOwner = new LandblockPresentationRetirementOwner(
|
||||
render,
|
||||
physics,
|
||||
staticPresentation,
|
||||
lighting,
|
||||
translucency);
|
||||
return new ConcreteFixture(
|
||||
state,
|
||||
engine,
|
||||
render,
|
||||
physics,
|
||||
new LandblockStaticPresentationPublisher(
|
||||
lighting,
|
||||
new TranslucencyFadeManager(),
|
||||
new WorldGameState(),
|
||||
events),
|
||||
staticPresentation,
|
||||
events,
|
||||
retirements,
|
||||
retirementOwner,
|
||||
runner,
|
||||
poses);
|
||||
poses,
|
||||
lights,
|
||||
lighting,
|
||||
translucency,
|
||||
world);
|
||||
}
|
||||
|
||||
private static LandblockStreamResult.Loaded Result(LandblockBuild build) =>
|
||||
|
|
@ -316,9 +521,11 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
|
||||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
uint sourceId = 0x01000001u) => new()
|
||||
uint sourceId = 0x01000001u,
|
||||
uint serverGuid = 0u) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = sourceId,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
|
|
@ -336,9 +543,13 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
LandblockPhysicsPublisher Physics,
|
||||
LandblockStaticPresentationPublisher Static,
|
||||
WorldEvents Events,
|
||||
LandblockRetirementCoordinator Retirements,
|
||||
LandblockPresentationRetirementOwner RetirementOwner,
|
||||
PhysicsScriptRunner Runner,
|
||||
EntityEffectPoseRegistry Poses);
|
||||
EntityEffectPoseRegistry Poses,
|
||||
LightManager Lights,
|
||||
LightingHookSink Lighting,
|
||||
TranslucencyFadeManager Translucency,
|
||||
WorldGameState World);
|
||||
|
||||
private sealed class NullHookSink : IAnimationHookSink
|
||||
{
|
||||
|
|
@ -355,9 +566,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,63 @@ namespace AcDream.App.Tests.Streaming;
|
|||
|
||||
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]
|
||||
public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -137,6 +137,203 @@ public sealed class LandblockRetirementCoordinatorTests
|
|||
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(
|
||||
GpuWorldState state,
|
||||
LandblockRetirementCoordinator coordinator,
|
||||
|
|
@ -187,15 +384,28 @@ public sealed class LandblockRetirementCoordinatorTests
|
|||
ticket.RunOnce(LandblockRetirementStage.EnvironmentCells, static () => { });
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint id) => new()
|
||||
private static WorldEntity Entity(uint id, uint serverGuid = 0u) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x01000000u + id,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
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(
|
||||
Array.Empty<TerrainVertex>(),
|
||||
Array.Empty<uint>());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue