spec(rendering): Phase N WorldBuilder migration design + N.1 scenery

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 08:47:23 +02:00
parent 9b210be126
commit 8a06fce7a5
3 changed files with 501 additions and 0 deletions

View file

@ -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_<NAME>=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) ### Phase J — Long-tail (deferred / low-priority)
Not detailed here; each gets its own brainstorm when it becomes relevant. Not detailed here; each gets its own brainstorm when it becomes relevant.

View file

@ -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 `<ProjectReference>`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_<NAME>=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 `<ProjectReference>` 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).

View file

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