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