diff --git a/src/AcDream.Core/Terrain/SurfaceInfo.cs b/src/AcDream.Core/Terrain/SurfaceInfo.cs
new file mode 100644
index 0000000..3df8013
--- /dev/null
+++ b/src/AcDream.Core/Terrain/SurfaceInfo.cs
@@ -0,0 +1,62 @@
+namespace AcDream.Core.Terrain;
+
+///
+/// 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 255 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 TerrainTex
+/// pointers, we store resolved byte atlas layers so the shader never has to
+/// resolve anything at render time.
+///
+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;
+
+ /// An empty recipe where only the base is present and all overlays are None.
+ public static SurfaceInfo BaseOnly(byte baseLayer) => new(
+ baseLayer,
+ None, None, 0, None, None, 0, None, None, 0,
+ None, None, 0, None, 0);
+}
+
+///
+/// Read-only inputs to 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.
+///
+///
+/// Fields:
+///
+/// - TerrainTypeToLayer: map from (uint)TerrainTextureType → terrain-atlas layer.
+/// - RoadLayer: atlas layer for TerrainTextureType.RoadType, or 255 if missing.
+/// - CornerAlphaLayers: alpha-atlas layer indices for TexMerge.CornerTerrainMaps, in original order.
+/// - SideAlphaLayers: alpha-atlas layer indices for TexMerge.SideTerrainMaps.
+/// - RoadAlphaLayers: alpha-atlas layer indices for TexMerge.RoadMaps.
+/// - CornerAlphaTCodes: parallel to CornerAlphaLayers — each alpha map's TCode.
+/// - SideAlphaTCodes: parallel to SideAlphaLayers.
+/// - RoadAlphaRCodes: parallel to RoadAlphaLayers — each road map's RCode.
+///
+/// The *Codes arrays let pick
+/// which alpha map to use for a given terrain/road code via the rotation loop
+/// lifted from WorldBuilder.
+///
+///
+public sealed record TerrainBlendingContext(
+ IReadOnlyDictionary TerrainTypeToLayer,
+ byte RoadLayer,
+ IReadOnlyList CornerAlphaLayers,
+ IReadOnlyList SideAlphaLayers,
+ IReadOnlyList RoadAlphaLayers,
+ IReadOnlyList CornerAlphaTCodes,
+ IReadOnlyList SideAlphaTCodes,
+ IReadOnlyList RoadAlphaRCodes);
diff --git a/src/AcDream.Core/Terrain/TerrainBlending.cs b/src/AcDream.Core/Terrain/TerrainBlending.cs
index 66e5583..4d3380e 100644
--- a/src/AcDream.Core/Terrain/TerrainBlending.cs
+++ b/src/AcDream.Core/Terrain/TerrainBlending.cs
@@ -2,13 +2,16 @@ 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/...
+/// 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.
///
public static class TerrainBlending
{
+ // --- Phase 3c.1 algorithms ---
+
///
/// 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) ---
+
+ ///
+ /// Extracts the 4 corner terrain types from a palette code, in the order
+ /// [t1, t2, t3, t4]. Inverse of 's terrain packing.
+ ///
+ public static uint[] ExtractTerrainCodes(uint palCode) => new uint[]
+ {
+ (palCode >> 15) & 0x1Fu,
+ (palCode >> 10) & 0x1Fu,
+ (palCode >> 5) & 0x1Fu,
+ palCode & 0x1Fu,
+ };
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Rotates a terrain-code bit pattern one step (1→2→4→8→1). Used by
+ /// and to walk
+ /// the 4 rotations of an alpha map looking for one that matches the
+ /// target terrain/road code pattern.
+ ///
+ public static uint RotateTerrainCode(uint code)
+ {
+ code *= 2;
+ return code >= 16 ? code - 15 : code;
+ }
+
+ ///
+ /// Decodes the road-bits region of a palette code into up to two road
+ /// "codes" (corner patterns) and an allRoad 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.
+ ///
+ 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),
+ };
+ }
+
+ ///
+ /// Finds an alpha-map layer and rotation for a single terrain overlay.
+ /// 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
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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 if no match exists.
+ /// Ported from WorldBuilder.FindRoadAlpha.
+ ///
+ 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);
+ }
+
+ ///
+ /// Turns a palette code into a full blend
+ /// recipe. Handles three cases internally:
+ ///
+ /// - All-road cell → base = road texture, no overlays
+ /// - All corners same terrain → base only, no overlays
+ /// - 2-4 distinct terrain types → base + up to 3 overlays per
+ /// WorldBuilder's duplicate-handling logic
+ ///
+ /// Road overlays (up to 2) are added on top if any corner has a road bit.
+ /// Ported from WorldBuilder.BuildTexture + ProcessTerrainOverlays +
+ /// ProcessRoadOverlays + GetTerrainTextures + BuildTerrainCodesWithDuplicates.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Packs a and split direction into the 4 uint32s
+ /// that appear as vertex attributes (Data0..Data3) on every vertex
+ /// in a cell. Bit layout matches WorldBuilder's Landscape.vert so the
+ /// shader port can be verbatim.
+ ///
+ ///
+ /// Byte layout (little-endian, consumed by the shader as uvec4
+ /// byte-attribute reads):
+ ///
+ ///
+ /// 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)
+ ///
+ ///
+ 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);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs b/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs
index 0963ce1..c87e50c 100644
--- a/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs
+++ b/tests/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs
@@ -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
+ {
+ [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 { [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
+ {
+ [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? terrainLayers = null,
+ byte roadLayer = SurfaceInfo.None,
+ IReadOnlyList? cornerAlphaLayers = null,
+ IReadOnlyList? sideAlphaLayers = null,
+ IReadOnlyList? roadAlphaLayers = null,
+ IReadOnlyList? cornerTCodes = null,
+ IReadOnlyList? sideTCodes = null,
+ IReadOnlyList? 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(),
+ 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 });
+ }
}