Compare commits

...

20 commits

Author SHA1 Message Date
Erik
1978ef9395 Merge branch 'claude/angry-villani-8ae757' — Phase N.1 WorldBuilder rendering migration 2026-05-08 10:50:35 +02:00
Erik
6010827b21 docs: roadmap N.0 shipped + realistic N.2-N.9 estimates + N.3 handoff
Roadmap updates after Phase N.1 ship:
- Marks N.0 (submodule + project refs setup) as ✓ SHIPPED with the
  c8782c9 commit reference
- Updates N.2-N.9 effort estimates with realistic post-N.1 numbers
  (originals were 1-2 days / 1 week / 2 weeks; realistic numbers
  factor in conformance-test discovery, ACME-vs-Chorizite delta
  hunts, and the visual-verification-then-revert cycle that ate
  most of N.1's calendar time)
- Adds a "Lessons from N.1" subsection so future N phases benefit
  from the rotation-bug-conformance-test pattern, the ACME divergence
  insight, and the "whackamole = stop" rule
- Updates total calendar estimate to 3-4 months / 10-12 engineering
  weeks for N.2-N.9 (was 2-3 months / 6-8 weeks)

New handoff doc at docs/research/2026-05-08-phase-n3-handoff.md
captures everything a fresh agent picking up N.3 (texture decoding)
needs: phase context, what to read first, suggested task decomposition,
watchouts (especially the ACME-divergence and conformance-test
lessons), and where to start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:49:16 +02:00
Erik
ad8b931be7 docs: mark Phase N.1 shipped + file road-edge tree as known issue
Adds Phase N.1 to "Phases already shipped" table at top of roadmap,
updates the Phase N section to mark N.1 with checkmark SHIPPED status,
and files the known road-edge-tree cosmetic difference at landblock
0xA9B1 in ISSUES.md as issue #50 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:38:01 +02:00
Erik
b0ec6deb50 phase(N.1): delete legacy scenery code path; WB is the only path
Phase N.1 step 8 (final code cleanup): now that ACDREAM_USE_WB_SCENERY
has been default-on (commit b84ecbd), remove the legacy in-line
algorithms so we don't accumulate dead-code drift.

Deleted:
- SceneryGenerator.UseWbScenery (feature flag)
- SceneryGenerator.IsOnRoad / DisplaceObject / RoadHalfWidth (legacy
  ports — Generate used to call them)
- The legacy in-line implementation in Generate()
- SceneryGeneratorTests.DisplaceObject_* (test the deleted method)
- SceneryWbConformanceTests.cs entirely (purpose served — proved
  equivalence pre-migration; would compare WB to WB after delete)

Renamed:
- GenerateViaWb -> GenerateInternal (it's the only path now)

Kept:
- Public IsRoadVertex predicate (small surface, useful)
- WbSceneryAdapter (consumed by GenerateInternal)
- All WbSceneryAdapterTests (still cover the adapter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:37:55 +02:00
Erik
b84ecbda51 phase(N.1): WB-backed scenery is now default-on
Phase N.1 step 7: flips ACDREAM_USE_WB_SCENERY to default-on after
visual verification at Holtburg confirmed Issue #49's previously
missing edge-vertex trees are still visible and rotation is correct.

A known cosmetic difference (the road-edge tree at landblock 0xA9B1)
remains. ACME WorldBuilder applies an additional per-vertex road
check that suppresses it; we tried adding it (commit e279c46) but
it over-suppressed in other landblocks (reverted in 677a726). Filed
as a follow-up issue in ISSUES.md (added in Task 8).

ACDREAM_USE_WB_SCENERY=0 still reverts to the legacy path. Task 8
will delete the legacy path entirely once a session passes without
visual regressions on default-on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:31:55 +02:00
Erik
677a726e61 Revert "phase(N.1): add ACME-conformant per-vertex road check"
This reverts commit e279c46aac.
2026-05-08 10:26:37 +02:00
Erik
e279c46aac phase(N.1): add ACME-conformant per-vertex road check
Phase N.1 hotfix: scenery near a road still rendered in acdream
even with WB-backed generation. Investigation (worktree session
2026-05-08) showed ACME WorldBuilder skips the entire vertex when
its road bit is set, before any per-object spawn rolls. ACME line:
  references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074
  if (entry.Road != 0) continue;

This check was previously REMOVED in commit 833d167 with a comment
claiming retail doesn't have it. The comment was wrong: ACME mirrors
retail and keeps the check, and the upstream Chorizite/WorldBuilder
we forked omits it (which is why our newly-WB-backed Generate path
still produced the bad tree). Adding back to both Generate (legacy)
and GenerateViaWb (WB-backed) for parity.

This does NOT regress #49: the 9x9 loop expansion that recovered
missing edge-vertex scenery is unchanged. Only vertices whose own
road bit is set are now skipped -- same as ACME.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:23:53 +02:00
Erik
ecf4fe9f10 phase(N.1): wire ACDREAM_USE_WB_SCENERY dispatch in Generate()
Phase N.1 step 5: when the flag is set, Generate() delegates to
GenerateViaWb. Default off; flag flips to default-on in step 7
after visual verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:58:20 +02:00
Erik
804bfbb819 phase(N.1): implement GenerateViaWb alternative path
Phase N.1 step 4: parallel implementation of Generate() that calls
WB's SceneryHelpers (Displace/CheckSlope/RotateObj/ObjAlign/ScaleObj)
and TerrainUtils (OnRoad/GetNormal) instead of the inline ports.

Not yet wired in — Generate() still runs the legacy path. Step 5
adds the dispatch.

Per-helper conformance tests in step 3 prove the Displace/OnRoad/
GetNormalZ/ScaleObj substitutions are behavior-equivalent. Rotation
is intentionally NOT conformance-tested because our existing port
diverges from retail by ~180°; WB's RotateObj fixes that bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:56:13 +02:00
Erik
4bfcb2b190 phase(N.1): per-helper conformance tests for WB substitutions (rotation excluded)
Phase N.1 step 3: prove our inline algorithms match WorldBuilder's
helpers for representative inputs including the 0xA9B1 edge-vertex case.

Four conformance tests pass: Displace, OnRoad, GetNormalZ, ScaleObj.
Our hand-ported algorithms match WB's helpers exactly for these.

Rotation is intentionally NOT conformance-tested. Investigation against
retail's Frame::set_heading (named-retail 0x00535e40) and
Frame::set_vector_heading (0x00535db0) showed our acdream port uses a
shortcut formula `yawDeg = -(450-degrees)%360` that diverges from
retail's atan2 round-trip by ~180°. WorldBuilder's SetHeading ports
the round-trip faithfully and matches retail. Our existing port is
wrong — undetectable visually because per-tree rotation noise masks
the offset. The migration to WB.SceneryHelpers.RotateObj fixes this
bug; adding a conformance test would lock in the wrong behavior.

Bumps IsOnRoad to internal so the OnRoad conformance test can call it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:53:00 +02:00
Erik
bbc618a40a phase(N.1): add ACDREAM_USE_WB_SCENERY feature flag scaffold
Phase N.1 step 2: read the env var into a static bool. No behavior
change yet — the flag is consumed in step 5 when GenerateViaWb is
wired in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:22:23 +02:00
Erik
91fd9de3f6 phase(N.1): document LandBlock length-81 invariant on adapter
Addresses code-review feedback on commit 26cf2b8. The dropped
ArgumentException length guards were correct to drop because
DatReaderWriter.LandBlock self-initializes Terrain[] and Height[]
to length 81 in its constructor — but that invariant was not
documented anywhere visible to future readers. Adds an XML doc
<remarks> block explaining the guarantee so callers constructing
synthetic LandBlocks know what to expect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:20:53 +02:00
Erik
26cf2b84e7 phase(N.1): add LandBlock → TerrainEntry[] adapter
Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our
LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's
TerrainUtils / SceneryRenderManager consume.

Field mapping (TerrainInfo → TerrainEntry):
  TerrainInfo.Road    (bits 0-1)   → TerrainEntry.Road
  TerrainInfo.Type    (bits 2-6)   → TerrainEntry.Type
  TerrainInfo.Scenery (bits 11-15) → TerrainEntry.Scenery
  LandBlock.Height[i]              → TerrainEntry.Height

The spec listed the texture property as 'Texture' but TerrainEntry's
actual property is named 'Type' (confirmed from source). The spec also
described LandBlock.Terrain as ushort[81] but it is TerrainInfo[81] —
DatReaderWriter already decodes the bit fields so the adapter uses
TerrainInfo's named properties rather than raw bit-shift expressions.

Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:11:59 +02:00
Erik
21425ffb22 plan(N.1): scenery via WorldBuilder helpers — implementation plan
Bite-sized TDD plan for Phase N.1. Eight tasks:
1. WbSceneryAdapter (LandBlock → TerrainEntry[] adapter)
2. ACDREAM_USE_WB_SCENERY feature flag scaffold
3. Per-helper conformance tests (Displace / OnRoad / GetNormalZ / RotateObj / ScaleObj)
4. Implement GenerateViaWb alternative path
5. Wire feature-flag dispatch in Generate()
6. Visual verification at landblock 0xA9B1 (manual)
7. Flip flag default-on
8. Delete legacy code paths + mark roadmap shipped

Each task has explicit code blocks, exact dotnet commands, expected
output, and a commit instruction. Conformance tests prove substitution
is behavior-preserving before the dispatch is wired in.

Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:05:53 +02:00
Erik
c8782c9365 phase(N.0): wire up WorldBuilder fork as submodule + project refs
Phase N.0 setup for the WorldBuilder migration. Replaces the local
read-only clone of Chorizite/WorldBuilder at references/WorldBuilder/
with a git submodule pointing at our fork
(github.com/eriknihlen/WorldBuilder.git, branch acdream).

Changes:
- .gitignore: exempt references/WorldBuilder from the references/
  ignore rule so the submodule can be tracked.
- .gitmodules (new): submodule entry tracking acdream branch on fork.
- src/AcDream.Core/AcDream.Core.csproj: add ProjectReference to
  WorldBuilder.Shared and Chorizite.OpenGLSDLBackend so we can call
  TerrainUtils, SceneryHelpers, etc. from our Core code.

Build green, all 93 scenery/terrain tests pass. The 8 pre-existing
DispatcherToMovement test failures are unrelated and exist on main.

Notes for users picking up this branch on main:
- After merge, the existing local clone at references/WorldBuilder
  may need to be removed before `git submodule update --init` will
  populate the submodule.
- Working on the fork happens via `cd references/WorldBuilder && git
  checkout acdream && <changes> && git push`. To pull upstream
  Chorizite/WorldBuilder fixes: `git remote add upstream
  https://github.com/Chorizite/WorldBuilder.git && git fetch upstream
  && git merge upstream/master`.

Next: Phase N.1 — replace SceneryGenerator algorithm calls with
WB's SceneryHelpers + TerrainUtils.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:51:49 +02:00
Erik
8a06fce7a5 spec(rendering): Phase N WorldBuilder migration design + N.1 scenery
Adds two design docs and a roadmap entry for the strategic shift
from "port retail rendering algorithms ourselves" to "depend on a
fork of Chorizite/WorldBuilder for rendering + dat-handling."

- docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md
  — parent design: integration model (fork + submodule), 10 sub-phases
  (N.0 setup through N.10 GL consolidation), strangler-fig phasing
  with per-phase feature flags, retail-decomp boundary clarified for
  what WB does NOT cover (network, physics, animation, motion, UI,
  plugin, audio, chat).

- docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md
  — N.1 detailed design: replace IsOnRoad / DisplaceObject /
  slope-normal calc / rotation / scale inside SceneryGenerator.Generate()
  with calls to WB's SceneryHelpers + TerrainUtils. Keep data flow,
  ScenerySpawn shape, and renderer integration. Add small LandBlock →
  TerrainEntry[] adapter. Feature flag ACDREAM_USE_WB_SCENERY=1.

- docs/plans/2026-04-11-roadmap.md — Phase N entry added between
  Phase M and Phase J. Lists all 10 sub-phases with effort estimates.

Fork already created at https://github.com/eriknihlen/WorldBuilder.
N.0 setup (replace references/WorldBuilder/ snapshot with submodule,
add project references, build green) is the next implementation step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:47:23 +02:00
Erik
9b210be126 docs(architecture): WorldBuilder inventory + CLAUDE.md alignment
Saves the comprehensive inventory of what WorldBuilder provides
(terrain, scenery, static objects, EnvCells, portals, sky, particles,
texture decode, mesh extraction, visibility) vs what acdream still
ports from retail decomp (network, physics, animation, movement, UI,
plugin, audio, chat).

This is the load-bearing reference for the strategic shift from
"port retail algorithms ourselves" to "rely on WorldBuilder for
rendering + dat-handling, port only what WB doesn't cover."

Updates CLAUDE.md:
- Adds top-level instruction: read the inventory FIRST before
  re-porting anything in the 🟢 list
- Reframes references/WorldBuilder/ as acdream's rendering BASE,
  not just a reference repo
- Updates the "Reference hierarchy by domain" table to point
  rendering/dat questions at WorldBuilder, with retail decomp as
  cross-check

Subsequent commits will fork WorldBuilder and replace our terrain/
scenery/object rendering with calls into the fork.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:31:03 +02:00
Erik
e3c36b5bf8 revert: remove obj_within_block — sorting sphere radii too large
The obj_within_block check using Setup.SortingSphere.Radius rejects
far too many spawns. Sorting spheres for trees are 5-10m, creating
a wide exclusion zone around every landblock edge. WorldBuilder
produces correct scenery with just bounds+road+building+slope checks
and no bounding sphere check. Revert to match WorldBuilder's approach.

The single extra tree near the road at vtx=(4,8) in 0xA9B1 remains
as a known minor discrepancy from retail — root cause TBD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 07:53:04 +02:00
Erik
e8aa1c82f4 fix(scenery): add retail obj_within_block check for edge-boundary spawns
Retail's CLandBlock::get_land_scenes creates a PhysicsObj for each
scenery spawn, then calls CPhysicsObj::obj_within_block (0x00461c30)
which verifies the model's sorting sphere stays within [r, 192-r] on
both axes. Edge-vertex spawns displaced close to the boundary (e.g.,
a tree at Y=190.97 from vertex y=8) get rejected because their sorting
sphere extends past the landblock edge.

We were missing this check, which caused a tree near a road at
~(85, 191) in landblock 0xA9B1 to appear in acdream but not retail.
The tree legitimately passed all other filters (road, building, slope)
but its Setup sorting sphere radius (~2-5m) meant it overflowed the
boundary.

Fix: look up each unique Setup's SortingSphere.Radius from the dat
(cached per objectId) and apply the within-block bounds check after
the slope filter, matching retail's order. GfxObj objects (0x01) use
radius 0 (permissive) since they're typically small single-mesh items.

Also: remove temporary ACDREAM_SCENERY_DIAG logging, fix duplicate
xmldoc on IsOnRoad, update road check reference to named-retail PDB
symbol (CLandBlock::on_road at 0x0052FFF0).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 07:44:17 +02:00
Erik
833d167ebc fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 21:15:11 +02:00
18 changed files with 2483 additions and 327 deletions

6
.gitignore vendored
View file

@ -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
View file

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

View file

@ -25,6 +25,19 @@ single source of truth for how the client is structured. All work must
align with this document. When the architecture doc and reality diverge,
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. |

View file

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

View file

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

View file

@ -57,6 +57,7 @@
| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
| 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.

View file

@ -0,0 +1,132 @@
# Phase N.3 handoff — texture decoding via WorldBuilder
**Use this whole document as the prompt** when handing off to a fresh
agent. Everything they need to pick up cold is below.
---
## Background you'll need
You're working in `acdream`, a from-scratch C# .NET 10 reimplementation
of Asheron's Call's retail client. The project's house rule (in
`CLAUDE.md`) is **the code is modern, the behavior is retail**.
acdream just shipped **Phase N.1** (commits `26cf2b8` through `ad8b931`),
the first sub-phase of a strategic migration to fork WorldBuilder
(`github.com/Chorizite/WorldBuilder`, MIT) and depend on its tested
rendering + dat-handling code instead of porting algorithms from retail
decomp ourselves.
**Read first:**
- `docs/architecture/worldbuilder-inventory.md` — the full taxonomy of
what WB has and what we keep porting ourselves
- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
— the parent design doc for Phase N
- `CLAUDE.md` — especially the "Reference repos" section (now points at
WB as the rendering BASE) and the workflow rules
**Phase N.1 commit history (just shipped):** read
`git log --oneline c8782c9..ad8b931` to see how N.0 + N.1 were
structured. The pattern repeats for N.3.
## What N.3 is
Replace acdream's texture decoding pipeline with WorldBuilder's
`Chorizite.OpenGLSDLBackend.Lib.TextureHelpers`. WB handles INDEX16,
P8, BGRA, DXT, and alpha-channel decoding. Our existing implementations
of these are scattered across `src/AcDream.App/Rendering/TextureCache.cs`
and possibly `src/AcDream.Core/Meshing/` — find them with
`grep -rln "INDEX16\|P8 decode\|DXT\|BGRA" src/`.
## Acceptance criteria
- Build green (`dotnet build`)
- All existing tests green (the 8 pre-existing `DispatcherToMovementIntegrationTests`
failures don't count — they exist on main)
- New conformance tests added per format that's substituted (one xUnit
Theory per: INDEX16, P8, BGRA, DXT). Each compares a fixed input byte
array decoded by our path vs WB's path; assertions on output pixel array.
- Visual verification at Holtburg (or wherever) shows no texture
regressions: terrain texturing, mesh texturing, particle textures all
look the same.
- ISSUES.md updated with any known cosmetic deltas (the N.1 pattern —
if WB and retail disagree on something subtle, file it, don't try
to fix it inline).
## Tasks (suggested decomposition)
Follow the N.1 plan structure (`docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md`)
as the template. Concretely:
1. **Audit our texture decode paths.** Grep, list every file/method that
decodes a texture. Map each to the WB equivalent in
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
(read it end to end first).
2. **Per-format conformance test.** TDD style: write the test, run it
to fail, then plumb the substitution. Conformance test fixture inputs
should include real-dat byte sequences (read a known-good texture from
a dat, encode the bytes as a hex blob in the test).
3. **Substitution.** Replace each decode site with the WB call. Keep our
GL upload pathways — those are NOT WB's responsibility.
4. **Visual verification.** Launch the client at Holtburg, walk around,
look at a tree (mesh texture), the ground (atlas texture), particles
(the recent C.1 rain/clouds/aurora work), and a building (composite
texture). Compare against retail or against a screenshot before the
change.
5. **Delete legacy decoders** once visual verification passes.
6. **Update roadmap + ISSUES** as the final commit.
## Watchouts (lessons from N.1)
- **ACME has a downstream fork with extra filters** (`references/WorldBuilder-ACME-Edition/`).
WB's `TextureHelpers` may have ACME-specific patches not yet in upstream.
Compare both before assuming WB's version is canonical. We forked
upstream WB; ACME is reference-only.
- **Conformance tests are non-negotiable.** Phase N.1's rotation bug was
caught by the conformance test. Don't skip them. If a test fails, it's
a real divergence — investigate before "fixing" the test.
- **Whackamole stops the migration.** If 3+ visual regressions appear on
default-on, stop, file as ISSUES, ship. The migration goal is "use WB's
tested code"; pixel-perfect equivalence with our broken hand-ports is
not the goal.
- **`Setup.SortingSphere``Setup.CylSphere`.** The N.1 attempt at
`obj_within_block` over-suppressed because we used the wrong radius
source (sorting sphere too large). For texture decoding this likely
doesn't matter, but the general lesson is: read WB's full source
carefully before adapting; don't assume parallel methods do parallel
things.
- **Per-vertex road check — STOP signal.** If you find yourself reading
ACME for "what's missing" and considering a per-vertex filter, STOP.
N.1 tried this (commit `e279c46`), regressed visually, reverted in
`677a726`. ACME's filter set works as a coherent unit; pick-and-choose
fails. If the N.3 work uncovers a similar ACME-only filter, file it
in ISSUES and move on, don't port it inline.
## Where to start
1. `git pull` on main to get the latest (Phase N.1 just merged).
2. Create a new worktree for the work:
`git worktree add .claude/worktrees/<your-name> -b claude/<your-name>`.
3. Read the three "read first" docs above.
4. Run `dotnet build && dotnet test` to confirm clean baseline.
5. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
end to end. Take notes on the public API surface.
6. Run the audit task (#1 in Tasks above). Output should be a markdown
table of "our function / file:line / WB equivalent / format covered."
7. Use `superpowers:writing-plans` to convert the audit into a concrete
per-format plan. Then use `superpowers:subagent-driven-development`
to execute it with fresh subagents per format.
## Useful greps
- `grep -rln "INDEX16\|IndexedSurface\|P8\|DXT\|BGRA\|TextureFormat" src/` — find decode paths
- `grep -rln "TextureCache" src/` — find our cache layer
- `grep -n "public static.*Decode\|public static.*Convert" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` — WB's public API
## Open question to resolve early
Does `Chorizite.OpenGLSDLBackend.Lib.TextureHelpers` cover ALL the
formats we use, or does it have gaps? Audit our texture types against
WB's API in step 1. If WB is missing a format we need, the migration for
that format gets deferred (file in ISSUES; keep our decoder for it; note
in the roadmap).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
# Phase N — WorldBuilder Rendering Migration: Design
**Date:** 2026-05-08
**Status:** Design complete, awaiting plan generation for N.1.
## Goal
Stop re-porting AC-specific rendering and dat-handling algorithms from
retail decomp. Instead, depend on a fork of WorldBuilder
(`github.com/Chorizite/WorldBuilder`, MIT) for terrain, scenery, static
objects, EnvCells, portals, sky, particles, texture decoding, mesh
extraction, and visibility / culling. Acdream keeps its own network,
physics, animation, motion, UI, plugin, audio, and chat layers — those
are not in WorldBuilder.
## Why
acdream has accumulated a recurring pattern of subtle porting bugs in
its own rendering algorithms (the latest: a tree near the road at
landblock `0xA9B1` that retail and WorldBuilder do not show but our
re-port did, despite the algorithm code looking byte-identical to
WorldBuilder's). The triangle-Z bug, the hover-over-terrain bug, and
the edge-vertex spawn bug are all in the same family: small porting
errors that survive surface-level review.
WorldBuilder is verified by visual inspection to render the AC world
correctly. It uses the same Silk.NET + .NET stack we already target.
It is MIT-licensed. It has fewer subtle bugs because its developers
have run it against the entire client_cell + client_portal dat content
and fixed everything users have reported.
The cost of "we re-port retail algorithms ourselves" is now higher than
the cost of "we depend on someone else's tested port and inherit their
fixes." Migrating the rendering+dat layer to WorldBuilder is the
right call.
## Inventory reference
The full taxonomy of "what WorldBuilder has, what we keep porting
ourselves" lives at
[`docs/architecture/worldbuilder-inventory.md`](../../architecture/worldbuilder-inventory.md).
Before re-implementing any rendering or dat-handling algorithm, **check
the inventory first**. CLAUDE.md is updated to enforce this.
## Architecture
### Integration model
**Fork upstream WorldBuilder, depend on the fork via git submodule.**
- Fork: `https://github.com/eriknihlen/WorldBuilder` (already created;
upstream: `Chorizite/WorldBuilder`).
- Long-lived branch in fork: `acdream`. Upstream `master` merges into
`acdream` periodically; our acdream-specific changes (delete editor
files, expose hooks for our scene state) live on `acdream`.
- The current read-only snapshot at `references/WorldBuilder/` is
**replaced** by a git submodule pointing at the fork's `acdream`
branch. Existing CLAUDE.md path references and research docs that
cite `references/WorldBuilder/...` keep working.
- Our solution adds two `<ProjectReference>`s:
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Chorizite.OpenGLSDLBackend.csproj`
- `references/WorldBuilder/WorldBuilder.Shared/WorldBuilder.Shared.csproj`
- Transitive NuGet dependencies (`Chorizite.Core`,
`Chorizite.DatReaderWriter.Extensions`, `BCnEncoder.Net`,
`SixLabors.ImageSharp`, `Silk.NET.SDL`, `MP3Sharp`) flow through.
- Editor-only files in WorldBuilder (Modules/Landscape/{Tools,
Commands, Services, Migrations, Hubs}, LandscapeDocument, etc.) stay
in the fork's source tree but are simply not referenced by acdream.
They impose no runtime cost. We can prune later if upstream stays
well-organized.
### Phasing — strangler fig, subsystem by subsystem
Each sub-phase is independently shippable behind a feature flag
(`ACDREAM_USE_WB_<NAME>=1`). After visual verification the flag becomes
default-on, then is removed and the old code is deleted. This gives us
a one-line revert if a phase regresses.
| # | Sub-phase | Effort | Risk |
|---|---|---|---|
| **N.0** | Submodule + project references + build green | 1-2 hrs | Low |
| **N.1** | Scenery algorithm calls | 1-2 days | Low |
| **N.2** | Terrain math helpers | 1-2 days | Low |
| **N.3** | Texture decoding | 2-3 days | Medium |
| **N.4** | Object meshing (Setup/GfxObj) | 1 week | Medium |
| **N.5** | Terrain rendering (full pipeline) | 2 weeks | High |
| **N.6** | Static objects rendering | 2 weeks | High |
| **N.7** | EnvCells / dungeons | 2 weeks | High |
| **N.8** | Sky + particles | 1 week | Medium |
| **N.9** | Visibility / culling | 3-5 days | Medium |
| **N.10** | GL infrastructure consolidation (optional) | 1 week | Medium |
Total estimated calendar: 2-3 months. Engineering effort: 6-8 weeks.
### What WorldBuilder does NOT cover (keep porting from retail decomp)
- Network protocol (UDP, ISAAC, ACE messages) — keep ours
- Physics: collision, BSP queries, sphere sweeps, walkable validation
— keep ours (partial), continue porting from retail decomp
- Animation: motion sequencer, cycle/non-cycle parts — keep ours
- Movement: WASD → MoveToState wire, remote-entity motion via
UpdateMotion + dead-reckoning — keep ours
- Game UI: chat, vitals, inventory, spell book — keep ours (ImGui
today, custom-toolkit later)
- Plugin API: IGameState, IEvents, IActions, IPacketPipeline,
IOverlay — keep ours (acdream-unique)
- Game events: combat, allegiance, spell casting — keep ours
- Audio (OpenAL pipeline) — keep ours
- TurbineChat + slash commands — keep ours
- Login + character selection flow — keep ours
Per CLAUDE.md update, these still follow the
"grep named → decompile → verify → port" workflow against retail decomp
at `docs/research/named-retail/`.
### Network reference posture
`references/Chorizite.ACProtocol/` (separate Chorizite repo) remains
the Primary Oracle for protocol field order and packed-dword
conventions per CLAUDE.md's reference table. No fork needed there. We
will lean on it harder during future network-conformance phases (Phase
M is already on the roadmap for that).
## Components
### N.0 — Setup (must land before N.1)
**Files / actions:**
- Remove `references/WorldBuilder/` from working tree (it's currently a
checked-in snapshot). Add it back as a submodule pointing at
`git@github.com:eriknihlen/WorldBuilder.git` tracking the `acdream`
branch (created off `master`).
- Add `<ProjectReference>` entries in
`src/AcDream.Core/AcDream.Core.csproj` and
`src/AcDream.App/AcDream.App.csproj` for the two WB projects.
- Update `.gitmodules` to reflect the new submodule.
- Verify `dotnet build` and `dotnet test` are green.
- Commit.
**Done criteria:**
- `git submodule status` shows `references/WorldBuilder` at the fork's
`acdream` HEAD.
- Solution builds clean with no new warnings.
- Existing 870+ tests still pass.
### N.1 — Scenery algorithm calls
See companion design doc:
[`2026-05-08-phase-n1-scenery-via-wb-helpers-design.md`](2026-05-08-phase-n1-scenery-via-wb-helpers-design.md).
Brief: replace the algorithm guts inside `SceneryGenerator.Generate()`
with calls to WB's `SceneryHelpers` (Displace, RotateObj, ScaleObj,
ObjAlign, CheckSlope) and `TerrainUtils` (OnRoad, GetNormal). Keep our
data flow, our `ScenerySpawn` shape, our renderer integration. Add a
small adapter `LandBlock → TerrainEntry[]`.
### N.2-N.10 — separately brainstormed when we get there
Each sub-phase will get its own brainstorm + spec when we reach it.
Estimating ahead is unreliable for the bigger phases (N.5, N.6, N.7);
we'll know more after N.1 ships and we have hands-on experience with
the WB integration.
## Risks
1. **Chorizite.Core dependency footprint.** Each render manager we
take pulls in `Chorizite.Core.Lib` and `Chorizite.Core.Render`.
Mitigation: take the NuGet dep, don't try to strip it. Risk is
mostly cosmetic (an extra package).
2. **WB's data-flow is editor-shaped.** `LandscapeDocument`,
`LandscapeChunk`, etc. are editor concepts. Mitigation: write small
adapters that produce the editor-shaped data from our dat reads.
Phase N.1 is intentionally chosen to avoid this — we use only the
stateless helpers, not the full `SceneryRenderManager`. Larger
phases (N.5+) will need real adapter layers.
3. **Upstream divergence.** WB's `master` will keep moving. Mitigation:
merge upstream `master` into our `acdream` branch periodically (at
minimum, before each new phase starts). Our acdream-specific
changes are isolated to deletions and additions on the `acdream`
branch, which merges cleanly with upstream most of the time.
4. **Behaviors WB doesn't have.** WB is a dat editor; some
in-game-only behaviors (creature appearance via CreaturePalette /
GfxObjRemapping / HiddenParts) aren't in WB and we'll still need to
handle them ourselves at the integration boundary. Mitigation:
ACME's `StaticObjectManager.cs` covers these and is documented in
CLAUDE.md as the secondary oracle for character appearance.
5. **Visual regression during migration.** Mitigation: feature flag
per phase. Visual verification at known-good locations (Holtburg,
Foundry statue, dungeon entrances) before flag becomes default-on.
## Testing
- **N.0:** existing 870+ tests stay green; `dotnet build` clean.
- **N.1:** new conformance test that runs both our `SceneryGenerator`
and a parallel call into WB's helpers against the same fixture data,
asserts identical spawn list. Visual verification at landblock
`0xA9B1` — the offending tree should be gone, Issue #49's missing
scenery should still be visible.
- **N.2-N.10:** each phase will define its own conformance and visual
verification criteria when brainstormed.
## Documentation impact
- [x] `docs/architecture/worldbuilder-inventory.md` — created.
- [x] `CLAUDE.md` — updated with new posture (top-level rule + reference
table + per-domain oracle hierarchy).
- [ ] `docs/plans/2026-04-11-roadmap.md` — add Phase N entry alongside
L, M, etc. (this happens in the same commit as the spec).
- [ ] `docs/architecture/acdream-architecture.md` — needs an
acknowledging note that the rendering layer is now WB-backed; can
follow in a later commit, not blocking.
## Out of scope for this design
- Phase N.2-N.10 detailed scope (each gets own brainstorm).
- Network conformance work (separate Phase M).
- Animation, physics, motion ports (continue against retail decomp,
not WB).
- UI, plugin, chat work (separate phases, not affected).

View file

@ -0,0 +1,191 @@
# Phase N.1 — Scenery via WorldBuilder Helpers: Design
**Date:** 2026-05-08
**Parent design:** [`2026-05-08-phase-n-worldbuilder-migration-design.md`](2026-05-08-phase-n-worldbuilder-migration-design.md)
**Status:** Design complete, awaiting plan generation.
## Goal
Replace the algorithm guts of `SceneryGenerator.Generate()` with calls
to WorldBuilder's stateless `SceneryHelpers` and `TerrainUtils`. Keep
our data flow, our `ScenerySpawn` shape, and our renderer integration
unchanged.
## Why scenery first
1. **Active bug source.** Issues #48, #49 are scenery-related; the
investigation in this session uncovered another (the road-edge tree
at `0xA9B1`) we couldn't easily root-cause despite our code looking
identical to WB's.
2. **Smallest coherent slice.** Scenery placement uses only stateless
helpers from WB (Displace, OnRoad, GetNormal, CheckSlope, RotateObj,
ScaleObj). No need to take WB's `SceneryRenderManager`, no need for
editor-shaped data flow.
3. **Proves the integration pattern.** Phase N.0 wires up the
submodule + project references. N.1 uses them with a tiny surface
area. If something is wrong with the dependency model, we discover
it cheaply.
## Architecture
### What changes
`src/AcDream.Core/World/SceneryGenerator.cs`:
- Remove our private `IsOnRoad(LandBlock, float, float)` helper.
- Remove our private `DisplaceObject(ObjectDesc, uint, uint, uint)` helper.
- Remove the `RoadHalfWidth` constant.
- Replace inline algorithm calls with WB equivalents (see table below).
New file `src/AcDream.Core/World/WbSceneryAdapter.cs` (or similar
location — TBD during implementation):
- Helper `BuildTerrainEntries(LandBlock block) → TerrainEntry[]`
converting our `DatReaderWriter.DBObjs.LandBlock` (the dat type) into
the `TerrainEntry[]` shape WB's `TerrainUtils` expects (9×9 grid,
Type/Scenery/Road/Height fields per vertex).
- Helper for `RegionInfo` if needed (small wrapper over our
`Region` dat).
### Algorithm-call substitution table
| Today (ours) | Phase N.1 (WB) |
|---|---|
| `IsRoadVertex(raw)` (kept; small util) | unchanged — small predicate, no benefit to swap |
| `IsOnRoad(block, lx, ly)` | `TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries)` |
| `DisplaceObject(obj, gx, gy, j)` | `SceneryHelpers.Displace(obj, gx, gy, j)` |
| Slope normal: `TerrainSurface.SampleNormalZFromHeightmap(...)` | `TerrainUtils.GetNormal(region, terrainEntries, lbX, lbY, lbOffset).Z` |
| Slope check: `nz < obj.MinSlope \|\| nz > obj.MaxSlope` | `SceneryHelpers.CheckSlope(obj, normal.Z)` (returns bool) |
| Rotation logic (`AFrame::set_heading` reproduction) | `SceneryHelpers.RotateObj(obj, gx, gy, j, localPos)` (returns Quaternion) |
| Scale logic (LCG + Pow + clamp) | `SceneryHelpers.ScaleObj(obj, gx, gy, j)` (returns float) |
### What does NOT change
- The 9×9 vertex loop (`for (x = 0; x < 9; x++) for (y = 0; y < 9; y++)`).
- Scene selection hash.
- Frequency roll.
- `obj.WeenieObj != 0` skip (weenie entries are dynamic spawns).
- Bounds check `lx, ly ∈ [0, 192)`.
- Per-spawn building check using our `buildingCells` HashSet.
- `BaseLoc.Z` offset application.
- `ScenerySpawn` record shape returned to the renderer.
- `Generate()` method signature — same parameters, same return type.
### What about `obj_within_block`?
We attempted this during the bug investigation but it's too aggressive
when applied with the model's actual sorting sphere radius (rejects
trees that should be there). WB also doesn't apply it. The retail
behavior we couldn't reproduce stays unreproduced for now — we accept
that as a known minor cosmetic discrepancy and move on. The point of
N.1 is matching WB's behavior, not retail's. If WB and retail
disagree, that's a WB-upstream problem to file separately.
## Components
### Files modified
- `src/AcDream.Core/World/SceneryGenerator.cs` — algorithm-call swap.
- `src/AcDream.Core/AcDream.Core.csproj` — already has WB project ref
from N.0.
### Files added
- `src/AcDream.Core/World/WbSceneryAdapter.cs` — `LandBlock →
TerrainEntry[]` and any other small adapters needed.
- `tests/AcDream.Core.Tests/World/SceneryGeneratorWbConformanceTests.cs`
— side-by-side test asserting our generator's output equals what
comes out when the same algorithms are called via WB directly.
### Files deleted (eventually, after flag is on by default)
- The deleted helpers in `SceneryGenerator.cs` mentioned above.
### Feature flag
Phase 1 of the rollout: `ACDREAM_USE_WB_SCENERY=1` (default off — old
path runs). When the env var is set, the new WB-backed path runs.
Phase 2 (after visual verification at Holtburg / `0xA9B1`): flag
default-on. Old path can still be reached via
`ACDREAM_USE_WB_SCENERY=0`.
Phase 3 (one or two sessions later, after no regressions): delete the
flag and the old code paths entirely.
## Done criteria
1. `dotnet build` green with no new warnings.
2. All existing tests pass (870+).
3. New conformance test passes: `SceneryGeneratorWbConformanceTests`
runs both code paths against fixture LandBlock data and asserts
identical spawn lists (same ObjectId, same LocalPosition within
1e-4, same Rotation within 1e-4, same Scale within 1e-4).
4. Visual verification at landblock `0xA9B1` (Holtburg area):
- The offending tree near the road that retail/WB do not show is
**gone** in our render.
- Issue #49's previously missing scenery (the tree from the 9×9
loop expansion) is **still visible**.
- No new visual regressions in surrounding landblocks during a
brief flight around Holtburg.
5. Issue #49 stays closed; no new issues filed.
## Risks (Phase-N.1-specific)
1. **`TerrainEntry` field semantics.** WB packs Type/Scenery/Road/
Height into the `TerrainEntry` struct in a specific format. Getting
the adapter wrong means OnRoad / scenery selection produces
different results than ours. Mitigation: read
`WorldBuilder.Shared/Modules/Landscape/Models/TerrainEntry.cs`
carefully; cross-check against WB's `TerrainUtils.GetRoad` /
`GetTerrainEntryForCell` to confirm field encoding.
2. **`RegionInfo` dependencies.** WB's `TerrainUtils.GetNormal` takes
a `RegionInfo` parameter. We need to either build a minimal
`RegionInfo` from our `Region` dat or call WB's normal calc
differently. Mitigation: investigate during implementation; expect
this is a small wrapper.
3. **`obj.MaxScale / obj.MinScale` divide-by-zero.** Our code checks
`if (obj.MinScale == obj.MaxScale)` first; WB's `ScaleObj` does the
same per-line review of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryHelpers.cs:42-51`. Should be a non-issue.
4. **Rotation quaternion convention.** Our rotation produces
`headingQuat * baseLoc.Orientation`. WB's `RotateObj` calls
`SetHeading` which does its own composition. Need to confirm the
resulting quaternion is the same convention our renderer expects.
Mitigation: the conformance test catches this if it's wrong.
## Testing
### Conformance test (new)
`SceneryGeneratorWbConformanceTests`:
- Construct a synthetic `LandBlock` with known terrain data.
- Run `SceneryGenerator.Generate(...)` with `ACDREAM_USE_WB_SCENERY=0`
and again with `=1`.
- Assert spawn counts equal.
- Assert each spawn's ObjectId, LocalPosition (within 1e-4), Rotation
(within 1e-4 per component), Scale (within 1e-4) are equal.
### Existing tests
`SceneryGeneratorTests` covers: road-vertex predicate, edge-vertex
displacement bounds, interior-vertex displacement bounds. These tests
exercise our internal helpers (`IsRoadVertex`, `DisplaceObject`).
After N.1, the `DisplaceObject` test must be either deleted (if we
delete the helper) or replaced (if we keep `IsRoadVertex` as a small
predicate — it's only one bit-test).
### Visual verification
User runs the client against ACE locally:
- Navigate to landblock `0xA9B1` (Holtburg). Verify offending tree
near road is gone.
- Confirm Issue #49's tree is still visible.
- Fly around Holtburg, scan visible scenery for any obvious
regression.
## Out of scope for N.1
- Replacing our `SceneryRenderManager` (we don't have one — we have
`SceneryGenerator` producing `ScenerySpawn[]` and the renderer
consuming it directly). N.1 only touches the generator.
- Replacing our terrain math helpers (that's N.2).
- Replacing the static-object renderer (that's N.6).
- Anything in N.2-N.10.

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

View file

@ -18,5 +18,13 @@
</ItemGroup>
<ItemGroup>
<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>

View file

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

View file

@ -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 &lt; 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 &lt; 0) val += 2^32"; our
/// unchecked((uint)(...)) is exactly equivalent.
/// </summary>
private static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq)
{
float x, y;
var baseLoc = obj.BaseLoc.Origin;
// X displacement: chunk_005A0000.c lines 4858-4866
// iVar4 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xb2cd)) + param_2 * -0x421be3bd
if (obj.DisplaceX <= 0)
x = baseLoc.X;
else
x = (float)(unchecked((uint)(1813693831u * iy - (iq + 45773u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix))
* 2.3283064e-10 * obj.DisplaceX + baseLoc.X);
// Y displacement: chunk_005A0000.c lines 4871-4878 (same structure, offset 0x11c0f = 72719)
if (obj.DisplaceY <= 0)
y = baseLoc.Y;
else
y = (float)(unchecked((uint)(1813693831u * iy - (iq + 72719u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix))
* 2.3283064e-10 * obj.DisplaceY + baseLoc.Y);
float z = baseLoc.Z;
// Quadrant selection: chunk_005A0000.c lines 4880-4902
// iVar4 = (param_3 * 0x6c1ac587 - (param_3 * 0x6f7bd965 + 0x421be3bd) * param_2) + -0x17fcedfd
// 0x6f7bd965=1870387557, 0x421be3bd=1109124029, -0x17fcedfd → -402451965 (uint: 3892515331)
double quadrant = unchecked((uint)(1813693831u * iy - ix * (1870387557u * iy + 1109124029u) - 402451965u)) * 2.3283064e-10;
if (quadrant >= 0.75) return new Vector3(y, -x, z);
if (quadrant >= 0.5) return new Vector3(-x, -y, z);
if (quadrant >= 0.25) return new Vector3(-y, x, z);
return new Vector3(x, y, z);
}
}

View file

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

View file

@ -139,6 +139,52 @@ public class TerrainSurfaceTests
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
}
[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()
{

View file

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

View file

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