acdream/tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs
Erik 4ed79207a6 fix(N.5b T7): tighten conformance sample upper bound to 191.975f
Code review identified a latent false-positive flake risk: physics
path clamps fx = localX/24 to (CellsPerSide - 0.001f) = 7.999, which
corresponds to localX <= 191.976. With samples up to 191.999f,
physics computes Z at the clamped position while the mesh sampler
uses the actual position — a difference of up to 23 mm at the upper
edge, which on a steep slope would falsely trip the 1 mm sentinel.

Tighten upper bound to 191.975f (strictly below the clamp boundary)
so both oracles compute Z at the same (cellX, tx). Also restored the
"worst-case from SplitFormulaDivergenceTest" inline comment for
landblock 0x4D96 per code review suggestion #3.

Test still passes: 10/10 landblocks, 1000 samples, max |delta|
= 0.0153 mm (previously 0.0305 mm — confirms the prior worst-case
was indeed at the boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:59:01 +02:00

189 lines
8.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Phase N.5b Z-conformance sentinel: proves that the visual terrain mesh
/// produced by <see cref="LandblockMesh.Build"/> agrees with the physics-side
/// <see cref="TerrainSurface.SampleZFromHeightmap"/> 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 <c>ACDREAM_DAT_DIR</c> 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.
/// </summary>
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), // worst-case landblock from SplitFormulaDivergenceTest
};
[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<Region>(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<uint, byte>(),
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: Array.Empty<byte>(),
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: Array.Empty<uint>(),
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
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<LandBlock>(landblockId);
if (landblock is null)
{
_out.WriteLine($" skipped {name}: dat not found (probably water-only)");
continue;
}
totalLandblocksTested++;
var surfaceCache = new Dictionary<uint, SurfaceInfo>();
var meshData = LandblockMesh.Build(landblock, lbX, lbY, heightTable, ctx, surfaceCache);
// Sample 100 (localX, localY) points uniformly in [0, 191.975].
// The physics path clamps fx = localX/24 to (CellsPerSide - 0.001f)
// = 7.999, which corresponds to localX <= 7.999 * 24 = 191.976.
// Sampling beyond that boundary makes physics compute Z at the
// clamped position while the mesh sampler uses the actual
// position — a difference of up to 23 mm at the upper edge,
// which on a steep slope would falsely trip the 1 mm sentinel.
// Stay strictly below the clamp boundary so both oracles
// compute Z at the same (cellX, tx).
for (int s = 0; s < 100; s++)
{
float lx = (float)rng.NextDouble() * 191.975f;
float ly = (float)rng.NextDouble() * 191.975f;
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}.");
}
/// <summary>
/// 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).
/// </summary>
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.");
}
}