From e54d5ca2cf0a1fc3ca08af74ddaa5832304ec671 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 08:49:15 +0200 Subject: [PATCH] phase(N.5b) Task 7: TerrainModernConformanceTests Z-conformance sentinel for issue #51's bug class. Sweeps 10 representative landblocks x 100 sample points (uniform random in local 0..192 with fixed seed 42). For each point: compute meshTriZ via barycentric interpolation in the matching triangle of the LandblockMesh.Build output; compute physicsZ via TerrainSurface.SampleZFromHeightmap; assert |delta| < 0.001m. Catches any silent formula or vertex-layout drift between the visual and physics paths. Skips gracefully if ACDREAM_DAT_DIR isn't set (CI without dat data). Local run with dat data: 10/10 landblocks loaded, 1000 samples, max |delta| = 0.0305 mm (worst case: Direlands 0xC040). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Terrain/TerrainModernConformanceTests.cs | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs diff --git a/tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs b/tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs new file mode 100644 index 0000000..3bc403b --- /dev/null +++ b/tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs @@ -0,0 +1,184 @@ +using System.Collections.Generic; +using System.IO; +using AcDream.Core.Physics; +using AcDream.Core.Terrain; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Terrain; + +/// +/// Phase N.5b Z-conformance sentinel: proves that the visual terrain mesh +/// produced by agrees with the physics-side +/// at arbitrary (X, Y) +/// within 1 mm. This is the exact bug class issue #51 names — if a future +/// refactor silently changes formula or vertex layout in either path, +/// this test fires before the player floats above (or sinks below) the +/// visible ground. +/// +/// The test is dat-data-dependent. If ACDREAM_DAT_DIR isn't set or +/// the directory doesn't exist, the test logs a SKIP and passes — keeps CI +/// (no dat data) green while still firing locally on every developer run. +/// +public class TerrainModernConformanceTests +{ + private readonly ITestOutputHelper _out; + + public TerrainModernConformanceTests(ITestOutputHelper output) => _out = output; + + private static readonly (string name, uint lbX, uint lbY)[] RepresentativeLandblocks = + { + ("Holtburg flat 0xA9B0", 0xA9, 0xB0), + ("Holtburg sloped 0xA9B1", 0xA9, 0xB1), + ("Foundry-area 0x8080", 0x80, 0x80), + ("Cragstone 0xCB99", 0xCB, 0x99), + ("Direlands sample 0xC040", 0xC0, 0x40), + ("MapOrigin 0x0000", 0x00, 0x00), + ("Mid-map 0x7F7F", 0x7F, 0x7F), + ("MapCorner 0xFEFE", 0xFE, 0xFE), + ("Subway outdoor 0x0185", 0x01, 0x85), + ("North continent 0x4D96", 0x4D, 0x96), + }; + + [Fact] + public void VisualMeshZ_AgreesWith_PhysicsZ_WithinOneMillimeter() + { + var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) + { + _out.WriteLine($"SKIP: dat directory not found at {datDir}"); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var region = dats.Get(0x13000000u); + Assert.NotNull(region); + var heightTable = region.LandDefs.LandHeightTable; + Assert.NotNull(heightTable); + Assert.True(heightTable.Length >= 256, "heightTable must have at least 256 entries"); + + // Empty blending context — the conformance test only cares about + // vertex Z values, never the surface info / atlas layers. An empty + // dictionary + empty arrays are sufficient for BuildSurface to + // resolve every cell to a "base only" surface (the Z values come + // from the heightmap, not from the surface info). + var ctx = new TerrainBlendingContext( + TerrainTypeToLayer: new Dictionary(), + RoadLayer: SurfaceInfo.None, + CornerAlphaLayers: Array.Empty(), + SideAlphaLayers: Array.Empty(), + RoadAlphaLayers: Array.Empty(), + CornerAlphaTCodes: Array.Empty(), + SideAlphaTCodes: Array.Empty(), + RoadAlphaRCodes: Array.Empty()); + + long totalSamples = 0; + long totalLandblocksTested = 0; + double maxDelta = 0; + (string name, uint lbX, uint lbY, float lx, float ly, float meshZ, float physicsZ) worstCase = default; + + // Fixed seed for reproducible sample distribution. If a future change + // makes the test fire, the same (lx, ly) sequence reproduces the + // exact failing point on a follow-up run. + var rng = new Random(42); + + foreach (var (name, lbX, lbY) in RepresentativeLandblocks) + { + uint landblockId = (lbX << 24) | (lbY << 16) | 0xFFFFu; + var landblock = dats.Get(landblockId); + if (landblock is null) + { + _out.WriteLine($" skipped {name}: dat not found (probably water-only)"); + continue; + } + totalLandblocksTested++; + + var surfaceCache = new Dictionary(); + var meshData = LandblockMesh.Build(landblock, lbX, lbY, heightTable, ctx, surfaceCache); + + // Sample 100 (localX, localY) points uniformly in [0, 192). + // We avoid the exact upper bound (192) because that maps to + // cell index 8 which the physics path clamps; the pure mesh + // sampler doesn't have triangles past 192 anyway. + for (int s = 0; s < 100; s++) + { + float lx = (float)rng.NextDouble() * 191.999f; + float ly = (float)rng.NextDouble() * 191.999f; + + float meshZ = SampleMeshZ(meshData, lx, ly); + float physicsZ = TerrainSurface.SampleZFromHeightmap( + landblock.Height, heightTable, lbX, lbY, lx, ly); + + double delta = Math.Abs(meshZ - physicsZ); + if (delta > maxDelta) + { + maxDelta = delta; + worstCase = (name, lbX, lbY, lx, ly, meshZ, physicsZ); + } + totalSamples++; + Assert.True(delta < 0.001, + $"Mesh Z disagrees with physics Z at lb=0x{lbX:X2}{lbY:X2} ({name}) " + + $"local=({lx:F2},{ly:F2}): meshZ={meshZ:F4} physicsZ={physicsZ:F4} delta={delta:F4}m"); + } + } + + _out.WriteLine($"=== Phase N.5b conformance sweep ==="); + _out.WriteLine($"Landblocks tested: {totalLandblocksTested}/{RepresentativeLandblocks.Length}"); + _out.WriteLine($"Total samples: {totalSamples}"); + _out.WriteLine($"Max |delta|: {maxDelta * 1000:F4} mm (tolerance: 1.0 mm)"); + if (totalSamples > 0) + _out.WriteLine($"Worst case: {worstCase.name} local=({worstCase.lx:F2},{worstCase.ly:F2}) " + + $"meshZ={worstCase.meshZ:F4} physicsZ={worstCase.physicsZ:F4}"); + + Assert.True(totalLandblocksTested >= 5, + $"Expected at least 5 representative landblocks loadable; got {totalLandblocksTested}."); + } + + /// + /// Sample the mesh's triangle-interpolated Z at (localX, localY). Walks + /// the mesh's triangles (3 indices each), tests point-in-triangle in 2D, + /// and barycentric-interpolates Z from the matching triangle's three Zs. + /// + /// The mesh has 128 triangles per landblock (64 cells × 2). Every (lx, ly) + /// in [0, 192) lies in exactly one triangle (or on a shared edge — the + /// epsilon makes either side acceptable since they agree at the seam). + /// + private static float SampleMeshZ(LandblockMeshData mesh, float lx, float ly) + { + for (int triBase = 0; triBase < mesh.Indices.Length; triBase += 3) + { + var v0 = mesh.Vertices[mesh.Indices[triBase + 0]]; + var v1 = mesh.Vertices[mesh.Indices[triBase + 1]]; + var v2 = mesh.Vertices[mesh.Indices[triBase + 2]]; + + // Barycentric coords for (lx, ly) wrt triangle v0/v1/v2 in 2D. + float denom = (v1.Position.Y - v2.Position.Y) * (v0.Position.X - v2.Position.X) + + (v2.Position.X - v1.Position.X) * (v0.Position.Y - v2.Position.Y); + if (Math.Abs(denom) < 1e-9f) continue; + + float a = ((v1.Position.Y - v2.Position.Y) * (lx - v2.Position.X) + + (v2.Position.X - v1.Position.X) * (ly - v2.Position.Y)) / denom; + float b = ((v2.Position.Y - v0.Position.Y) * (lx - v2.Position.X) + + (v0.Position.X - v2.Position.X) * (ly - v2.Position.Y)) / denom; + float c = 1f - a - b; + + // Inside test with epsilon for boundary stability — points that + // land exactly on a shared edge between two triangles still + // resolve, picking whichever the loop hits first (Z agrees on + // the seam either way). + const float eps = 1e-4f; + if (a >= -eps && b >= -eps && c >= -eps) + return a * v0.Position.Z + b * v1.Position.Z + c * v2.Position.Z; + } + + // Should not happen for valid mesh + in-bounds (lx, ly). + throw new InvalidOperationException( + $"No triangle found containing local=({lx:F2},{ly:F2}); mesh has {mesh.Indices.Length / 3} triangles."); + } +}