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(); 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(); 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(() => 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(); 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(() => 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(); 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(() => 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()); Assert.Throws(() => 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(() => new LandblockPresentationPipeline( fixture.Render, fixture.Physics, fixture.Static, fixture.State, foreignOwner)); Assert.Throws(() => new LandblockPresentationRetirementOwner( fixture.Render, fixture.Physics, fixture.Static, new LightingHookSink( new LightManager(), new NullPoseSource()), fixture.Translucency)); Assert.Throws(() => new LandblockPresentationRetirementOwner( fixture.Render, fixture.Physics, fixture.Static, fixture.Lighting, new TranslucencyFadeManager())); Assert.Throws(() => new LandblockPresentationPipeline( fixture.Render, fixture.Physics, fixture.Static, new GpuWorldState(), fixture.RetirementOwner)); } [Fact] public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay() { ConcreteFixture fixture = Fixture(new List()); 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()); 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(); 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(); 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(); 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 calls, Action? commitEnvCells = null, Action? removeTerrain = null, Action? 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())); 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.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(), [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(), }; private static LandblockMeshData EmptyMesh() => new( Array.Empty(), Array.Empty()); 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 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; } } }