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