diff --git a/docs/superpowers/plans/2026-04-12-physics-collision-engine.md b/docs/superpowers/plans/2026-04-12-physics-collision-engine.md
new file mode 100644
index 0000000..fe46e77
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-12-physics-collision-engine.md
@@ -0,0 +1,1095 @@
+# Phase B.3 — Physics Collision Engine 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:** Build a pure-computation collision engine that resolves entity positions against terrain heightmaps and EnvCell floor polygons, handling outdoor/indoor cell transitions, step-height enforcement, and gravity reporting.
+
+**Architecture:** Three components in `src/AcDream.Core/Physics/`: `TerrainSurface` (outdoor heightmap Z interpolation), `CellSurface` (indoor floor polygon projection), and `PhysicsEngine` (top-level resolver combining both with step-height + cell transitions). Populated by the streaming system; consumed by Phase B.2 (player movement, separate spec).
+
+**Tech Stack:** .NET 10, System.Numerics, DatReaderWriter types, xUnit.
+
+**Spec:** `docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md`
+
+---
+
+## File structure
+
+```
+src/AcDream.Core/
+ Physics/ [new folder]
+ TerrainSurface.cs [new] outdoor heightmap Z interpolation + cell ID
+ CellSurface.cs [new] indoor floor polygon projection
+ PhysicsEngine.cs [new] top-level resolver
+ ResolveResult.cs [new] result record
+
+src/AcDream.App/
+ Rendering/GameWindow.cs [modify] populate physics engine from streaming
+
+tests/AcDream.Core.Tests/
+ Physics/ [new folder]
+ TerrainSurfaceTests.cs [new]
+ CellSurfaceTests.cs [new]
+ PhysicsEngineTests.cs [new]
+```
+
+---
+
+## Task 1: TerrainSurface (outdoor heightmap Z + cell ID)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/TerrainSurface.cs`
+- Test: `tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs`
+
+This extracts the existing `SampleTerrainZ` algorithm from GameWindow into a reusable class and adds outdoor cell ID computation.
+
+- [ ] **Step 1: Write failing tests**
+
+Create `tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs`:
+
+```csharp
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class TerrainSurfaceTests
+{
+ // A height table where index N maps to N * 1.0f (linear).
+ // Makes test assertions predictable: height byte 10 → Z = 10.0.
+ private static float[] LinearHeightTable()
+ {
+ var table = new float[256];
+ for (int i = 0; i < 256; i++) table[i] = i * 1.0f;
+ return table;
+ }
+
+ // A flat heightmap where every vertex is height byte 50.
+ private static byte[] FlatHeightmap(byte value = 50)
+ {
+ var heights = new byte[81];
+ Array.Fill(heights, value);
+ return heights;
+ }
+
+ [Fact]
+ public void SampleZ_FlatTerrain_ReturnsSameValueEverywhere()
+ {
+ var surface = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+
+ Assert.Equal(50f, surface.SampleZ(0f, 0f));
+ Assert.Equal(50f, surface.SampleZ(96f, 96f));
+ Assert.Equal(50f, surface.SampleZ(191f, 191f));
+ }
+
+ [Fact]
+ public void SampleZ_SlopeAlongX_InterpolatesLinearly()
+ {
+ // Heights increase along X: column 0 = byte 10, column 8 = byte 90.
+ // Each column step is (90-10)/8 = 10 bytes.
+ var heights = new byte[81];
+ for (int x = 0; x < 9; x++)
+ for (int y = 0; y < 9; y++)
+ heights[x * 9 + y] = (byte)(10 + x * 10);
+
+ var surface = new TerrainSurface(heights, LinearHeightTable());
+
+ // At x=0 (vertex 0): Z = 10
+ Assert.Equal(10f, surface.SampleZ(0f, 96f), precision: 1);
+ // At x=96 (midpoint, vertex 4): Z = 50
+ Assert.Equal(50f, surface.SampleZ(96f, 96f), precision: 1);
+ // At x=192 (vertex 8): Z = 90
+ Assert.Equal(90f, surface.SampleZ(192f, 96f), precision: 1);
+ // At x=48 (between vertex 2 and 3): Z = 30 + 0.5 * 10 = 35
+ // vertex 2 = byte 30, vertex 3 = byte 40, midpoint = 35
+ Assert.Equal(35f, surface.SampleZ(60f, 96f), precision: 1);
+ }
+
+ [Fact]
+ public void SampleZ_ClampsOutOfBounds()
+ {
+ var surface = new TerrainSurface(FlatHeightmap(42), LinearHeightTable());
+
+ // Negative coordinates clamp to 0
+ Assert.Equal(42f, surface.SampleZ(-10f, -10f));
+ // Beyond 192 clamps to boundary
+ Assert.Equal(42f, surface.SampleZ(300f, 300f));
+ }
+
+ [Fact]
+ public void ComputeOutdoorCellId_Origin_ReturnsFirst()
+ {
+ var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
+
+ // Cell (0,0) at position (0,0) → cell ID 0x0001
+ Assert.Equal(0x0001u, surface.ComputeOutdoorCellId(0f, 0f));
+ }
+
+ [Fact]
+ public void ComputeOutdoorCellId_SecondColumn_ReturnsCorrect()
+ {
+ var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
+
+ // 24 units in X = cell (1, 0) → cell ID 0x0001 + 1*8 = 0x0009
+ Assert.Equal(0x0009u, surface.ComputeOutdoorCellId(24f, 0f));
+ }
+
+ [Fact]
+ public void ComputeOutdoorCellId_LastCell_Returns0x0040()
+ {
+ var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
+
+ // Cell (7,7) at position (191,191) → 0x0001 + 7*8 + 7 = 0x0040
+ Assert.Equal(0x0040u, surface.ComputeOutdoorCellId(191f, 191f));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests — verify they fail**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TerrainSurfaceTests" --nologo`
+
+Expected: FAIL with build error `TerrainSurface not found`.
+
+- [ ] **Step 3: Implement TerrainSurface**
+
+Create `src/AcDream.Core/Physics/TerrainSurface.cs`:
+
+```csharp
+using System;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Outdoor terrain height resolver for a single landblock. Performs
+/// bilinear interpolation of the 9×9 heightmap grid to produce the
+/// ground Z at any (localX, localY) within the 192×192 landblock
+/// footprint. Also computes the outdoor cell ID for AC's position
+/// encoding.
+///
+///
+/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined
+/// and not reusable). The heightmap is indexed x-major:
+/// heights[x * 9 + y]; each byte is a lookup into
+/// (256-entry float array from
+/// Region.LandDefs.LandHeightTable).
+///
+///
+public sealed class TerrainSurface
+{
+ private const int HeightmapSide = 9;
+ private const float CellSize = 24f;
+ private const int CellsPerSide = 8; // 192 / 24
+
+ private readonly byte[] _heights;
+ private readonly float[] _heightTable;
+
+ public TerrainSurface(byte[] heights, float[] heightTable)
+ {
+ ArgumentNullException.ThrowIfNull(heights);
+ ArgumentNullException.ThrowIfNull(heightTable);
+ if (heights.Length < 81)
+ throw new ArgumentException("heights must have 81 entries", nameof(heights));
+ if (heightTable.Length < 256)
+ throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
+
+ _heights = heights;
+ _heightTable = heightTable;
+ }
+
+ ///
+ /// Bilinear-interpolated terrain Z at (localX, localY) in
+ /// landblock-local coordinates (0..192 range).
+ ///
+ public float SampleZ(float localX, float localY)
+ {
+ float fx = Math.Clamp(localX / CellSize, 0f, HeightmapSide - 1f);
+ float fy = Math.Clamp(localY / CellSize, 0f, HeightmapSide - 1f);
+
+ int x0 = Math.Min((int)fx, HeightmapSide - 2);
+ int y0 = Math.Min((int)fy, HeightmapSide - 2);
+ int x1 = x0 + 1;
+ int y1 = y0 + 1;
+ float tx = fx - x0;
+ float ty = fy - y0;
+
+ float h00 = _heightTable[_heights[x0 * HeightmapSide + y0]];
+ float h10 = _heightTable[_heights[x1 * HeightmapSide + y0]];
+ float h01 = _heightTable[_heights[x0 * HeightmapSide + y1]];
+ float h11 = _heightTable[_heights[x1 * HeightmapSide + y1]];
+
+ float hx0 = h00 * (1 - tx) + h10 * tx;
+ float hx1 = h01 * (1 - tx) + h11 * tx;
+ return hx0 * (1 - ty) + hx1 * ty;
+ }
+
+ ///
+ /// Compute the outdoor cell ID for the given landblock-local position.
+ /// Outdoor cells are an 8×8 grid of 24×24-unit cells numbered
+ /// 0x0001..0x0040. Cell (0,0) at position (0,0) is 0x0001.
+ ///
+ public uint ComputeOutdoorCellId(float localX, float localY)
+ {
+ int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1);
+ int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1);
+ return (uint)(1 + cx * CellsPerSide + cy);
+ }
+}
+```
+
+- [ ] **Step 4: Run tests — verify all pass**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TerrainSurfaceTests" --nologo`
+
+Expected: PASS 6/6.
+
+- [ ] **Step 5: Run full suite — no regressions**
+
+Run: `dotnet test -c Debug --nologo`
+
+Expected: 227+ tests green, no regressions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/TerrainSurface.cs tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
+git commit -m "$(cat <<'EOF'
+feat(core): Phase B.3 — TerrainSurface (outdoor heightmap Z + cell ID)
+
+Extracts the bilinear heightmap interpolation from GameWindow's
+inlined SampleTerrainZ into a reusable class. Also adds outdoor
+cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040).
+
+First component of the physics collision engine.
+
+6 new tests, all green.
+
+Co-Authored-By: Claude Opus 4.6 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: CellSurface (indoor floor polygon projection)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/CellSurface.cs`
+- Test: `tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs`
+
+This is the most algorithmically complex component. It projects an XY point onto the floor polygons of an EnvCell's CellStruct and returns the Z.
+
+- [ ] **Step 1: Write failing tests**
+
+Create `tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class CellSurfaceTests
+{
+ ///
+ /// Build a minimal CellSurface representing a flat square floor
+ /// centered at (originX, originY) with the given half-size and Z.
+ /// The floor polygon is a quad: 4 vertices at the corners.
+ ///
+ private static CellSurface MakeFlatFloor(
+ uint cellId, float originX, float originY, float z,
+ float halfSize = 10f)
+ {
+ // 4 vertices forming a square floor at the given Z, in WORLD space.
+ var vertices = new Dictionary
+ {
+ [0] = new(originX - halfSize, originY - halfSize, z),
+ [1] = new(originX + halfSize, originY - halfSize, z),
+ [2] = new(originX + halfSize, originY + halfSize, z),
+ [3] = new(originX - halfSize, originY + halfSize, z),
+ };
+
+ // One quad polygon with 4 vertex IDs.
+ var polygonVertexIds = new List>
+ {
+ new() { 0, 1, 2, 3 },
+ };
+
+ return new CellSurface(cellId, vertices, polygonVertexIds);
+ }
+
+ [Fact]
+ public void SampleFloorZ_InsideFlat_ReturnsZ()
+ {
+ var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f);
+
+ float? z = surface.SampleFloorZ(50f, 50f);
+
+ Assert.NotNull(z);
+ Assert.Equal(10f, z!.Value, precision: 2);
+ }
+
+ [Fact]
+ public void SampleFloorZ_OutsideFloor_ReturnsNull()
+ {
+ var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 5f);
+
+ float? z = surface.SampleFloorZ(100f, 100f);
+
+ Assert.Null(z);
+ }
+
+ [Fact]
+ public void SampleFloorZ_AtEdge_ReturnsZ()
+ {
+ var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 10f);
+
+ // Right at the edge of the polygon.
+ float? z = surface.SampleFloorZ(60f, 50f);
+
+ Assert.NotNull(z);
+ Assert.Equal(10f, z!.Value, precision: 2);
+ }
+
+ [Fact]
+ public void SampleFloorZ_SlopedFloor_InterpolatesZ()
+ {
+ // A triangular floor that slopes from Z=0 to Z=20.
+ var vertices = new Dictionary
+ {
+ [0] = new(0f, 0f, 0f),
+ [1] = new(20f, 0f, 0f),
+ [2] = new(10f, 20f, 20f),
+ };
+ var polygons = new List> { new() { 0, 1, 2 } };
+ var surface = new CellSurface(0x0100, vertices, polygons);
+
+ // At the centroid (10, 6.67): Z should be roughly 6.67
+ float? z = surface.SampleFloorZ(10f, 6.67f);
+
+ Assert.NotNull(z);
+ Assert.InRange(z!.Value, 5f, 8f); // approximate
+ }
+}
+```
+
+- [ ] **Step 2: Run tests — verify they fail**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellSurfaceTests" --nologo`
+
+Expected: FAIL with build error `CellSurface not found`.
+
+- [ ] **Step 3: Implement CellSurface**
+
+Create `src/AcDream.Core/Physics/CellSurface.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Indoor floor resolver for a single EnvCell. Projects an XY point
+/// onto the cell's floor polygons and returns the Z at that point.
+///
+///
+/// Uses a simplified constructor that takes pre-transformed vertex
+/// positions (world-space) and polygon vertex-id lists. The caller
+/// is responsible for transforming CellStruct vertices from cell-local
+/// space to world space using EnvCell.Position before constructing
+/// this surface.
+///
+///
+///
+/// Floor polygon iteration is brute-force (no BSP). Cell polygon
+/// counts are typically < 20, making this acceptable for the MVP.
+/// Each polygon is fan-triangulated and tested via point-in-triangle
+/// + barycentric Z interpolation.
+///
+///
+public sealed class CellSurface
+{
+ public uint CellId { get; }
+
+ private readonly List<(Vector3 A, Vector3 B, Vector3 C)> _triangles;
+
+ ///
+ /// Construct a CellSurface from pre-transformed vertex positions
+ /// and polygon definitions.
+ ///
+ /// The EnvCell dat id (e.g., 0xA9B40100).
+ /// Vertex id → world-space position map.
+ ///
+ /// List of polygons, each a list of vertex IDs. Polygons with fewer
+ /// than 3 vertices are skipped. Quads and larger are fan-triangulated.
+ ///
+ public CellSurface(
+ uint cellId,
+ Dictionary vertices,
+ List> polygonVertexIds)
+ {
+ CellId = cellId;
+ _triangles = new List<(Vector3, Vector3, Vector3)>();
+
+ foreach (var polyVerts in polygonVertexIds)
+ {
+ if (polyVerts.Count < 3) continue;
+
+ // Resolve vertex positions.
+ var positions = new List(polyVerts.Count);
+ bool skip = false;
+ foreach (var vid in polyVerts)
+ {
+ if (!vertices.TryGetValue((ushort)vid, out var pos))
+ {
+ skip = true;
+ break;
+ }
+ positions.Add(pos);
+ }
+ if (skip) continue;
+
+ // Fan triangulation: (v0, v1, v2), (v0, v2, v3), ...
+ for (int i = 1; i < positions.Count - 1; i++)
+ {
+ _triangles.Add((positions[0], positions[i], positions[i + 1]));
+ }
+ }
+ }
+
+ ///
+ /// Project (worldX, worldY) onto this cell's floor polygons and
+ /// return the Z. Returns null if outside all floor polygons.
+ ///
+ public float? SampleFloorZ(float worldX, float worldY)
+ {
+ foreach (var (a, b, c) in _triangles)
+ {
+ if (PointInTriangleXY(worldX, worldY, a, b, c, out float z))
+ return z;
+ }
+ return null;
+ }
+
+ ///
+ /// Test if (px, py) falls inside triangle (a, b, c) projected onto
+ /// the XY plane. If inside, computes the barycentric Z interpolation
+ /// and returns it via .
+ ///
+ private static bool PointInTriangleXY(
+ float px, float py,
+ Vector3 a, Vector3 b, Vector3 c,
+ out float z)
+ {
+ z = 0;
+
+ // Barycentric coordinate computation in 2D (XY plane).
+ float v0x = c.X - a.X, v0y = c.Y - a.Y;
+ float v1x = b.X - a.X, v1y = b.Y - a.Y;
+ float v2x = px - a.X, v2y = py - a.Y;
+
+ float dot00 = v0x * v0x + v0y * v0y;
+ float dot01 = v0x * v1x + v0y * v1y;
+ float dot02 = v0x * v2x + v0y * v2y;
+ float dot11 = v1x * v1x + v1y * v1y;
+ float dot12 = v1x * v2x + v1y * v2y;
+
+ float denom = dot00 * dot11 - dot01 * dot01;
+ if (MathF.Abs(denom) < 1e-10f) return false; // degenerate triangle
+
+ float invDenom = 1f / denom;
+ float u = (dot11 * dot02 - dot01 * dot12) * invDenom;
+ float v = (dot00 * dot12 - dot01 * dot02) * invDenom;
+
+ if (u < -1e-6f || v < -1e-6f || u + v > 1f + 1e-6f)
+ return false;
+
+ // Barycentric Z interpolation.
+ z = a.Z * (1 - u - v) + b.Z * v + c.Z * u;
+ return true;
+ }
+}
+```
+
+- [ ] **Step 4: Run tests — verify all pass**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellSurfaceTests" --nologo`
+
+Expected: PASS 4/4.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/CellSurface.cs tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs
+git commit -m "$(cat <<'EOF'
+feat(core): Phase B.3 — CellSurface (indoor floor polygon projection)
+
+Projects an XY point onto a cell's floor polygons via brute-force
+triangle iteration + barycentric Z interpolation. Fan-triangulates
+quads and larger polygons. Returns null when outside all floor
+surfaces. Accepts pre-transformed world-space vertex positions so
+the caller handles EnvCell coordinate transforms.
+
+Second component of the physics collision engine.
+
+4 new tests, all green.
+
+Co-Authored-By: Claude Opus 4.6 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 3: PhysicsEngine (top-level resolver + ResolveResult)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/ResolveResult.cs`
+- Create: `src/AcDream.Core/Physics/PhysicsEngine.cs`
+- Test: `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs`
+
+The integration layer that combines terrain + cell surfaces with step-height enforcement and outdoor/indoor transitions.
+
+- [ ] **Step 1: Create ResolveResult record**
+
+Create `src/AcDream.Core/Physics/ResolveResult.cs`:
+
+```csharp
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Result of : the validated
+/// position after collision, the cell the entity ended up in,
+/// and whether they're standing on a surface.
+///
+public readonly record struct ResolveResult(
+ Vector3 Position,
+ uint CellId,
+ bool IsOnGround);
+```
+
+- [ ] **Step 2: Write failing tests for PhysicsEngine**
+
+Create `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class PhysicsEngineTests
+{
+ private static float[] LinearHeightTable()
+ {
+ var table = new float[256];
+ for (int i = 0; i < 256; i++) table[i] = i * 1.0f;
+ return table;
+ }
+
+ private static byte[] FlatHeightmap(byte value = 50)
+ {
+ var heights = new byte[81];
+ Array.Fill(heights, value);
+ return heights;
+ }
+
+ private PhysicsEngine MakeFlatEngine(float terrainZ = 50f)
+ {
+ var engine = new PhysicsEngine();
+ var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable());
+ engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+ return engine;
+ }
+
+ [Fact]
+ public void Resolve_FlatTerrain_ZMatchesTerrain()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+
+ var result = engine.Resolve(
+ new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f),
+ stepUpHeight: 2f);
+
+ Assert.Equal(50f, result.Position.Z, precision: 1);
+ Assert.True(result.IsOnGround);
+ }
+
+ [Fact]
+ public void Resolve_WalkUpSmallSlope_Accepted()
+ {
+ // Heights slope from 50 to 52 across X — small enough for step height.
+ var heights = new byte[81];
+ for (int x = 0; x < 9; x++)
+ for (int y = 0; y < 9; y++)
+ heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope
+
+ var engine = new PhysicsEngine();
+ var terrain = new TerrainSurface(heights, LinearHeightTable());
+ engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ var result = engine.Resolve(
+ new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
+ stepUpHeight: 5f);
+
+ Assert.True(result.IsOnGround);
+ Assert.True(result.Position.Z >= 50f); // moved uphill
+ }
+
+ [Fact]
+ public void Resolve_StepUpExceedsHeight_MovementBlocked()
+ {
+ // Heights jump sharply: left half = 50, right half = 100.
+ var heights = new byte[81];
+ for (int x = 0; x < 9; x++)
+ for (int y = 0; y < 9; y++)
+ heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100);
+
+ var engine = new PhysicsEngine();
+ var terrain = new TerrainSurface(heights, LinearHeightTable());
+ engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ // Try to walk from the low side to the high side.
+ var result = engine.Resolve(
+ new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
+ stepUpHeight: 2f);
+
+ // Movement should be blocked — Z delta (50→100) exceeds step height (2).
+ Assert.Equal(96f, result.Position.X, precision: 1); // didn't move
+ Assert.True(result.IsOnGround);
+ }
+
+ [Fact]
+ public void Resolve_EnterIndoorCell_TransitionsToCell()
+ {
+ var engine = new PhysicsEngine();
+ var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+
+ // Indoor cell with a floor at Z=55 covering (40..60, 40..60).
+ var cellVerts = new Dictionary
+ {
+ [0] = new(40f, 40f, 55f),
+ [1] = new(60f, 40f, 55f),
+ [2] = new(60f, 60f, 55f),
+ [3] = new(40f, 60f, 55f),
+ };
+ var cellPolys = new List> { new() { 0, 1, 2, 3 } };
+ var cell = new CellSurface(0x0100, cellVerts, cellPolys);
+
+ engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell },
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ // Walk from outdoor (30, 50) into the cell's floor area (50, 50).
+ var result = engine.Resolve(
+ new Vector3(30f, 50f, 50f), cellId: 0x0001, delta: new Vector3(20f, 0f, 0f),
+ stepUpHeight: 10f);
+
+ // Should transition to the indoor cell and snap to its floor Z.
+ Assert.Equal(0x0100u, result.CellId);
+ Assert.Equal(55f, result.Position.Z, precision: 1);
+ Assert.True(result.IsOnGround);
+ }
+
+ [Fact]
+ public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
+ {
+ var engine = new PhysicsEngine();
+ var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+
+ var cellVerts = new Dictionary
+ {
+ [0] = new(40f, 40f, 55f),
+ [1] = new(60f, 40f, 55f),
+ [2] = new(60f, 60f, 55f),
+ [3] = new(40f, 60f, 55f),
+ };
+ var cellPolys = new List> { new() { 0, 1, 2, 3 } };
+ var cell = new CellSurface(0x0100, cellVerts, cellPolys);
+
+ engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell },
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ // Start inside the cell, walk out.
+ var result = engine.Resolve(
+ new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
+ stepUpHeight: 10f);
+
+ // Should transition back to outdoor.
+ Assert.True(result.CellId < 0x0100u);
+ Assert.Equal(50f, result.Position.Z, precision: 1);
+ Assert.True(result.IsOnGround);
+ }
+
+ [Fact]
+ public void Resolve_NoSurfaceUnderEntity_NotOnGround()
+ {
+ var engine = new PhysicsEngine();
+ // No landblocks loaded — entity is floating in void.
+
+ var result = engine.Resolve(
+ new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero,
+ stepUpHeight: 2f);
+
+ Assert.False(result.IsOnGround);
+ }
+}
+```
+
+- [ ] **Step 3: Implement PhysicsEngine**
+
+Create `src/AcDream.Core/Physics/PhysicsEngine.cs`:
+
+The engine stores per-landblock terrain + cells and resolves movement. Implementation should follow the algorithm in the spec (Section 2, resolution algorithm steps 1-6). Key decisions:
+
+- Store terrain surfaces in `Dictionary Cells, float WorldOffsetX, float WorldOffsetY)>`
+- `AddLandblock` takes the canonical landblock id + TerrainSurface + cells + world offset
+- `RemoveLandblock` removes the entry
+- `Resolve` implements the outdoor/indoor logic:
+ - Compute candidate position
+ - Find which landblock the candidate is in (from world position → landblock offset)
+ - If outdoor: sample terrain Z, check cells for containment (outdoor→indoor transition), step-height check
+ - If indoor: sample current cell floor Z, if null → transition to outdoor, step-height check
+ - Return ResolveResult
+
+The implementation MUST handle:
+- Converting between world-space and landblock-local coordinates using `worldOffsetX/Y`
+- The step-height check only blocks upward movement (downhill is always OK)
+- When no landblock is found for the candidate position, return `isOnGround = false`
+- Multi-cell overlap (two EnvCells at the same XY, different Z): pick the one closest to current Z
+
+```csharp
+// Skeleton — implementer fills in the Resolve body per the spec algorithm.
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+public sealed class PhysicsEngine
+{
+ private readonly Dictionary _landblocks = new();
+
+ private sealed record LandblockPhysics(
+ TerrainSurface Terrain,
+ IReadOnlyList Cells,
+ float WorldOffsetX,
+ float WorldOffsetY);
+
+ public void AddLandblock(uint landblockId, TerrainSurface terrain,
+ IReadOnlyList cells, float worldOffsetX, float worldOffsetY)
+ {
+ _landblocks[landblockId] = new LandblockPhysics(terrain, cells, worldOffsetX, worldOffsetY);
+ }
+
+ public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId);
+
+ public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
+ {
+ var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
+
+ // Find the landblock this candidate position falls in.
+ LandblockPhysics? physics = null;
+ uint landblockId = 0;
+ foreach (var kvp in _landblocks)
+ {
+ var lb = kvp.Value;
+ float localX = candidatePos.X - lb.WorldOffsetX;
+ float localY = candidatePos.Y - lb.WorldOffsetY;
+ if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
+ {
+ physics = lb;
+ landblockId = kvp.Key;
+ break;
+ }
+ }
+
+ if (physics is null)
+ return new ResolveResult(candidatePos, cellId, IsOnGround: false);
+
+ float localCandX = candidatePos.X - physics.WorldOffsetX;
+ float localCandY = candidatePos.Y - physics.WorldOffsetY;
+
+ // Check if the candidate position falls on any indoor cell floor.
+ // Pick the cell whose floor Z is closest to the entity's current Z.
+ CellSurface? bestCell = null;
+ float? bestCellZ = null;
+ float bestZDist = float.MaxValue;
+
+ foreach (var cell in physics.Cells)
+ {
+ float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
+ if (floorZ is not null)
+ {
+ float dist = MathF.Abs(floorZ.Value - currentPos.Z);
+ if (dist < bestZDist)
+ {
+ bestCell = cell;
+ bestCellZ = floorZ;
+ bestZDist = dist;
+ }
+ }
+ }
+
+ // Determine target surface Z and cell.
+ float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
+ float targetZ;
+ uint targetCellId;
+
+ bool currentlyIndoor = cellId >= 0x0100;
+
+ if (currentlyIndoor && bestCellZ is not null)
+ {
+ // Stay indoors on the best cell's floor.
+ targetZ = bestCellZ.Value;
+ targetCellId = bestCell!.CellId;
+ }
+ else if (currentlyIndoor && bestCellZ is null)
+ {
+ // Walked out of the current cell — transition to outdoor.
+ targetZ = terrainZ;
+ targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
+ }
+ else if (!currentlyIndoor && bestCellZ is not null
+ && MathF.Abs(bestCellZ.Value - currentPos.Z) < stepUpHeight + 2f)
+ {
+ // Walked into an indoor cell from outdoor — transition to indoor.
+ targetZ = bestCellZ.Value;
+ targetCellId = bestCell!.CellId;
+ }
+ else
+ {
+ // Stay outdoors on terrain.
+ targetZ = terrainZ;
+ targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
+ }
+
+ // Step-height enforcement: block upward movement that exceeds the limit.
+ float zDelta = targetZ - currentPos.Z;
+ if (zDelta > stepUpHeight)
+ {
+ // Too steep to step up — reject horizontal movement.
+ return new ResolveResult(currentPos, cellId, IsOnGround: true);
+ }
+
+ // Encode the landblock ID into the cell ID.
+ uint fullCellId = (landblockId & 0xFFFF0000u) | (targetCellId & 0xFFFFu);
+
+ return new ResolveResult(
+ new Vector3(candidatePos.X, candidatePos.Y, targetZ),
+ fullCellId,
+ IsOnGround: true);
+ }
+}
+```
+
+- [ ] **Step 4: Run tests — verify all pass**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineTests" --nologo`
+
+Expected: PASS 6/6.
+
+- [ ] **Step 5: Run full suite — no regressions**
+
+Run: `dotnet test -c Debug --nologo`
+
+Expected: 227 + 16 = 243+ tests green, no regressions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/ResolveResult.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+git commit -m "$(cat <<'EOF'
+feat(core): Phase B.3 — PhysicsEngine (top-level collision resolver)
+
+Combines TerrainSurface + CellSurface into a single Resolve() API
+that handles outdoor terrain walking, indoor floor walking,
+outdoor↔indoor cell transitions, step-height enforcement, and
+ground detection.
+
+Step-height blocks upward Z deltas exceeding the limit (walls,
+cliffs); downhill movement is always accepted. Indoor transitions
+pick the cell whose floor Z is closest to the entity's current Z
+(handles multi-story buildings). Reports IsOnGround=false when
+no landblock or surface covers the entity's position (gravity
+applied by the caller).
+
+6 new tests covering flat terrain, slopes, step-height rejection,
+indoor entry/exit, and void detection.
+
+Co-Authored-By: Claude Opus 4.6 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: GameWindow integration (populate engine from streaming)
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+
+Wire the physics engine into the streaming pipeline so it's populated when landblocks load and cleaned up when they unload. Phase B.2 will consume it.
+
+- [ ] **Step 1: Add `_physicsEngine` field to GameWindow**
+
+Near the other streaming fields (`_streamer`, `_worldState`, etc.), add:
+
+```csharp
+private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
+```
+
+- [ ] **Step 2: Populate in `ApplyLoadedTerrainLocked`**
+
+After the existing `_worldState.SetLandblockAabb(...)` call, add:
+
+```csharp
+// Phase B.3: populate the physics collision engine for this landblock.
+var terrainSurface = new AcDream.Core.Physics.TerrainSurface(lb.Heightmap.Height, _heightTable);
+
+// Build CellSurfaces for each EnvCell in this landblock (reuse the
+// same EnvCell walk we do for the interior entity builder).
+var cellSurfaces = new List();
+var lbInfoPhys = _dats.Get((lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
+if (lbInfoPhys is not null && lbInfoPhys.NumCells > 0)
+{
+ uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u;
+ for (uint cellOffset = 0; cellOffset < lbInfoPhys.NumCells; cellOffset++)
+ {
+ uint envCellId = firstCellId + cellOffset;
+ var envCell = _dats.Get(envCellId);
+ if (envCell is null || envCell.EnvironmentId == 0) continue;
+
+ var environment = _dats.Get(0x0D000000u | envCell.EnvironmentId);
+ if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
+ continue;
+
+ // Transform CellStruct vertices from cell-local to world space.
+ var worldVerts = new Dictionary();
+ var cellOrigin = envCell.Position.Origin + new System.Numerics.Vector3(origin.X, origin.Y, 0f);
+ foreach (var kvp in cellStruct.VertexArray.Vertices)
+ {
+ var localPos = kvp.Value.Origin;
+ // Apply cell orientation + cell origin + landblock origin.
+ var rotated = System.Numerics.Vector3.Transform(localPos, envCell.Position.Orientation);
+ worldVerts[kvp.Key] = rotated + cellOrigin;
+ }
+
+ // Extract physics polygon vertex-id lists.
+ var polyVertIds = new List>();
+ foreach (var poly in cellStruct.PhysicsPolygons.Values)
+ {
+ if (poly.VertexIds.Count >= 3)
+ polyVertIds.Add(new List(poly.VertexIds));
+ }
+
+ if (polyVertIds.Count > 0)
+ {
+ cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(
+ envCellId, worldVerts, polyVertIds));
+ }
+ }
+}
+
+_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
+ worldOffsetX: origin.X, worldOffsetY: origin.Y);
+```
+
+**IMPORTANT:** `origin` is the `Vector3` already computed earlier in `ApplyLoadedTerrainLocked` — it's the landblock's world offset. Verify it's in scope at the point where you're adding this code.
+
+- [ ] **Step 3: Clean up in unload path**
+
+Find where `_worldState.RemoveLandblock(...)` is called (in `StreamingController.Tick`'s drain of `LandblockStreamResult.Unloaded`). The controller calls `_state.RemoveLandblock(unloaded.LandblockId)`. The physics engine cleanup should happen alongside — but the controller doesn't have a reference to the physics engine.
+
+Simplest: add a `removeTerrain` callback to `StreamingController` (same pattern as `applyTerrain`) OR let GameWindow handle it by subscribing to the `RemoveLandblock` action. OR just add a direct call in the controller's unload handler.
+
+For the MVP: in `StreamingController.Tick`, add a second callback `Action removeTerrain` that GameWindow passes to call `_physicsEngine.RemoveLandblock(id)` AND `_terrain.RemoveLandblock(id)`. This mirrors the existing `applyTerrain` callback pattern.
+
+- [ ] **Step 4: Build + test**
+
+Run: `cmd.exe /c "taskkill /F /IM AcDream.App.exe 2>nul" && dotnet build -c Debug && dotnet test -c Debug --nologo`
+
+Expected: 0 errors, 243+ tests green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Streaming/StreamingController.cs
+git commit -m "$(cat <<'EOF'
+feat(app): Phase B.3 — wire PhysicsEngine into streaming pipeline
+
+Populates the collision engine with TerrainSurface + CellSurface
+entries when landblocks stream in, removes them when they stream
+out. CellSurface vertices are transformed from cell-local to world
+space using EnvCell.Position orientation + origin.
+
+Phase B.2 (player movement mode) will call PhysicsEngine.Resolve()
+to get collision-validated positions before sending them to the
+server.
+
+Co-Authored-By: Claude Opus 4.6 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 5: Roadmap update
+
+- [ ] **Step 1: Move B.3 into the shipped table**
+
+Open `docs/plans/2026-04-11-roadmap.md`, add B.3 to the shipped table, mark B.1 as already shipped (Phase 4.9), and update the quick-lookup.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/plans/2026-04-11-roadmap.md
+git commit -m "docs: mark Phase B.3 (physics collision engine) as shipped
+
+Co-Authored-By: Claude Opus 4.6 (1M context) "
+```
+
+---
+
+## Self-review
+
+**Spec coverage:**
+- TerrainSurface.SampleZ (bilinear interpolation) — Task 1 ✓
+- TerrainSurface.ComputeOutdoorCellId — Task 1 ✓
+- CellSurface.SampleFloorZ (polygon projection + barycentric Z) — Task 2 ✓
+- PhysicsEngine.Resolve (outdoor/indoor/transition/step-height/gravity) — Task 3 ✓
+- Integration with streaming pipeline — Task 4 ✓
+- Step-height enforcement — Task 3 tests ✓
+- Outdoor→indoor transition — Task 3 tests ✓
+- Indoor→outdoor transition — Task 3 tests ✓
+- Gravity (isOnGround=false) — Task 3 tests ✓
+- Multi-story handling (closest-Z cell) — Task 3 implementation ✓
+- Roadmap update — Task 5 ✓
+
+**Placeholder scan:** None found. All code blocks are complete.
+
+**Type consistency:** `TerrainSurface(byte[], float[])`, `CellSurface(uint, Dictionary, List>)`, `PhysicsEngine.AddLandblock(uint, TerrainSurface, IReadOnlyList, float, float)`, `PhysicsEngine.Resolve(Vector3, uint, Vector3, float) → ResolveResult` — all consistent across tasks.
+
+---
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-04-12-physics-collision-engine.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — I dispatch a fresh Sonnet subagent per task, review between tasks, fast iteration. Best for this plan because Tasks 1-3 are independent pure-computation modules with clear test suites.
+
+**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
+
+Which approach?