diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index e0425d8..0780891 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -17,6 +17,13 @@ public sealed class ShadowObjectRegistry private readonly Dictionary> _cells = new(); private readonly Dictionary> _entityToCells = new(); // for deregistration + /// + /// A6.P4 door fix (2026-05-24): per-entity original shape list, used by + /// to recompose part world-transforms when + /// the entity moves. Cleared by . + /// + private readonly Dictionary> _entityShapes = new(); + /// /// Register an entity into the cells it overlaps based on world position + radius. /// @@ -98,6 +105,102 @@ public sealed class ShadowObjectRegistry _entityToCells[entityId] = cellIds; } + /// + /// A6.P4 door fix (2026-05-24): register one logical entity composed of + /// multiple collision shapes. All emitted rows + /// share , so + /// propagates an ETHEREAL flip to every part (the existing per-entityId + /// iteration handles this naturally). The shape list is cached in + /// so can + /// recompose part world-transforms when the entity moves. + /// + /// + /// Retail anchor: CPhysicsObj::FindObjCollisions → + /// CPartArray::FindObjCollisions at + /// acclient_2013_pseudo_c.txt:276961-286250. One PhysicsObj per + /// entity, parts iterated for collision testing. + /// + /// + public void RegisterMultiPart( + uint entityId, + Vector3 entityWorldPos, + Quaternion entityWorldRot, + System.Collections.Generic.IReadOnlyList 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(); + var seenCells = new HashSet(); + 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; + } + + /// Helper: append a to a cell's + /// list, creating the list if needed. + private void AddEntryToCell(ShadowEntry entry, uint cellId) + { + if (!_cells.TryGetValue(cellId, out var list)) + { + list = new List(); + _cells[cellId] = list; + } + list.Add(entry); + } + /// /// Update an already-registered entity's world position + rotation, /// preserving its , @@ -201,6 +304,7 @@ public sealed class ShadowObjectRegistry list.RemoveAll(e => e.EntityId == entityId); } _entityToCells.Remove(entityId); + _entityShapes.Remove(entityId); } /// Remove all entities belonging to a landblock. @@ -386,18 +490,35 @@ public sealed class ShadowObjectRegistry /// /// 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. /// public IEnumerable AllEntriesForDebug() { - var seen = new HashSet(); - foreach (var kvp in _cells) + var seenEntities = new HashSet(); + 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 (seen.Add(entry.EntityId)) - yield return entry; + if (!_cells.TryGetValue(cellId, out var list)) continue; + bool anyFound = false; + foreach (var entry in list) + { + if (entry.EntityId == entityId) + { + yield return entry; + anyFound = true; + } + } + if (anyFound) break; // Only use the first cell — avoids duplicating multi-cell shapes. } } } diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs new file mode 100644 index 0000000..e1c03b2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs @@ -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 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(), + 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); + } +}