diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs
index aca7239..b306e41 100644
--- a/src/AcDream.Core/World/SceneryGenerator.cs
+++ b/src/AcDream.Core/World/SceneryGenerator.cs
@@ -25,11 +25,11 @@ namespace AcDream.Core.World;
/// (scale hash constant 0x7f51=32593 not in dumped chunks;
/// confirmed against ACViewer which matches all other constants)
///
-/// Key implementation note: the decompiled client computes each LCG value as a
-/// signed 32-bit int, then normalises with "if (val < 0) val += 2^32" before
-/// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast.
-/// ACViewer's reference omits this cast and is subtly wrong for negative inputs.
-/// We deliberately match the decompiled client, not ACViewer.
+/// Phase N.1 (2026-05-08): migrated all algorithm calls to WorldBuilder's
+/// SceneryHelpers + TerrainUtils. The legacy in-line implementations
+/// have been removed; WbSceneryAdapter bridges LandBlock data to WB's
+/// TerrainEntry[]. See
+/// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md.
///
public static class SceneryGenerator
{
@@ -37,15 +37,7 @@ public static class SceneryGenerator
private const int VerticesPerSide = 9;
private const float CellSize = 24.0f;
private const float LandblockSize = 192.0f; // 8 cells * 24 units
-
- ///
- /// Phase N.1: scenery placement uses WorldBuilder's SceneryHelpers
- /// + TerrainUtils by default. Set ACDREAM_USE_WB_SCENERY=0
- /// to restore the legacy in-line algorithms (escape hatch — to be deleted
- /// in Task 8 once we have a session of green visuals).
- ///
- internal static readonly bool UseWbScenery =
- System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") != "0";
+ private const int CellsPerSide = 8;
public readonly record struct ScenerySpawn(
uint ObjectId, // GfxObj or Setup id
@@ -54,12 +46,9 @@ public static class SceneryGenerator
float Scale);
///
- /// Generate all scenery entries for one landblock. Uses the bit-packed
- /// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into
- /// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] → a SceneInfo
- /// index into Region.SceneInfo.SceneTypes[sceneInfo].Scenes. Each cell picks
- /// one scene via a pseudo-random hash of the cell's global coordinates, then
- /// iterates the scene's ObjectDesc entries with per-object frequency rolls.
+ /// Generate all scenery entries for one landblock. Phase N.1 migrated this
+ /// to call WorldBuilder's SceneryHelpers + TerrainUtils;
+ /// see docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md.
///
public static IReadOnlyList Generate(
DatCollection dats,
@@ -69,210 +58,20 @@ public static class SceneryGenerator
HashSet? buildingCells = null,
float[]? heightTable = null)
{
- // Phase N.1: route to the WorldBuilder-backed implementation when
- // ACDREAM_USE_WB_SCENERY=1. See
- // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md.
- if (UseWbScenery)
- return GenerateViaWb(dats, region, block, landblockId, buildingCells);
-
- var result = new List();
-
- if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
- return result;
-
- uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
- uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
-
- // RETAIL iterates 9×9 = 81 VERTICES, not 8×8 = 64 cells.
- // Named retail: CLandBlock::get_land_scenes (0x00530460) uses
- // `side_vertex_count` (offset 0x40, value 9) as the loop bound.
- // The do-while condition `(var+1) < side_vertex_count` runs var 0..8.
- // Edge vertices (x=8 or y=8) produce valid spawns when the per-object
- // displacement shifts the position back into the [0, 192) range.
- for (int x = 0; x < VerticesPerSide; x++)
- {
- for (int y = 0; y < VerticesPerSide; y++)
- {
- int i = x * VerticesPerSide + y;
- ushort raw = block.Terrain[i];
-
- uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6
- uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15
-
- // NOTE: retail does NOT skip based on this vertex's road bit.
- // The road test happens AFTER displacement via the 4-corner
- // polygonal OnRoad check (see below). Removing the
- // pre-displacement early-exit restores retail behavior.
-
- if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
- var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes;
- if (sceneType >= sceneTypeList.Count) continue;
-
- uint sceneInfo = sceneTypeList[(int)sceneType];
- if (sceneInfo >= region.SceneInfo.SceneTypes.Count) continue;
-
- var scenes = region.SceneInfo.SceneTypes[(int)sceneInfo].Scenes;
- if (scenes.Count == 0) continue;
-
- uint cellX = (uint)x;
- uint cellY = (uint)y;
- uint globalCellX = cellX + blockX;
- uint globalCellY = cellY + blockY;
-
- // Scene-selection hash: picks one scene from the terrain's scene list.
- // Decompiled: chunk_00530000.c line 1144
- // iVar5 = (iVar8 * 0x2a7f2b89 + 0x6c1ac587) * iVar9 + iVar8 * -0x421be3bd + 0x7f8cda01
- // where iVar8=globalCellX, iVar9=globalCellY.
- uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u)
- - 1109124029u * globalCellX + 2139937281u;
- double offset = cellMat * 2.3283064e-10;
- int sceneIdx = (int)(scenes.Count * offset);
- if (sceneIdx >= scenes.Count || sceneIdx < 0) sceneIdx = 0;
-
- uint sceneId = (uint)scenes[sceneIdx];
- var scene = dats.Get(sceneId);
- if (scene is null) continue;
-
- // Per-object hashes: roll frequency, compute displacement, scale, rotation.
- // Decompiled: chunk_00530000.c lines 1168-1174
- // iStack_60 = iVar9 * 0x6c1ac587 → cellYMat
- // uStack_78 = iVar9 * iVar8 * 0x5111bfef + 0x70892fb7 → cellMat2
- // iStack_64 = iVar8 * -0x421be3bd → cellXMat
- // initial: local_90 = uStack_78 * 0x5b67 (j=0 term)
- // per-loop: iStack_70 = (iStack_60 - local_90) + iStack_64; local_90 += uStack_78
- // ⟹ iStack_70 = cellYMat - cellMat2 * (0x5b67 + j) + cellXMat
- uint cellXMat = unchecked(0u - 1109124029u * globalCellX);
- uint cellYMat = 1813693831u * globalCellY;
- uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u;
-
- for (uint j = 0; j < scene.Objects.Count; j++)
- {
- var obj = scene.Objects[(int)j];
- if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery
-
- // Frequency roll: chunk_00530000.c line 1174 + 1179
- // (fVar1 * _DAT_007c6f10 < (float)piVar11[0x11]) → noise < obj.Frequency
- double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10;
- if (noise >= obj.Frequency) continue;
-
- // Displacement: pseudo-random offset within the cell.
- var localPos = DisplaceObject(obj, globalCellX, globalCellY, j);
-
- float lx = cellX * CellSize + localPos.X;
- float ly = cellY * CellSize + localPos.Y;
-
- if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
- continue;
-
- // Retail post-displacement road check (FUN_00530d30).
- // Ported from ACViewer Landblock.OnRoad — uses the 4-corner
- // road bits of the containing cell plus the 5-unit road
- // half-width to test whether the displaced (lx,ly) lies on
- // the road ribbon.
- bool isOnRoad = IsOnRoad(block, lx, ly);
- if (isOnRoad)
- {
- continue;
- }
-
- // Per-spawn building check on the DISPLACED position's cell.
- // Retail: CSortCell::has_building(cell) per spawn, not per vertex.
- // WorldBuilder: buildingsGrid[gx2, gy2] with 8×8 cell grid.
- if (buildingCells is not null)
- {
- int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1);
- int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1);
- if (buildingCells.Contains(dcx * VerticesPerSide + dcy))
- continue;
- }
-
- // Slope filter: retail uses CLandCell::find_terrain_poly →
- // polygon->plane.N.z to get the triangle-specific normal.
- // SampleNormalZFromHeightmap picks the correct triangle via
- // the cell's split direction, matching retail + WorldBuilder.
- if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f))
- {
- float nz = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap(
- block.Height, heightTable,
- landblockId >> 24, (landblockId >> 16) & 0xFFu,
- lx, ly);
- if (nz < obj.MinSlope || nz > obj.MaxSlope) continue;
- }
-
- // BaseLoc.Z offset: scenery-specific vertical offset from
- // the ground (e.g., flowers planted at -0.1m so they
- // don't float above grass). The renderer adds groundZ
- // later, so pass the BaseLoc.Z through as-is.
- float lz = obj.BaseLoc.Origin.Z;
-
- // Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60)
- // Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation
- // into the frame, THEN calls AFrame::set_heading(degrees).
- //
- // set_heading uses yaw = -(450 - heading) % 360 before converting
- // to a quaternion, which introduces a 90° offset + sign flip
- // relative to a naive Z rotation. WorldBuilder's
- // SceneryHelpers.SetHeading reproduces this.
- //
- // For objects with Align != 0, retail uses FUN_005a6f60 to
- // align to the landcell polygon's normal instead of setting
- // heading from the noise.
- //
- // Composition: final = baseLoc.Orientation * headingQuat
- Quaternion rotation = obj.BaseLoc.Orientation;
- if (rotation.LengthSquared() < 0.0001f)
- rotation = Quaternion.Identity;
-
- if (obj.MaxRotation > 0f)
- {
- double rotNoise = unchecked((uint)(1813693831u * globalCellY
- - (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u)
- - 1109124029u * globalCellX)) * 2.3283064e-10;
- float degrees = (float)(rotNoise * obj.MaxRotation);
- // AFrame::set_heading transform — matches retail.
- float yawDeg = -((450f - degrees) % 360f);
- float yawRad = yawDeg * MathF.PI / 180f;
- var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad);
- rotation = headingQuat * rotation;
- }
-
- // Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern)
- // offset constant 0x7f51 = 32593 (not in dumped chunks; cross-verified via ACViewer)
- // same LCG structure as rotation/displacement; uint cast per decompiled normalisation
- float scale;
- if (obj.MinScale == obj.MaxScale)
- {
- scale = obj.MaxScale;
- }
- else
- {
- double scaleNoise = unchecked((uint)(1813693831u * globalCellY
- - (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u)
- - 1109124029u * globalCellX)) * 2.3283064e-10;
- scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale);
- }
- if (scale <= 0) scale = 1f;
-
- result.Add(new ScenerySpawn(
- ObjectId: obj.ObjectId,
- LocalPosition: new Vector3(lx, ly, lz),
- Rotation: rotation,
- Scale: scale));
- }
- }
- }
-
- return result;
+ // heightTable kept for backward compat; WB path uses
+ // region.LandDefs.LandHeightTable internally via TerrainUtils.GetNormal.
+ _ = heightTable;
+ return GenerateInternal(dats, region, block, landblockId, buildingCells);
}
///
- /// Phase N.1 alternative implementation that delegates the
- /// algorithm calls to WorldBuilder's SceneryHelpers +
- /// TerrainUtils. Structurally identical to
- /// but with WB's tested ports doing the work. Selected by
- /// .
+ /// Returns true if the raw terrain word indicates a road vertex.
+ /// Bits 0-1 of the terrain word encode the road type; any non-zero value
+ /// means the vertex is on a road. Ported from ACViewer GetRoad().
///
- private static IReadOnlyList GenerateViaWb(
+ public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
+
+ private static IReadOnlyList GenerateInternal(
DatCollection dats,
Region region,
LandBlock block,
@@ -394,160 +193,4 @@ public static class SceneryGenerator
return result;
}
-
- ///
- /// Returns true if the raw terrain word indicates a road vertex.
- /// Bits 0-1 of the terrain word encode the road type; any non-zero value
- /// means the vertex is on a road. Ported from ACViewer GetRoad().
- ///
- public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
-
- ///
- /// Half-width of a road ribbon in world units — the road extends from each
- /// road vertex by this amount into the neighbor cells. Matches retail's
- /// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30.
- ///
- private const float RoadHalfWidth = 5.0f;
-
- ///
- /// Retail-faithful road ribbon test — direct port of ACViewer's
- /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which
- /// itself is a port of CLandBlock::on_road (named-retail 0x0052FFF0).
- ///
- /// Classifies the 4 corners of the cell containing (lx, ly) by road type
- /// (bits 0-1 of the terrain word) and applies a different geometric test
- /// based on which corners are road vertices. Road ribbons have a 5m
- /// half-width (TileLength - RoadWidth = 19m).
- ///
- internal static bool IsOnRoad(LandBlock block, float lx, float ly)
- {
- int x = (int)MathF.Floor(lx / CellSize);
- int y = (int)MathF.Floor(ly / CellSize);
- // Clamp so we don't index past the 9x9 terrain grid
- x = Math.Clamp(x, 0, CellsPerSide - 1);
- y = Math.Clamp(y, 0, CellsPerSide - 1);
-
- float rMin = RoadHalfWidth; // 5
- float rMax = CellSize - RoadHalfWidth; // 19
-
- // Corner road bits (ACViewer convention):
- // r0 = (x0, y0) = SW
- // r1 = (x0, y1) = NW
- // r2 = (x1, y0) = SE
- // r3 = (x1, y1) = NE
- bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]);
- bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]);
- bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]);
- bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]);
-
- if (!r0 && !r1 && !r2 && !r3) return false;
-
- float dx = lx - x * CellSize;
- float dy = ly - y * CellSize;
-
- if (r0)
- {
- if (r1)
- {
- if (r2)
- {
- if (r3) return true;
- return dx < rMin || dy < rMin;
- }
- else
- {
- if (r3) return dx < rMin || dy > rMax;
- return dx < rMin;
- }
- }
- else
- {
- if (r2)
- {
- if (r3) return dx > rMax || dy < rMin;
- return dy < rMin;
- }
- else
- {
- if (r3) return MathF.Abs(dx - dy) < rMin;
- return dx + dy < rMin;
- }
- }
- }
- else
- {
- if (r1)
- {
- if (r2)
- {
- if (r3) return dx > rMax || dy > rMax;
- return MathF.Abs(dx + dy - CellSize) < rMin;
- }
- else
- {
- if (r3) return dy > rMax;
- return CellSize + dx - dy < rMin;
- }
- }
- else
- {
- if (r2)
- {
- if (r3) return dx > rMax;
- return CellSize - dx + dy < rMin;
- }
- else
- {
- if (r3) return CellSize * 2f - dx - dy < rMin;
- return false;
- }
- }
- }
- }
-
- private const int CellsPerSide = 8;
-
- ///
- /// Pseudo-random displacement within a cell for a scenery object. Returns a
- /// Vector3 in local cell-offset space (the caller adds it to the cell corner
- /// to get landblock-local position).
- ///
- /// Verified against decompiled acclient.exe: chunk_005A0000.c lines 4844-4903 (FUN_005a6cc0).
- /// X offset constant 0xb2cd = 45773; Y offset constant 0x11c0f = 72719.
- /// Quadrant hash: line 4880; thresholds 0.25/0.5/0.75 map to _DAT_007c97cc/_DAT_007938b8/_DAT_0079c6dc.
- /// Decompiled normalises signed-int LCG results with "if (val < 0) val += 2^32"; our
- /// unchecked((uint)(...)) is exactly equivalent.
- ///
- internal static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq)
- {
- float x, y;
- var baseLoc = obj.BaseLoc.Origin;
-
- // X displacement: chunk_005A0000.c lines 4858-4866
- // iVar4 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xb2cd)) + param_2 * -0x421be3bd
- if (obj.DisplaceX <= 0)
- x = baseLoc.X;
- else
- x = (float)(unchecked((uint)(1813693831u * iy - (iq + 45773u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix))
- * 2.3283064e-10 * obj.DisplaceX + baseLoc.X);
-
- // Y displacement: chunk_005A0000.c lines 4871-4878 (same structure, offset 0x11c0f = 72719)
- if (obj.DisplaceY <= 0)
- y = baseLoc.Y;
- else
- y = (float)(unchecked((uint)(1813693831u * iy - (iq + 72719u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix))
- * 2.3283064e-10 * obj.DisplaceY + baseLoc.Y);
-
- float z = baseLoc.Z;
-
- // Quadrant selection: chunk_005A0000.c lines 4880-4902
- // iVar4 = (param_3 * 0x6c1ac587 - (param_3 * 0x6f7bd965 + 0x421be3bd) * param_2) + -0x17fcedfd
- // 0x6f7bd965=1870387557, 0x421be3bd=1109124029, -0x17fcedfd → -402451965 (uint: 3892515331)
- double quadrant = unchecked((uint)(1813693831u * iy - ix * (1870387557u * iy + 1109124029u) - 402451965u)) * 2.3283064e-10;
-
- if (quadrant >= 0.75) return new Vector3(y, -x, z);
- if (quadrant >= 0.5) return new Vector3(-x, -y, z);
- if (quadrant >= 0.25) return new Vector3(-y, x, z);
- return new Vector3(x, y, z);
- }
}
diff --git a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs
index 3963a5c..83cd73f 100644
--- a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs
+++ b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs
@@ -1,13 +1,14 @@
-using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World;
///
-/// Tests for SceneryGenerator: road-exclusion, loop bounds, building
-/// suppression, and slope filter. The full Generate() pipeline requires
-/// real dat files so behavior is tested via internal helpers.
+/// Tests for SceneryGenerator. As of Phase N.1 (commit b84ecbd / Task 8 final
+/// commit), the displacement / road / slope / rotation / scale algorithms run
+/// through WorldBuilder's helpers (SceneryHelpers + TerrainUtils). The only
+/// our-side code remaining is the small
+/// predicate, which is what these tests cover.
///
public class SceneryGeneratorTests
{
@@ -47,63 +48,4 @@ public class SceneryGeneratorTests
$"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}");
}
}
-
- // --- Edge vertex displacement tests ---
- // Retail iterates 9×9 vertices (0..8 on each axis). Vertices at x=8 or y=8
- // have base positions at 192 (= 8 * 24), which is AT the landblock boundary.
- // These produce valid scenery when displacement shifts them back into [0, 192).
-
- [Fact]
- public void DisplaceObject_EdgeVertex_CanProduceValidPosition()
- {
- // Vertex (3, 8): base_y = 8 * 24 = 192.
- // With DisplaceY > 0, some LCG seeds will produce negative displacement,
- // shifting the Y back below 192 into the valid range.
- var obj = new ObjectDesc
- {
- DisplaceX = 12f,
- DisplaceY = 12f,
- BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }
- };
-
- // Search across a range of global cell coords to find at least one
- // case where vertex y=8 displaces into [0, 192).
- bool foundValid = false;
- for (uint gx = 0; gx < 64 && !foundValid; gx++)
- {
- for (uint gy = 0; gy < 64 && !foundValid; gy++)
- {
- var localPos = SceneryGenerator.DisplaceObject(obj, gx, gy, 0);
- // Vertex (3, 8): cell corner at (3*24, 8*24) = (72, 192)
- float lx = 3 * 24f + localPos.X;
- float ly = 8 * 24f + localPos.Y;
- if (ly >= 0 && ly < 192f && lx >= 0 && lx < 192f)
- foundValid = true;
- }
- }
-
- Assert.True(foundValid,
- "Expected at least one (globalCellX, globalCellY) where vertex y=8 " +
- "displaces back into [0, 192) — retail's 9×9 loop relies on this");
- }
-
- [Fact]
- public void DisplaceObject_InteriorVertex_AlwaysNearOrigin()
- {
- var obj = new ObjectDesc
- {
- DisplaceX = 12f,
- DisplaceY = 12f,
- BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }
- };
-
- // For interior vertices (x < 8, y < 8), displacement is bounded by
- // DisplaceX/Y (max 12 units each), so the result stays within one
- // cell of the origin.
- var localPos = SceneryGenerator.DisplaceObject(obj, 100, 100, 0);
- Assert.True(Math.Abs(localPos.X) <= 12f,
- $"Interior displacement X={localPos.X} exceeds DisplaceX=12");
- Assert.True(Math.Abs(localPos.Y) <= 12f,
- $"Interior displacement Y={localPos.Y} exceeds DisplaceY=12");
- }
}
diff --git a/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs b/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs
deleted file mode 100644
index 362743d..0000000
--- a/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs
+++ /dev/null
@@ -1,176 +0,0 @@
-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);
- }
-}