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