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

38 KiB
Raw Blame History

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:

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:

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
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:

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:

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
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:

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:

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
// 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
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:

private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
  • Step 2: Populate in ApplyLoadedTerrainLocked

After the existing _worldState.SetLandblockAabb(...) call, add:

// 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
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
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?