diff --git a/.gitignore b/.gitignore index d060c06d..af968b25 100644 --- a/.gitignore +++ b/.gitignore @@ -18,11 +18,7 @@ packages/ Thumbs.db # Reference repos and retail client (large, not our code, separate licenses) -# 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/ +references/ # Claude Code session state .claude/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index c691aa85..00000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "references/WorldBuilder"] - path = references/WorldBuilder - url = git@github.com:eriknihlen/WorldBuilder.git - branch = acdream diff --git a/CLAUDE.md b/CLAUDE.md index 17316686..469b95c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,19 +25,6 @@ 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. @@ -638,18 +625,11 @@ these, ideally all four: for the palette-indexed formats. See `ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical subpalette overlay algorithm. -- **`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/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/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 @@ -704,15 +684,12 @@ 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." 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. | +| **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. | | **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 464590f3..87c7b2da 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,50 +46,6 @@ 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 deleted file mode 100644 index 68144c98..00000000 --- a/docs/architecture/worldbuilder-inventory.md +++ /dev/null @@ -1,250 +0,0 @@ -# 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 ee78dc50..91f71004 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -57,7 +57,6 @@ | 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 @@ -499,146 +498,6 @@ 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 deleted file mode 100644 index 7b7e7fa6..00000000 --- a/docs/research/2026-05-08-phase-n3-handoff.md +++ /dev/null @@ -1,132 +0,0 @@ -# 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 deleted file mode 100644 index d6bae6f3..00000000 --- a/docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md +++ /dev/null @@ -1,1130 +0,0 @@ -# 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 deleted file mode 100644 index 33f22801..00000000 --- a/docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md +++ /dev/null @@ -1,223 +0,0 @@ -# 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 deleted file mode 100644 index 6ec1b587..00000000 --- a/docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md +++ /dev/null @@ -1,191 +0,0 @@ -# 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 deleted file mode 160000 index 167788be..00000000 --- a/references/WorldBuilder +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81 diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 1ac800c1..6155c02b 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -18,13 +18,5 @@ - - - diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index 525569e7..fe511881 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -198,73 +198,6 @@ 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 b306e41f..5c88128e 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -1,9 +1,7 @@ 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; @@ -25,11 +23,17 @@ namespace AcDream.Core.World; /// (scale hash constant 0x7f51=32593 not in dumped chunks; /// confirmed against ACViewer which matches all other constants) /// -/// 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. +/// 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. /// public static class SceneryGenerator { @@ -37,7 +41,6 @@ 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 @@ -46,9 +49,12 @@ public static class SceneryGenerator float Scale); /// - /// 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. + /// 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. /// public static IReadOnlyList Generate( DatCollection dats, @@ -57,49 +63,37 @@ 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; - // Build the TerrainEntry[] WB's helpers consume โ€” once per landblock. - var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block); - - uint blockX = (landblockId >> 24) * 8; + uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock uint blockY = ((landblockId >> 16) & 0xFFu) * 8; - uint lbX = landblockId >> 24; - uint lbY = (landblockId >> 16) & 0xFFu; - for (int x = 0; x < VerticesPerSide; x++) + // 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++) { - for (int y = 0; y < VerticesPerSide; y++) + for (int y = 0; y < CellsPerSide; y++) { int i = x * VerticesPerSide + y; ushort raw = block.Terrain[i]; - uint terrainType = (uint)((raw >> 2) & 0x1F); - uint sceneType = (uint)((raw >> 11) & 0x1F); + 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; if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; @@ -116,7 +110,10 @@ public static class SceneryGenerator uint globalCellX = cellX + blockX; uint globalCellY = cellY + blockY; - // Scene-selection hash: identical to Generate. + // Scene-selection hash: picks one scene from the terrain's scene list. + // Decompiled: chunk_00530000.c line 1144 + // iVar5 = (iVar8 * 0x2a7f2b89 + 0x6c1ac587) * iVar9 + iVar8 * -0x421be3bd + 0x7f8cda01 + // where iVar8=globalCellX, iVar9=globalCellY. uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u) - 1109124029u * globalCellX + 2139937281u; double offset = cellMat * 2.3283064e-10; @@ -127,7 +124,14 @@ public static class SceneryGenerator var scene = dats.Get(sceneId); if (scene is null) continue; - // Per-object frequency setup: identical to Generate. + // Per-object hashes: roll frequency, compute displacement, scale, rotation. + // Decompiled: chunk_00530000.c lines 1168-1174 + // iStack_60 = iVar9 * 0x6c1ac587 โ†’ cellYMat + // uStack_78 = iVar9 * iVar8 * 0x5111bfef + 0x70892fb7 โ†’ cellMat2 + // iStack_64 = iVar8 * -0x421be3bd โ†’ cellXMat + // initial: local_90 = uStack_78 * 0x5b67 (j=0 term) + // per-loop: iStack_70 = (iStack_60 - local_90) + iStack_64; local_90 += uStack_78 + // โŸน iStack_70 = cellYMat - cellMat2 * (0x5b67 + j) + cellXMat uint cellXMat = unchecked(0u - 1109124029u * globalCellX); uint cellYMat = 1813693831u * globalCellY; uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; @@ -135,13 +139,15 @@ public static class SceneryGenerator for (uint j = 0; j < scene.Objects.Count; j++) { var obj = scene.Objects[(int)j]; - if (obj.WeenieObj != 0) continue; + if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery + // Frequency roll: chunk_00530000.c line 1174 + 1179 + // (fVar1 * _DAT_007c6f10 < (float)piVar11[0x11]) โ†’ noise < obj.Frequency double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10; if (noise >= obj.Frequency) continue; - // โ”€โ”€โ”€ WB substitution: displacement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - var localPos = SceneryHelpers.Displace(obj, globalCellX, globalCellY, j); + // Displacement: pseudo-random offset within the cell. + var localPos = DisplaceObject(obj, globalCellX, globalCellY, j); float lx = cellX * CellSize + localPos.X; float ly = cellY * CellSize + localPos.Y; @@ -149,48 +155,277 @@ public static class SceneryGenerator 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) + // 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) { - 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; + 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; + // 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)) + { + 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; + } + + // BaseLoc.Z offset: scenery-specific vertical offset from + // the ground (e.g., flowers planted at -0.1m so they + // don't float above grass). The renderer adds groundZ + // later, so pass the BaseLoc.Z through as-is. float lz = obj.BaseLoc.Origin.Z; - // โ”€โ”€โ”€ 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); + // 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; - // โ”€โ”€โ”€ WB substitution: scale โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j); + if (obj.MaxRotation > 0f) + { + double rotNoise = unchecked((uint)(1813693831u * globalCellY + - (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u) + - 1109124029u * globalCellX)) * 2.3283064e-10; + float degrees = (float)(rotNoise * obj.MaxRotation); + // AFrame::set_heading transform โ€” matches retail. + float yawDeg = -((450f - degrees) % 360f); + float yawRad = yawDeg * MathF.PI / 180f; + var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad); + rotation = headingQuat * rotation; + } + + // Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern) + // offset constant 0x7f51 = 32593 (not in dumped chunks; cross-verified via ACViewer) + // same LCG structure as rotation/displacement; uint cast per decompiled normalisation + float scale; + if (obj.MinScale == obj.MaxScale) + { + scale = obj.MaxScale; + } + else + { + double scaleNoise = unchecked((uint)(1813693831u * globalCellY + - (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u) + - 1109124029u * globalCellX)) * 2.3283064e-10; + scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); + } if (scale <= 0) scale = 1f; result.Add(new ScenerySpawn( - ObjectId: obj.ObjectId, + 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 deleted file mode 100644 index 1a901493..00000000 --- a/src/AcDream.Core/World/WbSceneryAdapter.cs +++ /dev/null @@ -1,55 +0,0 @@ -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 17a756c9..c26b214b 100644 --- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -139,52 +139,6 @@ 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 83cd73f9..d003ea81 100644 --- a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs +++ b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs @@ -4,11 +4,10 @@ using DatReaderWriter.Types; namespace AcDream.Core.Tests.World; /// -/// 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. +/// 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. /// public class SceneryGeneratorTests { @@ -33,12 +32,15 @@ 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 deleted file mode 100644 index 79fd3588..00000000 --- a/tests/AcDream.Core.Tests/World/WbSceneryAdapterTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -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!)); - } -}