diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
new file mode 100644
index 0000000..4fff410
--- /dev/null
+++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
@@ -0,0 +1,121 @@
+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 default; caller substitutes the real BoundingSphere.Radius
+ // at registration time when available. Loose-but-safe broadphase value.
+ float bspRadius = 2f * entScale;
+
+ 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;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs
new file mode 100644
index 0000000..b167935
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Linq;
+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.
+ ///
+ private static Setup CreateDoorSetup()
+ {
+ var setup = new Setup
+ {
+ Radius = 0.141f,
+ Height = 0.200f,
+ StepUpHeight = 0.090f,
+ StepDownHeight = 0.090f,
+ Parts = { 0x010044B5u, 0x010044B6u, 0x010044B6u },
+ Spheres =
+ {
+ new Sphere { Radius = 0.100f, Origin = new Vector3(0f, 0f, 0.018f) }
+ },
+ PlacementFrames =
+ {
+ [Placement.Default] = new AnimationFrame(3)
+ {
+ Frames =
+ {
+ new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity },
+ new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity },
+ new Frame { 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);
+
+ 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);
+ Assert.Equal(3, bspCount);
+ }
+
+ [Fact]
+ public void FromSetup_DoorSetup_SphereAtExpectedLocalOffset()
+ {
+ var setup = CreateDoorSetup();
+ var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true);
+
+ var sphereAsCyl = shapes.FirstOrDefault(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();
+ 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);
+ }
+
+ [Fact]
+ public void FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders()
+ {
+ var setup = new Setup
+ {
+ Parts = { 0x02000001u },
+ CylSpheres =
+ {
+ new CylSphere { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0, 0, 0.6f) }
+ },
+ Spheres =
+ {
+ new Sphere { Radius = 0.50f, Origin = new Vector3(0, 0, 0.7f) }
+ }
+ };
+
+ 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)
+ {
+ 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();
+
+ 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));
+ }
+}