acdream/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md
Erik 21425ffb22 plan(N.1): scenery via WorldBuilder helpers — implementation plan
Bite-sized TDD plan for Phase N.1. Eight tasks:
1. WbSceneryAdapter (LandBlock → TerrainEntry[] adapter)
2. ACDREAM_USE_WB_SCENERY feature flag scaffold
3. Per-helper conformance tests (Displace / OnRoad / GetNormalZ / RotateObj / ScaleObj)
4. Implement GenerateViaWb alternative path
5. Wire feature-flag dispatch in Generate()
6. Visual verification at landblock 0xA9B1 (manual)
7. Flip flag default-on
8. Delete legacy code paths + mark roadmap shipped

Each task has explicit code blocks, exact dotnet commands, expected
output, and a commit instruction. Conformance tests prove substitution
is behavior-preserving before the dispatch is wired in.

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>
2026-05-08 09:05:53 +02:00

45 KiB
Raw Blame History

Phase N.1 — Scenery via WorldBuilder Helpers Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the in-line algorithm guts of SceneryGenerator.Generate() with calls to WorldBuilder's SceneryHelpers (Displace / RotateObj / ScaleObj / CheckSlope / ObjAlign) and TerrainUtils (OnRoad / GetNormal). Keep our data flow, our ScenerySpawn shape, and our renderer integration. Place behind a ACDREAM_USE_WB_SCENERY=1 feature flag, prove equivalence with helper-level conformance tests, then flip default-on after visual verification at landblock 0xA9B1.

Architecture: Strangler-fig substitution. The 9×9 vertex loop, scene-selection hash, frequency roll, bounds check, building grid, and BaseLoc.Z handling stay identical. Only the five algorithm calls (displacement, road test, slope normal, rotation, scale) get replaced. A small adapter WbSceneryAdapter.BuildTerrainEntries(LandBlock) produces the TerrainEntry[] shape WB's helpers expect.

Tech Stack: .NET 10 / C# 13, Silk.NET (transitively), WorldBuilder.Shared + Chorizite.OpenGLSDLBackend (project references already wired up in Phase N.0), DatReaderWriter for LandBlock / Region / ObjectDesc / Scene types, xUnit for tests.

Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md Parent design: docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md Inventory: docs/architecture/worldbuilder-inventory.md

Prerequisite: Phase N.0 already shipped (commit c8782c9) — references/WorldBuilder/ is a git submodule pointing at github.com/eriknihlen/WorldBuilder.git acdream branch; AcDream.Core.csproj references WorldBuilder.Shared + Chorizite.OpenGLSDLBackend.


File Plan

File Disposition Responsibility
src/AcDream.Core/World/WbSceneryAdapter.cs NEW LandBlock (acdream's dat type) → TerrainEntry[] (WB's data shape). Stateless.
src/AcDream.Core/World/SceneryGenerator.cs MODIFY Add UseWbScenery feature-flag flag. Add GenerateViaWb private alternative path. Wire dispatch in Generate(). Keep legacy methods for now — deleted in final task.
tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs NEW Verify field bit-packing round-trips (Road, Type, Scenery, Height) and bounds-check behavior.
tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs NEW Five small tests proving WB's Displace/OnRoad/GetNormal/RotateObj/ScaleObj match our existing inline logic for representative inputs.
tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs MODIFY After Task 7 (legacy delete), prune the now-irrelevant DisplaceObject_* tests and update class doc.

Why split adapter into its own file: the adapter is a pure stateless utility. Putting it in its own file keeps SceneryGenerator.cs focused on the generator algorithm, and makes the adapter trivially reusable in N.2+ (terrain math helpers will need the same TerrainEntry[]).

Why combine conformance tests in one file: all five tests share the same imports and fixtures, and they all measure the same thing (helper equivalence). Splitting would be over-decomposed.


Task 1: LandBlock → TerrainEntry[] adapter

Files:

  • Create: src/AcDream.Core/World/WbSceneryAdapter.cs

  • Test: tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs

  • Step 1.1: Write the failing test

Create tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs:

using AcDream.Core.World;
using DatReaderWriter.DBObjs;

namespace AcDream.Core.Tests.World;

/// <summary>
/// Tests for <see cref="WbSceneryAdapter"/>. The adapter converts our
/// LandBlock dat type (Terrain ushort[81] + Height byte[81]) into
/// WorldBuilder's <see cref="WorldBuilder.Shared.Models.TerrainEntry"/>[81]
/// shape, which WB's TerrainUtils / SceneryRenderManager consume.
///
/// Bit layout in our LandBlock.Terrain[i] (ushort):
///   bits 0-1   : Road        (2 bits, ACViewer convention)
///   bits 2-6   : TerrainType (5 bits) → WB calls this Texture
///   bits 11-15 : SceneType   (5 bits) → WB calls this Scenery
/// Height comes from LandBlock.Height[i] (byte).
/// </summary>
public class WbSceneryAdapterTests
{
    [Fact]
    public void BuildTerrainEntries_PreservesRoadTextureSceneryHeight()
    {
        var block = new LandBlock
        {
            Terrain = new ushort[81],
            Height  = new byte[81],
        };

        // 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] = 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] = 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].Texture);
        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].Texture);
        Assert.Equal((byte)0x00, entries[80].Scenery);
    }

    [Fact]
    public void BuildTerrainEntries_AllZeros_ProducesEmptyEntries()
    {
        var block = new LandBlock
        {
            Terrain = new ushort[81],
            Height  = new byte[81],
        };
        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.Texture);
            Assert.Equal((byte)0, e.Scenery);
        });
    }

    [Fact]
    public void BuildTerrainEntries_NullBlock_Throws()
    {
        Assert.Throws<ArgumentNullException>(() =>
            WbSceneryAdapter.BuildTerrainEntries(null!));
    }
}
  • Step 1.2: Run the test to verify it fails

Run: dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "WbSceneryAdapterTests" 2>&1 | tail -10 Expected: BUILD ERROR or FAIL with "type or namespace 'WbSceneryAdapter' could not be found".

  • Step 1.3: Implement the adapter

Create src/AcDream.Core/World/WbSceneryAdapter.cs:

using DatReaderWriter.DBObjs;
using WorldBuilder.Shared.Models;

namespace AcDream.Core.World;

/// <summary>
/// Bridges acdream's dat types into WorldBuilder's data shapes for the
/// Phase N rendering migration. See
/// <c>docs/architecture/worldbuilder-inventory.md</c> for the full strategy.
/// </summary>
internal static class WbSceneryAdapter
{
    private const int VerticesPerSide = 9;
    private const int TerrainSize     = VerticesPerSide * VerticesPerSide; // 81

    /// <summary>
    /// Builds a 9×9 = 81-entry <see cref="TerrainEntry"/> array from a
    /// <see cref="LandBlock"/>'s packed terrain bits + height bytes. WB's
    /// <c>TerrainUtils.OnRoad</c> / <c>GetNormal</c> / <c>GetHeight</c>
    /// consume this shape.
    ///
    /// Bit layout in our <c>LandBlock.Terrain[i]</c> (ushort):
    ///   bits 0-1   : Road        (2 bits) → WB Road
    ///   bits 2-6   : TerrainType (5 bits) → WB Texture
    ///   bits 11-15 : SceneType   (5 bits) → WB Scenery
    /// Height comes from <c>LandBlock.Height[i]</c> (byte).
    /// </summary>
    public static TerrainEntry[] BuildTerrainEntries(LandBlock block)
    {
        ArgumentNullException.ThrowIfNull(block);
        if (block.Terrain.Length != TerrainSize)
            throw new ArgumentException(
                $"LandBlock.Terrain must be {TerrainSize} entries (9×9), got {block.Terrain.Length}",
                nameof(block));
        if (block.Height.Length != TerrainSize)
            throw new ArgumentException(
                $"LandBlock.Height must be {TerrainSize} entries (9×9), got {block.Height.Length}",
                nameof(block));

        var entries = new TerrainEntry[TerrainSize];
        for (int i = 0; i < TerrainSize; i++)
        {
            ushort raw = block.Terrain[i];
            byte road    = (byte)(raw & 0x3);
            byte texture = (byte)((raw >> 2) & 0x1F);
            byte scenery = (byte)((raw >> 11) & 0x1F);
            byte height  = block.Height[i];
            entries[i] = new TerrainEntry(height, texture, scenery, road, encounters: null);
        }
        return entries;
    }
}
  • Step 1.4: Run the test to verify it passes

Run: dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "WbSceneryAdapterTests" 2>&1 | tail -5 Expected: Passed! - Failed: 0, Passed: 3 (or similar — three tests).

  • Step 1.5: Commit
git add src/AcDream.Core/World/WbSceneryAdapter.cs tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs
git commit -m "$(cat <<'EOF'
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.

Bit-pack mapping (ours → WB):
  Terrain bits 0-1   (Road)        → TerrainEntry.Road
  Terrain bits 2-6   (TerrainType) → TerrainEntry.Texture
  Terrain bits 11-15 (SceneType)   → TerrainEntry.Scenery
  Height byte                      → TerrainEntry.Height

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>
EOF
)"

Task 2: Feature flag scaffold

Files:

  • Modify: src/AcDream.Core/World/SceneryGenerator.cs

This task only adds the flag-read field. It changes no behavior and there is nothing to assert beyond "it compiles." The flag is consumed in Task 5.

  • Step 2.1: Add the feature flag field

Open src/AcDream.Core/World/SceneryGenerator.cs. Find the line:

    // AC landblock geometry — matches LandblockMesh.
    private const int VerticesPerSide = 9;
    private const float CellSize = 24.0f;
    private const float LandblockSize = 192.0f;  // 8 cells * 24 units

Immediately AFTER the LandblockSize constant, ADD:


    /// <summary>
    /// Phase N.1 feature flag — when set to "1", scenery placement uses
    /// WorldBuilder's <c>SceneryHelpers</c> + <c>TerrainUtils</c> instead of
    /// our hand-ported algorithms. Default off until visual verification at
    /// landblock 0xA9B1 confirms behavior. See
    /// <c>docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md</c>.
    /// </summary>
    internal static readonly bool UseWbScenery =
        System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1";
  • Step 2.2: Verify build still passes

Run: dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5 Expected: Build succeeded. and 0 Error(s).

  • Step 2.3: Verify all existing tests still pass

Run: dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|Wb" 2>&1 | tail -3 Expected: Passed! with all scenery-area tests passing.

  • Step 2.4: Commit
git add src/AcDream.Core/World/SceneryGenerator.cs
git commit -m "$(cat <<'EOF'
phase(N.1): add ACDREAM_USE_WB_SCENERY feature flag scaffold

Phase N.1 step 2: read the env var into a static bool. No behavior
change yet — the flag is consumed in step 5 when GenerateViaWb is
wired in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: Per-helper conformance tests

Files:

  • Create: tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs

These five tests prove WB's SceneryHelpers.Displace / TerrainUtils.OnRoad / TerrainUtils.GetNormal / SceneryHelpers.RotateObj / SceneryHelpers.ScaleObj produce the same answers as the hand-ported logic currently inlined in Generate(). After this task, we have empirical evidence that the substitution is safe.

If a test fails, that is the bug — investigate before proceeding to Task 4.

  • Step 3.1: Write all five conformance tests

Create tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs:

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.
///
/// If any of these fails the substitution would silently change rendered
/// scenery; investigate before proceeding to Task 4 (GenerateViaWb).
///
/// Inputs are chosen to exercise:
///   - A non-edge vertex (gx=100, gy=100, j=0) — typical case
///   - The edge vertex at y=8 specifically (Issue #49 territory)
/// </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       = (uint)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
        {
            Terrain = new ushort[81],
            Height  = new byte[81],
        };
        // road bit at vertex (0,0) — index 0*9+0 = 0
        block.Terrain[0]   = 0x0003; // road=3
        // road bit at vertex (1,1) — index 1*9+1 = 10
        block.Terrain[10]  = 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.
        // (TerrainUtils.GetNormal calls region.LandDefs.LandHeightTable[height].)
        // LandHeightTable is float[] (size 256) in DatReaderWriter — see
        // src/AcDream.App/Rendering/GameWindow.cs:1306-1308 for the runtime check.
        var region = new DatReaderWriter.DBObjs.Region
        {
            LandDefs = new LandDefs(),
        };
        // If LandDefs default-initializes LandHeightTable to a non-null float[256],
        // copy into it. If it's null, assign directly. The implementer should
        // pick whichever pattern compiles in DatReaderWriter 2.1.7's API:
        //   Option A: region.LandDefs.LandHeightTable = heightTable;
        //   Option B: Array.Copy(heightTable, region.LandDefs.LandHeightTable, 256);
        region.LandDefs.LandHeightTable = heightTable;

        var block = new LandBlock
        {
            Terrain = new ushort[81],
            Height  = heights,
        };
        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 rotation logic ↔ WB's SceneryHelpers.RotateObj must
    /// produce the same Quaternion for non-Align objects with MaxRotation.
    /// </summary>
    [Theory]
    [InlineData( 100u, 100u, 0u, 360f)]
    [InlineData(   4u,   8u, 0u, 360f)]
    [InlineData( 200u, 250u, 1u, 180f)]
    public void RotateObj_OursMatchesWb_NonAlign(uint gx, uint gy, uint j, float maxRot)
    {
        var obj = MakeObj(maxRotation: maxRot);

        // Our inline logic from SceneryGenerator.Generate (~lines 220-231):
        Quaternion ours = obj.BaseLoc.Orientation;
        if (ours.LengthSquared() < 0.0001f) ours = Quaternion.Identity;
        if (obj.MaxRotation > 0f)
        {
            double rotNoise = unchecked((uint)(1813693831u * gy
                - (j + 63127u) * (1360117743u * gy * gx + 1888038839u)
                - 1109124029u * gx)) * 2.3283064e-10;
            float degrees = (float)(rotNoise * obj.MaxRotation);
            float yawDeg = -((450f - degrees) % 360f);
            float yawRad = yawDeg * MathF.PI / 180f;
            var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad);
            ours = headingQuat * ours;
        }

        // WB's SceneryHelpers.Displace returns the localPos that RotateObj
        // expects for its loc parameter (used only when SetHeading is called
        // with non-zero matrix, but a stub Vector3 works since BaseLoc is identity).
        var localPos = WB_SceneryHelpers.Displace(obj, gx, gy, j);
        Quaternion wb = WB_SceneryHelpers.RotateObj(obj, gx, gy, j, localPos);

        Assert.Equal(ours.X, wb.X, precision: 4);
        Assert.Equal(ours.Y, wb.Y, precision: 4);
        Assert.Equal(ours.Z, wb.Z, precision: 4);
        Assert.Equal(ours.W, wb.W, 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);
    }
}
  • Step 3.2: Make our IsOnRoad accessible to the test

IsOnRoad is currently private. Bump to internal so the conformance test can call it. Open src/AcDream.Core/World/SceneryGenerator.cs and change:

    private static bool IsOnRoad(LandBlock block, float lx, float ly)

to:

    internal static bool IsOnRoad(LandBlock block, float lx, float ly)
  • Step 3.3: Run the conformance tests

Run: dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryWbConformance" 2>&1 | tail -10 Expected: ALL TESTS PASS.

If any test fails, stop and investigate — that's the bug WB is hiding from us. Report which assertion failed (e.g., "Displace at gx=4 gy=8 returns different Y") and confer with the user before proceeding.

  • Step 3.4: Commit
git add tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs src/AcDream.Core/World/SceneryGenerator.cs
git commit -m "$(cat <<'EOF'
phase(N.1): per-helper conformance tests for WB substitutions

Phase N.1 step 3: prove our inline algorithms (Displace, IsOnRoad,
slope normal Z, RotateObj, ScaleObj) match WorldBuilder's helpers
for representative inputs including the 0xA9B1 edge-vertex case.

Bumps IsOnRoad to internal so the test can call it directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: Implement GenerateViaWb

Files:

  • Modify: src/AcDream.Core/World/SceneryGenerator.cs

Add a new private method GenerateViaWb that produces IReadOnlyList<ScenerySpawn> using only WB helpers for the substituted algorithm calls. The 9×9 loop, scene selection, frequency check, bounds check, building grid, and BaseLoc.Z handling stay structurally identical to Generate.

  • Step 4.1: Add the required using directives

Open src/AcDream.Core/World/SceneryGenerator.cs. The file currently has:

using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;

namespace AcDream.Core.World;

Replace with:

using System.Numerics;
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using WorldBuilder.Shared.Modules.Landscape.Lib;

namespace AcDream.Core.World;
  • Step 4.2: Add GenerateViaWb immediately after Generate

Find the closing } of Generate(...) in SceneryGenerator.cs (just before the IsRoadVertex method). Immediately AFTER Generate's closing brace, ADD:


    /// <summary>
    /// Phase N.1 alternative implementation that delegates the
    /// algorithm calls to WorldBuilder's <c>SceneryHelpers</c> +
    /// <c>TerrainUtils</c>. Structurally identical to <see cref="Generate"/>
    /// but with WB's tested ports doing the work. Selected by
    /// <see cref="UseWbScenery"/>.
    /// </summary>
    private static IReadOnlyList<ScenerySpawn> GenerateViaWb(
        DatCollection dats,
        Region region,
        LandBlock block,
        uint landblockId,
        HashSet<int>? buildingCells)
    {
        var result = new List<ScenerySpawn>();

        if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
            return result;

        // Build the TerrainEntry[] WB's helpers consume — once per landblock.
        var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block);

        uint blockX = (landblockId >> 24) * 8;
        uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
        uint lbX    = landblockId >> 24;
        uint lbY    = (landblockId >> 16) & 0xFFu;

        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);
                uint sceneType   = (uint)((raw >> 11) & 0x1F);

                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: identical to Generate.
                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<Scene>(sceneId);
                if (scene is null) continue;

                // Per-object frequency setup: identical to Generate.
                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;

                    double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10;
                    if (noise >= obj.Frequency) continue;

                    // ─── WB substitution: displacement ───────────────────
                    var localPos = SceneryHelpers.Displace(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;

                    // ─── WB substitution: road check ──────────────────────
                    if (TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries))
                        continue;

                    // Building check: identical to Generate.
                    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;
                    }

                    // ─── WB substitution: slope check ─────────────────────
                    Vector3 normal = TerrainUtils.GetNormal(
                        region, terrainEntries, lbX, lbY,
                        new Vector3(lx, ly, 0));
                    if (!SceneryHelpers.CheckSlope(obj, normal.Z))
                        continue;

                    float lz = obj.BaseLoc.Origin.Z;

                    // ─── WB substitution: rotation ────────────────────────
                    Quaternion rotation;
                    if (obj.Align != 0)
                        rotation = SceneryHelpers.ObjAlign(obj, normal, lz, localPos);
                    else
                        rotation = SceneryHelpers.RotateObj(obj, globalCellX, globalCellY, j, localPos);

                    // ─── WB substitution: scale ───────────────────────────
                    float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j);
                    if (scale <= 0) scale = 1f;

                    result.Add(new ScenerySpawn(
                        ObjectId:      obj.ObjectId,
                        LocalPosition: new Vector3(lx, ly, lz),
                        Rotation:      rotation,
                        Scale:         scale));
                }
            }
        }

        return result;
    }
  • Step 4.3: Verify build

Run: dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5 Expected: Build succeeded. 0 Error(s).

If you get an error like 'SceneryHelpers' is an ambiguous reference, it's because both Chorizite.OpenGLSDLBackend.Lib and WorldBuilder.Shared expose helpers — fix by qualifying: Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers.Displace(...).

  • Step 4.4: Verify existing tests still pass

Run: dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3 Expected: all scenery-area tests pass (the ones added in Tasks 1 and 3, plus the original SceneryGeneratorTests). No behavior change yet for the live Generate path — GenerateViaWb is added but not called.

  • Step 4.5: Commit
git add src/AcDream.Core/World/SceneryGenerator.cs
git commit -m "$(cat <<'EOF'
phase(N.1): implement GenerateViaWb alternative path

Phase N.1 step 4: parallel implementation of Generate() that calls
WB's SceneryHelpers (Displace/CheckSlope/RotateObj/ObjAlign/ScaleObj)
and TerrainUtils (OnRoad/GetNormal) instead of the inline ports.

Not yet wired in — Generate() still runs the legacy path. Step 5
adds the dispatch.

Per-helper conformance tests in step 3 prove this implementation is
behavior-equivalent to the legacy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: Wire feature-flag dispatch

Files:

  • Modify: src/AcDream.Core/World/SceneryGenerator.cs

  • Step 5.1: Add the dispatch at the top of Generate

In src/AcDream.Core/World/SceneryGenerator.cs, find the body of Generate:

    public static IReadOnlyList<ScenerySpawn> Generate(
        DatCollection dats,
        Region region,
        LandBlock block,
        uint landblockId,
        HashSet<int>? buildingCells = null,
        float[]? heightTable = null)
    {
        var result = new List<ScenerySpawn>();

        if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
            return result;

Immediately AFTER the opening { and BEFORE var result = new List<ScenerySpawn>();, ADD:

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

So the method's opening becomes:

    public static IReadOnlyList<ScenerySpawn> Generate(
        DatCollection dats,
        Region region,
        LandBlock block,
        uint landblockId,
        HashSet<int>? 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<ScenerySpawn>();

        if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
            return result;

Note: heightTable is NOT passed to GenerateViaWb — the WB path uses region.LandDefs.LandHeightTable via TerrainUtils.GetNormal. The legacy path keeps the parameter for backward compatibility.

  • Step 5.2: Verify build

Run: dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5 Expected: Build succeeded.

  • Step 5.3: Verify all existing tests still pass with flag OFF (default)

Run: dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3 Expected: all pass (the legacy path is still default, so behavior is unchanged).

  • Step 5.4: Commit
git add src/AcDream.Core/World/SceneryGenerator.cs
git commit -m "$(cat <<'EOF'
phase(N.1): wire ACDREAM_USE_WB_SCENERY dispatch in Generate()

Phase N.1 step 5: when the flag is set, Generate() delegates to
GenerateViaWb. Default off; flag flips to default-on in step 7
after visual verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: Visual verification — manual checkpoint

This task is interactive. You must work with the user. They run the client, look at landblock 0xA9B1, and confirm two things visually:

  1. The road-edge tree we have been chasing all session is not present in the WB-backed render.
  2. The Issue #49 missing scenery (the trees the 9×9 loop expansion fixed) is still visible.
  • Step 6.1: Make sure build is green

Run: dotnet build 2>&1 | tail -3 Expected: Build succeeded.

  • Step 6.2: Tell the user how to launch with the flag set

Tell the user (paraphrase): "Set $env:ACDREAM_USE_WB_SCENERY = '1' in your launch terminal alongside the other env vars, then launch the client and navigate to Holtburg. Specifically check the road-edge area near coordinates (87, 191) in landblock 0xA9B1 — the tree we have been chasing all session should be gone now. Also confirm Issue #49's previously missing trees are still there."

If dotnet run is the standard launch command, the full PowerShell launch is:

$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE      = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_USE_WB_SCENERY = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
  • Step 6.3: Wait for user's verification report

The user will tell you "yes the offending tree is gone and Issue #49 is still fine" or "still wrong". If still wrong, do NOT proceed — investigate and report back.

  • Step 6.4: Commit nothing

This task does not produce a code change. The commit happens in Task 7 once the flag is flipped to default-on.


Task 7: Flip default-on

Files:

  • Modify: src/AcDream.Core/World/SceneryGenerator.cs

After visual verification passes in Task 6, the WB-backed path becomes the default. The env var still exists as an escape hatch (ACDREAM_USE_WB_SCENERY=0 reverts to legacy) so that if a regression is reported the next day, we can flip back without redeploying.

  • Step 7.1: Flip the flag default

In src/AcDream.Core/World/SceneryGenerator.cs, find:

    internal static readonly bool UseWbScenery =
        System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1";

Replace with:

    /// <summary>
    /// Phase N.1: scenery placement uses WorldBuilder's <c>SceneryHelpers</c>
    /// + <c>TerrainUtils</c> by default. Set <c>ACDREAM_USE_WB_SCENERY=0</c>
    /// to restore the legacy in-line algorithms (escape hatch — to be deleted
    /// in a follow-up commit once we have a few sessions of green visuals).
    /// </summary>
    internal static readonly bool UseWbScenery =
        System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") != "0";
  • Step 7.2: Verify build + tests

Run: dotnet build 2>&1 | tail -3 && dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3 Expected: build green, all targeted tests pass.

  • Step 7.3: Commit
git add src/AcDream.Core/World/SceneryGenerator.cs
git commit -m "$(cat <<'EOF'
phase(N.1): WB-backed scenery is now default-on

Phase N.1 step 7: flips ACDREAM_USE_WB_SCENERY to default-on after
visual verification at Holtburg confirmed the road-edge tree at
0xA9B1 is gone and Issue #49 trees are still visible.

ACDREAM_USE_WB_SCENERY=0 still reverts to the legacy path. Follow-up
commit will delete the legacy code entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: Delete the legacy code path

Files:

  • Modify: src/AcDream.Core/World/SceneryGenerator.cs
  • Modify: tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs

After at least one session of clean visuals on the default-on flag, remove the legacy code so we don't accumulate dead-code drift.

  • Step 8.1: Delete the legacy Generate body and rename GenerateViaWb

In src/AcDream.Core/World/SceneryGenerator.cs:

  1. Delete the UseWbScenery field entirely.
  2. Delete the entire body of Generate after its signature.
  3. Replace it with a body that just calls GenerateViaWb's logic (or rename GenerateViaWb to Generate's body).

The simplest approach: rename GenerateViaWb to GenerateInternal and have the public Generate call it. Then delete the legacy logic. Final shape:

public static IReadOnlyList<ScenerySpawn> Generate(
    DatCollection dats,
    Region region,
    LandBlock block,
    uint landblockId,
    HashSet<int>? buildingCells = null,
    float[]? heightTable = null)
{
    // heightTable kept for backward compat; WB path uses
    // region.LandDefs.LandHeightTable internally.
    _ = heightTable;
    return GenerateInternal(dats, region, block, landblockId, buildingCells);
}

private static IReadOnlyList<ScenerySpawn> GenerateInternal(
    DatCollection dats,
    Region region,
    LandBlock block,
    uint landblockId,
    HashSet<int>? buildingCells)
{
    // ... body that was GenerateViaWb ...
}
  1. Delete the now-unused private helpers: IsOnRoad, DisplaceObject, RoadHalfWidth, CellsPerSide (if only used by legacy path — keep if GenerateInternal's building check still references it).

Concretely, keep:

  • VerticesPerSide, CellSize, LandblockSize, CellsPerSide constants (still used in GenerateInternal)
  • IsRoadVertex (still useful as a tiny public predicate)
  • WbSceneryAdapter (still used)

Delete:

  • UseWbScenery

  • IsOnRoad (and its RoadHalfWidth dependency)

  • DisplaceObject (now dead)

  • Step 8.2: Update SceneryGeneratorTests.cs to remove now-irrelevant tests

In tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs, the existing DisplaceObject_EdgeVertex_CanProduceValidPosition and DisplaceObject_InteriorVertex_AlwaysNearOrigin tests reference the deleted SceneryGenerator.DisplaceObject helper. Delete them.

Keep:

  • All IsRoadVertex_* tests (IsRoadVertex is preserved).

The class doc comment at the top should be updated to reflect the new state:

/// <summary>
/// Tests for SceneryGenerator: the road-vertex predicate (only piece of
/// our own algorithm code remaining post Phase N.1). The displacement /
/// road / slope / rotation / scale algorithms now run through
/// WorldBuilder's helpers — see SceneryWbConformanceTests.cs for the
/// helper-level equivalence proof.
/// </summary>
  • Step 8.3: Update the SceneryWbConformanceTests now that legacy helpers are gone

SceneryWbConformanceTests currently calls SceneryGenerator.DisplaceObject and SceneryGenerator.IsOnRoad. Once those are deleted, the tests are now testing "WB matches WB" which is meaningless.

Delete SceneryWbConformanceTests.cs entirely. The conformance tests served their purpose during the migration — they proved the substitution was safe. Now that we're committed to the WB path, they're vestigial.

Run: rm tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs

  • Step 8.4: Verify build + tests

Run: dotnet build 2>&1 | tail -3 && dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter" 2>&1 | tail -3 Expected: build green, tests pass (just the IsRoadVertex tests + WbSceneryAdapter tests).

  • Step 8.5: Commit
git add src/AcDream.Core/World/SceneryGenerator.cs tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs
git commit -m "$(cat <<'EOF'
phase(N.1): delete legacy scenery code path; WB is the only path

Phase N.1 step 8 (final): now that ACDREAM_USE_WB_SCENERY has been
default-on for a session with no regressions, remove the legacy
in-line algorithms so we don't accumulate dead-code drift.

Deleted:
- SceneryGenerator.UseWbScenery (feature flag)
- SceneryGenerator.IsOnRoad / DisplaceObject / RoadHalfWidth (legacy ports)
- SceneryGeneratorTests.DisplaceObject_* (test the deleted method)
- SceneryWbConformanceTests.cs (purpose served — proved equivalence pre-migration)

Renamed:
- GenerateViaWb → GenerateInternal (the only path now)

Kept:
- IsRoadVertex (small predicate, still used by tests + may be useful elsewhere)
- WbSceneryAdapter (consumed by GenerateInternal; reusable in N.2)

Phase N.1 complete. Issues #48, #49 are addressed via WB's tested
algorithms. Roadmap entry under Phase N can be marked shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 8.6: Mark Phase N.1 shipped in the roadmap

In docs/plans/2026-04-11-roadmap.md, find the Phase N section (search for ### Phase N — WorldBuilder Rendering Migration). Inside the sub-phases table find the row for N.1, currently:

- **N.1 — Scenery algorithm calls.** Replace `IsOnRoad` /
  `DisplaceObject` / slope-normal calc / rotation / scale inside
  `SceneryGenerator.Generate()` with calls to WB's `SceneryHelpers` +
  `TerrainUtils`. Tiny adapter `LandBlock → TerrainEntry[]`. Keeps our
  data flow + `ScenerySpawn` shape. Feature flag
  `ACDREAM_USE_WB_SCENERY=1`. ~1-2 days.

Add a status marker at the start of the line:

- **✓ SHIPPED — N.1 — Scenery algorithm calls.** Shipped 2026-05-08.
  Replaced `IsOnRoad` / `DisplaceObject` / slope-normal calc / rotation /
  scale inside `SceneryGenerator.Generate()` with calls to WB's
  `SceneryHelpers` + `TerrainUtils`. Adapter `WbSceneryAdapter` produces
  `TerrainEntry[]`. Visual verification at Holtburg confirmed the
  road-edge tree at 0xA9B1 is gone and Issue #49 trees are still visible.

Also: add a row to the top of the file's "Phases already shipped" table, in commit-shipped order:

| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ |

Then commit:

git add docs/plans/2026-04-11-roadmap.md
git commit -m "$(cat <<'EOF'
docs(roadmap): mark Phase N.1 (scenery via WB helpers) shipped

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Done definition

After all 8 tasks land cleanly:

  • dotnet build and dotnet test (excluding the 8 pre-existing DispatcherToMovementIntegrationTests failures unrelated to this work) green.
  • Visual verification at Holtburg confirms:
    • The road-edge tree near 0xA9B1 is gone.
    • Issue #49's missing scenery is still visible.
    • No new visual regressions in surrounding landblocks during a brief flight.
  • Phase N.1 marked shipped in docs/plans/2026-04-11-roadmap.md.
  • SceneryGenerator.Generate calls only WB helpers for displacement / road / slope / rotation / scale.
  • Issue #49 stays closed; no new related issues filed.