diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index efe39e1..1ff5461 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1970,7 +1970,7 @@ public sealed class GameWindow : IDisposable entity.Position = worldPos; entity.Rotation = rot; - // Track remote-entity motion for stop detection. Only record the +// Track remote-entity motion for stop detection. Only record the // timestamp when position moved MEANINGFULLY (> 0.05m). Updates // that report the same position keep the old Time, so the // TickAnimations check can see when motion last changed. diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index 4b77642..433ab87 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -76,26 +76,30 @@ void main() { // Derive which of the 4 cell corners this vertex represents from // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a - // specific order for each split direction; the table below must stay + // specific order for each split direction; the tables below must stay // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches. + // 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons + // convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR} + // + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to + // {BL,BR,TL} + {BR,TR,TL}. int vIdx = gl_VertexID % 6; int corner = 0; if (splitDir == 0u) { - // SWtoNE order: BL, TL, BR, BR, TL, TR → corners 0, 3, 1, 1, 3, 2 + // SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3 if (vIdx == 0) corner = 0; - else if (vIdx == 1) corner = 3; - else if (vIdx == 2) corner = 1; - else if (vIdx == 3) corner = 1; - else if (vIdx == 4) corner = 3; - else corner = 2; - } else { - // SEtoNW order: BL, TR, BR, BL, TL, TR → corners 0, 2, 1, 0, 3, 2 - if (vIdx == 0) corner = 0; - else if (vIdx == 1) corner = 2; - else if (vIdx == 2) corner = 1; + else if (vIdx == 1) corner = 1; + else if (vIdx == 2) corner = 2; else if (vIdx == 3) corner = 0; - else if (vIdx == 4) corner = 3; - else corner = 2; + else if (vIdx == 4) corner = 2; + else corner = 3; + } else { + // SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3 + if (vIdx == 0) corner = 0; + else if (vIdx == 1) corner = 1; + else if (vIdx == 2) corner = 3; + else if (vIdx == 3) corner = 1; + else if (vIdx == 4) corner = 2; + else corner = 3; } vec2 baseUV; diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index a3c5f78..e05cd10 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -46,20 +46,31 @@ public sealed class TerrainSurface /// /// Triangle-aware terrain Z at (localX, localY) in landblock-local - /// coordinates (0..192 range). Uses the AC2D FSplitNESW formula to - /// determine which triangle the point falls in, then does barycentric - /// interpolation within that triangle. This matches the visual terrain - /// mesh exactly. + /// 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 (from LandblockMesh.cs index buffer): - /// SWtoNE: tri1 = {BL,TL,BR}, tri2 = {BR,TL,TR} — shared edge TL→BR (x+y=1 boundary) - /// SEtoNW: tri1 = {BL,TR,BR}, tri2 = {BL,TL,TR} — shared edge BL→TR (y=x boundary) + /// + /// 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. + /// /// - /// NOTE: The SWtoNE "cut" exposes the SW(BL) and NE(TR) corners as isolated - /// vertices — the hypotenuse runs NW(TL)→SE(BR), so the dividing test is - /// x+y=1 (not y=x). Confusing naming aside, the formula below matches - /// TerrainGeometryGenerator.GetHeight (ACME WorldBuilder-ACME-Edition) which - /// was verified against the mesh index buffer. + /// + /// 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) { @@ -75,32 +86,33 @@ public sealed class TerrainSurface float tx = fx - cx; float ty = fy - cy; - // Four corner heights (BL, BR, TR, TL) + // 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 using the AC2D render formula + // Split direction — same formula as TerrainBlending.CalculateSplitDirection + // and ACE's LandblockStruct.ConstructPolygons. bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); if (splitSWtoNE) { - // Mesh: {BL,TL,BR} and {BR,TL,TR}. Shared hypotenuse = TL(0,1)→BR(1,0). - // Dividing line: tx + ty = 1. - if (tx + ty <= 1f) - return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL triangle + // 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 hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // TR+TL+BR triangle + return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle } else { - // Mesh: {BL,TR,BR} and {BL,TL,TR}. Shared hypotenuse = BL(0,0)→TR(1,1). - // Dividing line: ty = tx. - if (ty <= tx) - return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR triangle + // 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 hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle + return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL triangle } } diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index ab79fff..573acf5 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -127,29 +127,37 @@ public static class LandblockMesh var nTR = normals[cx + 1, cy + 1]; var nTL = normals[cx, cy + 1]; - // Emit 6 vertices in the exact order WorldBuilder's Landscape.vert - // expects. The vertex shader maps gl_VertexID % 6 → corner index - // for UV lookup, so the CPU order must match. + // Emit 6 vertices in an order matching ACE's + // LandblockStruct.ConstructPolygons triangulation. The vertex + // shader maps gl_VertexID % 6 → corner index for UV lookup, + // so the CPU order and shader table must stay in lockstep. // - // SWtoNE (splitDir=0): - // vIdx: 0 1 2 3 4 5 - // corner: 0 3 1 1 3 2 - // BL TL BR BR TL TR - // SEtoNW (splitDir=1): - // vIdx: 0 1 2 3 4 5 - // corner: 0 2 1 0 3 2 - // BL TR BR BL TL TR + // SWtoNE (splitDir=0, SWtoNEcut=true): diagonal BL → TR. + // Triangles: {BL,BR,TR} + {BL,TR,TL} (shared edge BL-TR) + // vIdx: 0 1 2 3 4 5 + // corner: 0 1 2 0 2 3 + // BL BR TR BL TR TL + // SEtoNW (splitDir=1, SWtoNEcut=false): diagonal BR → TL. + // Triangles: {BL,BR,TL} + {BR,TR,TL} (shared edge BR-TL) + // vIdx: 0 1 2 3 4 5 + // corner: 0 1 3 1 2 3 + // BL BR TL BR TR TL + // + // 2026-04-21 fix: previous mapping had the enum→geometry + // inversion — SWtoNE built NW-SE-diagonal triangles (ACE's + // SEtoNW geometry) and vice versa, causing remote players + // to hover/clip on sloped cells by up to ~1m. if (split == CellSplitDirection.SWtoNE) { WriteCell(vertices, ref vi, d0, d1, d2, d3, - posBL, nBL, posTL, nTL, posBR, nBR, - posBR, nBR, posTL, nTL, posTR, nTR); + posBL, nBL, posBR, nBR, posTR, nTR, + posBL, nBL, posTR, nTR, posTL, nTL); } else { WriteCell(vertices, ref vi, d0, d1, d2, d3, - posBL, nBL, posTR, nTR, posBR, nBR, - posBL, nBL, posTL, nTL, posTR, nTR); + posBL, nBL, posBR, nBR, posTL, nTL, + posBR, nBR, posTR, nTR, posTL, nTL); } } } diff --git a/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs b/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs index 789331f..4d73529 100644 --- a/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs @@ -79,7 +79,7 @@ public class ClientConformanceTests public void SplitDirection_TerrainSurface_AgreesWith_TerrainBlending() { // Build an asymmetric heightmap where the two split directions - // produce different Z at the cell center (0.5, 0.5). + // 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]; @@ -91,32 +91,33 @@ public class ClientConformanceTests heights[0 * 9 + 1] = 0; // TL heights[1 * 9 + 1] = 0; // TR - // Sample at cell center (12, 12) = (0.5, 0.5) in cell coords. - // For SWtoNE split (tx+ty=1 boundary): both triangles give Z=50 at center. - // For SEtoNW split (ty=tx boundary): both triangles give Z=50 at center. - // So at exact center both agree. Sample at (18, 6) = (0.75, 0.25) instead. - // SWtoNE: tx+ty=1.0 → tx=0.75, ty=0.25, 0.75+0.25=1.0 → boundary - // Use (20, 4) = (0.833, 0.167): tx+ty=1.0 → still boundary - // Use (20, 2) = (0.833, 0.083): tx+ty=0.917 < 1 → BL+BR+TL triangle - // Z = 0 + (100-0)*0.833 + (0-0)*0.083 = 83.3 - // For SEtoNW: ty=0.083 < tx=0.833 → BL+BR+TR triangle - // Z = 0 + (100-0)*0.833 + (0-100)*0.083 = 83.3 - 8.3 = 75.0 - // These differ! So we can distinguish. + // 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. - // Use landblock (0,0) and check what the client says the split is. 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+TL triangle at (0.833, 0.083) → Z ≈ 83.3 - Assert.InRange(z, 82f, 85f); + // SWtoNE → {BL,BR,TR} triangle at (0.833, 0.083) → Z ≈ 75.0 + Assert.InRange(z, 74f, 77f); } else { - // SEtoNW → BL+BR+TR triangle at (0.833, 0.083) → Z ≈ 75.0 - Assert.InRange(z, 74f, 77f); + // SEtoNW → {BL,BR,TL} triangle at (0.833, 0.083) → Z ≈ 83.3 + Assert.InRange(z, 82f, 85f); } }