acdream/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs
Erik 56975f8919 fix(terrain): align per-cell triangle geometry with ACE's ConstructPolygons convention
Our LandblockMesh, terrain.vert corner tables, and TerrainSurface.SampleZ
used the OPPOSITE diagonal for each CellSplitDirection enum value from
what ACE (and the decompiled retail client at FUN_00532a50) picks for the
same sign bit. Same formula, same sign-bit mapping, inverted geometry.

Symptom: remote players rendered at server-broadcast Z hovered or clipped
by up to ~1m on sloped cells. Flat cells masked the bug because all four
corner heights were equal so any triangle pair returned the same Z. Live
diagnostic confirmed +0.79m hover on cell (7,5) at lb(AA,B4) — a ~20°
slope — while flat neighbors agreed to floating-point noise.

Three coordinated edits so CPU mesh + GPU corner lookup + CPU sampler all
agree on the retail geometry:
 - LandblockMesh: SWtoNE branch now emits {BL,BR,TR}+{BL,TR,TL} (y=x cut),
   SEtoNW emits {BL,BR,TL}+{BR,TR,TL} (x+y=1 cut).
 - terrain.vert: corner-index tables updated to match.
 - TerrainSurface.SampleZ: swapped the two branches' interpolation.

After the fix, 19 live DIAG samples across flat + two slope transitions
all land within 0.01m of server Z. Staircase pattern during remote motion
on slopes is a separate bug (no per-frame collision resolution) and will
be addressed via the transition/FindValidPosition port.

Cross-verified against: ACE LandblockStruct.ConstructPolygons lines 221-
244, decompiled retail FUN_00532a50 (chunk_00530000.c:2235), ClientReference
IsSWtoNECut (tests/AcDream.Core.Tests/Terrain/ClientReference.cs).

Updated test SplitDirection_TerrainSurface_AgreesWith_TerrainBlending
with corrected expectations (Z values swap between the two branches).
All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:20:59 +02:00

262 lines
9.6 KiB
C#

using AcDream.Core.Terrain;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Terrain;
/// <summary>
/// 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.
/// </summary>
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 off-center.
// 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 (20, 2) = (0.833, 0.083) in cell-local coords.
//
// 2026-04-21 fix: matched to ACE's ConstructPolygons geometry.
// Our enum values now mean:
// SWtoNE → diagonal BL→TR (y=x), triangles {BL,BR,TR} + {BL,TR,TL}
// SEtoNW → diagonal BR→TL (x+y=1), triangles {BL,BR,TL} + {BR,TR,TL}
//
// At (tx=0.833, ty=0.083):
// SWtoNE: tx > ty → {BL,BR,TR} triangle
// Z = hBL + tx*(hBR-hBL) + ty*(hTR-hBR) = 0.833*100 + 0.083*(-100) ≈ 75.0
// SEtoNW: tx+ty=0.917 ≤ 1 → {BL,BR,TL} triangle
// Z = hBL + tx*(hBR-hBL) + ty*(hTL-hBL) = 0.833*100 + 0 ≈ 83.3
// Expectations swapped vs pre-fix.
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,TR} triangle at (0.833, 0.083) → Z ≈ 75.0
Assert.InRange(z, 74f, 77f);
}
else
{
// SEtoNW → {BL,BR,TL} triangle at (0.833, 0.083) → Z ≈ 83.3
Assert.InRange(z, 82f, 85f);
}
}
// ── 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);
}
}