10-task TDD implementation plan for the design in
docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md
(commit d71ceab). Each task is bite-sized (write failing test → run
→ implement → run → commit), with complete code in every step per
the writing-plans skill's "no placeholders" rule.
Map: Task 1-2 = ShadowShape + ShadowShapeBuilder; Task 3-6 =
ShadowObjectRegistry multi-part extensions (ShadowEntry fields,
RegisterMultiPart, multi-part UpdatePosition, Deregister cleanup);
Task 7 = RegisterLiveEntityCollision refactor (closes door bug);
Task 8 = landblock-static refactor (unifies paths); Task 9 = live-
capture regression pin; Task 10 = strip investigation diagnostics +
ship docs.
Visual verification gates after Task 7 (door fix surface) and Task 8
(static-collision regression check). 40+ test green-gate at every
commit boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
65 KiB
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 LOCsrc/AcDream.Core/Physics/ShadowShapeBuilder.cs— pure function over Setup, ~80 LOCtests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs— unit tests, ~120 LOCtests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs— registry tests, ~180 LOCtests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl— live capture (1 record)
Modified files:
src/AcDream.Core/Physics/ShadowObjectRegistry.cs— addRegisterMultiPart, extendShadowEntrywithLocalPosition/LocalRotation, add_entityShapesmap, makeRegistera 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— addLiveCompare_DoorBlocksWhenClosed. Net +80.
Strip-on-ship (investigation diagnostics):
src/AcDream.App/Rendering/GameWindow.cs—_doorSetupDumpedfield +[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
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// 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 <see cref="ShadowObjectRegistry.UpdatePosition"/>
/// can re-transform them when the entity moves.
///
/// <para>
/// Retail anchor: <c>CPhysicsPart</c> + <c>CPhysicsPart::find_obj_collisions</c>
/// at <c>acclient_2013_pseudo_c.txt:275045-275055</c>. Each part stores its
/// own transform and dispatches per-part collision to its GfxObj.
/// </para>
/// </summary>
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
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) <noreply@anthropic.com>"
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<uint, bool> 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:
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
{
/// <summary>
/// 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).
/// </summary>
private static Setup CreateDoorSetup()
{
var setup = new Setup
{
Radius = 0.141f,
Height = 0.200f,
StepUpHeight = 0.090f,
StepDownHeight = 0.090f,
Parts = new List<uint> { 0x010044B5u, 0x010044B6u, 0x010044B6u },
CylSpheres = new List<CylSphere>(),
Spheres = new List<Sphere>
{
new() { Radius = 0.100f, Origin = new Vector3(0f, 0f, 0.018f) }
},
PlacementFrames = new Dictionary<Placement, AnimationFrame>
{
[Placement.Default] = new AnimationFrame
{
Frames = new List<Frame>
{
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<uint, bool> 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:
using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Pure-function builder that translates a <see cref="Setup"/> into a list of
/// <see cref="ShadowShape"/>s suitable for registration via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
///
/// <para>
/// 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].
/// </para>
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
/// ShadowShape per part contribution so the existing FindObjCollisions
/// iteration loop in <see cref="Transition"/> tests each part independently.
/// </para>
/// </summary>
public static class ShadowShapeBuilder
{
/// <summary>
/// Build the shape list for a Setup.
/// </summary>
/// <param name="setup">The Setup to walk.</param>
/// <param name="entScale">The entity's overall scale factor; multiplies
/// every radius, height, and local offset.</param>
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup,
float entScale,
Func<uint, bool> hasPhysicsBsp)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
var result = new List<ShadowShape>();
// 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;
}
/// <summary>Resolve the placement frame in priority Resting → Default →
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
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:
Func<uint, float?> bspRadiusOrNull // null when no BSP, else the radius
But that complicates tests. Pragmatic compromise: keep Func<uint, bool> 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:
[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<uint, bool> 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<uint> { 0x02000001u },
CylSpheres = new List<CylSphere>
{
new() { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0, 0, 0.6f) }
},
Spheres = new List<Sphere>
{
new() { Radius = 0.50f, Origin = new Vector3(0, 0, 0.7f) }
},
PlacementFrames = new Dictionary<Placement, AnimationFrame>()
};
// 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<uint>(),
CylSpheres = new List<CylSphere>(),
Spheres = new List<Sphere>(),
PlacementFrames = new Dictionary<Placement, AnimationFrame>()
};
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true);
Assert.Empty(shapes);
}
[Fact]
public void FromSetup_NullSetup_Throws()
{
Assert.Throws<ArgumentNullException>(
() => 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
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<ShadowShape>. 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) <noreply@anthropic.com>"
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:
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):
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
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) <noreply@anthropic.com>"
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:
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class ShadowObjectRegistryMultiPartTests
{
private const uint LbId = 0xA9B40000u;
private const float OffX = 0f;
private const float OffY = 0f;
private static IReadOnlyList<ShadowShape> DoorShapes() => new[]
{
// 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:
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new();
/// <summary>
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
/// <see cref="UpdatePosition"/> to recompose part world-transforms when
/// the entity moves. Cleared by <see cref="Deregister"/>.
/// </summary>
private readonly Dictionary<uint, IReadOnlyList<ShadowShape>> _entityShapes = new();
3b) Add the RegisterMultiPart method below the existing Register:
/// <summary>
/// A6.P4 door fix (2026-05-24): register one logical entity composed of
/// multiple collision shapes. All emitted <see cref="ShadowEntry"/> rows
/// share <paramref name="entityId"/>, so <see cref="UpdatePhysicsState"/>
/// propagates an ETHEREAL flip to every part (the existing per-entityId
/// iteration handles this naturally). The shape list is cached in
/// <see cref="_entityShapes"/> so <see cref="UpdatePosition"/> can
/// recompose part world-transforms when the entity moves.
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> →
/// <c>CPartArray::FindObjCollisions</c> at
/// <c>acclient_2013_pseudo_c.txt:276961-286250</c>. One PhysicsObj per
/// entity, parts iterated for collision testing.
/// </para>
/// </summary>
public void RegisterMultiPart(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
IReadOnlyList<ShadowShape> shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint cellScope = 0u)
{
Deregister(entityId);
if (shapes.Count == 0) return;
_entityShapes[entityId] = shapes;
var allCells = new List<uint>();
var seenCells = new HashSet<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
foreach (var shape in shapes)
{
// 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;
}
/// <summary>Helper: append a <see cref="ShadowEntry"/> to a cell's
/// list, creating the list if needed.</summary>
private void AddEntryToCell(ShadowEntry entry, uint cellId)
{
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List<ShadowEntry>();
_cells[cellId] = list;
}
list.Add(entry);
}
- 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:
[Fact]
public void RegisterMultiPart_EmptyShapeList_NoOp()
{
var reg = new ShadowObjectRegistry();
reg.RegisterMultiPart(
entityId: 0x1u,
entityWorldPos: Vector3.Zero,
entityWorldRot: Quaternion.Identity,
shapes: System.Array.Empty<ShadowShape>(),
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId);
Assert.Equal(0, reg.TotalRegistered);
}
[Fact]
public void Deregister_RemovesAllParts()
{
var reg = new ShadowObjectRegistry();
const uint doorEntityId = 0x000F4244u;
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
Quaternion.Identity, DoorShapes(), 0x10008u,
EntityCollisionFlags.None, OffX, OffY, LbId);
reg.Deregister(doorEntityId);
Assert.Equal(0, reg.TotalRegistered);
foreach (var entry in reg.AllEntriesForDebug())
Assert.NotEqual(doorEntityId, entry.EntityId);
}
[Fact]
public void UpdatePhysicsState_PropagatesEtherealToAllParts()
{
var reg = new ShadowObjectRegistry();
const uint doorEntityId = 0x000F4244u;
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
Quaternion.Identity, DoorShapes(), 0x10008u,
EntityCollisionFlags.None, OffX, OffY, LbId);
// 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
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) <noreply@anthropic.com>"
Task 5: Extend UpdatePosition for multi-part entities
Files:
-
Modify:
src/AcDream.Core/Physics/ShadowObjectRegistry.cs(UpdatePositionmethod around line 116) -
Test:
tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs -
Step 1: Write failing test
Append to ShadowObjectRegistryMultiPartTests.cs:
[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:
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
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) <noreply@anthropic.com>"
Task 6: Extend Deregister to clear _entityShapes
Files:
-
Modify:
src/AcDream.Core/Physics/ShadowObjectRegistry.cs(Deregistermethod around line 193) -
Step 1: Add the cleanup line
In Deregister, add the _entityShapes clear so the cache doesn't leak stale entries:
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:
[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
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) <noreply@anthropic.com>"
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:
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:
- Cellar climb still works (no #98 regression)
- Door blocks from outside, dead-center approach
- Door blocks from outside, ~50cm off-center
- Inside furniture still blocks
- Outside walls still block
If any regression, file findings; do NOT commit until clean.
- Step 5: Commit
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) <noreply@anthropic.com>"
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:
// ── 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<uint, bool> 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<AcDream.Core.Physics.ShadowShape>();
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:
- Big procedural scenery (trees) still block as before
- Building exterior walls still block (cottage exterior, inn exterior)
- Building interior walls + furniture still block (inn, cottage interior)
- Bridges + signs still walkable / interact correctly
This is the regression-check after the static path was rewritten.
- Step 6: Commit
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) <noreply@anthropic.com>"
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:
[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
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) <noreply@anthropic.com>"
Task 10: Strip investigation diagnostics + ship docs
Files:
-
Modify:
src/AcDream.App/Rendering/GameWindow.cs(strip_doorSetupDumpedfield +[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
_doorSetupDumpedfield declaration
In src/AcDream.App/Rendering/GameWindow.cs, locate and remove the field around line 49-54:
/// <summary>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.</summary>
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:
## #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:
**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:
| 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
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) <noreply@anthropic.com>"
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?