acdream/docs/superpowers/plans/2026-04-12-physics-collision-engine.md
Erik 7ced94b138 docs(plans): Phase B.3 physics collision engine implementation plan
5-task TDD plan: TerrainSurface (outdoor heightmap Z + cell ID),
CellSurface (indoor floor polygon projection via barycentric interp),
PhysicsEngine (top-level resolver with step-height + cell transitions),
GameWindow integration (populate from streaming), and roadmap update.

~16 new unit tests with fake data. No dat files or rendering needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:48:06 +02:00

1095 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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;
/// <summary>
/// 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.
///
/// <para>
/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined
/// and not reusable). The heightmap is indexed x-major:
/// <c>heights[x * 9 + y]</c>; each byte is a lookup into
/// <paramref name="heightTable"/> (256-entry float array from
/// Region.LandDefs.LandHeightTable).
/// </para>
/// </summary>
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;
}
/// <summary>
/// Bilinear-interpolated terrain Z at (localX, localY) in
/// landblock-local coordinates (0..192 range).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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) <noreply@anthropic.com>
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
{
/// <summary>
/// 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.
/// </summary>
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<ushort, Vector3>
{
[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<List<short>>
{
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<ushort, Vector3>
{
[0] = new(0f, 0f, 0f),
[1] = new(20f, 0f, 0f),
[2] = new(10f, 20f, 20f),
};
var polygons = new List<List<short>> { 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;
/// <summary>
/// Indoor floor resolver for a single EnvCell. Projects an XY point
/// onto the cell's floor polygons and returns the Z at that point.
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class CellSurface
{
public uint CellId { get; }
private readonly List<(Vector3 A, Vector3 B, Vector3 C)> _triangles;
/// <summary>
/// Construct a CellSurface from pre-transformed vertex positions
/// and polygon definitions.
/// </summary>
/// <param name="cellId">The EnvCell dat id (e.g., 0xA9B40100).</param>
/// <param name="vertices">Vertex id → world-space position map.</param>
/// <param name="polygonVertexIds">
/// List of polygons, each a list of vertex IDs. Polygons with fewer
/// than 3 vertices are skipped. Quads and larger are fan-triangulated.
/// </param>
public CellSurface(
uint cellId,
Dictionary<ushort, Vector3> vertices,
List<List<short>> 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<Vector3>(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]));
}
}
}
/// <summary>
/// Project (worldX, worldY) onto this cell's floor polygons and
/// return the Z. Returns null if outside all floor polygons.
/// </summary>
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;
}
/// <summary>
/// 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 <paramref name="z"/>.
/// </summary>
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) <noreply@anthropic.com>
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;
/// <summary>
/// Result of <see cref="PhysicsEngine.Resolve"/>: the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
/// </summary>
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<CellSurface>(),
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<CellSurface>(),
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<CellSurface>(),
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<ushort, Vector3>
{
[0] = new(40f, 40f, 55f),
[1] = new(60f, 40f, 55f),
[2] = new(60f, 60f, 55f),
[3] = new(40f, 60f, 55f),
};
var cellPolys = new List<List<short>> { 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<ushort, Vector3>
{
[0] = new(40f, 40f, 55f),
[1] = new(60f, 40f, 55f),
[2] = new(60f, 60f, 55f),
[3] = new(40f, 60f, 55f),
};
var cellPolys = new List<List<short>> { 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<uint, (TerrainSurface Terrain, IReadOnlyList<CellSurface> 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<uint, LandblockPhysics> _landblocks = new();
private sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
float WorldOffsetX,
float WorldOffsetY);
public void AddLandblock(uint landblockId, TerrainSurface terrain,
IReadOnlyList<CellSurface> 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) <noreply@anthropic.com>
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<AcDream.Core.Physics.CellSurface>();
var lbInfoPhys = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((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<DatReaderWriter.DBObjs.EnvCell>(envCellId);
if (envCell is null || envCell.EnvironmentId == 0) continue;
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(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<ushort, System.Numerics.Vector3>();
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<List<short>>();
foreach (var poly in cellStruct.PhysicsPolygons.Values)
{
if (poly.VertexIds.Count >= 3)
polyVertIds.Add(new List<short>(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<uint> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>"
```
---
## 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<ushort, Vector3>, List<List<short>>)`, `PhysicsEngine.AddLandblock(uint, TerrainSurface, IReadOnlyList<CellSurface>, 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?