acdream/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs
Erik ea7ffbc186 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>
2026-07-21 22:07:36 +02:00

589 lines
21 KiB
C#

using System.Collections.Immutable;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.App.Tests.Streaming;
public sealed class LandblockConcretePresentationPipelineTests
{
private const uint LandblockId = 0xA9B4FFFFu;
[Fact]
public void Loaded_ConcreteOwnersPreserveRetailPrefixAndSpatialOrder()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(
calls,
commitEnvCells: _ => calls.Add("envcell"));
fixture.Events.EntitySpawned += _ =>
{
Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
Assert.False(fixture.State.IsLoaded(LandblockId));
calls.Add("plugin");
};
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner,
onLandblockLoaded: _ => calls.Add("live-recovery"),
ensureEnvCellMeshes: null);
LandblockBuild build = Build(Entity(0x80A9B401u));
pipeline.PublishLoaded(Result(build));
Assert.Equal(
["terrain", "envcell", "plugin", "pin", "live-recovery"],
calls);
Assert.True(fixture.State.IsNearTier(LandblockId));
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
}
[Fact]
public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes()
{
var calls = new List<string>();
bool failEnvOnce = true;
ConcreteFixture fixture = Fixture(
calls,
commitEnvCells: _ =>
{
calls.Add("envcell");
if (failEnvOnce)
{
failEnvOnce = false;
throw new InvalidOperationException("injected envcell failure");
}
});
fixture.Events.EntitySpawned += _ => calls.Add("plugin");
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner);
LandblockBuild build = Build(Entity(0x80A9B401u));
LandblockStreamResult.Loaded result = Result(build);
Assert.Throws<InvalidOperationException>(() => pipeline.PublishLoaded(result));
Assert.False(fixture.State.IsLoaded(LandblockId));
Assert.True(pipeline.HasPendingPublication(result));
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(0, fixture.Physics.Diagnostics.CompleteCount);
Assert.Equal(0, fixture.Static.Diagnostics.BeginCount);
pipeline.ResumePublication(result);
Assert.Equal(1, calls.Count(call => call == "terrain"));
Assert.Equal(2, calls.Count(call => call == "envcell"));
Assert.Equal(1, calls.Count(call => call == "plugin"));
Assert.Equal(1, calls.Count(call => call == "pin"));
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
Assert.False(pipeline.HasPendingPublication(result));
Assert.True(fixture.State.IsLoaded(LandblockId));
}
[Fact]
public void CrossOwnerValidationFailure_BlocksBeforeAnyPresentationMutation()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(calls);
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner);
WorldEntity first = Entity(0x80A9B401u);
LandblockBuild build = Build(first, Entity(first.Id));
LandblockStreamResult.Loaded result = Result(build);
Assert.Throws<InvalidOperationException>(() => pipeline.PublishLoaded(result));
Assert.Empty(calls);
Assert.False(fixture.State.IsLoaded(LandblockId));
Assert.Equal(0, fixture.Engine.LandblockCount);
Assert.Equal(0, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(0, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(0, fixture.Static.Diagnostics.BeginCount);
Assert.True(pipeline.HasPendingPublication(result));
Assert.Equal(1, pipeline.PendingPublicationCount);
}
[Fact]
public void RetainedIdSourceChange_BlocksBeforeReplacementMutation()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(calls);
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner);
WorldEntity first = Entity(0x80A9B401u);
pipeline.PublishLoaded(Result(Build(first)));
long renderBegins = fixture.Render.Diagnostics.BeginCount;
long physicsBegins = fixture.Physics.Diagnostics.BeginCount;
long staticBegins = fixture.Static.Diagnostics.BeginCount;
calls.Clear();
LandblockStreamResult.Loaded changed = Result(Build(
Entity(first.Id, sourceId: 0x01000002u)));
Assert.Throws<InvalidOperationException>(() =>
pipeline.PublishLoaded(changed));
Assert.Empty(calls);
Assert.Equal(renderBegins, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(physicsBegins, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(staticBegins, fixture.Static.Diagnostics.BeginCount);
Assert.True(pipeline.HasPendingPublication(changed));
}
[Fact]
public void ConcreteConstructor_RequiresMatchingRetirementOwner()
{
ConcreteFixture fixture = Fixture(new List<string>());
Assert.Throws<ArgumentNullException>(() =>
new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
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]
public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay()
{
ConcreteFixture fixture = Fixture(new List<string>());
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner);
WorldEntity first = Entity(0x80A9B401u);
WorldEntity replacement = Entity(first.Id);
pipeline.PublishLoaded(Result(Build(first)));
Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
pipeline.PublishLoaded(Result(Build(replacement)));
Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
Assert.True(fixture.Poses.TryGetRootPose(first.Id, out _));
}
[Fact]
public void OmittedStaticReapply_RealActivatorBalancesScriptAndPoseOwners()
{
ConcreteFixture fixture = Fixture(new List<string>());
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner);
WorldEntity entity = Entity(0x80A9B401u);
pipeline.PublishLoaded(Result(Build(entity)));
Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
Assert.True(fixture.Poses.TryGetRootPose(entity.Id, out _));
pipeline.PublishLoaded(Result(Build()));
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
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<uint>? removeTerrain = null,
Action<uint>? removeEnvCells = null)
{
var poses = new EntityEffectPoseRegistry();
var particleSink = new ParticleHookSink(
new ParticleSystem(new EmitterDescRegistry()),
poses);
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 10.0,
Hook = new CreateParticleHook { EmitterInfoId = 100u },
});
var runner = new PhysicsScriptRunner(
id => id == 0xAAu ? script : null,
new NullHookSink());
var activator = new EntityScriptActivator(
runner,
particleSink,
poses,
_ => new ScriptActivationInfo(0xAAu, Array.Empty<Matrix4x4>()));
var state = new GpuWorldState(
new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)),
entityScriptActivator: activator);
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var render = new LandblockRenderPublisher(
(_, _, _) => calls.Add("terrain"),
removeTerrain ?? (_ => calls.Add("terrain-remove")),
new CellVisibility(),
state,
commitEnvCells: commitEnvCells,
removeEnvCells: removeEnvCells ?? (_ => calls.Add("envcell-remove")));
var physics = new LandblockPhysicsPublisher(engine, new float[256]);
var lights = new LightManager();
var lighting = new LightingHookSink(
lights,
new NullPoseSource());
var translucency = new TranslucencyFadeManager();
var world = new WorldGameState();
var events = new WorldEvents();
var staticPresentation = new LandblockStaticPresentationPublisher(
lighting,
translucency,
world,
events);
var retirementOwner = new LandblockPresentationRetirementOwner(
render,
physics,
staticPresentation,
lighting,
translucency);
return new ConcreteFixture(
state,
engine,
render,
physics,
staticPresentation,
events,
retirementOwner,
runner,
poses,
lights,
lighting,
translucency,
world);
}
private static LandblockStreamResult.Loaded Result(LandblockBuild build) =>
new(
LandblockId,
LandblockStreamTier.Near,
build,
EmptyMesh());
private static LandblockBuild Build(params WorldEntity[] entities)
{
uint cellId = (LandblockId & 0xFFFF0000u) | 0x0100u;
var shell = new EnvCellShellPlacement(
cellId,
0x2_0000_0001UL,
0x0D000001u,
1,
ImmutableArray<ushort>.Empty,
Vector3.Zero,
Quaternion.Identity,
Matrix4x4.Identity,
new WbBoundingBox(Vector3.Zero, Vector3.One),
new WbBoundingBox(Vector3.Zero, Vector3.One));
var envCells = new EnvCellLandblockBuild(
LandblockId,
Array.Empty<LoadedCell>(),
[shell]);
return new LandblockBuild(
new LoadedLandblock(
LandblockId,
new LandBlock
{
Terrain = new TerrainInfo[81],
Height = new byte[81],
},
entities,
PhysicsDatBundle.Empty),
envCells,
new LandblockBuildOrigin(0xA9, 0xB4));
}
private static WorldEntity Entity(
uint id,
uint sourceId = 0x01000001u,
uint serverGuid = 0u) => new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = sourceId,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static LandblockMeshData EmptyMesh() => new(
Array.Empty<TerrainVertex>(),
Array.Empty<uint>());
private sealed record ConcreteFixture(
GpuWorldState State,
PhysicsEngine Engine,
LandblockRenderPublisher Render,
LandblockPhysicsPublisher Physics,
LandblockStaticPresentationPublisher Static,
WorldEvents Events,
LandblockPresentationRetirementOwner RetirementOwner,
PhysicsScriptRunner Runner,
EntityEffectPoseRegistry Poses,
LightManager Lights,
LightingHookSink Lighting,
TranslucencyFadeManager Translucency,
WorldGameState World);
private sealed class NullHookSink : IAnimationHookSink
{
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook)
{
}
}
private sealed class RecordingMeshAdapter(List<string> calls) : IWbMeshAdapter
{
public void IncrementRefCount(ulong id)
{
}
public void PinPreparedRenderData(ulong id) => calls.Add("pin");
public void DecrementRefCount(ulong id) => calls.Add("unpin");
}
private sealed class NullPoseSource : IEntityEffectPoseSource
{
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
rootWorld = default;
return false;
}
public bool TryGetPartPose(
uint localEntityId,
int partIndex,
out Matrix4x4 partLocal)
{
partLocal = default;
return false;
}
}
}