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, onLandblockLoaded: _ => calls.Add("live-recovery"), retirementCoordinator: fixture.Retirements); 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, retirementCoordinator: fixture.Retirements); 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, retirementCoordinator: fixture.Retirements); 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, retirementCoordinator: fixture.Retirements); 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_RequiresSharedRetirementCoordinator() { ConcreteFixture fixture = Fixture(new List()); Assert.Throws(() => new LandblockPresentationPipeline( fixture.Render, fixture.Physics, fixture.Static, fixture.State, retirementCoordinator: null)); } [Fact] public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay() { ConcreteFixture fixture = Fixture(new List()); var pipeline = new LandblockPresentationPipeline( fixture.Render, fixture.Physics, fixture.Static, fixture.State, retirementCoordinator: fixture.Retirements); 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, retirementCoordinator: fixture.Retirements); 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 _)); } private static ConcreteFixture Fixture( List calls, Action? commitEnvCells = 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"), static _ => { }, new CellVisibility(), state, commitEnvCells: commitEnvCells); var physics = new LandblockPhysicsPublisher(engine, new float[256]); var lighting = new LightingHookSink( new LightManager(), new NullPoseSource()); var events = new WorldEvents(); var retirements = new LandblockRetirementCoordinator( state, static _ => { }, static _ => LandblockRetirementStage.None); return new ConcreteFixture( state, engine, render, physics, new LandblockStaticPresentationPublisher( lighting, new TranslucencyFadeManager(), new WorldGameState(), events), events, retirements, runner, poses); } 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) => new() { Id = id, 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, LandblockRetirementCoordinator Retirements, PhysicsScriptRunner Runner, EntityEffectPoseRegistry Poses); 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) { } } 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; } } }