diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index efdd79f..7571a6c 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -282,7 +282,7 @@ public static class SceneryGenerator /// based on which corners are road vertices. Road ribbons have a 5m /// half-width (TileLength - RoadWidth = 19m). /// - private static bool IsOnRoad(LandBlock block, float lx, float ly) + internal static bool IsOnRoad(LandBlock block, float lx, float ly) { int x = (int)MathF.Floor(lx / CellSize); int y = (int)MathF.Floor(ly / CellSize); diff --git a/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs b/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs new file mode 100644 index 0000000..362743d --- /dev/null +++ b/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs @@ -0,0 +1,176 @@ +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using WB_TerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils; +using WB_SceneryHelpers = Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers; + +namespace AcDream.Core.Tests.World; + +/// +/// Phase N.1 helper-level conformance tests. Each test compares an algorithm +/// in our existing path against WorldBuilder's +/// equivalent for representative inputs. Passing tests are empirical evidence +/// that swapping our inline logic for WB's helpers is behavior-preserving. +/// +/// Inputs exercise both typical vertices (gx=100, gy=100, j=0) and edge +/// vertices at y=8 specifically (Issue #49 territory). +/// +/// IMPORTANT: rotation (RotateObj) is intentionally NOT conformance-tested. +/// During Phase N.1 design we discovered our acdream port of retail's +/// AFrame::set_heading uses a shortcut formula `yawDeg = -(450-degrees)%360` +/// that does NOT match retail's actual behavior. Retail goes through an +/// atan2 round-trip in Frame::set_vector_heading (named-retail symbol +/// 0x00535db0); WorldBuilder ports that round-trip faithfully. Our code +/// produces rotations ~180° off from retail/WB. This bug has been visually +/// undetectable because per-tree rotation noise masks the constant offset. +/// Phase N.1's migration to WB.SceneryHelpers.RotateObj fixes it. Adding a +/// conformance test for rotation here would fail forever — it's the bug +/// the migration is meant to close, not something to preserve. +/// +public class SceneryWbConformanceTests +{ + private static ObjectDesc MakeObj( + float displaceX = 12f, + float displaceY = 12f, + float minScale = 1f, + float maxScale = 1f, + float maxRotation = 0f, + float minSlope = 0f, + float maxSlope = 1f, + int align = 0) + { + return new ObjectDesc + { + ObjectId = 0x02000258u, + DisplaceX = displaceX, + DisplaceY = displaceY, + MinScale = minScale, + MaxScale = maxScale, + MaxRotation = maxRotation, + MinSlope = minSlope, + MaxSlope = maxSlope, + Align = align, + BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }, + }; + } + + /// + /// Our DisplaceObject ↔ WB's SceneryHelpers.Displace must produce the + /// same Vector3 for the same (obj, ix, iy, iq). + /// + [Theory] + [InlineData(100u, 100u, 0u)] // typical + [InlineData( 50u, 50u, 1u)] // typical, j=1 + [InlineData( 4u, 8u, 0u)] // edge vertex y=8 + [InlineData( 8u, 4u, 0u)] // edge vertex x=8 + public void Displace_OursMatchesWb(uint ix, uint iy, uint iq) + { + var obj = MakeObj(); + var ours = SceneryGenerator.DisplaceObject(obj, ix, iy, iq); + var wb = WB_SceneryHelpers.Displace(obj, ix, iy, iq); + + Assert.Equal(ours.X, wb.X, precision: 4); + Assert.Equal(ours.Y, wb.Y, precision: 4); + Assert.Equal(ours.Z, wb.Z, precision: 4); + } + + /// + /// Our IsOnRoad ↔ WB's TerrainUtils.OnRoad must produce the same bool + /// for the same (lx, ly) when the underlying terrain bits match. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell (0,0) center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex bug location + [InlineData( 3.0f, 3.0f)] // near a road if r0 is set + [InlineData( 23.5f, 12.0f)] // edge of cell, between cells + public void OnRoad_OursMatchesWb_DiagonalRoad(float lx, float ly) + { + // Build a synthetic LandBlock with road bits at SW (0,0) and NE (1,1) + // of cell (0,0) — the diagonal pattern we saw at 0xA9B1. + var block = new LandBlock(); + // road bit at vertex (0,0) — index 0*9+0 = 0 + block.Terrain[0] = (TerrainInfo)0x0003; // road=3 + // road bit at vertex (1,1) — index 1*9+1 = 10 + block.Terrain[10] = (TerrainInfo)0x0003; + + bool ours = SceneryGenerator.IsOnRoad(block, lx, ly); + + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + bool wb = WB_TerrainUtils.OnRoad(new Vector3(lx, ly, 0), entries); + + Assert.Equal(ours, wb); + } + + /// + /// Our SampleNormalZFromHeightmap ↔ WB's TerrainUtils.GetNormal(...).Z + /// must produce the same Z for representative slope inputs. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex location + [InlineData( 3.0f, 188.0f)] // near a y-edge + public void GetNormalZ_OursMatchesWb_LinearTable(float lx, float ly) + { + // Heightmap with non-flat terrain so normals are non-trivial. + 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 heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 1.0f; + + const uint lbX = 0xA9, lbY = 0xB1; + + // Build a Region-shaped object with the LandHeightTable populated. + var region = new Region(); + region.LandDefs = new LandDefs(); + region.LandDefs.LandHeightTable = heightTable; + + var block = new LandBlock(); + // Copy heights into block.Height (LandBlock self-initializes Height to byte[81]). + for (int i = 0; i < 81; i++) block.Height[i] = heights[i]; + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + + float ours = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap( + heights, heightTable, lbX, lbY, lx, ly); + float wb = WB_TerrainUtils.GetNormal(region, entries, lbX, lbY, + new Vector3(lx, ly, 0)).Z; + + Assert.Equal(ours, wb, precision: 4); + } + + /// + /// Our inline scale logic ↔ WB's SceneryHelpers.ScaleObj must produce + /// the same float for representative inputs. + /// + [Theory] + [InlineData(100u, 100u, 0u, 0.5f, 1.5f)] + [InlineData( 4u, 8u, 0u, 1.0f, 1.0f)] + [InlineData(200u, 250u, 1u, 0.8f, 1.2f)] + public void ScaleObj_OursMatchesWb(uint gx, uint gy, uint j, float minScale, float maxScale) + { + var obj = MakeObj(minScale: minScale, maxScale: maxScale); + + // Our inline logic from SceneryGenerator.Generate (~lines 236-247): + float ours; + if (obj.MinScale == obj.MaxScale) + { + ours = obj.MaxScale; + } + else + { + double scaleNoise = unchecked((uint)(1813693831u * gy + - (j + 32593u) * (1360117743u * gy * gx + 1888038839u) + - 1109124029u * gx)) * 2.3283064e-10; + ours = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); + } + if (ours <= 0) ours = 1f; + + float wb = WB_SceneryHelpers.ScaleObj(obj, gx, gy, j); + if (wb <= 0) wb = 1f; + + Assert.Equal(ours, wb, precision: 4); + } +}