using System; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; public class ShadowShapeBuilderTests { /// /// Synthetic Setup mirroring live dump of 0x020019FF (cottage door) /// captured 2026-05-24: 0 cylSpheres, 1 sphere (r=0.100, origin=(0,0,0.018)), /// 3 parts (0x010044B5 + 0x010044B6 + 0x010044B6), setup.Radius=0.141, /// setup.Height=0.200. PlacementFrames[Default] has identity transforms /// for all 3 parts. /// private static Setup CreateDoorSetup() { var setup = new Setup { Radius = 0.141f, Height = 0.200f, StepUpHeight = 0.090f, StepDownHeight = 0.090f, Parts = { 0x010044B5u, 0x010044B6u, 0x010044B6u }, Spheres = { new Sphere { Radius = 0.100f, Origin = new Vector3(0f, 0f, 0.018f) } }, PlacementFrames = { [Placement.Default] = new AnimationFrame(3) { Frames = { new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity } } } } }; return setup; } /// /// AP-152, corrected 2026-08-06 (was FromSetup_DoorSetup_ProducesFourShapes, /// which pinned the additive emission as intended). /// /// /// Retail dispatches EXCLUSIVELY and BSP wins. /// CPhysicsObj::FindObjCollisions @0x0050f050 tests /// HAS_PHYSICS_BSP_PS first (0x0050f165 /// test dword [esi+0xa8],0x10000 / 0x0050f16f je 0x50f1a2) and /// leaves the BSP branch through the UNCONDITIONAL /// 0x0050f19d jmp 0x50f2b0, which is past both the CylSphere loop /// (0x50f1a2) and the Sphere loop (0x50f21d). /// CPhysicsObj::calc_cross_cells @0x00515230 tests the same flag at /// 0x00515285 and routes to find_bbox_cell_list @0x00510fc0. /// So the cottage door's 0.100 m base Sphere is neither tested for /// collision nor used for cell membership — only the three slab BSP parts. /// /// [Fact] public void FromSetup_DoorSetup_EmitsBspPartsOnly() { var setup = CreateDoorSetup(); Func hasBsp = id => id == 0x010044B5u || id == 0x010044B6u; var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1.0f, hasBsp); Assert.Equal(3, shapes.Count); Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.BSP, s.CollisionType)); Assert.DoesNotContain(shapes, s => s.CollisionType == ShadowCollisionType.Sphere); Assert.DoesNotContain(shapes, s => s.CollisionType == ShadowCollisionType.Cylinder); } /// /// AP-152, re-hosted 2026-08-06 on hasPhysicsBsp: _ => false — the /// DAT-real configuration for the 3,605 Sphere-only Setups. The fact /// itself is unchanged and still live: a Setup Sphere emits a TRUE /// (not a height-capped /// Cylinder), passing its local offset and radius through. /// ShadowCollisionType.Sphere is produced at exactly one site in /// src/, and it is the premise of the whole CSphere family port. /// Retail: CSphere::intersects_sphere @0x00537A80 uses 3-D /// distance, so there is no height cap. /// [Fact] public void FromSetup_DoorSetup_SphereAtExpectedLocalOffset() { var setup = CreateDoorSetup(); var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => false); var sphereShape = Assert.Single(shapes); Assert.Equal(ShadowCollisionType.Sphere, sphereShape.CollisionType); Assert.Equal(0f, sphereShape.LocalPosition.X, 4); Assert.Equal(0f, sphereShape.LocalPosition.Y, 4); Assert.Equal(0.018f, sphereShape.LocalPosition.Z, 4); Assert.Equal(0.100f, sphereShape.Radius, 4); // CylHeight must be 0 — spheres have no height cap. Assert.Equal(0f, sphereShape.CylHeight, 4); } /// /// AP-152 trap 1. The step-0 dispatch gate and the step-3 emission must /// read the SAME part identities. If the gate read setup.Parts /// while step 3 read the installed AnimPartChanged replacements, /// a swap could suppress the primitives while step 3 emitted nothing — /// LiveEntityCollisionBuilder.Build would then return null and the /// entity's collision would disappear entirely. /// [Fact] public void FromSetup_DispatchGateReadsTheEffectivePartIdentities() { const uint basePart = 0x010044B5u; const uint replacementWithBsp = 0x0100AA01u; var setup = new Setup { Parts = { basePart }, CylSpheres = { new CylSphere { Radius = 0.4f, Height = 1.2f, Origin = Vector3.Zero } }, }; Func hasBsp = id => id == replacementWithBsp; var swapped = ShadowShapeBuilder.FromSetup( setup, 1.0f, hasBsp, effectivePartGfxObjIds: [replacementWithBsp]); var unswapped = ShadowShapeBuilder.FromSetup(setup, 1.0f, hasBsp); // Replacement carries the BSP -> BSP wins, the CylSphere is suppressed. ShadowShape swappedShape = Assert.Single(swapped); Assert.Equal(ShadowCollisionType.BSP, swappedShape.CollisionType); Assert.Equal(replacementWithBsp, swappedShape.GfxObjId); // Base identity has no BSP -> no BSP shape exists, so the CylSphere // must survive. A gate reading setup.Parts would agree here and // disagree above; a gate reading nothing at all would disagree here. ShadowShape unswappedShape = Assert.Single(unswapped); Assert.Equal(ShadowCollisionType.Cylinder, unswappedShape.CollisionType); Assert.Equal(0.4f, unswappedShape.Radius, 4); } [Fact] public void FromSetup_PartWithoutBsp_SkipsBspShape() { var setup = CreateDoorSetup(); Func hasBsp = id => id == 0x010044B5u; var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, hasBsp); int bspCount = 0; foreach (var s in shapes) if (s.CollisionType == ShadowCollisionType.BSP) bspCount++; Assert.Equal(1, bspCount); } [Fact] public void FromSetup_EffectivePartIdentitiesControlPhysicsBspSelection() { const uint replacementWithBsp = 0x0100AA01u; const uint replacementWithoutBsp = 0x0100AA02u; var setup = new Setup { Parts = { 0x010044B5u, 0x010044B6u }, }; var shapes = ShadowShapeBuilder.FromSetup( setup, entScale: 1f, hasPhysicsBsp: id => id == replacementWithBsp, effectivePartGfxObjIds: [replacementWithBsp, replacementWithoutBsp]); ShadowShape shape = Assert.Single(shapes); Assert.Equal(replacementWithBsp, shape.GfxObjId); } [Fact] public void FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders() { var setup = new Setup { Parts = { 0x02000001u }, CylSpheres = { new CylSphere { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0, 0, 0.6f) } }, Spheres = { new Sphere { Radius = 0.50f, Origin = new Vector3(0, 0, 0.7f) } } }; var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => false); Assert.Single(shapes); Assert.Equal(ShadowCollisionType.Cylinder, shapes[0].CollisionType); Assert.Equal(0.40f, shapes[0].Radius, 3); Assert.Equal(1.20f, shapes[0].CylHeight, 3); } /// /// Corrected 2026-08-06 (AP-152 §11.5). This test used to run /// CreateDoorSetup() — which has ZERO CylSpheres — and then assert /// radius/offset scaling inside /// if (s.CollisionType == ShadowCollisionType.Cylinder). That /// branch had been unreachable since Setup Spheres started emitting /// (2026-06-24), so the only /// assertion that ever executed was Scale == 2.0f: the name /// promised radius and offset scaling and pinned neither. Both primitive /// kinds are now asserted unconditionally, on fixtures that actually /// emit them. /// [Fact] public void FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets() { var sphereShape = Assert.Single( ShadowShapeBuilder.FromSetup(CreateDoorSetup(), entScale: 2.0f, _ => false)); Assert.Equal(ShadowCollisionType.Sphere, sphereShape.CollisionType); Assert.Equal(2.0f, sphereShape.Scale, 3); Assert.Equal(0.200f, sphereShape.Radius, 3); // 0.100 * 2 Assert.Equal(0.036f, sphereShape.LocalPosition.Z, 3); // 0.018 * 2 var cylSetup = new Setup { CylSpheres = { new CylSphere { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0.1f, 0.2f, 0.6f) } } }; var cylShape = Assert.Single( ShadowShapeBuilder.FromSetup(cylSetup, entScale: 2.0f, _ => false)); Assert.Equal(ShadowCollisionType.Cylinder, cylShape.CollisionType); Assert.Equal(2.0f, cylShape.Scale, 3); Assert.Equal(0.800f, cylShape.Radius, 3); // 0.40 * 2 Assert.Equal(2.400f, cylShape.CylHeight, 3); // 1.20 * 2 Assert.Equal(0.200f, cylShape.LocalPosition.X, 3); Assert.Equal(0.400f, cylShape.LocalPosition.Y, 3); Assert.Equal(1.200f, cylShape.LocalPosition.Z, 3); } [Fact] public void FromSetup_EmptySetup_ReturnsEmptyList() { var setup = new Setup(); var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true); Assert.Empty(shapes); } [Fact] public void FromSetup_NullSetup_Throws() { Assert.Throws( () => ShadowShapeBuilder.FromSetup(null!, 1.0f, _ => true)); } }