using System.Collections.Generic;
using System.Collections.Immutable;
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) => ShadowShape.Cylinder(
gfxObjId: 0u, localPosition: local, localRotation: Quaternion.Identity,
scale: 1f, 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 ──
///
/// Graph-form fixture. The bounding sphere is OFF-CENTRE by default
/// because that is the DAT-real case — a GfxObj's physics BSP is authored
/// in the GfxObj's own coordinates and 376 of the 973 installed
/// physics-BSP parts have a root sphere further from the part origin than
/// half their radius.
///
private static GfxObjPhysics BspGfx(float radius, float centerZ = 0.75f)
{
var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf };
return new GfxObjPhysics
{
BSP = new PhysicsBSPTree { Root = leaf },
BoundingSphere = new Sphere
{ Origin = new Vector3(0f, 0f, centerZ), Radius = radius },
Resolved = new Dictionary(),
PhysicsPolygons = new Dictionary(),
Vertices = new VertexArray(),
};
}
/// Flat-form fixture — the production storage since I6/I7.
private static GfxObjPhysics FlatBspGfx(float radius, float centerZ)
{
var node = new FlatPhysicsBspNode(
BSPNodeType.Leaf, default, -1, -1, 0, 0,
new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius),
new FlatIndexRange(0, 0));
GfxObjPhysics phys = BspGfx(radius, centerZ);
phys.FlatPhysicsBsp = new FlatPhysicsBsp(
0,
ImmutableArray.Create(node),
ImmutableArray.Empty,
FlatPolygonTable.Empty);
return phys;
}
[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));
}
///
/// AP-156, landblock half. A landblock-baked multi-part entity is the
/// same CPartArray walk as a live Setup
/// (CPhysicsObj::find_bbox_cell_list @0x00510fc0 →
/// CPartArray::calc_cross_cells_static @0x00518160 →
/// CEnvCell::find_transit_cells @0x0052cae0), which transforms each
/// part's CGfxObj::physics_sphere CENTRE through the part's own
/// Position before reading its radius. Carrying the radius alone puts a
/// stair run's or fence's flood sphere at the part origin instead of on
/// its geometry. Both storage forms — the flat BSP that production uses
/// and the graph fallback — must supply the centre, SCALED with the part.
///
[Fact]
public void FromLandblockBspParts_CarriesTheScaledRootSphereCentre()
{
Matrix4x4 halfScale = Matrix4x4.CreateScale(0.5f)
* Matrix4x4.CreateTranslation(0f, 2f, 0f);
var flat = ShadowShapeBuilder.FromLandblockBspParts(
[new MeshRef(0x01000AC5u, halfScale)],
isBuildingShell: false,
getGfxObj: _ => FlatBspGfx(4f, centerZ: 3f));
ShadowShape flatShape = Assert.Single(flat);
Assert.Equal(0.5f, flatShape.Scale, 3);
Assert.Equal(2f, flatShape.Radius, 3); // 4 * 0.5
Assert.Equal(new Vector3(0f, 0f, 1.5f), flatShape.BoundsCenter); // 3 * 0.5
// Graph fallback (fixtures without a flat BSP) takes the same path.
var graph = ShadowShapeBuilder.FromLandblockBspParts(
[new MeshRef(0x01000AC5u, halfScale)],
isBuildingShell: false,
getGfxObj: _ => BspGfx(4f, centerZ: 3f));
ShadowShape graphShape = Assert.Single(graph);
Assert.Equal(new Vector3(0f, 0f, 1.5f), graphShape.BoundsCenter);
}
[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
}
}