diff --git a/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs b/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs
new file mode 100644
index 0000000..789331f
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs
@@ -0,0 +1,261 @@
+using AcDream.Core.Terrain;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Terrain;
+
+///
+/// Conformance tests verifying acdream's terrain algorithms produce
+/// identical results to the decompiled AC client (ClientReference.cs).
+/// Ported from WorldBuilder-ACME-Edition/WorldBuilder.Tests/TerrainConformanceTests.cs.
+///
+public class ClientConformanceTests
+{
+ // ── Split Direction ──────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(0, 0)]
+ [InlineData(1, 0)]
+ [InlineData(0, 1)]
+ [InlineData(7, 7)]
+ [InlineData(127, 127)]
+ [InlineData(1016, 1016)]
+ [InlineData(2039, 2039)]
+ [InlineData(500, 1200)]
+ [InlineData(1999, 3)]
+ public void SplitDirection_MatchesClient(int globalX, int globalY)
+ {
+ bool clientResult = ClientReference.IsSWtoNECut(globalX, globalY);
+
+ // Our CalculateSplitDirection takes landblock + cell separately.
+ // Convert global back to (landblockX, cellX, landblockY, cellY).
+ uint lbX = (uint)(globalX / 8);
+ uint cx = (uint)(globalX % 8);
+ uint lbY = (uint)(globalY / 8);
+ uint cy = (uint)(globalY % 8);
+ bool acdreamResult = TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy)
+ == CellSplitDirection.SWtoNE;
+
+ Assert.Equal(clientResult, acdreamResult);
+ }
+
+ [Fact]
+ public void SplitDirection_MatchesClient_FullSweep()
+ {
+ int mismatches = 0;
+ int tested = 0;
+
+ // Sweep every 8th landblock (covers the full coordinate range
+ // without testing 4M+ cells in CI — still 25,600 cells).
+ for (uint lbX = 0; lbX < 255; lbX += 8)
+ {
+ for (uint lbY = 0; lbY < 255; lbY += 8)
+ {
+ for (uint cx = 0; cx < 8; cx++)
+ {
+ for (uint cy = 0; cy < 8; cy++)
+ {
+ int gx = (int)(lbX * 8 + cx);
+ int gy = (int)(lbY * 8 + cy);
+
+ bool client = ClientReference.IsSWtoNECut(gx, gy);
+ bool acdream = TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy)
+ == CellSplitDirection.SWtoNE;
+
+ if (client != acdream) mismatches++;
+ tested++;
+ }
+ }
+ }
+ }
+
+ Assert.True(mismatches == 0,
+ $"Split direction mismatch: {mismatches} of {tested} cells differ");
+ }
+
+ // Also verify TerrainSurface's private IsSplitSWtoNE matches.
+ // We test it indirectly via SampleZ on a known asymmetric heightmap.
+ [Fact]
+ public void SplitDirection_TerrainSurface_AgreesWith_TerrainBlending()
+ {
+ // Build an asymmetric heightmap where the two split directions
+ // produce different Z at the cell center (0.5, 0.5).
+ // Heights: BL=0, BR=100, TL=0, TR=0 (steep slope along X only at Y=0)
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
+
+ // Set cell (0,0) corners: BL=(0,0)=0, BR=(1,0)=100, TL=(0,1)=0, TR=(1,1)=0
+ heights[0 * 9 + 0] = 0; // BL
+ heights[1 * 9 + 0] = 100; // BR
+ heights[0 * 9 + 1] = 0; // TL
+ heights[1 * 9 + 1] = 0; // TR
+
+ // Sample at cell center (12, 12) = (0.5, 0.5) in cell coords.
+ // For SWtoNE split (tx+ty=1 boundary): both triangles give Z=50 at center.
+ // For SEtoNW split (ty=tx boundary): both triangles give Z=50 at center.
+ // So at exact center both agree. Sample at (18, 6) = (0.75, 0.25) instead.
+ // SWtoNE: tx+ty=1.0 → tx=0.75, ty=0.25, 0.75+0.25=1.0 → boundary
+ // Use (20, 4) = (0.833, 0.167): tx+ty=1.0 → still boundary
+ // Use (20, 2) = (0.833, 0.083): tx+ty=0.917 < 1 → BL+BR+TL triangle
+ // Z = 0 + (100-0)*0.833 + (0-0)*0.083 = 83.3
+ // For SEtoNW: ty=0.083 < tx=0.833 → BL+BR+TR triangle
+ // Z = 0 + (100-0)*0.833 + (0-100)*0.083 = 83.3 - 8.3 = 75.0
+ // These differ! So we can distinguish.
+
+ // Use landblock (0,0) and check what the client says the split is.
+ bool clientSplit = ClientReference.IsSWtoNECut(0, 0);
+ var surface = new TerrainSurface(heights, heightTable, 0, 0);
+ float z = surface.SampleZ(20f, 2f);
+
+ if (clientSplit)
+ {
+ // SWtoNE → BL+BR+TL triangle at (0.833, 0.083) → Z ≈ 83.3
+ Assert.InRange(z, 82f, 85f);
+ }
+ else
+ {
+ // SEtoNW → BL+BR+TR triangle at (0.833, 0.083) → Z ≈ 75.0
+ Assert.InRange(z, 74f, 77f);
+ }
+ }
+
+ // ── PalCode ──────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(0, 0, 0, 0, 1, 1, 1, 1)]
+ [InlineData(1, 0, 0, 0, 5, 10, 15, 20)]
+ [InlineData(3, 3, 3, 3, 31, 31, 31, 31)]
+ [InlineData(0, 1, 2, 3, 0, 5, 10, 15)]
+ [InlineData(2, 0, 1, 3, 8, 4, 12, 16)]
+ public void PalCode_MatchesClient(int r0, int r1, int r2, int r3, int t0, int t1, int t2, int t3)
+ {
+ // Our GetPalCode parameter order: (rBL, rBR, rTR, rTL, tBL, tBR, tTR, tTL)
+ // Client parameter order: (r0,t0, r1,t1, r2,t2, r3,t3) where
+ // 0=BL(ix,iy), 1=BR(ix+1,iy), 2=TR(ix+1,iy+1), 3=TL(ix,iy+1)
+ uint clientResult = ClientReference.GetPalCode(r0, t0, r1, t1, r2, t2, r3, t3);
+ uint acdreamResult = TerrainBlending.GetPalCode(r0, r1, r2, r3, t0, t1, t2, t3);
+
+ Assert.Equal(clientResult, acdreamResult);
+ }
+
+ [Fact]
+ public void PalCode_MatchesClient_ExhaustiveRoads()
+ {
+ int mismatches = 0;
+ int t0 = 5, t1 = 10, t2 = 15, t3 = 20;
+
+ for (int r0 = 0; r0 <= 3; r0++)
+ for (int r1 = 0; r1 <= 3; r1++)
+ for (int r2 = 0; r2 <= 3; r2++)
+ for (int r3 = 0; r3 <= 3; r3++)
+ {
+ uint client = ClientReference.GetPalCode(r0, t0, r1, t1, r2, t2, r3, t3);
+ uint acdream = TerrainBlending.GetPalCode(r0, r1, r2, r3, t0, t1, t2, t3);
+ if (client != acdream) mismatches++;
+ }
+
+ Assert.Equal(0, mismatches);
+ }
+
+ [Fact]
+ public void PalCode_MatchesClient_ExhaustiveTypes()
+ {
+ int mismatches = 0;
+ int r0 = 1, r1 = 0, r2 = 2, r3 = 3;
+
+ for (int t0 = 0; t0 < 32; t0++)
+ for (int t1 = 0; t1 < 32; t1++)
+ for (int t2 = 0; t2 < 32; t2++)
+ for (int t3 = 0; t3 < 32; t3++)
+ {
+ uint client = ClientReference.GetPalCode(r0, t0, r1, t1, r2, t2, r3, t3);
+ uint acdream = TerrainBlending.GetPalCode(r0, r1, r2, r3, t0, t1, t2, t3);
+ if (client != acdream) mismatches++;
+ }
+
+ Assert.Equal(0, mismatches);
+ }
+
+ // ── Height Sampling ──────────────────────────────────────────────────
+
+ [Fact]
+ public void HeightSampling_FlatTerrain_MatchesClient()
+ {
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = i * 2f;
+
+ byte heightByte = 50;
+ float clientHeight = ClientReference.GetVertexHeight(heightTable, heightByte);
+
+ // Build a flat heightmap and sample at the center.
+ var heights = new byte[81];
+ Array.Fill(heights, heightByte);
+ var surface = new TerrainSurface(heights, heightTable);
+ float acdreamHeight = surface.SampleZ(96f, 96f);
+
+ Assert.Equal(clientHeight, acdreamHeight, precision: 3);
+ }
+
+ [Fact]
+ public void HeightSampling_VertexCorners_MatchClient()
+ {
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = i * 2f;
+
+ // Sloped heightmap: height = x*10 + y*5
+ var heights = new byte[81];
+ for (int x = 0; x <= 8; x++)
+ for (int y = 0; y <= 8; y++)
+ heights[x * 9 + y] = (byte)(x * 10 + y * 5);
+
+ var surface = new TerrainSurface(heights, heightTable);
+
+ for (int vx = 0; vx <= 8; vx++)
+ for (int vy = 0; vy <= 8; vy++)
+ {
+ byte hByte = heights[vx * 9 + vy];
+ float clientH = ClientReference.GetVertexHeight(heightTable, hByte);
+ float acdreamH = surface.SampleZ(vx * 24f, vy * 24f);
+
+ Assert.Equal(clientH, acdreamH, precision: 1); // edge vertices have float clamping artifacts
+ }
+ }
+
+ [Theory]
+ [InlineData(0f, 0f)]
+ [InlineData(12f, 12f)]
+ [InlineData(23.9f, 23.9f)]
+ [InlineData(48f, 72f)]
+ [InlineData(96f, 96f)]
+ [InlineData(180f, 180f)]
+ public void HeightSampling_InterpolatedPoints_InRange(float localX, float localY)
+ {
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = i * 2f;
+
+ var heights = new byte[81];
+ for (int x = 0; x <= 8; x++)
+ for (int y = 0; y <= 8; y++)
+ heights[x * 9 + y] = (byte)(x * 10 + y * 5);
+
+ var surface = new TerrainSurface(heights, heightTable);
+ float z = surface.SampleZ(localX, localY);
+
+ float minH = heightTable[0];
+ float maxH = heightTable[heights.Max()];
+
+ Assert.InRange(z, minH, maxH);
+ }
+
+ // ── Constants ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Constants_MatchClient()
+ {
+ // TerrainSurface uses these internally; verify they match.
+ Assert.Equal(ClientReference.CellSize, 24f);
+ Assert.Equal(ClientReference.CellsPerBlock, 8);
+ Assert.Equal(ClientReference.BlockLength, 192f);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Terrain/ClientReference.cs b/tests/AcDream.Core.Tests/Terrain/ClientReference.cs
new file mode 100644
index 0000000..6408cec
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Terrain/ClientReference.cs
@@ -0,0 +1,75 @@
+using System.Numerics;
+using System.Runtime.CompilerServices;
+
+namespace AcDream.Core.Tests.Terrain;
+
+///
+/// Faithful C# port of the original AC client terrain algorithms,
+/// translated from decompiled C++ in acclient-source-split/CLandBlockStruct.cpp.
+/// Serves as the ground-truth oracle for conformance testing.
+///
+/// Ported from WorldBuilder-ACME-Edition/WorldBuilder.Tests/ClientReference.cs.
+/// All formulas use signed int arithmetic with unchecked wrapping to match x86 behavior.
+///
+public static class ClientReference
+{
+ ///
+ /// Port of CLandBlockStruct::ConstructPolygons at offset 0x00531D10.
+ /// Returns true when the triangle split goes from SW to NE (SWtoNEcut=1).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsSWtoNECut(int globalCellX, int globalCellY)
+ {
+ unchecked
+ {
+ int v7 = globalCellY * (214614067 * globalCellX + 1813693831)
+ - 1109124029 * globalCellX - 1369149221;
+ return (double)(uint)v7 * 2.3283064e-10 >= 0.5;
+ }
+ }
+
+ ///
+ /// Port of pal_code[0] from CLandBlockStruct::GetCellRotation at offset 0x00532170.
+ /// Corner order: 0=(ix,iy), 1=(ix+1,iy), 2=(ix+1,iy+1), 3=(ix,iy+1)
+ ///
+ public static uint GetPalCode(
+ int r0, int t0,
+ int r1, int t1,
+ int r2, int t2,
+ int r3, int t3,
+ int texSize = 1)
+ {
+ unchecked
+ {
+ return (uint)(t3
+ + (texSize << 28)
+ + 32 * (t2 + 32 * (t1 + 32 * (t0 + 32 * (r3 + 4 * (r2 + 4 * (r1 + 4 * r0)))))));
+ }
+ }
+
+ ///
+ /// Port of CLandBlockStruct::ConstructVertices at offset 0x005328D0.
+ /// Height = LandDefs::Land_Height_Table[height_byte]
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static float GetVertexHeight(float[] landHeightTable, byte heightByte)
+ {
+ return landHeightTable[heightByte];
+ }
+
+ ///
+ /// Port of CLandBlockStruct::ConstructVertices vertex position.
+ /// Each vertex is at (ix * polySize, iy * polySize, height).
+ ///
+ public static Vector3 GetVertexPosition(float[] landHeightTable, byte heightByte, int ix, int iy, float polySize = 24f)
+ {
+ return new Vector3(ix * polySize, iy * polySize, landHeightTable[heightByte]);
+ }
+
+ public const int MapWidth = 255;
+ public const int MapHeight = 255;
+ public const float CellSize = 24.0f;
+ public const int CellsPerBlock = 8;
+ public const float RoadWidth = 5.0f;
+ public const float BlockLength = 192.0f;
+}