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
}
}