diff --git a/.gitignore b/.gitignore index af968b2..d060c06 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,11 @@ packages/ Thumbs.db # Reference repos and retail client (large, not our code, separate licenses) -references/ +# WorldBuilder is exempt โ€” it's a load-bearing dependency tracked as a git +# submodule pointing at our fork (Phase N, see docs/architecture/worldbuilder-inventory.md). +references/* +!references/WorldBuilder +!references/WorldBuilder/ # Claude Code session state .claude/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c691aa8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "references/WorldBuilder"] + path = references/WorldBuilder + url = git@github.com:eriknihlen/WorldBuilder.git + branch = acdream diff --git a/CLAUDE.md b/CLAUDE.md index 469b95c..1731668 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,19 @@ single source of truth for how the client is structured. All work must align with this document. When the architecture doc and reality diverge, update one or the other โ€” never leave them out of sync. +**WorldBuilder is acdream's rendering + dat-handling base** as of +2026-05-08. Before re-implementing any AC-specific rendering or +dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md` +FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into +our fork once wired up), not from retail decomp. WorldBuilder is +MIT-licensed, verified to render the world correctly, and uses the same +Silk.NET stack we target. Re-porting from retail decomp when WB already +has a tested port is how subtle bugs (the scenery edge-vertex bug, the +triangle-Z bug) keep slipping in. Retail decomp remains the oracle for +network, physics, animation, movement, UI, plugin, audio, chat โ€” see +the inventory doc's ๐Ÿ”ด list for the full scope of "we still write this +ourselves". + **Execution phases:** R1โ†’R8 in the architecture doc. Each phase has clear goals, test criteria, and builds on the previous. Don't skip phases. @@ -625,11 +638,18 @@ these, ideally all four: for the palette-indexed formats. See `ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical subpalette overlay algorithm. -- **`references/WorldBuilder/`** โ€” C# + Silk.NET dat editor. Exact-stack - match to acdream for rendering approaches: terrain blending, texture - atlases, shader patterns. Most useful for "how do I do this GL thing - with Silk.NET on net10 idiomatically?" Less useful for protocol or - character appearance (dat editor, not game client). +- **`references/WorldBuilder/`** โ€” **acdream's rendering + dat-handling + BASE (not just a reference).** As of 2026-05-08 acdream is moving to + fork WorldBuilder upstream and depend on the fork for terrain, + scenery, static objects, EnvCells, portals, sky, particles, texture + decoding, mesh extraction, visibility/culling. WorldBuilder is + MIT-licensed, exact-stack match (Silk.NET + .NET), and verified to + render the world correctly. **Before re-porting any rendering or + dat-handling algorithm from retail decomp, check + `docs/architecture/worldbuilder-inventory.md` first.** If WB has it, + use WB's port. If WB doesn't have it (network, physics, animation, + movement, UI, plugin, audio, chat), port from retail decomp as + before. - **`references/Chorizite.ACProtocol/`** โ€” clean-room C# protocol library generated from a protocol XML description. Useful sanity check on field order, packed-dword conventions, type-prefix handling. The @@ -684,12 +704,15 @@ decompiled client code and would have fixed it in minutes. | Domain | Primary Oracle | Secondary | Notes | |--------|---------------|-----------|-------| -| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." | -| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **ACME `ClientReference.cs`** โ€” decompiled retail client with exact offsets | ACME `TerrainGeometryGenerator.cs` (matches the mesh index buffer) | WorldBuilder original is SUPERSEDED for terrain algorithms. AC2D confirms the same formula. | -| **Terrain blending** (texture atlas, alpha masks, road overlays) | **ACME `LandSurfaceManager.cs`** | WorldBuilder original `LandSurfaceManager.cs` (same code, less tested) | Both use the same TexMerge pipeline. ACME has conformance tests. | -| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **ACME `StaticObjectManager.cs`** โ€” includes CreaturePalette, GfxObjRemapping, HiddenParts | ACViewer `Render/` namespace | ACME has the complete creature appearance pipeline in one file. | -| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **ACME `TextureHelpers.cs`** | ACViewer `Render/TextureCache.cs` (palette overlay = `IndexToColor`) | For subpalette overlay specifically, ACViewer's `IndexToColor` is the canonical algorithm. | -| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **ACME `EnvCellManager.cs`** โ€” portal traversal, mixed landblock detection, collision cache | ACViewer `Physics/Common/EnvCell.cs` | ACME is significantly more complete than original WorldBuilder for dungeons. | +| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." Use for everything in the ๐Ÿ”ด list (network, physics, animation, movement, UI, plugin, audio, chat). | +| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **WorldBuilder `TerrainGeometryGenerator.cs` + `TerrainUtils.cs`** | retail decomp for cross-check | WB is acdream's terrain base. ACME's port is older/SUPERSEDED by WB. | +| **Terrain blending** (texture atlas, alpha masks, road overlays) | **WorldBuilder `LandSurfaceManager.cs`** | ACME `LandSurfaceManager.cs` (same algo, less complete) | WB is acdream's blending base. | +| **Scenery** (procedural placement: trees, bushes, rocks, fences) | **WorldBuilder `SceneryRenderManager.cs` + `SceneryHelpers.cs`** | retail decomp `CLandBlock::get_land_scenes` | WB is acdream's scenery base. Re-porting from retail decomp is what caused the edge-vertex bug. | +| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **WorldBuilder `StaticObjectRenderManager.cs` + `ObjectMeshManager.cs`** | ACME `StaticObjectManager.cs` (includes CreaturePalette, GfxObjRemapping, HiddenParts โ€” useful for character appearance which WB doesn't cover) | WB for static objects, ACME for character appearance. | +| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **WorldBuilder `TextureHelpers.cs`** | ACME `TextureHelpers.cs`; ACViewer's `IndexToColor` is canonical for subpalette overlay | WB is acdream's decode base. | +| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. | +| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. | +| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | โ€” | WB. | | **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. | | **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. | | **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. | diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 87c7b2d..464590f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,50 @@ Copy this block when adding a new issue: # Active issues +## #50 โ€” Road-edge tree at 0xA9B1 visible in acdream but not retail + +**Status:** OPEN +**Severity:** LOW (cosmetic; one spawned tree near the road in Holtburg) +**Filed:** 2026-05-08 +**Component:** scenery placement / Phase N (WorldBuilder rendering migration) + +**Description:** With `ACDREAM_USE_WB_SCENERY=1` (default since commit `b84ecbd`), +a tree at landblock 0xA9B1 around `(lx=85.08, ly=190.97)` appears in acdream but +neither retail nor ACME WorldBuilder render it. Upstream Chorizite/WorldBuilder +DOES render it, so our migration to WB's helpers (Phase N.1) inherited this +discrepancy from upstream. + +**Root cause (suspected):** ACME WorldBuilder includes a per-vertex road check that +skips the entire vertex when its road bit is set (see +`references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074`). +The current vertex (4,8) has a road bit set in the dat. ACME skips it; +Chorizite/WorldBuilder doesn't; we don't. + +**Fix attempt that didn't work:** commit `e279c46` added the per-vertex road check +directly to our `GenerateViaWb` (and legacy `Generate` for parity). It successfully +removed the offending tree but over-suppressed scenery in other landblocks (visual +regressions during user testing). Reverted in commit `677a726`. ACME's check likely +interacts with other factors (per-vertex building check, or something else in ACME's +pipeline) that we'd need to port together, not the road check alone. + +**Next steps:** +1. Investigate ACME's full per-vertex filter set (road + building + anything else) + and port them as a coherent unit, not piecemeal. +2. OR upstream the per-vertex road check to Chorizite/WorldBuilder (which is now our + submodule fork) so it lands as a generic ACME-conformance improvement. +3. OR consider switching fork target from Chorizite/WorldBuilder to ACME WorldBuilder + for future phases (N.2+). + +Visually undetectable to most users; one extra tree at one landblock. Defer until +other Phase N work catches a similar issue and a coherent fix becomes obvious. + +**Files:** +- `src/AcDream.Core/World/SceneryGenerator.cs` โ€” `GenerateInternal` is the active path +- `src/AcDream.Core/World/WbSceneryAdapter.cs` โ€” adapter used by `GenerateInternal` +- `references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074` โ€” ACME's per-vertex road filter + +--- + ## #49 โ€” Scenery (X, Y) placement drifts from retail at some landblocks **Status:** OPEN diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md new file mode 100644 index 0000000..68144c9 --- /dev/null +++ b/docs/architecture/worldbuilder-inventory.md @@ -0,0 +1,250 @@ +# WorldBuilder Inventory โ€” what we take, adapt, or leave + +**Status:** load-bearing reference. As of 2026-05-08 acdream's strategy is +to **rely heavily on WorldBuilder** for rendering and dat-handling rather +than re-port retail algorithms ourselves. WorldBuilder is MIT-licensed, is +verified by visual inspection to render the AC world correctly (terrain, +scenery, slabs, dungeons, slopes, particles), and uses the same Silk.NET ++ .NET stack we already target. + +**Integration model:** **fork upstream WorldBuilder** at +`github.com/Chorizite/WorldBuilder`, depend on our fork, delete editor-only +code, expose hooks for our network state to feed scene data in. Sync with +upstream via merge so we inherit fixes. This document tells you, before +you write code, whether the thing you're about to port already exists in +WorldBuilder. + +**Workflow change:** Before re-implementing any AC-specific rendering or +dat-handling algorithm, **check this inventory first**. If WorldBuilder +has it, port from WorldBuilder (or call into our fork once it's wired +up), not from retail decomp. Retail decomp remains the oracle for things +WorldBuilder lacks โ€” animation, motion, physics collision, networking. + +--- + +## Repo layout (as of cloned snapshot under `references/WorldBuilder/`) + +- **`Chorizite.OpenGLSDLBackend/`** โ€” full OpenGL renderer (Silk.NET). +- **`WorldBuilder.Shared/`** โ€” data models, dat parsers, landscape module. +- **`WorldBuilder/`** โ€” Avalonia desktop app shell (NOT taken). +- **`WorldBuilder.{Windows,Linux,Mac}/`** โ€” platform entry points (NOT taken). +- **`WorldBuilder.Server/`** โ€” collab editing backend (NOT taken). +- **`Tests/` + `WorldBuilder.Shared.Benchmarks/`** โ€” test harness (study). + +**Upstream NuGet dependencies** (these stay as NuGet packages, we don't +vendor them): + +| Package | Version | Purpose | +|---|---|---| +| `Chorizite.Core` | 0.0.18 | Plugin framework โ€” contains `Chorizite.Core.Lib.BoundingBox`, `Chorizite.Core.Render.*` interfaces used by every render manager | +| `Chorizite.DatReaderWriter` | 2.1.x | dat parsing (we already use 2.1.7) | +| `Chorizite.DatReaderWriter.Extensions` | 1.1.x | extra dat helpers | +| `BCnEncoder.Net` | 2.2.x | DXT decode (we already use) | +| `SixLabors.ImageSharp` | 3.1.x | image loading | +| `Silk.NET.OpenGL` + `Silk.NET.SDL` | 2.23.x | GL + windowing (we use Silk's own windowing, they use SDL) | +| `MP3Sharp` | 1.0.5 | MP3 decode | + +--- + +## ๐ŸŸข RENDERING โ€” take wholesale or adapt + +These are what makes WB "perfect". Anything in this section, we should +use from WB rather than re-implement. + +### Terrain + +| Component | What it does | +|---|---| +| `TerrainRenderManager` | Full pipeline (per-chunk GPU buffers, draw orchestration) | +| `LandSurfaceManager` | Texture blending atlas (palCode, alpha masks, road overlays) | +| `TerrainGeometryGenerator` | Heightmap โ†’ mesh, normals, OnRoad, GetHeight, GetNormal | +| `TerrainChunk` | 16ร—16 landblock chunk geometry | +| `TextureAtlasManager` | Texture atlas builder | +| `VertexLandscape` | Terrain vertex format | + +### Scenery (procedural placement: trees, bushes, rocks, fences) + +| Component | What it does | +|---|---| +| `SceneryRenderManager` | Generate + render per-vertex scenery | +| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope | +| `SceneryInstance` | Per-spawn instance data | + +### Static objects (buildings, slabs, props โ€” Setup + GfxObj + ObjDesc) + +| Component | What it does | +|---|---| +| `StaticObjectRenderManager` | Master pipeline for static objects | +| `ObjectRenderManagerBase` + `BaseObjectRenderManager` | Common render base | +| `ObjectMeshManager` | Mesh extraction from Setup/GfxObj, ObjDesc application | + +### Dungeons / interiors + +| Component | What it does | +|---|---| +| `EnvCellRenderManager` | Dungeon interior cell geometry | +| `PortalRenderManager` | Portal traversal / visibility | + +### Sky + atmosphere + +| Component | What it does | +|---|---| +| `SkyboxRenderManager` | Skybox rendering | +| `ParticleEmitterRenderer` + `ParticleBatcher` + `ActiveParticleEmitter` | Particle systems (sky particles, weather, magic) | + +### Visibility / culling + +| Component | What it does | +|---|---| +| `VisibilityManager` + `VisibilitySnapshot` | Frustum + cell visibility | +| `Frustum` | Frustum-cull math | + +### Other rendering helpers + +| Component | What it does | +|---|---| +| `MinimapRenderer` | Top-down minimap | +| `GlobalMeshBuffer` | Shared GPU mesh buffer | +| `GpuResourceManager` | GPU resource lifecycle | +| `InstanceData` | Instanced draw data | +| `TextureHelpers` | INDEX16, P8, BGRA, DXT decode + alpha (canonical port) | +| `DebugRenderer` + `DebugRendererLineDrawer` + `EdgeLineBuilder` | Debug primitives | + +### Shaders (22 total) + +Located at `Chorizite.OpenGLSDLBackend/Shaders/`: + +`Landscape.{vert,frag}` ยท `StaticObject.{vert,frag}` ยท `StaticObjectModern.{vert,frag}` ยท `Particle.{vert,frag}` ยท `PortalStencil.{vert,frag}` ยท `Outline.{vert,frag}` ยท `Simple3D.{vert,frag}` ยท `InstancedLine.{vert,frag}` ยท `Text.{vert,frag}` ยท `UI.{vert,frag}` ยท `Gizmo.{vert,frag}` (editor-only) + +--- + +## ๐ŸŸข LOW-LEVEL GL / FRAMEWORK โ€” take or replace with our own + +Either take WB's wrappers wholesale, or keep our own and adapt the +render managers to use ours. These wrappers are stateless or +near-stateless and are the easiest to swap. + +| Component | What it does | +|---|---| +| `OpenGLGraphicsDevice` | Silk.NET.OpenGL wrapper | +| `OpenGLRenderer` | Render orchestration | +| `GLSLShader` | Shader compile/link/uniforms | +| `GLHelpers` + `GLStateScope` | GL state utility | +| `ManagedGLFrameBuffer` / `ManagedGLIndexBuffer` / `ManagedGLTexture` / `ManagedGLTextureArray` / `ManagedGLUniformBuffer` / `ManagedGLVertexArray` / `ManagedGLVertexBuffer` | GL resource wrappers | +| `TextureParameters` | Sampler config | +| `GpuMemoryTracker` | Memory tracking | +| `Camera2D` / `Camera3D` / `CameraBase` / `ICamera` / `CameraController` | Camera primitives | +| `GameScene` + `SingleObjectScene` + `SceneData` + `ModernRenderData` + `RenderPass` | Scene / pass structures | + +--- + +## ๐ŸŸข GEOMETRY / MATH UTILS โ€” take wholesale + +| Component | File | +|---|---| +| `TerrainUtils` (OnRoad, GetNormal, GetHeight, GetRoad, palCode) | `WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs` | +| `TerrainCacheManager` | `โ€ฆ/Lib/TerrainCacheManager.cs` | +| `TerrainRaycast` | `โ€ฆ/Lib/TerrainRaycast.cs` | +| `GeometryUtils` | `WorldBuilder.Shared/Lib/GeometryUtils.cs` | +| `RaycastingUtils` (ray-vs-sphere/AABB/triangle) | `WorldBuilder.Shared/Lib/RaycastingUtils.cs` | +| `DoubleNumerics` (double-precision Vector/Matrix) | `WorldBuilder.Shared/Lib/DoubleNumerics.cs` | +| `DatUtils` | `WorldBuilder.Shared/Lib/DatUtils.cs` | +| `BoundingBoxExtensions` | `Chorizite.OpenGLSDLBackend/Lib/BoundingBoxExtensions.cs` | + +--- + +## ๐ŸŸข DATA MODELS โ€” take selectively + +| Component | What it does | +|---|---| +| `RegionInfo` | Landblock metadata wrapper (LandblockSizeInUnits, CellSizeInUnits, etc.) | +| `TerrainEntry` | Per-vertex terrain (Type/Scenery/Road/Height) | +| `MergedLandblock` | Merged dat data | +| `CellSplitDirection` | SW-NE vs NE-SW | +| `Cell` | Generic cell wrapper | +| `ObjectId` | Object identifier | +| `Position` | World position | +| `ACEnums` | AC-specific enums | +| `WbBuildingPortal` / `WbCellPortal` | Portal structures | +| `BuildingObject` | Building data | + +--- + +## ๐ŸŸก EDITOR-ONLY โ€” leave behind / delete in fork + +These exist for the editor experience and have no place in a game +client. Delete in fork. + +- **`Modules/Landscape/Tools/*`** โ€” `BrushTool`, `BucketFillTool`, + `RoadLineTool`, `RoadVertexTool`, `InspectorTool`, + `ObjectManipulationTool`, `Gizmo*` (DragHandler, HitTester, Renderer, + State), `TexturePainting*`, `SceneRaycaster`, + `LandscapeBrush`, `LandscapeToolBase`, `LandscapeToolContext`, + `IToolSettingsProvider`, `ILandscapeBrush`, `ILandscapeEditorService`, + `ILandscapeRaycastService`, `ILandscapeTool`, `ITexturePaintingTool` +- **`Modules/Landscape/Commands/*`** โ€” undo/redo command pattern for + editor (Add/Delete/Move/Rename/Reorder/etc.) +- **`LandscapeDocument` + `LandscapeLayer` + `LandscapeLayerGroup` + `LandscapeChunk` + `LandscapeLayerChunk` + `LandscapeLayerBase`** โ€” editor document model +- **`Modules/Landscape/Models/TerrainPatch*` + `LandblockChangedEventArgs`** โ€” editor mutation events +- **`Modules/Landscape/Services/ILandscapeCacheService` + `ILandscapeDataProvider` + `ILandscapeObjectService` + impls** โ€” editor data flow +- **All `Migrations/*`** โ€” SQLite schema migrations (project file format) +- **`Repositories/*`** + **`Services/*`** โ€” project storage, dat repository, AceDb, SignalR sync, document manager, undo stack, world coordinates, keyword DB, project migration, semantic kernel AI helpers +- **`Hubs/*`** โ€” collaborative editing via SignalR +- **`StaticObject` (editor model)** โ€” replace with our own scene-state data model fed from network +- **`BackendGizmoDrawer` + `GizmoRenderer`** โ€” editor gizmos +- **`ProjectStructures, IProject, Project`** โ€” editor project files +- **`KeyBinding`** โ€” editor input binding +- **`ViewportInputEvent[Extensions]`** โ€” editor viewport input +- **`EditorState`** โ€” editor state container + +--- + +## ๐ŸŸก AUDIO / FONT โ€” we already have alternatives + +Keep ours; don't take theirs. + +- **`AudioPlaybackEngine`** โ€” uses MP3Sharp. We have OpenAL. +- **`FontRenderer`** โ€” uses ImageSharp. We have BitmapFont/StbTrueTypeSharp + ImGui. + +--- + +## ๐Ÿ”ด NOT IN WORLDBUILDER โ€” port from retail decomp ourselves + +WorldBuilder is a dat editor; it does not have: + +- **Network protocol** โ€” UDP framing, ISAAC, packet codec, ACE message + layer (we have this; oracle is `references/holtburger`) +- **Physics** โ€” collision (CPhysicsObj transitions, BSP queries, sphere + sweeps), step-up, walkable validation (we have partial; oracle is the + retail decomp at `docs/research/named-retail/`) +- **Animation** โ€” motion sequencer, cycle/non-cycle parts, animation + frame interpolation (we have this; oracle is retail decomp) +- **Movement** โ€” local player WASD โ†’ MoveToState wire, remote-entity + motion via UpdateMotion + dead-reckoning (we have this; oracle is + `references/holtburger` + retail decomp) +- **Game UI** โ€” chat, vitals, inventory, spell book, allegiance, options + (we have this; ImGui-based today, custom-toolkit later) +- **Plugin API** โ€” `IGameState`, `IEvents`, `IActions`, `IPacketPipeline`, + `IOverlay` (we have this โ€” acdream-unique) +- **Game events** โ€” combat, allegiance, spell casting, quest events + (we have this; oracle is ACE for opcodes + retail for client behavior) +- **Audio** โ€” OpenAL pipeline, sound triggers (we have this) +- **TurbineChat** + **slash commands** (we have this) +- **Login + character selection flow** (we have this) + +--- + +## What this means for the workflow + +The CLAUDE.md "grep named โ†’ decompile โ†’ verify โ†’ port" workflow stays +the rule for everything in the ๐Ÿ”ด list (network, physics, animation, +movement, UI, plugin, audio, chat). For anything in ๐ŸŸข, the new rule is: +**check this inventory FIRST**. If WB has it, port from WB. Re-porting +from retail decomp when WB already has a tested port is no longer +appropriate โ€” that's how we got the scenery edge-vertex bug. + +When the inventory says "take wholesale or adapt" and we discover a +behavior mismatch with retail (rare โ€” WB is verified), the resolution +is: reconcile WB โ†” retail decomp โ†” holtburger โ†” ACE โ†” ACViewer (the +existing reference hierarchy in CLAUDE.md). WorldBuilder ranks at the +top of that hierarchy for anything ๐ŸŸข. diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 91f7100..ee78dc5 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -57,6 +57,7 @@ | K | Input architecture โ€” `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live โœ“ | | L.0 | Full retail-style Settings interface โ€” F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live โœ“ | | C.1 | PES particle system + sky-pass refinements โ€” retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive โ€” pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` โ†’ alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 โ†’ 1331 tests. | Live โœ“ | +| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live โœ“ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -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_=1` flag. +- Has its own conformance test (side-by-side against existing path). +- Visual verification before flag becomes default-on. +- Old code deleted after default-on lands cleanly. + +**N.2-N.10 detailed specs are NOT yet written** โ€” each gets its own +brainstorm + spec when we reach it. + +**Acceptance:** +- All 10 sub-phases shipped, feature flags removed, old rendering code + paths deleted. +- Visual verification at Holtburg + Foundry statue + a representative + dungeon shows no regression vs Phase C.1. +- WB upstream merges into our `acdream` branch are clean (or have + documented conflict-resolution patterns). + +--- + ### Phase J โ€” Long-tail (deferred / low-priority) Not detailed here; each gets its own brainstorm when it becomes relevant. diff --git a/docs/research/2026-05-08-phase-n3-handoff.md b/docs/research/2026-05-08-phase-n3-handoff.md new file mode 100644 index 0000000..7b7e7fa --- /dev/null +++ b/docs/research/2026-05-08-phase-n3-handoff.md @@ -0,0 +1,132 @@ +# Phase N.3 handoff โ€” texture decoding via WorldBuilder + +**Use this whole document as the prompt** when handing off to a fresh +agent. Everything they need to pick up cold is below. + +--- + +## Background you'll need + +You're working in `acdream`, a from-scratch C# .NET 10 reimplementation +of Asheron's Call's retail client. The project's house rule (in +`CLAUDE.md`) is **the code is modern, the behavior is retail**. + +acdream just shipped **Phase N.1** (commits `26cf2b8` through `ad8b931`), +the first sub-phase of a strategic migration to fork WorldBuilder +(`github.com/Chorizite/WorldBuilder`, MIT) and depend on its tested +rendering + dat-handling code instead of porting algorithms from retail +decomp ourselves. + +**Read first:** +- `docs/architecture/worldbuilder-inventory.md` โ€” the full taxonomy of + what WB has and what we keep porting ourselves +- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md` + โ€” the parent design doc for Phase N +- `CLAUDE.md` โ€” especially the "Reference repos" section (now points at + WB as the rendering BASE) and the workflow rules + +**Phase N.1 commit history (just shipped):** read +`git log --oneline c8782c9..ad8b931` to see how N.0 + N.1 were +structured. The pattern repeats for N.3. + +## What N.3 is + +Replace acdream's texture decoding pipeline with WorldBuilder's +`Chorizite.OpenGLSDLBackend.Lib.TextureHelpers`. WB handles INDEX16, +P8, BGRA, DXT, and alpha-channel decoding. Our existing implementations +of these are scattered across `src/AcDream.App/Rendering/TextureCache.cs` +and possibly `src/AcDream.Core/Meshing/` โ€” find them with +`grep -rln "INDEX16\|P8 decode\|DXT\|BGRA" src/`. + +## Acceptance criteria + +- Build green (`dotnet build`) +- All existing tests green (the 8 pre-existing `DispatcherToMovementIntegrationTests` + failures don't count โ€” they exist on main) +- New conformance tests added per format that's substituted (one xUnit + Theory per: INDEX16, P8, BGRA, DXT). Each compares a fixed input byte + array decoded by our path vs WB's path; assertions on output pixel array. +- Visual verification at Holtburg (or wherever) shows no texture + regressions: terrain texturing, mesh texturing, particle textures all + look the same. +- ISSUES.md updated with any known cosmetic deltas (the N.1 pattern โ€” + if WB and retail disagree on something subtle, file it, don't try + to fix it inline). + +## Tasks (suggested decomposition) + +Follow the N.1 plan structure (`docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md`) +as the template. Concretely: + +1. **Audit our texture decode paths.** Grep, list every file/method that + decodes a texture. Map each to the WB equivalent in + `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` + (read it end to end first). +2. **Per-format conformance test.** TDD style: write the test, run it + to fail, then plumb the substitution. Conformance test fixture inputs + should include real-dat byte sequences (read a known-good texture from + a dat, encode the bytes as a hex blob in the test). +3. **Substitution.** Replace each decode site with the WB call. Keep our + GL upload pathways โ€” those are NOT WB's responsibility. +4. **Visual verification.** Launch the client at Holtburg, walk around, + look at a tree (mesh texture), the ground (atlas texture), particles + (the recent C.1 rain/clouds/aurora work), and a building (composite + texture). Compare against retail or against a screenshot before the + change. +5. **Delete legacy decoders** once visual verification passes. +6. **Update roadmap + ISSUES** as the final commit. + +## Watchouts (lessons from N.1) + +- **ACME has a downstream fork with extra filters** (`references/WorldBuilder-ACME-Edition/`). + WB's `TextureHelpers` may have ACME-specific patches not yet in upstream. + Compare both before assuming WB's version is canonical. We forked + upstream WB; ACME is reference-only. +- **Conformance tests are non-negotiable.** Phase N.1's rotation bug was + caught by the conformance test. Don't skip them. If a test fails, it's + a real divergence โ€” investigate before "fixing" the test. +- **Whackamole stops the migration.** If 3+ visual regressions appear on + default-on, stop, file as ISSUES, ship. The migration goal is "use WB's + tested code"; pixel-perfect equivalence with our broken hand-ports is + not the goal. +- **`Setup.SortingSphere` โ‰  `Setup.CylSphere`.** The N.1 attempt at + `obj_within_block` over-suppressed because we used the wrong radius + source (sorting sphere too large). For texture decoding this likely + doesn't matter, but the general lesson is: read WB's full source + carefully before adapting; don't assume parallel methods do parallel + things. +- **Per-vertex road check โ€” STOP signal.** If you find yourself reading + ACME for "what's missing" and considering a per-vertex filter, STOP. + N.1 tried this (commit `e279c46`), regressed visually, reverted in + `677a726`. ACME's filter set works as a coherent unit; pick-and-choose + fails. If the N.3 work uncovers a similar ACME-only filter, file it + in ISSUES and move on, don't port it inline. + +## Where to start + +1. `git pull` on main to get the latest (Phase N.1 just merged). +2. Create a new worktree for the work: + `git worktree add .claude/worktrees/ -b claude/`. +3. Read the three "read first" docs above. +4. Run `dotnet build && dotnet test` to confirm clean baseline. +5. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` + end to end. Take notes on the public API surface. +6. Run the audit task (#1 in Tasks above). Output should be a markdown + table of "our function / file:line / WB equivalent / format covered." +7. Use `superpowers:writing-plans` to convert the audit into a concrete + per-format plan. Then use `superpowers:subagent-driven-development` + to execute it with fresh subagents per format. + +## Useful greps + +- `grep -rln "INDEX16\|IndexedSurface\|P8\|DXT\|BGRA\|TextureFormat" src/` โ€” find decode paths +- `grep -rln "TextureCache" src/` โ€” find our cache layer +- `grep -n "public static.*Decode\|public static.*Convert" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` โ€” WB's public API + +## Open question to resolve early + +Does `Chorizite.OpenGLSDLBackend.Lib.TextureHelpers` cover ALL the +formats we use, or does it have gaps? Audit our texture types against +WB's API in step 1. If WB is missing a format we need, the migration for +that format gets deferred (file in ISSUES; keep our decoder for it; note +in the roadmap). diff --git a/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md b/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md new file mode 100644 index 0000000..d6bae6f --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md @@ -0,0 +1,1130 @@ +# Phase N.1 โ€” Scenery via WorldBuilder Helpers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the in-line algorithm guts of `SceneryGenerator.Generate()` with calls to WorldBuilder's `SceneryHelpers` (Displace / RotateObj / ScaleObj / CheckSlope / ObjAlign) and `TerrainUtils` (OnRoad / GetNormal). Keep our data flow, our `ScenerySpawn` shape, and our renderer integration. Place behind a `ACDREAM_USE_WB_SCENERY=1` feature flag, prove equivalence with helper-level conformance tests, then flip default-on after visual verification at landblock `0xA9B1`. + +**Architecture:** Strangler-fig substitution. The 9ร—9 vertex loop, scene-selection hash, frequency roll, bounds check, building grid, and `BaseLoc.Z` handling stay identical. Only the five algorithm calls (displacement, road test, slope normal, rotation, scale) get replaced. A small adapter `WbSceneryAdapter.BuildTerrainEntries(LandBlock)` produces the `TerrainEntry[]` shape WB's helpers expect. + +**Tech Stack:** .NET 10 / C# 13, Silk.NET (transitively), `WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend` (project references already wired up in Phase N.0), DatReaderWriter for `LandBlock` / `Region` / `ObjectDesc` / `Scene` types, xUnit for tests. + +**Spec:** `docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md` +**Parent design:** `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md` +**Inventory:** `docs/architecture/worldbuilder-inventory.md` + +**Prerequisite:** Phase N.0 already shipped (commit `c8782c9`) โ€” `references/WorldBuilder/` is a git submodule pointing at `github.com/eriknihlen/WorldBuilder.git` `acdream` branch; `AcDream.Core.csproj` references `WorldBuilder.Shared` + `Chorizite.OpenGLSDLBackend`. + +--- + +## File Plan + +| File | Disposition | Responsibility | +|---|---|---| +| `src/AcDream.Core/World/WbSceneryAdapter.cs` | NEW | `LandBlock` (acdream's dat type) โ†’ `TerrainEntry[]` (WB's data shape). Stateless. | +| `src/AcDream.Core/World/SceneryGenerator.cs` | MODIFY | Add `UseWbScenery` feature-flag flag. Add `GenerateViaWb` private alternative path. Wire dispatch in `Generate()`. Keep legacy methods for now โ€” deleted in final task. | +| `tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs` | NEW | Verify field bit-packing round-trips (Road, Type, Scenery, Height) and bounds-check behavior. | +| `tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs` | NEW | Five small tests proving WB's `Displace`/`OnRoad`/`GetNormal`/`RotateObj`/`ScaleObj` match our existing inline logic for representative inputs. | +| `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs` | MODIFY | After Task 7 (legacy delete), prune the now-irrelevant `DisplaceObject_*` tests and update class doc. | + +**Why split adapter into its own file:** the adapter is a pure stateless utility. Putting it in its own file keeps `SceneryGenerator.cs` focused on the generator algorithm, and makes the adapter trivially reusable in N.2+ (terrain math helpers will need the same `TerrainEntry[]`). + +**Why combine conformance tests in one file:** all five tests share the same imports and fixtures, and they all measure the same thing (helper equivalence). Splitting would be over-decomposed. + +--- + +## Task 1: LandBlock โ†’ TerrainEntry[] adapter + +**Files:** +- Create: `src/AcDream.Core/World/WbSceneryAdapter.cs` +- Test: `tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs` + +- [ ] **Step 1.1: Write the failing test** + +Create `tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs`: + +```csharp +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Tests.World; + +/// +/// Tests for . The adapter converts our +/// LandBlock dat type (Terrain ushort[81] + Height byte[81]) into +/// WorldBuilder's [81] +/// shape, which WB's TerrainUtils / SceneryRenderManager consume. +/// +/// Bit layout in our LandBlock.Terrain[i] (ushort): +/// bits 0-1 : Road (2 bits, ACViewer convention) +/// bits 2-6 : TerrainType (5 bits) โ†’ WB calls this Texture +/// bits 11-15 : SceneType (5 bits) โ†’ WB calls this Scenery +/// Height comes from LandBlock.Height[i] (byte). +/// +public class WbSceneryAdapterTests +{ + [Fact] + public void BuildTerrainEntries_PreservesRoadTextureSceneryHeight() + { + var block = new LandBlock + { + Terrain = new ushort[81], + Height = new byte[81], + }; + + // Vertex 0: road=0x3, type=0x00, scenery=0x1F, height=42 + // raw layout: bits 0-1=11, bits 2-6=00000, bits 7-10=0000, bits 11-15=11111 + // = 0xF803 + block.Terrain[0] = 0xF803; + block.Height[0] = 42; + + // Vertex 80: road=0x0, type=0x1F, scenery=0x00, height=200 + // raw layout: bits 0-1=00, bits 2-6=11111, bits 11-15=00000 + // = 0x007C + block.Terrain[80] = 0x007C; + block.Height[80] = 200; + + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + + Assert.Equal(81, entries.Length); + + Assert.Equal((byte)42, entries[0].Height); + Assert.Equal((byte)0x3, entries[0].Road); + Assert.Equal((byte)0x00, entries[0].Texture); + Assert.Equal((byte)0x1F, entries[0].Scenery); + + Assert.Equal((byte)200, entries[80].Height); + Assert.Equal((byte)0x0, entries[80].Road); + Assert.Equal((byte)0x1F, entries[80].Texture); + Assert.Equal((byte)0x00, entries[80].Scenery); + } + + [Fact] + public void BuildTerrainEntries_AllZeros_ProducesEmptyEntries() + { + var block = new LandBlock + { + Terrain = new ushort[81], + Height = new byte[81], + }; + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + Assert.All(entries, e => + { + Assert.Equal((byte)0, e.Height); + Assert.Equal((byte)0, e.Road); + Assert.Equal((byte)0, e.Texture); + Assert.Equal((byte)0, e.Scenery); + }); + } + + [Fact] + public void BuildTerrainEntries_NullBlock_Throws() + { + Assert.Throws(() => + WbSceneryAdapter.BuildTerrainEntries(null!)); + } +} +``` + +- [ ] **Step 1.2: Run the test to verify it fails** + +Run: `dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "WbSceneryAdapterTests" 2>&1 | tail -10` +Expected: BUILD ERROR or FAIL with "type or namespace 'WbSceneryAdapter' could not be found". + +- [ ] **Step 1.3: Implement the adapter** + +Create `src/AcDream.Core/World/WbSceneryAdapter.cs`: + +```csharp +using DatReaderWriter.DBObjs; +using WorldBuilder.Shared.Models; + +namespace AcDream.Core.World; + +/// +/// Bridges acdream's dat types into WorldBuilder's data shapes for the +/// Phase N rendering migration. See +/// docs/architecture/worldbuilder-inventory.md for the full strategy. +/// +internal static class WbSceneryAdapter +{ + private const int VerticesPerSide = 9; + private const int TerrainSize = VerticesPerSide * VerticesPerSide; // 81 + + /// + /// Builds a 9ร—9 = 81-entry array from a + /// 's packed terrain bits + height bytes. WB's + /// TerrainUtils.OnRoad / GetNormal / GetHeight + /// consume this shape. + /// + /// Bit layout in our LandBlock.Terrain[i] (ushort): + /// bits 0-1 : Road (2 bits) โ†’ WB Road + /// bits 2-6 : TerrainType (5 bits) โ†’ WB Texture + /// bits 11-15 : SceneType (5 bits) โ†’ WB Scenery + /// Height comes from LandBlock.Height[i] (byte). + /// + public static TerrainEntry[] BuildTerrainEntries(LandBlock block) + { + ArgumentNullException.ThrowIfNull(block); + if (block.Terrain.Length != TerrainSize) + throw new ArgumentException( + $"LandBlock.Terrain must be {TerrainSize} entries (9ร—9), got {block.Terrain.Length}", + nameof(block)); + if (block.Height.Length != TerrainSize) + throw new ArgumentException( + $"LandBlock.Height must be {TerrainSize} entries (9ร—9), got {block.Height.Length}", + nameof(block)); + + var entries = new TerrainEntry[TerrainSize]; + for (int i = 0; i < TerrainSize; i++) + { + ushort raw = block.Terrain[i]; + byte road = (byte)(raw & 0x3); + byte texture = (byte)((raw >> 2) & 0x1F); + byte scenery = (byte)((raw >> 11) & 0x1F); + byte height = block.Height[i]; + entries[i] = new TerrainEntry(height, texture, scenery, road, encounters: null); + } + return entries; + } +} +``` + +- [ ] **Step 1.4: Run the test to verify it passes** + +Run: `dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "WbSceneryAdapterTests" 2>&1 | tail -5` +Expected: `Passed! - Failed: 0, Passed: 3` (or similar โ€” three tests). + +- [ ] **Step 1.5: Commit** + +```bash +git add src/AcDream.Core/World/WbSceneryAdapter.cs tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs +git commit -m "$(cat <<'EOF' +phase(N.1): add LandBlock โ†’ TerrainEntry[] adapter + +Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our +LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's +TerrainUtils / SceneryRenderManager consume. + +Bit-pack mapping (ours โ†’ WB): + Terrain bits 0-1 (Road) โ†’ TerrainEntry.Road + Terrain bits 2-6 (TerrainType) โ†’ TerrainEntry.Texture + Terrain bits 11-15 (SceneType) โ†’ TerrainEntry.Scenery + Height byte โ†’ TerrainEntry.Height + +Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Feature flag scaffold + +**Files:** +- Modify: `src/AcDream.Core/World/SceneryGenerator.cs` + +This task only adds the flag-read field. It changes no behavior and there is nothing to assert beyond "it compiles." The flag is consumed in Task 5. + +- [ ] **Step 2.1: Add the feature flag field** + +Open `src/AcDream.Core/World/SceneryGenerator.cs`. Find the line: + +```csharp + // AC landblock geometry โ€” matches LandblockMesh. + private const int VerticesPerSide = 9; + private const float CellSize = 24.0f; + private const float LandblockSize = 192.0f; // 8 cells * 24 units +``` + +Immediately AFTER the `LandblockSize` constant, ADD: + +```csharp + + /// + /// Phase N.1 feature flag โ€” when set to "1", scenery placement uses + /// WorldBuilder's SceneryHelpers + TerrainUtils instead of + /// our hand-ported algorithms. Default off until visual verification at + /// landblock 0xA9B1 confirms behavior. See + /// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + /// + internal static readonly bool UseWbScenery = + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1"; +``` + +- [ ] **Step 2.2: Verify build still passes** + +Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5` +Expected: `Build succeeded.` and `0 Error(s)`. + +- [ ] **Step 2.3: Verify all existing tests still pass** + +Run: `dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|Wb" 2>&1 | tail -3` +Expected: `Passed!` with all scenery-area tests passing. + +- [ ] **Step 2.4: Commit** + +```bash +git add src/AcDream.Core/World/SceneryGenerator.cs +git commit -m "$(cat <<'EOF' +phase(N.1): add ACDREAM_USE_WB_SCENERY feature flag scaffold + +Phase N.1 step 2: read the env var into a static bool. No behavior +change yet โ€” the flag is consumed in step 5 when GenerateViaWb is +wired in. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Per-helper conformance tests + +**Files:** +- Create: `tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs` + +These five tests prove WB's `SceneryHelpers.Displace` / `TerrainUtils.OnRoad` / `TerrainUtils.GetNormal` / `SceneryHelpers.RotateObj` / `SceneryHelpers.ScaleObj` produce the same answers as the hand-ported logic currently inlined in `Generate()`. After this task, we have empirical evidence that the substitution is safe. + +If a test fails, that is the bug โ€” investigate before proceeding to Task 4. + +- [ ] **Step 3.1: Write all five conformance tests** + +Create `tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs`: + +```csharp +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using WB_TerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils; +using WB_SceneryHelpers = Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers; + +namespace AcDream.Core.Tests.World; + +/// +/// Phase N.1 helper-level conformance tests. Each test compares an algorithm +/// in our existing path against WorldBuilder's +/// equivalent for representative inputs. Passing tests are empirical evidence +/// that swapping our inline logic for WB's helpers is behavior-preserving. +/// +/// If any of these fails the substitution would silently change rendered +/// scenery; investigate before proceeding to Task 4 (GenerateViaWb). +/// +/// Inputs are chosen to exercise: +/// - A non-edge vertex (gx=100, gy=100, j=0) โ€” typical case +/// - The edge vertex at y=8 specifically (Issue #49 territory) +/// +public class SceneryWbConformanceTests +{ + private static ObjectDesc MakeObj( + float displaceX = 12f, + float displaceY = 12f, + float minScale = 1f, + float maxScale = 1f, + float maxRotation = 0f, + float minSlope = 0f, + float maxSlope = 1f, + int align = 0) + { + return new ObjectDesc + { + ObjectId = 0x02000258u, + DisplaceX = displaceX, + DisplaceY = displaceY, + MinScale = minScale, + MaxScale = maxScale, + MaxRotation = maxRotation, + MinSlope = minSlope, + MaxSlope = maxSlope, + Align = (uint)align, + BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }, + }; + } + + /// + /// Our DisplaceObject โ†” WB's SceneryHelpers.Displace must produce the + /// same Vector3 for the same (obj, ix, iy, iq). + /// + [Theory] + [InlineData(100u, 100u, 0u)] // typical + [InlineData( 50u, 50u, 1u)] // typical, j=1 + [InlineData( 4u, 8u, 0u)] // edge vertex y=8 + [InlineData( 8u, 4u, 0u)] // edge vertex x=8 + public void Displace_OursMatchesWb(uint ix, uint iy, uint iq) + { + var obj = MakeObj(); + var ours = SceneryGenerator.DisplaceObject(obj, ix, iy, iq); + var wb = WB_SceneryHelpers.Displace(obj, ix, iy, iq); + + Assert.Equal(ours.X, wb.X, precision: 4); + Assert.Equal(ours.Y, wb.Y, precision: 4); + Assert.Equal(ours.Z, wb.Z, precision: 4); + } + + /// + /// Our IsOnRoad โ†” WB's TerrainUtils.OnRoad must produce the same bool + /// for the same (lx, ly) when the underlying terrain bits match. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell (0,0) center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex bug location + [InlineData( 3.0f, 3.0f)] // near a road if r0 is set + [InlineData( 23.5f, 12.0f)] // edge of cell, between cells + public void OnRoad_OursMatchesWb_DiagonalRoad(float lx, float ly) + { + // Build a synthetic LandBlock with road bits at SW (0,0) and NE (1,1) + // of cell (0,0) โ€” the diagonal pattern we saw at 0xA9B1. + var block = new LandBlock + { + Terrain = new ushort[81], + Height = new byte[81], + }; + // road bit at vertex (0,0) โ€” index 0*9+0 = 0 + block.Terrain[0] = 0x0003; // road=3 + // road bit at vertex (1,1) โ€” index 1*9+1 = 10 + block.Terrain[10] = 0x0003; + + bool ours = SceneryGenerator.IsOnRoad(block, lx, ly); + + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + bool wb = WB_TerrainUtils.OnRoad(new Vector3(lx, ly, 0), entries); + + Assert.Equal(ours, wb); + } + + /// + /// Our SampleNormalZFromHeightmap โ†” WB's TerrainUtils.GetNormal(...).Z + /// must produce the same Z for representative slope inputs. + /// + [Theory] + [InlineData( 12.0f, 12.0f)] // cell center + [InlineData( 85.08f, 190.97f)] // the 0xA9B1 edge-vertex location + [InlineData( 3.0f, 188.0f)] // near a y-edge + public void GetNormalZ_OursMatchesWb_LinearTable(float lx, float ly) + { + // Heightmap with non-flat terrain so normals are non-trivial. + var heights = new byte[81]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256); + + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 1.0f; + + const uint lbX = 0xA9, lbY = 0xB1; + + // Build a Region-shaped object with the LandHeightTable populated. + // (TerrainUtils.GetNormal calls region.LandDefs.LandHeightTable[height].) + // LandHeightTable is float[] (size 256) in DatReaderWriter โ€” see + // src/AcDream.App/Rendering/GameWindow.cs:1306-1308 for the runtime check. + var region = new DatReaderWriter.DBObjs.Region + { + LandDefs = new LandDefs(), + }; + // If LandDefs default-initializes LandHeightTable to a non-null float[256], + // copy into it. If it's null, assign directly. The implementer should + // pick whichever pattern compiles in DatReaderWriter 2.1.7's API: + // Option A: region.LandDefs.LandHeightTable = heightTable; + // Option B: Array.Copy(heightTable, region.LandDefs.LandHeightTable, 256); + region.LandDefs.LandHeightTable = heightTable; + + var block = new LandBlock + { + Terrain = new ushort[81], + Height = heights, + }; + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + + float ours = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap( + heights, heightTable, lbX, lbY, lx, ly); + float wb = WB_TerrainUtils.GetNormal(region, entries, lbX, lbY, + new Vector3(lx, ly, 0)).Z; + + Assert.Equal(ours, wb, precision: 4); + } + + /// + /// Our inline rotation logic โ†” WB's SceneryHelpers.RotateObj must + /// produce the same Quaternion for non-Align objects with MaxRotation. + /// + [Theory] + [InlineData( 100u, 100u, 0u, 360f)] + [InlineData( 4u, 8u, 0u, 360f)] + [InlineData( 200u, 250u, 1u, 180f)] + public void RotateObj_OursMatchesWb_NonAlign(uint gx, uint gy, uint j, float maxRot) + { + var obj = MakeObj(maxRotation: maxRot); + + // Our inline logic from SceneryGenerator.Generate (~lines 220-231): + Quaternion ours = obj.BaseLoc.Orientation; + if (ours.LengthSquared() < 0.0001f) ours = Quaternion.Identity; + if (obj.MaxRotation > 0f) + { + double rotNoise = unchecked((uint)(1813693831u * gy + - (j + 63127u) * (1360117743u * gy * gx + 1888038839u) + - 1109124029u * gx)) * 2.3283064e-10; + float degrees = (float)(rotNoise * obj.MaxRotation); + float yawDeg = -((450f - degrees) % 360f); + float yawRad = yawDeg * MathF.PI / 180f; + var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad); + ours = headingQuat * ours; + } + + // WB's SceneryHelpers.Displace returns the localPos that RotateObj + // expects for its loc parameter (used only when SetHeading is called + // with non-zero matrix, but a stub Vector3 works since BaseLoc is identity). + var localPos = WB_SceneryHelpers.Displace(obj, gx, gy, j); + Quaternion wb = WB_SceneryHelpers.RotateObj(obj, gx, gy, j, localPos); + + Assert.Equal(ours.X, wb.X, precision: 4); + Assert.Equal(ours.Y, wb.Y, precision: 4); + Assert.Equal(ours.Z, wb.Z, precision: 4); + Assert.Equal(ours.W, wb.W, precision: 4); + } + + /// + /// Our inline scale logic โ†” WB's SceneryHelpers.ScaleObj must produce + /// the same float for representative inputs. + /// + [Theory] + [InlineData(100u, 100u, 0u, 0.5f, 1.5f)] + [InlineData( 4u, 8u, 0u, 1.0f, 1.0f)] + [InlineData(200u, 250u, 1u, 0.8f, 1.2f)] + public void ScaleObj_OursMatchesWb(uint gx, uint gy, uint j, float minScale, float maxScale) + { + var obj = MakeObj(minScale: minScale, maxScale: maxScale); + + // Our inline logic from SceneryGenerator.Generate (~lines 236-247): + float ours; + if (obj.MinScale == obj.MaxScale) + { + ours = obj.MaxScale; + } + else + { + double scaleNoise = unchecked((uint)(1813693831u * gy + - (j + 32593u) * (1360117743u * gy * gx + 1888038839u) + - 1109124029u * gx)) * 2.3283064e-10; + ours = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); + } + if (ours <= 0) ours = 1f; + + float wb = WB_SceneryHelpers.ScaleObj(obj, gx, gy, j); + if (wb <= 0) wb = 1f; + + Assert.Equal(ours, wb, precision: 4); + } +} +``` + +- [ ] **Step 3.2: Make our `IsOnRoad` accessible to the test** + +`IsOnRoad` is currently `private`. Bump to `internal` so the conformance test can call it. Open `src/AcDream.Core/World/SceneryGenerator.cs` and change: + +```csharp + private static bool IsOnRoad(LandBlock block, float lx, float ly) +``` + +to: + +```csharp + internal static bool IsOnRoad(LandBlock block, float lx, float ly) +``` + +- [ ] **Step 3.3: Run the conformance tests** + +Run: `dotnet test --no-restore tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryWbConformance" 2>&1 | tail -10` +Expected: ALL TESTS PASS. + +If any test fails, **stop and investigate** โ€” that's the bug WB is hiding from us. Report which assertion failed (e.g., "Displace at gx=4 gy=8 returns different Y") and confer with the user before proceeding. + +- [ ] **Step 3.4: Commit** + +```bash +git add tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs src/AcDream.Core/World/SceneryGenerator.cs +git commit -m "$(cat <<'EOF' +phase(N.1): per-helper conformance tests for WB substitutions + +Phase N.1 step 3: prove our inline algorithms (Displace, IsOnRoad, +slope normal Z, RotateObj, ScaleObj) match WorldBuilder's helpers +for representative inputs including the 0xA9B1 edge-vertex case. + +Bumps IsOnRoad to internal so the test can call it directly. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Implement `GenerateViaWb` + +**Files:** +- Modify: `src/AcDream.Core/World/SceneryGenerator.cs` + +Add a new private method `GenerateViaWb` that produces `IReadOnlyList` using only WB helpers for the substituted algorithm calls. The 9ร—9 loop, scene selection, frequency check, bounds check, building grid, and `BaseLoc.Z` handling stay structurally identical to `Generate`. + +- [ ] **Step 4.1: Add the required `using` directives** + +Open `src/AcDream.Core/World/SceneryGenerator.cs`. The file currently has: + +```csharp +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.World; +``` + +Replace with: + +```csharp +using System.Numerics; +using Chorizite.OpenGLSDLBackend.Lib; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using WorldBuilder.Shared.Modules.Landscape.Lib; + +namespace AcDream.Core.World; +``` + +- [ ] **Step 4.2: Add `GenerateViaWb` immediately after `Generate`** + +Find the closing `}` of `Generate(...)` in `SceneryGenerator.cs` (just before the `IsRoadVertex` method). Immediately AFTER `Generate`'s closing brace, ADD: + +```csharp + + /// + /// Phase N.1 alternative implementation that delegates the + /// algorithm calls to WorldBuilder's SceneryHelpers + + /// TerrainUtils. Structurally identical to + /// but with WB's tested ports doing the work. Selected by + /// . + /// + private static IReadOnlyList GenerateViaWb( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells) + { + var result = new List(); + + if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) + return result; + + // Build the TerrainEntry[] WB's helpers consume โ€” once per landblock. + var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block); + + uint blockX = (landblockId >> 24) * 8; + uint blockY = ((landblockId >> 16) & 0xFFu) * 8; + uint lbX = landblockId >> 24; + uint lbY = (landblockId >> 16) & 0xFFu; + + for (int x = 0; x < VerticesPerSide; x++) + { + for (int y = 0; y < VerticesPerSide; y++) + { + int i = x * VerticesPerSide + y; + ushort raw = block.Terrain[i]; + + uint terrainType = (uint)((raw >> 2) & 0x1F); + uint sceneType = (uint)((raw >> 11) & 0x1F); + + if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; + var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; + if (sceneType >= sceneTypeList.Count) continue; + + uint sceneInfo = sceneTypeList[(int)sceneType]; + if (sceneInfo >= region.SceneInfo.SceneTypes.Count) continue; + + var scenes = region.SceneInfo.SceneTypes[(int)sceneInfo].Scenes; + if (scenes.Count == 0) continue; + + uint cellX = (uint)x; + uint cellY = (uint)y; + uint globalCellX = cellX + blockX; + uint globalCellY = cellY + blockY; + + // Scene-selection hash: identical to Generate. + uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u) + - 1109124029u * globalCellX + 2139937281u; + double offset = cellMat * 2.3283064e-10; + int sceneIdx = (int)(scenes.Count * offset); + if (sceneIdx >= scenes.Count || sceneIdx < 0) sceneIdx = 0; + + uint sceneId = (uint)scenes[sceneIdx]; + var scene = dats.Get(sceneId); + if (scene is null) continue; + + // Per-object frequency setup: identical to Generate. + uint cellXMat = unchecked(0u - 1109124029u * globalCellX); + uint cellYMat = 1813693831u * globalCellY; + uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; + + for (uint j = 0; j < scene.Objects.Count; j++) + { + var obj = scene.Objects[(int)j]; + if (obj.WeenieObj != 0) continue; + + double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10; + if (noise >= obj.Frequency) continue; + + // โ”€โ”€โ”€ WB substitution: displacement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + var localPos = SceneryHelpers.Displace(obj, globalCellX, globalCellY, j); + + float lx = cellX * CellSize + localPos.X; + float ly = cellY * CellSize + localPos.Y; + + if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) + continue; + + // โ”€โ”€โ”€ WB substitution: road check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries)) + continue; + + // Building check: identical to Generate. + if (buildingCells is not null) + { + int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1); + int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1); + if (buildingCells.Contains(dcx * VerticesPerSide + dcy)) + continue; + } + + // โ”€โ”€โ”€ WB substitution: slope check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Vector3 normal = TerrainUtils.GetNormal( + region, terrainEntries, lbX, lbY, + new Vector3(lx, ly, 0)); + if (!SceneryHelpers.CheckSlope(obj, normal.Z)) + continue; + + float lz = obj.BaseLoc.Origin.Z; + + // โ”€โ”€โ”€ WB substitution: rotation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Quaternion rotation; + if (obj.Align != 0) + rotation = SceneryHelpers.ObjAlign(obj, normal, lz, localPos); + else + rotation = SceneryHelpers.RotateObj(obj, globalCellX, globalCellY, j, localPos); + + // โ”€โ”€โ”€ WB substitution: scale โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j); + if (scale <= 0) scale = 1f; + + result.Add(new ScenerySpawn( + ObjectId: obj.ObjectId, + LocalPosition: new Vector3(lx, ly, lz), + Rotation: rotation, + Scale: scale)); + } + } + } + + return result; + } +``` + +- [ ] **Step 4.3: Verify build** + +Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5` +Expected: `Build succeeded.` `0 Error(s)`. + +If you get an error like `'SceneryHelpers' is an ambiguous reference`, it's because both Chorizite.OpenGLSDLBackend.Lib and WorldBuilder.Shared expose helpers โ€” fix by qualifying: `Chorizite.OpenGLSDLBackend.Lib.SceneryHelpers.Displace(...)`. + +- [ ] **Step 4.4: Verify existing tests still pass** + +Run: `dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3` +Expected: all scenery-area tests pass (the ones added in Tasks 1 and 3, plus the original SceneryGeneratorTests). No behavior change yet for the live `Generate` path โ€” `GenerateViaWb` is added but not called. + +- [ ] **Step 4.5: Commit** + +```bash +git add src/AcDream.Core/World/SceneryGenerator.cs +git commit -m "$(cat <<'EOF' +phase(N.1): implement GenerateViaWb alternative path + +Phase N.1 step 4: parallel implementation of Generate() that calls +WB's SceneryHelpers (Displace/CheckSlope/RotateObj/ObjAlign/ScaleObj) +and TerrainUtils (OnRoad/GetNormal) instead of the inline ports. + +Not yet wired in โ€” Generate() still runs the legacy path. Step 5 +adds the dispatch. + +Per-helper conformance tests in step 3 prove this implementation is +behavior-equivalent to the legacy path. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Wire feature-flag dispatch + +**Files:** +- Modify: `src/AcDream.Core/World/SceneryGenerator.cs` + +- [ ] **Step 5.1: Add the dispatch at the top of `Generate`** + +In `src/AcDream.Core/World/SceneryGenerator.cs`, find the body of `Generate`: + +```csharp + public static IReadOnlyList Generate( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells = null, + float[]? heightTable = null) + { + var result = new List(); + + if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) + return result; +``` + +Immediately AFTER the opening `{` and BEFORE `var result = new List();`, ADD: + +```csharp + // Phase N.1: route to the WorldBuilder-backed implementation when + // ACDREAM_USE_WB_SCENERY=1. See + // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + if (UseWbScenery) + return GenerateViaWb(dats, region, block, landblockId, buildingCells); + +``` + +So the method's opening becomes: + +```csharp + public static IReadOnlyList Generate( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells = null, + float[]? heightTable = null) + { + // Phase N.1: route to the WorldBuilder-backed implementation when + // ACDREAM_USE_WB_SCENERY=1. See + // docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + if (UseWbScenery) + return GenerateViaWb(dats, region, block, landblockId, buildingCells); + + var result = new List(); + + if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) + return result; +``` + +Note: `heightTable` is NOT passed to `GenerateViaWb` โ€” the WB path uses `region.LandDefs.LandHeightTable` via `TerrainUtils.GetNormal`. The legacy path keeps the parameter for backward compatibility. + +- [ ] **Step 5.2: Verify build** + +Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj 2>&1 | tail -5` +Expected: `Build succeeded.` + +- [ ] **Step 5.3: Verify all existing tests still pass with flag OFF (default)** + +Run: `dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3` +Expected: all pass (the legacy path is still default, so behavior is unchanged). + +- [ ] **Step 5.4: Commit** + +```bash +git add src/AcDream.Core/World/SceneryGenerator.cs +git commit -m "$(cat <<'EOF' +phase(N.1): wire ACDREAM_USE_WB_SCENERY dispatch in Generate() + +Phase N.1 step 5: when the flag is set, Generate() delegates to +GenerateViaWb. Default off; flag flips to default-on in step 7 +after visual verification. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Visual verification โ€” manual checkpoint + +This task is interactive. **You must work with the user.** They run the client, look at landblock `0xA9B1`, and confirm two things visually: + +1. The road-edge tree we have been chasing all session is **not present** in the WB-backed render. +2. The Issue #49 missing scenery (the trees the 9ร—9 loop expansion fixed) is **still visible**. + +- [ ] **Step 6.1: Make sure build is green** + +Run: `dotnet build 2>&1 | tail -3` +Expected: `Build succeeded.` + +- [ ] **Step 6.2: Tell the user how to launch with the flag set** + +Tell the user (paraphrase): "Set `$env:ACDREAM_USE_WB_SCENERY = '1'` in your launch terminal alongside the other env vars, then launch the client and navigate to Holtburg. Specifically check the road-edge area near coordinates (87, 191) in landblock 0xA9B1 โ€” the tree we have been chasing all session should be gone now. Also confirm Issue #49's previously missing trees are still there." + +If `dotnet run` is the standard launch command, the full PowerShell launch is: + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_USE_WB_SCENERY = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" +``` + +- [ ] **Step 6.3: Wait for user's verification report** + +The user will tell you "yes the offending tree is gone and Issue #49 is still fine" or "still wrong". If still wrong, do NOT proceed โ€” investigate and report back. + +- [ ] **Step 6.4: Commit nothing** + +This task does not produce a code change. The commit happens in Task 7 once the flag is flipped to default-on. + +--- + +## Task 7: Flip default-on + +**Files:** +- Modify: `src/AcDream.Core/World/SceneryGenerator.cs` + +After visual verification passes in Task 6, the WB-backed path becomes the default. The env var still exists as an escape hatch (`ACDREAM_USE_WB_SCENERY=0` reverts to legacy) so that if a regression is reported the next day, we can flip back without redeploying. + +- [ ] **Step 7.1: Flip the flag default** + +In `src/AcDream.Core/World/SceneryGenerator.cs`, find: + +```csharp + internal static readonly bool UseWbScenery = + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") == "1"; +``` + +Replace with: + +```csharp + /// + /// Phase N.1: scenery placement uses WorldBuilder's SceneryHelpers + /// + TerrainUtils by default. Set ACDREAM_USE_WB_SCENERY=0 + /// to restore the legacy in-line algorithms (escape hatch โ€” to be deleted + /// in a follow-up commit once we have a few sessions of green visuals). + /// + internal static readonly bool UseWbScenery = + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_SCENERY") != "0"; +``` + +- [ ] **Step 7.2: Verify build + tests** + +Run: `dotnet build 2>&1 | tail -3 && dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter|SceneryWbConformance" 2>&1 | tail -3` +Expected: build green, all targeted tests pass. + +- [ ] **Step 7.3: Commit** + +```bash +git add src/AcDream.Core/World/SceneryGenerator.cs +git commit -m "$(cat <<'EOF' +phase(N.1): WB-backed scenery is now default-on + +Phase N.1 step 7: flips ACDREAM_USE_WB_SCENERY to default-on after +visual verification at Holtburg confirmed the road-edge tree at +0xA9B1 is gone and Issue #49 trees are still visible. + +ACDREAM_USE_WB_SCENERY=0 still reverts to the legacy path. Follow-up +commit will delete the legacy code entirely. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Delete the legacy code path + +**Files:** +- Modify: `src/AcDream.Core/World/SceneryGenerator.cs` +- Modify: `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs` + +After at least one session of clean visuals on the default-on flag, remove the legacy code so we don't accumulate dead-code drift. + +- [ ] **Step 8.1: Delete the legacy `Generate` body and rename `GenerateViaWb`** + +In `src/AcDream.Core/World/SceneryGenerator.cs`: + +1. Delete the `UseWbScenery` field entirely. +2. Delete the entire body of `Generate` after its signature. +3. Replace it with a body that just calls `GenerateViaWb`'s logic (or rename `GenerateViaWb` to `Generate`'s body). + +The simplest approach: rename `GenerateViaWb` to `GenerateInternal` and have the public `Generate` call it. Then delete the legacy logic. Final shape: + +```csharp +public static IReadOnlyList Generate( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells = null, + float[]? heightTable = null) +{ + // heightTable kept for backward compat; WB path uses + // region.LandDefs.LandHeightTable internally. + _ = heightTable; + return GenerateInternal(dats, region, block, landblockId, buildingCells); +} + +private static IReadOnlyList GenerateInternal( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells) +{ + // ... body that was GenerateViaWb ... +} +``` + +4. Delete the now-unused private helpers: `IsOnRoad`, `DisplaceObject`, `RoadHalfWidth`, `CellsPerSide` (if only used by legacy path โ€” keep if `GenerateInternal`'s building check still references it). + +Concretely, keep: +- `VerticesPerSide`, `CellSize`, `LandblockSize`, `CellsPerSide` constants (still used in `GenerateInternal`) +- `IsRoadVertex` (still useful as a tiny public predicate) +- `WbSceneryAdapter` (still used) + +Delete: +- `UseWbScenery` +- `IsOnRoad` (and its `RoadHalfWidth` dependency) +- `DisplaceObject` (now dead) + +- [ ] **Step 8.2: Update SceneryGeneratorTests.cs to remove now-irrelevant tests** + +In `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs`, the existing +`DisplaceObject_EdgeVertex_CanProduceValidPosition` and +`DisplaceObject_InteriorVertex_AlwaysNearOrigin` tests reference the deleted +`SceneryGenerator.DisplaceObject` helper. Delete them. + +Keep: +- All `IsRoadVertex_*` tests (`IsRoadVertex` is preserved). + +The class doc comment at the top should be updated to reflect the new state: + +```csharp +/// +/// Tests for SceneryGenerator: the road-vertex predicate (only piece of +/// our own algorithm code remaining post Phase N.1). The displacement / +/// road / slope / rotation / scale algorithms now run through +/// WorldBuilder's helpers โ€” see SceneryWbConformanceTests.cs for the +/// helper-level equivalence proof. +/// +``` + +- [ ] **Step 8.3: Update the SceneryWbConformanceTests now that legacy helpers are gone** + +`SceneryWbConformanceTests` currently calls `SceneryGenerator.DisplaceObject` and `SceneryGenerator.IsOnRoad`. Once those are deleted, the tests are now testing "WB matches WB" which is meaningless. + +Delete `SceneryWbConformanceTests.cs` entirely. The conformance tests served their purpose during the migration โ€” they proved the substitution was safe. Now that we're committed to the WB path, they're vestigial. + +Run: `rm tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs` + +- [ ] **Step 8.4: Verify build + tests** + +Run: `dotnet build 2>&1 | tail -3 && dotnet test --no-build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "SceneryGenerator|WbSceneryAdapter" 2>&1 | tail -3` +Expected: build green, tests pass (just the IsRoadVertex tests + WbSceneryAdapter tests). + +- [ ] **Step 8.5: Commit** + +```bash +git add src/AcDream.Core/World/SceneryGenerator.cs tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs tests/AcDream.Core.Tests/World/SceneryWbConformanceTests.cs +git commit -m "$(cat <<'EOF' +phase(N.1): delete legacy scenery code path; WB is the only path + +Phase N.1 step 8 (final): now that ACDREAM_USE_WB_SCENERY has been +default-on for a session with no regressions, remove the legacy +in-line algorithms so we don't accumulate dead-code drift. + +Deleted: +- SceneryGenerator.UseWbScenery (feature flag) +- SceneryGenerator.IsOnRoad / DisplaceObject / RoadHalfWidth (legacy ports) +- SceneryGeneratorTests.DisplaceObject_* (test the deleted method) +- SceneryWbConformanceTests.cs (purpose served โ€” proved equivalence pre-migration) + +Renamed: +- GenerateViaWb โ†’ GenerateInternal (the only path now) + +Kept: +- IsRoadVertex (small predicate, still used by tests + may be useful elsewhere) +- WbSceneryAdapter (consumed by GenerateInternal; reusable in N.2) + +Phase N.1 complete. Issues #48, #49 are addressed via WB's tested +algorithms. Roadmap entry under Phase N can be marked shipped. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 8.6: Mark Phase N.1 shipped in the roadmap** + +In `docs/plans/2026-04-11-roadmap.md`, find the Phase N section (search for `### Phase N โ€” WorldBuilder Rendering Migration`). Inside the sub-phases table find the row for **N.1**, currently: + +``` +- **N.1 โ€” Scenery algorithm calls.** Replace `IsOnRoad` / + `DisplaceObject` / slope-normal calc / rotation / scale inside + `SceneryGenerator.Generate()` with calls to WB's `SceneryHelpers` + + `TerrainUtils`. Tiny adapter `LandBlock โ†’ TerrainEntry[]`. Keeps our + data flow + `ScenerySpawn` shape. Feature flag + `ACDREAM_USE_WB_SCENERY=1`. ~1-2 days. +``` + +Add a status marker at the start of the line: + +``` +- **โœ“ SHIPPED โ€” N.1 โ€” Scenery algorithm calls.** Shipped 2026-05-08. + Replaced `IsOnRoad` / `DisplaceObject` / slope-normal calc / rotation / + scale inside `SceneryGenerator.Generate()` with calls to WB's + `SceneryHelpers` + `TerrainUtils`. Adapter `WbSceneryAdapter` produces + `TerrainEntry[]`. Visual verification at Holtburg confirmed the + road-edge tree at 0xA9B1 is gone and Issue #49 trees are still visible. +``` + +Also: add a row to the top of the file's "Phases already shipped" table, in commit-shipped order: + +``` +| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live โœ“ | +``` + +Then commit: + +```bash +git add docs/plans/2026-04-11-roadmap.md +git commit -m "$(cat <<'EOF' +docs(roadmap): mark Phase N.1 (scenery via WB helpers) shipped + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Done definition + +After all 8 tasks land cleanly: + +- [x] `dotnet build` and `dotnet test` (excluding the 8 pre-existing `DispatcherToMovementIntegrationTests` failures unrelated to this work) green. +- [x] Visual verification at Holtburg confirms: + - The road-edge tree near `0xA9B1` is **gone**. + - Issue #49's missing scenery is **still visible**. + - No new visual regressions in surrounding landblocks during a brief flight. +- [x] Phase N.1 marked shipped in `docs/plans/2026-04-11-roadmap.md`. +- [x] `SceneryGenerator.Generate` calls only WB helpers for displacement / road / slope / rotation / scale. +- [x] Issue #49 stays closed; no new related issues filed. diff --git a/docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md b/docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md new file mode 100644 index 0000000..33f2280 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md @@ -0,0 +1,223 @@ +# Phase N โ€” WorldBuilder Rendering Migration: Design + +**Date:** 2026-05-08 +**Status:** Design complete, awaiting plan generation for N.1. + +## Goal + +Stop re-porting AC-specific rendering and dat-handling algorithms from +retail decomp. Instead, depend on a fork of WorldBuilder +(`github.com/Chorizite/WorldBuilder`, MIT) for terrain, scenery, static +objects, EnvCells, portals, sky, particles, texture decoding, mesh +extraction, and visibility / culling. Acdream keeps its own network, +physics, animation, motion, UI, plugin, audio, and chat layers โ€” those +are not in WorldBuilder. + +## Why + +acdream has accumulated a recurring pattern of subtle porting bugs in +its own rendering algorithms (the latest: a tree near the road at +landblock `0xA9B1` that retail and WorldBuilder do not show but our +re-port did, despite the algorithm code looking byte-identical to +WorldBuilder's). The triangle-Z bug, the hover-over-terrain bug, and +the edge-vertex spawn bug are all in the same family: small porting +errors that survive surface-level review. + +WorldBuilder is verified by visual inspection to render the AC world +correctly. It uses the same Silk.NET + .NET stack we already target. +It is MIT-licensed. It has fewer subtle bugs because its developers +have run it against the entire client_cell + client_portal dat content +and fixed everything users have reported. + +The cost of "we re-port retail algorithms ourselves" is now higher than +the cost of "we depend on someone else's tested port and inherit their +fixes." Migrating the rendering+dat layer to WorldBuilder is the +right call. + +## Inventory reference + +The full taxonomy of "what WorldBuilder has, what we keep porting +ourselves" lives at +[`docs/architecture/worldbuilder-inventory.md`](../../architecture/worldbuilder-inventory.md). +Before re-implementing any rendering or dat-handling algorithm, **check +the inventory first**. CLAUDE.md is updated to enforce this. + +## Architecture + +### Integration model + +**Fork upstream WorldBuilder, depend on the fork via git submodule.** + +- Fork: `https://github.com/eriknihlen/WorldBuilder` (already created; + upstream: `Chorizite/WorldBuilder`). +- Long-lived branch in fork: `acdream`. Upstream `master` merges into + `acdream` periodically; our acdream-specific changes (delete editor + files, expose hooks for our scene state) live on `acdream`. +- The current read-only snapshot at `references/WorldBuilder/` is + **replaced** by a git submodule pointing at the fork's `acdream` + branch. Existing CLAUDE.md path references and research docs that + cite `references/WorldBuilder/...` keep working. +- Our solution adds two ``s: + - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Chorizite.OpenGLSDLBackend.csproj` + - `references/WorldBuilder/WorldBuilder.Shared/WorldBuilder.Shared.csproj` +- Transitive NuGet dependencies (`Chorizite.Core`, + `Chorizite.DatReaderWriter.Extensions`, `BCnEncoder.Net`, + `SixLabors.ImageSharp`, `Silk.NET.SDL`, `MP3Sharp`) flow through. +- Editor-only files in WorldBuilder (Modules/Landscape/{Tools, + Commands, Services, Migrations, Hubs}, LandscapeDocument, etc.) stay + in the fork's source tree but are simply not referenced by acdream. + They impose no runtime cost. We can prune later if upstream stays + well-organized. + +### Phasing โ€” strangler fig, subsystem by subsystem + +Each sub-phase is independently shippable behind a feature flag +(`ACDREAM_USE_WB_=1`). After visual verification the flag becomes +default-on, then is removed and the old code is deleted. This gives us +a one-line revert if a phase regresses. + +| # | Sub-phase | Effort | Risk | +|---|---|---|---| +| **N.0** | Submodule + project references + build green | 1-2 hrs | Low | +| **N.1** | Scenery algorithm calls | 1-2 days | Low | +| **N.2** | Terrain math helpers | 1-2 days | Low | +| **N.3** | Texture decoding | 2-3 days | Medium | +| **N.4** | Object meshing (Setup/GfxObj) | 1 week | Medium | +| **N.5** | Terrain rendering (full pipeline) | 2 weeks | High | +| **N.6** | Static objects rendering | 2 weeks | High | +| **N.7** | EnvCells / dungeons | 2 weeks | High | +| **N.8** | Sky + particles | 1 week | Medium | +| **N.9** | Visibility / culling | 3-5 days | Medium | +| **N.10** | GL infrastructure consolidation (optional) | 1 week | Medium | + +Total estimated calendar: 2-3 months. Engineering effort: 6-8 weeks. + +### What WorldBuilder does NOT cover (keep porting from retail decomp) + +- Network protocol (UDP, ISAAC, ACE messages) โ€” keep ours +- Physics: collision, BSP queries, sphere sweeps, walkable validation + โ€” keep ours (partial), continue porting from retail decomp +- Animation: motion sequencer, cycle/non-cycle parts โ€” keep ours +- Movement: WASD โ†’ MoveToState wire, remote-entity motion via + UpdateMotion + dead-reckoning โ€” keep ours +- Game UI: chat, vitals, inventory, spell book โ€” keep ours (ImGui + today, custom-toolkit later) +- Plugin API: IGameState, IEvents, IActions, IPacketPipeline, + IOverlay โ€” keep ours (acdream-unique) +- Game events: combat, allegiance, spell casting โ€” keep ours +- Audio (OpenAL pipeline) โ€” keep ours +- TurbineChat + slash commands โ€” keep ours +- Login + character selection flow โ€” keep ours + +Per CLAUDE.md update, these still follow the +"grep named โ†’ decompile โ†’ verify โ†’ port" workflow against retail decomp +at `docs/research/named-retail/`. + +### Network reference posture + +`references/Chorizite.ACProtocol/` (separate Chorizite repo) remains +the Primary Oracle for protocol field order and packed-dword +conventions per CLAUDE.md's reference table. No fork needed there. We +will lean on it harder during future network-conformance phases (Phase +M is already on the roadmap for that). + +## Components + +### N.0 โ€” Setup (must land before N.1) + +**Files / actions:** +- Remove `references/WorldBuilder/` from working tree (it's currently a + checked-in snapshot). Add it back as a submodule pointing at + `git@github.com:eriknihlen/WorldBuilder.git` tracking the `acdream` + branch (created off `master`). +- Add `` entries in + `src/AcDream.Core/AcDream.Core.csproj` and + `src/AcDream.App/AcDream.App.csproj` for the two WB projects. +- Update `.gitmodules` to reflect the new submodule. +- Verify `dotnet build` and `dotnet test` are green. +- Commit. + +**Done criteria:** +- `git submodule status` shows `references/WorldBuilder` at the fork's + `acdream` HEAD. +- Solution builds clean with no new warnings. +- Existing 870+ tests still pass. + +### N.1 โ€” Scenery algorithm calls + +See companion design doc: +[`2026-05-08-phase-n1-scenery-via-wb-helpers-design.md`](2026-05-08-phase-n1-scenery-via-wb-helpers-design.md). + +Brief: replace the algorithm guts inside `SceneryGenerator.Generate()` +with calls to WB's `SceneryHelpers` (Displace, RotateObj, ScaleObj, +ObjAlign, CheckSlope) and `TerrainUtils` (OnRoad, GetNormal). Keep our +data flow, our `ScenerySpawn` shape, our renderer integration. Add a +small adapter `LandBlock โ†’ TerrainEntry[]`. + +### N.2-N.10 โ€” separately brainstormed when we get there + +Each sub-phase will get its own brainstorm + spec when we reach it. +Estimating ahead is unreliable for the bigger phases (N.5, N.6, N.7); +we'll know more after N.1 ships and we have hands-on experience with +the WB integration. + +## Risks + +1. **Chorizite.Core dependency footprint.** Each render manager we + take pulls in `Chorizite.Core.Lib` and `Chorizite.Core.Render`. + Mitigation: take the NuGet dep, don't try to strip it. Risk is + mostly cosmetic (an extra package). + +2. **WB's data-flow is editor-shaped.** `LandscapeDocument`, + `LandscapeChunk`, etc. are editor concepts. Mitigation: write small + adapters that produce the editor-shaped data from our dat reads. + Phase N.1 is intentionally chosen to avoid this โ€” we use only the + stateless helpers, not the full `SceneryRenderManager`. Larger + phases (N.5+) will need real adapter layers. + +3. **Upstream divergence.** WB's `master` will keep moving. Mitigation: + merge upstream `master` into our `acdream` branch periodically (at + minimum, before each new phase starts). Our acdream-specific + changes are isolated to deletions and additions on the `acdream` + branch, which merges cleanly with upstream most of the time. + +4. **Behaviors WB doesn't have.** WB is a dat editor; some + in-game-only behaviors (creature appearance via CreaturePalette / + GfxObjRemapping / HiddenParts) aren't in WB and we'll still need to + handle them ourselves at the integration boundary. Mitigation: + ACME's `StaticObjectManager.cs` covers these and is documented in + CLAUDE.md as the secondary oracle for character appearance. + +5. **Visual regression during migration.** Mitigation: feature flag + per phase. Visual verification at known-good locations (Holtburg, + Foundry statue, dungeon entrances) before flag becomes default-on. + +## Testing + +- **N.0:** existing 870+ tests stay green; `dotnet build` clean. +- **N.1:** new conformance test that runs both our `SceneryGenerator` + and a parallel call into WB's helpers against the same fixture data, + asserts identical spawn list. Visual verification at landblock + `0xA9B1` โ€” the offending tree should be gone, Issue #49's missing + scenery should still be visible. +- **N.2-N.10:** each phase will define its own conformance and visual + verification criteria when brainstormed. + +## Documentation impact + +- [x] `docs/architecture/worldbuilder-inventory.md` โ€” created. +- [x] `CLAUDE.md` โ€” updated with new posture (top-level rule + reference + table + per-domain oracle hierarchy). +- [ ] `docs/plans/2026-04-11-roadmap.md` โ€” add Phase N entry alongside + L, M, etc. (this happens in the same commit as the spec). +- [ ] `docs/architecture/acdream-architecture.md` โ€” needs an + acknowledging note that the rendering layer is now WB-backed; can + follow in a later commit, not blocking. + +## Out of scope for this design + +- Phase N.2-N.10 detailed scope (each gets own brainstorm). +- Network conformance work (separate Phase M). +- Animation, physics, motion ports (continue against retail decomp, + not WB). +- UI, plugin, chat work (separate phases, not affected). diff --git a/docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md b/docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md new file mode 100644 index 0000000..6ec1b58 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md @@ -0,0 +1,191 @@ +# Phase N.1 โ€” Scenery via WorldBuilder Helpers: Design + +**Date:** 2026-05-08 +**Parent design:** [`2026-05-08-phase-n-worldbuilder-migration-design.md`](2026-05-08-phase-n-worldbuilder-migration-design.md) +**Status:** Design complete, awaiting plan generation. + +## Goal + +Replace the algorithm guts of `SceneryGenerator.Generate()` with calls +to WorldBuilder's stateless `SceneryHelpers` and `TerrainUtils`. Keep +our data flow, our `ScenerySpawn` shape, and our renderer integration +unchanged. + +## Why scenery first + +1. **Active bug source.** Issues #48, #49 are scenery-related; the + investigation in this session uncovered another (the road-edge tree + at `0xA9B1`) we couldn't easily root-cause despite our code looking + identical to WB's. +2. **Smallest coherent slice.** Scenery placement uses only stateless + helpers from WB (Displace, OnRoad, GetNormal, CheckSlope, RotateObj, + ScaleObj). No need to take WB's `SceneryRenderManager`, no need for + editor-shaped data flow. +3. **Proves the integration pattern.** Phase N.0 wires up the + submodule + project references. N.1 uses them with a tiny surface + area. If something is wrong with the dependency model, we discover + it cheaply. + +## Architecture + +### What changes + +`src/AcDream.Core/World/SceneryGenerator.cs`: +- Remove our private `IsOnRoad(LandBlock, float, float)` helper. +- Remove our private `DisplaceObject(ObjectDesc, uint, uint, uint)` helper. +- Remove the `RoadHalfWidth` constant. +- Replace inline algorithm calls with WB equivalents (see table below). + +New file `src/AcDream.Core/World/WbSceneryAdapter.cs` (or similar +location โ€” TBD during implementation): +- Helper `BuildTerrainEntries(LandBlock block) โ†’ TerrainEntry[]` + converting our `DatReaderWriter.DBObjs.LandBlock` (the dat type) into + the `TerrainEntry[]` shape WB's `TerrainUtils` expects (9ร—9 grid, + Type/Scenery/Road/Height fields per vertex). +- Helper for `RegionInfo` if needed (small wrapper over our + `Region` dat). + +### Algorithm-call substitution table + +| Today (ours) | Phase N.1 (WB) | +|---|---| +| `IsRoadVertex(raw)` (kept; small util) | unchanged โ€” small predicate, no benefit to swap | +| `IsOnRoad(block, lx, ly)` | `TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries)` | +| `DisplaceObject(obj, gx, gy, j)` | `SceneryHelpers.Displace(obj, gx, gy, j)` | +| Slope normal: `TerrainSurface.SampleNormalZFromHeightmap(...)` | `TerrainUtils.GetNormal(region, terrainEntries, lbX, lbY, lbOffset).Z` | +| Slope check: `nz < obj.MinSlope \|\| nz > obj.MaxSlope` | `SceneryHelpers.CheckSlope(obj, normal.Z)` (returns bool) | +| Rotation logic (`AFrame::set_heading` reproduction) | `SceneryHelpers.RotateObj(obj, gx, gy, j, localPos)` (returns Quaternion) | +| Scale logic (LCG + Pow + clamp) | `SceneryHelpers.ScaleObj(obj, gx, gy, j)` (returns float) | + +### What does NOT change + +- The 9ร—9 vertex loop (`for (x = 0; x < 9; x++) for (y = 0; y < 9; y++)`). +- Scene selection hash. +- Frequency roll. +- `obj.WeenieObj != 0` skip (weenie entries are dynamic spawns). +- Bounds check `lx, ly โˆˆ [0, 192)`. +- Per-spawn building check using our `buildingCells` HashSet. +- `BaseLoc.Z` offset application. +- `ScenerySpawn` record shape returned to the renderer. +- `Generate()` method signature โ€” same parameters, same return type. + +### What about `obj_within_block`? + +We attempted this during the bug investigation but it's too aggressive +when applied with the model's actual sorting sphere radius (rejects +trees that should be there). WB also doesn't apply it. The retail +behavior we couldn't reproduce stays unreproduced for now โ€” we accept +that as a known minor cosmetic discrepancy and move on. The point of +N.1 is matching WB's behavior, not retail's. If WB and retail +disagree, that's a WB-upstream problem to file separately. + +## Components + +### Files modified + +- `src/AcDream.Core/World/SceneryGenerator.cs` โ€” algorithm-call swap. +- `src/AcDream.Core/AcDream.Core.csproj` โ€” already has WB project ref + from N.0. + +### Files added + +- `src/AcDream.Core/World/WbSceneryAdapter.cs` โ€” `LandBlock โ†’ + TerrainEntry[]` and any other small adapters needed. +- `tests/AcDream.Core.Tests/World/SceneryGeneratorWbConformanceTests.cs` + โ€” side-by-side test asserting our generator's output equals what + comes out when the same algorithms are called via WB directly. + +### Files deleted (eventually, after flag is on by default) + +- The deleted helpers in `SceneryGenerator.cs` mentioned above. + +### Feature flag + +Phase 1 of the rollout: `ACDREAM_USE_WB_SCENERY=1` (default off โ€” old +path runs). When the env var is set, the new WB-backed path runs. + +Phase 2 (after visual verification at Holtburg / `0xA9B1`): flag +default-on. Old path can still be reached via +`ACDREAM_USE_WB_SCENERY=0`. + +Phase 3 (one or two sessions later, after no regressions): delete the +flag and the old code paths entirely. + +## Done criteria + +1. `dotnet build` green with no new warnings. +2. All existing tests pass (870+). +3. New conformance test passes: `SceneryGeneratorWbConformanceTests` + runs both code paths against fixture LandBlock data and asserts + identical spawn lists (same ObjectId, same LocalPosition within + 1e-4, same Rotation within 1e-4, same Scale within 1e-4). +4. Visual verification at landblock `0xA9B1` (Holtburg area): + - The offending tree near the road that retail/WB do not show is + **gone** in our render. + - Issue #49's previously missing scenery (the tree from the 9ร—9 + loop expansion) is **still visible**. + - No new visual regressions in surrounding landblocks during a + brief flight around Holtburg. +5. Issue #49 stays closed; no new issues filed. + +## Risks (Phase-N.1-specific) + +1. **`TerrainEntry` field semantics.** WB packs Type/Scenery/Road/ + Height into the `TerrainEntry` struct in a specific format. Getting + the adapter wrong means OnRoad / scenery selection produces + different results than ours. Mitigation: read + `WorldBuilder.Shared/Modules/Landscape/Models/TerrainEntry.cs` + carefully; cross-check against WB's `TerrainUtils.GetRoad` / + `GetTerrainEntryForCell` to confirm field encoding. +2. **`RegionInfo` dependencies.** WB's `TerrainUtils.GetNormal` takes + a `RegionInfo` parameter. We need to either build a minimal + `RegionInfo` from our `Region` dat or call WB's normal calc + differently. Mitigation: investigate during implementation; expect + this is a small wrapper. +3. **`obj.MaxScale / obj.MinScale` divide-by-zero.** Our code checks + `if (obj.MinScale == obj.MaxScale)` first; WB's `ScaleObj` does the + same per-line review of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryHelpers.cs:42-51`. Should be a non-issue. +4. **Rotation quaternion convention.** Our rotation produces + `headingQuat * baseLoc.Orientation`. WB's `RotateObj` calls + `SetHeading` which does its own composition. Need to confirm the + resulting quaternion is the same convention our renderer expects. + Mitigation: the conformance test catches this if it's wrong. + +## Testing + +### Conformance test (new) + +`SceneryGeneratorWbConformanceTests`: +- Construct a synthetic `LandBlock` with known terrain data. +- Run `SceneryGenerator.Generate(...)` with `ACDREAM_USE_WB_SCENERY=0` + and again with `=1`. +- Assert spawn counts equal. +- Assert each spawn's ObjectId, LocalPosition (within 1e-4), Rotation + (within 1e-4 per component), Scale (within 1e-4) are equal. + +### Existing tests + +`SceneryGeneratorTests` covers: road-vertex predicate, edge-vertex +displacement bounds, interior-vertex displacement bounds. These tests +exercise our internal helpers (`IsRoadVertex`, `DisplaceObject`). +After N.1, the `DisplaceObject` test must be either deleted (if we +delete the helper) or replaced (if we keep `IsRoadVertex` as a small +predicate โ€” it's only one bit-test). + +### Visual verification + +User runs the client against ACE locally: +- Navigate to landblock `0xA9B1` (Holtburg). Verify offending tree + near road is gone. +- Confirm Issue #49's tree is still visible. +- Fly around Holtburg, scan visible scenery for any obvious + regression. + +## Out of scope for N.1 + +- Replacing our `SceneryRenderManager` (we don't have one โ€” we have + `SceneryGenerator` producing `ScenerySpawn[]` and the renderer + consuming it directly). N.1 only touches the generator. +- Replacing our terrain math helpers (that's N.2). +- Replacing the static-object renderer (that's N.6). +- Anything in N.2-N.10. diff --git a/references/WorldBuilder b/references/WorldBuilder new file mode 160000 index 0000000..167788b --- /dev/null +++ b/references/WorldBuilder @@ -0,0 +1 @@ +Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81 diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 6155c02..1ac800c 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -18,5 +18,13 @@ + + + diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index fe51188..525569e 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -198,6 +198,73 @@ public sealed class TerrainSurface return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE); } + /// + /// Sample the terrain triangle's surface-normal Z component at (localX, localY) + /// from a raw heightmap. Returns the upward component of the unit normal for + /// the specific triangle the point lies in โ€” flat ground returns 1.0, steeper + /// slopes return smaller values. Used by for + /// the retail slope filter (CLandCell::find_terrain_poly โ†’ polygon.plane.N.z). + /// + public static float SampleNormalZFromHeightmap( + byte[] heights, float[] heightTable, + uint landblockX, uint landblockY, + float localX, float localY) + { + ArgumentNullException.ThrowIfNull(heights); + ArgumentNullException.ThrowIfNull(heightTable); + if (heights.Length < 81) + throw new ArgumentException("heights must have 81 entries", nameof(heights)); + if (heightTable.Length < 256) + throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); + + float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f); + float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f); + int cx = (int)fx; + int cy = (int)fy; + cx = Math.Clamp(cx, 0, CellsPerSide - 1); + cy = Math.Clamp(cy, 0, CellsPerSide - 1); + + float tx = fx - cx; + float ty = fy - cy; + + float hBL = heightTable[heights[cx * HeightmapSide + cy ]]; + float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]]; + float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]]; + float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]]; + + bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy); + + float dzdx, dzdy; + if (splitSWtoNE) + { + if (tx > ty) + { + dzdx = (hBR - hBL) / CellSize; + dzdy = (hTR - hBR) / CellSize; + } + else + { + dzdx = (hTR - hTL) / CellSize; + dzdy = (hTL - hBL) / CellSize; + } + } + else + { + if (tx + ty <= 1f) + { + dzdx = (hBR - hBL) / CellSize; + dzdy = (hTL - hBL) / CellSize; + } + else + { + dzdx = (hTR - hTL) / CellSize; + dzdy = (hTR - hBR) / CellSize; + } + } + + return 1f / MathF.Sqrt(dzdx * dzdx + dzdy * dzdy + 1f); + } + /// /// Pick the cell's triangle for the chosen diagonal and barycentric- /// interpolate Z. Single source of truth shared by both diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 5c88128..b306e41 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -1,7 +1,9 @@ using System.Numerics; +using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; +using WorldBuilder.Shared.Modules.Landscape.Lib; namespace AcDream.Core.World; @@ -23,17 +25,11 @@ namespace AcDream.Core.World; /// (scale hash constant 0x7f51=32593 not in dumped chunks; /// confirmed against ACViewer which matches all other constants) /// -/// Key implementation note: the decompiled client computes each LCG value as a -/// signed 32-bit int, then normalises with "if (val < 0) val += 2^32" before -/// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast. -/// ACViewer's reference omits this cast and is subtly wrong for negative inputs. -/// We deliberately match the decompiled client, not ACViewer. -/// -/// 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. +/// Phase N.1 (2026-05-08): migrated all algorithm calls to WorldBuilder's +/// SceneryHelpers + TerrainUtils. The legacy in-line implementations +/// have been removed; WbSceneryAdapter bridges LandBlock data to WB's +/// TerrainEntry[]. See +/// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. /// public static class SceneryGenerator { @@ -41,6 +37,7 @@ public static class SceneryGenerator private const int VerticesPerSide = 9; private const float CellSize = 24.0f; private const float LandblockSize = 192.0f; // 8 cells * 24 units + private const int CellsPerSide = 8; public readonly record struct ScenerySpawn( uint ObjectId, // GfxObj or Setup id @@ -49,12 +46,9 @@ public static class SceneryGenerator float Scale); /// - /// Generate all scenery entries for one landblock. Uses the bit-packed - /// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into - /// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] โ†’ a SceneInfo - /// index into Region.SceneInfo.SceneTypes[sceneInfo].Scenes. Each cell picks - /// one scene via a pseudo-random hash of the cell's global coordinates, then - /// iterates the scene's ObjectDesc entries with per-object frequency rolls. + /// Generate all scenery entries for one landblock. Phase N.1 migrated this + /// to call WorldBuilder's SceneryHelpers + TerrainUtils; + /// see docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. /// public static IReadOnlyList Generate( DatCollection dats, @@ -63,37 +57,49 @@ public static class SceneryGenerator uint landblockId, HashSet? buildingCells = 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); + } + + /// + /// Returns true if the raw terrain word indicates a road vertex. + /// Bits 0-1 of the terrain word encode the road type; any non-zero value + /// means the vertex is on a road. Ported from ACViewer GetRoad(). + /// + public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; + + private static IReadOnlyList GenerateInternal( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId, + HashSet? buildingCells) { var result = new List(); if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) return result; - uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock - uint blockY = ((landblockId >> 16) & 0xFFu) * 8; + // Build the TerrainEntry[] WB's helpers consume โ€” once per landblock. + var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block); - // RETAIL iterates 8ร—8 = 64 CELLS, not 9ร—9 = 81 vertices. - // Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses - // `while (local_94 < 8)` and `while (local_8c < 8)` โ€” bound by - // `param_1+0x40` which is SideCellCount=8 for outdoor landblocks. - // The terrain word at each cell's SW corner drives that cell's scenery. - for (int x = 0; x < CellsPerSide; x++) + uint blockX = (landblockId >> 24) * 8; + uint blockY = ((landblockId >> 16) & 0xFFu) * 8; + uint lbX = landblockId >> 24; + uint lbY = (landblockId >> 16) & 0xFFu; + + for (int x = 0; x < VerticesPerSide; x++) { - for (int y = 0; y < CellsPerSide; y++) + for (int y = 0; y < VerticesPerSide; y++) { int i = x * VerticesPerSide + y; ushort raw = block.Terrain[i]; - uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 - uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 - - // NOTE: retail does NOT skip based on this vertex's road bit. - // The road test happens AFTER displacement via the 4-corner - // polygonal OnRoad check (see below). Removing the - // pre-displacement early-exit restores retail behavior. - - // Skip cells that contain buildings. - if (buildingCells is not null && buildingCells.Contains(i)) continue; + uint terrainType = (uint)((raw >> 2) & 0x1F); + uint sceneType = (uint)((raw >> 11) & 0x1F); if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; @@ -110,10 +116,7 @@ public static class SceneryGenerator uint globalCellX = cellX + blockX; uint globalCellY = cellY + blockY; - // Scene-selection hash: picks one scene from the terrain's scene list. - // Decompiled: chunk_00530000.c line 1144 - // iVar5 = (iVar8 * 0x2a7f2b89 + 0x6c1ac587) * iVar9 + iVar8 * -0x421be3bd + 0x7f8cda01 - // where iVar8=globalCellX, iVar9=globalCellY. + // Scene-selection hash: identical to Generate. uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u) - 1109124029u * globalCellX + 2139937281u; double offset = cellMat * 2.3283064e-10; @@ -124,14 +127,7 @@ public static class SceneryGenerator var scene = dats.Get(sceneId); if (scene is null) continue; - // Per-object hashes: roll frequency, compute displacement, scale, rotation. - // Decompiled: chunk_00530000.c lines 1168-1174 - // iStack_60 = iVar9 * 0x6c1ac587 โ†’ cellYMat - // uStack_78 = iVar9 * iVar8 * 0x5111bfef + 0x70892fb7 โ†’ cellMat2 - // iStack_64 = iVar8 * -0x421be3bd โ†’ cellXMat - // initial: local_90 = uStack_78 * 0x5b67 (j=0 term) - // per-loop: iStack_70 = (iStack_60 - local_90) + iStack_64; local_90 += uStack_78 - // โŸน iStack_70 = cellYMat - cellMat2 * (0x5b67 + j) + cellXMat + // Per-object frequency setup: identical to Generate. uint cellXMat = unchecked(0u - 1109124029u * globalCellX); uint cellYMat = 1813693831u * globalCellY; uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; @@ -139,15 +135,13 @@ public static class SceneryGenerator for (uint j = 0; j < scene.Objects.Count; j++) { var obj = scene.Objects[(int)j]; - if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery + 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; if (noise >= obj.Frequency) continue; - // Displacement: pseudo-random offset within the cell. - var localPos = DisplaceObject(obj, globalCellX, globalCellY, j); + // โ”€โ”€โ”€ WB substitution: displacement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + var localPos = SceneryHelpers.Displace(obj, globalCellX, globalCellY, j); float lx = cellX * CellSize + localPos.X; float ly = cellY * CellSize + localPos.Y; @@ -155,277 +149,48 @@ public static class SceneryGenerator if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) continue; - // Retail post-displacement road check (FUN_00530d30). - // Ported from ACViewer Landblock.OnRoad โ€” uses the 4-corner - // road bits of the containing cell plus the 5-unit road - // half-width to test whether the displaced (lx,ly) lies on - // the road ribbon. - bool isOnRoad = IsOnRoad(block, lx, ly); - if (isOnRoad) - { + // โ”€โ”€โ”€ WB substitution: road check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries)) continue; - } - // L-fix2 (2026-04-28): the extra cell-origin road-vertex - // guard previously here is REMOVED. It wasn't in the - // retail decomp โ€” it was a heuristic added to widen - // road margins visually. The proper retail post- - // displacement road check (FUN_00530d30 port via - // IsOnRoad above) already handles road exclusion. - // The extra guard was over-suppressing โ€” every cell - // whose SW corner happened to touch a road vertex - // had ALL of its scenery dropped, even when the - // displaced position was well clear of the ribbon. - // User reported missing trees they could see in - // retail; this is the most likely cause. - - // 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)) + // Building check: identical to Generate. + if (buildingCells is not null) { - int sx = Math.Clamp((int)(lx / CellSize), 0, VerticesPerSide - 2); - int sy = Math.Clamp((int)(ly / CellSize), 0, VerticesPerSide - 2); - int sxR = sx + 1; - int syU = sy + 1; - float h00 = heightTable[block.Height[sx * VerticesPerSide + sy]]; - float h10 = heightTable[block.Height[sxR * VerticesPerSide + sy]]; - float h01 = heightTable[block.Height[sx * VerticesPerSide + syU]]; - float dx = (h10 - h00) / CellSize; - float dy = (h01 - h00) / CellSize; - float nz = 1f / MathF.Sqrt(dx * dx + dy * dy + 1f); // normal Z component - if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; + int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1); + int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1); + if (buildingCells.Contains(dcx * VerticesPerSide + dcy)) + continue; } - // BaseLoc.Z offset: scenery-specific vertical offset from - // the ground (e.g., flowers planted at -0.1m so they - // don't float above grass). The renderer adds groundZ - // later, so pass the BaseLoc.Z through as-is. + // โ”€โ”€โ”€ WB substitution: slope check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Vector3 normal = TerrainUtils.GetNormal( + region, terrainEntries, lbX, lbY, + new Vector3(lx, ly, 0)); + if (!SceneryHelpers.CheckSlope(obj, normal.Z)) + continue; + float lz = obj.BaseLoc.Origin.Z; - // Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60) - // Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation - // into the frame, THEN calls AFrame::set_heading(degrees). - // - // set_heading uses yaw = -(450 - heading) % 360 before converting - // to a quaternion, which introduces a 90ยฐ offset + sign flip - // relative to a naive Z rotation. WorldBuilder's - // SceneryHelpers.SetHeading reproduces this. - // - // For objects with Align != 0, retail uses FUN_005a6f60 to - // align to the landcell polygon's normal instead of setting - // heading from the noise. - // - // Composition: final = baseLoc.Orientation * headingQuat - Quaternion rotation = obj.BaseLoc.Orientation; - if (rotation.LengthSquared() < 0.0001f) - rotation = Quaternion.Identity; - - if (obj.MaxRotation > 0f) - { - double rotNoise = unchecked((uint)(1813693831u * globalCellY - - (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u) - - 1109124029u * globalCellX)) * 2.3283064e-10; - float degrees = (float)(rotNoise * obj.MaxRotation); - // AFrame::set_heading transform โ€” matches retail. - float yawDeg = -((450f - degrees) % 360f); - float yawRad = yawDeg * MathF.PI / 180f; - var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad); - rotation = headingQuat * rotation; - } - - // Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern) - // offset constant 0x7f51 = 32593 (not in dumped chunks; cross-verified via ACViewer) - // same LCG structure as rotation/displacement; uint cast per decompiled normalisation - float scale; - if (obj.MinScale == obj.MaxScale) - { - scale = obj.MaxScale; - } + // โ”€โ”€โ”€ WB substitution: rotation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Quaternion rotation; + if (obj.Align != 0) + rotation = SceneryHelpers.ObjAlign(obj, normal, lz, localPos); else - { - double scaleNoise = unchecked((uint)(1813693831u * globalCellY - - (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u) - - 1109124029u * globalCellX)) * 2.3283064e-10; - scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); - } + rotation = SceneryHelpers.RotateObj(obj, globalCellX, globalCellY, j, localPos); + + // โ”€โ”€โ”€ WB substitution: scale โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j); if (scale <= 0) scale = 1f; result.Add(new ScenerySpawn( - ObjectId: obj.ObjectId, + ObjectId: obj.ObjectId, LocalPosition: new Vector3(lx, ly, lz), - Rotation: rotation, - Scale: scale)); + Rotation: rotation, + Scale: scale)); } } } return result; } - - /// - /// Returns true if the raw terrain word indicates a road vertex. - /// Bits 0-1 of the terrain word encode the road type; any non-zero value - /// means the vertex is on a road. Ported from ACViewer GetRoad(). - /// - public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; - - /// - /// Half-width of a road ribbon in world units โ€” the road extends from each - /// road vertex by this amount into the neighbor cells. Matches retail's - /// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30. - /// - private const float RoadHalfWidth = 5.0f; - - /// - /// Retail-faithful post-displacement road test. Ported from ACViewer - /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which is - /// a direct port of FUN_00530d30 in the retail client. - /// - /// Examines the 4 corners of the cell containing (lx, ly) and, depending - /// on how many are road vertices (0, 1, 2, 3, or 4), applies a polygonal - /// test using the 5-unit road half-width to check if (lx, ly) lies on the - /// road ribbon. Returns true if the point is on a road. - /// - /// - /// Retail-faithful road ribbon test โ€” direct port of ACViewer's - /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which - /// itself is a port of FUN_00530d30 in acclient.exe. - /// - /// 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). - /// - 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; - - /// - /// Pseudo-random displacement within a cell for a scenery object. Returns a - /// Vector3 in local cell-offset space (the caller adds it to the cell corner - /// to get landblock-local position). - /// - /// Verified against decompiled acclient.exe: chunk_005A0000.c lines 4844-4903 (FUN_005a6cc0). - /// X offset constant 0xb2cd = 45773; Y offset constant 0x11c0f = 72719. - /// Quadrant hash: line 4880; thresholds 0.25/0.5/0.75 map to _DAT_007c97cc/_DAT_007938b8/_DAT_0079c6dc. - /// Decompiled normalises signed-int LCG results with "if (val < 0) val += 2^32"; our - /// unchecked((uint)(...)) is exactly equivalent. - /// - 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); - } } diff --git a/src/AcDream.Core/World/WbSceneryAdapter.cs b/src/AcDream.Core/World/WbSceneryAdapter.cs new file mode 100644 index 0000000..1a90149 --- /dev/null +++ b/src/AcDream.Core/World/WbSceneryAdapter.cs @@ -0,0 +1,55 @@ +using DatReaderWriter.DBObjs; +using WorldBuilder.Shared.Models; + +namespace AcDream.Core.World; + +/// +/// Bridges acdream's dat types into WorldBuilder's data shapes for the +/// Phase N rendering migration. See +/// docs/architecture/worldbuilder-inventory.md for the full strategy. +/// +internal static class WbSceneryAdapter +{ + private const int VerticesPerSide = 9; + private const int TerrainSize = VerticesPerSide * VerticesPerSide; // 81 + + /// + /// Builds a 9ร—9 = 81-entry array from a + /// 's packed terrain bits + height bytes. WB's + /// TerrainUtils.OnRoad / GetNormal / GetHeight + /// consume this shape. + /// + /// Field mapping (TerrainInfo โ†’ ): + /// TerrainInfo.Road (bits 0-1) โ†’ + /// TerrainInfo.Type (bits 2-6) โ†’ + /// TerrainInfo.Scenery (bits 11-15) โ†’ + /// LandBlock.Height[i] โ†’ + /// + /// + /// No runtime length guards are needed here because + /// DatReaderWriter.DBObjs.LandBlock's default constructor + /// self-initializes both Terrain and Height to fixed-length + /// arrays of exactly 81 elements (9ร—9 vertices per landblock). Any caller + /// that constructs a synthetic with partial arrays + /// will receive an at the first + /// mis-sized index, which is the correct fast-fail behaviour for a + /// contract violation of this kind. + /// + public static TerrainEntry[] BuildTerrainEntries(LandBlock block) + { + ArgumentNullException.ThrowIfNull(block); + + var entries = new TerrainEntry[TerrainSize]; + for (int i = 0; i < TerrainSize; i++) + { + var ti = block.Terrain[i]; + entries[i] = new TerrainEntry( + height: block.Height[i], + texture: (byte)ti.Type, + scenery: ti.Scenery, + road: ti.Road, + encounters: null); + } + return entries; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs index c26b214..17a756c 100644 --- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -139,6 +139,52 @@ public class TerrainSurfaceTests Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f); } + [Fact] + public void SampleNormalZFromHeightmap_FlatTerrain_ReturnsOne() + { + var heights = FlatHeightmap(50); + var hTable = LinearHeightTable(); + float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 96f, 96f); + Assert.Equal(1f, nz, precision: 5); + } + + [Fact] + public void SampleNormalZFromHeightmap_SlopedTerrain_ReturnsLessThanOne() + { + var heights = new byte[81]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = (byte)(x * 20); + var hTable = LinearHeightTable(); + float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 12f, 12f); + Assert.True(nz < 1f, $"Sloped terrain should have nz < 1.0, got {nz}"); + Assert.True(nz > 0f, $"nz should be positive, got {nz}"); + } + + [Fact] + public void SampleNormalZFromHeightmap_AgreesWithSampleSurface() + { + var heights = new byte[81]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256); + + var hTable = LinearHeightTable(); + const uint lbX = 0xA9, lbY = 0xB3; + var instance = new TerrainSurface(heights, hTable, lbX, lbY); + + for (float lx = 0.5f; lx < 192f; lx += 8f) + for (float ly = 0.5f; ly < 192f; ly += 8f) + { + var (_, normal) = instance.SampleSurface(lx, ly); + float staticNz = TerrainSurface.SampleNormalZFromHeightmap( + heights, hTable, lbX, lbY, lx, ly); + Assert.True( + Math.Abs(normal.Z - staticNz) < 0.0001f, + $"NormalZ mismatch at ({lx:F1},{ly:F1}): instance={normal.Z:F4} static={staticNz:F4}"); + } + } + [Fact] public void ComputeOutdoorCellId_Origin_ReturnsFirst() { diff --git a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs index d003ea8..83cd73f 100644 --- a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs +++ b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs @@ -4,10 +4,11 @@ using DatReaderWriter.Types; namespace AcDream.Core.Tests.World; /// -/// Tests for SceneryGenerator road-exclusion logic. -/// The full Generate() pipeline requires real dat files (Region, Scene, etc.) -/// so road-check behavior is tested via the internal IsRoadVertex helper, -/// which is the single gate that guards against placing trees on roads. +/// Tests for SceneryGenerator. As of Phase N.1 (commit b84ecbd / Task 8 final +/// commit), the displacement / road / slope / rotation / scale algorithms run +/// through WorldBuilder's helpers (SceneryHelpers + TerrainUtils). The only +/// our-side code remaining is the small +/// predicate, which is what these tests cover. /// public class SceneryGeneratorTests { @@ -32,15 +33,12 @@ public class SceneryGeneratorTests [Fact] public void IsRoadVertex_ZeroTerrain_IsNotRoad() { - // A fully blank terrain entry (no type, no road, no scene) is not a road. Assert.False(SceneryGenerator.IsRoadVertex(0)); } [Fact] public void IsRoadVertex_MatchesTerrainInfoRoadProperty() { - // Verify that IsRoadVertex agrees with the typed TerrainInfo.Road property - // for a sample of raw values, ensuring the bit convention is consistent. for (ushort raw = 0; raw < 4; raw++) { TerrainInfo ti = raw; diff --git a/tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs b/tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs new file mode 100644 index 0000000..79fd358 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs @@ -0,0 +1,74 @@ +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.World; + +/// +/// Tests for . The adapter converts our +/// LandBlock dat type (Terrain TerrainInfo[81] + Height byte[81]) into +/// WorldBuilder's [81] +/// shape, which WB's TerrainUtils / SceneryRenderManager consume. +/// +/// Bit layout in LandBlock.Terrain[i] (TerrainInfo / ushort): +/// bits 0-1 : Road (2 bits) โ†’ WB TerrainEntry.Road +/// bits 2-6 : TerrainType (5 bits) โ†’ WB TerrainEntry.Type +/// bits 11-15 : SceneType (5 bits) โ†’ WB TerrainEntry.Scenery +/// Height comes from LandBlock.Height[i] (byte) โ†’ WB TerrainEntry.Height. +/// +public class WbSceneryAdapterTests +{ + [Fact] + public void BuildTerrainEntries_PreservesRoadTextureSceneryHeight() + { + var block = new LandBlock(); + + // Vertex 0: road=0x3, type=0x00, scenery=0x1F, height=42 + // raw layout: bits 0-1=11, bits 2-6=00000, bits 7-10=0000, bits 11-15=11111 + // = 0xF803 + block.Terrain[0] = (TerrainInfo)0xF803; + block.Height[0] = 42; + + // Vertex 80: road=0x0, type=0x1F, scenery=0x00, height=200 + // raw layout: bits 0-1=00, bits 2-6=11111, bits 11-15=00000 + // = 0x007C + block.Terrain[80] = (TerrainInfo)0x007C; + block.Height[80] = 200; + + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + + Assert.Equal(81, entries.Length); + + Assert.Equal((byte)42, entries[0].Height); + Assert.Equal((byte)0x3, entries[0].Road); + Assert.Equal((byte)0x00, entries[0].Type); + Assert.Equal((byte)0x1F, entries[0].Scenery); + + Assert.Equal((byte)200, entries[80].Height); + Assert.Equal((byte)0x0, entries[80].Road); + Assert.Equal((byte)0x1F, entries[80].Type); + Assert.Equal((byte)0x00, entries[80].Scenery); + } + + [Fact] + public void BuildTerrainEntries_AllZeros_ProducesEmptyEntries() + { + var block = new LandBlock(); + // Terrain and Height are already zero-initialized by LandBlock constructor. + var entries = WbSceneryAdapter.BuildTerrainEntries(block); + Assert.All(entries, e => + { + Assert.Equal((byte)0, e.Height); + Assert.Equal((byte)0, e.Road); + Assert.Equal((byte)0, e.Type); + Assert.Equal((byte)0, e.Scenery); + }); + } + + [Fact] + public void BuildTerrainEntries_NullBlock_Throws() + { + Assert.Throws(() => + WbSceneryAdapter.BuildTerrainEntries(null!)); + } +}