diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs
new file mode 100644
index 0000000..b1bc4cf
--- /dev/null
+++ b/src/AcDream.Core/Physics/TerrainSurface.cs
@@ -0,0 +1,79 @@
+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);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
new file mode 100644
index 0000000..fb70cd4
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
@@ -0,0 +1,96 @@
+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));
+ }
+}