fix(scenery): #48 unify scenery Z with physics-path triangle picker

Closes #48. Trees on sloped cells visibly hovered above the visible
terrain because GameWindow.SampleTerrainZ (the bilinear fallback used
during scenery hydration before physics registers a landblock) had
its diagonal arms swapped — used the SEtoNW triangle test on SWtoNE
cells and vice versa. The ACDREAM_DUMP_SCENERY_Z=1 diagnostic showed
every scenery line ran through the bilinear path (streaming race),
so on hilly terrain scenery was placed at a Z up to ~1.5 m off from
the visible mesh.

Latent since ff325ab (2026-04-17 "feat(ui): debug overlay + refined
input controls" carrying along the upgrade). That commit reached for
WorldBuilder TerrainUtils.GetHeight as the secondary oracle and
re-derived the triangle-pair tests; the named-retail / ACE algorithm
in 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:
- TerrainSurface.InterpolateZInTriangle (private static) — single
  source of truth for the triangle pick + barycentric Z, sourced
  from FUN_00532a50 / ACE LandblockStruct.ConstructPolygons.
- TerrainSurface.SampleZFromHeightmap (public static) — heightmap-
  byte-array variant for the scenery hydration fallback. Both this
  and TerrainSurface.SampleZ (instance) now delegate to the same
  InterpolateZInTriangle.
- GameWindow.SampleTerrainZ — thin wrapper over the new static.
- TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock
  asserts both sampler paths agree at 1500 sample points across both
  diagonals, so future drift gets caught.

The ACDREAM_DUMP_SCENERY_Z=1 diagnostic in BuildSceneryEntitiesForStreaming
is kept committed (env-var gated, zero cost when off) — useful for
the related #49 scenery (X, Y) placement investigation filed in the
same commit.

Visual verified at Holtburg landblock 0xA9B30001 2026-05-06: the
formerly floating 32 m pines (setups 0x020002D3 / 0x020002D9) now
sit flush on the visible terrain mesh.

Test baseline: dotnet test reports the same 8 pre-existing motion /
BSP step-up failures as the handoff doc warned about — no new
failures introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-07 14:30:25 +02:00
parent c1bb43ab89
commit a4693954d8
5 changed files with 352 additions and 60 deletions

View file

@ -46,9 +46,112 @@ Copy this block when adding a new issue:
# Active issues
## #48 — A few specific scenery trees hover above terrain (per-GfxObj Z misplacement)
## #49 — Scenery (X, Y) placement drifts from retail at some landblocks
**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 · (this commit)] 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

View file

@ -0,0 +1,86 @@
# 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.

View file

@ -2532,62 +2532,28 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// Bilinear sample of the landblock heightmap at (x, y) in landblock-local
/// world units. Matches the x-major indexing convention of LandblockMesh.
/// 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 <c>TerrainSurface</c> for the landblock yet
/// (streaming race). Delegates to
/// <see cref="AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap"/>
/// so this fallback and the player-physics path stay in lock-step on
/// sloped cells.
///
/// <para>
/// 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.
/// </para>
/// </summary>
private float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY)
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
{
// 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;
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);
}
}
return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap(
block.Height, heightTable, landblockX, landblockY, localX, localY);
}
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
@ -4674,6 +4640,11 @@ public sealed class GameWindow : IDisposable
}
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)
@ -4695,12 +4666,20 @@ public sealed class GameWindow : IDisposable
}
}
// 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}" +
$" zRange=[{zMin:F3}..{zMax:F3}] zSpan={zMax - zMin: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}");
}
}

View file

@ -143,23 +143,89 @@ 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);
}
/// <summary>
/// Sample terrain Z directly from a landblock's raw heightmap. Same
/// algorithm as <see cref="SampleZ"/> (instance), but reads the four
/// corner heights through <c>heightTable[heights[x*9+y]]</c> on the fly
/// instead of from the pre-resolved instance cache. Use this when a
/// <see cref="TerrainSurface"/> 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.
///
/// <para>
/// Issue #48 root cause: the previous bilinear fallback in
/// <c>GameWindow.SampleTerrainZ</c> 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.
/// </para>
/// </summary>
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);
}
/// <summary>
/// Pick the cell's triangle for the chosen diagonal and barycentric-
/// interpolate Z. Single source of truth shared by both
/// <see cref="SampleZ"/> (instance, pre-resolved cache) and
/// <see cref="SampleZFromHeightmap"/> (static, raw heightmap).
/// Triangle layout matches ACE <c>LandblockStruct.ConstructPolygons</c>:
/// SWtoNE cells split BL→TR (line y=x), SEtoNW cells split BR→TL
/// (line x+y=1).
/// </summary>
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 triangle
else
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle
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 triangle
else
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL triangle
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL
}
}

View file

@ -67,6 +67,64 @@ 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<ArgumentNullException>(() =>
TerrainSurface.SampleZFromHeightmap(null!, goodTable, 0, 0, 0f, 0f));
Assert.Throws<ArgumentNullException>(() =>
TerrainSurface.SampleZFromHeightmap(goodHeights, null!, 0, 0, 0f, 0f));
Assert.Throws<ArgumentException>(() =>
TerrainSurface.SampleZFromHeightmap(new byte[80], goodTable, 0, 0, 0f, 0f));
Assert.Throws<ArgumentException>(() =>
TerrainSurface.SampleZFromHeightmap(goodHeights, new float[255], 0, 0, 0f, 0f));
}
[Fact]
public void SampleSurfacePolygon_ReturnsContainingTriangleVertices()
{