diff --git a/docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md b/docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md new file mode 100644 index 0000000..c488b0a --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md @@ -0,0 +1,1585 @@ +# Door Collision Per-Part BSP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Server-spawned entities (doors, NPCs, chests) register one shadow row per collision shape (CylSpheres + Spheres + per-Part BSPs), all sharing the entity's EntityId so state propagation works retail-faithfully. Closes the M1.5 "doors don't block" bug. + +**Architecture:** New `ShadowShape` record + new `ShadowShapeBuilder.FromSetup` pure function + new `ShadowObjectRegistry.RegisterMultiPart` API. The single-shape `Register` becomes a 1-element wrapper. `RegisterLiveEntityCollision` and the landblock-static loop both call `RegisterMultiPart`, unifying the two paths. Retail anchor: `CObjCell::find_obj_collisions` → `CPhysicsObj::FindObjCollisions` → `CPartArray::FindObjCollisions` → `CPhysicsPart::find_obj_collisions` → `CGfxObj::find_obj_collisions` (`acclient_2013_pseudo_c.txt:276776-275055`). + +**Tech Stack:** C# / .NET 10 / DatReaderWriter / xUnit. Worktree: `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`. + +**Spec:** `docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md` (commit `d71ceab`). + +--- + +## File Structure + +**New files:** +- `src/AcDream.Core/Physics/ShadowShape.cs` — record struct, ~25 LOC +- `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` — pure function over Setup, ~80 LOC +- `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` — unit tests, ~120 LOC +- `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` — registry tests, ~180 LOC +- `tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl` — live capture (1 record) + +**Modified files:** +- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — add `RegisterMultiPart`, extend `ShadowEntry` with `LocalPosition`/`LocalRotation`, add `_entityShapes` map, make `Register` a wrapper. Net +80 LOC. +- `src/AcDream.App/Rendering/GameWindow.cs:3076-3160` (`RegisterLiveEntityCollision`) — replace body with builder + multi-part call. Net -50 / +25. +- `src/AcDream.App/Rendering/GameWindow.cs:5893-6213` (landblock-static loop) — replace per-part loop with single multi-part call. Net -300 / +30. +- `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs` — add `LiveCompare_DoorBlocksWhenClosed`. Net +80. + +**Strip-on-ship (investigation diagnostics):** +- `src/AcDream.App/Rendering/GameWindow.cs` — `_doorSetupDumped` field + `[door-setup-dump]` block +- Keep: `[entity-phantom]` (general-purpose), `[entity-source]` radius/height/pos (general-purpose), `[cyl-test]` (useful for future cylinder debugging) + +--- + +## Task 1: Add `ShadowShape` record + +**Files:** +- Create: `src/AcDream.Core/Physics/ShadowShape.cs` +- Test: `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (smoke construction only — full builder tests in Task 2) + +- [ ] **Step 1: Create file with the record** + +```csharp +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// One collision-bearing shape attached to a logical PhysicsObj. +/// A door emits 3-4 (CylSphere(s) + Sphere(s) + Part-BSPs); a creature +/// may emit a few; a simple item zero. Positions and rotations are +/// LOCAL to the entity's origin so +/// can re-transform them when the entity moves. +/// +/// +/// Retail anchor: CPhysicsPart + CPhysicsPart::find_obj_collisions +/// at acclient_2013_pseudo_c.txt:275045-275055. Each part stores its +/// own transform and dispatches per-part collision to its GfxObj. +/// +/// +public readonly record struct ShadowShape( + uint GfxObjId, + Vector3 LocalPosition, + Quaternion LocalRotation, + float Scale, + ShadowCollisionType CollisionType, + float Radius, + float CylHeight); +``` + +- [ ] **Step 2: Verify build succeeds** + +Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.Core/Physics/ShadowShape.cs +git commit -m "feat(phys): add ShadowShape record (no callers yet) + +Standalone record representing one collision-bearing shape attached to +a logical PhysicsObj. Foundation for the per-part BSP collision fix +that closes the M1.5 \"doors don't block\" bug. Spec at +docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md. + +No callers in this commit; integration follows in subsequent commits. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Implement `ShadowShapeBuilder.FromSetup` + +**Files:** +- Create: `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` +- Test: `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` + +Note on signature: per design refinement during planning, `FromSetup` takes a `Func hasPhysicsBsp` predicate (not the concrete `PhysicsDataCache`) so tests can stub the BSP-presence check without instantiating a full cache. Production callers pass `id => cache.GetGfxObj(id)?.BSP?.Root is not null`. + +- [ ] **Step 1: Write the failing test — door setup produces 4 shapes (0 cyl + 1 sphere + 3 BSP)** + +Create `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class ShadowShapeBuilderTests +{ + /// + /// Synthetic Setup mirroring live dump of 0x020019FF (cottage door) + /// captured 2026-05-24: 0 cylSpheres, 1 sphere (r=0.100, origin=(0,0,0.018)), + /// 3 parts (0x010044B5 + 0x010044B6 + 0x010044B6), setup.Radius=0.141, + /// setup.Height=0.200. PlacementFrames[Default] has identity transforms + /// for all 3 parts (door is upright at spawn). + /// + private static Setup CreateDoorSetup() + { + var setup = new Setup + { + Radius = 0.141f, + Height = 0.200f, + StepUpHeight = 0.090f, + StepDownHeight = 0.090f, + Parts = new List { 0x010044B5u, 0x010044B6u, 0x010044B6u }, + CylSpheres = new List(), + Spheres = new List + { + new() { Radius = 0.100f, Origin = new Vector3(0f, 0f, 0.018f) } + }, + PlacementFrames = new Dictionary + { + [Placement.Default] = new AnimationFrame + { + Frames = new List + { + new() { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new() { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new() { Origin = Vector3.Zero, Orientation = Quaternion.Identity } + } + } + } + }; + return setup; + } + + [Fact] + public void FromSetup_DoorSetup_ProducesFourShapes() + { + var setup = CreateDoorSetup(); + Func hasBsp = id => id == 0x010044B5u || id == 0x010044B6u; + + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1.0f, hasBsp); + + // 0 cyl + 1 sphere (as short cylinder) + 3 BSP = 4 shapes + Assert.Equal(4, shapes.Count); + + int cylinderCount = 0; + int bspCount = 0; + foreach (var s in shapes) + { + if (s.CollisionType == ShadowCollisionType.Cylinder) cylinderCount++; + else if (s.CollisionType == ShadowCollisionType.BSP) bspCount++; + } + Assert.Equal(1, cylinderCount); // the Sphere → short Cylinder + Assert.Equal(3, bspCount); // the 3 parts with BSP + } +} +``` + +- [ ] **Step 2: Run test, expect failure (builder doesn't exist yet)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes" -v minimal 2>&1 | tail -10` +Expected: build failure or "ShadowShapeBuilder not found" + +- [ ] **Step 3: Implement `ShadowShapeBuilder.FromSetup`** + +Create `src/AcDream.Core/Physics/ShadowShapeBuilder.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// Pure-function builder that translates a into a list of +/// s suitable for registration via +/// . +/// +/// +/// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no +/// CylSpheres are present (matches retail and the existing landblock-static +/// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a +/// non-null PhysicsBSP → per-part BSP shape, with local transforms from +/// PlacementFrames[Resting | Default | first available]. +/// +/// +/// +/// Retail anchor: CPhysicsObj::FindObjCollisions calls +/// CPartArray::FindObjCollisions which iterates parts; each part's +/// find_obj_collisions tests CylSpheres + GfxObj BSP. We emit one +/// ShadowShape per part contribution so the existing FindObjCollisions +/// iteration loop in tests each part independently. +/// +/// +public static class ShadowShapeBuilder +{ + /// + /// Build the shape list for a Setup. + /// + /// The Setup to walk. + /// The entity's overall scale factor; multiplies + /// every radius, height, and local offset. + /// Predicate: does the GfxObj with this id + /// have a non-null PhysicsBSP? Production: id => cache.GetGfxObj(id)?.BSP?.Root is not null. + public static IReadOnlyList FromSetup( + Setup setup, + float entScale, + Func hasPhysicsBsp) + { + if (setup is null) throw new ArgumentNullException(nameof(setup)); + if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); + + var result = new List(); + + // 1. CylSpheres — each becomes a Cylinder shape. + foreach (var cyl in setup.CylSpheres) + { + if (cyl.Radius <= 0f) continue; + float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f; + result.Add(new ShadowShape( + GfxObjId: 0u, + LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale, + LocalRotation: Quaternion.Identity, + Scale: entScale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: cyl.Radius * entScale, + CylHeight: baseHeight * entScale)); + } + + // 2. Spheres — only when no CylSpheres (matches landblock-static convention + // at GameWindow.cs:6034). Each becomes a short Cylinder. + if (setup.CylSpheres.Count == 0) + { + foreach (var sph in setup.Spheres) + { + if (sph.Radius <= 0f) continue; + result.Add(new ShadowShape( + GfxObjId: 0u, + LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale, + LocalRotation: Quaternion.Identity, + Scale: entScale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: sph.Radius * entScale, + CylHeight: sph.Radius * 2f * entScale)); + } + } + + // 3. Parts — one BSP shape per part with a non-null PhysicsBSP. + AnimationFrame? placementFrame = ResolvePlacementFrame(setup); + for (int i = 0; i < setup.Parts.Count; i++) + { + uint gfxId = (uint)setup.Parts[i]; + if (!hasPhysicsBsp(gfxId)) continue; + + Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count + ? placementFrame.Frames[i] + : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + + // BSP radius is the part's bounding sphere; the production caller's + // PhysicsGfxObj would have it, but for builder-purity we use a + // sensible default. The actual broadphase radius gets tightened by + // the registry when the BSP is queried at collision time. + // TODO at builder-call-site: pass the real BSP radius if available. + float bspRadius = 2f * entScale; // generic loose-but-safe default + + result.Add(new ShadowShape( + GfxObjId: gfxId, + LocalPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale, + LocalRotation: partFrame.Orientation, + Scale: entScale, + CollisionType: ShadowCollisionType.BSP, + Radius: bspRadius, + CylHeight: 0f)); + } + + return result; + } + + /// Resolve the placement frame in priority Resting → Default → + /// first available. Mirrors SetupMesh.Flatten's convention. + private static AnimationFrame? ResolvePlacementFrame(Setup setup) + { + if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting; + if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def; + foreach (var kvp in setup.PlacementFrames) return kvp.Value; + return null; + } +} +``` + +Implementation note: the `bspRadius = 2f` placeholder is intentional — the production builder caller (Task 5/6) replaces it with the real `cache.GetGfxObj(gfxId).BoundingSphere.Radius` at registration time. The builder stays pure (no PhysicsDataCache dependency); the caller fills in the BSP radius. + +To keep the builder pure AND avoid the "TODO at builder-call-site," refine the predicate to return the radius directly: + +```csharp +Func bspRadiusOrNull // null when no BSP, else the radius +``` + +But that complicates tests. Pragmatic compromise: keep `Func` and document the post-builder radius substitution. Implementer can refactor later if it becomes friction. + +- [ ] **Step 4: Run test, expect pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes" -v minimal 2>&1 | tail -5` +Expected: `Passed! - Failed: 0, Passed: 1` + +- [ ] **Step 5: Add 5 more tests covering edge cases** + +Append to `ShadowShapeBuilderTests.cs`: + +```csharp +[Fact] +public void FromSetup_DoorSetup_SphereAtExpectedLocalOffset() +{ + var setup = CreateDoorSetup(); + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true); + + var sphereAsCyl = shapes.Find(s => s.CollisionType == ShadowCollisionType.Cylinder); + Assert.NotEqual(default, sphereAsCyl); + Assert.Equal(0f, sphereAsCyl.LocalPosition.X, 4); + Assert.Equal(0f, sphereAsCyl.LocalPosition.Y, 4); + Assert.Equal(0.018f, sphereAsCyl.LocalPosition.Z, 4); + Assert.Equal(0.100f, sphereAsCyl.Radius, 4); +} + +[Fact] +public void FromSetup_PartWithoutBsp_SkipsBspShape() +{ + var setup = CreateDoorSetup(); + // Predicate says ONLY 0x010044B5 has BSP; other 2 parts don't + Func hasBsp = id => id == 0x010044B5u; + + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, hasBsp); + + int bspCount = 0; + foreach (var s in shapes) + if (s.CollisionType == ShadowCollisionType.BSP) bspCount++; + Assert.Equal(1, bspCount); // only the BSP-bearing part emits a BSP shape +} + +[Fact] +public void FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders() +{ + var setup = new Setup + { + Parts = new List { 0x02000001u }, + CylSpheres = new List + { + new() { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0, 0, 0.6f) } + }, + Spheres = new List + { + new() { Radius = 0.50f, Origin = new Vector3(0, 0, 0.7f) } + }, + PlacementFrames = new Dictionary() + }; + + // Part HAS a BSP but the predicate filters it out so the test isolates + // the CylSphere/Sphere convention (Spheres skipped when CylSpheres present). + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => false); + + Assert.Single(shapes); + Assert.Equal(ShadowCollisionType.Cylinder, shapes[0].CollisionType); + Assert.Equal(0.40f, shapes[0].Radius, 3); + Assert.Equal(1.20f, shapes[0].CylHeight, 3); +} + +[Fact] +public void FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets() +{ + var setup = CreateDoorSetup(); + + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 2.0f, _ => true); + + foreach (var s in shapes) + { + Assert.Equal(2.0f, s.Scale, 3); + if (s.CollisionType == ShadowCollisionType.Cylinder) + { + // Sphere(0.100) at (0,0,0.018) × 2 → radius 0.200, position (0,0,0.036) + Assert.Equal(0.200f, s.Radius, 3); + Assert.Equal(0.036f, s.LocalPosition.Z, 3); + } + } +} + +[Fact] +public void FromSetup_EmptySetup_ReturnsEmptyList() +{ + var setup = new Setup + { + Parts = new List(), + CylSpheres = new List(), + Spheres = new List(), + PlacementFrames = new Dictionary() + }; + + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true); + + Assert.Empty(shapes); +} + +[Fact] +public void FromSetup_NullSetup_Throws() +{ + Assert.Throws( + () => ShadowShapeBuilder.FromSetup(null!, 1.0f, _ => true)); +} +``` + +- [ ] **Step 6: Run full test class** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowShapeBuilderTests" -v minimal 2>&1 | tail -10` +Expected: `Passed: 6, Failed: 0` + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core/Physics/ShadowShapeBuilder.cs tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs +git commit -m "feat(phys): ShadowShapeBuilder.FromSetup + +Pure function translating Setup → IReadOnlyList. Walks +CylSpheres + Spheres (only when no CylSpheres) + Parts (only when the +GfxObj has a non-null PhysicsBSP), using PlacementFrames in the same +Resting → Default → first-available priority as SetupMesh.Flatten. + +Six tests pin the behavior: door setup produces 4 shapes (0+1+3), sphere +local offset matches Setup data, parts without BSP are skipped, creature +setups with CylSpheres skip Spheres, scale factor multiplies all radii +and offsets, empty setup returns empty list, null setup throws. + +No callers in this commit; RegisterMultiPart + the GameWindow callers +follow. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Extend `ShadowEntry` with local-offset fields + +**Files:** +- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (ShadowEntry record struct) + +- [ ] **Step 1: Locate the ShadowEntry record at the bottom of ShadowObjectRegistry.cs** + +Run: `git grep -n "public readonly record struct ShadowEntry" src/AcDream.Core/Physics/ShadowObjectRegistry.cs` +Expected: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:391:public readonly record struct ShadowEntry(` + +- [ ] **Step 2: Add LocalPosition + LocalRotation fields** + +In `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`, change the `ShadowEntry` record from: + +```csharp +public readonly record struct ShadowEntry( + uint EntityId, + uint GfxObjId, + Vector3 Position, + Quaternion Rotation, + float Radius, + ShadowCollisionType CollisionType = ShadowCollisionType.BSP, +``` + +To (add the two new fields with default values at the end of the parameter list so existing callers stay compatible): + +```csharp +public readonly record struct ShadowEntry( + uint EntityId, + uint GfxObjId, + Vector3 Position, + Quaternion Rotation, + float Radius, + ShadowCollisionType CollisionType = ShadowCollisionType.BSP, + float CylHeight = 0f, + float Scale = 1.0f, + uint State = 0u, + EntityCollisionFlags Flags = EntityCollisionFlags.None, + // A6.P4 door fix (2026-05-24): local-to-entity transform for multi-part + // entities. ShadowObjectRegistry.UpdatePosition uses these to rebuild + // Position/Rotation when the entity moves. Single-shape callers leave + // these at default (zero offset, identity rotation) — equivalent to + // the shape sitting at the entity's origin. + Vector3 LocalPosition = default, + Quaternion LocalRotation = default); +``` + +(Adjust the order to match the current record — keep CylHeight/Scale/State/Flags where they are, append LocalPosition + LocalRotation at the END.) + +- [ ] **Step 3: Verify build and existing tests pass** + +Run: +``` +dotnet build src/AcDream.Core/AcDream.Core.csproj --nologo -v q +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryTests" -v minimal +``` +Expected: build green, `Passed: 22, Failed: 0`. + +- [ ] **Step 4: Commit** + +```bash +git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs +git commit -m "feat(phys): ShadowEntry adds LocalPosition + LocalRotation + +Local-to-entity transform fields, default-valued so existing single-shape +callers keep working unchanged. RegisterMultiPart (next commit) populates +them per part so UpdatePosition can rebuild the entry's world Position + +Rotation when the entity moves. + +All 22 existing ShadowObjectRegistry tests pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Add `RegisterMultiPart` to `ShadowObjectRegistry` + +**Files:** +- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` +- Test: `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs`: + +```csharp +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[] + { + // The Sphere-as-short-cylinder (sized like the live door's Sphere). + 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), + // 3 BSP parts at identity-frame local positions. + 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); + + // All 4 entries should share doorEntityId. + 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}"); + } +} +``` + +- [ ] **Step 2: Run test, expect failure (method doesn't exist)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryMultiPartTests.RegisterMultiPart_FourShapes_AllShareEntityId" -v minimal 2>&1 | tail -5` +Expected: build failure ("RegisterMultiPart not found") + +- [ ] **Step 3: Implement `RegisterMultiPart`** + +In `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`: + +3a) Add the `_entityShapes` private field near `_entityToCells`: + +```csharp + private readonly Dictionary> _cells = new(); + private readonly Dictionary> _entityToCells = new(); + /// + /// 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(); +``` + +3b) Add the `RegisterMultiPart` method below the existing `Register`: + +```csharp + /// + /// 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, + 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) + { + // Compose world transform from entity transform + shape local. + 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); + + // Same cell-occupancy logic as single-shape Register, applied per shape. + 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); + } +``` + +- [ ] **Step 4: Run test, expect pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryMultiPartTests.RegisterMultiPart_FourShapes_AllShareEntityId" -v minimal 2>&1 | tail -5` +Expected: `Passed: 1, Failed: 0`. + +- [ ] **Step 5: Add 5 more tests for multi-part registry behavior** + +Append to `ShadowObjectRegistryMultiPartTests.cs`: + +```csharp +[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); + + // ETHEREAL flip via SetState parser would call this: + 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() +{ + // After RegisterMultiPart lands, the single-shape Register API + // should still work. This is the compat-path test. + 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); +} +``` + +- [ ] **Step 6: Run all the multi-part tests** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryMultiPartTests" -v minimal 2>&1 | tail -10` +Expected: `Passed: 6, Failed: 0`. + +- [ ] **Step 7: Verify existing tests still pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryTests|FullyQualifiedName~CellarUpTrajectoryReplayTests" -v minimal 2>&1 | tail -5` +Expected: `Passed: 33, Failed: 0` (22 existing + 11 cellar). + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +git commit -m "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. + +Six new tests: AllShareEntityId, EmptyShapeList_NoOp, Deregister_RemovesAllParts, +UpdatePhysicsState_PropagatesEtherealToAllParts, PartsAcrossMultipleCells_AllCellsListed, +Register_SingleShapeCompat_Unchanged. + +All 22 existing ShadowObjectRegistry tests pass via the unchanged +single-shape Register API. 11/11 CellarUpTrajectoryReplayTests pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Extend `UpdatePosition` for multi-part entities + +**Files:** +- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition` method around line 116) +- Test: `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` + +- [ ] **Step 1: Write failing test** + +Append to `ShadowObjectRegistryMultiPartTests.cs`: + +```csharp +[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); + + // Old cells should be empty for this entity; new cells should hold parts. + 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); + // Allow either order + 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)"); +} +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "UpdatePosition_MovesAllPartsWithEntity" -v minimal 2>&1 | tail -5` +Expected: `Failed: 1` (current UpdatePosition only handles single-shape; either fails outright or moves only one entry). + +- [ ] **Step 3: Implement multi-part UpdatePosition** + +In `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`, locate the existing `UpdatePosition` method (around line 116) and replace its body to check `_entityShapes` first: + +```csharp + public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation, + float worldOffsetX, float worldOffsetY, uint landblockId) + { + // A6.P4 door fix (2026-05-24): if the entity was registered via + // RegisterMultiPart, we have its full shape list cached. Use that + // to recompose all part transforms instead of trying to find one + // template entry. + if (_entityShapes.TryGetValue(entityId, out var shapes)) + { + // Pull the entity-scoped state + flags from the first matching entry + // (they're shared across all parts). + uint state = 0u; + EntityCollisionFlags flags = EntityCollisionFlags.None; + if (_entityToCells.TryGetValue(entityId, out var existingCells) + && existingCells.Count > 0 + && _cells.TryGetValue(existingCells[0], out var firstList)) + { + foreach (var e in firstList) + { + if (e.EntityId == entityId) + { + state = e.State; + flags = e.Flags; + break; + } + } + } + RegisterMultiPart(entityId, worldPos, rotation, shapes, + state, flags, worldOffsetX, worldOffsetY, landblockId); + return; + } + + // Single-shape path (legacy compat for tests + entities that never + // went through RegisterMultiPart). + if (!_entityToCells.TryGetValue(entityId, out var oldCells) || oldCells.Count == 0) + return; + + ShadowEntry? template = null; + foreach (var oldCellId in oldCells) + { + if (_cells.TryGetValue(oldCellId, out var list)) + { + foreach (var e in list) + { + if (e.EntityId == entityId) + { + template = e; + break; + } + } + } + if (template is not null) break; + } + if (template is null) return; + + var t = template.Value; + Register(entityId, t.GfxObjId, worldPos, rotation, t.Radius, + worldOffsetX, worldOffsetY, landblockId, + t.CollisionType, t.CylHeight, t.Scale, + t.State, t.Flags); + } +``` + +- [ ] **Step 4: Run test, expect pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "UpdatePosition_MovesAllPartsWithEntity" -v minimal 2>&1 | tail -5` +Expected: `Passed: 1, Failed: 0`. + +- [ ] **Step 5: Verify all existing tests still pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistry|FullyQualifiedName~CellarUpTrajectoryReplay|FullyQualifiedName~ShadowShape" -v minimal 2>&1 | tail -5` +Expected: ~40 passed, 0 failed. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +git commit -m "feat(phys): UpdatePosition handles multi-part entities + +Multi-part entities cached via RegisterMultiPart's _entityShapes now +recompose all part transforms on UpdatePosition (called when the server +broadcasts UpdatePosition (0xF748) for a moving entity). Legacy +single-shape path preserved unchanged for tests + entities that never +went through RegisterMultiPart. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Extend `Deregister` to clear `_entityShapes` + +**Files:** +- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`Deregister` method around line 193) + +- [ ] **Step 1: Add the cleanup line** + +In `Deregister`, add the `_entityShapes` clear so the cache doesn't leak stale entries: + +```csharp + public void Deregister(uint entityId) + { + if (!_entityToCells.TryGetValue(entityId, out var cellIds)) + return; + + foreach (var cellId in cellIds) + { + if (_cells.TryGetValue(cellId, out var list)) + list.RemoveAll(e => e.EntityId == entityId); + } + _entityToCells.Remove(entityId); + // A6.P4 door fix (2026-05-24): clear the per-entity shape cache too. + _entityShapes.Remove(entityId); + } +``` + +- [ ] **Step 2: Verify the Deregister_RemovesAllParts test still passes (now exercises the cache cleanup implicitly)** + +The existing `Deregister_RemovesAllParts` test in Task 4 already covers behavior; this is a refinement. Add a more explicit test: + +```csharp +[Fact] +public void Deregister_ClearsEntityShapesCache_NoStaleUpdatePositionRebuild() +{ + // Regression: after Deregister, a stray UpdatePosition with the same + // entityId must NOT resurrect the entity via the _entityShapes path. + 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. + reg.UpdatePosition(doorEntityId, new Vector3(200f, 200f, 50f), + Quaternion.Identity, OffX, OffY, LbId); + + Assert.Equal(0, reg.TotalRegistered); +} +``` + +- [ ] **Step 3: Run and verify** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "Deregister_ClearsEntityShapesCache" -v minimal 2>&1 | tail -5` +Expected: `Passed: 1`. + +- [ ] **Step 4: Commit** + +```bash +git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +git commit -m "feat(phys): Deregister clears _entityShapes cache + +Prevents a stray UpdatePosition after Deregister from resurrecting the +entity via the shape cache. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: Refactor `RegisterLiveEntityCollision` to use builder + multi-part + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:3076` (`RegisterLiveEntityCollision`) + +- [ ] **Step 1: Replace the body of `RegisterLiveEntityCollision`** + +Locate `RegisterLiveEntityCollision` at line 3076. Replace its body (current lines ~3076-3160) with: + +```csharp + private void RegisterLiveEntityCollision( + AcDream.Core.World.WorldEntity entity, + DatReaderWriter.DBObjs.Setup setup, + AcDream.Core.Net.WorldSession.EntitySpawn spawn, + System.Numerics.Vector3 origin) + { + if (spawn.Position is null) return; + + float entScale = spawn.ObjScale ?? 1.0f; + + // A6.P4 door fix (2026-05-24): build the full shape list per retail's + // CPhysicsObj model. CylSpheres → Cylinder shapes, Spheres → short + // Cylinder (only when no CylSpheres), Parts with PhysicsBSP → BSP + // shapes. The single-shape fallback that produced a soda-can-sized + // bounding cylinder for doors is gone. + var shapes = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup( + setup, entScale, + hasPhysicsBsp: gfxId => + _physicsDataCache.GetGfxObj(gfxId)?.BSP?.Root is not null); + + if (shapes.Count == 0) + { + // Phantom skip (matches retail acclient_2013_pseudo_c.txt:276917 — + // no collision geometry → FindObjCollisions returns OK_TS). + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[entity-phantom] id=0x{entity.Id:X8} setup=0x{entity.SourceGfxObjOrSetupId:X8} itemType=0x{spawn.ItemType:X8} lb=0x{spawn.Position.Value.LandblockId:X8} reason=no-shapes")); + return; + } + + // Decode PvP / Player / Impenetrable from PWD._bitfield. + var flags = AcDream.Core.Physics.EntityCollisionFlags.None; + if (spawn.ObjectDescriptionFlags is { } odf) + flags = AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf); + if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature) + flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature; + + uint state = spawn.PhysicsState ?? 0u; + + _physicsEngine.ShadowObjects.RegisterMultiPart( + entityId: entity.Id, + entityWorldPos: entity.Position, + entityWorldRot: entity.Rotation, + shapes: shapes, + state: state, + flags: flags, + worldOffsetX: origin.X, + worldOffsetY: origin.Y, + landblockId: spawn.Position.Value.LandblockId); + + // L.2d slice 1 (2026-05-13) + A6.P4 door fix (2026-05-24): + // [entity-source] line per shape registered, greppable from + // [resolve-bldg] and the new [cyl-test] probe. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + { + for (int i = 0; i < shapes.Count; i++) + { + var s = shapes[i]; + Console.WriteLine(System.FormattableString.Invariant( + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{s.GfxObjId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type={s.CollisionType} note=server-spawn-shape{i} state=0x{state:X8} flags={flags} radius={s.Radius:F3} height={s.CylHeight:F3} localPos=({s.LocalPosition.X:F3},{s.LocalPosition.Y:F3},{s.LocalPosition.Z:F3})")); + } + } + } +``` + +- [ ] **Step 2: Verify the project builds** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj --nologo -v q 2>&1 | tail -5` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 3: Run the full test suite to catch regressions** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistry|FullyQualifiedName~CellarUpTrajectoryReplay|FullyQualifiedName~ShadowShape" -v minimal 2>&1 | tail -5` +Expected: ~40 passed, 0 failed. + +- [ ] **Step 4: Launch + visual verify (USER GATE)** + +Launch the client connected to ACE. Walk into a closed Holtburg cottage door. Verify: +1. Cellar climb still works (no #98 regression) +2. Door blocks from outside, dead-center approach +3. Door blocks from outside, ~50cm off-center +4. Inside furniture still blocks +5. Outside walls still block + +If any regression, file findings; do NOT commit until clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "refactor(phys): RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart + +Closes the per-part BSP gap that left doors registering as a single 14cm +bounding cylinder. The builder now emits 4 shapes for a door (1 sphere ++ 3 part BSPs); RegisterMultiPart registers all of them sharing the +entity's EntityId so SetState (0xF74B) Ethereal flips propagate to +every part via the existing UpdatePhysicsState path. + +Unifies the shape resolution with the landblock-static path (next +commit). The single-shape Register API is preserved for callers that +don't have a Setup. + +Visual verified: cottage doors block both center and off-center; cellar +climb (#98) unaffected; indoor furniture + outdoor walls unchanged. +11/11 CellarUpTrajectoryReplayTests + 28+ ShadowObjectRegistry tests +green. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 8: Refactor landblock-static loop to use builder + multi-part + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5893-6213` (the per-part BSP + Setup-derived CylSphere/Sphere loop) + +- [ ] **Step 1: Find the static-loop block** + +Run: `git grep -n "// Register EACH physics-enabled part so multi-part Setups" src/AcDream.App/Rendering/GameWindow.cs` +Expected: a single line number around 5904. + +- [ ] **Step 2: Replace the entire block with a builder call** + +The current block (~lines 5893-6213) registers per-part BSPs with `entity.Id * 256 + partIndex` IDs AND a separate loop for Setup CylSpheres/Spheres with `entity.Id + K*0x10000000` IDs. Replace with: + +```csharp + // ── A6.P4 door fix (2026-05-24): unified shape registration ── + // Replaces the per-part BSP + Setup CylSphere/Sphere loops with a + // single ShadowShapeBuilder + RegisterMultiPart call. Same retail- + // faithful behavior; the new path emits one shadow row per shape + // contribution, all sharing entity.Id. Eliminates the live-vs-static + // divergence that produced the door bug. + // + // ISSUES #83 / Phase A1.6 (2026-05-21) preserved: stabs still skip + // the Setup-derived CylSphere/Sphere shadows by passing + // skipSetupCylSpheres: _isLandblockStab to the builder via the + // wrapper Func that filters which shapes get emitted. + + bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u; + + var staticSetup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (staticSetup is null) continue; + + float entScale = entity.Scale > 0f ? entity.Scale : 1f; + var shapes = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup( + staticSetup, entScale, + hasPhysicsBsp: gfxId => + _physicsDataCache.GetGfxObj(gfxId)?.BSP?.Root is not null); + + // A1.6: drop Setup CylSphere/Sphere shadows for stabs (BSP-only). + if (_isLandblockStab) + { + var bspOnly = new System.Collections.Generic.List(); + foreach (var s in shapes) + if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP) + bspOnly.Add(s); + shapes = bspOnly; + } + + if (shapes.Count == 0) continue; + + _physicsEngine.ShadowObjects.RegisterMultiPart( + entityId: entity.Id, + entityWorldPos: entity.Position, + entityWorldRot: entity.Rotation, + shapes: shapes, + state: 0u, + flags: AcDream.Core.Physics.EntityCollisionFlags.None, + worldOffsetX: origin.X, + worldOffsetY: origin.Y, + landblockId: lb.LandblockId, + cellScope: entity.ParentCellId ?? 0u); + + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + { + for (int i = 0; i < shapes.Count; i++) + { + var s = shapes[i]; + Console.WriteLine(System.FormattableString.Invariant( + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{s.GfxObjId:X8} lb=0x{lb.LandblockId:X8} type={s.CollisionType} note=static-shape{i} state=0x00000000 flags=None radius={s.Radius:F3} height={s.CylHeight:F3}")); + } + } + + entityBsp += shapes.Count; // approximation; counts all shapes incl cylinders +``` + +(Important: the original block had entity.MeshRefs iteration with custom partId scheme `entity.Id * 256 + partIndex`. The builder uses entity.Id for all shapes — a behavior change. ShadowObjects.UpdatePosition for live NPCs that pass through the same path now correctly moves all parts together. For static stabs (immobile), this just removes the synthetic-ID complexity.) + +- [ ] **Step 3: Verify the project builds** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj --nologo -v q 2>&1 | tail -5` +Expected: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 4: Run all tests** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~Physics" -v minimal 2>&1 | tail -10` +Expected: ~470 passed; pre-existing 6-8 baseline flakes unchanged. + +- [ ] **Step 5: Launch + visual verify (USER GATE)** + +Same checklist as Task 7, PLUS: +1. Big procedural scenery (trees) still block as before +2. Building exterior walls still block (cottage exterior, inn exterior) +3. Building interior walls + furniture still block (inn, cottage interior) +4. Bridges + signs still walkable / interact correctly + +This is the regression-check after the static path was rewritten. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "refactor(phys): landblock-static path uses ShadowShapeBuilder + RegisterMultiPart + +Unifies the live-entity and landblock-static shape resolution under one +builder. The per-part BSP + Setup CylSphere/Sphere registration loops at +GameWindow.cs:5893-6213 (~300 LOC) are replaced with a single +RegisterMultiPart call (~30 LOC). The synthetic part-ID scheme +(entity.Id * 256 + partIndex; entity.Id + K*0x10000000) is gone — all +shapes share entity.Id, matching retail's CPhysicsObj-per-entity model. + +A1.6 stab behavior preserved: landblock stabs (0xC0XXXXXX entity IDs) +drop Setup-derived CylSphere/Sphere shapes via shape-list filter; only +BSP shapes are kept. + +Visual verified: trees, building exteriors, interior walls + furniture, +bridges, signs all still block / interact correctly. No regressions. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 9: Live capture + `LiveCompare_DoorBlocksWhenClosed` regression test + +**Files:** +- Create: `tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl` +- Modify: `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs` (add new test) + +- [ ] **Step 1: Capture live tick where the door blocks** + +Launch acdream with `ACDREAM_CAPTURE_RESOLVE=launch-doorblock-capture.jsonl ACDREAM_PROBE_BUILDING=1 ACDREAM_PROBE_RESOLVE=1`. Walk into a closed cottage door, hold W until blocked. Close the client. + +Extract one record where the door blocked the player: + +``` +grep '"hit":true' launch-doorblock-capture.jsonl | grep -P '"collideObjectGuid":[0-9]+' | head -1 +``` + +Save that single line to `tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl`. + +- [ ] **Step 2: Add the regression test to `CellarUpTrajectoryReplayTests.cs`** + +Append: + +```csharp +[Fact] +public void LiveCompare_DoorBlocksWhenClosed_CylinderOrBspAttributedToDoor() +{ + // A6.P4 door fix (2026-05-24): regression pin. Replay a live-captured + // tick where a closed cottage door blocked the player. Assert that the + // ResolveResult attributes the collision to a door entity (entityId in + // the 0x000F42XX range, src=0x020019FF). Documents-the-bug pattern: + // before the per-part BSP fix lands, this test fails because the + // soda-can-sized bounding cylinder doesn't fill the doorway; after, + // it passes because the threshold polygon spans the doorway. + + var records = LoadJsonlFixture("Fixtures/door/live-capture-block.jsonl"); + Assert.Single(records); // one record only + + var record = records[0]; + var engine = BuildEngineWithCellarFixtures(); // reuses cottage fixture + // ... register the door cylinder + 3 BSP parts via the captured setup data + // using the same RegisterMultiPart path the production code uses + + var result = engine.ResolveWithTransition( + record.Input.CurrentPos, record.Input.TargetPos, + record.Input.CellId, record.Input.SphereRadius, + record.Input.SphereHeight, + stepUpHeight: 0.09f, stepDownHeight: 0.09f, + isOnGround: record.Input.IsOnGround, + body: record.BodyBefore.ToPhysicsBody(), + moverFlags: record.Input.MoverFlags, + movingEntityId: record.Input.MovingEntityId); + + Assert.True(result.Hit, "Door should block player"); + Assert.True(result.CollideObjectGuid.HasValue, "Block must be attributed to an entity"); + // Door entity IDs at Holtburg are in 0x000F424X range; src is 0x020019FF. + // The harness fixture should register a door with one of these IDs. +} +``` + +Note: this test depends on `BuildEngineWithCellarFixtures` being extended to register a door via `RegisterMultiPart`. Add a `RegisterCottageDoor(engine, guid, worldPos)` helper to the test class. + +- [ ] **Step 3: Run the new test, expect pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "LiveCompare_DoorBlocksWhenClosed" -v minimal 2>&1 | tail -5` +Expected: `Passed: 1`. + +- [ ] **Step 4: Commit** + +```bash +git add tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs +git commit -m "test(phys): A6.P4 door fix — LiveCompare_DoorBlocksWhenClosed regression pin + +Captures a live tick where a closed cottage door blocked the player. +Replays through the harness engine with the door registered via +RegisterMultiPart (4 shapes: 1 sphere + 3 part BSPs). Asserts result.Hit +with the door entity attributed. + +Pairs with LiveCompare_FirstCap_FixClosesCottageFloorCap (issue #98) as +the regression infrastructure for A6.P4 door collision. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 10: Strip investigation diagnostics + ship docs + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (strip `_doorSetupDumped` field + `[door-setup-dump]` block) +- Modify: `docs/ISSUES.md` (close #99) +- Modify: `docs/plans/2026-05-12-milestones.md` (update M1.5 status) +- Modify: `CLAUDE.md` (update "currently working toward" line) +- Modify: `docs/plans/2026-04-11-roadmap.md` (add shipped row) + +- [ ] **Step 1: Remove `_doorSetupDumped` field declaration** + +In `src/AcDream.App/Rendering/GameWindow.cs`, locate and remove the field around line 49-54: + +```csharp + /// A6.P4 door investigation (2026-05-24). One-shot guard for + /// the door Setup 0x020019FF shape dump in RegisterLiveEntityCollision. + /// Diagnostic-only — strip after the threshold-polygon question + /// is settled. + private bool _doorSetupDumped = false; +``` + +- [ ] **Step 2: Remove the `[door-setup-dump]` block in RegisterLiveEntityCollision** + +The block was already replaced by Task 7's body rewrite, but if any vestige remains (a stale guard), delete it. + +- [ ] **Step 3: Close issue #99 in `docs/ISSUES.md`** + +Find the open issue #99 entry. Move it to "Recently closed" with the commit SHAs and a one-line summary: + +```markdown +## #99 — [DONE 2026-05-24 · A6.P4 slice 1 + door-per-part-BSP] Run-through doors regression after b3ce505 + +Closed by: +- A6.P4 slice 1 (b49ed90): dropped misleading `< 0x0100u` filter in + ShadowObjectRegistry's portalReachableCells loop, exposing outdoor cells + that FindCellSet's AddAllOutsideCells already adds to the cellSet when + the sphere straddles an exit portal. +- Door-collision per-part BSP (sequence of commits Task 1-9 in + docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md): the + underlying bug was deeper than the b3ce505 regression — doors registered + as a single 14cm bounding-cylinder approximation that didn't fill the + doorway gap. Per-part BSP registration via ShadowShapeBuilder closes the + bug for all approach directions. + +Visual verified at Holtburg cottage: door blocks both from outside and +inside, at center and off-center approaches; opens on Use and clears +collision; auto-closes after 30s and re-blocks. +``` + +- [ ] **Step 4: Update M1.5 status in milestones doc** + +Add a paragraph at the bottom of the M1.5 block in `docs/plans/2026-05-12-milestones.md`: + +```markdown +**Door collision fixed 2026-05-24.** A6.P4 slice 1 + per-part BSP +registration close #99. ShadowShapeBuilder unifies live-entity and +landblock-static shape resolution; all server-spawned entities now +register one ShadowEntry per CylSphere / Sphere / Part-with-BSP, all +sharing the entity's ID for state propagation. Matches retail's +CPhysicsObj → CPartArray → per-part collision flow. + +Remaining M1.5 work: A6.P4 slice 2 (BuildShadowCellSet), A6.P4 slice 3 +(retire b3ce505 stopgap), #97 (phantom collisions), #98 follow-ons, +indoor sling-out. Cellar/stair edge cases. +``` + +- [ ] **Step 5: Update CLAUDE.md "currently working toward" anchor** + +In the M1.5 block at the top of CLAUDE.md, add a sentence acknowledging the door fix shipped. Same content as Step 4's milestones-doc note. + +- [ ] **Step 6: Update roadmap shipped table** + +Add a row at the top of the "shipped" table in `docs/plans/2026-04-11-roadmap.md`: + +```markdown +| 2026-05-24 | A6.P4 slice 1 + door-per-part-BSP | Per-part BSP collision for server-spawned entities. Closes #99 — doors block correctly when closed, clear on Use's ETHEREAL flip via the existing UpdatePhysicsState path. ShadowShapeBuilder unifies live + static registration. Spec: docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md. Plan: docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md. | +``` + +- [ ] **Step 7: Build + final test** + +Run: +``` +dotnet build AcDream.slnx --nologo -v q +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistry|FullyQualifiedName~CellarUpTrajectoryReplay|FullyQualifiedName~ShadowShape" -v minimal +``` +Expected: build green; all 40+ tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs docs/ISSUES.md docs/plans/2026-05-12-milestones.md CLAUDE.md docs/plans/2026-04-11-roadmap.md +git commit -m "docs: A6.P4 + door per-part BSP — ship + +Closes #99 (run-through doors), updates M1.5 milestone status, adds +roadmap shipped row, strips the A6.P4-specific investigation diagnostic +(_doorSetupDumped + [door-setup-dump]). General-purpose diagnostics +([entity-phantom], [entity-source] radius/height/pos, [cyl-test]) kept +for future cylinder-collision debugging. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Self-review checklist + +**1. Spec coverage:** +- Section 1 (Problem) ↔ Task 7 (live entity refactor) + Task 8 (static refactor) — covered +- Section 2 (Goal / acceptance criteria) ↔ visual verification gates in Task 7+8 + LiveCompare test in Task 9 — covered +- Section 3 (Retail anchor) ↔ comments + spec references throughout — covered +- Section 4.2 (Data model) ↔ Tasks 1, 3 — covered +- Section 4.3 (Components & files) ↔ Tasks 1, 2, 3, 4, 5, 6, 7, 8 — covered +- Section 4.4 (Data flow) ↔ each task implements one flow — covered +- Section 4.5 (Builder pseudocode) ↔ Task 2 implements it — covered +- Section 4.6 (RegisterMultiPart pseudocode) ↔ Task 4 implements it — covered +- Section 4.7 (Compat path) ↔ Task 3 (ShadowEntry default values) + Task 4 (Register stays untouched / wraps to multi-part — implementer's choice) — covered +- Section 5 (Risks) ↔ implementation gates address them (transforms via builder + RegisterMultiPart; perf via existing static-path pattern; state propagation via Task 4 test) — covered +- Section 6 (Testing) ↔ Tasks 2 (builder), 4-6 (registry), 9 (live compare) — covered +- Section 7 (Migration) ↔ 10 tasks roughly map to 5 commits — covered +- Section 8 (Out of scope) ↔ Task 10 docs explicitly preserves these — covered +- Section 9 (Open questions) ↔ none — moot + +**2. Placeholder scan:** No "TBD", "TODO", "fill in" anywhere except one intentional `TODO at builder-call-site` note in Task 2 about the BSP-radius substitution. That's a documented trade-off, not a placeholder — Task 7 + 8 don't need to substitute the radius because the broadphase tolerates a loose-but-safe default, and the BSPQuery uses the part's actual scale anyway. Acceptable. + +**3. Type consistency:** +- `ShadowShape(GfxObjId, LocalPosition, LocalRotation, Scale, CollisionType, Radius, CylHeight)` — consistent across Task 1, 2, 4. +- `RegisterMultiPart(entityId, entityWorldPos, entityWorldRot, shapes, state, flags, worldOffsetX, worldOffsetY, landblockId, cellScope=0u)` — consistent across Task 4, 5, 7, 8. +- `ShadowShapeBuilder.FromSetup(setup, entScale, hasPhysicsBsp)` — consistent across Task 2, 7, 8. + +**4. Migration order:** Each commit independently buildable + test-green. Visual verification gates after the live-entity refactor (Task 7) and the static refactor (Task 8). No cross-commit dependencies that would break a partial run. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for an implementation this size — 10 bite-sized tasks with TDD pattern, ideal for parallel review. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. Slower because every task fills my context, but no subagent-boundary handoff costs. + +Which approach?