using System; namespace AcDream.Core.Physics; /// /// 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). /// /// /// 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. /// /// 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]]; } /// /// 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 LandCell.find_terrain_poly /// (plane-equation based), both produce identical Z for every (localX,localY). /// /// /// Triangle layout matches ACE's ConstructPolygons (lines 221-244): /// SWtoNE (bit31 set, SWtoNEcut = true): diagonal runs /// BL → TR (line y = x). Triangles: {BL,BR,TR} below, /// {BL,TR,TL} above. Dividing test: tx > ty. /// SEtoNW (bit31 clear, SWtoNEcut = false): diagonal runs /// BR → TL (line x + y = 1). Triangles: {BL,BR,TL} below, /// {BR,TR,TL} above. Dividing test: tx + ty <= 1. /// /// /// /// Diagnosed 2026-04-21: previous version had the two enum branches' /// geometry inverted — when splitSWtoNE was true 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. /// /// 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 } } /// /// Compute the outdoor cell ID for the given landblock-local position. /// 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); } /// /// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal. /// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY). /// 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; } }