using System.Numerics; using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using AcDream.Runtime.Physics; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.App.Tests.Physics; public sealed class LiveEntityCollisionBuilderTests { private const uint Guid = 0x70000100u; private const uint Cell = 0x01010001u; [Fact] public void EffectivePartResolution_IsSetupAndVisualOptionIndependent() { uint[] resolved = LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( [0x01001000u, 0x01002000u], id => id == 0x01001000u ? 0x01001001u : id); Assert.Equal([0x01001001u, 0x01002000u], resolved); } /// /// Re-hosts the state/flag/seed-cell coverage that used to ride on the /// deleted Setup-radius fallback (AP-22). The old fixture was a Setup with /// a radius and zero primitives, which cannot exist in client_portal.dat — /// all 1,294 shapeless Setups have Radius exactly 0. This uses a /// DAT-possible single-CylSphere Setup instead. /// [Fact] public void Build_PropagatesExactStateFlagsScaleAndFullSeedCell() { var setup = new Setup(); setup.CylSpheres.Add(new CylSphere { Origin = Vector3.Zero, Radius = 0.4f, Height = 1.2f, }); WorldSession.EntitySpawn spawn = Spawn( scale: 2f, itemType: (uint)ItemType.Creature, descriptionFlags: 0x28u); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); record.FinalPhysicsState = PhysicsStateFlags.Hidden | PhysicsStateFlags.Static; WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = Builder(); LiveEntityCollisionRegistration registration = Assert.IsType( builder.Build( entity, setup, Array.Empty(), spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, new Vector3(192f, -192f, 0f))); ShadowShape shape = Assert.Single(registration.Shapes); Assert.Equal(ShadowCollisionType.Cylinder, shape.CollisionType); Assert.Equal(0.8f, shape.Radius); Assert.Equal(2.4f, shape.CylHeight); Assert.Equal((uint)record.FinalPhysicsState, registration.State); Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.HasWeenie)); Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsCreature)); Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPlayer)); Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPK)); Assert.Equal(Cell, registration.LandblockId); Assert.Equal(Cell, registration.SeedCellId); Assert.Equal((192f, -192f), (registration.WorldOffsetX, registration.WorldOffsetY)); } /// /// AP-22: retail synthesizes no shape for a Setup with no primitives and /// no physics-BSP part, whatever its summary Radius/Height say. /// CPhysicsObj::FindObjCollisions (0x0050f050) branches to the /// epilogue at 0x0050f22f je 0x50f31b and returns the seeded /// OK_TS; CPartArray::GetRadius/GetHeight are absent from /// its call set. This is the direct inverse of the deleted /// RadiusFallback fact. /// [Fact] public void ShapelessSetupWithRadius_ProducesNoRegistration() { var setup = new Setup { Radius = 0.75f, Height = 2.5f }; WorldSession.EntitySpawn spawn = Spawn(scale: 2f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; Assert.Null(Builder().Build( entity, setup, Array.Empty(), spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, new Vector3(192f, -192f, 0f))); } /// /// The BSP root bounding sphere reaches the shape WHOLE — radius and /// centre, both scaled. Retail's CGfxObj::physics_sphere is /// BSPTREE::GetSphere(physics_bsp) @0x005397e0, and /// CEnvCell::find_transit_cells @0x0052cae0 transforms its centre /// through the part's Position (0x0052cb4c add eax,0x30) before /// reading the radius at 0x0052cb65. Carrying only the radius is /// AP-156: 428 of the 530 installed BSP-bearing Setups then flood from /// spheres that do not contain their own physics polygons. (The row as /// filed said "170 of 172"; 172 is AP-152's DISPATCH population, not /// AP-156's containment population — corrected at the fix review.) /// [Fact] public void BspOnlyPart_UsesRealScaledPhysicsBoundingSphere() { const uint part = 0x0100ABCDu; var setup = new Setup(); setup.Parts.Add(part); WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( id => id == part ? Bsp(3f, centerZ: 2f) : null, PoseResolver()); LiveEntityCollisionRegistration registration = Assert.IsType( builder.Build( entity, setup, [part], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); ShadowShape shape = Assert.Single(registration.Shapes); Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType); Assert.Equal(4.5f, shape.Radius); // 3 m * 1.5 Assert.Equal(new Vector3(0f, 0f, 3f), shape.BoundsCenter); // 2 m * 1.5 Assert.Equal(part, shape.GfxObjId); } /// /// AP-152. Every other fixture in this file is primitive-only or BSP-only, /// so nothing at the App layer used to exercise the CylSphere+BSP /// combination — 73 of the 172 affected installed Setups. /// Retail's CPhysicsObj::FindObjCollisions @0x0050f050 tests /// HAS_PHYSICS_BSP_PS at 0x0050f165 and leaves the BSP branch /// through the unconditional 0x0050f19d jmp 0x50f2b0, past both the /// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d); /// calc_cross_cells @0x00515230 dispatches identically at /// 0x00515285. The CylSphere must not survive, and the surviving BSP shape /// must still receive the real scaled bounding radius. /// [Fact] public void CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape() { const uint part = 0x0100AC01u; var setup = new Setup(); setup.Parts.Add(part); setup.CylSpheres.Add(new CylSphere { Origin = Vector3.Zero, Radius = 0.4f, Height = 1.2f, }); WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( id => id == part ? Bsp(3f, centerZ: 2f) : null, PoseResolver()); LiveEntityCollisionRegistration registration = Assert.IsType(builder.Build( entity, setup, [part], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); ShadowShape shape = Assert.Single(registration.Shapes); Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType); Assert.Equal(4.5f, shape.Radius); // 3 m physics-BSP radius * 1.5 scale Assert.Equal(new Vector3(0f, 0f, 3f), shape.BoundsCenter); Assert.Equal(part, shape.GfxObjId); } [Fact] public void EffectiveReplacementWithoutPhysicsBsp_RemovesBasePartCollision() { const uint basePart = 0x0100AB01u; const uint replacement = 0x0100AB02u; var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( id => id == basePart ? Bsp(1f) : null, PoseResolver()); Assert.Null(builder.Build( entity, setup, [replacement], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); } [Fact] public void EffectiveReplacementWithPhysicsBsp_AddsReplacementCollision() { const uint basePart = 0x0100AB11u; const uint replacement = 0x0100AB12u; var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( id => id == replacement ? Bsp(2.25f) : null, PoseResolver()); LiveEntityCollisionRegistration registration = Assert.IsType(builder.Build( entity, setup, [replacement], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); ShadowShape shape = Assert.Single(registration.Shapes); Assert.Equal(replacement, shape.GfxObjId); Assert.Equal(2.25f, shape.Radius); } [Fact] public void ReconcileAppearance_VisibleReplacementWithoutBspRemovesOldPayload() { const uint basePart = 0x0100AC01u; const uint replacement = 0x0100AC02u; var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( id => id == basePart ? Bsp(1f) : null, PoseResolver()); LiveEntityCollisionRegistration initial = Assert.IsType(builder.Build( entity, setup, [basePart], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); var registry = new ShadowObjectRegistry(); LiveEntityCollisionBuilder.Register(registry, initial); LiveEntityCollisionRegistration? changed = builder.Build( entity, setup, [replacement], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero, retainEmptyPayload: true); LiveEntityCollisionBuilder.ReconcileAppearance( registry, entity.Id, changed, suspendIfNew: false); Assert.Equal(1, registry.RetainedRegistrationCount); Assert.DoesNotContain( registry.GetObjectsInCell(Cell), entry => entry.EntityId == entity.Id); } [Fact] public void ReconcileAppearance_SuspendedReplacementRetainsNewPayloadUntilRestore() { const uint basePart = 0x0100AC11u; const uint replacement = 0x0100AC12u; var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( id => id == basePart || id == replacement ? Bsp(1f) : null, PoseResolver()); LiveEntityCollisionRegistration initial = Assert.IsType(builder.Build( entity, setup, [basePart], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); var registry = new ShadowObjectRegistry(); LiveEntityCollisionBuilder.Register(registry, initial); Assert.True(registry.Suspend(entity.Id)); LiveEntityCollisionRegistration changed = Assert.IsType(builder.Build( entity, setup, [replacement], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); LiveEntityCollisionBuilder.ReconcileAppearance( registry, entity.Id, changed, suspendIfNew: true); Assert.Equal(1, registry.RetainedRegistrationCount); Assert.Equal(1, registry.SuspendedRegistrationCount); Assert.DoesNotContain( registry.GetObjectsInCell(Cell), entry => entry.EntityId == entity.Id); LiveEntityCollisionRegistration empty = Assert.IsType(builder.Build( entity, setup, [0x0100AC13u], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero, retainEmptyPayload: true)); LiveEntityCollisionBuilder.ReconcileAppearance( registry, entity.Id, empty, suspendIfNew: true); LiveEntityCollisionBuilder.ReconcileAppearance( registry, entity.Id, changed, suspendIfNew: true); Assert.Equal(1, registry.RetainedRegistrationCount); Assert.Equal(1, registry.SuspendedRegistrationCount); registry.UpdatePosition( entity.Id, entity.Position, entity.Rotation, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: Cell, seedCellId: Cell); ShadowEntry restored = Assert.Single( registry.GetObjectsInCell(Cell), entry => entry.EntityId == entity.Id); Assert.Equal(replacement, restored.GfxObjId); Assert.Equal(0, registry.SuspendedRegistrationCount); } [Fact] public void ReconcileAppearance_ExistingMembershipDoesNotDependOnNewFlood() { const uint basePart = 0x0100AC21u; const uint replacement = 0x0100AC22u; var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( id => id == basePart || id == replacement ? Bsp(1f) : null, PoseResolver()); LiveEntityCollisionRegistration initial = Assert.IsType(builder.Build( entity, setup, [basePart], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); var registry = new ShadowObjectRegistry(); LiveEntityCollisionBuilder.Register(registry, initial); LiveEntityCollisionRegistration changed = Assert.IsType(builder.Build( entity, setup, [replacement], spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)) with { LandblockId = 0u, SeedCellId = 0u, }; LiveEntityCollisionBuilder.ReconcileAppearance( registry, entity.Id, changed, suspendIfNew: false); ShadowEntry entry = Assert.Single( registry.GetObjectsInCell(Cell), candidate => candidate.EntityId == entity.Id); Assert.Equal(replacement, entry.GfxObjId); } [Fact] public void ShapelessSetup_ProducesNoRegistration() { WorldSession.EntitySpawn spawn = Spawn(scale: 1f); var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; Assert.Null(Builder().Build( entity, new Setup(), Array.Empty(), spawn, record.ServerGuid, record.Generation, record.WorldEntity!, record.FinalPhysicsState, Vector3.Zero)); } [Fact] public void Build_RejectsDifferentRecordIncarnation() { WorldSession.EntitySpawn spawn = Spawn(scale: 1f); var expected = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); var other = LiveEntityTestFixture.CreateExactProjectionRecord( spawn with { InstanceSequence = 2 }); WorldEntity entity = Entity(); expected.WorldEntity = entity; other.WorldEntity = entity; Assert.Throws(() => Builder().Build( entity, new Setup { Radius = 1f }, Array.Empty(), spawn, other.ServerGuid, other.Generation, other.WorldEntity!, other.FinalPhysicsState, Vector3.Zero)); } private static LiveEntityCollisionBuilder Builder() => new( _ => null, PoseResolver()); /// /// A physics-BSP root bounding sphere. DELIBERATELY OFF-CENTRE by /// default: a GfxObj's BSP is authored in the GfxObj's own coordinates /// and its root sphere is usually NOT centred on that origin (376 of the /// 973 installed physics-BSP parts sit further from it than half their /// radius). A fixture pinned at Vector3.Zero cannot observe the /// centre at all — which is how AP-156's discarded origin stayed green. /// private static ShadowPartGeometry? Bsp(float radius, float centerZ = 1.25f) => ShadowPartGeometry.Create( new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius), null); private static LiveEntityDefaultPoseResolver PoseResolver() => new( _ => null, new NullAnimationLoader(), dumpMotion: false); private static WorldEntity Entity() => new() { Id = 101u, ServerGuid = Guid, SourceGfxObjOrSetupId = 0x02000100u, Position = new Vector3(4f, 5f, 6f), Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = Cell, }; private static WorldSession.EntitySpawn Spawn( float scale, uint? itemType = null, uint? descriptionFlags = null) => new( Guid, new CreateObject.ServerPosition(Cell, 4f, 5f, 6f, 1f, 0f, 0f, 0f), 0x02000100u, Array.Empty(), Array.Empty(), Array.Empty(), BasePaletteId: null, ObjScale: scale, Name: "collision fixture", ItemType: itemType, MotionState: null, MotionTableId: null, PhysicsState: 0u, ObjectDescriptionFlags: descriptionFlags, InstanceSequence: 1); private sealed class NullAnimationLoader : IAnimationLoader { public Animation? LoadAnimation(uint id) => null; } }