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>
45 KiB
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
IsOnRoadaccessible 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
usingdirectives
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
GenerateViaWbimmediately afterGenerate
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:
- The road-edge tree we have been chasing all session is not present in the WB-backed render.
- 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
Generatebody and renameGenerateViaWb
In src/AcDream.Core/World/SceneryGenerator.cs:
- Delete the
UseWbSceneryfield entirely. - Delete the entire body of
Generateafter its signature. - Replace it with a body that just calls
GenerateViaWb's logic (or renameGenerateViaWbtoGenerate'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 ...
}
- Delete the now-unused private helpers:
IsOnRoad,DisplaceObject,RoadHalfWidth,CellsPerSide(if only used by legacy path — keep ifGenerateInternal's building check still references it).
Concretely, keep:
VerticesPerSide,CellSize,LandblockSize,CellsPerSideconstants (still used inGenerateInternal)IsRoadVertex(still useful as a tiny public predicate)WbSceneryAdapter(still used)
Delete:
-
UseWbScenery -
IsOnRoad(and itsRoadHalfWidthdependency) -
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 (IsRoadVertexis 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 buildanddotnet test(excluding the 8 pre-existingDispatcherToMovementIntegrationTestsfailures unrelated to this work) green.- Visual verification at Holtburg confirms:
- The road-edge tree near
0xA9B1is gone. - Issue #49's missing scenery is still visible.
- No new visual regressions in surrounding landblocks during a brief flight.
- The road-edge tree near
- Phase N.1 marked shipped in
docs/plans/2026-04-11-roadmap.md. SceneryGenerator.Generatecalls only WB helpers for displacement / road / slope / rotation / scale.- Issue #49 stays closed; no new related issues filed.