phase(N.1): per-helper conformance tests for WB substitutions (rotation excluded)

Phase N.1 step 3: prove our inline algorithms match WorldBuilder's
helpers for representative inputs including the 0xA9B1 edge-vertex case.

Four conformance tests pass: Displace, OnRoad, GetNormalZ, ScaleObj.
Our hand-ported algorithms match WB's helpers exactly for these.

Rotation is intentionally NOT conformance-tested. Investigation against
retail's Frame::set_heading (named-retail 0x00535e40) and
Frame::set_vector_heading (0x00535db0) showed our acdream port uses a
shortcut formula `yawDeg = -(450-degrees)%360` that diverges from
retail's atan2 round-trip by ~180°. WorldBuilder's SetHeading ports
the round-trip faithfully and matches retail. Our existing port is
wrong — undetectable visually because per-tree rotation noise masks
the offset. The migration to WB.SceneryHelpers.RotateObj fixes this
bug; adding a conformance test would lock in the wrong behavior.

Bumps IsOnRoad to internal so the OnRoad conformance test can call it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 09:53:00 +02:00
parent bbc618a40a
commit 4bfcb2b190
2 changed files with 177 additions and 1 deletions

View file

@ -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).
/// </summary>
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);

View file

@ -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;
/// <summary>
/// Phase N.1 helper-level conformance tests. Each test compares an algorithm
/// in our existing <see cref="SceneryGenerator"/> 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.
/// </summary>
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) },
};
}
/// <summary>
/// Our DisplaceObject ↔ WB's SceneryHelpers.Displace must produce the
/// same Vector3 for the same (obj, ix, iy, iq).
/// </summary>
[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);
}
/// <summary>
/// Our IsOnRoad ↔ WB's TerrainUtils.OnRoad must produce the same bool
/// for the same (lx, ly) when the underlying terrain bits match.
/// </summary>
[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);
}
/// <summary>
/// Our SampleNormalZFromHeightmap ↔ WB's TerrainUtils.GetNormal(...).Z
/// must produce the same Z for representative slope inputs.
/// </summary>
[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);
}
/// <summary>
/// Our inline scale logic ↔ WB's SceneryHelpers.ScaleObj must produce
/// the same float for representative inputs.
/// </summary>
[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);
}
}