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:
parent
bbc618a40a
commit
4bfcb2b190
2 changed files with 177 additions and 1 deletions
|
|
@ -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);
|
||||
|
|
|
|||
176
tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs
Normal file
176
tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue