using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; public class ShadowObjectRegistryMultiPartTests { private const uint LbId = 0xA9B40000u; private const float OffX = 0f; private const float OffY = 0f; private static IReadOnlyList DoorShapes() => new[] { ShadowShape.Cylinder( gfxObjId: 0u, localPosition: new Vector3(0f, 0f, 0.018f), localRotation: Quaternion.Identity, scale: 1.0f, radius: 0.100f, cylHeight: 0.200f), ShadowShape.Bsp( gfxObjId: 0x010044B5u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)), ShadowShape.Bsp( gfxObjId: 0x010044B6u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)), ShadowShape.Bsp( gfxObjId: 0x010044B6u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)) }; [Fact] public void RegisterMultiPart_FourShapes_AllShareEntityId() { var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart( entityId: doorEntityId, entityWorldPos: new Vector3(132.6f, 17.1f, 94.08f), entityWorldRot: Quaternion.Identity, shapes: DoorShapes(), state: 0x10008u, flags: EntityCollisionFlags.None, worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId); int found = 0; foreach (var entry in reg.AllEntriesForDebug()) { if (entry.EntityId == doorEntityId) found++; } Assert.True(found >= 4, $"Expected at least 4 entries for door entity (one per shape); found {found}"); } [Fact] public void RegisterMultiPart_EmptyShapeList_NoOp() { var reg = new ShadowObjectRegistry(); reg.RegisterMultiPart( entityId: 0x1u, entityWorldPos: Vector3.Zero, entityWorldRot: Quaternion.Identity, shapes: System.Array.Empty(), state: 0u, flags: EntityCollisionFlags.None, worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId); Assert.Equal(0, reg.TotalRegistered); } [Fact] public void Deregister_RemovesAllParts() { var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f), Quaternion.Identity, DoorShapes(), 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); reg.Deregister(doorEntityId); Assert.Equal(0, reg.TotalRegistered); foreach (var entry in reg.AllEntriesForDebug()) Assert.NotEqual(doorEntityId, entry.EntityId); } [Fact] public void UpdatePhysicsState_PropagatesEtherealToAllParts() { var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f), Quaternion.Identity, DoorShapes(), 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); reg.UpdatePhysicsState(doorEntityId, 0x1000Cu); // 0x10008 | 0x4 int updated = 0; foreach (var entry in reg.AllEntriesForDebug()) { if (entry.EntityId != doorEntityId) continue; Assert.Equal(0x1000Cu, entry.State); updated++; } Assert.True(updated >= 4, $"Expected all parts updated, only {updated} were"); } [Fact] public void RegisterMultiPart_PartsAcrossMultipleCells_AllCellsListed() { var reg = new ShadowObjectRegistry(); // Two shapes 30m apart in X — must span two outdoor 24m cells. var shapes = new[] { ShadowShape.Cylinder(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f, 1f, 2f), ShadowShape.Cylinder(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f, 1f, 2f), }; reg.RegisterMultiPart(0x1u, new Vector3(12f, 12f, 50f), Quaternion.Identity, shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId); // Part 1 at world (12, 12) → cell (0,0) = LbId | 1 // Part 2 at world (42, 12) → cell (1,0) = LbId | 9 var entriesIn1 = reg.GetObjectsInCell(LbId | 1u); var entriesIn9 = reg.GetObjectsInCell(LbId | 9u); Assert.Contains(entriesIn1, e => e.EntityId == 0x1u); Assert.Contains(entriesIn9, e => e.EntityId == 0x1u); } [Fact] public void Register_SingleShapeCompat_Unchanged() { var reg = new ShadowObjectRegistry(); reg.Register(42u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); Assert.Equal(1, reg.TotalRegistered); Assert.Single(reg.GetObjectsInCell(LbId | 1u), e => e.EntityId == 42u); } [Fact] public void UpdatePosition_MovesAllPartsWithEntity() { var reg = new ShadowObjectRegistry(); const uint movingEntityId = 0xA1u; var shapes = new[] { ShadowShape.Cylinder(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f, 0.5f, 1f), ShadowShape.Cylinder(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f, 0.5f, 1f), }; reg.RegisterMultiPart(movingEntityId, new Vector3(10f, 10f, 50f), Quaternion.Identity, shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId); // Move entity to (50, 10, 50). Parts should be at (50, 10, 50) and (51, 10, 50). reg.UpdatePosition(movingEntityId, new Vector3(50f, 10f, 50f), Quaternion.Identity, OffX, OffY, LbId); Vector3 expectedPart0 = new(50f, 10f, 50f); Vector3 expectedPart1 = new(51f, 10f, 50f); var atNew = reg.AllEntriesForDebug().Where(e => e.EntityId == movingEntityId).ToList(); Assert.Equal(2, atNew.Count); bool found0 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart0) < 0.01f); bool found1 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart1) < 0.01f); Assert.True(found0 && found1, "Expected both parts at new world positions (50, 10, 50) and (51, 10, 50)"); } [Fact] public void Deregister_ClearsEntityShapesCache_NoStaleUpdatePositionRebuild() { // A6.P4 door fix (2026-05-24) regression: after Deregister, a stray // UpdatePosition with the same entityId must NOT resurrect the entity // via the _entityShapes path. The Deregister cleanup added in Task 4 // (which folded Task 6 into the multi-part registration commit) clears // _entityShapes[entityId] alongside the cell-list cleanup. var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f), Quaternion.Identity, DoorShapes(), 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); reg.Deregister(doorEntityId); // Stray UpdatePosition should be a no-op now (no entry to find AND // no _entityShapes entry to rebuild from). reg.UpdatePosition(doorEntityId, new Vector3(200f, 200f, 50f), Quaternion.Identity, OffX, OffY, LbId); Assert.Equal(0, reg.TotalRegistered); } // --------------------------------------------------------------------- // AP-152 — cross-cell dispatch. CPhysicsObj::calc_cross_cells @0x00515230 // tests HAS_PHYSICS_BSP_PS at 0x00515285 and routes a BSP-bearing object // to CPhysicsObj::find_bbox_cell_list @0x00510fc0 through // 0x0051528f jne 0x515305. The cylsphere branch (0x005152d1 // CObjCell::find_cell_list @0x0052b9f0) and the sorting-sphere branch // (0x005152fb ... @0x0052b990) are BOTH below that jump and unreachable // from it. BuildFloodSpheres used to prefer Cylinders over everything // whenever any Cylinder was present, which is retail's SECOND priority // applied ahead of its first. // --------------------------------------------------------------------- /// Cells of the landblock that hold at least one row for owner. private static List OutdoorCellsHolding(ShadowObjectRegistry reg, uint ownerId) { var cells = new List(); for (uint index = 1u; index <= 64u; index++) { uint cellId = LbId | index; if (reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId)) cells.Add(cellId); } return cells; } private static ShadowShape Cyl(float radius, Vector3 localPosition = default) => ShadowShape.Cylinder( gfxObjId: 0u, localPosition: localPosition, localRotation: Quaternion.Identity, scale: 1f, radius: radius, cylHeight: radius * 2f); /// /// A physics-BSP part shape. defaults /// OFF-CENTRE because that is the DAT-real configuration: 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 bounding sphere further /// from the part origin than half their own radius. A fixture pinned at /// Vector3.Zero is the one configuration in which discarding the /// centre is invisible. /// private static ShadowShape Bsp( float radius, Vector3 boundsCenter = default, Vector3 localPosition = default, Quaternion localRotation = default) => ShadowShape.Bsp( gfxObjId: 0x010044B5u, localPosition: localPosition, localRotation: localRotation == default ? Quaternion.Identity : localRotation, scale: 1f, localGeometry: ShadowPartGeometry.Create( new FlatCollisionSphere( boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter, radius), null)); private static List FloodCellsFor(params ShadowShape[] shapes) { var reg = new ShadowObjectRegistry(); const uint ownerId = 0xBEEF01u; // Centre of the landblock's cell (1,1) so a 14 m footprint stays // inside the block's own 8x8 outdoor grid on every side. reg.RegisterMultiPart( ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity, shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); return OutdoorCellsHolding(reg, ownerId); } [Fact] public void BuildFloodSpheres_BspBearingOwner_FloodsFromBspNotFromCylinder() { List cylinderOnly = FloodCellsFor(Cyl(0.5f)); List bspOnly = FloodCellsFor(Bsp(14f)); List mixed = FloodCellsFor(Cyl(0.5f), Bsp(14f)); // Controls: the two footprints must actually differ, or the fact below // is satisfiable by any dispatch rule at all. Assert.Equal([LbId | 10u], cylinderOnly); Assert.True(bspOnly.Count > 1, $"BSP footprint control failed: expected >1 cell, got {bspOnly.Count}"); // The fact: a mixed list floods from the BSP shapes, exactly as if the // cylinder were not there. Retail 0x0051528f. Assert.Equal(bspOnly, mixed); Assert.NotEqual(cylinderOnly, mixed); } /// /// AP-156. Retail's per-part cross-cell walk transforms the BSP root /// bounding sphere's CENTRE through the part's own frame before it uses /// the radius: CGfxObj::physics_sphere is /// BSPTREE::GetSphere(physics_bsp) @0x005397e0 (root /// BSPNODE + 4, past its vftable), and /// CEnvCell::find_transit_cells @0x0052cae0 — the part-array /// overload reached from find_bbox_cell_list @0x00510fc0 through /// CPartArray::calc_cross_cells_static @0x00518160's /// [vtbl+0x7c] — loads it at 0x0052cb36 mov esi,[ecx+0x74], /// runs Position::localtolocal on it against /// [part+0x30] (0x0052cb4c add eax,0x30), and only then /// reads [esi+0xc] for the radius. /// /// /// acdream used to flood from the part ORIGIN with the sphere's radius. /// Over the installed DAT that failed to contain the parts' own physics /// polygons for 428 of the 530 BSP-bearing Setups, shortfall up to /// 35.869 m at entity scale 1.75 (0x0200129A) — under-inclusive /// membership, the #98 / #168 class. (172 is AP-152's DISPATCH /// population; the AP-156 row's original "170 of 172" understated this /// 2.5x and was corrected at the fix review.) /// /// [Fact] public void BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin() { // Same sphere, three placements of the SAME 6 m radius: // a) part origin at (0,0,0), bounds centre at the origin too; // b) part origin at (0,0,0), bounds centre 30 m along +Y; // c) part origin 30 m along +Y, bounds centre at the origin. // (b) and (c) describe the identical world sphere, so they must flood // the identical cells — and neither may equal (a). List concentric = FloodCellsFor( Bsp(6f, boundsCenter: new Vector3(0.001f, 0f, 0f))); List viaBoundsCentre = FloodCellsFor( Bsp(6f, boundsCenter: new Vector3(0f, 30f, 0f))); List viaPartOrigin = FloodCellsFor( Bsp(6f, boundsCenter: new Vector3(0.001f, 0f, 0f), localPosition: new Vector3(0f, 30f, 0f))); // Control: the offset must actually move the footprint, or the // equality below is satisfiable by ignoring BoundsCenter entirely. Assert.NotEqual(concentric, viaBoundsCentre); Assert.Equal(viaPartOrigin, viaBoundsCentre); } /// /// Retail's 10-sphere clamp is on the CYLSPHERE branch and nowhere else. /// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count /// (0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa); the /// BSP branch — find_bbox_cell_list @0x00510fc0 → /// CPartArray::calc_cross_cells_static @0x00518160 → /// CEnvCell::find_transit_cells @0x0052cae0 — walks every part with /// no cap, and the sorting-sphere overload @0x0052b990 takes one sphere. /// 7 installed Setups carry more than 10 physics-BSP parts (max 49, /// Setup 0x02001A91) and landblock-baked stair runs and fences routinely /// do, so clamping the BSP branch dropped their tail parts out of the /// flood entirely. /// [Fact] public void BuildFloodSpheres_CapsCylSpheresAtTenButNeverTheBspParts() { // Ten shapes on the owner's own cell, then an eleventh 72 m north. var near = new Vector3(0f, 0f, 0f); var far = new Vector3(0f, 72f, 0f); uint ownCell = LbId | (uint)(1 * 8 + 1 + 1); // (x=1, y=1) uint farCell = LbId | (uint)(1 * 8 + 4 + 1); // (x=1, y=4) var bsp = new ShadowShape[11]; for (int i = 0; i < 10; i++) bsp[i] = Bsp(1f, boundsCenter: new Vector3(0.001f, 0f, 0f)); bsp[10] = Bsp(1f, boundsCenter: far); List bspCells = FloodCellsFor(bsp); var cyls = new ShadowShape[11]; for (int i = 0; i < 10; i++) cyls[i] = Cyl(1f, near); cyls[10] = Cyl(1f, far); List cylCells = FloodCellsFor(cyls); Assert.Contains(ownCell, bspCells); Assert.Contains(ownCell, cylCells); // The eleventh BSP part still floods; the eleventh cylsphere does not. Assert.Contains(farCell, bspCells); Assert.DoesNotContain(farCell, cylCells); } /// /// The BoundsCentre is expressed in the SHAPE's own frame, so the part's /// LocalRotation must carry it — exactly as retail transforms the sphere /// centre through [part+0x30], the part's full Position, not just /// its origin. A +Y offset on a part yawed 180 degrees must land at -Y. /// [Fact] public void BuildFloodSpheres_BspShape_RotatesTheBoundsCentreByThePartRotation() { Quaternion yaw180 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI); List yawed = FloodCellsFor( Bsp(6f, boundsCenter: new Vector3(0f, 20f, 0f), localRotation: yaw180)); List negatedUnrotated = FloodCellsFor( Bsp(6f, boundsCenter: new Vector3(0f, -20f, 0f))); List unrotated = FloodCellsFor( Bsp(6f, boundsCenter: new Vector3(0f, 20f, 0f))); Assert.NotEqual(unrotated, negatedUnrotated); // control Assert.Equal(negatedUnrotated, yawed); } /// /// The AP-152 delta end-to-end: a CylSphere+BSP Setup (73 of the 172 /// affected installed Setups are this shape) registered through the /// production builder. Before the fix the emitted list carried both, and /// BuildFloodSpheres' cylinder preference confined the owner to the /// cylinder's cell while its slab BSP reached further — an object absent /// from shadow cells it physically occupies, the #98 / #168 symptom class. /// /// /// The BSP bounds are supplied through the production seam /// (physicsBspBounds, the same resolver /// LiveEntityCollisionBuilder passes) and are OFF-CENTRE, so the /// flood is asserted where the geometry is rather than where the part /// origin is. /// /// [Fact] public void FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint() { const uint part = 0x010044B5u; var setup = new DatReaderWriter.DBObjs.Setup { Parts = { part }, CylSpheres = { new DatReaderWriter.Types.CylSphere { Radius = 0.5f, Height = 1f, Origin = Vector3.Zero } }, }; // 14 m stands in for a slab wide enough to leave its own landcell; // the +18 m Y offset stands in for the 376-of-973 installed parts // whose root sphere is nowhere near the part origin. var bounds = new FlatCollisionSphere(new Vector3(0f, 18f, 0f), 14f); IReadOnlyList shapes = ShadowShapeBuilder.FromSetup( setup, entScale: 1f, hasPhysicsBsp: id => id == part, physicsBspBounds: id => id == part ? ShadowPartGeometry.Create(bounds, null) : (ShadowPartGeometry?)null); ShadowShape only = Assert.Single(shapes); Assert.Equal(ShadowCollisionType.BSP, only.CollisionType); Assert.Equal(14f, only.Radius); Assert.Equal(new Vector3(0f, 18f, 0f), only.BoundsCenter); var reg = new ShadowObjectRegistry(); const uint ownerId = 0xBEEF02u; reg.RegisterMultiPart( ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity, shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); List cells = OutdoorCellsHolding(reg, ownerId); Assert.True(cells.Count > 1, $"Expected the slab footprint to span more than its own landcell; got {cells.Count}"); // Landcells are 24 m. The slab's sphere is centred at y = 54 m with // r = 14 m, so it spans y in [40, 68] — rows 1 and 2, and it comes no // closer than 16 m to row 0 (y < 24). Flooding it around the PART // ORIGIN instead (y = 36 m, span [22, 50]) would drag row 0 in. Assert.Contains(LbId | (uint)(1 * 8 + 2 + 1), cells); // (x=1, y=2) Assert.DoesNotContain(LbId | (uint)(1 * 8 + 0 + 1), cells); // (x=1, y=0) } }