diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 87c7b2da..7c18f3a8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,112 +46,9 @@ Copy this block when adding a new issue: # Active issues -## #49 — Scenery (X, Y) placement drifts from retail at some landblocks +## #48 — A few specific scenery trees hover above terrain (per-GfxObj Z misplacement) **Status:** OPEN -**Severity:** MEDIUM (visible misplacement; species-specific or per-cell, not a global offset) -**Filed:** 2026-05-06 -**Component:** scenery placement / `SceneryGenerator` - -**Description:** While verifying the `#48` Z fix at Holtburg -landblock `0xA9B30001`, the user spotted a scenery tree placed at -the **wrong (X, Y)** in acdream relative to retail at the same -character coords. Specifically: a large tree that retail places far -across the road on the right (east) side appears in acdream on the -left (west) side, near a chess board / picnic-bench area. Side-by- -side screenshot pair captured 2026-05-06. - -This is **not** a Z bug — every tree in the same screenshot has its -trunk meeting the visible terrain (the `#48` `SampleTerrainZ` fix is -working). It's also **not** the LandBlockInfo Stab path — the chess -board / bench themselves are correctly placed, so the landblock -origin and `lbOffset` math are right. - -**Hypotheses (need cdb retail trace to disambiguate):** - -1. The displacement-noise math in `SceneryGenerator` differs from - retail's `chunk_005A0000` LCG by a constant or a sign flip. Audit - `eeee4c5` claimed "all MATCH" against the decomp, but a runtime - trace would prove or disprove. -2. Coordinate-system handedness: cell-local `(lx, ly)` in our path - may map to retail's `(ly, lx)` somewhere, rotating tree XY 90° - around the cell's NW corner. -3. The `obj.Align != 0` path in retail (`FUN_005a6f60`, aligns the - object to the landcell polygon's normal) may use a different - reference point than ours, drifting placement on sloped cells. -4. Slope filter could reject a cell retail accepts (or vice versa), - pushing trees into adjacent cells. -5. Region-table / `SceneInfo` lookup might select a different - scenery list for the cell type. - -**Investigation plan (gold-standard, per `project_retail_debugger.md`):** - -1. Run the existing `ACDREAM_DUMP_SCENERY_Z=1` diagnostic to capture - acdream's full per-spawn (gfx, world XY, scale, partT) for - landblock `0xA9B3FFFF`. -2. Attach cdb to a live retail client at the same Holtburg spot - (`tools/pdb-extract/check_exe_pdb.py` confirms PDB pairs with - v11.4186). Set a breakpoint on `CLandBlock::get_land_scenes` (or - the inner `chunk_005A0000` placement function); capture every - `(gfxObjId, worldX, worldY, scale, heading)` retail emits for - the same landblock. -3. Diff the two tables. The spawn that's offset will be obvious; - the offset pattern (one tree, all trees, one species, constant - delta, etc.) determines which hypothesis above is correct. - -**Files:** - -- [`src/AcDream.Core/World/SceneryGenerator.cs`](src/AcDream.Core/World/SceneryGenerator.cs) — placement math (LCG noise, displacement, rotation, scale, slope filter) -- `acclient!CLandBlock::get_land_scenes` (`docs/research/named-retail/acclient_2013_pseudo_c.txt`) — retail entry point -- `chunk_005A0000.c` — referenced retail source per `SceneryGenerator.cs` comments -- [`docs/research/named-retail/symbols.json`](docs/research/named-retail/symbols.json) — for cdb breakpoints - -**Acceptance:** Side-by-side outdoor screenshot pair (acdream vs -retail, same character coords, same time of day) shows scenery -positions matching at multiple landblocks. The cdb trace + diagnostic -diff documents quantitative agreement (zero offset within float -precision) on at least one landblock end-to-end. - -**Out of scope here (kept under `#48`):** Z floating. That's fixed. - ---- - -## #48 — [DONE 2026-05-06 · a469395] A few specific scenery trees hover above terrain (per-GfxObj Z misplacement) - -**Resolution:** Hypothesis 2 (physics-sampler vs bilinear-fallback Z -mismatch). The bilinear fallback in `GameWindow.SampleTerrainZ` had -its two diagonal arms swapped — used the SEtoNW triangle test on -SWtoNE cells and vice versa. Every scenery hydration in our -diagnostic ran through the bilinear path (`source=bilinear` in all -`[scenery-z]` log lines) because physics hadn't yet built a -`TerrainSurface` for the streaming-in landblock — so on sloped -cells, scenery sat at a different Z than the visible terrain mesh -by up to ~1.5 m. The bug was latent since `ff325ab` (2026-04-17) -which upgraded the fallback from naive 4-corner bilinear to -triangle-aware barycentric, but with the diagonal-pair tests -swapped. `TerrainSurface.SampleZ` (used by the physics path / player -Z) was always correct, so player feet stayed flush — the two paths -just disagreed and only scenery noticed. - -Fix: extracted the canonical triangle-pick math into -`TerrainSurface.InterpolateZInTriangle` (private static); added -`TerrainSurface.SampleZFromHeightmap` (public static) that reads -heights directly from the landblock byte array using the same -canonical math; redirected `GameWindow.SampleTerrainZ` to delegate -to it. New conformance test -`SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock` pins -both sampler paths together at 1500 sample points across both -diagonals, so future drift gets caught. User visually confirmed -2026-05-06. - -The diagnostic dump (`ACDREAM_DUMP_SCENERY_Z=1`, -`GameWindow.cs:4661`) is kept committed — it's gated by env var, -zero cost when off, and is the right starting point for `#49` -(scenery X/Y placement) too. - -Pseudocode: [`docs/research/2026-05-06-issue-48-fix-pseudocode.md`](docs/research/2026-05-06-issue-48-fix-pseudocode.md). - -**Status:** DONE **Severity:** LOW (cosmetic; ~3 trees per landblock, easy to ignore but obvious once spotted) **Filed:** 2026-05-06 **Component:** rendering / scenery placement / terrain Z sampling diff --git a/docs/research/2026-05-06-issue-48-fix-pseudocode.md b/docs/research/2026-05-06-issue-48-fix-pseudocode.md deleted file mode 100644 index c613711d..00000000 --- a/docs/research/2026-05-06-issue-48-fix-pseudocode.md +++ /dev/null @@ -1,86 +0,0 @@ -# Issue #48 fix — bilinear fallback triangle-pair test was swapped - -## Diagnosis (from the diagnostic dump) - -ACDREAM_DUMP_SCENERY_Z=1 in landblock 0xA9B3 around the user's spawn -showed: - -- Every scenery line had `source=bilinear` (physics engine had not - registered the landblock at hydration time — typical streaming race). -- The trees flagged as "floating" had multi-part setups whose lowest - vertex extended into the ground per our calc (`partWorldZMin = - groundZ - 3.825`) — i.e., per the diagnostic the tree should sit - flush. The retail client at the same world coords does sit flush. -- Player Z at the same world coords (~91 m) came from the physics - sampler (`TerrainSurface.SampleZ`), which is correct. - -That meant the bilinear fallback in `GameWindow.SampleTerrainZ` was -producing a different ground Z than the visible terrain mesh and the -player physics path on sloped cells, so trees were placed at a -ground-Z that didn't match the terrain the player was walking on. - -## Root cause - -Two terrain Z samplers exist: - -1. `AcDream.Core.Physics.TerrainSurface.SampleZ` (instance) — used by the - physics engine for player + entity ground-snap. Triangle-aware, - matches the visible terrain mesh. -2. `AcDream.App.Rendering.GameWindow.SampleTerrainZ` (private bilinear - fallback) — used by scenery hydration when physics has not yet built - a `TerrainSurface` for a streaming-in landblock. - -Both choose a per-cell **diagonal** with the AC2D `FSplitNESW` formula -(constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, `0x519B8F25`). -Both then choose one of the cell's two **triangles** based on the -fractional position within the cell. **The fallback's two arms were -swapped** relative to the chosen diagonal: - -| Diagonal | Correct dividing test | Correct triangles | Bilinear-fallback test (BUGGY) | -|---|---|---|---| -| `SWtoNE` (BL→TR, line y=x) | `tx > ty` | {BL,BR,TR} below / {BL,TR,TL} above | `s + t <= 1` (wrong — that's the SEtoNW test) | -| `SEtoNW` (BR→TL, line x+y=1) | `tx + ty <= 1` | {BL,BR,TL} below / {BR,TR,TL} above | `s >= t` (wrong — that's the SWtoNE test) | - -On sloped cells the wrong triangle's plane gives a Z that disagrees -with the rendered terrain by up to ~1.5 m. Flat cells happen to mask -the bug because all four corners share one Z. - -## Fix - -1. Extract the correct triangle-picker math from `TerrainSurface.SampleZ` - (instance) into a new public static method - `TerrainSurface.SampleZFromHeightmap(byte[] heights, float[] heightTable, - uint landblockX, uint landblockY, float localX, float localY)`. - Same algorithm, but reads the four corner heights directly from the - landblock's raw heightmap byte array instead of the pre-resolved - instance cache. One source of truth for the triangle math. -2. Replace `GameWindow.SampleTerrainZ` body with a call to that static. -3. Conformance test in `tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs` - exercising a sloped heightmap on both diagonals, asserting that the - new static and the existing instance method return the same Z at the - same `(localX, localY)` (especially at points near the cell diagonal - where the previous bug manifested). - -## Why this is the right fix - -- Retail (`docs/research/named-retail/acclient_2013_pseudo_c.txt`) places - scenery via `CLandBlock::get_land_scenes` (0x00530460) → - `Plane::set_height(plane, &pos)` (0x0052f050), which projects the - position onto the terrain triangle's plane. The split direction comes - from the same `FSplitNESW` formula. Our `TerrainSurface.SampleZ` is a - faithful port of that algorithm and is already used by physics; the - bilinear fallback should be identical. -- The bug is purely in the fallback path. Player Z is unaffected. -- No retail behavior changes; only acdream consistency. -- "Don't break" constraints from the handoff are satisfied: - - Player Z snap untouched (different code path). - - Species that already render flush still do (their Z was correct - on cells where bilinear and physics agreed; now it's correct - everywhere). - -## Pre-existing bugs out of scope - -The user reports separate (X, Y) misplacement at other locations. -That's a different bug — likely in `SceneryGenerator`'s placement -math or one of the terrain-mesh / region tables — and outside the -scope of this fix. File as a follow-up issue. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b00345d8..6e666478 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -183,15 +183,6 @@ public sealed class GameWindow : IDisposable private static readonly bool s_retailCloseDegrades = !string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal); - // Issue #48 diagnostic — dump per-scenery-spawn placement evidence - // (rendered gfx id, sample source physics-vs-bilinear, ground/baseLoc/finalZ, - // mesh vertex Z range, DIDDegrade slot 0). One log line per spawn lets - // the user identify a floating tree by its world coordinates and tell - // whether the cause is BaseLoc.Z addition (H1), bilinear-fallback drift - // (H2), or DIDDegrade selection (H3). Diagnostic-first per CLAUDE.md. - private static readonly bool s_dumpSceneryZ = - string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_SCENERY_Z"), "1", StringComparison.Ordinal); - /// /// Issue #47 humanoid-setup detector. Matches Aluvian Male /// (0x02000001) and the 34-part heritage sibling setups @@ -2532,28 +2523,62 @@ public sealed class GameWindow : IDisposable } /// - /// Triangle-aware terrain Z sample directly from a landblock's raw - /// heightmap. Used as the bilinear fallback in scenery hydration when - /// physics hasn't built a TerrainSurface for the landblock yet - /// (streaming race). Delegates to - /// - /// so this fallback and the player-physics path stay in lock-step on - /// sloped cells. - /// - /// - /// Issue #48: the previous in-place implementation here had its two - /// diagonal arms swapped (SWtoNE cells used the SEtoNW triangle test - /// and vice versa), so scenery on hilly terrain sat at a different Z - /// than the visible terrain mesh — a multi-meter offset in some - /// cells, the user-reported "floating trees" symptom. - /// + /// Bilinear sample of the landblock heightmap at (x, y) in landblock-local + /// world units. Matches the x-major indexing convention of LandblockMesh. /// - private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY) + private float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY) { + // Exact port of WorldBuilder TerrainUtils.GetHeight (line 59-108). + // Barycentric interpolation over the cell's triangle pair, respecting + // the cell's split direction (SWtoNE vs SEtoNW). + const float CellSize = 24f; + + uint cellX = (uint)(worldX / CellSize); + uint cellY = (uint)(worldY / CellSize); + if (cellX >= 8) cellX = 7; + if (cellY >= 8) cellY = 7; + uint landblockX = (block.Id >> 24) & 0xFFu; uint landblockY = (block.Id >> 16) & 0xFFu; - return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap( - block.Height, heightTable, landblockX, landblockY, localX, localY); + var splitDirection = AcDream.Core.Terrain.TerrainBlending.CalculateSplitDirection( + landblockX, cellX, landblockY, cellY); + + // 4 cell corners (heightmap x-major: Height[x*9 + y]) + float h0 = heightTable[block.Height[cellX * 9 + cellY]]; // BL + float h1 = heightTable[block.Height[(cellX + 1) * 9 + cellY]]; // BR + float h2 = heightTable[block.Height[(cellX + 1) * 9 + (cellY + 1)]]; // TR + float h3 = heightTable[block.Height[cellX * 9 + (cellY + 1)]]; // TL + + float lx = worldX - cellX * CellSize; + float ly = worldY - cellY * CellSize; + float s = lx / CellSize; + float t = ly / CellSize; + + if (splitDirection == AcDream.Core.Terrain.CellSplitDirection.SWtoNE) + { + if (s + t <= 1f) + { + return h0 * (1f - s - t) + h1 * s + h3 * t; + } + else + { + float u = s + t - 1f; + float v = 1f - s; + float w = 1f - u - v; + return h1 * w + h2 * u + h3 * v; + } + } + else // SEtoNW + { + if (s >= t) + { + return h0 * (1f - s) + h1 * (s - t) + h2 * t; + } + else + { + return h0 * (1f - t) + h2 * s + h3 * (t - s); + } + } } private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete) @@ -4614,76 +4639,10 @@ public sealed class GameWindow : IDisposable // fall back to the local bilinear sample. var worldPx = localX + lbOffset.X; var worldPy = localY + lbOffset.Y; - float? maybePhysicsZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy); - float groundZ = maybePhysicsZ + float groundZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy) ?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); float finalZ = groundZ + spawn.LocalPosition.Z; - // Issue #48 diagnostic. One log line per (spawn, rendered-mesh) - // disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2 - // (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0). - // User identifies a floating tree visually, finds the matching - // line by world coords + gfx id, the data picks the hypothesis. - if (s_dumpSceneryZ) - { - string source = maybePhysicsZ.HasValue ? "physics" : "bilinear"; - foreach (var mr in meshRefs) - { - var dgfx = _dats.Get(mr.GfxObjId); - if (dgfx is null) continue; - - float zMin = float.PositiveInfinity, zMax = float.NegativeInfinity; - foreach (var v in dgfx.VertexArray.Vertices.Values) - { - if (v.Origin.Z < zMin) zMin = v.Origin.Z; - if (v.Origin.Z > zMax) zMax = v.Origin.Z; - } - if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; } - - // Per-part transform offset inside the setup (post-spawn-scale). - // For setup spawns this is Setup.PlacementFrames[Default].Frames[i] * - // spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale. - var partT = mr.PartTransform.Translation; - - bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade); - string ddInfo = string.Empty; - if (hasDD && dgfx.DIDDegrade != 0) - { - var ddi = _dats.Get(dgfx.DIDDegrade); - if (ddi is not null && ddi.Degrades.Count > 0) - { - uint slot0Id = (uint)ddi.Degrades[0].Id; - float slot0Min = 0f; - var slot0Gfx = _dats.Get(slot0Id); - if (slot0Gfx is not null && slot0Gfx.VertexArray.Vertices.Count > 0) - { - slot0Min = float.PositiveInfinity; - foreach (var v in slot0Gfx.VertexArray.Vertices.Values) - if (v.Origin.Z < slot0Min) slot0Min = v.Origin.Z; - if (float.IsPositiveInfinity(slot0Min)) slot0Min = 0f; - } - ddInfo = $" deg[0]=0x{slot0Id:X8} deg[0]ZMin={slot0Min:F3}"; - } - } - - // partWorldZMin = the lowest vertex of this part in world space. - // = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex) - // If everything is right and the lowest part of the tree should - // touch the ground, we expect partWorldZMin <= groundZ for at - // least one part of a multi-part setup. - float partWorldZMin = finalZ + partT.Z + zMin; - - Console.WriteLine( - $"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" + - $" source={source}" + - $" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" + - $" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" + - $" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" + - $" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" + - $" hasDIDDegrade={hasDD}{ddInfo}"); - } - } - var hydrated = new AcDream.Core.World.WorldEntity { Id = sceneryIdBase + localIndex++, diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index fe511881..caa54935 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -143,89 +143,23 @@ public sealed class TerrainSurface // 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); - } - - /// - /// 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 + 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 - return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL + 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 } } diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs index c26b214b..7ceda8fd 100644 --- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -67,64 +67,6 @@ public class TerrainSurfaceTests 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(() => - TerrainSurface.SampleZFromHeightmap(null!, goodTable, 0, 0, 0f, 0f)); - Assert.Throws(() => - TerrainSurface.SampleZFromHeightmap(goodHeights, null!, 0, 0, 0f, 0f)); - Assert.Throws(() => - TerrainSurface.SampleZFromHeightmap(new byte[80], goodTable, 0, 0, 0f, 0f)); - Assert.Throws(() => - TerrainSurface.SampleZFromHeightmap(goodHeights, new float[255], 0, 0, 0f, 0f)); - } - [Fact] public void SampleSurfacePolygon_ReturnsContainingTriangleVertices() {