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>
This commit is contained in:
Erik 2026-04-11 13:53:32 +02:00
parent a8459eecbb
commit a6cd56663f
3 changed files with 680 additions and 3 deletions

View file

@ -0,0 +1,62 @@
namespace AcDream.Core.Terrain;
/// <summary>
/// Per-cell terrain blending recipe derived from a palette code. Holds atlas
/// layer indices (terrain atlas + alpha atlas) and rotation values for a base
/// texture plus up to three terrain overlays and two road overlays. Layer
/// indices use the sentinel <c>255</c> for "not present" — the fragment shader
/// skips any layer with index 255.
///
/// Ported from WorldBuilder's TMI structure with the field set adapted to
/// acdream's atlas layout: where WorldBuilder stores <c>TerrainTex</c>
/// pointers, we store resolved byte atlas layers so the shader never has to
/// resolve anything at render time.
/// </summary>
public readonly record struct SurfaceInfo(
byte BaseLayer,
byte Ovl0Layer, byte Ovl0AlphaLayer, byte Ovl0Rotation,
byte Ovl1Layer, byte Ovl1AlphaLayer, byte Ovl1Rotation,
byte Ovl2Layer, byte Ovl2AlphaLayer, byte Ovl2Rotation,
byte RoadLayer, byte Road0AlphaLayer, byte Road0Rotation,
byte Road1AlphaLayer, byte Road1Rotation)
{
public const byte None = 255;
/// <summary>An empty recipe where only the base is present and all overlays are None.</summary>
public static SurfaceInfo BaseOnly(byte baseLayer) => new(
baseLayer,
None, None, 0, None, None, 0, None, None, 0,
None, None, 0, None, 0);
}
/// <summary>
/// Read-only inputs to <see cref="TerrainBlending.BuildSurface"/> sourced from
/// the Region dat / TerrainAtlas at load time. Lets BuildSurface stay pure
/// (no dat or GL dependencies) so it can be unit-tested with synthetic data.
///
/// <para>
/// Fields:
/// <list type="bullet">
/// <item><c>TerrainTypeToLayer</c>: map from (uint)TerrainTextureType → terrain-atlas layer.</item>
/// <item><c>RoadLayer</c>: atlas layer for TerrainTextureType.RoadType, or 255 if missing.</item>
/// <item><c>CornerAlphaLayers</c>: alpha-atlas layer indices for TexMerge.CornerTerrainMaps, in original order.</item>
/// <item><c>SideAlphaLayers</c>: alpha-atlas layer indices for TexMerge.SideTerrainMaps.</item>
/// <item><c>RoadAlphaLayers</c>: alpha-atlas layer indices for TexMerge.RoadMaps.</item>
/// <item><c>CornerAlphaTCodes</c>: parallel to CornerAlphaLayers — each alpha map's TCode.</item>
/// <item><c>SideAlphaTCodes</c>: parallel to SideAlphaLayers.</item>
/// <item><c>RoadAlphaRCodes</c>: parallel to RoadAlphaLayers — each road map's RCode.</item>
/// </list>
/// The *Codes arrays let <see cref="TerrainBlending.FindTerrainAlpha"/> pick
/// which alpha map to use for a given terrain/road code via the rotation loop
/// lifted from WorldBuilder.
/// </para>
/// </summary>
public sealed record TerrainBlendingContext(
IReadOnlyDictionary<uint, byte> TerrainTypeToLayer,
byte RoadLayer,
IReadOnlyList<byte> CornerAlphaLayers,
IReadOnlyList<byte> SideAlphaLayers,
IReadOnlyList<byte> RoadAlphaLayers,
IReadOnlyList<uint> CornerAlphaTCodes,
IReadOnlyList<uint> SideAlphaTCodes,
IReadOnlyList<uint> RoadAlphaRCodes);

View file

@ -2,13 +2,16 @@ 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/...
/// per-corner terrain/road data into the palette codes, surface blend recipes,
/// and per-cell vertex data that drive per-cell texture blending. All
/// algorithms are ports of WorldBuilder (Chorizite) — see
/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs.
/// Pure CPU math, no GL, no dats — everything here is unit-testable in isolation.
/// </summary>
public static class TerrainBlending
{
// --- Phase 3c.1 algorithms ---
/// <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
@ -57,4 +60,397 @@ public static class TerrainBlending
? CellSplitDirection.SEtoNW
: CellSplitDirection.SWtoNE;
}
// --- Phase 3c.3 algorithms (surface recipe + vertex data packing) ---
/// <summary>
/// Extracts the 4 corner terrain types from a palette code, in the order
/// [t1, t2, t3, t4]. Inverse of <see cref="GetPalCode"/>'s terrain packing.
/// </summary>
public static uint[] ExtractTerrainCodes(uint palCode) => new uint[]
{
(palCode >> 15) & 0x1Fu,
(palCode >> 10) & 0x1Fu,
(palCode >> 5) & 0x1Fu,
palCode & 0x1Fu,
};
/// <summary>
/// Pseudo-random index in [0, count) derived from the palette code. Same
/// code always returns the same index, so neighbor cells that share a
/// palette code pick the same alpha variant. Ported from
/// WorldBuilder.GeneratePseudoRandomIndex.
/// </summary>
public static int PseudoRandomIndex(uint palCode, int count)
{
if (count <= 0) return 0;
// The computation uses int overflow and double multiplication
// exactly as in WorldBuilder — do NOT "clean up" the casts.
int pseudoRandom = (int)Math.Floor((1379576222 * palCode - 1372186442) * 2.3283064e-10 * count);
return pseudoRandom >= count ? 0 : pseudoRandom;
}
/// <summary>
/// Rotates a terrain-code bit pattern one step (1→2→4→8→1). Used by
/// <see cref="FindTerrainAlpha"/> and <see cref="FindRoadAlpha"/> to walk
/// the 4 rotations of an alpha map looking for one that matches the
/// target terrain/road code pattern.
/// </summary>
public static uint RotateTerrainCode(uint code)
{
code *= 2;
return code >= 16 ? code - 15 : code;
}
/// <summary>
/// Decodes the road-bits region of a palette code into up to two road
/// "codes" (corner patterns) and an <c>allRoad</c> flag. Ported verbatim
/// from WorldBuilder.GetRoadCodes. The magic constants describe which of
/// the 4 corners have roads (mask bit 0 = corner 1, bit 1 = corner 2,
/// etc.) and which canonical patterns the result maps to.
/// </summary>
public static (uint road0, uint road1, bool allRoad) GetRoadCodes(uint palCode)
{
uint mask = 0;
if ((palCode & 0x0C000000u) != 0) mask |= 1; // r1 in bits 26-27
if ((palCode & 0x03000000u) != 0) mask |= 2; // r2 in bits 24-25
if ((palCode & 0x00C00000u) != 0) mask |= 4; // r3 in bits 22-23
if ((palCode & 0x00300000u) != 0) mask |= 8; // r4 in bits 20-21
if (mask == 0xF)
return (0, 0, allRoad: true);
return mask switch
{
0x0 => (0, 0, false),
0xE => (6, 12, false),
0xD => (9, 12, false),
0xB => (9, 3, false),
0x7 => (3, 6, false),
_ => (mask, 0, false),
};
}
/// <summary>
/// Finds an alpha-map layer and rotation for a single terrain overlay.
/// <paramref name="terrainCode"/> is a 4-bit pattern indicating which
/// corners the overlay applies to (1,2,4,8 = single-corner patterns →
/// use corner maps; other patterns → use side maps). Returns
/// <see cref="SurfaceInfo.None"/> and rotation 0 if no alpha map
/// matches under any rotation, which is a valid "don't draw this
/// overlay" state.
/// Ported from WorldBuilder.FindTerrainAlpha.
/// </summary>
public static (byte alphaLayer, byte rotation) FindTerrainAlpha(
uint palCode, uint terrainCode, TerrainBlendingContext ctx)
{
bool isCornerTerrain = terrainCode is 1 or 2 or 4 or 8;
var maps = isCornerTerrain ? ctx.CornerAlphaLayers : ctx.SideAlphaLayers;
var tCodes = isCornerTerrain ? ctx.CornerAlphaTCodes : ctx.SideAlphaTCodes;
if (maps.Count == 0 || tCodes.Count == 0)
return (SurfaceInfo.None, 0);
int randomIndex = PseudoRandomIndex(palCode, maps.Count);
uint currentCode = tCodes[randomIndex];
int rotationCount = 0;
while (currentCode != terrainCode && rotationCount < 4)
{
currentCode = RotateTerrainCode(currentCode);
rotationCount++;
}
if (rotationCount >= 4)
return (SurfaceInfo.None, 0);
return (maps[randomIndex], (byte)rotationCount);
}
/// <summary>
/// Finds an alpha-map layer and rotation for a road overlay. Unlike
/// terrain alphas, roads iterate through all maps (starting at a random
/// offset) looking for one whose RCode matches the requested pattern
/// under some rotation.
/// Returns <see cref="SurfaceInfo.None"/> if no match exists.
/// Ported from WorldBuilder.FindRoadAlpha.
/// </summary>
public static (byte alphaLayer, byte rotation) FindRoadAlpha(
uint palCode, uint roadCode, TerrainBlendingContext ctx)
{
var maps = ctx.RoadAlphaLayers;
var rCodes = ctx.RoadAlphaRCodes;
if (maps.Count == 0 || rCodes.Count == 0)
return (SurfaceInfo.None, 0);
int randomIndex = PseudoRandomIndex(palCode, maps.Count);
for (int i = 0; i < maps.Count; i++)
{
int index = (i + randomIndex) % maps.Count;
uint currentCode = rCodes[index];
for (int rotationCount = 0; rotationCount < 4; rotationCount++)
{
if (currentCode == roadCode)
return (maps[index], (byte)rotationCount);
currentCode = RotateTerrainCode(currentCode);
}
}
return (SurfaceInfo.None, 0);
}
/// <summary>
/// Turns a palette code into a full <see cref="SurfaceInfo"/> blend
/// recipe. Handles three cases internally:
/// <list type="bullet">
/// <item>All-road cell → base = road texture, no overlays</item>
/// <item>All corners same terrain → base only, no overlays</item>
/// <item>2-4 distinct terrain types → base + up to 3 overlays per
/// WorldBuilder's duplicate-handling logic</item>
/// </list>
/// Road overlays (up to 2) are added on top if any corner has a road bit.
/// Ported from WorldBuilder.BuildTexture + ProcessTerrainOverlays +
/// ProcessRoadOverlays + GetTerrainTextures + BuildTerrainCodesWithDuplicates.
/// </summary>
public static SurfaceInfo BuildSurface(uint palCode, TerrainBlendingContext ctx)
{
var terrainCorners = ExtractTerrainCodes(palCode); // t1..t4
var (road0, road1, allRoad) = GetRoadCodes(palCode);
// All-road cell: base IS the road texture, no overlays at all.
if (allRoad)
{
return SurfaceInfo.BaseOnly(ctx.RoadLayer);
}
// Resolve base + up to 3 overlay terrain layers and the per-overlay
// "which corners" code. For 4 distinct corners this produces layers
// = [t1,t2,t3,t4] and codes = [2,4,8] (so overlays 0,1,2 cover corners
// 1,2,3 respectively while the base covers corner 0). For cells with
// duplicates the layout is rebuilt via BuildTerrainCodesWithDuplicates.
byte[] terrainLayers = new byte[4];
uint[] overlayCodes = new uint[3];
BuildOverlayLayers(terrainCorners, ctx, terrainLayers, overlayCodes);
// Base is always terrainLayers[0]. Overlays 0..2 correspond to
// terrainLayers[1..3] when present.
byte baseLayer = terrainLayers[0];
// Find alpha layer + rotation for each of up to 3 overlays.
// A zero terrain code means "no more overlays" (short-circuit loop).
byte ovl0Layer = SurfaceInfo.None, ovl0Alpha = SurfaceInfo.None, ovl0Rot = 0;
byte ovl1Layer = SurfaceInfo.None, ovl1Alpha = SurfaceInfo.None, ovl1Rot = 0;
byte ovl2Layer = SurfaceInfo.None, ovl2Alpha = SurfaceInfo.None, ovl2Rot = 0;
if (overlayCodes[0] != 0)
{
var (a, r) = FindTerrainAlpha(palCode, overlayCodes[0], ctx);
if (a != SurfaceInfo.None)
{
ovl0Layer = terrainLayers[1]; ovl0Alpha = a; ovl0Rot = r;
}
}
if (overlayCodes[1] != 0)
{
var (a, r) = FindTerrainAlpha(palCode, overlayCodes[1], ctx);
if (a != SurfaceInfo.None)
{
ovl1Layer = terrainLayers[2]; ovl1Alpha = a; ovl1Rot = r;
}
}
if (overlayCodes[2] != 0)
{
var (a, r) = FindTerrainAlpha(palCode, overlayCodes[2], ctx);
if (a != SurfaceInfo.None)
{
ovl2Layer = terrainLayers[3]; ovl2Alpha = a; ovl2Rot = r;
}
}
// Road overlays: share the road terrain layer, differ in alpha and rotation.
byte roadLayer = SurfaceInfo.None;
byte road0Alpha = SurfaceInfo.None, road0Rot = 0;
byte road1Alpha = SurfaceInfo.None, road1Rot = 0;
if (ctx.RoadLayer != SurfaceInfo.None && road0 != 0)
{
var (a0, r0) = FindRoadAlpha(palCode, road0, ctx);
if (a0 != SurfaceInfo.None)
{
roadLayer = ctx.RoadLayer;
road0Alpha = a0;
road0Rot = r0;
if (road1 != 0)
{
var (a1, r1) = FindRoadAlpha(palCode, road1, ctx);
if (a1 != SurfaceInfo.None)
{
road1Alpha = a1;
road1Rot = r1;
}
}
}
}
return new SurfaceInfo(
baseLayer,
ovl0Layer, ovl0Alpha, ovl0Rot,
ovl1Layer, ovl1Alpha, ovl1Rot,
ovl2Layer, ovl2Alpha, ovl2Rot,
roadLayer, road0Alpha, road0Rot,
road1Alpha, road1Rot);
}
/// <summary>
/// Fills terrainLayers[0..3] and overlayCodes[0..2] based on the 4 corner
/// terrain codes. If all 4 corners are distinct we use the straightforward
/// mapping; otherwise we delegate to the duplicate-handling path. Combined
/// port of WorldBuilder.GetTerrainTextures +
/// BuildTerrainCodesWithDuplicates.
/// </summary>
private static void BuildOverlayLayers(
uint[] terrainCorners,
TerrainBlendingContext ctx,
byte[] terrainLayers,
uint[] overlayCodes)
{
// Find first duplicate pair — if any exists, use the duplicate path.
for (int i = 0; i < 4; i++)
{
for (int j = i + 1; j < 4; j++)
{
if (terrainCorners[i] == terrainCorners[j])
{
BuildWithDuplicates(terrainCorners, ctx, terrainLayers, overlayCodes, i);
return;
}
}
}
// All 4 distinct: layers = [t1,t2,t3,t4], overlay codes = [2, 4, 8]
for (int i = 0; i < 4; i++)
terrainLayers[i] = LookupTerrainLayer(ctx, terrainCorners[i]);
overlayCodes[0] = 2;
overlayCodes[1] = 4;
overlayCodes[2] = 8;
}
private static void BuildWithDuplicates(
uint[] terrainCorners,
TerrainBlendingContext ctx,
byte[] terrainLayers,
uint[] overlayCodes,
int duplicateIndex)
{
uint primaryTerrain = terrainCorners[duplicateIndex];
uint secondaryTerrain = 0;
terrainLayers[0] = LookupTerrainLayer(ctx, primaryTerrain);
terrainLayers[1] = SurfaceInfo.None;
terrainLayers[2] = SurfaceInfo.None;
terrainLayers[3] = SurfaceInfo.None;
overlayCodes[0] = 0;
overlayCodes[1] = 0;
overlayCodes[2] = 0;
bool foundSecondary = false;
for (int k = 0; k < 4; k++)
{
if (primaryTerrain == terrainCorners[k]) continue;
if (!foundSecondary)
{
overlayCodes[0] = (uint)(1 << k);
secondaryTerrain = terrainCorners[k];
terrainLayers[1] = LookupTerrainLayer(ctx, secondaryTerrain);
foundSecondary = true;
}
else
{
// Another corner differs from primary.
if (secondaryTerrain == terrainCorners[k]
&& overlayCodes[0] == (1u << (k - 1)))
{
// Adjacent same-secondary corners merge into a 2-bit pattern.
overlayCodes[0] += (uint)(1 << k);
}
else
{
terrainLayers[2] = LookupTerrainLayer(ctx, terrainCorners[k]);
overlayCodes[1] = (uint)(1 << k);
}
break;
}
}
}
private static byte LookupTerrainLayer(TerrainBlendingContext ctx, uint terrainCode)
{
return ctx.TerrainTypeToLayer.TryGetValue(terrainCode, out var layer)
? layer
: SurfaceInfo.None;
}
/// <summary>
/// Packs a <see cref="SurfaceInfo"/> and split direction into the 4 uint32s
/// that appear as vertex attributes (<c>Data0..Data3</c>) on every vertex
/// in a cell. Bit layout matches WorldBuilder's Landscape.vert so the
/// shader port can be verbatim.
///
/// <para>
/// Byte layout (little-endian, consumed by the shader as <c>uvec4</c>
/// byte-attribute reads):
/// </para>
/// <code>
/// data0: [ baseTex, baseAlpha=255, ovl0Tex, ovl0Alpha ]
/// data1: [ ovl1Tex, ovl1Alpha, ovl2Tex, ovl2Alpha ]
/// data2: [ road0Tex, road0Alpha, road1Tex, road1Alpha ]
/// data3: bit 0-1 rotBase (unused, 0)
/// bit 2-3 rotOvl0
/// bit 4-5 rotOvl1
/// bit 6-7 rotOvl2
/// bit 8-9 rotRd0
/// bit 10-11 rotRd1
/// bit 12 splitDirection (0=SWtoNE, 1=SEtoNW)
/// </code>
/// </summary>
public static (uint d0, uint d1, uint d2, uint d3) FillCellData(
SurfaceInfo s, CellSplitDirection split)
{
uint texBase = s.BaseLayer;
uint alphaBase = SurfaceInfo.None; // unused, always 255
uint texOvl0 = s.Ovl0Layer;
uint alphaOvl0 = s.Ovl0AlphaLayer;
uint rotOvl0 = s.Ovl0Rotation;
uint texOvl1 = s.Ovl1Layer;
uint alphaOvl1 = s.Ovl1AlphaLayer;
uint rotOvl1 = s.Ovl1Rotation;
uint texOvl2 = s.Ovl2Layer;
uint alphaOvl2 = s.Ovl2AlphaLayer;
uint rotOvl2 = s.Ovl2Rotation;
// Road0 and Road1 share s.RoadLayer (same road texture, different alpha masks).
uint texRd0 = s.RoadLayer;
uint alphaRd0 = s.Road0AlphaLayer;
uint rotRd0 = s.Road0Rotation;
uint texRd1 = s.Road1AlphaLayer == SurfaceInfo.None ? SurfaceInfo.None : s.RoadLayer;
uint alphaRd1 = s.Road1AlphaLayer;
uint rotRd1 = s.Road1Rotation;
uint d0 = (texBase | (alphaBase << 8)) | ((texOvl0 | (alphaOvl0 << 8)) << 16);
uint d1 = (texOvl1 | (alphaOvl1 << 8)) | ((texOvl2 | (alphaOvl2 << 8)) << 16);
uint d2 = (texRd0 | (alphaRd0 << 8)) | ((texRd1 | (alphaRd1 << 8)) << 16);
uint d3 = 0u // rotBase in bits 0-1 (unused)
| (rotOvl0 << 2)
| (rotOvl1 << 4)
| (rotOvl2 << 6)
| (rotRd0 << 8)
| (rotRd1 << 10)
| ((((uint)split) & 1u) << 12);
return (d0, d1, d2, d3);
}
}

View file

@ -110,4 +110,223 @@ public class TerrainBlendingTests
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 });
}
}