using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// #185 (2026-07-08) — the outdoor-stairs "invisible wall" root cause: the /// former per-part landblock shadow registration used a synthetic part-id /// entity.Id * 256u + partIndex that OVERFLOWED uint32 for class-prefixed /// landblock ids (0x40/0x80/0xC0…). The << 8 dropped the /// prefix byte, so different-class entities sharing the low 24 bits collided on /// one shadow part-id and Register's deregister-then-insert silently /// overwrote one entity's collision geometry — rendered stair steps with NO /// collision. The fix registers each multi-part entity via /// under its UNIQUE 32-bit /// entity.Id (retail add_shadows_to_cells / CPartArray::AddPartsShadow). /// public class ShadowRegistrationOverflowTests { // Two real stair-entity ids from the #185 capture that share the low 24 bits // (0xF68221) but differ in the class-prefix byte. private const uint EntityA = 0x40F68221u; private const uint EntityB = 0xC0F68221u; private const uint LbId = 0xF6820000u; private const float OffX = 0f, OffY = 0f; // ── The root cause, as pure arithmetic ──────────────────────────────── [Fact] public void OldPartIdScheme_OverflowsUint32_AndCollides() { // entity.Id * 256u == entity.Id << 8, truncated to 32 bits → the prefix // byte falls off the top and the two distinct entities alias. uint oldPartA = unchecked(EntityA * 256u); // 0x40F68221 << 8 → 0xF6822100 uint oldPartB = unchecked(EntityB * 256u); // 0xC0F68221 << 8 → 0xF6822100 Assert.Equal(0xF6822100u, oldPartA); Assert.Equal(oldPartA, oldPartB); // COLLISION = the #185 bug Assert.NotEqual(EntityA, EntityB); // …yet the entities ARE distinct } // ── The bug: old per-part Register loses one registration ───────────── private static ShadowShape Cyl(Vector3 local) => new( GfxObjId: 0u, LocalPosition: local, LocalRotation: Quaternion.Identity, Scale: 1f, CollisionType: ShadowCollisionType.Cylinder, Radius: 1f, CylHeight: 2f); [Fact] public void OldPerPartRegister_CollidingIds_SecondSilentlyOverwritesFirst() { var reg = new ShadowObjectRegistry(); // Two DIFFERENT physical objects at DIFFERENT cells, registered the OLD way // (Register with the synthetic overflowing part-id). EntityA at cell (0,0), // EntityB at cell (1,0) — 30 m apart in X. var posA = new Vector3(12f, 12f, 50f); // → cell LbId|1 var posB = new Vector3(42f, 12f, 50f); // → cell LbId|9 reg.Register(unchecked(EntityA * 256u), 0u, posA, Quaternion.Identity, 1f, OffX, OffY, LbId, ShadowCollisionType.Cylinder, 2f); reg.Register(unchecked(EntityB * 256u), 0u, posB, Quaternion.Identity, 1f, OffX, OffY, LbId, ShadowCollisionType.Cylinder, 2f); // EntityA's registration is GONE (its part-id was reused by EntityB): its // cell is empty. This is exactly the missing stair-step collision. Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); Assert.NotEmpty(reg.GetObjectsInCell(LbId | 9u)); Assert.Equal(1, reg.TotalRegistered); // one silently lost } // ── The fix: RegisterMultiPart keys on the unique entity.Id ─────────── [Fact] public void RegisterMultiPart_CollidingLowBitsIds_BothSurvive() { var reg = new ShadowObjectRegistry(); var posA = new Vector3(12f, 12f, 50f); // → cell LbId|1 var posB = new Vector3(42f, 12f, 50f); // → cell LbId|9 reg.RegisterMultiPart(EntityA, posA, Quaternion.Identity, new[] { Cyl(Vector3.Zero) }, 0u, EntityCollisionFlags.None, OffX, OffY, LbId, isStatic: true); reg.RegisterMultiPart(EntityB, posB, Quaternion.Identity, new[] { Cyl(Vector3.Zero) }, 0u, EntityCollisionFlags.None, OffX, OffY, LbId, isStatic: true); // Both distinct entities survive at their own cells — no overflow collision. Assert.Contains(reg.GetObjectsInCell(LbId | 1u), e => e.EntityId == EntityA); Assert.Contains(reg.GetObjectsInCell(LbId | 9u), e => e.EntityId == EntityB); Assert.Equal(2, reg.TotalRegistered); } // ── The builder: one BSP shape per BSP part; shells + no-BSP excluded ── private static GfxObjPhysics BspGfx(float radius) { var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf }; return new GfxObjPhysics { BSP = new PhysicsBSPTree { Root = leaf }, BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = radius }, Resolved = new Dictionary(), PhysicsPolygons = new Dictionary(), Vertices = new VertexArray(), }; } [Fact] public void FromLandblockBspParts_OneShapePerBspPart_LocalTransformPreserved() { var meshRefs = new[] { new MeshRef(0x01000AC5u, Matrix4x4.CreateTranslation(0f, 0.5f, 0.4f)), new MeshRef(0x01000AC5u, Matrix4x4.CreateTranslation(0f, 1.0f, 0.8f)), new MeshRef(0x0BADBADu, Matrix4x4.Identity), // no physics BSP → skipped }; var shapes = ShadowShapeBuilder.FromLandblockBspParts( meshRefs, isBuildingShell: false, getGfxObj: id => id == 0x01000AC5u ? BspGfx(1.05f) : null); Assert.Equal(2, shapes.Count); // only the two BSP-bearing parts Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.BSP, s.CollisionType)); Assert.All(shapes, s => Assert.Equal(0x01000AC5u, s.GfxObjId)); // Local part offsets survive (decomposed from PartTransform). Assert.Contains(shapes, s => Vector3.Distance(s.LocalPosition, new Vector3(0f, 0.5f, 0.4f)) < 1e-4f); Assert.Contains(shapes, s => Vector3.Distance(s.LocalPosition, new Vector3(0f, 1.0f, 0.8f)) < 1e-4f); // Radius = local BoundingSphere radius × part scale (1.0). Assert.All(shapes, s => Assert.Equal(1.05f, s.Radius, 3)); } [Fact] public void FromLandblockBspParts_BuildingShell_ReturnsEmpty() { var meshRefs = new[] { new MeshRef(0x01000AC5u, Matrix4x4.Identity) }; var shapes = ShadowShapeBuilder.FromLandblockBspParts( meshRefs, isBuildingShell: true, getGfxObj: _ => BspGfx(1f)); Assert.Empty(shapes); // building shells collide via the building channel } }