using System.Numerics; using AcDream.Core.Terrain; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.Core.Tests.Terrain; public class LandblockMeshTests { /// /// Synthetic height table with a * 2.0f scale (mirrors Phase 1's ramp so /// existing test intuition carries through the Phase 3c rewrite). /// private static readonly float[] IdentityHeightTable = Enumerable.Range(0, 256).Select(i => i * 2f).ToArray(); private static TerrainBlendingContext MakeContext() => new( TerrainTypeToLayer: new Dictionary { [0u] = 0 }, RoadLayer: SurfaceInfo.None, CornerAlphaLayers: Array.Empty(), SideAlphaLayers: Array.Empty(), RoadAlphaLayers: Array.Empty(), CornerAlphaTCodes: Array.Empty(), SideAlphaTCodes: Array.Empty(), RoadAlphaRCodes: Array.Empty()); private static LandBlock BuildFlatLandBlock(byte heightIndex = 0) { var block = new LandBlock { HasObjects = false, Terrain = new TerrainInfo[81], Height = new byte[81], }; for (int i = 0; i < 81; i++) { block.Terrain[i] = (ushort)0; block.Height[i] = heightIndex; } return block; } [Fact] public void Build_FlatBlock_Produces384VerticesAnd128Triangles() { var block = BuildFlatLandBlock(); var cache = new Dictionary(); var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); // 64 cells × 6 vertices per cell = 384 Assert.Equal(384, mesh.Vertices.Length); // Each cell emits 2 triangles = 6 indices, 64 cells → 384 indices (= 128 triangles) Assert.Equal(128 * 3, mesh.Indices.Length); } [Fact] public void Build_Vertices_CoverExactly192x192WorldUnits() { var block = BuildFlatLandBlock(); var cache = new Dictionary(); var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); var minX = mesh.Vertices.Min(v => v.Position.X); var maxX = mesh.Vertices.Max(v => v.Position.X); var minY = mesh.Vertices.Min(v => v.Position.Y); var maxY = mesh.Vertices.Max(v => v.Position.Y); Assert.Equal(0.0f, minX); Assert.Equal(192.0f, maxX); Assert.Equal(0.0f, minY); Assert.Equal(192.0f, maxY); } [Fact] public void Build_FlatBlock_AllVerticesSameZ() { var block = BuildFlatLandBlock(heightIndex: 10); var cache = new Dictionary(); var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray(); Assert.Single(zs); Assert.Equal(20.0f, zs[0]); // heightIndex 10 × IdentityHeightTable[10] = 20 } [Fact] public void Build_FlatBlock_NormalsPointStraightUp() { var block = BuildFlatLandBlock(); var cache = new Dictionary(); var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); foreach (var v in mesh.Vertices) { Assert.Equal(new Vector3(0, 0, 1), v.Normal); } } [Fact] public void Build_AllVerticesOfACellShareIdenticalData() { var block = BuildFlatLandBlock(); var cache = new Dictionary(); var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); // Vertices are emitted in strides of 6 per cell. Within each stride, // Data0..3 must be identical — the vertex shader relies on that when // it propagates the cell's blend recipe to all 3 fragment-shader outputs. for (int cellIdx = 0; cellIdx < 64; cellIdx++) { int baseIdx = cellIdx * 6; var d0 = mesh.Vertices[baseIdx].Data0; var d1 = mesh.Vertices[baseIdx].Data1; var d2 = mesh.Vertices[baseIdx].Data2; var d3 = mesh.Vertices[baseIdx].Data3; for (int i = 1; i < 6; i++) { Assert.Equal(d0, mesh.Vertices[baseIdx + i].Data0); Assert.Equal(d1, mesh.Vertices[baseIdx + i].Data1); Assert.Equal(d2, mesh.Vertices[baseIdx + i].Data2); Assert.Equal(d3, mesh.Vertices[baseIdx + i].Data3); } } } [Fact] public void Build_SurfaceCacheIsReusedAcrossIdenticalCells() { var block = BuildFlatLandBlock(); // every cell has identical all-zero corners var cache = new Dictionary(); LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); // A uniform flat landblock produces exactly ONE palette code (all // corners are type 0, no roads) → BuildSurface called once, cache // contains a single entry even though 64 cells were processed. Assert.Single(cache); } [Fact] public void Build_CellsWithDistinctTerrainTypes_ProducesDistinctPaletteCodes() { // Put a dirt cell (type 4) at the center of an otherwise grass landblock. // Grass cells all share one palCode; the "dirt + grass border" cells // around the center introduce additional palette codes. var block = BuildFlatLandBlock(); // Type is at bits 2-6, so type=4 → ushort = (4 << 2) = 0x10. block.Terrain[4 * 9 + 4] = (ushort)(4 << 2); var ctx = new TerrainBlendingContext( TerrainTypeToLayer: new Dictionary { [0u] = 0, [4u] = 1 }, RoadLayer: SurfaceInfo.None, CornerAlphaLayers: new byte[] { 0, 1, 2, 3 }, SideAlphaLayers: Array.Empty(), RoadAlphaLayers: Array.Empty(), CornerAlphaTCodes: new uint[] { 1, 2, 4, 8 }, SideAlphaTCodes: Array.Empty(), RoadAlphaRCodes: Array.Empty()); var cache = new Dictionary(); LandblockMesh.Build(block, 0, 0, IdentityHeightTable, ctx, cache); // Should have more than one palette code now — uniform-grass cells // plus at least one boundary cell with a non-zero corner type. Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}"); } [Fact] public void Build_AllTriangles_WindCounterClockwiseInWorldXY() { // #108-residual winding pin: TerrainModernRenderer enables backface // culling with FrontFace(Ccw) — the GL port of retail's single-sided // terrain (ACRender::landPolysDraw 0x006b7040 draws a land triangle // only when the eye is on the POSITIVE side of its plane). That cull // is only correct if EVERY emitted triangle winds the same way: // counter-clockwise in world XY viewed from above (+Z toward the // viewer), i.e. cross2D(v1-v0, v2-v0) > 0. Varied heights + several // landblock coords exercise both FSplitNESW split directions across // the 64 cells. A future emission-order change that flips any // triangle would silently punch terrain holes under culling. var block = BuildFlatLandBlock(); for (int i = 0; i < 81; i++) block.Height[i] = (byte)((i * 37) % 64); // varied, deterministic slopes foreach (var (lbx, lby) in new[] { (0u, 0u), (0xA9u, 0xB4u), (3u, 7u) }) { var cache = new Dictionary(); var mesh = LandblockMesh.Build(block, lbx, lby, IdentityHeightTable, MakeContext(), cache); for (int t = 0; t < mesh.Indices.Length; t += 3) { var p0 = mesh.Vertices[mesh.Indices[t + 0]].Position; var p1 = mesh.Vertices[mesh.Indices[t + 1]].Position; var p2 = mesh.Vertices[mesh.Indices[t + 2]].Position; float crossZ = (p1.X - p0.X) * (p2.Y - p0.Y) - (p1.Y - p0.Y) * (p2.X - p0.X); Assert.True(crossZ > 0f, $"lb=({lbx},{lby}) triangle {t / 3} winds CW in world XY (crossZ={crossZ}) — " + "backface culling in TerrainModernRenderer would cull its TOP side"); } } } [Fact] public void Build_HeightmapPackedAsXMajor_NotYMajor() { // Regression from the Phase 1 → 2a transpose bug. The underlying // heightmap is indexed x*9+y; testing this lives on even after the // per-cell refactor because the corner lookup in the cell loop still // reads block.Height[cx*9+cy] for the BL corner. var block = BuildFlatLandBlock(); block.Height[2 * 9 + 0] = 5; // x=2, y=0 → world (48, 0), Z should be 10 var cache = new Dictionary(); var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); // Search the vertex buffer for a vertex at world position (48, 0). var atX48Y0 = mesh.Vertices.FirstOrDefault(v => Math.Abs(v.Position.X - 48f) < 0.01f && Math.Abs(v.Position.Y) < 0.01f); var atX0Y48 = mesh.Vertices.FirstOrDefault(v => Math.Abs(v.Position.X) < 0.01f && Math.Abs(v.Position.Y - 48f) < 0.01f); Assert.Equal(10.0f, atX48Y0.Position.Z); Assert.Equal(0.0f, atX0Y48.Position.Z); } [Fact] public void Build_NormalsMatchRetailIncidentFaceAverages_NotCentralDifferences() { // A deliberately non-planar surface makes retail's split-aware // incident-plane average observably different from the former // central-difference approximation. var block = BuildFlatLandBlock(); for (int x = 0; x < LandblockMesh.HeightmapSide; x++) for (int y = 0; y < LandblockMesh.HeightmapSide; y++) block.Height[x * LandblockMesh.HeightmapSide + y] = (byte)((x * x * 3 + y * y * 5 + x * y * 11 + x * 7 + y * 13) % 96); const uint landblockX = 0xA9; const uint landblockY = 0xB4; var mesh = LandblockMesh.Build( block, landblockX, landblockY, IdentityHeightTable, MakeContext(), new Dictionary()); // Independent geometry oracle: derive each polygon plane from the // actual emitted positions/indices, accumulate it at the shared // position, and normalize only after every incident polygon is seen. var incidentNormalSums = new Dictionary(); for (int i = 0; i < mesh.Indices.Length; i += 3) { Vector3 p0 = mesh.Vertices[mesh.Indices[i]].Position; Vector3 p1 = mesh.Vertices[mesh.Indices[i + 1]].Position; Vector3 p2 = mesh.Vertices[mesh.Indices[i + 2]].Position; Vector3 planeNormal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); AddNormal(incidentNormalSums, p0, planeNormal); AddNormal(incidentNormalSums, p1, planeNormal); AddNormal(incidentNormalSums, p2, planeNormal); } foreach (TerrainVertex vertex in mesh.Vertices) { Vector3 expected = Vector3.Normalize(incidentNormalSums[vertex.Position]); AssertVectorNear(expected, vertex.Normal, 1e-6f); Assert.InRange(vertex.Normal.Length(), 1f - 1e-6f, 1f + 1e-6f); } bool differsFromCentralDifferences = false; for (int x = 0; x < LandblockMesh.HeightmapSide; x++) { for (int y = 0; y < LandblockMesh.HeightmapSide; y++) { int xL = Math.Max(x - 1, 0); int xR = Math.Min(x + 1, LandblockMesh.HeightmapSide - 1); int yD = Math.Max(y - 1, 0); int yU = Math.Min(y + 1, LandblockMesh.HeightmapSide - 1); float dx = (HeightAt(block, xR, y) - HeightAt(block, xL, y)) / ((xR - xL) * LandblockMesh.CellSize); float dy = (HeightAt(block, x, yU) - HeightAt(block, x, yD)) / ((yU - yD) * LandblockMesh.CellSize); Vector3 oldApproximation = Vector3.Normalize(new Vector3(-dx, -dy, 1f)); Vector3 position = new( x * LandblockMesh.CellSize, y * LandblockMesh.CellSize, HeightAt(block, x, y)); Vector3 actual = mesh.Vertices.First(vertex => vertex.Position == position).Normal; differsFromCentralDifferences |= Vector3.Distance(oldApproximation, actual) > 1e-4f; } } Assert.True( differsFromCentralDifferences, "Synthetic terrain failed to distinguish retail incident-face averaging from central differences."); } [Theory] [InlineData(0u, 0u)] [InlineData(0xA9u, 0xB4u)] public void Build_RetailNormalChange_PreservesExactSplitAwarePositionsAndIndices( uint landblockX, uint landblockY) { var block = BuildFlatLandBlock(); for (int x = 0; x < LandblockMesh.HeightmapSide; x++) for (int y = 0; y < LandblockMesh.HeightmapSide; y++) block.Height[x * LandblockMesh.HeightmapSide + y] = (byte)((x * 17 + y * 29 + x * y * 3) % 80); var mesh = LandblockMesh.Build( block, landblockX, landblockY, IdentityHeightTable, MakeContext(), new Dictionary()); Assert.Equal( Enumerable.Range(0, LandblockMesh.VerticesPerLandblock).Select(i => (uint)i), mesh.Indices); int vertexIndex = 0; for (int cy = 0; cy < LandblockMesh.CellsPerSide; cy++) { for (int cx = 0; cx < LandblockMesh.CellsPerSide; cx++) { Vector3 bl = PositionAt(block, cx, cy); Vector3 br = PositionAt(block, cx + 1, cy); Vector3 tr = PositionAt(block, cx + 1, cy + 1); Vector3 tl = PositionAt(block, cx, cy + 1); Vector3[] expected = TerrainBlending.CalculateSplitDirection( landblockX, (uint)cx, landblockY, (uint)cy) == CellSplitDirection.SWtoNE ? [bl, br, tr, bl, tr, tl] : [bl, br, tl, br, tr, tl]; foreach (Vector3 position in expected) Assert.Equal(position, mesh.Vertices[vertexIndex++].Position); } } Assert.Equal(LandblockMesh.VerticesPerLandblock, vertexIndex); } private static float HeightAt(LandBlock block, int x, int y) => IdentityHeightTable[block.Height[x * LandblockMesh.HeightmapSide + y]]; private static Vector3 PositionAt(LandBlock block, int x, int y) => new( x * LandblockMesh.CellSize, y * LandblockMesh.CellSize, HeightAt(block, x, y)); private static void AddNormal( IDictionary sums, Vector3 position, Vector3 normal) { sums.TryGetValue(position, out Vector3 sum); sums[position] = sum + normal; } private static void AssertVectorNear(Vector3 expected, Vector3 actual, float epsilon) { Assert.InRange(actual.X, expected.X - epsilon, expected.X + epsilon); Assert.InRange(actual.Y, expected.Y - epsilon, expected.Y + epsilon); Assert.InRange(actual.Z, expected.Z - epsilon, expected.Z + epsilon); } }