diff --git a/src/AcDream.Core/Terrain/CellSplitDirection.cs b/src/AcDream.Core/Terrain/CellSplitDirection.cs
new file mode 100644
index 0000000..0fdc2b9
--- /dev/null
+++ b/src/AcDream.Core/Terrain/CellSplitDirection.cs
@@ -0,0 +1,14 @@
+namespace AcDream.Core.Terrain;
+
+///
+/// How a 24×24 terrain cell is split into two triangles. The direction is a
+/// deterministic hash of the landblock+cell coordinates so that the visible
+/// terrain mesh agrees with the server's collision triangulation.
+/// Enum values match WorldBuilder's CellSplitDirection so ported code stays
+/// byte-identical at bit-packing boundaries.
+///
+public enum CellSplitDirection
+{
+ SWtoNE = 0,
+ SEtoNW = 1,
+}
diff --git a/src/AcDream.Core/Terrain/TerrainBlending.cs b/src/AcDream.Core/Terrain/TerrainBlending.cs
new file mode 100644
index 0000000..66e5583
--- /dev/null
+++ b/src/AcDream.Core/Terrain/TerrainBlending.cs
@@ -0,0 +1,60 @@
+namespace AcDream.Core.Terrain;
+
+///
+/// Asheron's Call terrain texture-merge math: pure functions that turn raw
+/// per-corner terrain/road data into the palette codes and split directions
+/// that drive per-cell texture blending. All algorithms are ports of
+/// WorldBuilder (Chorizite) — see references/WorldBuilder/...
+/// Pure CPU math, no GL, no dats — everything here is unit-testable in isolation.
+///
+public static class TerrainBlending
+{
+ ///
+ /// Packs the 4 corner terrain types and 4 corner road flags of a cell into
+ /// a single 32-bit palette code. Two cells with identical corner layouts
+ /// always hash to the same code, which keys a cache of precomputed blend
+ /// overlays in later phases.
+ /// Ported verbatim from WorldBuilder LandSurfaceManager.GetPalCode.
+ /// Bit layout (from LSB):
+ /// bits 0- 4 t4 (5 bits)
+ /// bits 5- 9 t3
+ /// bits 10-14 t2
+ /// bits 15-19 t1
+ /// bits 20-21 r4 (2 bits)
+ /// bits 22-23 r3
+ /// bits 24-25 r2
+ /// bits 26-27 r1
+ /// bit 28 size marker (always set)
+ ///
+ public static uint GetPalCode(
+ int r1, int r2, int r3, int r4,
+ int t1, int t2, int t3, int t4)
+ {
+ uint terrainBits = (uint)((t1 << 15) | (t2 << 10) | (t3 << 5) | t4);
+ uint roadBits = (uint)((r1 << 26) | (r2 << 24) | (r3 << 22) | (r4 << 20));
+ uint sizeBits = 1u << 28;
+ return sizeBits | roadBits | terrainBits;
+ }
+
+ ///
+ /// Picks which way a 24×24 terrain cell is split into triangles. The hash
+ /// is designed so that both the visible mesh and the server's collision
+ /// triangulation agree on the same split — diverging from it causes
+ /// visual/physics disagreement at cell boundaries.
+ /// Ported verbatim from WorldBuilder TerrainUtils.CalculateSplitDirection;
+ /// the magic constants must stay exact or AC cells won't split correctly.
+ ///
+ public static CellSplitDirection CalculateSplitDirection(
+ uint landblockX, uint cellX, uint landblockY, uint cellY)
+ {
+ uint seedA = (landblockX * 8 + cellX) * 214614067u;
+ uint seedB = (landblockY * 8 + cellY) * 1109124029u;
+ uint magicA = seedA + 1813693831u;
+ uint magicB = seedB;
+ // uint wraparound is intentional — matches WorldBuilder and AC.
+ float splitDir = magicA - magicB - 1369149221u;
+ return splitDir * 2.3283064e-10f >= 0.5f
+ ? CellSplitDirection.SEtoNW
+ : CellSplitDirection.SWtoNE;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs b/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs
new file mode 100644
index 0000000..0963ce1
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs
@@ -0,0 +1,113 @@
+using AcDream.Core.Terrain;
+using Xunit;
+
+namespace AcDream.Core.Tests.Terrain;
+
+public class TerrainBlendingTests
+{
+ // Asheron's Call terrain texture type values (from TerrainTextureType enum)
+ private const int Grass = 1;
+ private const int Dirt = 4;
+
+ [Fact]
+ public void GetPalCode_AllGrassNoRoads_ProducesHandComputedValue()
+ {
+ // sizeBits = 1 << 28 = 0x10000000
+ // terrainBits= (1<<15)|(1<<10)|(1<<5)|1 = 0x8421 (t1..t4 = Grass=1)
+ // roadBits = 0
+ // total = 0x10008421
+ uint actual = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Grass, Grass);
+ Assert.Equal(0x10008421u, actual);
+ }
+
+ [Fact]
+ public void GetPalCode_AllZeros_ProducesOnlySizeBit()
+ {
+ uint actual = TerrainBlending.GetPalCode(0, 0, 0, 0, 0, 0, 0, 0);
+ Assert.Equal(0x10000000u, actual);
+ }
+
+ [Fact]
+ public void GetPalCode_IsDeterministic()
+ {
+ uint a = TerrainBlending.GetPalCode(1, 0, 0, 1, Grass, Dirt, Grass, Dirt);
+ uint b = TerrainBlending.GetPalCode(1, 0, 0, 1, Grass, Dirt, Grass, Dirt);
+ Assert.Equal(a, b);
+ }
+
+ [Fact]
+ public void GetPalCode_DifferentTerrainCornersProduceDifferentCodes()
+ {
+ uint allGrass = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Grass, Grass);
+ uint grassDirtMix = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Dirt, Grass, Dirt);
+ Assert.NotEqual(allGrass, grassDirtMix);
+ }
+
+ [Fact]
+ public void GetPalCode_RoadFlagAffectsOutput()
+ {
+ uint noRoad = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Grass, Grass);
+ uint withRoad = TerrainBlending.GetPalCode(1, 0, 0, 0, Grass, Grass, Grass, Grass);
+ Assert.NotEqual(noRoad, withRoad);
+ // r1 occupies bits 26-27, so a road-only delta changes only those bits
+ Assert.Equal(1u << 26, noRoad ^ withRoad);
+ }
+
+ [Fact]
+ public void GetPalCode_SizeBitAlwaysSet()
+ {
+ uint code = TerrainBlending.GetPalCode(3, 3, 3, 3, Dirt, Dirt, Dirt, Dirt);
+ Assert.Equal(1u << 28, code & (1u << 28));
+ }
+
+ [Fact]
+ public void GetPalCode_TerrainBitsOccupyLowFifteen()
+ {
+ // With all roads zero, the top 15 bits (above bit 15) should only contain
+ // the sizeBits marker. Terrain fields occupy bits 0-19: t1=15..19, t2=10..14,
+ // t3=5..9, t4=0..4. With t values up to 31 (5 bits), the terrain region
+ // stays within bits 0-19.
+ uint code = TerrainBlending.GetPalCode(0, 0, 0, 0, 31, 31, 31, 31);
+ // Nothing between bits 20-27 should be set.
+ uint forbiddenRegion = code & 0x0FF00000u;
+ Assert.Equal(0u, forbiddenRegion);
+ }
+
+ [Fact]
+ public void CalculateSplitDirection_OriginCell_IsSWtoNE()
+ {
+ // Hand-computed:
+ // seedA = 0, seedB = 0
+ // magicA = 1813693831, magicB = 0
+ // uintRes = 1813693831 - 0 - 1369149221 = 444544610
+ // norm = 444544610 * (1/2^32) ≈ 0.1035 < 0.5 → SWtoNE
+ var actual = TerrainBlending.CalculateSplitDirection(0, 0, 0, 0);
+ Assert.Equal(CellSplitDirection.SWtoNE, actual);
+ }
+
+ [Fact]
+ public void CalculateSplitDirection_IsDeterministic()
+ {
+ var a = TerrainBlending.CalculateSplitDirection(0xA9, 3, 0xB4, 5);
+ var b = TerrainBlending.CalculateSplitDirection(0xA9, 3, 0xB4, 5);
+ Assert.Equal(a, b);
+ }
+
+ [Fact]
+ public void CalculateSplitDirection_DifferentCellsCanDiffer()
+ {
+ // We can't assert the exact value for every cell without instrumenting
+ // WorldBuilder, but across a full landblock's 64 cells the hash is
+ // designed to produce a mix of both directions — if we see only one,
+ // the hash has collapsed.
+ int swCount = 0, seCount = 0;
+ for (uint cx = 0; cx < 8; cx++)
+ for (uint cy = 0; cy < 8; cy++)
+ {
+ var dir = TerrainBlending.CalculateSplitDirection(0xA9, cx, 0xB4, cy);
+ if (dir == CellSplitDirection.SWtoNE) swCount++; else seCount++;
+ }
+ Assert.True(swCount > 0, "Expected at least one SWtoNE cell in a landblock");
+ Assert.True(seCount > 0, "Expected at least one SEtoNW cell in a landblock");
+ }
+}