From 833d167ebc7ba3b5e732abc6daf23aa2b6af2070 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 7 May 2026 21:15:11 +0200 Subject: [PATCH 01/19] =?UTF-8?q?fix(scenery):=20#49=209=C3=979=20loop,=20?= =?UTF-8?q?per-spawn=20building=20check,=20triangle=20slope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.Core/Physics/TerrainSurface.cs | 67 ++++++++++++++++++ src/AcDream.Core/World/SceneryGenerator.cs | 69 +++++++----------- .../Physics/TerrainSurfaceTests.cs | 46 ++++++++++++ .../World/SceneryGeneratorTests.cs | 70 +++++++++++++++++-- 4 files changed, 203 insertions(+), 49 deletions(-) diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index fe51188..525569e 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -198,6 +198,73 @@ public sealed class TerrainSurface return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE); } + /// + /// Sample the terrain triangle's surface-normal Z component at (localX, localY) + /// from a raw heightmap. Returns the upward component of the unit normal for + /// the specific triangle the point lies in — flat ground returns 1.0, steeper + /// slopes return smaller values. Used by for + /// the retail slope filter (CLandCell::find_terrain_poly → polygon.plane.N.z). + /// + public static float SampleNormalZFromHeightmap( + byte[] heights, float[] heightTable, + uint landblockX, uint landblockY, + float localX, float localY) + { + ArgumentNullException.ThrowIfNull(heights); + ArgumentNullException.ThrowIfNull(heightTable); + if (heights.Length < 81) + throw new ArgumentException("heights must have 81 entries", nameof(heights)); + if (heightTable.Length < 256) + throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); + + float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f); + float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f); + int cx = (int)fx; + int cy = (int)fy; + cx = Math.Clamp(cx, 0, CellsPerSide - 1); + cy = Math.Clamp(cy, 0, CellsPerSide - 1); + + float tx = fx - cx; + float ty = fy - cy; + + float hBL = heightTable[heights[cx * HeightmapSide + cy ]]; + float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]]; + float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]]; + float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]]; + + bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy); + + float dzdx, dzdy; + if (splitSWtoNE) + { + if (tx > ty) + { + dzdx = (hBR - hBL) / CellSize; + dzdy = (hTR - hBR) / CellSize; + } + else + { + dzdx = (hTR - hTL) / CellSize; + dzdy = (hTL - hBL) / CellSize; + } + } + else + { + if (tx + ty <= 1f) + { + dzdx = (hBR - hBL) / CellSize; + dzdy = (hTL - hBL) / CellSize; + } + else + { + dzdx = (hTR - hTL) / CellSize; + dzdy = (hTR - hBR) / CellSize; + } + } + + return 1f / MathF.Sqrt(dzdx * dzdx + dzdy * dzdy + 1f); + } + /// /// Pick the cell's triangle for the chosen diagonal and barycentric- /// interpolate Z. Single source of truth shared by both diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 5c88128..610d4fe 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -28,12 +28,6 @@ namespace AcDream.Core.World; /// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast. /// ACViewer's reference omits this cast and is subtly wrong for negative inputs. /// We deliberately match the decompiled client, not ACViewer. -/// -/// We deliberately skip the slope/road/building-overlap checks the original does; -/// those prevent scenery from floating in roads or clipping buildings but -/// require walkable-polygon lookups that we don't yet have. Accepting visual -/// artifacts (trees inside roads, scenery clipping buildings) for a first pass -/// and deferring the filters to a later phase. /// public static class SceneryGenerator { @@ -72,14 +66,15 @@ public static class SceneryGenerator uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock uint blockY = ((landblockId >> 16) & 0xFFu) * 8; - // RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices. - // Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses - // `while (local_94 < 8)` and `while (local_8c < 8)` — bound by - // `param_1+0x40` which is SideCellCount=8 for outdoor landblocks. - // The terrain word at each cell's SW corner drives that cell's scenery. - for (int x = 0; x < CellsPerSide; x++) + // RETAIL iterates 9×9 = 81 VERTICES, not 8×8 = 64 cells. + // Named retail: CLandBlock::get_land_scenes (0x00530460) uses + // `side_vertex_count` (offset 0x40, value 9) as the loop bound. + // The do-while condition `(var+1) < side_vertex_count` runs var 0..8. + // Edge vertices (x=8 or y=8) produce valid spawns when the per-object + // displacement shifts the position back into the [0, 192) range. + for (int x = 0; x < VerticesPerSide; x++) { - for (int y = 0; y < CellsPerSide; y++) + for (int y = 0; y < VerticesPerSide; y++) { int i = x * VerticesPerSide + y; ushort raw = block.Terrain[i]; @@ -92,9 +87,6 @@ public static class SceneryGenerator // polygonal OnRoad check (see below). Removing the // pre-displacement early-exit restores retail behavior. - // Skip cells that contain buildings. - if (buildingCells is not null && buildingCells.Contains(i)) continue; - if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; if (sceneType >= sceneTypeList.Count) continue; @@ -166,34 +158,27 @@ public static class SceneryGenerator continue; } - // L-fix2 (2026-04-28): the extra cell-origin road-vertex - // guard previously here is REMOVED. It wasn't in the - // retail decomp — it was a heuristic added to widen - // road margins visually. The proper retail post- - // displacement road check (FUN_00530d30 port via - // IsOnRoad above) already handles road exclusion. - // The extra guard was over-suppressing — every cell - // whose SW corner happened to touch a road vertex - // had ALL of its scenery dropped, even when the - // displaced position was well clear of the ribbon. - // User reported missing trees they could see in - // retail; this is the most likely cause. + // Per-spawn building check on the DISPLACED position's cell. + // Retail: CSortCell::has_building(cell) per spawn, not per vertex. + // WorldBuilder: buildingsGrid[gx2, gy2] with 8×8 cell grid. + if (buildingCells is not null) + { + int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1); + int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1); + if (buildingCells.Contains(dcx * VerticesPerSide + dcy)) + continue; + } - // Slope filter (ACME conformance fix 4e): compute terrain normal - // Z-component at the displaced position and check against the - // object's MinSlope/MaxSlope bounds. + // Slope filter: retail uses CLandCell::find_terrain_poly → + // polygon->plane.N.z to get the triangle-specific normal. + // SampleNormalZFromHeightmap picks the correct triangle via + // the cell's split direction, matching retail + WorldBuilder. if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f)) { - int sx = Math.Clamp((int)(lx / CellSize), 0, VerticesPerSide - 2); - int sy = Math.Clamp((int)(ly / CellSize), 0, VerticesPerSide - 2); - int sxR = sx + 1; - int syU = sy + 1; - float h00 = heightTable[block.Height[sx * VerticesPerSide + sy]]; - float h10 = heightTable[block.Height[sxR * VerticesPerSide + sy]]; - float h01 = heightTable[block.Height[sx * VerticesPerSide + syU]]; - float dx = (h10 - h00) / CellSize; - float dy = (h01 - h00) / CellSize; - float nz = 1f / MathF.Sqrt(dx * dx + dy * dy + 1f); // normal Z component + float nz = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap( + block.Height, heightTable, + landblockId >> 24, (landblockId >> 16) & 0xFFu, + lx, ly); if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; } @@ -396,7 +381,7 @@ public static class SceneryGenerator /// Decompiled normalises signed-int LCG results with "if (val < 0) val += 2^32"; our /// unchecked((uint)(...)) is exactly equivalent. /// - private static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq) + internal static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq) { float x, y; var baseLoc = obj.BaseLoc.Origin; diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs index c26b214..17a756c 100644 --- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -139,6 +139,52 @@ public class TerrainSurfaceTests Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f); } + [Fact] + public void SampleNormalZFromHeightmap_FlatTerrain_ReturnsOne() + { + var heights = FlatHeightmap(50); + var hTable = LinearHeightTable(); + float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 96f, 96f); + Assert.Equal(1f, nz, precision: 5); + } + + [Fact] + public void SampleNormalZFromHeightmap_SlopedTerrain_ReturnsLessThanOne() + { + 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 * 20); + var hTable = LinearHeightTable(); + float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 12f, 12f); + Assert.True(nz < 1f, $"Sloped terrain should have nz < 1.0, got {nz}"); + Assert.True(nz > 0f, $"nz should be positive, got {nz}"); + } + + [Fact] + public void SampleNormalZFromHeightmap_AgreesWithSampleSurface() + { + 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 hTable = LinearHeightTable(); + const uint lbX = 0xA9, lbY = 0xB3; + var instance = new TerrainSurface(heights, hTable, lbX, lbY); + + for (float lx = 0.5f; lx < 192f; lx += 8f) + for (float ly = 0.5f; ly < 192f; ly += 8f) + { + var (_, normal) = instance.SampleSurface(lx, ly); + float staticNz = TerrainSurface.SampleNormalZFromHeightmap( + heights, hTable, lbX, lbY, lx, ly); + Assert.True( + Math.Abs(normal.Z - staticNz) < 0.0001f, + $"NormalZ mismatch at ({lx:F1},{ly:F1}): instance={normal.Z:F4} static={staticNz:F4}"); + } + } + [Fact] public void ComputeOutdoorCellId_Origin_ReturnsFirst() { diff --git a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs index d003ea8..3963a5c 100644 --- a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs +++ b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs @@ -1,13 +1,13 @@ +using System.Numerics; using AcDream.Core.World; using DatReaderWriter.Types; namespace AcDream.Core.Tests.World; /// -/// Tests for SceneryGenerator road-exclusion logic. -/// The full Generate() pipeline requires real dat files (Region, Scene, etc.) -/// so road-check behavior is tested via the internal IsRoadVertex helper, -/// which is the single gate that guards against placing trees on roads. +/// 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. /// public class SceneryGeneratorTests { @@ -32,15 +32,12 @@ public class SceneryGeneratorTests [Fact] public void IsRoadVertex_ZeroTerrain_IsNotRoad() { - // A fully blank terrain entry (no type, no road, no scene) is not a road. Assert.False(SceneryGenerator.IsRoadVertex(0)); } [Fact] public void IsRoadVertex_MatchesTerrainInfoRoadProperty() { - // Verify that IsRoadVertex agrees with the typed TerrainInfo.Road property - // for a sample of raw values, ensuring the bit convention is consistent. for (ushort raw = 0; raw < 4; raw++) { TerrainInfo ti = raw; @@ -50,4 +47,63 @@ public class SceneryGeneratorTests $"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}"); } } + + // --- Edge vertex displacement tests --- + // Retail iterates 9×9 vertices (0..8 on each axis). Vertices at x=8 or y=8 + // have base positions at 192 (= 8 * 24), which is AT the landblock boundary. + // These produce valid scenery when displacement shifts them back into [0, 192). + + [Fact] + public void DisplaceObject_EdgeVertex_CanProduceValidPosition() + { + // Vertex (3, 8): base_y = 8 * 24 = 192. + // With DisplaceY > 0, some LCG seeds will produce negative displacement, + // shifting the Y back below 192 into the valid range. + var obj = new ObjectDesc + { + DisplaceX = 12f, + DisplaceY = 12f, + BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) } + }; + + // Search across a range of global cell coords to find at least one + // case where vertex y=8 displaces into [0, 192). + bool foundValid = false; + for (uint gx = 0; gx < 64 && !foundValid; gx++) + { + for (uint gy = 0; gy < 64 && !foundValid; gy++) + { + var localPos = SceneryGenerator.DisplaceObject(obj, gx, gy, 0); + // Vertex (3, 8): cell corner at (3*24, 8*24) = (72, 192) + float lx = 3 * 24f + localPos.X; + float ly = 8 * 24f + localPos.Y; + if (ly >= 0 && ly < 192f && lx >= 0 && lx < 192f) + foundValid = true; + } + } + + Assert.True(foundValid, + "Expected at least one (globalCellX, globalCellY) where vertex y=8 " + + "displaces back into [0, 192) — retail's 9×9 loop relies on this"); + } + + [Fact] + public void DisplaceObject_InteriorVertex_AlwaysNearOrigin() + { + var obj = new ObjectDesc + { + DisplaceX = 12f, + DisplaceY = 12f, + BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) } + }; + + // For interior vertices (x < 8, y < 8), displacement is bounded by + // DisplaceX/Y (max 12 units each), so the result stays within one + // cell of the origin. + var localPos = SceneryGenerator.DisplaceObject(obj, 100, 100, 0); + Assert.True(Math.Abs(localPos.X) <= 12f, + $"Interior displacement X={localPos.X} exceeds DisplaceX=12"); + Assert.True(Math.Abs(localPos.Y) <= 12f, + $"Interior displacement Y={localPos.Y} exceeds DisplaceY=12"); + } } From e8aa1c82f4d7bd43dbbfdc58eb6f5ba176689073 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 07:44:17 +0200 Subject: [PATCH 02/19] fix(scenery): add retail obj_within_block check for edge-boundary spawns Retail's CLandBlock::get_land_scenes creates a PhysicsObj for each scenery spawn, then calls CPhysicsObj::obj_within_block (0x00461c30) which verifies the model's sorting sphere stays within [r, 192-r] on both axes. Edge-vertex spawns displaced close to the boundary (e.g., a tree at Y=190.97 from vertex y=8) get rejected because their sorting sphere extends past the landblock edge. We were missing this check, which caused a tree near a road at ~(85, 191) in landblock 0xA9B1 to appear in acdream but not retail. The tree legitimately passed all other filters (road, building, slope) but its Setup sorting sphere radius (~2-5m) meant it overflowed the boundary. Fix: look up each unique Setup's SortingSphere.Radius from the dat (cached per objectId) and apply the within-block bounds check after the slope filter, matching retail's order. GfxObj objects (0x01) use radius 0 (permissive) since they're typically small single-mesh items. Also: remove temporary ACDREAM_SCENERY_DIAG logging, fix duplicate xmldoc on IsOnRoad, update road check reference to named-retail PDB symbol (CLandBlock::on_road at 0x0052FFF0). Co-Authored-By: Claude Opus 4.6 --- src/AcDream.Core/World/SceneryGenerator.cs | 70 ++++++++++++++++++---- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 610d4fe..d4ae229 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -59,6 +59,7 @@ public static class SceneryGenerator float[]? heightTable = null) { var result = new List(); + var sortingRadiusCache = new Dictionary(); if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) return result; @@ -182,6 +183,15 @@ public static class SceneryGenerator if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; } + // Retail obj_within_block check: the model's sorting sphere must + // fit entirely within the landblock bounds. This rejects edge-vertex + // spawns whose bounding sphere would extend past the boundary. + // Named-retail: CPhysicsObj::obj_within_block (0x00461c30), called + // inside CLandBlock::get_land_scenes after find_terrain_poly/CheckSlope. + float sortRadius = GetSortingSphereRadius(dats, obj.ObjectId, sortingRadiusCache); + if (!IsWithinBlock(lx, ly, sortRadius)) + continue; + // BaseLoc.Z offset: scenery-specific vertical offset from // the ground (e.g., flowers planted at -0.1m so they // don't float above grass). The renderer adds groundZ @@ -262,20 +272,10 @@ public static class SceneryGenerator /// private const float RoadHalfWidth = 5.0f; - /// - /// Retail-faithful post-displacement road test. Ported from ACViewer - /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which is - /// a direct port of FUN_00530d30 in the retail client. - /// - /// Examines the 4 corners of the cell containing (lx, ly) and, depending - /// on how many are road vertices (0, 1, 2, 3, or 4), applies a polygonal - /// test using the 5-unit road half-width to check if (lx, ly) lies on the - /// road ribbon. Returns true if the point is on a road. - /// /// /// Retail-faithful road ribbon test — direct port of ACViewer's /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which - /// itself is a port of FUN_00530d30 in acclient.exe. + /// itself is a port of CLandBlock::on_road (named-retail 0x0052FFF0). /// /// Classifies the 4 corners of the cell containing (lx, ly) by road type /// (bits 0-1 of the terrain word) and applies a different geometric test @@ -370,6 +370,54 @@ public static class SceneryGenerator private const int CellsPerSide = 8; + /// + /// Retail CPhysicsObj::obj_within_block check — verifies the object's sorting + /// sphere stays entirely within the landblock bounds. Returns true if the object + /// is within bounds. + /// + /// Named-retail: CPhysicsObj::obj_within_block (0x00461c30). + /// ACViewer: PhysicsObj.obj_within_block. + /// + /// Retail loads the full PhysicsObj, transforms the sorting sphere center via + /// LocalToGlobal, then checks center ± radius against [0, BlockLength]. + /// We approximate by ignoring the sorting sphere center offset (typically small + /// for scenery) and checking the spawn position directly against [radius, 192-radius]. + /// + private static bool IsWithinBlock(float lx, float ly, float sortingSphereRadius) + { + if (sortingSphereRadius <= 0f) return true; + return lx >= sortingSphereRadius && ly >= sortingSphereRadius + && lx < LandblockSize - sortingSphereRadius + && ly < LandblockSize - sortingSphereRadius; + } + + /// + /// Gets the sorting sphere radius for a scenery object, with caching. + /// Setup objects (0x02xxxxxx) use Setup.SortingSphere.Radius. + /// GfxObj objects (0x01xxxxxx) fall back to 0 (allows placement; these are + /// typically small items like grass patches whose BSP sphere is tiny). + /// + private static float GetSortingSphereRadius( + DatCollection dats, uint objectId, Dictionary cache) + { + if (cache.TryGetValue(objectId, out float cached)) + return cached; + + float radius = 0f; + if ((objectId >> 24) == 0x02) + { + // Setup — has an explicit SortingSphere + var setup = dats.Get(objectId); + if (setup?.SortingSphere is { } sphere) + radius = sphere.Radius; + } + // GfxObj (0x01) — no Setup.SortingSphere; use 0 (permissive). + // These are typically small single-mesh scenery items. + + cache[objectId] = radius; + return radius; + } + /// /// Pseudo-random displacement within a cell for a scenery object. Returns a /// Vector3 in local cell-offset space (the caller adds it to the cell corner From e3c36b5bf87ad9c7ea722e122c55f48c71aa294b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 07:53:04 +0200 Subject: [PATCH 03/19] =?UTF-8?q?revert:=20remove=20obj=5Fwithin=5Fblock?= =?UTF-8?q?=20=E2=80=94=20sorting=20sphere=20radii=20too=20large?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The obj_within_block check using Setup.SortingSphere.Radius rejects far too many spawns. Sorting spheres for trees are 5-10m, creating a wide exclusion zone around every landblock edge. WorldBuilder produces correct scenery with just bounds+road+building+slope checks and no bounding sphere check. Revert to match WorldBuilder's approach. The single extra tree near the road at vtx=(4,8) in 0xA9B1 remains as a known minor discrepancy from retail — root cause TBD. Co-Authored-By: Claude Opus 4.6 --- src/AcDream.Core/World/SceneryGenerator.cs | 58 ---------------------- 1 file changed, 58 deletions(-) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index d4ae229..a5dc0ce 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -59,7 +59,6 @@ public static class SceneryGenerator float[]? heightTable = null) { var result = new List(); - var sortingRadiusCache = new Dictionary(); if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) return result; @@ -183,15 +182,6 @@ public static class SceneryGenerator if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; } - // Retail obj_within_block check: the model's sorting sphere must - // fit entirely within the landblock bounds. This rejects edge-vertex - // spawns whose bounding sphere would extend past the boundary. - // Named-retail: CPhysicsObj::obj_within_block (0x00461c30), called - // inside CLandBlock::get_land_scenes after find_terrain_poly/CheckSlope. - float sortRadius = GetSortingSphereRadius(dats, obj.ObjectId, sortingRadiusCache); - if (!IsWithinBlock(lx, ly, sortRadius)) - continue; - // BaseLoc.Z offset: scenery-specific vertical offset from // the ground (e.g., flowers planted at -0.1m so they // don't float above grass). The renderer adds groundZ @@ -370,54 +360,6 @@ public static class SceneryGenerator private const int CellsPerSide = 8; - /// - /// Retail CPhysicsObj::obj_within_block check — verifies the object's sorting - /// sphere stays entirely within the landblock bounds. Returns true if the object - /// is within bounds. - /// - /// Named-retail: CPhysicsObj::obj_within_block (0x00461c30). - /// ACViewer: PhysicsObj.obj_within_block. - /// - /// Retail loads the full PhysicsObj, transforms the sorting sphere center via - /// LocalToGlobal, then checks center ± radius against [0, BlockLength]. - /// We approximate by ignoring the sorting sphere center offset (typically small - /// for scenery) and checking the spawn position directly against [radius, 192-radius]. - /// - private static bool IsWithinBlock(float lx, float ly, float sortingSphereRadius) - { - if (sortingSphereRadius <= 0f) return true; - return lx >= sortingSphereRadius && ly >= sortingSphereRadius - && lx < LandblockSize - sortingSphereRadius - && ly < LandblockSize - sortingSphereRadius; - } - - /// - /// Gets the sorting sphere radius for a scenery object, with caching. - /// Setup objects (0x02xxxxxx) use Setup.SortingSphere.Radius. - /// GfxObj objects (0x01xxxxxx) fall back to 0 (allows placement; these are - /// typically small items like grass patches whose BSP sphere is tiny). - /// - private static float GetSortingSphereRadius( - DatCollection dats, uint objectId, Dictionary cache) - { - if (cache.TryGetValue(objectId, out float cached)) - return cached; - - float radius = 0f; - if ((objectId >> 24) == 0x02) - { - // Setup — has an explicit SortingSphere - var setup = dats.Get(objectId); - if (setup?.SortingSphere is { } sphere) - radius = sphere.Radius; - } - // GfxObj (0x01) — no Setup.SortingSphere; use 0 (permissive). - // These are typically small single-mesh scenery items. - - cache[objectId] = radius; - return radius; - } - /// /// Pseudo-random displacement within a cell for a scenery object. Returns a /// Vector3 in local cell-offset space (the caller adds it to the cell corner From 9b210be126264cdf4fd4db86c9457e668b3bd79e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 08:31:03 +0200 Subject: [PATCH 04/19] docs(architecture): WorldBuilder inventory + CLAUDE.md alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saves the comprehensive inventory of what WorldBuilder provides (terrain, scenery, static objects, EnvCells, portals, sky, particles, texture decode, mesh extraction, visibility) vs what acdream still ports from retail decomp (network, physics, animation, movement, UI, plugin, audio, chat). This is the load-bearing reference for the strategic shift from "port retail algorithms ourselves" to "rely on WorldBuilder for rendering + dat-handling, port only what WB doesn't cover." Updates CLAUDE.md: - Adds top-level instruction: read the inventory FIRST before re-porting anything in the 🟢 list - Reframes references/WorldBuilder/ as acdream's rendering BASE, not just a reference repo - Updates the "Reference hierarchy by domain" table to point rendering/dat questions at WorldBuilder, with retail decomp as cross-check Subsequent commits will fork WorldBuilder and replace our terrain/ scenery/object rendering with calls into the fork. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 45 +++- docs/architecture/worldbuilder-inventory.md | 250 ++++++++++++++++++++ 2 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 docs/architecture/worldbuilder-inventory.md diff --git a/CLAUDE.md b/CLAUDE.md index 469b95c..1731668 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,19 @@ single source of truth for how the client is structured. All work must align with this document. When the architecture doc and reality diverge, update one or the other — never leave them out of sync. +**WorldBuilder is acdream's rendering + dat-handling base** as of +2026-05-08. Before re-implementing any AC-specific rendering or +dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md` +FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into +our fork once wired up), not from retail decomp. WorldBuilder is +MIT-licensed, verified to render the world correctly, and uses the same +Silk.NET stack we target. Re-porting from retail decomp when WB already +has a tested port is how subtle bugs (the scenery edge-vertex bug, the +triangle-Z bug) keep slipping in. Retail decomp remains the oracle for +network, physics, animation, movement, UI, plugin, audio, chat — see +the inventory doc's 🔴 list for the full scope of "we still write this +ourselves". + **Execution phases:** R1→R8 in the architecture doc. Each phase has clear goals, test criteria, and builds on the previous. Don't skip phases. @@ -625,11 +638,18 @@ these, ideally all four: for the palette-indexed formats. See `ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical subpalette overlay algorithm. -- **`references/WorldBuilder/`** — C# + Silk.NET dat editor. Exact-stack - match to acdream for rendering approaches: terrain blending, texture - atlases, shader patterns. Most useful for "how do I do this GL thing - with Silk.NET on net10 idiomatically?" Less useful for protocol or - character appearance (dat editor, not game client). +- **`references/WorldBuilder/`** — **acdream's rendering + dat-handling + BASE (not just a reference).** As of 2026-05-08 acdream is moving to + fork WorldBuilder upstream and depend on the fork for terrain, + scenery, static objects, EnvCells, portals, sky, particles, texture + decoding, mesh extraction, visibility/culling. WorldBuilder is + MIT-licensed, exact-stack match (Silk.NET + .NET), and verified to + render the world correctly. **Before re-porting any rendering or + dat-handling algorithm from retail decomp, check + `docs/architecture/worldbuilder-inventory.md` first.** If WB has it, + use WB's port. If WB doesn't have it (network, physics, animation, + movement, UI, plugin, audio, chat), port from retail decomp as + before. - **`references/Chorizite.ACProtocol/`** — clean-room C# protocol library generated from a protocol XML description. Useful sanity check on field order, packed-dword conventions, type-prefix handling. The @@ -684,12 +704,15 @@ decompiled client code and would have fixed it in minutes. | Domain | Primary Oracle | Secondary | Notes | |--------|---------------|-----------|-------| -| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." | -| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **ACME `ClientReference.cs`** — decompiled retail client with exact offsets | ACME `TerrainGeometryGenerator.cs` (matches the mesh index buffer) | WorldBuilder original is SUPERSEDED for terrain algorithms. AC2D confirms the same formula. | -| **Terrain blending** (texture atlas, alpha masks, road overlays) | **ACME `LandSurfaceManager.cs`** | WorldBuilder original `LandSurfaceManager.cs` (same code, less tested) | Both use the same TexMerge pipeline. ACME has conformance tests. | -| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **ACME `StaticObjectManager.cs`** — includes CreaturePalette, GfxObjRemapping, HiddenParts | ACViewer `Render/` namespace | ACME has the complete creature appearance pipeline in one file. | -| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **ACME `TextureHelpers.cs`** | ACViewer `Render/TextureCache.cs` (palette overlay = `IndexToColor`) | For subpalette overlay specifically, ACViewer's `IndexToColor` is the canonical algorithm. | -| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **ACME `EnvCellManager.cs`** — portal traversal, mixed landblock detection, collision cache | ACViewer `Physics/Common/EnvCell.cs` | ACME is significantly more complete than original WorldBuilder for dungeons. | +| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." Use for everything in the 🔴 list (network, physics, animation, movement, UI, plugin, audio, chat). | +| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **WorldBuilder `TerrainGeometryGenerator.cs` + `TerrainUtils.cs`** | retail decomp for cross-check | WB is acdream's terrain base. ACME's port is older/SUPERSEDED by WB. | +| **Terrain blending** (texture atlas, alpha masks, road overlays) | **WorldBuilder `LandSurfaceManager.cs`** | ACME `LandSurfaceManager.cs` (same algo, less complete) | WB is acdream's blending base. | +| **Scenery** (procedural placement: trees, bushes, rocks, fences) | **WorldBuilder `SceneryRenderManager.cs` + `SceneryHelpers.cs`** | retail decomp `CLandBlock::get_land_scenes` | WB is acdream's scenery base. Re-porting from retail decomp is what caused the edge-vertex bug. | +| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **WorldBuilder `StaticObjectRenderManager.cs` + `ObjectMeshManager.cs`** | ACME `StaticObjectManager.cs` (includes CreaturePalette, GfxObjRemapping, HiddenParts — useful for character appearance which WB doesn't cover) | WB for static objects, ACME for character appearance. | +| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **WorldBuilder `TextureHelpers.cs`** | ACME `TextureHelpers.cs`; ACViewer's `IndexToColor` is canonical for subpalette overlay | WB is acdream's decode base. | +| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. | +| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. | +| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. | | **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. | | **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. | | **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md new file mode 100644 index 0000000..68144c9 --- /dev/null +++ b/docs/architecture/worldbuilder-inventory.md @@ -0,0 +1,250 @@ +# WorldBuilder Inventory — what we take, adapt, or leave + +**Status:** load-bearing reference. As of 2026-05-08 acdream's strategy is +to **rely heavily on WorldBuilder** for rendering and dat-handling rather +than re-port retail algorithms ourselves. WorldBuilder is MIT-licensed, is +verified by visual inspection to render the AC world correctly (terrain, +scenery, slabs, dungeons, slopes, particles), and uses the same Silk.NET ++ .NET stack we already target. + +**Integration model:** **fork upstream WorldBuilder** at +`github.com/Chorizite/WorldBuilder`, depend on our fork, delete editor-only +code, expose hooks for our network state to feed scene data in. Sync with +upstream via merge so we inherit fixes. This document tells you, before +you write code, whether the thing you're about to port already exists in +WorldBuilder. + +**Workflow change:** Before re-implementing any AC-specific rendering or +dat-handling algorithm, **check this inventory first**. If WorldBuilder +has it, port from WorldBuilder (or call into our fork once it's wired +up), not from retail decomp. Retail decomp remains the oracle for things +WorldBuilder lacks — animation, motion, physics collision, networking. + +--- + +## Repo layout (as of cloned snapshot under `references/WorldBuilder/`) + +- **`Chorizite.OpenGLSDLBackend/`** — full OpenGL renderer (Silk.NET). +- **`WorldBuilder.Shared/`** — data models, dat parsers, landscape module. +- **`WorldBuilder/`** — Avalonia desktop app shell (NOT taken). +- **`WorldBuilder.{Windows,Linux,Mac}/`** — platform entry points (NOT taken). +- **`WorldBuilder.Server/`** — collab editing backend (NOT taken). +- **`Tests/` + `WorldBuilder.Shared.Benchmarks/`** — test harness (study). + +**Upstream NuGet dependencies** (these stay as NuGet packages, we don't +vendor them): + +| Package | Version | Purpose | +|---|---|---| +| `Chorizite.Core` | 0.0.18 | Plugin framework — contains `Chorizite.Core.Lib.BoundingBox`, `Chorizite.Core.Render.*` interfaces used by every render manager | +| `Chorizite.DatReaderWriter` | 2.1.x | dat parsing (we already use 2.1.7) | +| `Chorizite.DatReaderWriter.Extensions` | 1.1.x | extra dat helpers | +| `BCnEncoder.Net` | 2.2.x | DXT decode (we already use) | +| `SixLabors.ImageSharp` | 3.1.x | image loading | +| `Silk.NET.OpenGL` + `Silk.NET.SDL` | 2.23.x | GL + windowing (we use Silk's own windowing, they use SDL) | +| `MP3Sharp` | 1.0.5 | MP3 decode | + +--- + +## 🟢 RENDERING — take wholesale or adapt + +These are what makes WB "perfect". Anything in this section, we should +use from WB rather than re-implement. + +### Terrain + +| Component | What it does | +|---|---| +| `TerrainRenderManager` | Full pipeline (per-chunk GPU buffers, draw orchestration) | +| `LandSurfaceManager` | Texture blending atlas (palCode, alpha masks, road overlays) | +| `TerrainGeometryGenerator` | Heightmap → mesh, normals, OnRoad, GetHeight, GetNormal | +| `TerrainChunk` | 16×16 landblock chunk geometry | +| `TextureAtlasManager` | Texture atlas builder | +| `VertexLandscape` | Terrain vertex format | + +### Scenery (procedural placement: trees, bushes, rocks, fences) + +| Component | What it does | +|---|---| +| `SceneryRenderManager` | Generate + render per-vertex scenery | +| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope | +| `SceneryInstance` | Per-spawn instance data | + +### Static objects (buildings, slabs, props — Setup + GfxObj + ObjDesc) + +| Component | What it does | +|---|---| +| `StaticObjectRenderManager` | Master pipeline for static objects | +| `ObjectRenderManagerBase` + `BaseObjectRenderManager` | Common render base | +| `ObjectMeshManager` | Mesh extraction from Setup/GfxObj, ObjDesc application | + +### Dungeons / interiors + +| Component | What it does | +|---|---| +| `EnvCellRenderManager` | Dungeon interior cell geometry | +| `PortalRenderManager` | Portal traversal / visibility | + +### Sky + atmosphere + +| Component | What it does | +|---|---| +| `SkyboxRenderManager` | Skybox rendering | +| `ParticleEmitterRenderer` + `ParticleBatcher` + `ActiveParticleEmitter` | Particle systems (sky particles, weather, magic) | + +### Visibility / culling + +| Component | What it does | +|---|---| +| `VisibilityManager` + `VisibilitySnapshot` | Frustum + cell visibility | +| `Frustum` | Frustum-cull math | + +### Other rendering helpers + +| Component | What it does | +|---|---| +| `MinimapRenderer` | Top-down minimap | +| `GlobalMeshBuffer` | Shared GPU mesh buffer | +| `GpuResourceManager` | GPU resource lifecycle | +| `InstanceData` | Instanced draw data | +| `TextureHelpers` | INDEX16, P8, BGRA, DXT decode + alpha (canonical port) | +| `DebugRenderer` + `DebugRendererLineDrawer` + `EdgeLineBuilder` | Debug primitives | + +### Shaders (22 total) + +Located at `Chorizite.OpenGLSDLBackend/Shaders/`: + +`Landscape.{vert,frag}` · `StaticObject.{vert,frag}` · `StaticObjectModern.{vert,frag}` · `Particle.{vert,frag}` · `PortalStencil.{vert,frag}` · `Outline.{vert,frag}` · `Simple3D.{vert,frag}` · `InstancedLine.{vert,frag}` · `Text.{vert,frag}` · `UI.{vert,frag}` · `Gizmo.{vert,frag}` (editor-only) + +--- + +## 🟢 LOW-LEVEL GL / FRAMEWORK — take or replace with our own + +Either take WB's wrappers wholesale, or keep our own and adapt the +render managers to use ours. These wrappers are stateless or +near-stateless and are the easiest to swap. + +| Component | What it does | +|---|---| +| `OpenGLGraphicsDevice` | Silk.NET.OpenGL wrapper | +| `OpenGLRenderer` | Render orchestration | +| `GLSLShader` | Shader compile/link/uniforms | +| `GLHelpers` + `GLStateScope` | GL state utility | +| `ManagedGLFrameBuffer` / `ManagedGLIndexBuffer` / `ManagedGLTexture` / `ManagedGLTextureArray` / `ManagedGLUniformBuffer` / `ManagedGLVertexArray` / `ManagedGLVertexBuffer` | GL resource wrappers | +| `TextureParameters` | Sampler config | +| `GpuMemoryTracker` | Memory tracking | +| `Camera2D` / `Camera3D` / `CameraBase` / `ICamera` / `CameraController` | Camera primitives | +| `GameScene` + `SingleObjectScene` + `SceneData` + `ModernRenderData` + `RenderPass` | Scene / pass structures | + +--- + +## 🟢 GEOMETRY / MATH UTILS — take wholesale + +| Component | File | +|---|---| +| `TerrainUtils` (OnRoad, GetNormal, GetHeight, GetRoad, palCode) | `WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs` | +| `TerrainCacheManager` | `…/Lib/TerrainCacheManager.cs` | +| `TerrainRaycast` | `…/Lib/TerrainRaycast.cs` | +| `GeometryUtils` | `WorldBuilder.Shared/Lib/GeometryUtils.cs` | +| `RaycastingUtils` (ray-vs-sphere/AABB/triangle) | `WorldBuilder.Shared/Lib/RaycastingUtils.cs` | +| `DoubleNumerics` (double-precision Vector/Matrix) | `WorldBuilder.Shared/Lib/DoubleNumerics.cs` | +| `DatUtils` | `WorldBuilder.Shared/Lib/DatUtils.cs` | +| `BoundingBoxExtensions` | `Chorizite.OpenGLSDLBackend/Lib/BoundingBoxExtensions.cs` | + +--- + +## 🟢 DATA MODELS — take selectively + +| Component | What it does | +|---|---| +| `RegionInfo` | Landblock metadata wrapper (LandblockSizeInUnits, CellSizeInUnits, etc.) | +| `TerrainEntry` | Per-vertex terrain (Type/Scenery/Road/Height) | +| `MergedLandblock` | Merged dat data | +| `CellSplitDirection` | SW-NE vs NE-SW | +| `Cell` | Generic cell wrapper | +| `ObjectId` | Object identifier | +| `Position` | World position | +| `ACEnums` | AC-specific enums | +| `WbBuildingPortal` / `WbCellPortal` | Portal structures | +| `BuildingObject` | Building data | + +--- + +## 🟡 EDITOR-ONLY — leave behind / delete in fork + +These exist for the editor experience and have no place in a game +client. Delete in fork. + +- **`Modules/Landscape/Tools/*`** — `BrushTool`, `BucketFillTool`, + `RoadLineTool`, `RoadVertexTool`, `InspectorTool`, + `ObjectManipulationTool`, `Gizmo*` (DragHandler, HitTester, Renderer, + State), `TexturePainting*`, `SceneRaycaster`, + `LandscapeBrush`, `LandscapeToolBase`, `LandscapeToolContext`, + `IToolSettingsProvider`, `ILandscapeBrush`, `ILandscapeEditorService`, + `ILandscapeRaycastService`, `ILandscapeTool`, `ITexturePaintingTool` +- **`Modules/Landscape/Commands/*`** — undo/redo command pattern for + editor (Add/Delete/Move/Rename/Reorder/etc.) +- **`LandscapeDocument` + `LandscapeLayer` + `LandscapeLayerGroup` + `LandscapeChunk` + `LandscapeLayerChunk` + `LandscapeLayerBase`** — editor document model +- **`Modules/Landscape/Models/TerrainPatch*` + `LandblockChangedEventArgs`** — editor mutation events +- **`Modules/Landscape/Services/ILandscapeCacheService` + `ILandscapeDataProvider` + `ILandscapeObjectService` + impls** — editor data flow +- **All `Migrations/*`** — SQLite schema migrations (project file format) +- **`Repositories/*`** + **`Services/*`** — project storage, dat repository, AceDb, SignalR sync, document manager, undo stack, world coordinates, keyword DB, project migration, semantic kernel AI helpers +- **`Hubs/*`** — collaborative editing via SignalR +- **`StaticObject` (editor model)** — replace with our own scene-state data model fed from network +- **`BackendGizmoDrawer` + `GizmoRenderer`** — editor gizmos +- **`ProjectStructures, IProject, Project`** — editor project files +- **`KeyBinding`** — editor input binding +- **`ViewportInputEvent[Extensions]`** — editor viewport input +- **`EditorState`** — editor state container + +--- + +## 🟡 AUDIO / FONT — we already have alternatives + +Keep ours; don't take theirs. + +- **`AudioPlaybackEngine`** — uses MP3Sharp. We have OpenAL. +- **`FontRenderer`** — uses ImageSharp. We have BitmapFont/StbTrueTypeSharp + ImGui. + +--- + +## 🔴 NOT IN WORLDBUILDER — port from retail decomp ourselves + +WorldBuilder is a dat editor; it does not have: + +- **Network protocol** — UDP framing, ISAAC, packet codec, ACE message + layer (we have this; oracle is `references/holtburger`) +- **Physics** — collision (CPhysicsObj transitions, BSP queries, sphere + sweeps), step-up, walkable validation (we have partial; oracle is the + retail decomp at `docs/research/named-retail/`) +- **Animation** — motion sequencer, cycle/non-cycle parts, animation + frame interpolation (we have this; oracle is retail decomp) +- **Movement** — local player WASD → MoveToState wire, remote-entity + motion via UpdateMotion + dead-reckoning (we have this; oracle is + `references/holtburger` + retail decomp) +- **Game UI** — chat, vitals, inventory, spell book, allegiance, options + (we have this; ImGui-based today, custom-toolkit later) +- **Plugin API** — `IGameState`, `IEvents`, `IActions`, `IPacketPipeline`, + `IOverlay` (we have this — acdream-unique) +- **Game events** — combat, allegiance, spell casting, quest events + (we have this; oracle is ACE for opcodes + retail for client behavior) +- **Audio** — OpenAL pipeline, sound triggers (we have this) +- **TurbineChat** + **slash commands** (we have this) +- **Login + character selection flow** (we have this) + +--- + +## What this means for the workflow + +The CLAUDE.md "grep named → decompile → verify → port" workflow stays +the rule for everything in the 🔴 list (network, physics, animation, +movement, UI, plugin, audio, chat). For anything in 🟢, the new rule is: +**check this inventory FIRST**. If WB has it, port from WB. Re-porting +from retail decomp when WB already has a tested port is no longer +appropriate — that's how we got the scenery edge-vertex bug. + +When the inventory says "take wholesale or adapt" and we discover a +behavior mismatch with retail (rare — WB is verified), the resolution +is: reconcile WB ↔ retail decomp ↔ holtburger ↔ ACE ↔ ACViewer (the +existing reference hierarchy in CLAUDE.md). WorldBuilder ranks at the +top of that hierarchy for anything 🟢. From 8a06fce7a527f384d877d0ae0aca1365e79dff92 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 08:47:23 +0200 Subject: [PATCH 05/19] spec(rendering): Phase N WorldBuilder migration design + N.1 scenery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two design docs and a roadmap entry for the strategic shift from "port retail rendering algorithms ourselves" to "depend on a fork of Chorizite/WorldBuilder for rendering + dat-handling." - docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md — parent design: integration model (fork + submodule), 10 sub-phases (N.0 setup through N.10 GL consolidation), strangler-fig phasing with per-phase feature flags, retail-decomp boundary clarified for what WB does NOT cover (network, physics, animation, motion, UI, plugin, audio, chat). - docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md — N.1 detailed design: replace IsOnRoad / DisplaceObject / slope-normal calc / rotation / scale inside SceneryGenerator.Generate() with calls to WB's SceneryHelpers + TerrainUtils. Keep data flow, ScenerySpawn shape, and renderer integration. Add small LandBlock → TerrainEntry[] adapter. Feature flag ACDREAM_USE_WB_SCENERY=1. - docs/plans/2026-04-11-roadmap.md — Phase N entry added between Phase M and Phase J. Lists all 10 sub-phases with effort estimates. Fork already created at https://github.com/eriknihlen/WorldBuilder. N.0 setup (replace references/WorldBuilder/ snapshot with submodule, add project references, build green) is the next implementation step. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/2026-04-11-roadmap.md | 87 +++++++ ...8-phase-n-worldbuilder-migration-design.md | 223 ++++++++++++++++++ ...-phase-n1-scenery-via-wb-helpers-design.md | 191 +++++++++++++++ 3 files changed, 501 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md create mode 100644 docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 91f7100..9e66f68 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -498,6 +498,93 @@ before porting. --- +### Phase N — WorldBuilder Rendering Migration + +**Goal:** Stop re-porting AC-specific rendering / dat-handling +algorithms. Depend on a fork of `Chorizite/WorldBuilder` (MIT) for +terrain, scenery, static objects, EnvCells, portals, sky, particles, +texture decoding, mesh extraction, and visibility. Acdream keeps its +own network, physics, animation, motion, UI, plugin, audio, chat +layers (those aren't in WB). + +**Why now (2026-05-08):** the scenery edge-vertex bug at landblock +`0xA9B1` was the third subtle porting bug in a quarter (after the +triangle-Z bug and the hover-over-terrain bug). Even when our code +looked byte-identical to WB's, our output diverged. WB renders the +world correctly; the cost of "we re-port retail algorithms" is now +higher than "we depend on WB's tested port." + +**Design + inventory:** + +- `docs/architecture/worldbuilder-inventory.md` — full taxonomy of + what WB has and what we keep porting ourselves. +- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md` — + parent design doc. +- `docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md` — + N.1 detailed design. + +**Integration model:** fork at +`https://github.com/eriknihlen/WorldBuilder` (already created), git +submodule replacing `references/WorldBuilder/` snapshot, project +references in our solution. Long-lived `acdream` branch in the fork +for our deletions/additions; merge upstream `master` periodically. + +**Sub-phases (strangler-fig with feature flags):** + +- **N.0 — Setup.** Submodule + project references + build green. ~1-2 hrs. +- **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. +- **N.2 — Terrain math helpers.** Refactor `TerrainSurface.SampleZ` / + `SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight` + / `GetNormal` internally. ~1-2 days. +- **N.3 — Texture decoding.** Replace `TextureCache` decode pipeline + with WB's `TextureHelpers`. ~2-3 days. +- **N.4 — Object meshing.** Replace `SetupMesh.cs` + `GfxObjMesh.cs` + with calls to WB's `ObjectMeshManager`. Character-appearance + behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain + ours — ACME is the secondary oracle. ~1 week. +- **N.5 — Terrain rendering.** Replace `TerrainChunkRenderer` + + `TerrainAtlas` + `TerrainBlending` with WB's `TerrainRenderManager` + + `LandSurfaceManager` + `TerrainGeometryGenerator`. ~2 weeks. +- **N.6 — Static objects rendering.** Replace `StaticMeshRenderer` + + `InstancedMeshRenderer` with WB's `StaticObjectRenderManager`. + ~2 weeks. +- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's + `EnvCellRenderManager` + `PortalRenderManager`. ~2 weeks. +- **N.8 — Sky + particles.** Replace sky rendering + particle pipeline + (#36 / C.1 work) with WB's `SkyboxRenderManager` + + `ParticleEmitterRenderer`. ~1 week. +- **N.9 — Visibility / culling.** Replace `CellVisibility` + + `FrustumCuller` with WB's `VisibilityManager`. ~3-5 days. +- **N.10 — GL infrastructure consolidation (optional).** Replace our + `Shader` / `TextureCache` / `SamplerCache` plumbing with WB's + `ManagedGL*` wrappers + `OpenGLGraphicsDevice`. ~1 week. + +**Estimated calendar:** 2-3 months. Engineering effort: 6-8 weeks. + +**Each sub-phase:** +- Ships behind `ACDREAM_USE_WB_=1` flag. +- Has its own conformance test (side-by-side against existing path). +- Visual verification before flag becomes default-on. +- Old code deleted after default-on lands cleanly. + +**N.2-N.10 detailed specs are NOT yet written** — each gets its own +brainstorm + spec when we reach it. + +**Acceptance:** +- All 10 sub-phases shipped, feature flags removed, old rendering code + paths deleted. +- Visual verification at Holtburg + Foundry statue + a representative + dungeon shows no regression vs Phase C.1. +- WB upstream merges into our `acdream` branch are clean (or have + documented conflict-resolution patterns). + +--- + ### Phase J — Long-tail (deferred / low-priority) Not detailed here; each gets its own brainstorm when it becomes relevant. diff --git a/docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md b/docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md new file mode 100644 index 0000000..33f2280 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md @@ -0,0 +1,223 @@ +# Phase N — WorldBuilder Rendering Migration: Design + +**Date:** 2026-05-08 +**Status:** Design complete, awaiting plan generation for N.1. + +## Goal + +Stop re-porting AC-specific rendering and dat-handling algorithms from +retail decomp. Instead, depend on a fork of WorldBuilder +(`github.com/Chorizite/WorldBuilder`, MIT) for terrain, scenery, static +objects, EnvCells, portals, sky, particles, texture decoding, mesh +extraction, and visibility / culling. Acdream keeps its own network, +physics, animation, motion, UI, plugin, audio, and chat layers — those +are not in WorldBuilder. + +## Why + +acdream has accumulated a recurring pattern of subtle porting bugs in +its own rendering algorithms (the latest: a tree near the road at +landblock `0xA9B1` that retail and WorldBuilder do not show but our +re-port did, despite the algorithm code looking byte-identical to +WorldBuilder's). The triangle-Z bug, the hover-over-terrain bug, and +the edge-vertex spawn bug are all in the same family: small porting +errors that survive surface-level review. + +WorldBuilder is verified by visual inspection to render the AC world +correctly. It uses the same Silk.NET + .NET stack we already target. +It is MIT-licensed. It has fewer subtle bugs because its developers +have run it against the entire client_cell + client_portal dat content +and fixed everything users have reported. + +The cost of "we re-port retail algorithms ourselves" is now higher than +the cost of "we depend on someone else's tested port and inherit their +fixes." Migrating the rendering+dat layer to WorldBuilder is the +right call. + +## Inventory reference + +The full taxonomy of "what WorldBuilder has, what we keep porting +ourselves" lives at +[`docs/architecture/worldbuilder-inventory.md`](../../architecture/worldbuilder-inventory.md). +Before re-implementing any rendering or dat-handling algorithm, **check +the inventory first**. CLAUDE.md is updated to enforce this. + +## Architecture + +### Integration model + +**Fork upstream WorldBuilder, depend on the fork via git submodule.** + +- Fork: `https://github.com/eriknihlen/WorldBuilder` (already created; + upstream: `Chorizite/WorldBuilder`). +- Long-lived branch in fork: `acdream`. Upstream `master` merges into + `acdream` periodically; our acdream-specific changes (delete editor + files, expose hooks for our scene state) live on `acdream`. +- The current read-only snapshot at `references/WorldBuilder/` is + **replaced** by a git submodule pointing at the fork's `acdream` + branch. Existing CLAUDE.md path references and research docs that + cite `references/WorldBuilder/...` keep working. +- Our solution adds two ``s: + - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Chorizite.OpenGLSDLBackend.csproj` + - `references/WorldBuilder/WorldBuilder.Shared/WorldBuilder.Shared.csproj` +- Transitive NuGet dependencies (`Chorizite.Core`, + `Chorizite.DatReaderWriter.Extensions`, `BCnEncoder.Net`, + `SixLabors.ImageSharp`, `Silk.NET.SDL`, `MP3Sharp`) flow through. +- Editor-only files in WorldBuilder (Modules/Landscape/{Tools, + Commands, Services, Migrations, Hubs}, LandscapeDocument, etc.) stay + in the fork's source tree but are simply not referenced by acdream. + They impose no runtime cost. We can prune later if upstream stays + well-organized. + +### Phasing — strangler fig, subsystem by subsystem + +Each sub-phase is independently shippable behind a feature flag +(`ACDREAM_USE_WB_=1`). After visual verification the flag becomes +default-on, then is removed and the old code is deleted. This gives us +a one-line revert if a phase regresses. + +| # | Sub-phase | Effort | Risk | +|---|---|---|---| +| **N.0** | Submodule + project references + build green | 1-2 hrs | Low | +| **N.1** | Scenery algorithm calls | 1-2 days | Low | +| **N.2** | Terrain math helpers | 1-2 days | Low | +| **N.3** | Texture decoding | 2-3 days | Medium | +| **N.4** | Object meshing (Setup/GfxObj) | 1 week | Medium | +| **N.5** | Terrain rendering (full pipeline) | 2 weeks | High | +| **N.6** | Static objects rendering | 2 weeks | High | +| **N.7** | EnvCells / dungeons | 2 weeks | High | +| **N.8** | Sky + particles | 1 week | Medium | +| **N.9** | Visibility / culling | 3-5 days | Medium | +| **N.10** | GL infrastructure consolidation (optional) | 1 week | Medium | + +Total estimated calendar: 2-3 months. Engineering effort: 6-8 weeks. + +### What WorldBuilder does NOT cover (keep porting from retail decomp) + +- Network protocol (UDP, ISAAC, ACE messages) — keep ours +- Physics: collision, BSP queries, sphere sweeps, walkable validation + — keep ours (partial), continue porting from retail decomp +- Animation: motion sequencer, cycle/non-cycle parts — keep ours +- Movement: WASD → MoveToState wire, remote-entity motion via + UpdateMotion + dead-reckoning — keep ours +- Game UI: chat, vitals, inventory, spell book — keep ours (ImGui + today, custom-toolkit later) +- Plugin API: IGameState, IEvents, IActions, IPacketPipeline, + IOverlay — keep ours (acdream-unique) +- Game events: combat, allegiance, spell casting — keep ours +- Audio (OpenAL pipeline) — keep ours +- TurbineChat + slash commands — keep ours +- Login + character selection flow — keep ours + +Per CLAUDE.md update, these still follow the +"grep named → decompile → verify → port" workflow against retail decomp +at `docs/research/named-retail/`. + +### Network reference posture + +`references/Chorizite.ACProtocol/` (separate Chorizite repo) remains +the Primary Oracle for protocol field order and packed-dword +conventions per CLAUDE.md's reference table. No fork needed there. We +will lean on it harder during future network-conformance phases (Phase +M is already on the roadmap for that). + +## Components + +### N.0 — Setup (must land before N.1) + +**Files / actions:** +- Remove `references/WorldBuilder/` from working tree (it's currently a + checked-in snapshot). Add it back as a submodule pointing at + `git@github.com:eriknihlen/WorldBuilder.git` tracking the `acdream` + branch (created off `master`). +- Add `` entries in + `src/AcDream.Core/AcDream.Core.csproj` and + `src/AcDream.App/AcDream.App.csproj` for the two WB projects. +- Update `.gitmodules` to reflect the new submodule. +- Verify `dotnet build` and `dotnet test` are green. +- Commit. + +**Done criteria:** +- `git submodule status` shows `references/WorldBuilder` at the fork's + `acdream` HEAD. +- Solution builds clean with no new warnings. +- Existing 870+ tests still pass. + +### N.1 — Scenery algorithm calls + +See companion design doc: +[`2026-05-08-phase-n1-scenery-via-wb-helpers-design.md`](2026-05-08-phase-n1-scenery-via-wb-helpers-design.md). + +Brief: replace the algorithm guts inside `SceneryGenerator.Generate()` +with calls to WB's `SceneryHelpers` (Displace, RotateObj, ScaleObj, +ObjAlign, CheckSlope) and `TerrainUtils` (OnRoad, GetNormal). Keep our +data flow, our `ScenerySpawn` shape, our renderer integration. Add a +small adapter `LandBlock → TerrainEntry[]`. + +### N.2-N.10 — separately brainstormed when we get there + +Each sub-phase will get its own brainstorm + spec when we reach it. +Estimating ahead is unreliable for the bigger phases (N.5, N.6, N.7); +we'll know more after N.1 ships and we have hands-on experience with +the WB integration. + +## Risks + +1. **Chorizite.Core dependency footprint.** Each render manager we + take pulls in `Chorizite.Core.Lib` and `Chorizite.Core.Render`. + Mitigation: take the NuGet dep, don't try to strip it. Risk is + mostly cosmetic (an extra package). + +2. **WB's data-flow is editor-shaped.** `LandscapeDocument`, + `LandscapeChunk`, etc. are editor concepts. Mitigation: write small + adapters that produce the editor-shaped data from our dat reads. + Phase N.1 is intentionally chosen to avoid this — we use only the + stateless helpers, not the full `SceneryRenderManager`. Larger + phases (N.5+) will need real adapter layers. + +3. **Upstream divergence.** WB's `master` will keep moving. Mitigation: + merge upstream `master` into our `acdream` branch periodically (at + minimum, before each new phase starts). Our acdream-specific + changes are isolated to deletions and additions on the `acdream` + branch, which merges cleanly with upstream most of the time. + +4. **Behaviors WB doesn't have.** WB is a dat editor; some + in-game-only behaviors (creature appearance via CreaturePalette / + GfxObjRemapping / HiddenParts) aren't in WB and we'll still need to + handle them ourselves at the integration boundary. Mitigation: + ACME's `StaticObjectManager.cs` covers these and is documented in + CLAUDE.md as the secondary oracle for character appearance. + +5. **Visual regression during migration.** Mitigation: feature flag + per phase. Visual verification at known-good locations (Holtburg, + Foundry statue, dungeon entrances) before flag becomes default-on. + +## Testing + +- **N.0:** existing 870+ tests stay green; `dotnet build` clean. +- **N.1:** new conformance test that runs both our `SceneryGenerator` + and a parallel call into WB's helpers against the same fixture data, + asserts identical spawn list. Visual verification at landblock + `0xA9B1` — the offending tree should be gone, Issue #49's missing + scenery should still be visible. +- **N.2-N.10:** each phase will define its own conformance and visual + verification criteria when brainstormed. + +## Documentation impact + +- [x] `docs/architecture/worldbuilder-inventory.md` — created. +- [x] `CLAUDE.md` — updated with new posture (top-level rule + reference + table + per-domain oracle hierarchy). +- [ ] `docs/plans/2026-04-11-roadmap.md` — add Phase N entry alongside + L, M, etc. (this happens in the same commit as the spec). +- [ ] `docs/architecture/acdream-architecture.md` — needs an + acknowledging note that the rendering layer is now WB-backed; can + follow in a later commit, not blocking. + +## Out of scope for this design + +- Phase N.2-N.10 detailed scope (each gets own brainstorm). +- Network conformance work (separate Phase M). +- Animation, physics, motion ports (continue against retail decomp, + not WB). +- UI, plugin, chat work (separate phases, not affected). diff --git a/docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md b/docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md new file mode 100644 index 0000000..6ec1b58 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md @@ -0,0 +1,191 @@ +# Phase N.1 — Scenery via WorldBuilder Helpers: Design + +**Date:** 2026-05-08 +**Parent design:** [`2026-05-08-phase-n-worldbuilder-migration-design.md`](2026-05-08-phase-n-worldbuilder-migration-design.md) +**Status:** Design complete, awaiting plan generation. + +## Goal + +Replace the algorithm guts of `SceneryGenerator.Generate()` with calls +to WorldBuilder's stateless `SceneryHelpers` and `TerrainUtils`. Keep +our data flow, our `ScenerySpawn` shape, and our renderer integration +unchanged. + +## Why scenery first + +1. **Active bug source.** Issues #48, #49 are scenery-related; the + investigation in this session uncovered another (the road-edge tree + at `0xA9B1`) we couldn't easily root-cause despite our code looking + identical to WB's. +2. **Smallest coherent slice.** Scenery placement uses only stateless + helpers from WB (Displace, OnRoad, GetNormal, CheckSlope, RotateObj, + ScaleObj). No need to take WB's `SceneryRenderManager`, no need for + editor-shaped data flow. +3. **Proves the integration pattern.** Phase N.0 wires up the + submodule + project references. N.1 uses them with a tiny surface + area. If something is wrong with the dependency model, we discover + it cheaply. + +## Architecture + +### What changes + +`src/AcDream.Core/World/SceneryGenerator.cs`: +- Remove our private `IsOnRoad(LandBlock, float, float)` helper. +- Remove our private `DisplaceObject(ObjectDesc, uint, uint, uint)` helper. +- Remove the `RoadHalfWidth` constant. +- Replace inline algorithm calls with WB equivalents (see table below). + +New file `src/AcDream.Core/World/WbSceneryAdapter.cs` (or similar +location — TBD during implementation): +- Helper `BuildTerrainEntries(LandBlock block) → TerrainEntry[]` + converting our `DatReaderWriter.DBObjs.LandBlock` (the dat type) into + the `TerrainEntry[]` shape WB's `TerrainUtils` expects (9×9 grid, + Type/Scenery/Road/Height fields per vertex). +- Helper for `RegionInfo` if needed (small wrapper over our + `Region` dat). + +### Algorithm-call substitution table + +| Today (ours) | Phase N.1 (WB) | +|---|---| +| `IsRoadVertex(raw)` (kept; small util) | unchanged — small predicate, no benefit to swap | +| `IsOnRoad(block, lx, ly)` | `TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries)` | +| `DisplaceObject(obj, gx, gy, j)` | `SceneryHelpers.Displace(obj, gx, gy, j)` | +| Slope normal: `TerrainSurface.SampleNormalZFromHeightmap(...)` | `TerrainUtils.GetNormal(region, terrainEntries, lbX, lbY, lbOffset).Z` | +| Slope check: `nz < obj.MinSlope \|\| nz > obj.MaxSlope` | `SceneryHelpers.CheckSlope(obj, normal.Z)` (returns bool) | +| Rotation logic (`AFrame::set_heading` reproduction) | `SceneryHelpers.RotateObj(obj, gx, gy, j, localPos)` (returns Quaternion) | +| Scale logic (LCG + Pow + clamp) | `SceneryHelpers.ScaleObj(obj, gx, gy, j)` (returns float) | + +### What does NOT change + +- The 9×9 vertex loop (`for (x = 0; x < 9; x++) for (y = 0; y < 9; y++)`). +- Scene selection hash. +- Frequency roll. +- `obj.WeenieObj != 0` skip (weenie entries are dynamic spawns). +- Bounds check `lx, ly ∈ [0, 192)`. +- Per-spawn building check using our `buildingCells` HashSet. +- `BaseLoc.Z` offset application. +- `ScenerySpawn` record shape returned to the renderer. +- `Generate()` method signature — same parameters, same return type. + +### What about `obj_within_block`? + +We attempted this during the bug investigation but it's too aggressive +when applied with the model's actual sorting sphere radius (rejects +trees that should be there). WB also doesn't apply it. The retail +behavior we couldn't reproduce stays unreproduced for now — we accept +that as a known minor cosmetic discrepancy and move on. The point of +N.1 is matching WB's behavior, not retail's. If WB and retail +disagree, that's a WB-upstream problem to file separately. + +## Components + +### Files modified + +- `src/AcDream.Core/World/SceneryGenerator.cs` — algorithm-call swap. +- `src/AcDream.Core/AcDream.Core.csproj` — already has WB project ref + from N.0. + +### Files added + +- `src/AcDream.Core/World/WbSceneryAdapter.cs` — `LandBlock → + TerrainEntry[]` and any other small adapters needed. +- `tests/AcDream.Core.Tests/World/SceneryGeneratorWbConformanceTests.cs` + — side-by-side test asserting our generator's output equals what + comes out when the same algorithms are called via WB directly. + +### Files deleted (eventually, after flag is on by default) + +- The deleted helpers in `SceneryGenerator.cs` mentioned above. + +### Feature flag + +Phase 1 of the rollout: `ACDREAM_USE_WB_SCENERY=1` (default off — old +path runs). When the env var is set, the new WB-backed path runs. + +Phase 2 (after visual verification at Holtburg / `0xA9B1`): flag +default-on. Old path can still be reached via +`ACDREAM_USE_WB_SCENERY=0`. + +Phase 3 (one or two sessions later, after no regressions): delete the +flag and the old code paths entirely. + +## Done criteria + +1. `dotnet build` green with no new warnings. +2. All existing tests pass (870+). +3. New conformance test passes: `SceneryGeneratorWbConformanceTests` + runs both code paths against fixture LandBlock data and asserts + identical spawn lists (same ObjectId, same LocalPosition within + 1e-4, same Rotation within 1e-4, same Scale within 1e-4). +4. Visual verification at landblock `0xA9B1` (Holtburg area): + - The offending tree near the road that retail/WB do not show is + **gone** in our render. + - Issue #49's previously missing scenery (the tree from the 9×9 + loop expansion) is **still visible**. + - No new visual regressions in surrounding landblocks during a + brief flight around Holtburg. +5. Issue #49 stays closed; no new issues filed. + +## Risks (Phase-N.1-specific) + +1. **`TerrainEntry` field semantics.** WB packs Type/Scenery/Road/ + Height into the `TerrainEntry` struct in a specific format. Getting + the adapter wrong means OnRoad / scenery selection produces + different results than ours. Mitigation: read + `WorldBuilder.Shared/Modules/Landscape/Models/TerrainEntry.cs` + carefully; cross-check against WB's `TerrainUtils.GetRoad` / + `GetTerrainEntryForCell` to confirm field encoding. +2. **`RegionInfo` dependencies.** WB's `TerrainUtils.GetNormal` takes + a `RegionInfo` parameter. We need to either build a minimal + `RegionInfo` from our `Region` dat or call WB's normal calc + differently. Mitigation: investigate during implementation; expect + this is a small wrapper. +3. **`obj.MaxScale / obj.MinScale` divide-by-zero.** Our code checks + `if (obj.MinScale == obj.MaxScale)` first; WB's `ScaleObj` does the + same per-line review of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryHelpers.cs:42-51`. Should be a non-issue. +4. **Rotation quaternion convention.** Our rotation produces + `headingQuat * baseLoc.Orientation`. WB's `RotateObj` calls + `SetHeading` which does its own composition. Need to confirm the + resulting quaternion is the same convention our renderer expects. + Mitigation: the conformance test catches this if it's wrong. + +## Testing + +### Conformance test (new) + +`SceneryGeneratorWbConformanceTests`: +- Construct a synthetic `LandBlock` with known terrain data. +- Run `SceneryGenerator.Generate(...)` with `ACDREAM_USE_WB_SCENERY=0` + and again with `=1`. +- Assert spawn counts equal. +- Assert each spawn's ObjectId, LocalPosition (within 1e-4), Rotation + (within 1e-4 per component), Scale (within 1e-4) are equal. + +### Existing tests + +`SceneryGeneratorTests` covers: road-vertex predicate, edge-vertex +displacement bounds, interior-vertex displacement bounds. These tests +exercise our internal helpers (`IsRoadVertex`, `DisplaceObject`). +After N.1, the `DisplaceObject` test must be either deleted (if we +delete the helper) or replaced (if we keep `IsRoadVertex` as a small +predicate — it's only one bit-test). + +### Visual verification + +User runs the client against ACE locally: +- Navigate to landblock `0xA9B1` (Holtburg). Verify offending tree + near road is gone. +- Confirm Issue #49's tree is still visible. +- Fly around Holtburg, scan visible scenery for any obvious + regression. + +## Out of scope for N.1 + +- Replacing our `SceneryRenderManager` (we don't have one — we have + `SceneryGenerator` producing `ScenerySpawn[]` and the renderer + consuming it directly). N.1 only touches the generator. +- Replacing our terrain math helpers (that's N.2). +- Replacing the static-object renderer (that's N.6). +- Anything in N.2-N.10. From c8782c93658c0f7a1082bf48e5e2675d6062e7ed Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 08:51:49 +0200 Subject: [PATCH 06/19] phase(N.0): wire up WorldBuilder fork as submodule + project refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase N.0 setup for the WorldBuilder migration. Replaces the local read-only clone of Chorizite/WorldBuilder at references/WorldBuilder/ with a git submodule pointing at our fork (github.com/eriknihlen/WorldBuilder.git, branch acdream). Changes: - .gitignore: exempt references/WorldBuilder from the references/ ignore rule so the submodule can be tracked. - .gitmodules (new): submodule entry tracking acdream branch on fork. - src/AcDream.Core/AcDream.Core.csproj: add ProjectReference to WorldBuilder.Shared and Chorizite.OpenGLSDLBackend so we can call TerrainUtils, SceneryHelpers, etc. from our Core code. Build green, all 93 scenery/terrain tests pass. The 8 pre-existing DispatcherToMovement test failures are unrelated and exist on main. Notes for users picking up this branch on main: - After merge, the existing local clone at references/WorldBuilder may need to be removed before `git submodule update --init` will populate the submodule. - Working on the fork happens via `cd references/WorldBuilder && git checkout acdream && && git push`. To pull upstream Chorizite/WorldBuilder fixes: `git remote add upstream https://github.com/Chorizite/WorldBuilder.git && git fetch upstream && git merge upstream/master`. Next: Phase N.1 — replace SceneryGenerator algorithm calls with WB's SceneryHelpers + TerrainUtils. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 +++++- .gitmodules | 4 ++++ references/WorldBuilder | 1 + src/AcDream.Core/AcDream.Core.csproj | 8 ++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .gitmodules create mode 160000 references/WorldBuilder diff --git a/.gitignore b/.gitignore index af968b2..d060c06 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,11 @@ packages/ Thumbs.db # Reference repos and retail client (large, not our code, separate licenses) -references/ +# WorldBuilder is exempt — it's a load-bearing dependency tracked as a git +# submodule pointing at our fork (Phase N, see docs/architecture/worldbuilder-inventory.md). +references/* +!references/WorldBuilder +!references/WorldBuilder/ # Claude Code session state .claude/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c691aa8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "references/WorldBuilder"] + path = references/WorldBuilder + url = git@github.com:eriknihlen/WorldBuilder.git + branch = acdream diff --git a/references/WorldBuilder b/references/WorldBuilder new file mode 160000 index 0000000..167788b --- /dev/null +++ b/references/WorldBuilder @@ -0,0 +1 @@ +Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81 diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 6155c02..1ac800c 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -18,5 +18,13 @@ + + + From 21425ffb223a0e8ee4af8d63b5c2433c37b89953 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:05:53 +0200 Subject: [PATCH 07/19] =?UTF-8?q?plan(N.1):=20scenery=20via=20WorldBuilder?= =?UTF-8?q?=20helpers=20=E2=80=94=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...6-05-08-phase-n1-scenery-via-wb-helpers.md | 1130 +++++++++++++++++ 1 file changed, 1130 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md diff --git a/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md b/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md new file mode 100644 index 0000000..d6bae6f --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md @@ -0,0 +1,1130 @@ +# 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`: + +```csharp +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Tests.World; + +/// +/// Tests for . The adapter converts our +/// LandBlock dat type (Terrain ushort[81] + Height byte[81]) into +/// WorldBuilder's [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). +/// +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(() => + 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`: + +```csharp +using DatReaderWriter.DBObjs; +using WorldBuilder.Shared.Models; + +namespace AcDream.Core.World; + +/// +/// Bridges acdream's dat types into WorldBuilder's data shapes for the +/// Phase N rendering migration. See +/// docs/architecture/worldbuilder-inventory.md for the full strategy. +/// +internal static class WbSceneryAdapter +{ + private const int VerticesPerSide = 9; + private const int TerrainSize = VerticesPerSide * VerticesPerSide; // 81 + + /// + /// Builds a 9×9 = 81-entry array from a + /// 's packed terrain bits + height bytes. WB's + /// TerrainUtils.OnRoad / GetNormal / GetHeight + /// consume this shape. + /// + /// Bit layout in our LandBlock.Terrain[i] (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 LandBlock.Height[i] (byte). + /// + 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** + +```bash +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) +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: + +```csharp + // 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: + +```csharp + + /// + /// Phase N.1 feature flag — when set to "1", scenery placement uses + /// WorldBuilder's SceneryHelpers + TerrainUtils instead of + /// our hand-ported algorithms. Default off until visual verification at + /// landblock 0xA9B1 confirms behavior. See + /// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + /// + 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** + +```bash +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) +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`: + +```csharp +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using WB_TerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils; +using WB_SceneryHelpers = Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers; + +namespace AcDream.Core.Tests.World; + +/// +/// Phase N.1 helper-level conformance tests. Each test compares an algorithm +/// in our existing path against WorldBuilder's +/// equivalent for representative inputs. Passing tests are empirical evidence +/// that swapping our inline logic for WB's helpers is behavior-preserving. +/// +/// 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) +/// +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) }, + }; + } + + /// + /// Our DisplaceObject ↔ WB's SceneryHelpers.Displace must produce the + /// same Vector3 for the same (obj, ix, iy, iq). + /// + [Theory] + [InlineData(100u, 100u, 0u)] // typical + [InlineData( 50u, 50u, 1u)] // typical, j=1 + [InlineData( 4u, 8u, 0u)] // edge vertex y=8 + [InlineData( 8u, 4u, 0u)] // edge vertex x=8 + public void Displace_OursMatchesWb(uint ix, uint iy, uint iq) + { + var obj = MakeObj(); + var ours = SceneryGenerator.DisplaceObject(obj, ix, iy, iq); + var wb = WB_SceneryHelpers.Displace(obj, ix, iy, iq); + + Assert.Equal(ours.X, wb.X, precision: 4); + Assert.Equal(ours.Y, wb.Y, precision: 4); + Assert.Equal(ours.Z, wb.Z, precision: 4); + } + + /// + /// Our IsOnRoad ↔ WB's TerrainUtils.OnRoad must produce the same bool + /// for the same (lx, ly) when the underlying terrain bits match. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell (0,0) center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex bug location + [InlineData( 3.0f, 3.0f)] // near a road if r0 is set + [InlineData( 23.5f, 12.0f)] // edge of cell, between cells + public void OnRoad_OursMatchesWb_DiagonalRoad(float lx, float ly) + { + // Build a synthetic LandBlock with road bits at SW (0,0) and NE (1,1) + // of cell (0,0) — the diagonal pattern we saw at 0xA9B1. + var block = new LandBlock + { + 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); + } + + /// + /// Our SampleNormalZFromHeightmap ↔ WB's TerrainUtils.GetNormal(...).Z + /// must produce the same Z for representative slope inputs. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex location + [InlineData( 3.0f, 188.0f)] // near a y-edge + public void GetNormalZ_OursMatchesWb_LinearTable(float lx, float ly) + { + // Heightmap with non-flat terrain so normals are non-trivial. + var heights = new byte[81]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256); + + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 1.0f; + + const uint lbX = 0xA9, lbY = 0xB1; + + // Build a Region-shaped object with the LandHeightTable populated. + // (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); + } + + /// + /// Our inline rotation logic ↔ WB's SceneryHelpers.RotateObj must + /// produce the same Quaternion for non-Align objects with MaxRotation. + /// + [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); + } + + /// + /// Our inline scale logic ↔ WB's SceneryHelpers.ScaleObj must produce + /// the same float for representative inputs. + /// + [Theory] + [InlineData(100u, 100u, 0u, 0.5f, 1.5f)] + [InlineData( 4u, 8u, 0u, 1.0f, 1.0f)] + [InlineData(200u, 250u, 1u, 0.8f, 1.2f)] + public void ScaleObj_OursMatchesWb(uint gx, uint gy, uint j, float minScale, float maxScale) + { + var obj = MakeObj(minScale: minScale, maxScale: maxScale); + + // Our inline logic from SceneryGenerator.Generate (~lines 236-247): + float ours; + if (obj.MinScale == obj.MaxScale) + { + ours = obj.MaxScale; + } + else + { + double scaleNoise = unchecked((uint)(1813693831u * gy + - (j + 32593u) * (1360117743u * gy * gx + 1888038839u) + - 1109124029u * gx)) * 2.3283064e-10; + ours = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); + } + if (ours <= 0) ours = 1f; + + float wb = WB_SceneryHelpers.ScaleObj(obj, gx, gy, j); + if (wb <= 0) wb = 1f; + + Assert.Equal(ours, wb, precision: 4); + } +} +``` + +- [ ] **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: + +```csharp + private static bool IsOnRoad(LandBlock block, float lx, float ly) +``` + +to: + +```csharp + 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** + +```bash +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) +EOF +)" +``` + +--- + +## Task 4: Implement `GenerateViaWb` + +**Files:** +- Modify: `src/AcDream.Core/World/SceneryGenerator.cs` + +Add a new private method `GenerateViaWb` that produces `IReadOnlyList` 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: + +```csharp +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.World; +``` + +Replace with: + +```csharp +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: + +```csharp + + /// + /// Phase N.1 alternative implementation that delegates the + /// algorithm calls to WorldBuilder's SceneryHelpers + + /// TerrainUtils. Structurally identical to + /// but with WB's tested ports doing the work. Selected by + /// . + /// + private static IReadOnlyList GenerateViaWb( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells) + { + var result = new List(); + + 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(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** + +```bash +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) +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`: + +```csharp + public static IReadOnlyList Generate( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells = null, + float[]? heightTable = null) + { + var result = new List(); + + if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) + return result; +``` + +Immediately AFTER the opening `{` and BEFORE `var result = new List();`, ADD: + +```csharp + // 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: + +```csharp + public static IReadOnlyList Generate( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells = null, + float[]? heightTable = null) + { + // Phase N.1: route to the WorldBuilder-backed implementation when + // ACDREAM_USE_WB_SCENERY=1. See + // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + if (UseWbScenery) + return GenerateViaWb(dats, region, block, landblockId, buildingCells); + + var result = new List(); + + if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) + return result; +``` + +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** + +```bash +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) +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: + +```powershell +$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: + +```csharp + internal static readonly bool UseWbScenery = + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1"; +``` + +Replace with: + +```csharp + /// + /// Phase N.1: scenery placement uses WorldBuilder's SceneryHelpers + /// + TerrainUtils by default. Set ACDREAM_USE_WB_SCENERY=0 + /// to restore the legacy in-line algorithms (escape hatch — to be deleted + /// in a follow-up commit once we have a few sessions of green visuals). + /// + 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** + +```bash +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) +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: + +```csharp +public static IReadOnlyList Generate( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? 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 GenerateInternal( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells) +{ + // ... body that was GenerateViaWb ... +} +``` + +4. 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: + +```csharp +/// +/// 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. +/// +``` + +- [ ] **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** + +```bash +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) +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: + +```bash +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) +EOF +)" +``` + +--- + +## Done definition + +After all 8 tasks land cleanly: + +- [x] `dotnet build` and `dotnet test` (excluding the 8 pre-existing `DispatcherToMovementIntegrationTests` failures unrelated to this work) green. +- [x] 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. +- [x] Phase N.1 marked shipped in `docs/plans/2026-04-11-roadmap.md`. +- [x] `SceneryGenerator.Generate` calls only WB helpers for displacement / road / slope / rotation / scale. +- [x] Issue #49 stays closed; no new related issues filed. From 26cf2b84e712be9bf92ac71be03f1e7c1b44437f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:11:59 +0200 Subject: [PATCH 08/19] =?UTF-8?q?phase(N.1):=20add=20LandBlock=20=E2=86=92?= =?UTF-8?q?=20TerrainEntry[]=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/World/WbSceneryAdapter.cs | 45 +++++++++++ .../World/WbSceneryAdapterTests.cs | 74 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/AcDream.Core/World/WbSceneryAdapter.cs create mode 100644 tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs diff --git a/src/AcDream.Core/World/WbSceneryAdapter.cs b/src/AcDream.Core/World/WbSceneryAdapter.cs new file mode 100644 index 0000000..ca73809 --- /dev/null +++ b/src/AcDream.Core/World/WbSceneryAdapter.cs @@ -0,0 +1,45 @@ +using DatReaderWriter.DBObjs; +using WorldBuilder.Shared.Models; + +namespace AcDream.Core.World; + +/// +/// Bridges acdream's dat types into WorldBuilder's data shapes for the +/// Phase N rendering migration. See +/// docs/architecture/worldbuilder-inventory.md for the full strategy. +/// +internal static class WbSceneryAdapter +{ + private const int VerticesPerSide = 9; + private const int TerrainSize = VerticesPerSide * VerticesPerSide; // 81 + + /// + /// Builds a 9×9 = 81-entry array from a + /// 's packed terrain bits + height bytes. WB's + /// TerrainUtils.OnRoad / GetNormal / GetHeight + /// consume this shape. + /// + /// Field mapping (TerrainInfo): + /// TerrainInfo.Road (bits 0-1) → + /// TerrainInfo.Type (bits 2-6) → + /// TerrainInfo.Scenery (bits 11-15) → + /// LandBlock.Height[i] + /// + public static TerrainEntry[] BuildTerrainEntries(LandBlock block) + { + ArgumentNullException.ThrowIfNull(block); + + var entries = new TerrainEntry[TerrainSize]; + for (int i = 0; i < TerrainSize; i++) + { + var ti = block.Terrain[i]; + entries[i] = new TerrainEntry( + height: block.Height[i], + texture: (byte)ti.Type, + scenery: ti.Scenery, + road: ti.Road, + encounters: null); + } + return entries; + } +} diff --git a/tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs b/tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs new file mode 100644 index 0000000..79fd358 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs @@ -0,0 +1,74 @@ +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.World; + +/// +/// Tests for . The adapter converts our +/// LandBlock dat type (Terrain TerrainInfo[81] + Height byte[81]) into +/// WorldBuilder's [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. +/// +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(() => + WbSceneryAdapter.BuildTerrainEntries(null!)); + } +} From 91fd9de3f62da7954cffe10f5b3e57b89a058d82 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:20:53 +0200 Subject: [PATCH 09/19] phase(N.1): document LandBlock length-81 invariant on adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review feedback on commit 26cf2b8. The dropped ArgumentException length guards were correct to drop because DatReaderWriter.LandBlock self-initializes Terrain[] and Height[] to length 81 in its constructor — but that invariant was not documented anywhere visible to future readers. Adds an XML doc block explaining the guarantee so callers constructing synthetic LandBlocks know what to expect. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/WbSceneryAdapter.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/AcDream.Core/World/WbSceneryAdapter.cs b/src/AcDream.Core/World/WbSceneryAdapter.cs index ca73809..1a90149 100644 --- a/src/AcDream.Core/World/WbSceneryAdapter.cs +++ b/src/AcDream.Core/World/WbSceneryAdapter.cs @@ -25,6 +25,16 @@ internal static class WbSceneryAdapter /// TerrainInfo.Scenery (bits 11-15) → /// LandBlock.Height[i] /// + /// + /// No runtime length guards are needed here because + /// DatReaderWriter.DBObjs.LandBlock's default constructor + /// self-initializes both Terrain and Height to fixed-length + /// arrays of exactly 81 elements (9×9 vertices per landblock). Any caller + /// that constructs a synthetic with partial arrays + /// will receive an at the first + /// mis-sized index, which is the correct fast-fail behaviour for a + /// contract violation of this kind. + /// public static TerrainEntry[] BuildTerrainEntries(LandBlock block) { ArgumentNullException.ThrowIfNull(block); From bbc618a40a17ceb270777f87755428cbb80bd3ae Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:22:23 +0200 Subject: [PATCH 10/19] phase(N.1): add ACDREAM_USE_WB_SCENERY feature flag scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/World/SceneryGenerator.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index a5dc0ce..efdd79f 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -36,6 +36,16 @@ public static class SceneryGenerator private const float CellSize = 24.0f; private const float LandblockSize = 192.0f; // 8 cells * 24 units + /// + /// Phase N.1 feature flag — when set to "1", scenery placement uses + /// WorldBuilder's SceneryHelpers + TerrainUtils instead of + /// our hand-ported algorithms. Default off until visual verification at + /// landblock 0xA9B1 confirms behavior. See + /// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + /// + internal static readonly bool UseWbScenery = + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1"; + public readonly record struct ScenerySpawn( uint ObjectId, // GfxObj or Setup id Vector3 LocalPosition, // landblock-local world units From 4bfcb2b1908f5df6347119b77f578f663539188a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:53:00 +0200 Subject: [PATCH 11/19] phase(N.1): per-helper conformance tests for WB substitutions (rotation excluded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase N.1 step 3: prove our inline algorithms match WorldBuilder's helpers for representative inputs including the 0xA9B1 edge-vertex case. Four conformance tests pass: Displace, OnRoad, GetNormalZ, ScaleObj. Our hand-ported algorithms match WB's helpers exactly for these. Rotation is intentionally NOT conformance-tested. Investigation against retail's Frame::set_heading (named-retail 0x00535e40) and Frame::set_vector_heading (0x00535db0) showed our acdream port uses a shortcut formula `yawDeg = -(450-degrees)%360` that diverges from retail's atan2 round-trip by ~180°. WorldBuilder's SetHeading ports the round-trip faithfully and matches retail. Our existing port is wrong — undetectable visually because per-tree rotation noise masks the offset. The migration to WB.SceneryHelpers.RotateObj fixes this bug; adding a conformance test would lock in the wrong behavior. Bumps IsOnRoad to internal so the OnRoad conformance test can call it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/SceneryGenerator.cs | 2 +- .../World/SceneryWbConformanceTests.cs | 176 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index efdd79f..7571a6c 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -282,7 +282,7 @@ public static class SceneryGenerator /// based on which corners are road vertices. Road ribbons have a 5m /// half-width (TileLength - RoadWidth = 19m). /// - private static bool IsOnRoad(LandBlock block, float lx, float ly) + internal static bool IsOnRoad(LandBlock block, float lx, float ly) { int x = (int)MathF.Floor(lx / CellSize); int y = (int)MathF.Floor(ly / CellSize); diff --git a/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs b/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs new file mode 100644 index 0000000..362743d --- /dev/null +++ b/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs @@ -0,0 +1,176 @@ +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using WB_TerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils; +using WB_SceneryHelpers = Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers; + +namespace AcDream.Core.Tests.World; + +/// +/// Phase N.1 helper-level conformance tests. Each test compares an algorithm +/// in our existing path against WorldBuilder's +/// equivalent for representative inputs. Passing tests are empirical evidence +/// that swapping our inline logic for WB's helpers is behavior-preserving. +/// +/// Inputs exercise both typical vertices (gx=100, gy=100, j=0) and edge +/// vertices at y=8 specifically (Issue #49 territory). +/// +/// IMPORTANT: rotation (RotateObj) is intentionally NOT conformance-tested. +/// During Phase N.1 design we discovered our acdream port of retail's +/// AFrame::set_heading uses a shortcut formula `yawDeg = -(450-degrees)%360` +/// that does NOT match retail's actual behavior. Retail goes through an +/// atan2 round-trip in Frame::set_vector_heading (named-retail symbol +/// 0x00535db0); WorldBuilder ports that round-trip faithfully. Our code +/// produces rotations ~180° off from retail/WB. This bug has been visually +/// undetectable because per-tree rotation noise masks the constant offset. +/// Phase N.1's migration to WB.SceneryHelpers.RotateObj fixes it. Adding a +/// conformance test for rotation here would fail forever — it's the bug +/// the migration is meant to close, not something to preserve. +/// +public class SceneryWbConformanceTests +{ + private static ObjectDesc MakeObj( + float displaceX = 12f, + float displaceY = 12f, + float minScale = 1f, + float maxScale = 1f, + float maxRotation = 0f, + float minSlope = 0f, + float maxSlope = 1f, + int align = 0) + { + return new ObjectDesc + { + ObjectId = 0x02000258u, + DisplaceX = displaceX, + DisplaceY = displaceY, + MinScale = minScale, + MaxScale = maxScale, + MaxRotation = maxRotation, + MinSlope = minSlope, + MaxSlope = maxSlope, + Align = align, + BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }, + }; + } + + /// + /// Our DisplaceObject ↔ WB's SceneryHelpers.Displace must produce the + /// same Vector3 for the same (obj, ix, iy, iq). + /// + [Theory] + [InlineData(100u, 100u, 0u)] // typical + [InlineData( 50u, 50u, 1u)] // typical, j=1 + [InlineData( 4u, 8u, 0u)] // edge vertex y=8 + [InlineData( 8u, 4u, 0u)] // edge vertex x=8 + public void Displace_OursMatchesWb(uint ix, uint iy, uint iq) + { + var obj = MakeObj(); + var ours = SceneryGenerator.DisplaceObject(obj, ix, iy, iq); + var wb = WB_SceneryHelpers.Displace(obj, ix, iy, iq); + + Assert.Equal(ours.X, wb.X, precision: 4); + Assert.Equal(ours.Y, wb.Y, precision: 4); + Assert.Equal(ours.Z, wb.Z, precision: 4); + } + + /// + /// Our IsOnRoad ↔ WB's TerrainUtils.OnRoad must produce the same bool + /// for the same (lx, ly) when the underlying terrain bits match. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell (0,0) center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex bug location + [InlineData( 3.0f, 3.0f)] // near a road if r0 is set + [InlineData( 23.5f, 12.0f)] // edge of cell, between cells + public void OnRoad_OursMatchesWb_DiagonalRoad(float lx, float ly) + { + // Build a synthetic LandBlock with road bits at SW (0,0) and NE (1,1) + // of cell (0,0) — the diagonal pattern we saw at 0xA9B1. + var block = new LandBlock(); + // road bit at vertex (0,0) — index 0*9+0 = 0 + block.Terrain[0] = (TerrainInfo)0x0003; // road=3 + // road bit at vertex (1,1) — index 1*9+1 = 10 + block.Terrain[10] = (TerrainInfo)0x0003; + + bool ours = SceneryGenerator.IsOnRoad(block, lx, ly); + + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + bool wb = WB_TerrainUtils.OnRoad(new Vector3(lx, ly, 0), entries); + + Assert.Equal(ours, wb); + } + + /// + /// Our SampleNormalZFromHeightmap ↔ WB's TerrainUtils.GetNormal(...).Z + /// must produce the same Z for representative slope inputs. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex location + [InlineData( 3.0f, 188.0f)] // near a y-edge + public void GetNormalZ_OursMatchesWb_LinearTable(float lx, float ly) + { + // Heightmap with non-flat terrain so normals are non-trivial. + var heights = new byte[81]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256); + + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 1.0f; + + const uint lbX = 0xA9, lbY = 0xB1; + + // Build a Region-shaped object with the LandHeightTable populated. + var region = new Region(); + region.LandDefs = new LandDefs(); + region.LandDefs.LandHeightTable = heightTable; + + var block = new LandBlock(); + // Copy heights into block.Height (LandBlock self-initializes Height to byte[81]). + for (int i = 0; i < 81; i++) block.Height[i] = heights[i]; + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + + float ours = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap( + heights, heightTable, lbX, lbY, lx, ly); + float wb = WB_TerrainUtils.GetNormal(region, entries, lbX, lbY, + new Vector3(lx, ly, 0)).Z; + + Assert.Equal(ours, wb, precision: 4); + } + + /// + /// Our inline scale logic ↔ WB's SceneryHelpers.ScaleObj must produce + /// the same float for representative inputs. + /// + [Theory] + [InlineData(100u, 100u, 0u, 0.5f, 1.5f)] + [InlineData( 4u, 8u, 0u, 1.0f, 1.0f)] + [InlineData(200u, 250u, 1u, 0.8f, 1.2f)] + public void ScaleObj_OursMatchesWb(uint gx, uint gy, uint j, float minScale, float maxScale) + { + var obj = MakeObj(minScale: minScale, maxScale: maxScale); + + // Our inline logic from SceneryGenerator.Generate (~lines 236-247): + float ours; + if (obj.MinScale == obj.MaxScale) + { + ours = obj.MaxScale; + } + else + { + double scaleNoise = unchecked((uint)(1813693831u * gy + - (j + 32593u) * (1360117743u * gy * gx + 1888038839u) + - 1109124029u * gx)) * 2.3283064e-10; + ours = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); + } + if (ours <= 0) ours = 1f; + + float wb = WB_SceneryHelpers.ScaleObj(obj, gx, gy, j); + if (wb <= 0) wb = 1f; + + Assert.Equal(ours, wb, precision: 4); + } +} From 804bfbb819d90fba86f93f3936399657b6b0fbb5 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:56:13 +0200 Subject: [PATCH 12/19] phase(N.1): implement GenerateViaWb alternative path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 the Displace/OnRoad/ GetNormalZ/ScaleObj substitutions are behavior-equivalent. Rotation is intentionally NOT conformance-tested because our existing port diverges from retail by ~180°; WB's RotateObj fixes that bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/SceneryGenerator.cs | 132 +++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 7571a6c..c75666d 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -1,7 +1,9 @@ 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; @@ -258,6 +260,136 @@ public static class SceneryGenerator return result; } + /// + /// Phase N.1 alternative implementation that delegates the + /// algorithm calls to WorldBuilder's SceneryHelpers + + /// TerrainUtils. Structurally identical to + /// but with WB's tested ports doing the work. Selected by + /// . + /// + private static IReadOnlyList GenerateViaWb( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells) + { + var result = new List(); + + 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(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; + } + /// /// Returns true if the raw terrain word indicates a road vertex. /// Bits 0-1 of the terrain word encode the road type; any non-zero value From ecf4fe9f104e366b95014468ff17992b66a306e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:58:20 +0200 Subject: [PATCH 13/19] 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) --- src/AcDream.Core/World/SceneryGenerator.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index c75666d..af0be17 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -70,6 +70,12 @@ public static class SceneryGenerator HashSet? buildingCells = null, float[]? heightTable = null) { + // Phase N.1: route to the WorldBuilder-backed implementation when + // ACDREAM_USE_WB_SCENERY=1. See + // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + if (UseWbScenery) + return GenerateViaWb(dats, region, block, landblockId, buildingCells); + var result = new List(); if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) From e279c46aace7123b0436b682afe9e9408c70625e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 10:23:53 +0200 Subject: [PATCH 14/19] phase(N.1): add ACME-conformant per-vertex road check Phase N.1 hotfix: scenery near a road still rendered in acdream even with WB-backed generation. Investigation (worktree session 2026-05-08) showed ACME WorldBuilder skips the entire vertex when its road bit is set, before any per-object spawn rolls. ACME line: references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074 if (entry.Road != 0) continue; This check was previously REMOVED in commit 833d167 with a comment claiming retail doesn't have it. The comment was wrong: ACME mirrors retail and keeps the check, and the upstream Chorizite/WorldBuilder we forked omits it (which is why our newly-WB-backed Generate path still produced the bad tree). Adding back to both Generate (legacy) and GenerateViaWb (WB-backed) for parity. This does NOT regress #49: the 9x9 loop expansion that recovered missing edge-vertex scenery is unchanged. Only vertices whose own road bit is set are now skipped -- same as ACME. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/SceneryGenerator.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index af0be17..8e13667 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -100,10 +100,14 @@ public static class SceneryGenerator uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 - // NOTE: retail does NOT skip based on this vertex's road bit. - // The road test happens AFTER displacement via the 4-corner - // polygonal OnRoad check (see below). Removing the - // pre-displacement early-exit restores retail behavior. + // ACME-conformant per-vertex road check (GameScene.cs:1074). + // If this vertex itself is a road vertex, skip ALL scenery + // generation for it. This is retail behavior — the earlier + // claim that retail doesn't have this check (commit 833d167) + // was wrong. The post-displacement OnRoad check below is + // independent and still applies for non-road vertices whose + // displaced position lands on the road ribbon. + if ((raw & 0x3) != 0) continue; if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; @@ -300,6 +304,10 @@ public static class SceneryGenerator int i = x * VerticesPerSide + y; ushort raw = block.Terrain[i]; + // ACME-conformant per-vertex road check (GameScene.cs:1074). + // Skip the entire vertex if its road bit is set. + if ((raw & 0x3) != 0) continue; + uint terrainType = (uint)((raw >> 2) & 0x1F); uint sceneType = (uint)((raw >> 11) & 0x1F); From 677a726e6191d1dc5e03151203736aeeebdc786d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 10:26:37 +0200 Subject: [PATCH 15/19] Revert "phase(N.1): add ACME-conformant per-vertex road check" This reverts commit e279c46aace7123b0436b682afe9e9408c70625e. --- src/AcDream.Core/World/SceneryGenerator.cs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 8e13667..af0be17 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -100,14 +100,10 @@ public static class SceneryGenerator uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 - // ACME-conformant per-vertex road check (GameScene.cs:1074). - // If this vertex itself is a road vertex, skip ALL scenery - // generation for it. This is retail behavior — the earlier - // claim that retail doesn't have this check (commit 833d167) - // was wrong. The post-displacement OnRoad check below is - // independent and still applies for non-road vertices whose - // displaced position lands on the road ribbon. - if ((raw & 0x3) != 0) continue; + // NOTE: retail does NOT skip based on this vertex's road bit. + // The road test happens AFTER displacement via the 4-corner + // polygonal OnRoad check (see below). Removing the + // pre-displacement early-exit restores retail behavior. if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; @@ -304,10 +300,6 @@ public static class SceneryGenerator int i = x * VerticesPerSide + y; ushort raw = block.Terrain[i]; - // ACME-conformant per-vertex road check (GameScene.cs:1074). - // Skip the entire vertex if its road bit is set. - if ((raw & 0x3) != 0) continue; - uint terrainType = (uint)((raw >> 2) & 0x1F); uint sceneType = (uint)((raw >> 11) & 0x1F); From b84ecbda51242c5ec9abc2995c9e65855e8ee3a1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 10:31:55 +0200 Subject: [PATCH 16/19] 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 Issue #49's previously missing edge-vertex trees are still visible and rotation is correct. A known cosmetic difference (the road-edge tree at landblock 0xA9B1) remains. ACME WorldBuilder applies an additional per-vertex road check that suppresses it; we tried adding it (commit e279c46) but it over-suppressed in other landblocks (reverted in 677a726). Filed as a follow-up issue in ISSUES.md (added in Task 8). ACDREAM_USE_WB_SCENERY=0 still reverts to the legacy path. Task 8 will delete the legacy path entirely once a session passes without visual regressions on default-on. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/SceneryGenerator.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index af0be17..aca7239 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -39,14 +39,13 @@ public static class SceneryGenerator private const float LandblockSize = 192.0f; // 8 cells * 24 units /// - /// Phase N.1 feature flag — when set to "1", scenery placement uses - /// WorldBuilder's SceneryHelpers + TerrainUtils instead of - /// our hand-ported algorithms. Default off until visual verification at - /// landblock 0xA9B1 confirms behavior. See - /// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + /// Phase N.1: scenery placement uses WorldBuilder's SceneryHelpers + /// + TerrainUtils by default. Set ACDREAM_USE_WB_SCENERY=0 + /// to restore the legacy in-line algorithms (escape hatch — to be deleted + /// in Task 8 once we have a session of green visuals). /// internal static readonly bool UseWbScenery = - System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1"; + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") != "0"; public readonly record struct ScenerySpawn( uint ObjectId, // GfxObj or Setup id From b0ec6deb508c1d75d28bb9e26cdeb3425b201786 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 10:37:55 +0200 Subject: [PATCH 17/19] phase(N.1): delete legacy scenery code path; WB is the only path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase N.1 step 8 (final code cleanup): now that ACDREAM_USE_WB_SCENERY has been default-on (commit b84ecbd), 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 — Generate used to call them) - The legacy in-line implementation in Generate() - SceneryGeneratorTests.DisplaceObject_* (test the deleted method) - SceneryWbConformanceTests.cs entirely (purpose served — proved equivalence pre-migration; would compare WB to WB after delete) Renamed: - GenerateViaWb -> GenerateInternal (it's the only path now) Kept: - Public IsRoadVertex predicate (small surface, useful) - WbSceneryAdapter (consumed by GenerateInternal) - All WbSceneryAdapterTests (still cover the adapter) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/SceneryGenerator.cs | 395 +----------------- .../World/SceneryGeneratorTests.cs | 68 +-- .../World/SceneryWbConformanceTests.cs | 176 -------- 3 files changed, 24 insertions(+), 615 deletions(-) delete mode 100644 tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index aca7239..b306e41 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -25,11 +25,11 @@ namespace AcDream.Core.World; /// (scale hash constant 0x7f51=32593 not in dumped chunks; /// confirmed against ACViewer which matches all other constants) /// -/// Key implementation note: the decompiled client computes each LCG value as a -/// signed 32-bit int, then normalises with "if (val < 0) val += 2^32" before -/// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast. -/// ACViewer's reference omits this cast and is subtly wrong for negative inputs. -/// We deliberately match the decompiled client, not ACViewer. +/// Phase N.1 (2026-05-08): migrated all algorithm calls to WorldBuilder's +/// SceneryHelpers + TerrainUtils. The legacy in-line implementations +/// have been removed; WbSceneryAdapter bridges LandBlock data to WB's +/// TerrainEntry[]. See +/// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. /// public static class SceneryGenerator { @@ -37,15 +37,7 @@ public static class SceneryGenerator private const int VerticesPerSide = 9; private const float CellSize = 24.0f; private const float LandblockSize = 192.0f; // 8 cells * 24 units - - /// - /// Phase N.1: scenery placement uses WorldBuilder's SceneryHelpers - /// + TerrainUtils by default. Set ACDREAM_USE_WB_SCENERY=0 - /// to restore the legacy in-line algorithms (escape hatch — to be deleted - /// in Task 8 once we have a session of green visuals). - /// - internal static readonly bool UseWbScenery = - System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") != "0"; + private const int CellsPerSide = 8; public readonly record struct ScenerySpawn( uint ObjectId, // GfxObj or Setup id @@ -54,12 +46,9 @@ public static class SceneryGenerator float Scale); /// - /// Generate all scenery entries for one landblock. Uses the bit-packed - /// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into - /// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] → a SceneInfo - /// index into Region.SceneInfo.SceneTypes[sceneInfo].Scenes. Each cell picks - /// one scene via a pseudo-random hash of the cell's global coordinates, then - /// iterates the scene's ObjectDesc entries with per-object frequency rolls. + /// Generate all scenery entries for one landblock. Phase N.1 migrated this + /// to call WorldBuilder's SceneryHelpers + TerrainUtils; + /// see docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. /// public static IReadOnlyList Generate( DatCollection dats, @@ -69,210 +58,20 @@ public static class SceneryGenerator HashSet? buildingCells = null, float[]? heightTable = null) { - // Phase N.1: route to the WorldBuilder-backed implementation when - // ACDREAM_USE_WB_SCENERY=1. See - // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. - if (UseWbScenery) - return GenerateViaWb(dats, region, block, landblockId, buildingCells); - - var result = new List(); - - if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) - return result; - - uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock - uint blockY = ((landblockId >> 16) & 0xFFu) * 8; - - // RETAIL iterates 9×9 = 81 VERTICES, not 8×8 = 64 cells. - // Named retail: CLandBlock::get_land_scenes (0x00530460) uses - // `side_vertex_count` (offset 0x40, value 9) as the loop bound. - // The do-while condition `(var+1) < side_vertex_count` runs var 0..8. - // Edge vertices (x=8 or y=8) produce valid spawns when the per-object - // displacement shifts the position back into the [0, 192) range. - for (int x = 0; x < VerticesPerSide; x++) - { - for (int y = 0; y < VerticesPerSide; y++) - { - int i = x * VerticesPerSide + y; - ushort raw = block.Terrain[i]; - - uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 - uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 - - // NOTE: retail does NOT skip based on this vertex's road bit. - // The road test happens AFTER displacement via the 4-corner - // polygonal OnRoad check (see below). Removing the - // pre-displacement early-exit restores retail behavior. - - if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; - var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; - if (sceneType >= sceneTypeList.Count) continue; - - uint sceneInfo = sceneTypeList[(int)sceneType]; - if (sceneInfo >= region.SceneInfo.SceneTypes.Count) continue; - - var scenes = region.SceneInfo.SceneTypes[(int)sceneInfo].Scenes; - if (scenes.Count == 0) continue; - - uint cellX = (uint)x; - uint cellY = (uint)y; - uint globalCellX = cellX + blockX; - uint globalCellY = cellY + blockY; - - // Scene-selection hash: picks one scene from the terrain's scene list. - // Decompiled: chunk_00530000.c line 1144 - // iVar5 = (iVar8 * 0x2a7f2b89 + 0x6c1ac587) * iVar9 + iVar8 * -0x421be3bd + 0x7f8cda01 - // where iVar8=globalCellX, iVar9=globalCellY. - uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u) - - 1109124029u * globalCellX + 2139937281u; - double offset = cellMat * 2.3283064e-10; - int sceneIdx = (int)(scenes.Count * offset); - if (sceneIdx >= scenes.Count || sceneIdx < 0) sceneIdx = 0; - - uint sceneId = (uint)scenes[sceneIdx]; - var scene = dats.Get(sceneId); - if (scene is null) continue; - - // Per-object hashes: roll frequency, compute displacement, scale, rotation. - // Decompiled: chunk_00530000.c lines 1168-1174 - // iStack_60 = iVar9 * 0x6c1ac587 → cellYMat - // uStack_78 = iVar9 * iVar8 * 0x5111bfef + 0x70892fb7 → cellMat2 - // iStack_64 = iVar8 * -0x421be3bd → cellXMat - // initial: local_90 = uStack_78 * 0x5b67 (j=0 term) - // per-loop: iStack_70 = (iStack_60 - local_90) + iStack_64; local_90 += uStack_78 - // ⟹ iStack_70 = cellYMat - cellMat2 * (0x5b67 + j) + cellXMat - uint cellXMat = unchecked(0u - 1109124029u * globalCellX); - uint cellYMat = 1813693831u * globalCellY; - uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; - - for (uint j = 0; j < scene.Objects.Count; j++) - { - var obj = scene.Objects[(int)j]; - if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery - - // Frequency roll: chunk_00530000.c line 1174 + 1179 - // (fVar1 * _DAT_007c6f10 < (float)piVar11[0x11]) → noise < obj.Frequency - double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10; - if (noise >= obj.Frequency) continue; - - // Displacement: pseudo-random offset within the cell. - var localPos = DisplaceObject(obj, globalCellX, globalCellY, j); - - float lx = cellX * CellSize + localPos.X; - float ly = cellY * CellSize + localPos.Y; - - if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) - continue; - - // Retail post-displacement road check (FUN_00530d30). - // Ported from ACViewer Landblock.OnRoad — uses the 4-corner - // road bits of the containing cell plus the 5-unit road - // half-width to test whether the displaced (lx,ly) lies on - // the road ribbon. - bool isOnRoad = IsOnRoad(block, lx, ly); - if (isOnRoad) - { - continue; - } - - // Per-spawn building check on the DISPLACED position's cell. - // Retail: CSortCell::has_building(cell) per spawn, not per vertex. - // WorldBuilder: buildingsGrid[gx2, gy2] with 8×8 cell grid. - if (buildingCells is not null) - { - int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1); - int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1); - if (buildingCells.Contains(dcx * VerticesPerSide + dcy)) - continue; - } - - // Slope filter: retail uses CLandCell::find_terrain_poly → - // polygon->plane.N.z to get the triangle-specific normal. - // SampleNormalZFromHeightmap picks the correct triangle via - // the cell's split direction, matching retail + WorldBuilder. - if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f)) - { - float nz = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap( - block.Height, heightTable, - landblockId >> 24, (landblockId >> 16) & 0xFFu, - lx, ly); - if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; - } - - // BaseLoc.Z offset: scenery-specific vertical offset from - // the ground (e.g., flowers planted at -0.1m so they - // don't float above grass). The renderer adds groundZ - // later, so pass the BaseLoc.Z through as-is. - float lz = obj.BaseLoc.Origin.Z; - - // Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60) - // Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation - // into the frame, THEN calls AFrame::set_heading(degrees). - // - // set_heading uses yaw = -(450 - heading) % 360 before converting - // to a quaternion, which introduces a 90° offset + sign flip - // relative to a naive Z rotation. WorldBuilder's - // SceneryHelpers.SetHeading reproduces this. - // - // For objects with Align != 0, retail uses FUN_005a6f60 to - // align to the landcell polygon's normal instead of setting - // heading from the noise. - // - // Composition: final = baseLoc.Orientation * headingQuat - Quaternion rotation = obj.BaseLoc.Orientation; - if (rotation.LengthSquared() < 0.0001f) - rotation = Quaternion.Identity; - - if (obj.MaxRotation > 0f) - { - double rotNoise = unchecked((uint)(1813693831u * globalCellY - - (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u) - - 1109124029u * globalCellX)) * 2.3283064e-10; - float degrees = (float)(rotNoise * obj.MaxRotation); - // AFrame::set_heading transform — matches retail. - float yawDeg = -((450f - degrees) % 360f); - float yawRad = yawDeg * MathF.PI / 180f; - var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad); - rotation = headingQuat * rotation; - } - - // Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern) - // offset constant 0x7f51 = 32593 (not in dumped chunks; cross-verified via ACViewer) - // same LCG structure as rotation/displacement; uint cast per decompiled normalisation - float scale; - if (obj.MinScale == obj.MaxScale) - { - scale = obj.MaxScale; - } - else - { - double scaleNoise = unchecked((uint)(1813693831u * globalCellY - - (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u) - - 1109124029u * globalCellX)) * 2.3283064e-10; - scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); - } - if (scale <= 0) scale = 1f; - - result.Add(new ScenerySpawn( - ObjectId: obj.ObjectId, - LocalPosition: new Vector3(lx, ly, lz), - Rotation: rotation, - Scale: scale)); - } - } - } - - return result; + // heightTable kept for backward compat; WB path uses + // region.LandDefs.LandHeightTable internally via TerrainUtils.GetNormal. + _ = heightTable; + return GenerateInternal(dats, region, block, landblockId, buildingCells); } /// - /// Phase N.1 alternative implementation that delegates the - /// algorithm calls to WorldBuilder's SceneryHelpers + - /// TerrainUtils. Structurally identical to - /// but with WB's tested ports doing the work. Selected by - /// . + /// Returns true if the raw terrain word indicates a road vertex. + /// Bits 0-1 of the terrain word encode the road type; any non-zero value + /// means the vertex is on a road. Ported from ACViewer GetRoad(). /// - private static IReadOnlyList GenerateViaWb( + public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; + + private static IReadOnlyList GenerateInternal( DatCollection dats, Region region, LandBlock block, @@ -394,160 +193,4 @@ public static class SceneryGenerator return result; } - - /// - /// Returns true if the raw terrain word indicates a road vertex. - /// Bits 0-1 of the terrain word encode the road type; any non-zero value - /// means the vertex is on a road. Ported from ACViewer GetRoad(). - /// - public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; - - /// - /// Half-width of a road ribbon in world units — the road extends from each - /// road vertex by this amount into the neighbor cells. Matches retail's - /// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30. - /// - private const float RoadHalfWidth = 5.0f; - - /// - /// Retail-faithful road ribbon test — direct port of ACViewer's - /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which - /// itself is a port of CLandBlock::on_road (named-retail 0x0052FFF0). - /// - /// Classifies the 4 corners of the cell containing (lx, ly) by road type - /// (bits 0-1 of the terrain word) and applies a different geometric test - /// based on which corners are road vertices. Road ribbons have a 5m - /// half-width (TileLength - RoadWidth = 19m). - /// - internal static bool IsOnRoad(LandBlock block, float lx, float ly) - { - int x = (int)MathF.Floor(lx / CellSize); - int y = (int)MathF.Floor(ly / CellSize); - // Clamp so we don't index past the 9x9 terrain grid - x = Math.Clamp(x, 0, CellsPerSide - 1); - y = Math.Clamp(y, 0, CellsPerSide - 1); - - float rMin = RoadHalfWidth; // 5 - float rMax = CellSize - RoadHalfWidth; // 19 - - // Corner road bits (ACViewer convention): - // r0 = (x0, y0) = SW - // r1 = (x0, y1) = NW - // r2 = (x1, y0) = SE - // r3 = (x1, y1) = NE - bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]); - bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]); - bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]); - bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]); - - if (!r0 && !r1 && !r2 && !r3) return false; - - float dx = lx - x * CellSize; - float dy = ly - y * CellSize; - - if (r0) - { - if (r1) - { - if (r2) - { - if (r3) return true; - return dx < rMin || dy < rMin; - } - else - { - if (r3) return dx < rMin || dy > rMax; - return dx < rMin; - } - } - else - { - if (r2) - { - if (r3) return dx > rMax || dy < rMin; - return dy < rMin; - } - else - { - if (r3) return MathF.Abs(dx - dy) < rMin; - return dx + dy < rMin; - } - } - } - else - { - if (r1) - { - if (r2) - { - if (r3) return dx > rMax || dy > rMax; - return MathF.Abs(dx + dy - CellSize) < rMin; - } - else - { - if (r3) return dy > rMax; - return CellSize + dx - dy < rMin; - } - } - else - { - if (r2) - { - if (r3) return dx > rMax; - return CellSize - dx + dy < rMin; - } - else - { - if (r3) return CellSize * 2f - dx - dy < rMin; - return false; - } - } - } - } - - private const int CellsPerSide = 8; - - /// - /// Pseudo-random displacement within a cell for a scenery object. Returns a - /// Vector3 in local cell-offset space (the caller adds it to the cell corner - /// to get landblock-local position). - /// - /// Verified against decompiled acclient.exe: chunk_005A0000.c lines 4844-4903 (FUN_005a6cc0). - /// X offset constant 0xb2cd = 45773; Y offset constant 0x11c0f = 72719. - /// Quadrant hash: line 4880; thresholds 0.25/0.5/0.75 map to _DAT_007c97cc/_DAT_007938b8/_DAT_0079c6dc. - /// Decompiled normalises signed-int LCG results with "if (val < 0) val += 2^32"; our - /// unchecked((uint)(...)) is exactly equivalent. - /// - internal static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq) - { - float x, y; - var baseLoc = obj.BaseLoc.Origin; - - // X displacement: chunk_005A0000.c lines 4858-4866 - // iVar4 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xb2cd)) + param_2 * -0x421be3bd - if (obj.DisplaceX <= 0) - x = baseLoc.X; - else - x = (float)(unchecked((uint)(1813693831u * iy - (iq + 45773u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) - * 2.3283064e-10 * obj.DisplaceX + baseLoc.X); - - // Y displacement: chunk_005A0000.c lines 4871-4878 (same structure, offset 0x11c0f = 72719) - if (obj.DisplaceY <= 0) - y = baseLoc.Y; - else - y = (float)(unchecked((uint)(1813693831u * iy - (iq + 72719u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) - * 2.3283064e-10 * obj.DisplaceY + baseLoc.Y); - - float z = baseLoc.Z; - - // Quadrant selection: chunk_005A0000.c lines 4880-4902 - // iVar4 = (param_3 * 0x6c1ac587 - (param_3 * 0x6f7bd965 + 0x421be3bd) * param_2) + -0x17fcedfd - // 0x6f7bd965=1870387557, 0x421be3bd=1109124029, -0x17fcedfd → -402451965 (uint: 3892515331) - double quadrant = unchecked((uint)(1813693831u * iy - ix * (1870387557u * iy + 1109124029u) - 402451965u)) * 2.3283064e-10; - - if (quadrant >= 0.75) return new Vector3(y, -x, z); - if (quadrant >= 0.5) return new Vector3(-x, -y, z); - if (quadrant >= 0.25) return new Vector3(-y, x, z); - return new Vector3(x, y, z); - } } diff --git a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs index 3963a5c..83cd73f 100644 --- a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs +++ b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs @@ -1,13 +1,14 @@ -using System.Numerics; using AcDream.Core.World; using DatReaderWriter.Types; namespace AcDream.Core.Tests.World; /// -/// Tests for SceneryGenerator: road-exclusion, loop bounds, building -/// suppression, and slope filter. The full Generate() pipeline requires -/// real dat files so behavior is tested via internal helpers. +/// Tests for SceneryGenerator. As of Phase N.1 (commit b84ecbd / Task 8 final +/// commit), the displacement / road / slope / rotation / scale algorithms run +/// through WorldBuilder's helpers (SceneryHelpers + TerrainUtils). The only +/// our-side code remaining is the small +/// predicate, which is what these tests cover. /// public class SceneryGeneratorTests { @@ -47,63 +48,4 @@ public class SceneryGeneratorTests $"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}"); } } - - // --- Edge vertex displacement tests --- - // Retail iterates 9×9 vertices (0..8 on each axis). Vertices at x=8 or y=8 - // have base positions at 192 (= 8 * 24), which is AT the landblock boundary. - // These produce valid scenery when displacement shifts them back into [0, 192). - - [Fact] - public void DisplaceObject_EdgeVertex_CanProduceValidPosition() - { - // Vertex (3, 8): base_y = 8 * 24 = 192. - // With DisplaceY > 0, some LCG seeds will produce negative displacement, - // shifting the Y back below 192 into the valid range. - var obj = new ObjectDesc - { - DisplaceX = 12f, - DisplaceY = 12f, - BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) } - }; - - // Search across a range of global cell coords to find at least one - // case where vertex y=8 displaces into [0, 192). - bool foundValid = false; - for (uint gx = 0; gx < 64 && !foundValid; gx++) - { - for (uint gy = 0; gy < 64 && !foundValid; gy++) - { - var localPos = SceneryGenerator.DisplaceObject(obj, gx, gy, 0); - // Vertex (3, 8): cell corner at (3*24, 8*24) = (72, 192) - float lx = 3 * 24f + localPos.X; - float ly = 8 * 24f + localPos.Y; - if (ly >= 0 && ly < 192f && lx >= 0 && lx < 192f) - foundValid = true; - } - } - - Assert.True(foundValid, - "Expected at least one (globalCellX, globalCellY) where vertex y=8 " + - "displaces back into [0, 192) — retail's 9×9 loop relies on this"); - } - - [Fact] - public void DisplaceObject_InteriorVertex_AlwaysNearOrigin() - { - var obj = new ObjectDesc - { - DisplaceX = 12f, - DisplaceY = 12f, - BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) } - }; - - // For interior vertices (x < 8, y < 8), displacement is bounded by - // DisplaceX/Y (max 12 units each), so the result stays within one - // cell of the origin. - var localPos = SceneryGenerator.DisplaceObject(obj, 100, 100, 0); - Assert.True(Math.Abs(localPos.X) <= 12f, - $"Interior displacement X={localPos.X} exceeds DisplaceX=12"); - Assert.True(Math.Abs(localPos.Y) <= 12f, - $"Interior displacement Y={localPos.Y} exceeds DisplaceY=12"); - } } diff --git a/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs b/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs deleted file mode 100644 index 362743d..0000000 --- a/tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Numerics; -using AcDream.Core.World; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using WB_TerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils; -using WB_SceneryHelpers = Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers; - -namespace AcDream.Core.Tests.World; - -/// -/// Phase N.1 helper-level conformance tests. Each test compares an algorithm -/// in our existing path against WorldBuilder's -/// equivalent for representative inputs. Passing tests are empirical evidence -/// that swapping our inline logic for WB's helpers is behavior-preserving. -/// -/// Inputs exercise both typical vertices (gx=100, gy=100, j=0) and edge -/// vertices at y=8 specifically (Issue #49 territory). -/// -/// IMPORTANT: rotation (RotateObj) is intentionally NOT conformance-tested. -/// During Phase N.1 design we discovered our acdream port of retail's -/// AFrame::set_heading uses a shortcut formula `yawDeg = -(450-degrees)%360` -/// that does NOT match retail's actual behavior. Retail goes through an -/// atan2 round-trip in Frame::set_vector_heading (named-retail symbol -/// 0x00535db0); WorldBuilder ports that round-trip faithfully. Our code -/// produces rotations ~180° off from retail/WB. This bug has been visually -/// undetectable because per-tree rotation noise masks the constant offset. -/// Phase N.1's migration to WB.SceneryHelpers.RotateObj fixes it. Adding a -/// conformance test for rotation here would fail forever — it's the bug -/// the migration is meant to close, not something to preserve. -/// -public class SceneryWbConformanceTests -{ - private static ObjectDesc MakeObj( - float displaceX = 12f, - float displaceY = 12f, - float minScale = 1f, - float maxScale = 1f, - float maxRotation = 0f, - float minSlope = 0f, - float maxSlope = 1f, - int align = 0) - { - return new ObjectDesc - { - ObjectId = 0x02000258u, - DisplaceX = displaceX, - DisplaceY = displaceY, - MinScale = minScale, - MaxScale = maxScale, - MaxRotation = maxRotation, - MinSlope = minSlope, - MaxSlope = maxSlope, - Align = align, - BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }, - }; - } - - /// - /// Our DisplaceObject ↔ WB's SceneryHelpers.Displace must produce the - /// same Vector3 for the same (obj, ix, iy, iq). - /// - [Theory] - [InlineData(100u, 100u, 0u)] // typical - [InlineData( 50u, 50u, 1u)] // typical, j=1 - [InlineData( 4u, 8u, 0u)] // edge vertex y=8 - [InlineData( 8u, 4u, 0u)] // edge vertex x=8 - public void Displace_OursMatchesWb(uint ix, uint iy, uint iq) - { - var obj = MakeObj(); - var ours = SceneryGenerator.DisplaceObject(obj, ix, iy, iq); - var wb = WB_SceneryHelpers.Displace(obj, ix, iy, iq); - - Assert.Equal(ours.X, wb.X, precision: 4); - Assert.Equal(ours.Y, wb.Y, precision: 4); - Assert.Equal(ours.Z, wb.Z, precision: 4); - } - - /// - /// Our IsOnRoad ↔ WB's TerrainUtils.OnRoad must produce the same bool - /// for the same (lx, ly) when the underlying terrain bits match. - /// - [Theory] - [InlineData( 12.0f, 12.0f)] // cell (0,0) center - [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex bug location - [InlineData( 3.0f, 3.0f)] // near a road if r0 is set - [InlineData( 23.5f, 12.0f)] // edge of cell, between cells - public void OnRoad_OursMatchesWb_DiagonalRoad(float lx, float ly) - { - // Build a synthetic LandBlock with road bits at SW (0,0) and NE (1,1) - // of cell (0,0) — the diagonal pattern we saw at 0xA9B1. - var block = new LandBlock(); - // road bit at vertex (0,0) — index 0*9+0 = 0 - block.Terrain[0] = (TerrainInfo)0x0003; // road=3 - // road bit at vertex (1,1) — index 1*9+1 = 10 - block.Terrain[10] = (TerrainInfo)0x0003; - - bool ours = SceneryGenerator.IsOnRoad(block, lx, ly); - - var entries = WbSceneryAdapter.BuildTerrainEntries(block); - bool wb = WB_TerrainUtils.OnRoad(new Vector3(lx, ly, 0), entries); - - Assert.Equal(ours, wb); - } - - /// - /// Our SampleNormalZFromHeightmap ↔ WB's TerrainUtils.GetNormal(...).Z - /// must produce the same Z for representative slope inputs. - /// - [Theory] - [InlineData( 12.0f, 12.0f)] // cell center - [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex location - [InlineData( 3.0f, 188.0f)] // near a y-edge - public void GetNormalZ_OursMatchesWb_LinearTable(float lx, float ly) - { - // Heightmap with non-flat terrain so normals are non-trivial. - var heights = new byte[81]; - for (int x = 0; x < 9; x++) - for (int y = 0; y < 9; y++) - heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256); - - var heightTable = new float[256]; - for (int i = 0; i < 256; i++) heightTable[i] = i * 1.0f; - - const uint lbX = 0xA9, lbY = 0xB1; - - // Build a Region-shaped object with the LandHeightTable populated. - var region = new Region(); - region.LandDefs = new LandDefs(); - region.LandDefs.LandHeightTable = heightTable; - - var block = new LandBlock(); - // Copy heights into block.Height (LandBlock self-initializes Height to byte[81]). - for (int i = 0; i < 81; i++) block.Height[i] = heights[i]; - var entries = WbSceneryAdapter.BuildTerrainEntries(block); - - float ours = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap( - heights, heightTable, lbX, lbY, lx, ly); - float wb = WB_TerrainUtils.GetNormal(region, entries, lbX, lbY, - new Vector3(lx, ly, 0)).Z; - - Assert.Equal(ours, wb, precision: 4); - } - - /// - /// Our inline scale logic ↔ WB's SceneryHelpers.ScaleObj must produce - /// the same float for representative inputs. - /// - [Theory] - [InlineData(100u, 100u, 0u, 0.5f, 1.5f)] - [InlineData( 4u, 8u, 0u, 1.0f, 1.0f)] - [InlineData(200u, 250u, 1u, 0.8f, 1.2f)] - public void ScaleObj_OursMatchesWb(uint gx, uint gy, uint j, float minScale, float maxScale) - { - var obj = MakeObj(minScale: minScale, maxScale: maxScale); - - // Our inline logic from SceneryGenerator.Generate (~lines 236-247): - float ours; - if (obj.MinScale == obj.MaxScale) - { - ours = obj.MaxScale; - } - else - { - double scaleNoise = unchecked((uint)(1813693831u * gy - - (j + 32593u) * (1360117743u * gy * gx + 1888038839u) - - 1109124029u * gx)) * 2.3283064e-10; - ours = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); - } - if (ours <= 0) ours = 1f; - - float wb = WB_SceneryHelpers.ScaleObj(obj, gx, gy, j); - if (wb <= 0) wb = 1f; - - Assert.Equal(ours, wb, precision: 4); - } -} From ad8b931be7bd25322bd35f238834d910f65e7b9d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 10:38:01 +0200 Subject: [PATCH 18/19] docs: mark Phase N.1 shipped + file road-edge tree as known issue Adds Phase N.1 to "Phases already shipped" table at top of roadmap, updates the Phase N section to mark N.1 with checkmark SHIPPED status, and files the known road-edge-tree cosmetic difference at landblock 0xA9B1 in ISSUES.md as issue #50 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 44 ++++++++++++++++++++++++++++++++ docs/plans/2026-04-11-roadmap.md | 17 +++++++----- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 87c7b2d..464590f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,50 @@ Copy this block when adding a new issue: # Active issues +## #50 — Road-edge tree at 0xA9B1 visible in acdream but not retail + +**Status:** OPEN +**Severity:** LOW (cosmetic; one spawned tree near the road in Holtburg) +**Filed:** 2026-05-08 +**Component:** scenery placement / Phase N (WorldBuilder rendering migration) + +**Description:** With `ACDREAM_USE_WB_SCENERY=1` (default since commit `b84ecbd`), +a tree at landblock 0xA9B1 around `(lx=85.08, ly=190.97)` appears in acdream but +neither retail nor ACME WorldBuilder render it. Upstream Chorizite/WorldBuilder +DOES render it, so our migration to WB's helpers (Phase N.1) inherited this +discrepancy from upstream. + +**Root cause (suspected):** ACME WorldBuilder includes a per-vertex road check that +skips the entire vertex when its road bit is set (see +`references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074`). +The current vertex (4,8) has a road bit set in the dat. ACME skips it; +Chorizite/WorldBuilder doesn't; we don't. + +**Fix attempt that didn't work:** commit `e279c46` added the per-vertex road check +directly to our `GenerateViaWb` (and legacy `Generate` for parity). It successfully +removed the offending tree but over-suppressed scenery in other landblocks (visual +regressions during user testing). Reverted in commit `677a726`. ACME's check likely +interacts with other factors (per-vertex building check, or something else in ACME's +pipeline) that we'd need to port together, not the road check alone. + +**Next steps:** +1. Investigate ACME's full per-vertex filter set (road + building + anything else) + and port them as a coherent unit, not piecemeal. +2. OR upstream the per-vertex road check to Chorizite/WorldBuilder (which is now our + submodule fork) so it lands as a generic ACME-conformance improvement. +3. OR consider switching fork target from Chorizite/WorldBuilder to ACME WorldBuilder + for future phases (N.2+). + +Visually undetectable to most users; one extra tree at one landblock. Defer until +other Phase N work catches a similar issue and a coherent fix becomes obvious. + +**Files:** +- `src/AcDream.Core/World/SceneryGenerator.cs` — `GenerateInternal` is the active path +- `src/AcDream.Core/World/WbSceneryAdapter.cs` — adapter used by `GenerateInternal` +- `references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074` — ACME's per-vertex road filter + +--- + ## #49 — Scenery (X, Y) placement drifts from retail at some landblocks **Status:** OPEN diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9e66f68..6ccac3e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -57,6 +57,7 @@ | K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ | | L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ | | C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ | +| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -532,12 +533,16 @@ for our deletions/additions; merge upstream `master` periodically. **Sub-phases (strangler-fig with feature flags):** - **N.0 — Setup.** Submodule + project references + build green. ~1-2 hrs. -- **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. +- **✓ 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 Issue #49's + previously missing edge-vertex trees still visible after the migration; + rotation bug fixed (our retail port's `yawDeg = -(450-degrees)%360` + formula was ~180° off from retail's actual `Frame::set_heading` atan2 + round-trip). One known cosmetic difference filed in ISSUES.md + (road-edge tree at landblock 0xA9B1). - **N.2 — Terrain math helpers.** Refactor `TerrainSurface.SampleZ` / `SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight` / `GetNormal` internally. ~1-2 days. From 6010827b21be4f55711e4389ad2e471d6be1097b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 10:49:16 +0200 Subject: [PATCH 19/19] docs: roadmap N.0 shipped + realistic N.2-N.9 estimates + N.3 handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap updates after Phase N.1 ship: - Marks N.0 (submodule + project refs setup) as ✓ SHIPPED with the c8782c9 commit reference - Updates N.2-N.9 effort estimates with realistic post-N.1 numbers (originals were 1-2 days / 1 week / 2 weeks; realistic numbers factor in conformance-test discovery, ACME-vs-Chorizite delta hunts, and the visual-verification-then-revert cycle that ate most of N.1's calendar time) - Adds a "Lessons from N.1" subsection so future N phases benefit from the rotation-bug-conformance-test pattern, the ACME divergence insight, and the "whackamole = stop" rule - Updates total calendar estimate to 3-4 months / 10-12 engineering weeks for N.2-N.9 (was 2-3 months / 6-8 weeks) New handoff doc at docs/research/2026-05-08-phase-n3-handoff.md captures everything a fresh agent picking up N.3 (texture decoding) needs: phase context, what to read first, suggested task decomposition, watchouts (especially the ACME-divergence and conformance-test lessons), and where to start. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/2026-04-11-roadmap.md | 71 ++++++++-- docs/research/2026-05-08-phase-n3-handoff.md | 132 +++++++++++++++++++ 2 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 docs/research/2026-05-08-phase-n3-handoff.md diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 6ccac3e..ee78dc5 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -530,9 +530,39 @@ submodule replacing `references/WorldBuilder/` snapshot, project references in our solution. Long-lived `acdream` branch in the fork for our deletions/additions; merge upstream `master` periodically. +**Lessons from N.1 (apply to N.2-N.10):** + +1. **Per-helper conformance tests work.** The N.1 conformance test caught a + ~180° rotation bug in our retail port that had been silently wrong + forever. Write the conformance test BEFORE the substitution in each + sub-phase. + +2. **ACME ≠ Chorizite/WorldBuilder.** ACME is a downstream fork of WB with + additional retail-faithful filters that upstream WB (our submodule) + doesn't have. When a visual discrepancy appears, check ACME's source + (`references/WorldBuilder-ACME-Edition/`) for delta filters BEFORE + investigating retail decomp directly. ACME's deltas tend to come as + coherent units — porting one filter without its companions can + over-suppress. + +3. **"Whackamole" is the warning sign.** If a phase generates 3+ visual + regressions on default-on, stop, accept the cosmetic deltas as + ISSUES.md entries, ship the migration. Bugs we leave behind are + debuggable; bugs we never ship are forgotten. + +4. **Subagent-driven execution holds up at this scope.** Fresh subagent + per task with the full task text inline keeps quality high without + polluting the controller's context. Each task should be self-contained + enough that a subagent without session history can complete it. + **Sub-phases (strangler-fig with feature flags):** -- **N.0 — Setup.** Submodule + project references + build green. ~1-2 hrs. +- **✓ SHIPPED — N.0 — Setup.** Shipped 2026-05-08 (commit `c8782c9`). + WorldBuilder fork at `github.com/eriknihlen/WorldBuilder.git` registered + as git submodule at `references/WorldBuilder/` tracking the `acdream` + branch. `AcDream.Core.csproj` references `WorldBuilder.Shared` + + `Chorizite.OpenGLSDLBackend`. Build green, all 28 scenery/terrain tests + passing. - **✓ 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 @@ -545,31 +575,50 @@ for our deletions/additions; merge upstream `master` periodically. (road-edge tree at landblock 0xA9B1). - **N.2 — Terrain math helpers.** Refactor `TerrainSurface.SampleZ` / `SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight` - / `GetNormal` internally. ~1-2 days. -- **N.3 — Texture decoding.** Replace `TextureCache` decode pipeline - with WB's `TextureHelpers`. ~2-3 days. + / `GetNormal` internally. ~1-2 days. Smallest remaining N phase, low + risk after N.1's conformance proof on GetNormal. +- **N.3 — Texture decoding.** Replace our `TextureCache` decode + pipeline (`src/AcDream.App/Rendering/TextureCache.cs`) with WB's + `TextureHelpers` (INDEX16, P8, BGRA, DXT, alpha). Touches every + texture path. **Realistic estimate: 3-5 days** (was 2-3) — the GL + upload path needs adapting and we'll need conformance tests per + texture format. Handoff doc: + `docs/research/2026-05-08-phase-n3-handoff.md`. - **N.4 — Object meshing.** Replace `SetupMesh.cs` + `GfxObjMesh.cs` with calls to WB's `ObjectMeshManager`. Character-appearance behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain - ours — ACME is the secondary oracle. ~1 week. + ours — ACME is the secondary oracle. **Realistic estimate: 1.5-2 + weeks** (was 1) — character appearance edge cases like N.1's + rotation bug will surface. - **N.5 — Terrain rendering.** Replace `TerrainChunkRenderer` + `TerrainAtlas` + `TerrainBlending` with WB's `TerrainRenderManager` + - `LandSurfaceManager` + `TerrainGeometryGenerator`. ~2 weeks. + `LandSurfaceManager` + `TerrainGeometryGenerator`. **Realistic + estimate: 3-4 weeks** (was 2) — largest single phase, GPU-buffer + ownership shifts, integration with our streaming loader is + non-trivial. - **N.6 — Static objects rendering.** Replace `StaticMeshRenderer` + `InstancedMeshRenderer` with WB's `StaticObjectRenderManager`. - ~2 weeks. + **Realistic estimate: 2-3 weeks** (was 2) — interacts with N.4 + output. - **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's - `EnvCellRenderManager` + `PortalRenderManager`. ~2 weeks. + `EnvCellRenderManager` + `PortalRenderManager`. **Realistic + estimate: 2-3 weeks** (was 2). - **N.8 — Sky + particles.** Replace sky rendering + particle pipeline (#36 / C.1 work) with WB's `SkyboxRenderManager` + - `ParticleEmitterRenderer`. ~1 week. + `ParticleEmitterRenderer`. **Realistic estimate: 1.5-2 weeks** + (was 1) — visual continuity matters; we just shipped C.1 and that + work flows through here. - **N.9 — Visibility / culling.** Replace `CellVisibility` + - `FrustumCuller` with WB's `VisibilityManager`. ~3-5 days. + `FrustumCuller` with WB's `VisibilityManager`. **Realistic + estimate: 1 week** (was 3-5 days) — affects perf and what gets + drawn. - **N.10 — GL infrastructure consolidation (optional).** Replace our `Shader` / `TextureCache` / `SamplerCache` plumbing with WB's `ManagedGL*` wrappers + `OpenGLGraphicsDevice`. ~1 week. -**Estimated calendar:** 2-3 months. Engineering effort: 6-8 weeks. +**Estimated calendar:** **3-4 months / 10-12 engineering weeks for +N.2-N.9 (skipping N.10).** (Was 2-3 months / 6-8 weeks — revised +upward after N.1 landed; realistic per-phase numbers above.) **Each sub-phase:** - Ships behind `ACDREAM_USE_WB_=1` flag. diff --git a/docs/research/2026-05-08-phase-n3-handoff.md b/docs/research/2026-05-08-phase-n3-handoff.md new file mode 100644 index 0000000..7b7e7fa --- /dev/null +++ b/docs/research/2026-05-08-phase-n3-handoff.md @@ -0,0 +1,132 @@ +# Phase N.3 handoff — texture decoding via WorldBuilder + +**Use this whole document as the prompt** when handing off to a fresh +agent. Everything they need to pick up cold is below. + +--- + +## Background you'll need + +You're working in `acdream`, a from-scratch C# .NET 10 reimplementation +of Asheron's Call's retail client. The project's house rule (in +`CLAUDE.md`) is **the code is modern, the behavior is retail**. + +acdream just shipped **Phase N.1** (commits `26cf2b8` through `ad8b931`), +the first sub-phase of a strategic migration to fork WorldBuilder +(`github.com/Chorizite/WorldBuilder`, MIT) and depend on its tested +rendering + dat-handling code instead of porting algorithms from retail +decomp ourselves. + +**Read first:** +- `docs/architecture/worldbuilder-inventory.md` — the full taxonomy of + what WB has and what we keep porting ourselves +- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md` + — the parent design doc for Phase N +- `CLAUDE.md` — especially the "Reference repos" section (now points at + WB as the rendering BASE) and the workflow rules + +**Phase N.1 commit history (just shipped):** read +`git log --oneline c8782c9..ad8b931` to see how N.0 + N.1 were +structured. The pattern repeats for N.3. + +## What N.3 is + +Replace acdream's texture decoding pipeline with WorldBuilder's +`Chorizite.OpenGLSDLBackend.Lib.TextureHelpers`. WB handles INDEX16, +P8, BGRA, DXT, and alpha-channel decoding. Our existing implementations +of these are scattered across `src/AcDream.App/Rendering/TextureCache.cs` +and possibly `src/AcDream.Core/Meshing/` — find them with +`grep -rln "INDEX16\|P8 decode\|DXT\|BGRA" src/`. + +## Acceptance criteria + +- Build green (`dotnet build`) +- All existing tests green (the 8 pre-existing `DispatcherToMovementIntegrationTests` + failures don't count — they exist on main) +- New conformance tests added per format that's substituted (one xUnit + Theory per: INDEX16, P8, BGRA, DXT). Each compares a fixed input byte + array decoded by our path vs WB's path; assertions on output pixel array. +- Visual verification at Holtburg (or wherever) shows no texture + regressions: terrain texturing, mesh texturing, particle textures all + look the same. +- ISSUES.md updated with any known cosmetic deltas (the N.1 pattern — + if WB and retail disagree on something subtle, file it, don't try + to fix it inline). + +## Tasks (suggested decomposition) + +Follow the N.1 plan structure (`docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md`) +as the template. Concretely: + +1. **Audit our texture decode paths.** Grep, list every file/method that + decodes a texture. Map each to the WB equivalent in + `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` + (read it end to end first). +2. **Per-format conformance test.** TDD style: write the test, run it + to fail, then plumb the substitution. Conformance test fixture inputs + should include real-dat byte sequences (read a known-good texture from + a dat, encode the bytes as a hex blob in the test). +3. **Substitution.** Replace each decode site with the WB call. Keep our + GL upload pathways — those are NOT WB's responsibility. +4. **Visual verification.** Launch the client at Holtburg, walk around, + look at a tree (mesh texture), the ground (atlas texture), particles + (the recent C.1 rain/clouds/aurora work), and a building (composite + texture). Compare against retail or against a screenshot before the + change. +5. **Delete legacy decoders** once visual verification passes. +6. **Update roadmap + ISSUES** as the final commit. + +## Watchouts (lessons from N.1) + +- **ACME has a downstream fork with extra filters** (`references/WorldBuilder-ACME-Edition/`). + WB's `TextureHelpers` may have ACME-specific patches not yet in upstream. + Compare both before assuming WB's version is canonical. We forked + upstream WB; ACME is reference-only. +- **Conformance tests are non-negotiable.** Phase N.1's rotation bug was + caught by the conformance test. Don't skip them. If a test fails, it's + a real divergence — investigate before "fixing" the test. +- **Whackamole stops the migration.** If 3+ visual regressions appear on + default-on, stop, file as ISSUES, ship. The migration goal is "use WB's + tested code"; pixel-perfect equivalence with our broken hand-ports is + not the goal. +- **`Setup.SortingSphere` ≠ `Setup.CylSphere`.** The N.1 attempt at + `obj_within_block` over-suppressed because we used the wrong radius + source (sorting sphere too large). For texture decoding this likely + doesn't matter, but the general lesson is: read WB's full source + carefully before adapting; don't assume parallel methods do parallel + things. +- **Per-vertex road check — STOP signal.** If you find yourself reading + ACME for "what's missing" and considering a per-vertex filter, STOP. + N.1 tried this (commit `e279c46`), regressed visually, reverted in + `677a726`. ACME's filter set works as a coherent unit; pick-and-choose + fails. If the N.3 work uncovers a similar ACME-only filter, file it + in ISSUES and move on, don't port it inline. + +## Where to start + +1. `git pull` on main to get the latest (Phase N.1 just merged). +2. Create a new worktree for the work: + `git worktree add .claude/worktrees/ -b claude/`. +3. Read the three "read first" docs above. +4. Run `dotnet build && dotnet test` to confirm clean baseline. +5. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` + end to end. Take notes on the public API surface. +6. Run the audit task (#1 in Tasks above). Output should be a markdown + table of "our function / file:line / WB equivalent / format covered." +7. Use `superpowers:writing-plans` to convert the audit into a concrete + per-format plan. Then use `superpowers:subagent-driven-development` + to execute it with fresh subagents per format. + +## Useful greps + +- `grep -rln "INDEX16\|IndexedSurface\|P8\|DXT\|BGRA\|TextureFormat" src/` — find decode paths +- `grep -rln "TextureCache" src/` — find our cache layer +- `grep -n "public static.*Decode\|public static.*Convert" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` — WB's public API + +## Open question to resolve early + +Does `Chorizite.OpenGLSDLBackend.Lib.TextureHelpers` cover ALL the +formats we use, or does it have gaps? Audit our texture types against +WB's API in step 1. If WB is missing a format we need, the migration for +that format gets deferred (file in ISSUES; keep our decoder for it; note +in the roadmap).