From 8ee76deefd473f790d44d7108a0f686447461d8c Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 6 May 2026 17:57:10 +0200 Subject: [PATCH 1/3] diag(scenery): #48 ACDREAM_DUMP_SCENERY_Z dump Per-spawn / per-rendered-mesh log line at scenery hydration: rendered gfx id, sample source (physics vs bilinear), groundZ, BaseLoc.Z, finalZ, mesh vertex Z range, and DIDDegrade slot 0 metadata. One log line lets the user identify a floating tree by world coords and the data picks the hypothesis (BaseLoc.Z addition / sampler drift / DIDDegrade selection). Diagnostic-first per CLAUDE.md before the fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 64 ++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 21e72ea..70e6d9d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -183,6 +183,15 @@ public sealed class GameWindow : IDisposable private static readonly bool s_retailCloseDegrades = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "1", 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 @@ -4639,10 +4648,63 @@ public sealed class GameWindow : IDisposable // fall back to the local bilinear sample. var worldPx = localX + lbOffset.X; var worldPy = localY + lbOffset.Y; - float groundZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy) + float? maybePhysicsZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy); + float groundZ = maybePhysicsZ ?? 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; } + + 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}"; + } + } + + 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}" + + $" hasDIDDegrade={hasDD}{ddInfo}"); + } + } + var hydrated = new AcDream.Core.World.WorldEntity { Id = sceneryIdBase + localIndex++, From a4693954d8acbe91f40b8c687993d001b703baf3 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 7 May 2026 14:30:25 +0200 Subject: [PATCH 2/3] fix(scenery): #48 unify scenery Z with physics-path triangle picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/ISSUES.md | 105 +++++++++++++++++- .../2026-05-06-issue-48-fix-pseudocode.md | 86 ++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 85 ++++++-------- src/AcDream.Core/Physics/TerrainSurface.cs | 78 ++++++++++++- .../Physics/TerrainSurfaceTests.cs | 58 ++++++++++ 5 files changed, 352 insertions(+), 60 deletions(-) create mode 100644 docs/research/2026-05-06-issue-48-fix-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7c18f3a..e0546c2 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/docs/research/2026-05-06-issue-48-fix-pseudocode.md b/docs/research/2026-05-06-issue-48-fix-pseudocode.md new file mode 100644 index 0000000..c613711 --- /dev/null +++ b/docs/research/2026-05-06-issue-48-fix-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 612a9ae..b00345d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2532,62 +2532,28 @@ public sealed class GameWindow : IDisposable } /// - /// 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 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. + /// /// - 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}"); } } diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index caa5493..fe51188 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -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); + } + + /// + /// 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 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 } } diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs index 7ceda8f..c26b214 100644 --- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -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(() => + 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() { From ab1ba5e0e2dd4af9b2a1986bc50ae66b753cf09b Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 7 May 2026 14:30:37 +0200 Subject: [PATCH 3/3] docs(issues): pin #48 SHA in ISSUES.md Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e0546c2..87c7b2d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -116,7 +116,7 @@ precision) on at least one landblock end-to-end. --- -## #48 — [DONE 2026-05-06 · (this commit)] A few specific scenery trees hover above terrain (per-GfxObj Z misplacement) +## #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