acdream/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs
Erik 833d167ebc fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 21:15:11 +02:00

109 lines
4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World;
/// <summary>
/// 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.
/// </summary>
public class SceneryGeneratorTests
{
// Terrain word layout (ushort):
// bits 0-1 = Road (non-zero → on a road)
// bits 2-6 = TerrainType
// bits 11-15 = SceneType
[Theory]
[InlineData(0x0000, false)] // no road bits
[InlineData(0x0001, true)] // road bit 0 set
[InlineData(0x0002, true)] // road bit 1 set
[InlineData(0x0003, true)] // both road bits set
[InlineData(0x007C, false)] // terrain type bits only, no road
[InlineData(0xF800, false)] // scenery bits only, no road
[InlineData(0xF803, true)] // road + scenery bits
public void IsRoadVertex_CorrectlyIdentifiesRoadBits(ushort raw, bool expectedIsRoad)
{
Assert.Equal(expectedIsRoad, SceneryGenerator.IsRoadVertex(raw));
}
[Fact]
public void IsRoadVertex_ZeroTerrain_IsNotRoad()
{
Assert.False(SceneryGenerator.IsRoadVertex(0));
}
[Fact]
public void IsRoadVertex_MatchesTerrainInfoRoadProperty()
{
for (ushort raw = 0; raw < 4; raw++)
{
TerrainInfo ti = raw;
bool expectedFromStruct = ti.Road != 0;
bool actual = SceneryGenerator.IsRoadVertex(raw);
Assert.True(actual == expectedFromStruct,
$"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");
}
}