acdream/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs
Erik a6cd56663f feat(core): terrain surface recipe + cell data packing (Phase 3c.3)
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>
2026-04-11 13:53:32 +02:00

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