feat(phys): ShadowObjectRegistry.RegisterMultiPart

Multi-shape entity registration matching retail's CPhysicsObj model: one
logical entity emits N ShadowEntry rows (one per CylSphere / Sphere /
Part-BSP), all sharing the entity's EntityId. _entityShapes caches the
original shape list per entity for UpdatePosition to recompose part
transforms when the entity moves.

Existing UpdatePhysicsState / Deregister / GetObjectsInCell /
AllEntriesForDebug work unchanged — they iterate by EntityId; multiple
matching entries get handled automatically.

AllEntriesForDebug updated to enumerate all parts per entity (not just
the first) by iterating the first cell that holds entries for each entity.
Single-shape callers that previously relied on deduplicated-by-EntityId
behavior are unaffected since they register exactly one entry per entity.

Six new tests: AllShareEntityId, EmptyShapeList_NoOp,
Deregister_RemovesAllParts, UpdatePhysicsState_PropagatesEtherealToAllParts,
PartsAcrossMultipleCells_AllCellsListed, Register_SingleShapeCompat_Unchanged.

All 24 existing ShadowObjectRegistry tests pass via the unchanged
single-shape Register API. 11/11 CellarUpTrajectoryReplayTests pass.
7/7 ShadowShapeBuilderTests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-24 15:19:29 +02:00
parent 1454eab75a
commit fca0a13217
2 changed files with 291 additions and 6 deletions

View file

@ -0,0 +1,164 @@
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);
}
}