acdream/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs
Erik 823936ec31 fix(streaming): preserve portal destination ownership
Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
2026-07-25 08:35:12 +02:00

657 lines
24 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 MeteredLoaded_RetainsExactHeadAndPublishesSpatiallyOnlyAfterOwnerSuffix()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(
calls,
commitEnvCells: _ => calls.Add("envcell"));
fixture.Events.EntitySpawned += _ => calls.Add("plugin");
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner,
onLandblockLoaded: _ => calls.Add("live-recovery"));
LandblockStreamResult.Loaded result =
Result(Build(Entity(0x80A9B401u), Entity(0x80A9B402u)));
LandblockStreamCostEstimate estimate =
LandblockStreamResultCost.Estimate(result);
var budget = new StreamingWorkBudget(
TimeSpan.FromSeconds(1),
maxCompletionAdmissions: 64,
maxAdoptedCpuBytes: 1_000_000,
maxEntityOperations: 1,
maxGpuUploadBytes: 1_000_000,
maxGlRetireOperations: 64,
destinationReserveFraction: 0.75f);
LandblockPublicationAdvance advance = default;
for (int frame = 0; frame < 64; frame++)
{
var meter = new StreamingWorkMeter(budget);
advance = frame == 0
? pipeline.PublishLoaded(
result,
estimate,
meter,
ensureProgress: false)
: pipeline.ResumePublication(
result,
meter,
ensureProgress: false);
meter.FinishFrame();
Assert.True(
meter.Snapshot.Used.EntityOperations <= 1,
$"stage={meter.Snapshot.LastStage}, " +
$"entities={meter.Snapshot.Used.EntityOperations}, " +
$"oversized={meter.Snapshot.OversizedProgressCount}");
if (fixture.Static.Diagnostics.CompleteCount == 0)
Assert.False(fixture.State.IsLoaded(LandblockId));
if (advance.Completed)
break;
}
Assert.True(advance.Completed);
Assert.False(pipeline.HasPendingPublication(result));
Assert.True(fixture.State.IsNearTier(LandblockId));
Assert.Equal(
["terrain", "envcell", "plugin", "plugin", "pin", "live-recovery"],
calls);
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);
}
[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_DetachesAndCleansStaticsWithoutMutatingLiveOwners()
{
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.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(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;
}
}
}