Ports WorldBuilder's full BuildTexture / FillCellData pipeline as
pure CPU functions in TerrainBlending.cs, along with the SurfaceInfo
recipe record and a TerrainBlendingContext input struct that carries
the atlas index lists the algorithm needs.
This is still pure algorithm work — no GL, no shaders, no mesh gen
changes. Visual Phase 3c.4 next commit wires it into LandblockMesh
and rewrites the terrain shaders to consume Data0..3.
Added (all ports of WorldBuilder LandSurfaceManager methods):
- ExtractTerrainCodes: inverse of GetPalCode terrain bits
- PseudoRandomIndex: deterministic hash over palette code for alpha
variant selection; overflow-dependent int math matches WorldBuilder
byte-for-byte
- RotateTerrainCode: *2 with wrap (1→2→4→8→1, multi-corner patterns
handled in tests)
- GetRoadCodes: decodes the 8-bit road mask into up to two canonical
road patterns + allRoad flag; magic 0xE/0xD/0xB/0x7 switch kept verbatim
- FindTerrainAlpha: picks corner vs side alpha map, walks the 4
rotations looking for a TCode match, returns (alphaLayer, rotation)
or (255, 0) for "not found"
- FindRoadAlpha: same idea for road maps, iterates all maps from a
pseudo-random offset
- BuildSurface: composes the above into a SurfaceInfo, handling the
all-road, all-duplicate-terrain, and distinct-terrain cases via
BuildOverlayLayers + BuildWithDuplicates (ports GetTerrainTextures +
BuildTerrainCodesWithDuplicates)
- FillCellData: packs a SurfaceInfo + CellSplitDirection into the 4
uint32 vertex attributes Data0..Data3. Byte layout documented in
XML comment and matches WorldBuilder's Landscape.vert uvec4 byte
unpacking exactly.
SurfaceInfo record carries resolved atlas byte layers directly (base +
3 terrain overlays + 2 road overlays, each with optional alpha layer
and 0-3 rotation). Sentinel 255 = "slot unused".
Tests (14 new, 75/75 total):
- ExtractTerrainCodes round-trip with GetPalCode
- RotateTerrainCode single-corner cycle + multi-corner patterns
- GetRoadCodes: no-road, all-road, single-corner road
- PseudoRandomIndex: range, count=0 guard, determinism
- BuildSurface: all-grass → base only; all-road → road as base;
two-grass-two-dirt → base + overlay
- FillCellData: full round-trip bit layout with recognizable
byte values in every slot, plus a no-road1 case that verifies
the texRd1 slot collapses to 255 when road1 alpha is absent
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
332 lines
13 KiB
C#
332 lines
13 KiB
C#
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");
|
|
}
|
|
|
|
// ---- Phase 3c.3 tests ----
|
|
|
|
[Fact]
|
|
public void ExtractTerrainCodes_InverseOfGetPalCode()
|
|
{
|
|
uint palCode = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Dirt, Grass, Dirt);
|
|
uint[] extracted = TerrainBlending.ExtractTerrainCodes(palCode);
|
|
Assert.Equal(new uint[] { Grass, Dirt, Grass, Dirt }, extracted);
|
|
}
|
|
|
|
[Fact]
|
|
public void RotateTerrainCode_CyclesThroughSingleCornerPatterns()
|
|
{
|
|
Assert.Equal(2u, TerrainBlending.RotateTerrainCode(1));
|
|
Assert.Equal(4u, TerrainBlending.RotateTerrainCode(2));
|
|
Assert.Equal(8u, TerrainBlending.RotateTerrainCode(4));
|
|
Assert.Equal(1u, TerrainBlending.RotateTerrainCode(8)); // 16 → -15 = 1
|
|
}
|
|
|
|
[Fact]
|
|
public void RotateTerrainCode_HandlesMultiCornerPatterns()
|
|
{
|
|
// Pattern 3 (corners 0+1) * 2 = 6, still < 16 → 6
|
|
Assert.Equal(6u, TerrainBlending.RotateTerrainCode(3));
|
|
// Pattern 6 (corners 1+2) * 2 = 12, still < 16 → 12
|
|
Assert.Equal(12u, TerrainBlending.RotateTerrainCode(6));
|
|
// Pattern 12 (corners 2+3) * 2 = 24, >= 16 → 24 - 15 = 9
|
|
Assert.Equal(9u, TerrainBlending.RotateTerrainCode(12));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetRoadCodes_NoRoad_IsAllZero()
|
|
{
|
|
uint pal = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Grass, Grass);
|
|
var (r0, r1, allRoad) = TerrainBlending.GetRoadCodes(pal);
|
|
Assert.Equal(0u, r0);
|
|
Assert.Equal(0u, r1);
|
|
Assert.False(allRoad);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetRoadCodes_AllFourRoads_IsAllRoad()
|
|
{
|
|
uint pal = TerrainBlending.GetPalCode(1, 1, 1, 1, Grass, Grass, Grass, Grass);
|
|
var (_, _, allRoad) = TerrainBlending.GetRoadCodes(pal);
|
|
Assert.True(allRoad);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetRoadCodes_SingleCornerRoad_MapsToMaskBit()
|
|
{
|
|
// r1 set → mask bit 1 → roadCodes[0] = 1 (single corner 0), roadCodes[1] = 0
|
|
uint pal = TerrainBlending.GetPalCode(1, 0, 0, 0, Grass, Grass, Grass, Grass);
|
|
var (r0, r1, allRoad) = TerrainBlending.GetRoadCodes(pal);
|
|
Assert.Equal(1u, r0);
|
|
Assert.Equal(0u, r1);
|
|
Assert.False(allRoad);
|
|
}
|
|
|
|
[Fact]
|
|
public void PseudoRandomIndex_ReturnsValueInRange()
|
|
{
|
|
for (int seed = 1; seed < 20; seed++)
|
|
{
|
|
int v = TerrainBlending.PseudoRandomIndex((uint)seed * 1000u, 5);
|
|
Assert.InRange(v, 0, 4);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PseudoRandomIndex_CountZero_ReturnsZero()
|
|
{
|
|
Assert.Equal(0, TerrainBlending.PseudoRandomIndex(0x12345678u, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void PseudoRandomIndex_IsDeterministic()
|
|
{
|
|
uint pal = 0xDEADBEEFu;
|
|
int a = TerrainBlending.PseudoRandomIndex(pal, 4);
|
|
int b = TerrainBlending.PseudoRandomIndex(pal, 4);
|
|
Assert.Equal(a, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildSurface_AllGrassNoRoads_ReturnsBaseOnly()
|
|
{
|
|
var ctx = MakeContext(terrainLayers: new Dictionary<uint, byte>
|
|
{
|
|
[Grass] = 7,
|
|
[Dirt] = 11,
|
|
});
|
|
uint pal = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Grass, Grass);
|
|
|
|
var surf = TerrainBlending.BuildSurface(pal, ctx);
|
|
|
|
Assert.Equal(7, surf.BaseLayer);
|
|
Assert.Equal(SurfaceInfo.None, surf.Ovl0Layer);
|
|
Assert.Equal(SurfaceInfo.None, surf.Ovl1Layer);
|
|
Assert.Equal(SurfaceInfo.None, surf.Ovl2Layer);
|
|
Assert.Equal(SurfaceInfo.None, surf.RoadLayer);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildSurface_AllRoadsCell_UsesRoadTextureAsBase()
|
|
{
|
|
var ctx = MakeContext(
|
|
terrainLayers: new Dictionary<uint, byte> { [Grass] = 7 },
|
|
roadLayer: 24);
|
|
uint pal = TerrainBlending.GetPalCode(1, 1, 1, 1, Grass, Grass, Grass, Grass);
|
|
|
|
var surf = TerrainBlending.BuildSurface(pal, ctx);
|
|
|
|
Assert.Equal(24, surf.BaseLayer);
|
|
Assert.Equal(SurfaceInfo.None, surf.Ovl0Layer);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildSurface_TwoGrassTwoDirt_EmitsBaseAndAtLeastOneOverlay()
|
|
{
|
|
var ctx = MakeContext(terrainLayers: new Dictionary<uint, byte>
|
|
{
|
|
[Grass] = 7,
|
|
[Dirt] = 11,
|
|
});
|
|
uint pal = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Dirt, Dirt);
|
|
|
|
var surf = TerrainBlending.BuildSurface(pal, ctx);
|
|
|
|
// Base must be the duplicate (grass, layer 7).
|
|
Assert.Equal(7, surf.BaseLayer);
|
|
// There must be at least one overlay with the other terrain (dirt, layer 11).
|
|
Assert.Equal(11, surf.Ovl0Layer);
|
|
}
|
|
|
|
[Fact]
|
|
public void FillCellData_RoundTripBitLayout()
|
|
{
|
|
// Craft a SurfaceInfo with distinct, recognizable byte values in each slot.
|
|
var s = new SurfaceInfo(
|
|
BaseLayer: 0x01,
|
|
Ovl0Layer: 0x02, Ovl0AlphaLayer: 0x03, Ovl0Rotation: 1,
|
|
Ovl1Layer: 0x04, Ovl1AlphaLayer: 0x05, Ovl1Rotation: 2,
|
|
Ovl2Layer: 0x06, Ovl2AlphaLayer: 0x07, Ovl2Rotation: 3,
|
|
RoadLayer: 0x08, Road0AlphaLayer: 0x09, Road0Rotation: 1,
|
|
Road1AlphaLayer: 0x0A, Road1Rotation: 2);
|
|
|
|
var (d0, d1, d2, d3) = TerrainBlending.FillCellData(s, CellSplitDirection.SEtoNW);
|
|
|
|
// data0 low byte = BaseLayer (0x01)
|
|
Assert.Equal(0x01u, d0 & 0xFFu);
|
|
// data0 byte 1 = baseAlpha = 255 (unused slot)
|
|
Assert.Equal(0xFFu, (d0 >> 8) & 0xFFu);
|
|
// data0 byte 2 = Ovl0Layer (0x02)
|
|
Assert.Equal(0x02u, (d0 >> 16) & 0xFFu);
|
|
// data0 byte 3 = Ovl0AlphaLayer (0x03)
|
|
Assert.Equal(0x03u, (d0 >> 24) & 0xFFu);
|
|
// data1: Ovl1Layer, Ovl1AlphaLayer, Ovl2Layer, Ovl2AlphaLayer
|
|
Assert.Equal(0x04u, d1 & 0xFFu);
|
|
Assert.Equal(0x05u, (d1 >> 8) & 0xFFu);
|
|
Assert.Equal(0x06u, (d1 >> 16) & 0xFFu);
|
|
Assert.Equal(0x07u, (d1 >> 24) & 0xFFu);
|
|
// data2: Road0Layer, Road0AlphaLayer, Road1Layer (= RoadLayer because Road1AlphaLayer != None), Road1AlphaLayer
|
|
Assert.Equal(0x08u, d2 & 0xFFu);
|
|
Assert.Equal(0x09u, (d2 >> 8) & 0xFFu);
|
|
Assert.Equal(0x08u, (d2 >> 16) & 0xFFu); // texRd1 = same road layer
|
|
Assert.Equal(0x0Au, (d2 >> 24) & 0xFFu);
|
|
// data3: rotBase=0 bits 0-1, rotOvl0=1 bits 2-3, rotOvl1=2 bits 4-5,
|
|
// rotOvl2=3 bits 6-7, rotRd0=1 bits 8-9, rotRd1=2 bits 10-11, split=1 bit 12
|
|
uint expectedD3 = (1u << 2) | (2u << 4) | (3u << 6) | (1u << 8) | (2u << 10) | (1u << 12);
|
|
Assert.Equal(expectedD3, d3);
|
|
}
|
|
|
|
[Fact]
|
|
public void FillCellData_NoRoad1_LeavesRoad1SlotAs255()
|
|
{
|
|
var s = new SurfaceInfo(
|
|
BaseLayer: 0x01,
|
|
Ovl0Layer: SurfaceInfo.None, Ovl0AlphaLayer: SurfaceInfo.None, Ovl0Rotation: 0,
|
|
Ovl1Layer: SurfaceInfo.None, Ovl1AlphaLayer: SurfaceInfo.None, Ovl1Rotation: 0,
|
|
Ovl2Layer: SurfaceInfo.None, Ovl2AlphaLayer: SurfaceInfo.None, Ovl2Rotation: 0,
|
|
RoadLayer: 0x08, Road0AlphaLayer: 0x09, Road0Rotation: 0,
|
|
Road1AlphaLayer: SurfaceInfo.None, Road1Rotation: 0);
|
|
|
|
var (_, _, d2, _) = TerrainBlending.FillCellData(s, CellSplitDirection.SWtoNE);
|
|
|
|
// Road0 present, Road1 absent → texRd1 and alphaRd1 both 255.
|
|
Assert.Equal(0x08u, d2 & 0xFFu);
|
|
Assert.Equal(0x09u, (d2 >> 8) & 0xFFu);
|
|
Assert.Equal(0xFFu, (d2 >> 16) & 0xFFu);
|
|
Assert.Equal(0xFFu, (d2 >> 24) & 0xFFu);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
private static TerrainBlendingContext MakeContext(
|
|
IReadOnlyDictionary<uint, byte>? terrainLayers = null,
|
|
byte roadLayer = SurfaceInfo.None,
|
|
IReadOnlyList<byte>? cornerAlphaLayers = null,
|
|
IReadOnlyList<byte>? sideAlphaLayers = null,
|
|
IReadOnlyList<byte>? roadAlphaLayers = null,
|
|
IReadOnlyList<uint>? cornerTCodes = null,
|
|
IReadOnlyList<uint>? sideTCodes = null,
|
|
IReadOnlyList<uint>? roadRCodes = null)
|
|
{
|
|
// Defaults mirror the actual Holtburg region shape: 4 corners, 1 side,
|
|
// 3 roads. TCodes are synthetic placeholders for the single-corner
|
|
// patterns the FindTerrainAlpha rotation loop will match.
|
|
return new TerrainBlendingContext(
|
|
TerrainTypeToLayer: terrainLayers ?? new Dictionary<uint, byte>(),
|
|
RoadLayer: roadLayer,
|
|
CornerAlphaLayers: cornerAlphaLayers ?? new byte[] { 0, 1, 2, 3 },
|
|
SideAlphaLayers: sideAlphaLayers ?? new byte[] { 4 },
|
|
RoadAlphaLayers: roadAlphaLayers ?? new byte[] { 5, 6, 7 },
|
|
CornerAlphaTCodes: cornerTCodes ?? new uint[] { 1, 2, 4, 8 },
|
|
SideAlphaTCodes: sideTCodes ?? new uint[] { 3 },
|
|
RoadAlphaRCodes: roadRCodes ?? new uint[] { 1, 2, 4 });
|
|
}
|
|
}
|