feat(core): terrain palette + cell split math (Phase 3c.1)

First of four steps porting WorldBuilder's texture-merge terrain
blending. This commit is pure CPU math with no GL or dat dependencies
so the ported logic can be verified in isolation before it starts
driving real rendering.

Ported:
  - GetPalCode(r1..r4, t1..t4): packs corner terrain/road bits into
    a 32-bit palette code (bit layout documented in XML comment)
  - CalculateSplitDirection: deterministic hash picking SWtoNE vs
    SEtoNW triangulation for a cell; magic constants kept exact to
    match AC's server-side collision triangulation
  - CellSplitDirection enum with values matching WorldBuilder's so
    later bit-packing stays byte-identical

Tests (10 new, 58/58 passing total):
  - GetPalCode golden value for all-grass-no-roads: 0x10008421
    (hand-computed from the bit layout, not derived from a run)
  - GetPalCode all-zero produces only the sizeBits marker
  - GetPalCode determinism, road-flag isolation (r1 flip touches
    only bit 26), size bit always set, terrain region bounded to
    bits 0-19
  - CalculateSplitDirection hand-computed golden for (0,0,0,0):
    (1813693831 - 1369149221) * (1/2^32) ~= 0.1035 < 0.5 -> SWtoNE
  - Determinism
  - Across a full 8x8 landblock the hash produces a mix of both
    split directions (would fail if the hash collapses)

Deferred to Phase 3c.3 (need dat data for TexMerge):
  BuildSurface, FillCellData, PseudoRandomIndex, SurfaceInfo

Reference: WorldBuilder Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs
           WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 13:36:35 +02:00
parent 3fb6b67735
commit e6cfcb612b
3 changed files with 187 additions and 0 deletions

View file

@ -0,0 +1,14 @@
namespace AcDream.Core.Terrain;
/// <summary>
/// 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.
/// </summary>
public enum CellSplitDirection
{
SWtoNE = 0,
SEtoNW = 1,
}

View file

@ -0,0 +1,60 @@
namespace AcDream.Core.Terrain;
/// <summary>
/// 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.
/// </summary>
public static class TerrainBlending
{
/// <summary>
/// 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)
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -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");
}
}