using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// Guards the DAT-only shape-source rule (Slice 1 of the unified /// collision-inclusion phase, 2026-06-24). /// /// /// Retail oracle: CPartArray::InitParts@0x00517F40 (parts come from /// the Setup list only), CGfxObj::Serialize@0x00534970 (physics_bsp /// gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 /// (returns OK / passable when physics_bsp == null). Render-mesh bounds /// never enter collision. /// /// public class ShadowShapeBuilderShapeSourceTests { // Retail: an object with no DAT physics shape (no CylSphere, no Sphere, // no part with a PhysicsBSP) emits NO collision shape and is passable. // CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null. [Fact] public void Setup_WithNoCylSpheres_NoSpheres_NoPhysicsBspParts_YieldsEmptyShapeList() { var setup = new Setup { CylSpheres = new List(), Spheres = new List(), Parts = { 0x01000ABCu }, PlacementFrames = new Dictionary(), }; var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); Assert.Empty(shapes); } // D4+W1 (2026-06-24) — BSP-only furniture weenie guard. // // A Setup with no CylSpheres, no Spheres, no Radius, but whose Part has a // PhysicsBSP (e.g. a candle holder or floor candelabra) MUST produce a BSP // ShadowShape. The premature `if (!hasCyl && !hasSphere && !hasRadius) return` // gate in GameWindow.RegisterLiveEntityCollision was dropped because it fired // BEFORE ShadowShapeBuilder ran and discarded these entities entirely. // // Retail oracle: CPhysicsPart::find_obj_collisions@0x0050D8D0 -- when // physics_bsp is non-null the part IS tested, and the outer loop in // CPartArray::FindObjCollisions@0x00518180 iterates ALL parts. // ShadowShapeBuilder.FromSetup mirrors this by emitting one BSP shape per // part that `hasPhysicsBsp` returns true for. // // AP-152 correction (2026-08-06): that "regardless of CylSpheres/Spheres" // used to be stated here and was then used to justify emitting the BSP // shapes IN ADDITION TO the primitives. It does not support that. The // per-part loop is reached only from the BSP branch of // CPhysicsObj::FindObjCollisions@0x0050f050, which is entered on // HAS_PHYSICS_BSP_PS (0x0050f165 test / 0x0050f16f je) and left by the // UNCONDITIONAL 0x0050f19d jmp 0x50f2b0 — past both primitive loops. The // dispatch is exclusive and BSP wins; this fixture's Setup simply has no // primitive to lose. [Fact] public void Setup_WithBspPart_NoCylSpheres_EmitsBspShape() { // hasPhysicsBsp predicate returns true for 0x0100AAAAu, simulating a // GfxObj that has a PhysicsBSP but no CylSpheres/Spheres. const uint BspGfxObjId = 0x0100AAAAu; var setup = new Setup { CylSpheres = new List(), Spheres = new List(), Parts = { BspGfxObjId }, PlacementFrames = new Dictionary(), }; var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: id => id == BspGfxObjId); Assert.Contains(shapes, s => s.CollisionType == ShadowCollisionType.BSP); } // Complementary regression guard: a Setup with no CylSpheres, no Spheres, // and a Part that has NO PhysicsBSP must still produce an empty shape list. // The premature gate removal does not accidentally register shapeless entities. [Fact] public void Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList() { const uint NoBspGfxObjId = 0x0100BBBBu; var setup = new Setup { CylSpheres = new List(), Spheres = new List(), Parts = { NoBspGfxObjId }, PlacementFrames = new Dictionary(), }; var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); Assert.Empty(shapes); } // 2026-07-07 — the PREMISE of the player-vs-monster crowd-collision fix (the // CSphere family port): a humanoid Setup carries body Spheres and NO // CylSpheres, so FromSetup emits Sphere-type shadows → collision runs through // Transition.SphereCollision (the retail CSphere::intersects_sphere family), // NOT the cylinder path. Mirrors the human Setup 0x02000001 (two body spheres // at 0.475 / 1.350, r=0.48 — TS-46 / the physics digest). If this ever flips // to CylSphere-first, the crowd fix would be aimed at the wrong dispatcher. [Fact] public void Setup_WithBodySpheres_NoCylSpheres_EmitsSphereShapes() { var setup = new Setup { CylSpheres = new List(), Spheres = new List { new Sphere { Origin = new Vector3(0f, 0f, 0.475f), Radius = 0.48f }, new Sphere { Origin = new Vector3(0f, 0f, 1.350f), Radius = 0.48f }, }, Parts = new List>(), PlacementFrames = new Dictionary(), }; var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); Assert.Equal(2, shapes.Count); Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.Sphere, s.CollisionType)); } }