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

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

View file

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

View file

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