using System.Numerics; using System.Reflection; using AcDream.App.Streaming; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.App.Tests.Streaming; public sealed class LandblockPhysicsPublisherTests { private const uint FirstLandblock = 0xA9B4FFFFu; private const uint AdjacentLandblock = 0xAAB4FFFFu; private const uint SetupId = 0x02000042u; private const uint SecondSetupId = 0x02000043u; private static readonly float[] HeightTable = Enumerable.Range(0, 256).Select(index => (float)index).ToArray(); [Fact] public void Constructor_ClonesHeightTableAndRejectsIncompleteInput() { Assert.Throws(() => new LandblockPhysicsPublisher( new PhysicsEngine(), new float[255])); Assert.Throws(() => new LandblockPhysicsPublisher( new PhysicsEngine(), HeightTable)); float[] mutable = HeightTable.ToArray(); var engine = new PhysicsEngine(); var cache = new PhysicsDataCache(); engine.DataCache = cache; var publisher = new LandblockPhysicsPublisher(engine, mutable); mutable[0] = 999f; LandblockPhysicsPublication receipt = Begin( publisher, Build(FirstLandblock)); publisher.CompletePublication(receipt); Assert.Equal(1, engine.LandblockCount); } [Fact] public void FarPublication_IsTerrainOnlyAndDemotionPreservesIt() { var fixture = Fixture(); LandblockPhysicsPublication receipt = Begin( fixture.Publisher, Build(FirstLandblock, bundle: PhysicsDatBundle.Empty)); fixture.Publisher.CompletePublication(receipt); Assert.Equal(1, fixture.Engine.LandblockCount); Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); fixture.Publisher.DemoteToTerrain(FirstLandblock); Assert.Equal(1, fixture.Engine.LandblockCount); Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); Assert.Equal(1, fixture.Publisher.Diagnostics.DemotionCount); fixture.Publisher.RemoveLandblock(FirstLandblock); Assert.Equal(0, fixture.Engine.LandblockCount); Assert.False(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); Assert.Equal(1, fixture.Publisher.Diagnostics.FullRemovalCount); } [Fact] public void BeginPublication_CommitsCellPortalAndBuildingPhysicsFromOneBundle() { var fixture = Fixture(); const uint envCellId = 0xA9B40100u; PhysicsDatBundle bundle = CellPortalAndBuildingBundle(envCellId); LandblockPhysicsPublication receipt = Begin( fixture.Publisher, Build( FirstLandblock, bundle: bundle, origin: new LandblockBuildOrigin(0xA8, 0xB4))); Assert.Equal(new Vector3(192f, 0f, 0f), receipt.Origin); LandblockPhysicsPublisherDiagnostics diagnostics = fixture.Publisher.Diagnostics; Assert.Equal(1, diagnostics.CellSurfaceCount); Assert.Equal(1, diagnostics.PortalPlaneCount); Assert.Equal(1, diagnostics.BuildingCount); Assert.NotNull(fixture.Cache.CellGraph.GetVisible(envCellId)); BuildingPhysics building = Assert.Single( fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!)); Assert.Equal(new Vector3(204f, 12f, 0f), building.WorldTransform.Translation); Assert.Equal(0x01000077u, building.ModelId); } [Fact] public void CompletePublication_PhysicsBspSuppressesSetupCylinderFallback() { const uint gfxObjId = 0x01000077u; var fixture = Fixture(); CacheCylinderSetup(fixture.Cache); fixture.Cache.RegisterGfxObjForTest(gfxObjId, BspGfx(radius: 1.25f)); WorldEntity entity = new() { Id = 0x80A9B401u, SourceGfxObjOrSetupId = SetupId, Position = new Vector3(12f, 12f, 0f), Rotation = Quaternion.Identity, MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)], }; Publish(fixture.Publisher, Build(FirstLandblock, [entity])); ShadowEntry entry = Assert.Single( fixture.Engine.ShadowObjects.AllEntriesForDebug()); Assert.Equal(ShadowCollisionType.BSP, entry.CollisionType); Assert.Equal(1, fixture.Publisher.Diagnostics.StaticBspOwnerCount); Assert.Equal(0, fixture.Publisher.Diagnostics.StaticCylinderOwnerCount); } [Fact] public void CompletePublication_BuildingShellNeverRegistersSetupCollision() { var fixture = Fixture(); CacheCylinderSetup(fixture.Cache); WorldEntity shell = new() { Id = 0xC0A9B401u, SourceGfxObjOrSetupId = SetupId, Position = new Vector3(12f, 12f, 0f), Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), IsBuildingShell = true, }; Publish( fixture.Publisher, Build( FirstLandblock, [shell], CellPortalAndBuildingBundle(0xA9B40100u))); Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); Assert.Empty(fixture.Engine.ShadowObjects.AllEntriesForDebug()); Assert.Single(fixture.Cache.BuildingIds); } [Fact] public void CompletePublication_MultipleSetupShapesShareOneLogicalOwner() { var fixture = Fixture(); CacheCylinderSetup(fixture.Cache, SetupId, shapeCount: 5); CacheCylinderSetup(fixture.Cache, SecondSetupId); const uint firstId = 0x80A9B401u; const uint collidingLegacyId = 0xC0A9B401u; WorldEntity first = CylinderEntity( firstId, new Vector3(12f, 12f, 0f)); WorldEntity second = CylinderEntity( collidingLegacyId, new Vector3(36f, 12f, 0f), SecondSetupId); Publish( fixture.Publisher, Build(FirstLandblock, [first, second])); Assert.Equal(2, fixture.Engine.ShadowObjects.RetainedRegistrationCount); ShadowEntry[] entries = fixture.Engine.ShadowObjects .AllEntriesForDebug() .ToArray(); Assert.Equal(6, entries.Length); Assert.Equal( [firstId, collidingLegacyId], entries.Select(entry => entry.EntityId).Distinct().Order().ToArray()); } [Fact] public void CompletePublication_RunsPerEntityCallbackBeforeCollisionAndIsIdempotent() { var fixture = Fixture(); WorldEntity entity = CylinderEntity( 0x80A9B401u, new Vector3(12f, 12f, 0f)); CacheCylinderSetup(fixture.Cache); LandblockPhysicsPublication receipt = Begin( fixture.Publisher, Build(FirstLandblock, [entity])); int callbackCount = 0; fixture.Publisher.CompletePublication(receipt, observed => { Assert.Same(entity, observed); Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); callbackCount++; }); fixture.Publisher.CompletePublication(receipt, _ => callbackCount++); Assert.Equal(1, callbackCount); Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); ShadowEntry entry = Assert.Single( fixture.Engine.ShadowObjects.AllEntriesForDebug()); Assert.Equal(entity.Id, entry.EntityId); Assert.Equal(ShadowCollisionType.Cylinder, entry.CollisionType); Assert.Equal(1, fixture.Publisher.Diagnostics.CompleteCount); Assert.Equal(1, fixture.Publisher.Diagnostics.RefloodCount); } [Fact] public void CompletePublication_CallbackFailureCanRetryWithoutStackingCollision() { var fixture = Fixture(); WorldEntity entity = CylinderEntity( 0x80A9B401u, new Vector3(12f, 12f, 0f)); CacheCylinderSetup(fixture.Cache); LandblockPhysicsPublication receipt = Begin( fixture.Publisher, Build(FirstLandblock, [entity])); int attempts = 0; Assert.Throws(() => fixture.Publisher.CompletePublication(receipt, _ => { attempts++; throw new InvalidOperationException("injected static presentation failure"); })); fixture.Publisher.CompletePublication(receipt, _ => attempts++); Assert.Equal(2, attempts); Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); Assert.Single(fixture.Engine.ShadowObjects.AllEntriesForDebug()); Assert.Equal(1, fixture.Publisher.Diagnostics.CompleteCount); } [Fact] public void NearReapply_ReplacesSameStaticOwnerWithoutDuplication() { var fixture = Fixture(); WorldEntity first = CylinderEntity( 0x80A9B401u, new Vector3(12f, 12f, 0f)); WorldEntity moved = CylinderEntity( first.Id, new Vector3(36f, 12f, 0f)); CacheCylinderSetup(fixture.Cache); LandblockPhysicsPublication firstReceipt = Begin( fixture.Publisher, Build(FirstLandblock, [first])); fixture.Publisher.CompletePublication(firstReceipt); LandblockPhysicsPublication secondReceipt = Begin( fixture.Publisher, Build(FirstLandblock, [moved])); fixture.Publisher.CompletePublication(secondReceipt); Assert.Equal(1, fixture.Engine.LandblockCount); Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); ShadowEntry entry = Assert.Single( fixture.Engine.ShadowObjects.AllEntriesForDebug()); Assert.Equal(moved.Position + new Vector3(0f, 0f, 0.6f), entry.Position); Assert.Equal(2, fixture.Publisher.Diagnostics.BeginCount); Assert.Equal(2, fixture.Publisher.Diagnostics.CompleteCount); } [Fact] public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner() { var fixture = Fixture(); CacheCylinderSetup(fixture.Cache); WorldEntity omitted = CylinderEntity( 0x80A9B401u, new Vector3(191.5f, 12f, 0f)); WorldEntity neighbor = CylinderEntity( 0x80AAB401u, new Vector3(192.5f, 12f, 0f)); Publish( fixture.Publisher, Build(FirstLandblock, [omitted])); Publish( fixture.Publisher, Build(AdjacentLandblock, [neighbor])); Publish( fixture.Publisher, Build(FirstLandblock, Array.Empty())); Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); ShadowEntry survivor = Assert.Single( fixture.Engine.ShadowObjects.AllEntriesForDebug()); Assert.Equal(neighbor.Id, survivor.EntityId); Assert.Contains( fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u), entry => entry.EntityId == neighbor.Id); Assert.DoesNotContain( fixture.Engine.ShadowObjects.AllEntriesForDebug(), entry => entry.EntityId == omitted.Id); } [Fact] public void NearReapply_RebasesCellAndBuildingAndRemovesMissingSnapshotData() { const uint envCellId = 0xA9B40100u; var fixture = Fixture(); PhysicsDatBundle populated = CellPortalAndBuildingBundle(envCellId); Publish( fixture.Publisher, Build(FirstLandblock, bundle: populated)); fixture.Engine.UpdatePlayerCurrCell(envCellId); AcDream.Core.World.Cells.ObjCell originalCurrent = fixture.Cache.CellGraph.CurrCell!; Assert.Equal( Vector3.Zero, fixture.Cache.CellGraph.GetVisible(envCellId)!.WorldTransform.Translation); Assert.Equal( new Vector3(12f, 12f, 0f), Assert.Single(fixture.Cache.BuildingIds .Select(id => fixture.Cache.GetBuilding(id)!)) .WorldTransform.Translation); Publish( fixture.Publisher, Build( FirstLandblock, bundle: populated, origin: new LandblockBuildOrigin(0xA8, 0xB4))); Assert.Equal( new Vector3(192f, 0f, 0f), fixture.Cache.CellGraph.GetVisible(envCellId)!.WorldTransform.Translation); Assert.NotSame(originalCurrent, fixture.Cache.CellGraph.CurrCell); Assert.Same( fixture.Cache.CellGraph.GetVisible(envCellId), fixture.Cache.CellGraph.CurrCell); Assert.Equal( new Vector3(204f, 12f, 0f), Assert.Single(fixture.Cache.BuildingIds .Select(id => fixture.Cache.GetBuilding(id)!)) .WorldTransform.Translation); Publish( fixture.Publisher, Build(FirstLandblock, bundle: PhysicsDatBundle.Empty)); Assert.Null(fixture.Cache.CellGraph.GetVisible(envCellId)); Assert.Null(fixture.Cache.CellGraph.CurrCell); Assert.Empty(fixture.Cache.BuildingIds); } [Fact] public void NearReapply_DoesNotRetireAdjacentCellOrBuildingCache() { const uint firstCellId = 0xA9B40100u; const uint adjacentCellId = 0xAAB40100u; var fixture = Fixture(); Publish( fixture.Publisher, Build( FirstLandblock, bundle: CellPortalAndBuildingBundle(firstCellId))); Publish( fixture.Publisher, Build( AdjacentLandblock, bundle: CellPortalAndBuildingBundle(adjacentCellId))); Publish( fixture.Publisher, Build(FirstLandblock, bundle: PhysicsDatBundle.Empty)); Assert.Null(fixture.Cache.CellGraph.GetVisible(firstCellId)); Assert.NotNull(fixture.Cache.CellGraph.GetVisible(adjacentCellId)); Assert.DoesNotContain( fixture.Cache.BuildingIds, id => (id & 0xFFFF0000u) == 0xA9B40000u); Assert.Contains( fixture.Cache.BuildingIds, id => (id & 0xFFFF0000u) == 0xAAB40000u); } [Fact] public void AdjacentLandblockDemotion_DoesNotEraseNeighborStaticOwner() { var fixture = Fixture(); CacheCylinderSetup(fixture.Cache); WorldEntity first = CylinderEntity( 0x80A9B401u, new Vector3(191.5f, 12f, 0f)); WorldEntity neighbor = CylinderEntity( 0x80AAB401u, new Vector3(192.5f, 12f, 0f)); Publish( fixture.Publisher, Build(FirstLandblock, [first])); Publish( fixture.Publisher, Build(AdjacentLandblock, [neighbor])); Assert.Equal(2, fixture.Engine.ShadowObjects.RetainedRegistrationCount); Assert.Contains( fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u), entry => entry.EntityId == neighbor.Id); fixture.Publisher.DemoteToTerrain(FirstLandblock); Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); ShadowEntry surviving = Assert.Single( fixture.Engine.ShadowObjects.AllEntriesForDebug()); Assert.Equal(neighbor.Id, surviving.EntityId); Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); Assert.True(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock)); } [Fact] public void FullRemoval_DoesNotEraseAdjacentLandblockCollision() { var fixture = Fixture(); CacheCylinderSetup(fixture.Cache); WorldEntity first = CylinderEntity( 0x80A9B401u, new Vector3(191.5f, 12f, 0f)); WorldEntity neighbor = CylinderEntity( 0x80AAB401u, new Vector3(192.5f, 12f, 0f)); Publish( fixture.Publisher, Build( FirstLandblock, [first], CellPortalAndBuildingBundle(0xA9B40100u))); Publish( fixture.Publisher, Build( AdjacentLandblock, [neighbor], CellPortalAndBuildingBundle(0xAAB40100u))); fixture.Publisher.RemoveLandblock(FirstLandblock); Assert.Equal(1, fixture.Engine.LandblockCount); Assert.False(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); Assert.True(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock)); Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); Assert.Equal( neighbor.Id, Assert.Single(fixture.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); uint survivingBuilding = Assert.Single(fixture.Cache.BuildingIds); Assert.Equal(0xAAB40000u, survivingBuilding & 0xFFFF0000u); } [Fact] public void CompletePublication_ForeignReceiptIsRejectedWithoutConsumingIt() { var first = Fixture(); var second = Fixture(); LandblockPhysicsPublication receipt = Begin( first.Publisher, Build(FirstLandblock)); Assert.Throws(() => second.Publisher.CompletePublication(receipt)); Assert.Equal(0, second.Publisher.Diagnostics.CompleteCount); first.Publisher.CompletePublication(receipt); Assert.Equal(1, first.Publisher.Diagnostics.CompleteCount); } [Fact] public void PublisherSurface_HasNoDatReaderResidencyOrLiveOriginDependency() { Type type = typeof(LandblockPhysicsPublisher); Type[] dependencies = type .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) .Select(field => field.FieldType) .Concat(type.GetConstructors().SelectMany(constructor => constructor.GetParameters().Select(parameter => parameter.ParameterType))) .ToArray(); Assert.DoesNotContain(dependencies, dependency => dependency.FullName?.Contains("DatReader", StringComparison.Ordinal) == true); Assert.DoesNotContain(dependencies, dependency => dependency.FullName?.Contains("StreamingRegion", StringComparison.Ordinal) == true); Assert.DoesNotContain(dependencies, dependency => dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); MethodInfo begin = Assert.Single( type.GetMethods(), method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication) && method.ReturnType == typeof(LandblockPhysicsPublication)); ParameterInfo parameter = Assert.Single(begin.GetParameters()); Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType); Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor => constructor.GetParameters()), parameterInfo => parameterInfo.ParameterType == typeof(PhysicsDataCache)); } [Fact] public void GameWindow_HasNoLandblockPhysicsPublicationBodies() { string source = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs")); Assert.DoesNotContain("_physicsDataCache.CacheCellStruct", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsDataCache.CacheBuilding", source, StringComparison.Ordinal); Assert.DoesNotContain("ShadowShapeBuilder.FromLandblockBspParts", source, StringComparison.Ordinal); Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.RemoveLandblock", source, StringComparison.Ordinal); } private static void Publish( LandblockPhysicsPublisher publisher, LandblockBuild build) { LandblockPhysicsPublication receipt = Begin(publisher, build); publisher.CompletePublication(receipt); } private static LandblockPhysicsPublication Begin( LandblockPhysicsPublisher publisher, LandblockBuild build) => publisher.BeginPublication(RenderReceipt(build)); private static LandblockRenderPublication RenderReceipt(LandblockBuild build) { var publisher = new LandblockRenderPublisher( publishTerrain: (_, _, _) => { }, removeTerrain: _ => { }, cellVisibility: new AcDream.App.Rendering.CellVisibility(), worldState: new GpuWorldState()); return publisher.BeginPublication( build, new AcDream.Core.Terrain.LandblockMeshData( Array.Empty(), Array.Empty())); } private static ( LandblockPhysicsPublisher Publisher, PhysicsEngine Engine, PhysicsDataCache Cache) Fixture() { var engine = new PhysicsEngine(); var cache = new PhysicsDataCache(); engine.DataCache = cache; return ( new LandblockPhysicsPublisher(engine, HeightTable), engine, cache); } private static void CacheCylinderSetup( PhysicsDataCache cache, uint setupId = SetupId, int shapeCount = 1) { var setup = new Setup(); for (int index = 0; index < shapeCount; index++) { setup.CylSpheres.Add( new CylSphere { Radius = 0.4f + index * 0.01f, Height = 1.2f, Origin = new Vector3(index * 0.1f, 0f, 0.6f), }); } cache.CacheSetup(setupId, setup); } private static GfxObjPhysics BspGfx(float radius) => new() { BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = DatReaderWriter.Enums.BSPNodeType.Leaf, }, }, BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = radius, }, PhysicsPolygons = new Dictionary(), Vertices = new VertexArray(), Resolved = new Dictionary(), }; private static PhysicsDatBundle CellPortalAndBuildingBundle(uint envCellId) { var cellStruct = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary { [0] = new SWVertex { Origin = Vector3.Zero }, [1] = new SWVertex { Origin = Vector3.UnitY }, [2] = new SWVertex { Origin = Vector3.UnitZ }, }, }, PhysicsPolygons = new Dictionary(), Polygons = new Dictionary { [0] = new Polygon { VertexIds = [0, 1, 2] }, }, }; var environment = new DatReaderWriter.DBObjs.Environment { Id = 0x0D000001u, Cells = { [1] = cellStruct }, }; var envCell = new EnvCell { Id = envCellId, EnvironmentId = 1, CellStructure = 1, Position = new Frame { Orientation = Quaternion.Identity }, CellPortals = { new CellPortal { PolygonId = 0, OtherCellId = 0xFFFF, }, }, }; var info = new LandBlockInfo { NumCells = 1, Buildings = { new BuildingInfo { ModelId = 0x01000077u, Frame = new Frame { Origin = new Vector3(12f, 12f, 0f), Orientation = Quaternion.Identity, }, }, }, }; return new PhysicsDatBundle( info, new Dictionary { [envCellId] = envCell }, new Dictionary { [environment.Id] = environment, }, new Dictionary(), new Dictionary()); } private static WorldEntity CylinderEntity( uint id, Vector3 position, uint setupId = SetupId) => new() { Id = id, SourceGfxObjOrSetupId = setupId, Position = position, Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; private static LandblockBuild Build( uint landblockId, IReadOnlyList? entities = null, PhysicsDatBundle? bundle = null, LandblockBuildOrigin? origin = null) { var landblockInfo = new LandBlockInfo(); PhysicsDatBundle physics = bundle ?? new PhysicsDatBundle( landblockInfo, new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary()); return new LandblockBuild( new LoadedLandblock( landblockId, FlatHeightmap(), entities ?? Array.Empty(), physics), Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4)); } private static LandBlock FlatHeightmap() => new() { Terrain = new TerrainInfo[81], Height = new byte[81], }; private static string FindRepoRoot() { string? directory = AppContext.BaseDirectory; while (directory is not null) { if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) return directory; directory = Directory.GetParent(directory)?.FullName; } throw new DirectoryNotFoundException("Could not locate repository root."); } }