# 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.
