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?