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

@ -17,6 +17,13 @@ public sealed class ShadowObjectRegistry
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new(); private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
/// <summary>
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
/// <see cref="UpdatePosition"/> to recompose part world-transforms when
/// the entity moves. Cleared by <see cref="Deregister"/>.
/// </summary>
private readonly Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes = new();
/// <summary> /// <summary>
/// Register an entity into the cells it overlaps based on world position + radius. /// Register an entity into the cells it overlaps based on world position + radius.
/// ///
@ -98,6 +105,102 @@ public sealed class ShadowObjectRegistry
_entityToCells[entityId] = cellIds; _entityToCells[entityId] = cellIds;
} }
/// <summary>
/// A6.P4 door fix (2026-05-24): register one logical entity composed of
/// multiple collision shapes. All emitted <see cref="ShadowEntry"/> rows
/// share <paramref name="entityId"/>, so <see cref="UpdatePhysicsState"/>
/// propagates an ETHEREAL flip to every part (the existing per-entityId
/// iteration handles this naturally). The shape list is cached in
/// <see cref="_entityShapes"/> so <see cref="UpdatePosition"/> can
/// recompose part world-transforms when the entity moves.
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> →
/// <c>CPartArray::FindObjCollisions</c> at
/// <c>acclient_2013_pseudo_c.txt:276961-286250</c>. One PhysicsObj per
/// entity, parts iterated for collision testing.
/// </para>
/// </summary>
public void RegisterMultiPart(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint cellScope = 0u)
{
Deregister(entityId);
if (shapes.Count == 0) return;
_entityShapes[entityId] = shapes;
var allCells = new List<uint>();
var seenCells = new HashSet<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
foreach (var shape in shapes)
{
var rotatedLocal = Vector3.Transform(shape.LocalPosition, entityWorldRot);
var partWorldPos = entityWorldPos + rotatedLocal;
var partWorldRot = entityWorldRot * shape.LocalRotation;
var entry = new ShadowEntry(
EntityId: entityId,
GfxObjId: shape.GfxObjId,
Position: partWorldPos,
Rotation: partWorldRot,
Radius: shape.Radius,
CollisionType: shape.CollisionType,
CylHeight: shape.CylHeight,
Scale: shape.Scale,
State: state,
Flags: flags,
LocalPosition: shape.LocalPosition,
LocalRotation: shape.LocalRotation);
if (cellScope != 0u)
{
AddEntryToCell(entry, cellScope);
if (seenCells.Add(cellScope)) allCells.Add(cellScope);
continue;
}
float localX = partWorldPos.X - worldOffsetX;
float localY = partWorldPos.Y - worldOffsetY;
float r = shape.Radius;
int minCx = Math.Max(0, (int)((localX - r) / 24f));
int maxCx = Math.Min(7, (int)((localX + r) / 24f));
int minCy = Math.Max(0, (int)((localY - r) / 24f));
int maxCy = Math.Min(7, (int)((localY + r) / 24f));
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
AddEntryToCell(entry, cellId);
if (seenCells.Add(cellId)) allCells.Add(cellId);
}
}
}
_entityToCells[entityId] = allCells;
}
/// <summary>Helper: append a <see cref="ShadowEntry"/> to a cell's
/// list, creating the list if needed.</summary>
private void AddEntryToCell(ShadowEntry entry, uint cellId)
{
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List<ShadowEntry>();
_cells[cellId] = list;
}
list.Add(entry);
}
/// <summary> /// <summary>
/// Update an already-registered entity's world position + rotation, /// Update an already-registered entity's world position + rotation,
/// preserving its <see cref="ShadowEntry.State"/>, /// preserving its <see cref="ShadowEntry.State"/>,
@ -201,6 +304,7 @@ public sealed class ShadowObjectRegistry
list.RemoveAll(e => e.EntityId == entityId); list.RemoveAll(e => e.EntityId == entityId);
} }
_entityToCells.Remove(entityId); _entityToCells.Remove(entityId);
_entityShapes.Remove(entityId);
} }
/// <summary>Remove all entities belonging to a landblock.</summary> /// <summary>Remove all entities belonging to a landblock.</summary>
@ -386,18 +490,35 @@ public sealed class ShadowObjectRegistry
/// <summary> /// <summary>
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells). /// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
/// For each entity, returns the first entry found in any cell it occupies. /// Single-shape entities return one entry per shape; multi-part entities
/// return one entry per registered part (including duplicate shapes at the
/// same position). Each entity is enumerated exactly once per logical part:
/// we use the first cell the entity occupies to read its entries, avoiding
/// re-emitting the same part for each cell it overlaps.
/// Intended for debug rendering only. /// Intended for debug rendering only.
/// </summary> /// </summary>
public IEnumerable<ShadowEntry> AllEntriesForDebug() public IEnumerable<ShadowEntry> AllEntriesForDebug()
{ {
var seen = new HashSet<uint>(); var seenEntities = new HashSet<uint>();
foreach (var kvp in _cells) foreach (var kvp in _entityToCells)
{ {
foreach (var entry in kvp.Value) uint entityId = kvp.Key;
if (!seenEntities.Add(entityId)) continue;
// Use the first cell that holds entries for this entity.
foreach (uint cellId in kvp.Value)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
bool anyFound = false;
foreach (var entry in list)
{
if (entry.EntityId == entityId)
{ {
if (seen.Add(entry.EntityId))
yield return entry; yield return entry;
anyFound = true;
}
}
if (anyFound) break; // Only use the first cell — avoids duplicating multi-cell shapes.
} }
} }
} }

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