using System; using System.Numerics; namespace AcDream.Core.Physics; public readonly record struct TerrainSurfacePolygon( float Z, Vector3 Normal, Vector3[] Vertices); /// /// 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 bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F] private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy] private readonly uint _landblockX; private readonly uint _landblockY; public TerrainSurface(byte[] heights, float[] heightTable, uint landblockX = 0, uint landblockY = 0, byte[]? terrainTypes = null) { 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]]; // Per-vertex water flag. TerrainType lives in bits 2-6 of each // TerrainInfo byte; water is types 0x10-0x14 inclusive (per // LandDefs.TerrainType enum: WaterRunning..WaterDeepSea). // Retail's SurfChar[32] lookup marks those 5 indices as water. // If terrainTypes is not provided (tests / legacy callers) we // default to "no water anywhere" which preserves old behavior. _cornerIsWater = new bool[HeightmapSide, HeightmapSide]; if (terrainTypes is not null && terrainTypes.Length >= 81) { for (int x = 0; x < HeightmapSide; x++) for (int y = 0; y < HeightmapSide; y++) { int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F; _cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14; } } // Per-cell water classification (mirrors ACE // LandblockStruct.CalcCellWater / CalcWater). A cell has 4 // vertex corners; count how many are water type. _cellWaterType = new byte[CellsPerSide, CellsPerSide]; for (int cx = 0; cx < CellsPerSide; cx++) for (int cy = 0; cy < CellsPerSide; cy++) { int waterCorners = 0; if (_cornerIsWater[cx, cy ]) waterCorners++; if (_cornerIsWater[cx + 1, cy ]) waterCorners++; if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++; if (_cornerIsWater[cx, cy + 1]) waterCorners++; _cellWaterType[cx, cy] = waterCorners switch { 0 => 0, // NotWater 4 => 2, // EntirelyWater _ => 1, // PartiallyWater }; } } /// /// 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); return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE); } /// /// Sample terrain Z directly from a landblock's raw heightmap. Same /// algorithm as (instance), but reads the four /// corner heights through heightTable[heights[x*9+y]] on the fly /// instead of from the pre-resolved instance cache. Use this when a /// hasn't been built yet for a landblock — /// e.g. scenery hydration during streaming, before physics has registered /// the landblock. Both paths produce the same Z, so scenery sits flush /// with the visible terrain mesh and with the player physics path. /// /// /// Issue #48 root cause: the previous bilinear fallback in /// GameWindow.SampleTerrainZ had its two diagonal arms swapped /// (used the SEtoNW triangle test for SWtoNE cells and vice versa), /// so on sloped cells scenery sat at a different Z than the visible /// terrain by up to ~1.5 m. Routing the fallback through this static /// helper guarantees both samplers stay in lock-step. /// /// public static float SampleZFromHeightmap( byte[] heights, float[] heightTable, uint landblockX, uint landblockY, float localX, float localY) { 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)); 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); float tx = fx - cx; float ty = fy - cy; // x-major heightmap indexing matches TerrainSurface's pre-resolution // (heights[x * 9 + y]) and ACE LandblockStruct. float hBL = heightTable[heights[cx * HeightmapSide + cy ]]; float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]]; float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]]; float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]]; bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy); return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE); } /// /// Sample the terrain triangle's surface-normal Z component at (localX, localY) /// from a raw heightmap. Returns the upward component of the unit normal for /// the specific triangle the point lies in — flat ground returns 1.0, steeper /// slopes return smaller values. Used by for /// the retail slope filter (CLandCell::find_terrain_poly → polygon.plane.N.z). /// public static float SampleNormalZFromHeightmap( byte[] heights, float[] heightTable, uint landblockX, uint landblockY, float localX, float localY) { 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)); 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); float tx = fx - cx; float ty = fy - cy; float hBL = heightTable[heights[cx * HeightmapSide + cy ]]; float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]]; float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]]; float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]]; bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy); float dzdx, dzdy; if (splitSWtoNE) { if (tx > ty) { dzdx = (hBR - hBL) / CellSize; dzdy = (hTR - hBR) / CellSize; } else { dzdx = (hTR - hTL) / CellSize; dzdy = (hTL - hBL) / CellSize; } } else { if (tx + ty <= 1f) { dzdx = (hBR - hBL) / CellSize; dzdy = (hTL - hBL) / CellSize; } else { dzdx = (hTR - hTL) / CellSize; dzdy = (hTR - hBR) / CellSize; } } return 1f / MathF.Sqrt(dzdx * dzdx + dzdy * dzdy + 1f); } /// /// Pick the cell's triangle for the chosen diagonal and barycentric- /// interpolate Z. Single source of truth shared by both /// (instance, pre-resolved cache) and /// (static, raw heightmap). /// Triangle layout matches ACE LandblockStruct.ConstructPolygons: /// SWtoNE cells split BL→TR (line y=x), SEtoNW cells split BR→TL /// (line x+y=1). /// private static float InterpolateZInTriangle( float hBL, float hBR, float hTR, float hTL, float tx, float ty, bool splitSWtoNE) { 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 return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL } 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 return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL } } /// /// Sample both the terrain Z and the triangle-plane surface normal at /// (localX, localY). The normal is derived from the gradient of the same /// triangle interpolates across, so returned /// (Z, Normal) is exactly the sloped plane the physics should /// contact against. /// /// /// This matters for : when AdjustOffset /// projects the per-step movement offset onto the contact plane, a flat /// plane (Normal = (0,0,1)) cannot impart any Z component to a /// horizontal velocity — the character walks off a slope and the /// per-frame step-down budget (~4 cm) can't catch up with the slope /// descent rate, so the sphere floats above the terrain. A SLOPED plane /// gives AdjustOffset the info it needs to produce slope-aligned motion /// with the correct Z component baked in. /// /// /// /// Retail does this via LandCell.find_terrain_poly → walkable.Plane /// (ACE Landblock.cs:125-137). We derive the equivalent plane /// analytically from the chosen triangle's three corner heights. /// /// public (float Z, System.Numerics.Vector3 Normal) SampleSurface(float localX, float localY) { 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); float tx = fx - cx; float ty = fy - cy; float hBL = _z[cx, cy ]; float hBR = _z[cx + 1, cy ]; float hTR = _z[cx + 1, cy + 1]; float hTL = _z[cx, cy + 1]; bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); // The SampleZ formula for each triangle is linear in (tx, ty): // Z = a + b * tx + c * ty // so dZ/dLocalX = b / CellSize, dZ/dLocalY = c / CellSize. // Surface normal = normalize((-dZ/dX, -dZ/dY, 1)) — a well-known // identity for a height-field plane. float z, dzdx, dzdy; if (splitSWtoNE) { // Diagonal BL(0,0) → TR(1,1). Triangles: {BL,BR,TR} / {BL,TR,TL}. if (tx > ty) { // {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; dzdx = (hBR - hBL) / CellSize; dzdy = (hTR - hBR) / CellSize; } else { // {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; dzdx = (hTR - hTL) / CellSize; dzdy = (hTL - hBL) / CellSize; } } else { // Diagonal BR(1,0) → TL(0,1). Triangles: {BL,BR,TL} / {BR,TR,TL}. if (tx + ty <= 1f) { // {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; dzdx = (hBR - hBL) / CellSize; dzdy = (hTL - hBL) / CellSize; } else { // {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty) // Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); dzdx = (hTR - hTL) / CellSize; dzdy = (hTR - hBR) / CellSize; } } var normal = System.Numerics.Vector3.Normalize( new System.Numerics.Vector3(-dzdx, -dzdy, 1f)); return (z, normal); } /// /// Sample the terrain triangle at (localX, localY), including the three /// local-space vertices that bound the sampled point. Edge-slide needs /// these vertices so the retail crossed-edge test can identify which edge /// the sphere left when a step-down probe fails. /// public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY) { float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f); float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f); int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1); int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1); float tx = fx - cx; float ty = fy - cy; float hBL = _z[cx, cy ]; float hBR = _z[cx + 1, cy ]; float hTR = _z[cx + 1, cy + 1]; float hTL = _z[cx, cy + 1]; bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); Vector3 bl = new(cx * CellSize, cy * CellSize, hBL); Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR); Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR); Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL); float z; Vector3[] vertices; if (splitSWtoNE) { if (tx > ty) { z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; vertices = new[] { bl, br, tr }; } else { z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; vertices = new[] { bl, tr, tl }; } } else { if (tx + ty <= 1f) { z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; vertices = new[] { bl, br, tl }; } else { z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); vertices = new[] { br, tr, tl }; } } var normal = Vector3.Normalize( Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0])); if (normal.Z < 0f) normal = -normal; return new TerrainSurfacePolygon(z, normal, vertices); } /// /// Retail per-point water depth in meters — the amount the character's /// feet are allowed to sink below the contact plane before the /// push-up fires. adds this to the /// signed-distance check so water cells visually submerge the /// character while dry terrain keeps feet exactly on the plane. /// /// /// Ported from ACE ObjCell.get_water_depth and /// LandblockStruct.calc_water_depth, except that the retail /// 0.1 fallback for "dry corner of a partially-water cell" is /// collapsed to 0. The 0.1 offset destabilizes the "feet exactly on /// plane" contact-touch check (dist > EPSILON, so SetContactPlane /// doesn't fire, ValidateTransition clears OnWalkable, gravity /// applies, character floats/falls each frame). The visible effect /// in retail is subtle (~10 cm natural sink-in) so dropping it to 0 /// is indistinguishable. /// /// /// NotWater cell → 0.0f /// EntirelyWater cell → 0.9f (fully submerged) /// PartiallyWater cell, nearest-corner water type → 0.45f /// PartiallyWater cell, nearest-corner non-water → 0.0f /// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug) /// /// public float SampleWaterDepth(float localX, float localY) { float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f); float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f); int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1); int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1); byte waterType = _cellWaterType[cx, cy]; if (waterType == 0) return 0f; // NotWater if (waterType == 2) return 0.9f; // EntirelyWater // PartiallyWater — resolve to nearest corner (retail picks the // terrain-vertex at the "+12" half of each axis, i.e. >= 12m into // a 24m cell rounds up). Return 0.45 for water corner, 0 for dry // (retail uses 0.1 for dry corners; see note above). float tx = fx - cx; float ty = fy - cy; int vx = cx + (tx >= 0.5f ? 1 : 0); int vy = cy + (ty >= 0.5f ? 1 : 0); return _cornerIsWater[vx, vy] ? 0.45f : 0f; } /// /// 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; } }