phase(N.1): add LandBlock → TerrainEntry[] adapter

Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our
LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's
TerrainUtils / SceneryRenderManager consume.

Field mapping (TerrainInfo → TerrainEntry):
  TerrainInfo.Road    (bits 0-1)   → TerrainEntry.Road
  TerrainInfo.Type    (bits 2-6)   → TerrainEntry.Type
  TerrainInfo.Scenery (bits 11-15) → TerrainEntry.Scenery
  LandBlock.Height[i]              → TerrainEntry.Height

The spec listed the texture property as 'Texture' but TerrainEntry's
actual property is named 'Type' (confirmed from source). The spec also
described LandBlock.Terrain as ushort[81] but it is TerrainInfo[81] —
DatReaderWriter already decodes the bit fields so the adapter uses
TerrainInfo's named properties rather than raw bit-shift expressions.

Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 09:11:59 +02:00
parent 21425ffb22
commit 26cf2b84e7
2 changed files with 119 additions and 0 deletions

View file

@ -0,0 +1,74 @@
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World;
/// <summary>
/// Tests for <see cref="WbSceneryAdapter"/>. The adapter converts our
/// LandBlock dat type (Terrain TerrainInfo[81] + Height byte[81]) into
/// WorldBuilder's <see cref="WorldBuilder.Shared.Models.TerrainEntry"/>[81]
/// shape, which WB's TerrainUtils / SceneryRenderManager consume.
///
/// Bit layout in LandBlock.Terrain[i] (TerrainInfo / ushort):
/// bits 0-1 : Road (2 bits) → WB TerrainEntry.Road
/// bits 2-6 : TerrainType (5 bits) → WB TerrainEntry.Type
/// bits 11-15 : SceneType (5 bits) → WB TerrainEntry.Scenery
/// Height comes from LandBlock.Height[i] (byte) → WB TerrainEntry.Height.
/// </summary>
public class WbSceneryAdapterTests
{
[Fact]
public void BuildTerrainEntries_PreservesRoadTextureSceneryHeight()
{
var block = new LandBlock();
// Vertex 0: road=0x3, type=0x00, scenery=0x1F, height=42
// raw layout: bits 0-1=11, bits 2-6=00000, bits 7-10=0000, bits 11-15=11111
// = 0xF803
block.Terrain[0] = (TerrainInfo)0xF803;
block.Height[0] = 42;
// Vertex 80: road=0x0, type=0x1F, scenery=0x00, height=200
// raw layout: bits 0-1=00, bits 2-6=11111, bits 11-15=00000
// = 0x007C
block.Terrain[80] = (TerrainInfo)0x007C;
block.Height[80] = 200;
var entries = WbSceneryAdapter.BuildTerrainEntries(block);
Assert.Equal(81, entries.Length);
Assert.Equal((byte)42, entries[0].Height);
Assert.Equal((byte)0x3, entries[0].Road);
Assert.Equal((byte)0x00, entries[0].Type);
Assert.Equal((byte)0x1F, entries[0].Scenery);
Assert.Equal((byte)200, entries[80].Height);
Assert.Equal((byte)0x0, entries[80].Road);
Assert.Equal((byte)0x1F, entries[80].Type);
Assert.Equal((byte)0x00, entries[80].Scenery);
}
[Fact]
public void BuildTerrainEntries_AllZeros_ProducesEmptyEntries()
{
var block = new LandBlock();
// Terrain and Height are already zero-initialized by LandBlock constructor.
var entries = WbSceneryAdapter.BuildTerrainEntries(block);
Assert.All(entries, e =>
{
Assert.Equal((byte)0, e.Height);
Assert.Equal((byte)0, e.Road);
Assert.Equal((byte)0, e.Type);
Assert.Equal((byte)0, e.Scenery);
});
}
[Fact]
public void BuildTerrainEntries_NullBlock_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
WbSceneryAdapter.BuildTerrainEntries(null!));
}
}