Adds the regression pin for the _entityShapes cleanup that fca0a13
already implemented (Task 4 folded Task 6's Deregister change in for
the multi-part tests to pass). Verifies that a stray UpdatePosition
after Deregister is a no-op — entity is NOT resurrected via the
_entityShapes rebuild path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
8.5 KiB
C#
220 lines
8.5 KiB
C#
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<ShadowShape> DoorShapes() => new[]
|
|
{
|
|
new ShadowShape(
|
|
GfxObjId: 0u,
|
|
LocalPosition: new Vector3(0f, 0f, 0.018f),
|
|
LocalRotation: Quaternion.Identity,
|
|
Scale: 1.0f,
|
|
CollisionType: ShadowCollisionType.Cylinder,
|
|
Radius: 0.100f,
|
|
CylHeight: 0.200f),
|
|
new ShadowShape(
|
|
GfxObjId: 0x010044B5u,
|
|
LocalPosition: Vector3.Zero,
|
|
LocalRotation: Quaternion.Identity,
|
|
Scale: 1.0f,
|
|
CollisionType: ShadowCollisionType.BSP,
|
|
Radius: 2.0f,
|
|
CylHeight: 0f),
|
|
new ShadowShape(
|
|
GfxObjId: 0x010044B6u,
|
|
LocalPosition: Vector3.Zero,
|
|
LocalRotation: Quaternion.Identity,
|
|
Scale: 1.0f,
|
|
CollisionType: ShadowCollisionType.BSP,
|
|
Radius: 2.0f,
|
|
CylHeight: 0f),
|
|
new ShadowShape(
|
|
GfxObjId: 0x010044B6u,
|
|
LocalPosition: Vector3.Zero,
|
|
LocalRotation: Quaternion.Identity,
|
|
Scale: 1.0f,
|
|
CollisionType: ShadowCollisionType.BSP,
|
|
Radius: 2.0f,
|
|
CylHeight: 0f)
|
|
};
|
|
|
|
[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<ShadowShape>(),
|
|
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[]
|
|
{
|
|
new ShadowShape(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f,
|
|
ShadowCollisionType.Cylinder, 1f, 2f),
|
|
new ShadowShape(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f,
|
|
ShadowCollisionType.Cylinder, 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[]
|
|
{
|
|
new ShadowShape(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f,
|
|
ShadowCollisionType.Cylinder, 0.5f, 1f),
|
|
new ShadowShape(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f,
|
|
ShadowCollisionType.Cylinder, 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);
|
|
}
|
|
}
|