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>
140 lines
5.9 KiB
C#
140 lines
5.9 KiB
C#
using System;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Outdoor terrain height resolver for a single landblock. Performs
|
||
/// per-triangle barycentric Z interpolation matching the visual terrain
|
||
/// mesh's triangle split direction (AC2D's FSplitNESW formula).
|
||
///
|
||
/// <para>
|
||
/// Each cell (24×24 units) is split into two triangles along either the
|
||
/// SW→NE or SE→NW diagonal. The split direction is determined by the
|
||
/// same formula the render mesh uses (0x0CCAC033 constants from AC2D),
|
||
/// so the Z this class produces matches the visual terrain surface exactly.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class TerrainSurface
|
||
{
|
||
private const int HeightmapSide = 9;
|
||
private const float CellSize = 24f;
|
||
private const int CellsPerSide = 8; // 192 / 24
|
||
|
||
private readonly float[,] _z; // pre-resolved heights [x, y]
|
||
private readonly uint _landblockX;
|
||
private readonly uint _landblockY;
|
||
|
||
public TerrainSurface(byte[] heights, float[] heightTable,
|
||
uint landblockX = 0, uint landblockY = 0)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(heights);
|
||
ArgumentNullException.ThrowIfNull(heightTable);
|
||
if (heights.Length < 81)
|
||
throw new ArgumentException("heights must have 81 entries", nameof(heights));
|
||
if (heightTable.Length < 256)
|
||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||
|
||
_landblockX = landblockX;
|
||
_landblockY = landblockY;
|
||
|
||
// Pre-resolve all 81 heights so SampleZ is a pure lookup + lerp.
|
||
_z = new float[HeightmapSide, HeightmapSide];
|
||
for (int x = 0; x < HeightmapSide; x++)
|
||
for (int y = 0; y < HeightmapSide; y++)
|
||
_z[x, y] = heightTable[heights[x * HeightmapSide + y]];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Triangle-aware terrain Z at (localX, localY) in landblock-local
|
||
/// coordinates (0..192 range). Uses the decompiled retail client formula
|
||
/// (FUN_00532a50 / ACE LandblockStruct.ConstructPolygons) to pick one of
|
||
/// two diagonals, then does barycentric interpolation inside the chosen
|
||
/// triangle. Cross-verified against ACE's <c>LandCell.find_terrain_poly</c>
|
||
/// (plane-equation based), both produce identical Z for every (localX,localY).
|
||
///
|
||
/// <para>
|
||
/// Triangle layout matches ACE's ConstructPolygons (lines 221-244):
|
||
/// <b>SWtoNE</b> (bit31 set, <c>SWtoNEcut = true</c>): diagonal runs
|
||
/// <b>BL → TR</b> (line y = x). Triangles: {BL,BR,TR} below,
|
||
/// {BL,TR,TL} above. Dividing test: <c>tx > ty</c>.
|
||
/// <b>SEtoNW</b> (bit31 clear, <c>SWtoNEcut = false</c>): diagonal runs
|
||
/// <b>BR → TL</b> (line x + y = 1). Triangles: {BL,BR,TL} below,
|
||
/// {BR,TR,TL} above. Dividing test: <c>tx + ty <= 1</c>.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Diagnosed 2026-04-21: previous version had the two enum branches'
|
||
/// geometry inverted — when <c>splitSWtoNE</c> was <c>true</c> we
|
||
/// interpolated across the NW-SE diagonal (ACE's SEtoNW geometry) and
|
||
/// vice versa. Symptom: remote players drawn at server Z hovered up to
|
||
/// ~1m above or clipped into the rendered ground on sloped cells
|
||
/// because our surface Z came from the wrong triangle of the cell quad.
|
||
/// Flat cells masked the bug because all four corners shared one Z.
|
||
/// </para>
|
||
/// </summary>
|
||
public float SampleZ(float localX, float localY)
|
||
{
|
||
// Which cell?
|
||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||
int cx = (int)fx;
|
||
int cy = (int)fy;
|
||
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
|
||
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
|
||
|
||
// Fractional position within the cell [0, 1]
|
||
float tx = fx - cx;
|
||
float ty = fy - cy;
|
||
|
||
// Four corner heights (BL=SW, BR=SE, TR=NE, TL=NW)
|
||
float hBL = _z[cx, cy ];
|
||
float hBR = _z[cx + 1, cy ];
|
||
float hTR = _z[cx + 1, cy + 1];
|
||
float hTL = _z[cx, cy + 1];
|
||
|
||
// Split direction — same formula as TerrainBlending.CalculateSplitDirection
|
||
// and ACE's LandblockStruct.ConstructPolygons.
|
||
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
|
||
|
||
if (splitSWtoNE)
|
||
{
|
||
// Diagonal BL(0,0) → TR(1,1) — line y = x.
|
||
// Triangles: {BL,BR,TR} below (tx > ty), {BL,TR,TL} above.
|
||
if (tx > ty)
|
||
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR triangle
|
||
else
|
||
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle
|
||
}
|
||
else
|
||
{
|
||
// Diagonal BR(1,0) → TL(0,1) — line x + y = 1.
|
||
// Triangles: {BL,BR,TL} below (tx+ty <= 1), {BR,TR,TL} above.
|
||
if (tx + ty <= 1f)
|
||
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL triangle
|
||
else
|
||
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL triangle
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Compute the outdoor cell ID for the given landblock-local position.
|
||
/// </summary>
|
||
public uint ComputeOutdoorCellId(float localX, float localY)
|
||
{
|
||
int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1);
|
||
int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1);
|
||
return (uint)(1 + cx * CellsPerSide + cy);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal.
|
||
/// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY).
|
||
/// </summary>
|
||
private static bool IsSplitSWtoNE(uint landblockX, uint cellX, uint landblockY, uint cellY)
|
||
{
|
||
uint x = landblockX * 8 + cellX;
|
||
uint y = landblockY * 8 + cellY;
|
||
uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u);
|
||
return (dw & 0x80000000u) != 0;
|
||
}
|
||
}
|