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);
+ }
+}