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