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?