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>
262 lines
9.6 KiB
C#
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);
|
|
}
|
|
}
|