diff --git a/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs b/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs new file mode 100644 index 0000000..789331f --- /dev/null +++ b/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs @@ -0,0 +1,261 @@ +using AcDream.Core.Terrain; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Terrain; + +/// +/// Conformance tests verifying acdream's terrain algorithms produce +/// identical results to the decompiled AC client (ClientReference.cs). +/// Ported from WorldBuilder-ACME-Edition/WorldBuilder.Tests/TerrainConformanceTests.cs. +/// +public class ClientConformanceTests +{ + // ── Split Direction ────────────────────────────────────────────────── + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 0)] + [InlineData(0, 1)] + [InlineData(7, 7)] + [InlineData(127, 127)] + [InlineData(1016, 1016)] + [InlineData(2039, 2039)] + [InlineData(500, 1200)] + [InlineData(1999, 3)] + public void SplitDirection_MatchesClient(int globalX, int globalY) + { + bool clientResult = ClientReference.IsSWtoNECut(globalX, globalY); + + // Our CalculateSplitDirection takes landblock + cell separately. + // Convert global back to (landblockX, cellX, landblockY, cellY). + uint lbX = (uint)(globalX / 8); + uint cx = (uint)(globalX % 8); + uint lbY = (uint)(globalY / 8); + uint cy = (uint)(globalY % 8); + bool acdreamResult = TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy) + == CellSplitDirection.SWtoNE; + + Assert.Equal(clientResult, acdreamResult); + } + + [Fact] + public void SplitDirection_MatchesClient_FullSweep() + { + int mismatches = 0; + int tested = 0; + + // Sweep every 8th landblock (covers the full coordinate range + // without testing 4M+ cells in CI — still 25,600 cells). + for (uint lbX = 0; lbX < 255; lbX += 8) + { + for (uint lbY = 0; lbY < 255; lbY += 8) + { + for (uint cx = 0; cx < 8; cx++) + { + for (uint cy = 0; cy < 8; cy++) + { + int gx = (int)(lbX * 8 + cx); + int gy = (int)(lbY * 8 + cy); + + bool client = ClientReference.IsSWtoNECut(gx, gy); + bool acdream = TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy) + == CellSplitDirection.SWtoNE; + + if (client != acdream) mismatches++; + tested++; + } + } + } + } + + Assert.True(mismatches == 0, + $"Split direction mismatch: {mismatches} of {tested} cells differ"); + } + + // Also verify TerrainSurface's private IsSplitSWtoNE matches. + // We test it indirectly via SampleZ on a known asymmetric heightmap. + [Fact] + public void SplitDirection_TerrainSurface_AgreesWith_TerrainBlending() + { + // Build an asymmetric heightmap where the two split directions + // produce different Z at the cell center (0.5, 0.5). + // Heights: BL=0, BR=100, TL=0, TR=0 (steep slope along X only at Y=0) + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; + + // Set cell (0,0) corners: BL=(0,0)=0, BR=(1,0)=100, TL=(0,1)=0, TR=(1,1)=0 + heights[0 * 9 + 0] = 0; // BL + heights[1 * 9 + 0] = 100; // BR + heights[0 * 9 + 1] = 0; // TL + heights[1 * 9 + 1] = 0; // TR + + // Sample at cell center (12, 12) = (0.5, 0.5) in cell coords. + // For SWtoNE split (tx+ty=1 boundary): both triangles give Z=50 at center. + // For SEtoNW split (ty=tx boundary): both triangles give Z=50 at center. + // So at exact center both agree. Sample at (18, 6) = (0.75, 0.25) instead. + // SWtoNE: tx+ty=1.0 → tx=0.75, ty=0.25, 0.75+0.25=1.0 → boundary + // Use (20, 4) = (0.833, 0.167): tx+ty=1.0 → still boundary + // Use (20, 2) = (0.833, 0.083): tx+ty=0.917 < 1 → BL+BR+TL triangle + // Z = 0 + (100-0)*0.833 + (0-0)*0.083 = 83.3 + // For SEtoNW: ty=0.083 < tx=0.833 → BL+BR+TR triangle + // Z = 0 + (100-0)*0.833 + (0-100)*0.083 = 83.3 - 8.3 = 75.0 + // These differ! So we can distinguish. + + // Use landblock (0,0) and check what the client says the split is. + bool clientSplit = ClientReference.IsSWtoNECut(0, 0); + var surface = new TerrainSurface(heights, heightTable, 0, 0); + float z = surface.SampleZ(20f, 2f); + + if (clientSplit) + { + // SWtoNE → BL+BR+TL triangle at (0.833, 0.083) → Z ≈ 83.3 + Assert.InRange(z, 82f, 85f); + } + else + { + // SEtoNW → BL+BR+TR triangle at (0.833, 0.083) → Z ≈ 75.0 + Assert.InRange(z, 74f, 77f); + } + } + + // ── PalCode ────────────────────────────────────────────────────────── + + [Theory] + [InlineData(0, 0, 0, 0, 1, 1, 1, 1)] + [InlineData(1, 0, 0, 0, 5, 10, 15, 20)] + [InlineData(3, 3, 3, 3, 31, 31, 31, 31)] + [InlineData(0, 1, 2, 3, 0, 5, 10, 15)] + [InlineData(2, 0, 1, 3, 8, 4, 12, 16)] + public void PalCode_MatchesClient(int r0, int r1, int r2, int r3, int t0, int t1, int t2, int t3) + { + // Our GetPalCode parameter order: (rBL, rBR, rTR, rTL, tBL, tBR, tTR, tTL) + // Client parameter order: (r0,t0, r1,t1, r2,t2, r3,t3) where + // 0=BL(ix,iy), 1=BR(ix+1,iy), 2=TR(ix+1,iy+1), 3=TL(ix,iy+1) + uint clientResult = ClientReference.GetPalCode(r0, t0, r1, t1, r2, t2, r3, t3); + uint acdreamResult = TerrainBlending.GetPalCode(r0, r1, r2, r3, t0, t1, t2, t3); + + Assert.Equal(clientResult, acdreamResult); + } + + [Fact] + public void PalCode_MatchesClient_ExhaustiveRoads() + { + int mismatches = 0; + int t0 = 5, t1 = 10, t2 = 15, t3 = 20; + + for (int r0 = 0; r0 <= 3; r0++) + for (int r1 = 0; r1 <= 3; r1++) + for (int r2 = 0; r2 <= 3; r2++) + for (int r3 = 0; r3 <= 3; r3++) + { + uint client = ClientReference.GetPalCode(r0, t0, r1, t1, r2, t2, r3, t3); + uint acdream = TerrainBlending.GetPalCode(r0, r1, r2, r3, t0, t1, t2, t3); + if (client != acdream) mismatches++; + } + + Assert.Equal(0, mismatches); + } + + [Fact] + public void PalCode_MatchesClient_ExhaustiveTypes() + { + int mismatches = 0; + int r0 = 1, r1 = 0, r2 = 2, r3 = 3; + + for (int t0 = 0; t0 < 32; t0++) + for (int t1 = 0; t1 < 32; t1++) + for (int t2 = 0; t2 < 32; t2++) + for (int t3 = 0; t3 < 32; t3++) + { + uint client = ClientReference.GetPalCode(r0, t0, r1, t1, r2, t2, r3, t3); + uint acdream = TerrainBlending.GetPalCode(r0, r1, r2, r3, t0, t1, t2, t3); + if (client != acdream) mismatches++; + } + + Assert.Equal(0, mismatches); + } + + // ── Height Sampling ────────────────────────────────────────────────── + + [Fact] + public void HeightSampling_FlatTerrain_MatchesClient() + { + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 2f; + + byte heightByte = 50; + float clientHeight = ClientReference.GetVertexHeight(heightTable, heightByte); + + // Build a flat heightmap and sample at the center. + var heights = new byte[81]; + Array.Fill(heights, heightByte); + var surface = new TerrainSurface(heights, heightTable); + float acdreamHeight = surface.SampleZ(96f, 96f); + + Assert.Equal(clientHeight, acdreamHeight, precision: 3); + } + + [Fact] + public void HeightSampling_VertexCorners_MatchClient() + { + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 2f; + + // Sloped heightmap: height = x*10 + y*5 + var heights = new byte[81]; + for (int x = 0; x <= 8; x++) + for (int y = 0; y <= 8; y++) + heights[x * 9 + y] = (byte)(x * 10 + y * 5); + + var surface = new TerrainSurface(heights, heightTable); + + for (int vx = 0; vx <= 8; vx++) + for (int vy = 0; vy <= 8; vy++) + { + byte hByte = heights[vx * 9 + vy]; + float clientH = ClientReference.GetVertexHeight(heightTable, hByte); + float acdreamH = surface.SampleZ(vx * 24f, vy * 24f); + + Assert.Equal(clientH, acdreamH, precision: 1); // edge vertices have float clamping artifacts + } + } + + [Theory] + [InlineData(0f, 0f)] + [InlineData(12f, 12f)] + [InlineData(23.9f, 23.9f)] + [InlineData(48f, 72f)] + [InlineData(96f, 96f)] + [InlineData(180f, 180f)] + public void HeightSampling_InterpolatedPoints_InRange(float localX, float localY) + { + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 2f; + + var heights = new byte[81]; + for (int x = 0; x <= 8; x++) + for (int y = 0; y <= 8; y++) + heights[x * 9 + y] = (byte)(x * 10 + y * 5); + + var surface = new TerrainSurface(heights, heightTable); + float z = surface.SampleZ(localX, localY); + + float minH = heightTable[0]; + float maxH = heightTable[heights.Max()]; + + Assert.InRange(z, minH, maxH); + } + + // ── Constants ──────────────────────────────────────────────────────── + + [Fact] + public void Constants_MatchClient() + { + // TerrainSurface uses these internally; verify they match. + Assert.Equal(ClientReference.CellSize, 24f); + Assert.Equal(ClientReference.CellsPerBlock, 8); + Assert.Equal(ClientReference.BlockLength, 192f); + } +} diff --git a/tests/AcDream.Core.Tests/Terrain/ClientReference.cs b/tests/AcDream.Core.Tests/Terrain/ClientReference.cs new file mode 100644 index 0000000..6408cec --- /dev/null +++ b/tests/AcDream.Core.Tests/Terrain/ClientReference.cs @@ -0,0 +1,75 @@ +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace AcDream.Core.Tests.Terrain; + +/// +/// Faithful C# port of the original AC client terrain algorithms, +/// translated from decompiled C++ in acclient-source-split/CLandBlockStruct.cpp. +/// Serves as the ground-truth oracle for conformance testing. +/// +/// Ported from WorldBuilder-ACME-Edition/WorldBuilder.Tests/ClientReference.cs. +/// All formulas use signed int arithmetic with unchecked wrapping to match x86 behavior. +/// +public static class ClientReference +{ + /// + /// Port of CLandBlockStruct::ConstructPolygons at offset 0x00531D10. + /// Returns true when the triangle split goes from SW to NE (SWtoNEcut=1). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSWtoNECut(int globalCellX, int globalCellY) + { + unchecked + { + int v7 = globalCellY * (214614067 * globalCellX + 1813693831) + - 1109124029 * globalCellX - 1369149221; + return (double)(uint)v7 * 2.3283064e-10 >= 0.5; + } + } + + /// + /// Port of pal_code[0] from CLandBlockStruct::GetCellRotation at offset 0x00532170. + /// Corner order: 0=(ix,iy), 1=(ix+1,iy), 2=(ix+1,iy+1), 3=(ix,iy+1) + /// + public static uint GetPalCode( + int r0, int t0, + int r1, int t1, + int r2, int t2, + int r3, int t3, + int texSize = 1) + { + unchecked + { + return (uint)(t3 + + (texSize << 28) + + 32 * (t2 + 32 * (t1 + 32 * (t0 + 32 * (r3 + 4 * (r2 + 4 * (r1 + 4 * r0))))))); + } + } + + /// + /// Port of CLandBlockStruct::ConstructVertices at offset 0x005328D0. + /// Height = LandDefs::Land_Height_Table[height_byte] + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float GetVertexHeight(float[] landHeightTable, byte heightByte) + { + return landHeightTable[heightByte]; + } + + /// + /// Port of CLandBlockStruct::ConstructVertices vertex position. + /// Each vertex is at (ix * polySize, iy * polySize, height). + /// + public static Vector3 GetVertexPosition(float[] landHeightTable, byte heightByte, int ix, int iy, float polySize = 24f) + { + return new Vector3(ix * polySize, iy * polySize, landHeightTable[heightByte]); + } + + public const int MapWidth = 255; + public const int MapHeight = 255; + public const float CellSize = 24.0f; + public const int CellsPerBlock = 8; + public const float RoadWidth = 5.0f; + public const float BlockLength = 192.0f; +}