Three fixes to match retail CLandBlock::get_land_scenes (0x00530460): 1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8 cells. Edge vertices (x=8 or y=8) produce valid spawns when the per-object displacement shifts the position back into [0, 192). Confirmed by named retail decomp do-while condition, WorldBuilder vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9]. 2. Building suppression: check at the DISPLACED position's cell (CSortCell::has_building per spawn), not at the loop vertex index. Matches WorldBuilder buildingsGrid[gx2, gy2] pattern. 3. Slope filter: replace finite-difference gradient approximation with triangle-aware normal sampling via new static method TerrainSurface.SampleNormalZFromHeightmap. Picks the correct triangle via IsSplitSWtoNE, matching retail find_terrain_poly → polygon->plane.N.z and WorldBuilder's GetNormal(). Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1, cross-validates with SampleSurface instance method) and DisplaceObject edge-vertex validity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
214 lines
8.3 KiB
C#
214 lines
8.3 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
public class TerrainSurfaceTests
|
|
{
|
|
// A height table where index N maps to N * 1.0f (linear).
|
|
// Makes test assertions predictable: height byte 10 → Z = 10.0.
|
|
private static float[] LinearHeightTable()
|
|
{
|
|
var table = new float[256];
|
|
for (int i = 0; i < 256; i++) table[i] = i * 1.0f;
|
|
return table;
|
|
}
|
|
|
|
// A flat heightmap where every vertex is height byte 50.
|
|
private static byte[] FlatHeightmap(byte value = 50)
|
|
{
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, value);
|
|
return heights;
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleZ_FlatTerrain_ReturnsSameValueEverywhere()
|
|
{
|
|
var surface = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
|
|
|
Assert.Equal(50f, surface.SampleZ(0f, 0f));
|
|
Assert.Equal(50f, surface.SampleZ(96f, 96f));
|
|
Assert.Equal(50f, surface.SampleZ(191f, 191f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleZ_SlopeAlongX_InterpolatesLinearly()
|
|
{
|
|
// Heights increase along X: column 0 = byte 10, column 8 = byte 90.
|
|
// Each column step is (90-10)/8 = 10 bytes.
|
|
var heights = new byte[81];
|
|
for (int x = 0; x < 9; x++)
|
|
for (int y = 0; y < 9; y++)
|
|
heights[x * 9 + y] = (byte)(10 + x * 10);
|
|
|
|
var surface = new TerrainSurface(heights, LinearHeightTable());
|
|
|
|
// At x=0 (vertex 0): Z = 10
|
|
Assert.Equal(10f, surface.SampleZ(0f, 96f), precision: 1);
|
|
// At x=96 (midpoint, vertex 4): Z = 50
|
|
Assert.Equal(50f, surface.SampleZ(96f, 96f), precision: 1);
|
|
// At x=192 (vertex 8): Z = 90
|
|
Assert.Equal(90f, surface.SampleZ(192f, 96f), precision: 1);
|
|
// At x=48 (between vertex 2 and 3): Z = 30 + 0.5 * 10 = 35
|
|
// vertex 2 = byte 30, vertex 3 = byte 40, midpoint = 35
|
|
Assert.Equal(35f, surface.SampleZ(60f, 96f), precision: 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleZ_ClampsOutOfBounds()
|
|
{
|
|
var surface = new TerrainSurface(FlatHeightmap(42), LinearHeightTable());
|
|
|
|
// Negative coordinates clamp to 0
|
|
Assert.Equal(42f, surface.SampleZ(-10f, -10f));
|
|
// Beyond 192 clamps to boundary
|
|
Assert.Equal(42f, surface.SampleZ(300f, 300f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock()
|
|
{
|
|
// Issue #48 conformance: the static SampleZFromHeightmap (bilinear
|
|
// fallback used at scenery hydration before physics registers a
|
|
// landblock) must produce the same Z as the instance SampleZ
|
|
// (player physics path) at every (x, y). The previous fallback in
|
|
// GameWindow had its diagonal arms swapped — this test pins both
|
|
// paths to one source of truth.
|
|
//
|
|
// Heightmap with distinct per-(x,y) values so every triangle plane
|
|
// is genuinely different from the others; flat / planar heightmaps
|
|
// would mask a triangle-pick bug because all four corners would
|
|
// give the same interpolated Z.
|
|
var heights = new byte[81];
|
|
for (int x = 0; x < 9; x++)
|
|
for (int y = 0; y < 9; y++)
|
|
heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256);
|
|
|
|
var hTable = LinearHeightTable();
|
|
|
|
// Pick a landblock where IsSplitSWtoNE(...) returns BOTH true and
|
|
// false across the 64 cells — Holtburg coords (0xA9, 0xB3) work.
|
|
const uint lbX = 0xA9, lbY = 0xB3;
|
|
var instance = new TerrainSurface(heights, hTable, lbX, lbY);
|
|
|
|
// Sample on a fine grid (~1500 points) covering all 64 cells and
|
|
// crossing every cell's diagonal boundary. A triangle-pick bug
|
|
// would show up as a >0.5 m Z mismatch on the diagonal-spanning
|
|
// cells (the corner heights vary by ~10 bytes = 10 Z each cell).
|
|
for (float lx = 0.5f; lx < 192f; lx += 5f)
|
|
for (float ly = 0.5f; ly < 192f; ly += 5f)
|
|
{
|
|
float instanceZ = instance.SampleZ(lx, ly);
|
|
float staticZ = TerrainSurface.SampleZFromHeightmap(
|
|
heights, hTable, lbX, lbY, lx, ly);
|
|
Assert.True(
|
|
Math.Abs(instanceZ - staticZ) < 0.0001f,
|
|
$"Z mismatch at ({lx:F1},{ly:F1}) lb=(0x{lbX:X},0x{lbY:X}): instance={instanceZ:F4} static={staticZ:F4}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleZFromHeightmap_RejectsBadInputs()
|
|
{
|
|
var goodHeights = new byte[81];
|
|
var goodTable = LinearHeightTable();
|
|
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
TerrainSurface.SampleZFromHeightmap(null!, goodTable, 0, 0, 0f, 0f));
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
TerrainSurface.SampleZFromHeightmap(goodHeights, null!, 0, 0, 0f, 0f));
|
|
Assert.Throws<ArgumentException>(() =>
|
|
TerrainSurface.SampleZFromHeightmap(new byte[80], goodTable, 0, 0, 0f, 0f));
|
|
Assert.Throws<ArgumentException>(() =>
|
|
TerrainSurface.SampleZFromHeightmap(goodHeights, new float[255], 0, 0, 0f, 0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleSurfacePolygon_ReturnsContainingTriangleVertices()
|
|
{
|
|
var heights = FlatHeightmap(50);
|
|
var surface = new TerrainSurface(heights, LinearHeightTable(), landblockX: 0, landblockY: 0);
|
|
|
|
var sample = surface.SampleSurfacePolygon(2f, 2f);
|
|
|
|
Assert.Equal(3, sample.Vertices.Length);
|
|
Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z));
|
|
Assert.Equal(1f, sample.Normal.Z, precision: 3);
|
|
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleNormalZFromHeightmap_FlatTerrain_ReturnsOne()
|
|
{
|
|
var heights = FlatHeightmap(50);
|
|
var hTable = LinearHeightTable();
|
|
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 96f, 96f);
|
|
Assert.Equal(1f, nz, precision: 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleNormalZFromHeightmap_SlopedTerrain_ReturnsLessThanOne()
|
|
{
|
|
var heights = new byte[81];
|
|
for (int x = 0; x < 9; x++)
|
|
for (int y = 0; y < 9; y++)
|
|
heights[x * 9 + y] = (byte)(x * 20);
|
|
var hTable = LinearHeightTable();
|
|
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 12f, 12f);
|
|
Assert.True(nz < 1f, $"Sloped terrain should have nz < 1.0, got {nz}");
|
|
Assert.True(nz > 0f, $"nz should be positive, got {nz}");
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleNormalZFromHeightmap_AgreesWithSampleSurface()
|
|
{
|
|
var heights = new byte[81];
|
|
for (int x = 0; x < 9; x++)
|
|
for (int y = 0; y < 9; y++)
|
|
heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256);
|
|
|
|
var hTable = LinearHeightTable();
|
|
const uint lbX = 0xA9, lbY = 0xB3;
|
|
var instance = new TerrainSurface(heights, hTable, lbX, lbY);
|
|
|
|
for (float lx = 0.5f; lx < 192f; lx += 8f)
|
|
for (float ly = 0.5f; ly < 192f; ly += 8f)
|
|
{
|
|
var (_, normal) = instance.SampleSurface(lx, ly);
|
|
float staticNz = TerrainSurface.SampleNormalZFromHeightmap(
|
|
heights, hTable, lbX, lbY, lx, ly);
|
|
Assert.True(
|
|
Math.Abs(normal.Z - staticNz) < 0.0001f,
|
|
$"NormalZ mismatch at ({lx:F1},{ly:F1}): instance={normal.Z:F4} static={staticNz:F4}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
|
|
{
|
|
var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
|
|
|
|
// Cell (0,0) at position (0,0) → cell ID 0x0001
|
|
Assert.Equal(0x0001u, surface.ComputeOutdoorCellId(0f, 0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeOutdoorCellId_SecondColumn_ReturnsCorrect()
|
|
{
|
|
var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
|
|
|
|
// 24 units in X = cell (1, 0) → cell ID 0x0001 + 1*8 = 0x0009
|
|
Assert.Equal(0x0009u, surface.ComputeOutdoorCellId(24f, 0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeOutdoorCellId_LastCell_Returns0x0040()
|
|
{
|
|
var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
|
|
|
|
// Cell (7,7) at position (191,191) → 0x0001 + 7*8 + 7 = 0x0040
|
|
Assert.Equal(0x0040u, surface.ComputeOutdoorCellId(191f, 191f));
|
|
}
|
|
}
|