From bbc618a40a17ceb270777f87755428cbb80bd3ae Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 09:22:23 +0200 Subject: [PATCH 01/10] 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 a5dc0ce8..efdd79f8 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 02/10] 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 efdd79f8..7571a6c6 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 00000000..362743dd --- /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 03/10] 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 7571a6c6..c75666d2 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 04/10] 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 c75666d2..af0be171 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 05/10] 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 af0be171..8e136675 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 06/10] 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 8e136675..af0be171 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 07/10] 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 af0be171..aca72394 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 08/10] 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 aca72394..b306e41f 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 3963a5ce..83cd73f9 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 362743dd..00000000 --- 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 09/10] 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 87c7b2da..464590f3 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 9e66f68c..6ccac3ee 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 10/10] 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 6ccac3ee..ee78dc50 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 00000000..7b7e7fa6 --- /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).