Merge branch 'claude/angry-villani-8ae757' — Phase N.1 WorldBuilder rendering migration

This commit is contained in:
Erik 2026-05-08 10:50:35 +02:00
commit 1978ef9395
18 changed files with 2483 additions and 327 deletions

6
.gitignore vendored
View file

@ -18,7 +18,11 @@ packages/
Thumbs.db Thumbs.db
# Reference repos and retail client (large, not our code, separate licenses) # Reference repos and retail client (large, not our code, separate licenses)
references/ # WorldBuilder is exempt — it's a load-bearing dependency tracked as a git
# submodule pointing at our fork (Phase N, see docs/architecture/worldbuilder-inventory.md).
references/*
!references/WorldBuilder
!references/WorldBuilder/
# Claude Code session state # Claude Code session state
.claude/ .claude/

4
.gitmodules vendored Normal file
View file

@ -0,0 +1,4 @@
[submodule "references/WorldBuilder"]
path = references/WorldBuilder
url = git@github.com:eriknihlen/WorldBuilder.git
branch = acdream

View file

@ -25,6 +25,19 @@ single source of truth for how the client is structured. All work must
align with this document. When the architecture doc and reality diverge, align with this document. When the architecture doc and reality diverge,
update one or the other — never leave them out of sync. update one or the other — never leave them out of sync.
**WorldBuilder is acdream's rendering + dat-handling base** as of
2026-05-08. Before re-implementing any AC-specific rendering or
dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md`
FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into
our fork once wired up), not from retail decomp. WorldBuilder is
MIT-licensed, verified to render the world correctly, and uses the same
Silk.NET stack we target. Re-porting from retail decomp when WB already
has a tested port is how subtle bugs (the scenery edge-vertex bug, the
triangle-Z bug) keep slipping in. Retail decomp remains the oracle for
network, physics, animation, movement, UI, plugin, audio, chat — see
the inventory doc's 🔴 list for the full scope of "we still write this
ourselves".
**Execution phases:** R1→R8 in the architecture doc. Each phase has clear **Execution phases:** R1→R8 in the architecture doc. Each phase has clear
goals, test criteria, and builds on the previous. Don't skip phases. goals, test criteria, and builds on the previous. Don't skip phases.
@ -625,11 +638,18 @@ these, ideally all four:
for the palette-indexed formats. See for the palette-indexed formats. See
`ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical `ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical
subpalette overlay algorithm. subpalette overlay algorithm.
- **`references/WorldBuilder/`** — C# + Silk.NET dat editor. Exact-stack - **`references/WorldBuilder/`** — **acdream's rendering + dat-handling
match to acdream for rendering approaches: terrain blending, texture BASE (not just a reference).** As of 2026-05-08 acdream is moving to
atlases, shader patterns. Most useful for "how do I do this GL thing fork WorldBuilder upstream and depend on the fork for terrain,
with Silk.NET on net10 idiomatically?" Less useful for protocol or scenery, static objects, EnvCells, portals, sky, particles, texture
character appearance (dat editor, not game client). decoding, mesh extraction, visibility/culling. WorldBuilder is
MIT-licensed, exact-stack match (Silk.NET + .NET), and verified to
render the world correctly. **Before re-porting any rendering or
dat-handling algorithm from retail decomp, check
`docs/architecture/worldbuilder-inventory.md` first.** If WB has it,
use WB's port. If WB doesn't have it (network, physics, animation,
movement, UI, plugin, audio, chat), port from retail decomp as
before.
- **`references/Chorizite.ACProtocol/`** — clean-room C# protocol - **`references/Chorizite.ACProtocol/`** — clean-room C# protocol
library generated from a protocol XML description. Useful sanity check library generated from a protocol XML description. Useful sanity check
on field order, packed-dword conventions, type-prefix handling. The on field order, packed-dword conventions, type-prefix handling. The
@ -684,12 +704,15 @@ decompiled client code and would have fixed it in minutes.
| Domain | Primary Oracle | Secondary | Notes | | Domain | Primary Oracle | Secondary | Notes |
|--------|---------------|-----------|-------| |--------|---------------|-----------|-------|
| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." | | **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." Use for everything in the 🔴 list (network, physics, animation, movement, UI, plugin, audio, chat). |
| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **ACME `ClientReference.cs`** — decompiled retail client with exact offsets | ACME `TerrainGeometryGenerator.cs` (matches the mesh index buffer) | WorldBuilder original is SUPERSEDED for terrain algorithms. AC2D confirms the same formula. | | **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **WorldBuilder `TerrainGeometryGenerator.cs` + `TerrainUtils.cs`** | retail decomp for cross-check | WB is acdream's terrain base. ACME's port is older/SUPERSEDED by WB. |
| **Terrain blending** (texture atlas, alpha masks, road overlays) | **ACME `LandSurfaceManager.cs`** | WorldBuilder original `LandSurfaceManager.cs` (same code, less tested) | Both use the same TexMerge pipeline. ACME has conformance tests. | | **Terrain blending** (texture atlas, alpha masks, road overlays) | **WorldBuilder `LandSurfaceManager.cs`** | ACME `LandSurfaceManager.cs` (same algo, less complete) | WB is acdream's blending base. |
| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **ACME `StaticObjectManager.cs`** — includes CreaturePalette, GfxObjRemapping, HiddenParts | ACViewer `Render/` namespace | ACME has the complete creature appearance pipeline in one file. | | **Scenery** (procedural placement: trees, bushes, rocks, fences) | **WorldBuilder `SceneryRenderManager.cs` + `SceneryHelpers.cs`** | retail decomp `CLandBlock::get_land_scenes` | WB is acdream's scenery base. Re-porting from retail decomp is what caused the edge-vertex bug. |
| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **ACME `TextureHelpers.cs`** | ACViewer `Render/TextureCache.cs` (palette overlay = `IndexToColor`) | For subpalette overlay specifically, ACViewer's `IndexToColor` is the canonical algorithm. | | **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **WorldBuilder `StaticObjectRenderManager.cs` + `ObjectMeshManager.cs`** | ACME `StaticObjectManager.cs` (includes CreaturePalette, GfxObjRemapping, HiddenParts — useful for character appearance which WB doesn't cover) | WB for static objects, ACME for character appearance. |
| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **ACME `EnvCellManager.cs`** — portal traversal, mixed landblock detection, collision cache | ACViewer `Physics/Common/EnvCell.cs` | ACME is significantly more complete than original WorldBuilder for dungeons. | | **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **WorldBuilder `TextureHelpers.cs`** | ACME `TextureHelpers.cs`; ACViewer's `IndexToColor` is canonical for subpalette overlay | WB is acdream's decode base. |
| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. |
| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. |
| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. |
| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. | | **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. |
| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. | | **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. |
| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. | | **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. |

View file

@ -46,6 +46,50 @@ Copy this block when adding a new issue:
# Active issues # 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 ## #49 — Scenery (X, Y) placement drifts from retail at some landblocks
**Status:** OPEN **Status:** OPEN

View file

@ -0,0 +1,250 @@
# WorldBuilder Inventory — what we take, adapt, or leave
**Status:** load-bearing reference. As of 2026-05-08 acdream's strategy is
to **rely heavily on WorldBuilder** for rendering and dat-handling rather
than re-port retail algorithms ourselves. WorldBuilder is MIT-licensed, is
verified by visual inspection to render the AC world correctly (terrain,
scenery, slabs, dungeons, slopes, particles), and uses the same Silk.NET
+ .NET stack we already target.
**Integration model:** **fork upstream WorldBuilder** at
`github.com/Chorizite/WorldBuilder`, depend on our fork, delete editor-only
code, expose hooks for our network state to feed scene data in. Sync with
upstream via merge so we inherit fixes. This document tells you, before
you write code, whether the thing you're about to port already exists in
WorldBuilder.
**Workflow change:** Before re-implementing any AC-specific rendering or
dat-handling algorithm, **check this inventory first**. If WorldBuilder
has it, port from WorldBuilder (or call into our fork once it's wired
up), not from retail decomp. Retail decomp remains the oracle for things
WorldBuilder lacks — animation, motion, physics collision, networking.
---
## Repo layout (as of cloned snapshot under `references/WorldBuilder/`)
- **`Chorizite.OpenGLSDLBackend/`** — full OpenGL renderer (Silk.NET).
- **`WorldBuilder.Shared/`** — data models, dat parsers, landscape module.
- **`WorldBuilder/`** — Avalonia desktop app shell (NOT taken).
- **`WorldBuilder.{Windows,Linux,Mac}/`** — platform entry points (NOT taken).
- **`WorldBuilder.Server/`** — collab editing backend (NOT taken).
- **`Tests/` + `WorldBuilder.Shared.Benchmarks/`** — test harness (study).
**Upstream NuGet dependencies** (these stay as NuGet packages, we don't
vendor them):
| Package | Version | Purpose |
|---|---|---|
| `Chorizite.Core` | 0.0.18 | Plugin framework — contains `Chorizite.Core.Lib.BoundingBox`, `Chorizite.Core.Render.*` interfaces used by every render manager |
| `Chorizite.DatReaderWriter` | 2.1.x | dat parsing (we already use 2.1.7) |
| `Chorizite.DatReaderWriter.Extensions` | 1.1.x | extra dat helpers |
| `BCnEncoder.Net` | 2.2.x | DXT decode (we already use) |
| `SixLabors.ImageSharp` | 3.1.x | image loading |
| `Silk.NET.OpenGL` + `Silk.NET.SDL` | 2.23.x | GL + windowing (we use Silk's own windowing, they use SDL) |
| `MP3Sharp` | 1.0.5 | MP3 decode |
---
## 🟢 RENDERING — take wholesale or adapt
These are what makes WB "perfect". Anything in this section, we should
use from WB rather than re-implement.
### Terrain
| Component | What it does |
|---|---|
| `TerrainRenderManager` | Full pipeline (per-chunk GPU buffers, draw orchestration) |
| `LandSurfaceManager` | Texture blending atlas (palCode, alpha masks, road overlays) |
| `TerrainGeometryGenerator` | Heightmap → mesh, normals, OnRoad, GetHeight, GetNormal |
| `TerrainChunk` | 16×16 landblock chunk geometry |
| `TextureAtlasManager` | Texture atlas builder |
| `VertexLandscape` | Terrain vertex format |
### Scenery (procedural placement: trees, bushes, rocks, fences)
| Component | What it does |
|---|---|
| `SceneryRenderManager` | Generate + render per-vertex scenery |
| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope |
| `SceneryInstance` | Per-spawn instance data |
### Static objects (buildings, slabs, props — Setup + GfxObj + ObjDesc)
| Component | What it does |
|---|---|
| `StaticObjectRenderManager` | Master pipeline for static objects |
| `ObjectRenderManagerBase` + `BaseObjectRenderManager` | Common render base |
| `ObjectMeshManager` | Mesh extraction from Setup/GfxObj, ObjDesc application |
### Dungeons / interiors
| Component | What it does |
|---|---|
| `EnvCellRenderManager` | Dungeon interior cell geometry |
| `PortalRenderManager` | Portal traversal / visibility |
### Sky + atmosphere
| Component | What it does |
|---|---|
| `SkyboxRenderManager` | Skybox rendering |
| `ParticleEmitterRenderer` + `ParticleBatcher` + `ActiveParticleEmitter` | Particle systems (sky particles, weather, magic) |
### Visibility / culling
| Component | What it does |
|---|---|
| `VisibilityManager` + `VisibilitySnapshot` | Frustum + cell visibility |
| `Frustum` | Frustum-cull math |
### Other rendering helpers
| Component | What it does |
|---|---|
| `MinimapRenderer` | Top-down minimap |
| `GlobalMeshBuffer` | Shared GPU mesh buffer |
| `GpuResourceManager` | GPU resource lifecycle |
| `InstanceData` | Instanced draw data |
| `TextureHelpers` | INDEX16, P8, BGRA, DXT decode + alpha (canonical port) |
| `DebugRenderer` + `DebugRendererLineDrawer` + `EdgeLineBuilder` | Debug primitives |
### Shaders (22 total)
Located at `Chorizite.OpenGLSDLBackend/Shaders/`:
`Landscape.{vert,frag}` · `StaticObject.{vert,frag}` · `StaticObjectModern.{vert,frag}` · `Particle.{vert,frag}` · `PortalStencil.{vert,frag}` · `Outline.{vert,frag}` · `Simple3D.{vert,frag}` · `InstancedLine.{vert,frag}` · `Text.{vert,frag}` · `UI.{vert,frag}` · `Gizmo.{vert,frag}` (editor-only)
---
## 🟢 LOW-LEVEL GL / FRAMEWORK — take or replace with our own
Either take WB's wrappers wholesale, or keep our own and adapt the
render managers to use ours. These wrappers are stateless or
near-stateless and are the easiest to swap.
| Component | What it does |
|---|---|
| `OpenGLGraphicsDevice` | Silk.NET.OpenGL wrapper |
| `OpenGLRenderer` | Render orchestration |
| `GLSLShader` | Shader compile/link/uniforms |
| `GLHelpers` + `GLStateScope` | GL state utility |
| `ManagedGLFrameBuffer` / `ManagedGLIndexBuffer` / `ManagedGLTexture` / `ManagedGLTextureArray` / `ManagedGLUniformBuffer` / `ManagedGLVertexArray` / `ManagedGLVertexBuffer` | GL resource wrappers |
| `TextureParameters` | Sampler config |
| `GpuMemoryTracker` | Memory tracking |
| `Camera2D` / `Camera3D` / `CameraBase` / `ICamera` / `CameraController` | Camera primitives |
| `GameScene` + `SingleObjectScene` + `SceneData` + `ModernRenderData` + `RenderPass` | Scene / pass structures |
---
## 🟢 GEOMETRY / MATH UTILS — take wholesale
| Component | File |
|---|---|
| `TerrainUtils` (OnRoad, GetNormal, GetHeight, GetRoad, palCode) | `WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs` |
| `TerrainCacheManager` | `…/Lib/TerrainCacheManager.cs` |
| `TerrainRaycast` | `…/Lib/TerrainRaycast.cs` |
| `GeometryUtils` | `WorldBuilder.Shared/Lib/GeometryUtils.cs` |
| `RaycastingUtils` (ray-vs-sphere/AABB/triangle) | `WorldBuilder.Shared/Lib/RaycastingUtils.cs` |
| `DoubleNumerics` (double-precision Vector/Matrix) | `WorldBuilder.Shared/Lib/DoubleNumerics.cs` |
| `DatUtils` | `WorldBuilder.Shared/Lib/DatUtils.cs` |
| `BoundingBoxExtensions` | `Chorizite.OpenGLSDLBackend/Lib/BoundingBoxExtensions.cs` |
---
## 🟢 DATA MODELS — take selectively
| Component | What it does |
|---|---|
| `RegionInfo` | Landblock metadata wrapper (LandblockSizeInUnits, CellSizeInUnits, etc.) |
| `TerrainEntry` | Per-vertex terrain (Type/Scenery/Road/Height) |
| `MergedLandblock` | Merged dat data |
| `CellSplitDirection` | SW-NE vs NE-SW |
| `Cell` | Generic cell wrapper |
| `ObjectId` | Object identifier |
| `Position` | World position |
| `ACEnums` | AC-specific enums |
| `WbBuildingPortal` / `WbCellPortal` | Portal structures |
| `BuildingObject` | Building data |
---
## 🟡 EDITOR-ONLY — leave behind / delete in fork
These exist for the editor experience and have no place in a game
client. Delete in fork.
- **`Modules/Landscape/Tools/*`** — `BrushTool`, `BucketFillTool`,
`RoadLineTool`, `RoadVertexTool`, `InspectorTool`,
`ObjectManipulationTool`, `Gizmo*` (DragHandler, HitTester, Renderer,
State), `TexturePainting*`, `SceneRaycaster`,
`LandscapeBrush`, `LandscapeToolBase`, `LandscapeToolContext`,
`IToolSettingsProvider`, `ILandscapeBrush`, `ILandscapeEditorService`,
`ILandscapeRaycastService`, `ILandscapeTool`, `ITexturePaintingTool`
- **`Modules/Landscape/Commands/*`** — undo/redo command pattern for
editor (Add/Delete/Move/Rename/Reorder/etc.)
- **`LandscapeDocument` + `LandscapeLayer` + `LandscapeLayerGroup` + `LandscapeChunk` + `LandscapeLayerChunk` + `LandscapeLayerBase`** — editor document model
- **`Modules/Landscape/Models/TerrainPatch*` + `LandblockChangedEventArgs`** — editor mutation events
- **`Modules/Landscape/Services/ILandscapeCacheService` + `ILandscapeDataProvider` + `ILandscapeObjectService` + impls** — editor data flow
- **All `Migrations/*`** — SQLite schema migrations (project file format)
- **`Repositories/*`** + **`Services/*`** — project storage, dat repository, AceDb, SignalR sync, document manager, undo stack, world coordinates, keyword DB, project migration, semantic kernel AI helpers
- **`Hubs/*`** — collaborative editing via SignalR
- **`StaticObject` (editor model)** — replace with our own scene-state data model fed from network
- **`BackendGizmoDrawer` + `GizmoRenderer`** — editor gizmos
- **`ProjectStructures, IProject, Project`** — editor project files
- **`KeyBinding`** — editor input binding
- **`ViewportInputEvent[Extensions]`** — editor viewport input
- **`EditorState`** — editor state container
---
## 🟡 AUDIO / FONT — we already have alternatives
Keep ours; don't take theirs.
- **`AudioPlaybackEngine`** — uses MP3Sharp. We have OpenAL.
- **`FontRenderer`** — uses ImageSharp. We have BitmapFont/StbTrueTypeSharp + ImGui.
---
## 🔴 NOT IN WORLDBUILDER — port from retail decomp ourselves
WorldBuilder is a dat editor; it does not have:
- **Network protocol** — UDP framing, ISAAC, packet codec, ACE message
layer (we have this; oracle is `references/holtburger`)
- **Physics** — collision (CPhysicsObj transitions, BSP queries, sphere
sweeps), step-up, walkable validation (we have partial; oracle is the
retail decomp at `docs/research/named-retail/`)
- **Animation** — motion sequencer, cycle/non-cycle parts, animation
frame interpolation (we have this; oracle is retail decomp)
- **Movement** — local player WASD → MoveToState wire, remote-entity
motion via UpdateMotion + dead-reckoning (we have this; oracle is
`references/holtburger` + retail decomp)
- **Game UI** — chat, vitals, inventory, spell book, allegiance, options
(we have this; ImGui-based today, custom-toolkit later)
- **Plugin API**`IGameState`, `IEvents`, `IActions`, `IPacketPipeline`,
`IOverlay` (we have this — acdream-unique)
- **Game events** — combat, allegiance, spell casting, quest events
(we have this; oracle is ACE for opcodes + retail for client behavior)
- **Audio** — OpenAL pipeline, sound triggers (we have this)
- **TurbineChat** + **slash commands** (we have this)
- **Login + character selection flow** (we have this)
---
## What this means for the workflow
The CLAUDE.md "grep named → decompile → verify → port" workflow stays
the rule for everything in the 🔴 list (network, physics, animation,
movement, UI, plugin, audio, chat). For anything in 🟢, the new rule is:
**check this inventory FIRST**. If WB has it, port from WB. Re-porting
from retail decomp when WB already has a tested port is no longer
appropriate — that's how we got the scenery edge-vertex bug.
When the inventory says "take wholesale or adapt" and we discover a
behavior mismatch with retail (rare — WB is verified), the resolution
is: reconcile WB ↔ retail decomp ↔ holtburger ↔ ACE ↔ ACViewer (the
existing reference hierarchy in CLAUDE.md). WorldBuilder ranks at the
top of that hierarchy for anything 🟢.

View file

@ -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 ✓ | | 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 ✓ | | 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 ✓ | | 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: Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost - FlyCamera default speed lowered + Shift-to-boost
@ -498,6 +499,146 @@ 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.
**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):**
- **✓ 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
`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. 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. **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`. **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`.
**Realistic estimate: 2-3 weeks** (was 2) — interacts with N.4
output.
- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
`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`. **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`. **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:** **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_<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,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/<your-name> -b claude/<your-name>`.
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).

File diff suppressed because it is too large Load diff

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.

@ -0,0 +1 @@
Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81

View file

@ -18,5 +18,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" /> <ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />
<!-- Phase N: WorldBuilder is acdream's rendering + dat-handling base.
See docs/architecture/worldbuilder-inventory.md for the inventory of
what we use from WB vs port from retail decomp ourselves.
WorldBuilder.Shared = stateless helpers (TerrainUtils, TerrainEntry, RegionInfo).
Chorizite.OpenGLSDLBackend = render managers + SceneryHelpers + ParticleEmitterRenderer
(full GL pipeline; we currently use only the stateless SceneryHelpers from it). -->
<ProjectReference Include="..\..\references\WorldBuilder\WorldBuilder.Shared\WorldBuilder.Shared.csproj" />
<ProjectReference Include="..\..\references\WorldBuilder\Chorizite.OpenGLSDLBackend\Chorizite.OpenGLSDLBackend.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -198,6 +198,73 @@ public sealed class TerrainSurface
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE); return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
} }
/// <summary>
/// Sample the terrain triangle's surface-normal Z component at (localX, localY)
/// from a raw heightmap. Returns the upward component of the unit normal for
/// the specific triangle the point lies in — flat ground returns 1.0, steeper
/// slopes return smaller values. Used by <see cref="SceneryGenerator"/> for
/// the retail slope filter (<c>CLandCell::find_terrain_poly → polygon.plane.N.z</c>).
/// </summary>
public static float SampleNormalZFromHeightmap(
byte[] heights, float[] heightTable,
uint landblockX, uint landblockY,
float localX, float localY)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = (int)fx;
int cy = (int)fy;
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
float tx = fx - cx;
float ty = fy - cy;
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
float dzdx, dzdy;
if (splitSWtoNE)
{
if (tx > ty)
{
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
else
{
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
}
else
{
if (tx + ty <= 1f)
{
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
else
{
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
}
return 1f / MathF.Sqrt(dzdx * dzdx + dzdy * dzdy + 1f);
}
/// <summary> /// <summary>
/// Pick the cell's triangle for the chosen diagonal and barycentric- /// Pick the cell's triangle for the chosen diagonal and barycentric-
/// interpolate Z. Single source of truth shared by both /// interpolate Z. Single source of truth shared by both

View file

@ -1,7 +1,9 @@
using System.Numerics; using System.Numerics;
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using WorldBuilder.Shared.Modules.Landscape.Lib;
namespace AcDream.Core.World; namespace AcDream.Core.World;
@ -23,17 +25,11 @@ namespace AcDream.Core.World;
/// (scale hash constant 0x7f51=32593 not in dumped chunks; /// (scale hash constant 0x7f51=32593 not in dumped chunks;
/// confirmed against ACViewer which matches all other constants) /// confirmed against ACViewer which matches all other constants)
/// ///
/// Key implementation note: the decompiled client computes each LCG value as a /// Phase N.1 (2026-05-08): migrated all algorithm calls to WorldBuilder's
/// signed 32-bit int, then normalises with "if (val &lt; 0) val += 2^32" before /// <c>SceneryHelpers</c> + <c>TerrainUtils</c>. The legacy in-line implementations
/// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast. /// have been removed; <c>WbSceneryAdapter</c> bridges <c>LandBlock</c> data to WB's
/// ACViewer's reference omits this cast and is subtly wrong for negative inputs. /// <c>TerrainEntry[]</c>. See
/// We deliberately match the decompiled client, not ACViewer. /// <c>docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md</c>.
///
/// We deliberately skip the slope/road/building-overlap checks the original does;
/// those prevent scenery from floating in roads or clipping buildings but
/// require walkable-polygon lookups that we don't yet have. Accepting visual
/// artifacts (trees inside roads, scenery clipping buildings) for a first pass
/// and deferring the filters to a later phase.
/// </summary> /// </summary>
public static class SceneryGenerator public static class SceneryGenerator
{ {
@ -41,6 +37,7 @@ public static class SceneryGenerator
private const int VerticesPerSide = 9; private const int VerticesPerSide = 9;
private const float CellSize = 24.0f; private const float CellSize = 24.0f;
private const float LandblockSize = 192.0f; // 8 cells * 24 units private const float LandblockSize = 192.0f; // 8 cells * 24 units
private const int CellsPerSide = 8;
public readonly record struct ScenerySpawn( public readonly record struct ScenerySpawn(
uint ObjectId, // GfxObj or Setup id uint ObjectId, // GfxObj or Setup id
@ -49,12 +46,9 @@ public static class SceneryGenerator
float Scale); float Scale);
/// <summary> /// <summary>
/// Generate all scenery entries for one landblock. Uses the bit-packed /// Generate all scenery entries for one landblock. Phase N.1 migrated this
/// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into /// to call WorldBuilder's <c>SceneryHelpers</c> + <c>TerrainUtils</c>;
/// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] → a SceneInfo /// see <c>docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md</c>.
/// 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.
/// </summary> /// </summary>
public static IReadOnlyList<ScenerySpawn> Generate( public static IReadOnlyList<ScenerySpawn> Generate(
DatCollection dats, DatCollection dats,
@ -63,37 +57,49 @@ public static class SceneryGenerator
uint landblockId, uint landblockId,
HashSet<int>? buildingCells = null, HashSet<int>? buildingCells = null,
float[]? heightTable = null) float[]? heightTable = null)
{
// heightTable kept for backward compat; WB path uses
// region.LandDefs.LandHeightTable internally via TerrainUtils.GetNormal.
_ = heightTable;
return GenerateInternal(dats, region, block, landblockId, buildingCells);
}
/// <summary>
/// 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().
/// </summary>
public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
private static IReadOnlyList<ScenerySpawn> GenerateInternal(
DatCollection dats,
Region region,
LandBlock block,
uint landblockId,
HashSet<int>? buildingCells)
{ {
var result = new List<ScenerySpawn>(); var result = new List<ScenerySpawn>();
if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
return result; return result;
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock // Build the TerrainEntry[] WB's helpers consume — once per landblock.
uint blockY = ((landblockId >> 16) & 0xFFu) * 8; var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block);
// RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices. uint blockX = (landblockId >> 24) * 8;
// Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
// `while (local_94 < 8)` and `while (local_8c < 8)` — bound by uint lbX = landblockId >> 24;
// `param_1+0x40` which is SideCellCount=8 for outdoor landblocks. uint lbY = (landblockId >> 16) & 0xFFu;
// The terrain word at each cell's SW corner drives that cell's scenery.
for (int x = 0; x < CellsPerSide; x++) for (int x = 0; x < VerticesPerSide; x++)
{ {
for (int y = 0; y < CellsPerSide; y++) for (int y = 0; y < VerticesPerSide; y++)
{ {
int i = x * VerticesPerSide + y; int i = x * VerticesPerSide + y;
ushort raw = block.Terrain[i]; ushort raw = block.Terrain[i];
uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 uint terrainType = (uint)((raw >> 2) & 0x1F);
uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 uint sceneType = (uint)((raw >> 11) & 0x1F);
// 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.
// Skip cells that contain buildings.
if (buildingCells is not null && buildingCells.Contains(i)) continue;
if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes;
@ -110,10 +116,7 @@ public static class SceneryGenerator
uint globalCellX = cellX + blockX; uint globalCellX = cellX + blockX;
uint globalCellY = cellY + blockY; uint globalCellY = cellY + blockY;
// Scene-selection hash: picks one scene from the terrain's scene list. // Scene-selection hash: identical to Generate.
// 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) uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u)
- 1109124029u * globalCellX + 2139937281u; - 1109124029u * globalCellX + 2139937281u;
double offset = cellMat * 2.3283064e-10; double offset = cellMat * 2.3283064e-10;
@ -124,14 +127,7 @@ public static class SceneryGenerator
var scene = dats.Get<Scene>(sceneId); var scene = dats.Get<Scene>(sceneId);
if (scene is null) continue; if (scene is null) continue;
// Per-object hashes: roll frequency, compute displacement, scale, rotation. // Per-object frequency setup: identical to Generate.
// 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 cellXMat = unchecked(0u - 1109124029u * globalCellX);
uint cellYMat = 1813693831u * globalCellY; uint cellYMat = 1813693831u * globalCellY;
uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u;
@ -139,15 +135,13 @@ public static class SceneryGenerator
for (uint j = 0; j < scene.Objects.Count; j++) for (uint j = 0; j < scene.Objects.Count; j++)
{ {
var obj = scene.Objects[(int)j]; var obj = scene.Objects[(int)j];
if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery if (obj.WeenieObj != 0) continue;
// 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; double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10;
if (noise >= obj.Frequency) continue; if (noise >= obj.Frequency) continue;
// Displacement: pseudo-random offset within the cell. // ─── WB substitution: displacement ───────────────────
var localPos = DisplaceObject(obj, globalCellX, globalCellY, j); var localPos = SceneryHelpers.Displace(obj, globalCellX, globalCellY, j);
float lx = cellX * CellSize + localPos.X; float lx = cellX * CellSize + localPos.X;
float ly = cellY * CellSize + localPos.Y; float ly = cellY * CellSize + localPos.Y;
@ -155,277 +149,48 @@ public static class SceneryGenerator
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
continue; continue;
// Retail post-displacement road check (FUN_00530d30). // ─── WB substitution: road check ──────────────────────
// Ported from ACViewer Landblock.OnRoad — uses the 4-corner if (TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries))
// 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; continue;
}
// L-fix2 (2026-04-28): the extra cell-origin road-vertex // Building check: identical to Generate.
// guard previously here is REMOVED. It wasn't in the if (buildingCells is not null)
// retail decomp — it was a heuristic added to widen
// road margins visually. The proper retail post-
// displacement road check (FUN_00530d30 port via
// IsOnRoad above) already handles road exclusion.
// The extra guard was over-suppressing — every cell
// whose SW corner happened to touch a road vertex
// had ALL of its scenery dropped, even when the
// displaced position was well clear of the ribbon.
// User reported missing trees they could see in
// retail; this is the most likely cause.
// Slope filter (ACME conformance fix 4e): compute terrain normal
// Z-component at the displaced position and check against the
// object's MinSlope/MaxSlope bounds.
if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f))
{ {
int sx = Math.Clamp((int)(lx / CellSize), 0, VerticesPerSide - 2); int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1);
int sy = Math.Clamp((int)(ly / CellSize), 0, VerticesPerSide - 2); int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1);
int sxR = sx + 1; if (buildingCells.Contains(dcx * VerticesPerSide + dcy))
int syU = sy + 1; continue;
float h00 = heightTable[block.Height[sx * VerticesPerSide + sy]];
float h10 = heightTable[block.Height[sxR * VerticesPerSide + sy]];
float h01 = heightTable[block.Height[sx * VerticesPerSide + syU]];
float dx = (h10 - h00) / CellSize;
float dy = (h01 - h00) / CellSize;
float nz = 1f / MathF.Sqrt(dx * dx + dy * dy + 1f); // normal Z component
if (nz < obj.MinSlope || nz > obj.MaxSlope) continue;
} }
// BaseLoc.Z offset: scenery-specific vertical offset from // ─── WB substitution: slope check ─────────────────────
// the ground (e.g., flowers planted at -0.1m so they Vector3 normal = TerrainUtils.GetNormal(
// don't float above grass). The renderer adds groundZ region, terrainEntries, lbX, lbY,
// later, so pass the BaseLoc.Z through as-is. new Vector3(lx, ly, 0));
if (!SceneryHelpers.CheckSlope(obj, normal.Z))
continue;
float lz = obj.BaseLoc.Origin.Z; float lz = obj.BaseLoc.Origin.Z;
// Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60) // ─── WB substitution: rotation ────────────────────────
// Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation Quaternion rotation;
// into the frame, THEN calls AFrame::set_heading(degrees). if (obj.Align != 0)
// rotation = SceneryHelpers.ObjAlign(obj, normal, lz, localPos);
// 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 else
{ rotation = SceneryHelpers.RotateObj(obj, globalCellX, globalCellY, j, localPos);
double scaleNoise = unchecked((uint)(1813693831u * globalCellY
- (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u) // ─── WB substitution: scale ───────────────────────────
- 1109124029u * globalCellX)) * 2.3283064e-10; float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j);
scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale);
}
if (scale <= 0) scale = 1f; if (scale <= 0) scale = 1f;
result.Add(new ScenerySpawn( result.Add(new ScenerySpawn(
ObjectId: obj.ObjectId, ObjectId: obj.ObjectId,
LocalPosition: new Vector3(lx, ly, lz), LocalPosition: new Vector3(lx, ly, lz),
Rotation: rotation, Rotation: rotation,
Scale: scale)); Scale: scale));
} }
} }
} }
return result; return result;
} }
/// <summary>
/// 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().
/// </summary>
public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
/// <summary>
/// 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.
/// </summary>
private const float RoadHalfWidth = 5.0f;
/// <summary>
/// Retail-faithful post-displacement road test. Ported from ACViewer
/// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which is
/// a direct port of FUN_00530d30 in the retail client.
///
/// Examines the 4 corners of the cell containing (lx, ly) and, depending
/// on how many are road vertices (0, 1, 2, 3, or 4), applies a polygonal
/// test using the 5-unit road half-width to check if (lx, ly) lies on the
/// road ribbon. Returns true if the point is on a road.
/// </summary>
/// <summary>
/// Retail-faithful road ribbon test — direct port of ACViewer's
/// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which
/// itself is a port of FUN_00530d30 in acclient.exe.
///
/// 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).
/// </summary>
private 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;
/// <summary>
/// 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 &lt; 0) val += 2^32"; our
/// unchecked((uint)(...)) is exactly equivalent.
/// </summary>
private 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);
}
} }

View file

@ -0,0 +1,55 @@
using DatReaderWriter.DBObjs;
using WorldBuilder.Shared.Models;
namespace AcDream.Core.World;
/// <summary>
/// Bridges acdream's dat types into WorldBuilder's data shapes for the
/// Phase N rendering migration. See
/// <c>docs/architecture/worldbuilder-inventory.md</c> for the full strategy.
/// </summary>
internal static class WbSceneryAdapter
{
private const int VerticesPerSide = 9;
private const int TerrainSize = VerticesPerSide * VerticesPerSide; // 81
/// <summary>
/// Builds a 9×9 = 81-entry <see cref="TerrainEntry"/> array from a
/// <see cref="LandBlock"/>'s packed terrain bits + height bytes. WB's
/// <c>TerrainUtils.OnRoad</c> / <c>GetNormal</c> / <c>GetHeight</c>
/// consume this shape.
///
/// Field mapping (<c>TerrainInfo</c> → <see cref="TerrainEntry"/>):
/// <c>TerrainInfo.Road</c> (bits 0-1) → <see cref="TerrainEntry.Road"/>
/// <c>TerrainInfo.Type</c> (bits 2-6) → <see cref="TerrainEntry.Type"/>
/// <c>TerrainInfo.Scenery</c> (bits 11-15) → <see cref="TerrainEntry.Scenery"/>
/// <c>LandBlock.Height[i]</c> → <see cref="TerrainEntry.Height"/>
/// </summary>
/// <remarks>
/// No runtime length guards are needed here because
/// <c>DatReaderWriter.DBObjs.LandBlock</c>'s default constructor
/// self-initializes both <c>Terrain</c> and <c>Height</c> to fixed-length
/// arrays of exactly 81 elements (9×9 vertices per landblock). Any caller
/// that constructs a synthetic <see cref="LandBlock"/> with partial arrays
/// will receive an <see cref="IndexOutOfRangeException"/> at the first
/// mis-sized index, which is the correct fast-fail behaviour for a
/// contract violation of this kind.
/// </remarks>
public static TerrainEntry[] BuildTerrainEntries(LandBlock block)
{
ArgumentNullException.ThrowIfNull(block);
var entries = new TerrainEntry[TerrainSize];
for (int i = 0; i < TerrainSize; i++)
{
var ti = block.Terrain[i];
entries[i] = new TerrainEntry(
height: block.Height[i],
texture: (byte)ti.Type,
scenery: ti.Scenery,
road: ti.Road,
encounters: null);
}
return entries;
}
}

View file

@ -139,6 +139,52 @@ public class TerrainSurfaceTests
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f); Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
} }
[Fact]
public void SampleNormalZFromHeightmap_FlatTerrain_ReturnsOne()
{
var heights = FlatHeightmap(50);
var hTable = LinearHeightTable();
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 96f, 96f);
Assert.Equal(1f, nz, precision: 5);
}
[Fact]
public void SampleNormalZFromHeightmap_SlopedTerrain_ReturnsLessThanOne()
{
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x * 20);
var hTable = LinearHeightTable();
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 12f, 12f);
Assert.True(nz < 1f, $"Sloped terrain should have nz < 1.0, got {nz}");
Assert.True(nz > 0f, $"nz should be positive, got {nz}");
}
[Fact]
public void SampleNormalZFromHeightmap_AgreesWithSampleSurface()
{
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256);
var hTable = LinearHeightTable();
const uint lbX = 0xA9, lbY = 0xB3;
var instance = new TerrainSurface(heights, hTable, lbX, lbY);
for (float lx = 0.5f; lx < 192f; lx += 8f)
for (float ly = 0.5f; ly < 192f; ly += 8f)
{
var (_, normal) = instance.SampleSurface(lx, ly);
float staticNz = TerrainSurface.SampleNormalZFromHeightmap(
heights, hTable, lbX, lbY, lx, ly);
Assert.True(
Math.Abs(normal.Z - staticNz) < 0.0001f,
$"NormalZ mismatch at ({lx:F1},{ly:F1}): instance={normal.Z:F4} static={staticNz:F4}");
}
}
[Fact] [Fact]
public void ComputeOutdoorCellId_Origin_ReturnsFirst() public void ComputeOutdoorCellId_Origin_ReturnsFirst()
{ {

View file

@ -4,10 +4,11 @@ using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World; namespace AcDream.Core.Tests.World;
/// <summary> /// <summary>
/// Tests for SceneryGenerator road-exclusion logic. /// Tests for SceneryGenerator. As of Phase N.1 (commit b84ecbd / Task 8 final
/// The full Generate() pipeline requires real dat files (Region, Scene, etc.) /// commit), the displacement / road / slope / rotation / scale algorithms run
/// so road-check behavior is tested via the internal IsRoadVertex helper, /// through WorldBuilder's helpers (SceneryHelpers + TerrainUtils). The only
/// which is the single gate that guards against placing trees on roads. /// our-side code remaining is the small <see cref="SceneryGenerator.IsRoadVertex"/>
/// predicate, which is what these tests cover.
/// </summary> /// </summary>
public class SceneryGeneratorTests public class SceneryGeneratorTests
{ {
@ -32,15 +33,12 @@ public class SceneryGeneratorTests
[Fact] [Fact]
public void IsRoadVertex_ZeroTerrain_IsNotRoad() public void IsRoadVertex_ZeroTerrain_IsNotRoad()
{ {
// A fully blank terrain entry (no type, no road, no scene) is not a road.
Assert.False(SceneryGenerator.IsRoadVertex(0)); Assert.False(SceneryGenerator.IsRoadVertex(0));
} }
[Fact] [Fact]
public void IsRoadVertex_MatchesTerrainInfoRoadProperty() public void IsRoadVertex_MatchesTerrainInfoRoadProperty()
{ {
// Verify that IsRoadVertex agrees with the typed TerrainInfo.Road property
// for a sample of raw values, ensuring the bit convention is consistent.
for (ushort raw = 0; raw < 4; raw++) for (ushort raw = 0; raw < 4; raw++)
{ {
TerrainInfo ti = raw; TerrainInfo ti = raw;

View file

@ -0,0 +1,74 @@
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World;
/// <summary>
/// Tests for <see cref="WbSceneryAdapter"/>. The adapter converts our
/// LandBlock dat type (Terrain TerrainInfo[81] + Height byte[81]) into
/// WorldBuilder's <see cref="WorldBuilder.Shared.Models.TerrainEntry"/>[81]
/// shape, which WB's TerrainUtils / SceneryRenderManager consume.
///
/// Bit layout in LandBlock.Terrain[i] (TerrainInfo / ushort):
/// bits 0-1 : Road (2 bits) → WB TerrainEntry.Road
/// bits 2-6 : TerrainType (5 bits) → WB TerrainEntry.Type
/// bits 11-15 : SceneType (5 bits) → WB TerrainEntry.Scenery
/// Height comes from LandBlock.Height[i] (byte) → WB TerrainEntry.Height.
/// </summary>
public class WbSceneryAdapterTests
{
[Fact]
public void BuildTerrainEntries_PreservesRoadTextureSceneryHeight()
{
var block = new LandBlock();
// Vertex 0: road=0x3, type=0x00, scenery=0x1F, height=42
// raw layout: bits 0-1=11, bits 2-6=00000, bits 7-10=0000, bits 11-15=11111
// = 0xF803
block.Terrain[0] = (TerrainInfo)0xF803;
block.Height[0] = 42;
// Vertex 80: road=0x0, type=0x1F, scenery=0x00, height=200
// raw layout: bits 0-1=00, bits 2-6=11111, bits 11-15=00000
// = 0x007C
block.Terrain[80] = (TerrainInfo)0x007C;
block.Height[80] = 200;
var entries = WbSceneryAdapter.BuildTerrainEntries(block);
Assert.Equal(81, entries.Length);
Assert.Equal((byte)42, entries[0].Height);
Assert.Equal((byte)0x3, entries[0].Road);
Assert.Equal((byte)0x00, entries[0].Type);
Assert.Equal((byte)0x1F, entries[0].Scenery);
Assert.Equal((byte)200, entries[80].Height);
Assert.Equal((byte)0x0, entries[80].Road);
Assert.Equal((byte)0x1F, entries[80].Type);
Assert.Equal((byte)0x00, entries[80].Scenery);
}
[Fact]
public void BuildTerrainEntries_AllZeros_ProducesEmptyEntries()
{
var block = new LandBlock();
// Terrain and Height are already zero-initialized by LandBlock constructor.
var entries = WbSceneryAdapter.BuildTerrainEntries(block);
Assert.All(entries, e =>
{
Assert.Equal((byte)0, e.Height);
Assert.Equal((byte)0, e.Road);
Assert.Equal((byte)0, e.Type);
Assert.Equal((byte)0, e.Scenery);
});
}
[Fact]
public void BuildTerrainEntries_NullBlock_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
WbSceneryAdapter.BuildTerrainEntries(null!));
}
}