diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 464590f3..87c7b2da 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,50 +46,6 @@ Copy this block when adding a new issue: # Active issues -## #50 — Road-edge tree at 0xA9B1 visible in acdream but not retail - -**Status:** OPEN -**Severity:** LOW (cosmetic; one spawned tree near the road in Holtburg) -**Filed:** 2026-05-08 -**Component:** scenery placement / Phase N (WorldBuilder rendering migration) - -**Description:** With `ACDREAM_USE_WB_SCENERY=1` (default since commit `b84ecbd`), -a tree at landblock 0xA9B1 around `(lx=85.08, ly=190.97)` appears in acdream but -neither retail nor ACME WorldBuilder render it. Upstream Chorizite/WorldBuilder -DOES render it, so our migration to WB's helpers (Phase N.1) inherited this -discrepancy from upstream. - -**Root cause (suspected):** ACME WorldBuilder includes a per-vertex road check that -skips the entire vertex when its road bit is set (see -`references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074`). -The current vertex (4,8) has a road bit set in the dat. ACME skips it; -Chorizite/WorldBuilder doesn't; we don't. - -**Fix attempt that didn't work:** commit `e279c46` added the per-vertex road check -directly to our `GenerateViaWb` (and legacy `Generate` for parity). It successfully -removed the offending tree but over-suppressed scenery in other landblocks (visual -regressions during user testing). Reverted in commit `677a726`. ACME's check likely -interacts with other factors (per-vertex building check, or something else in ACME's -pipeline) that we'd need to port together, not the road check alone. - -**Next steps:** -1. Investigate ACME's full per-vertex filter set (road + building + anything else) - and port them as a coherent unit, not piecemeal. -2. OR upstream the per-vertex road check to Chorizite/WorldBuilder (which is now our - submodule fork) so it lands as a generic ACME-conformance improvement. -3. OR consider switching fork target from Chorizite/WorldBuilder to ACME WorldBuilder - for future phases (N.2+). - -Visually undetectable to most users; one extra tree at one landblock. Defer until -other Phase N work catches a similar issue and a coherent fix becomes obvious. - -**Files:** -- `src/AcDream.Core/World/SceneryGenerator.cs` — `GenerateInternal` is the active path -- `src/AcDream.Core/World/WbSceneryAdapter.cs` — adapter used by `GenerateInternal` -- `references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074` — ACME's per-vertex road filter - ---- - ## #49 — Scenery (X, Y) placement drifts from retail at some landblocks **Status:** OPEN diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index ee78dc50..9e66f68c 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -57,7 +57,6 @@ | K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ | | L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ | | C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ | -| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -530,95 +529,42 @@ 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.0 — Setup.** Submodule + project references + build green. ~1-2 hrs. +- **N.1 — Scenery algorithm calls.** Replace `IsOnRoad` / + `DisplaceObject` / slope-normal calc / rotation / scale inside + `SceneryGenerator.Generate()` with calls to WB's `SceneryHelpers` + + `TerrainUtils`. Tiny adapter `LandBlock → TerrainEntry[]`. Keeps our + data flow + `ScenerySpawn` shape. Feature flag + `ACDREAM_USE_WB_SCENERY=1`. ~1-2 days. - **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`. + / `GetNormal` internally. ~1-2 days. +- **N.3 — Texture decoding.** Replace `TextureCache` decode pipeline + with WB's `TextureHelpers`. ~2-3 days. - **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. + ours — ACME is the secondary oracle. ~1 week. - **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. + `LandSurfaceManager` + `TerrainGeometryGenerator`. ~2 weeks. - **N.6 — Static objects rendering.** Replace `StaticMeshRenderer` + `InstancedMeshRenderer` with WB's `StaticObjectRenderManager`. - **Realistic estimate: 2-3 weeks** (was 2) — interacts with N.4 - output. + ~2 weeks. - **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's - `EnvCellRenderManager` + `PortalRenderManager`. **Realistic - estimate: 2-3 weeks** (was 2). + `EnvCellRenderManager` + `PortalRenderManager`. ~2 weeks. - **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. + `ParticleEmitterRenderer`. ~1 week. - **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. + `FrustumCuller` with WB's `VisibilityManager`. ~3-5 days. - **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.) +**Estimated calendar:** 2-3 months. Engineering effort: 6-8 weeks. **Each sub-phase:** - Ships behind `ACDREAM_USE_WB_=1` flag. diff --git a/docs/research/2026-05-08-phase-n3-handoff.md b/docs/research/2026-05-08-phase-n3-handoff.md deleted file mode 100644 index 7b7e7fa6..00000000 --- a/docs/research/2026-05-08-phase-n3-handoff.md +++ /dev/null @@ -1,132 +0,0 @@ -# Phase N.3 handoff — texture decoding via WorldBuilder - -**Use this whole document as the prompt** when handing off to a fresh -agent. Everything they need to pick up cold is below. - ---- - -## Background you'll need - -You're working in `acdream`, a from-scratch C# .NET 10 reimplementation -of Asheron's Call's retail client. The project's house rule (in -`CLAUDE.md`) is **the code is modern, the behavior is retail**. - -acdream just shipped **Phase N.1** (commits `26cf2b8` through `ad8b931`), -the first sub-phase of a strategic migration to fork WorldBuilder -(`github.com/Chorizite/WorldBuilder`, MIT) and depend on its tested -rendering + dat-handling code instead of porting algorithms from retail -decomp ourselves. - -**Read first:** -- `docs/architecture/worldbuilder-inventory.md` — the full taxonomy of - what WB has and what we keep porting ourselves -- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md` - — the parent design doc for Phase N -- `CLAUDE.md` — especially the "Reference repos" section (now points at - WB as the rendering BASE) and the workflow rules - -**Phase N.1 commit history (just shipped):** read -`git log --oneline c8782c9..ad8b931` to see how N.0 + N.1 were -structured. The pattern repeats for N.3. - -## What N.3 is - -Replace acdream's texture decoding pipeline with WorldBuilder's -`Chorizite.OpenGLSDLBackend.Lib.TextureHelpers`. WB handles INDEX16, -P8, BGRA, DXT, and alpha-channel decoding. Our existing implementations -of these are scattered across `src/AcDream.App/Rendering/TextureCache.cs` -and possibly `src/AcDream.Core/Meshing/` — find them with -`grep -rln "INDEX16\|P8 decode\|DXT\|BGRA" src/`. - -## Acceptance criteria - -- Build green (`dotnet build`) -- All existing tests green (the 8 pre-existing `DispatcherToMovementIntegrationTests` - failures don't count — they exist on main) -- New conformance tests added per format that's substituted (one xUnit - Theory per: INDEX16, P8, BGRA, DXT). Each compares a fixed input byte - array decoded by our path vs WB's path; assertions on output pixel array. -- Visual verification at Holtburg (or wherever) shows no texture - regressions: terrain texturing, mesh texturing, particle textures all - look the same. -- ISSUES.md updated with any known cosmetic deltas (the N.1 pattern — - if WB and retail disagree on something subtle, file it, don't try - to fix it inline). - -## Tasks (suggested decomposition) - -Follow the N.1 plan structure (`docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md`) -as the template. Concretely: - -1. **Audit our texture decode paths.** Grep, list every file/method that - decodes a texture. Map each to the WB equivalent in - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` - (read it end to end first). -2. **Per-format conformance test.** TDD style: write the test, run it - to fail, then plumb the substitution. Conformance test fixture inputs - should include real-dat byte sequences (read a known-good texture from - a dat, encode the bytes as a hex blob in the test). -3. **Substitution.** Replace each decode site with the WB call. Keep our - GL upload pathways — those are NOT WB's responsibility. -4. **Visual verification.** Launch the client at Holtburg, walk around, - look at a tree (mesh texture), the ground (atlas texture), particles - (the recent C.1 rain/clouds/aurora work), and a building (composite - texture). Compare against retail or against a screenshot before the - change. -5. **Delete legacy decoders** once visual verification passes. -6. **Update roadmap + ISSUES** as the final commit. - -## Watchouts (lessons from N.1) - -- **ACME has a downstream fork with extra filters** (`references/WorldBuilder-ACME-Edition/`). - WB's `TextureHelpers` may have ACME-specific patches not yet in upstream. - Compare both before assuming WB's version is canonical. We forked - upstream WB; ACME is reference-only. -- **Conformance tests are non-negotiable.** Phase N.1's rotation bug was - caught by the conformance test. Don't skip them. If a test fails, it's - a real divergence — investigate before "fixing" the test. -- **Whackamole stops the migration.** If 3+ visual regressions appear on - default-on, stop, file as ISSUES, ship. The migration goal is "use WB's - tested code"; pixel-perfect equivalence with our broken hand-ports is - not the goal. -- **`Setup.SortingSphere` ≠ `Setup.CylSphere`.** The N.1 attempt at - `obj_within_block` over-suppressed because we used the wrong radius - source (sorting sphere too large). For texture decoding this likely - doesn't matter, but the general lesson is: read WB's full source - carefully before adapting; don't assume parallel methods do parallel - things. -- **Per-vertex road check — STOP signal.** If you find yourself reading - ACME for "what's missing" and considering a per-vertex filter, STOP. - N.1 tried this (commit `e279c46`), regressed visually, reverted in - `677a726`. ACME's filter set works as a coherent unit; pick-and-choose - fails. If the N.3 work uncovers a similar ACME-only filter, file it - in ISSUES and move on, don't port it inline. - -## Where to start - -1. `git pull` on main to get the latest (Phase N.1 just merged). -2. Create a new worktree for the work: - `git worktree add .claude/worktrees/ -b claude/`. -3. Read the three "read first" docs above. -4. Run `dotnet build && dotnet test` to confirm clean baseline. -5. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` - end to end. Take notes on the public API surface. -6. Run the audit task (#1 in Tasks above). Output should be a markdown - table of "our function / file:line / WB equivalent / format covered." -7. Use `superpowers:writing-plans` to convert the audit into a concrete - per-format plan. Then use `superpowers:subagent-driven-development` - to execute it with fresh subagents per format. - -## Useful greps - -- `grep -rln "INDEX16\|IndexedSurface\|P8\|DXT\|BGRA\|TextureFormat" src/` — find decode paths -- `grep -rln "TextureCache" src/` — find our cache layer -- `grep -n "public static.*Decode\|public static.*Convert" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` — WB's public API - -## Open question to resolve early - -Does `Chorizite.OpenGLSDLBackend.Lib.TextureHelpers` cover ALL the -formats we use, or does it have gaps? Audit our texture types against -WB's API in step 1. If WB is missing a format we need, the migration for -that format gets deferred (file in ISSUES; keep our decoder for it; note -in the roadmap). diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index b306e41f..a5dc0ce8 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -1,9 +1,7 @@ using System.Numerics; -using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; -using WorldBuilder.Shared.Modules.Landscape.Lib; namespace AcDream.Core.World; @@ -25,11 +23,11 @@ namespace AcDream.Core.World; /// (scale hash constant 0x7f51=32593 not in dumped chunks; /// confirmed against ACViewer which matches all other constants) /// -/// Phase N.1 (2026-05-08): migrated all algorithm calls to WorldBuilder's -/// SceneryHelpers + TerrainUtils. The legacy in-line implementations -/// have been removed; WbSceneryAdapter bridges LandBlock data to WB's -/// TerrainEntry[]. See -/// docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. +/// Key implementation note: the decompiled client computes each LCG value as a +/// signed 32-bit int, then normalises with "if (val < 0) val += 2^32" before +/// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast. +/// ACViewer's reference omits this cast and is subtly wrong for negative inputs. +/// We deliberately match the decompiled client, not ACViewer. /// public static class SceneryGenerator { @@ -37,7 +35,6 @@ public static class SceneryGenerator private const int VerticesPerSide = 9; private const float CellSize = 24.0f; private const float LandblockSize = 192.0f; // 8 cells * 24 units - private const int CellsPerSide = 8; public readonly record struct ScenerySpawn( uint ObjectId, // GfxObj or Setup id @@ -46,9 +43,12 @@ public static class SceneryGenerator float Scale); /// - /// Generate all scenery entries for one landblock. Phase N.1 migrated this - /// to call WorldBuilder's SceneryHelpers + TerrainUtils; - /// see docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md. + /// Generate all scenery entries for one landblock. Uses the bit-packed + /// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into + /// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] → a SceneInfo + /// index into Region.SceneInfo.SceneTypes[sceneInfo].Scenes. Each cell picks + /// one scene via a pseudo-random hash of the cell's global coordinates, then + /// iterates the scene's ObjectDesc entries with per-object frequency rolls. /// public static IReadOnlyList Generate( DatCollection dats, @@ -57,40 +57,21 @@ public static class SceneryGenerator uint landblockId, HashSet? buildingCells = null, float[]? heightTable = null) - { - // heightTable kept for backward compat; WB path uses - // region.LandDefs.LandHeightTable internally via TerrainUtils.GetNormal. - _ = heightTable; - return GenerateInternal(dats, region, block, landblockId, buildingCells); - } - - /// - /// Returns true if the raw terrain word indicates a road vertex. - /// Bits 0-1 of the terrain word encode the road type; any non-zero value - /// means the vertex is on a road. Ported from ACViewer GetRoad(). - /// - public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; - - private static IReadOnlyList GenerateInternal( - DatCollection dats, - Region region, - LandBlock block, - uint landblockId, - HashSet? buildingCells) { var result = new List(); if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) return result; - // Build the TerrainEntry[] WB's helpers consume — once per landblock. - var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block); - - uint blockX = (landblockId >> 24) * 8; + uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock uint blockY = ((landblockId >> 16) & 0xFFu) * 8; - uint lbX = landblockId >> 24; - uint lbY = (landblockId >> 16) & 0xFFu; + // RETAIL iterates 9×9 = 81 VERTICES, not 8×8 = 64 cells. + // Named retail: CLandBlock::get_land_scenes (0x00530460) uses + // `side_vertex_count` (offset 0x40, value 9) as the loop bound. + // The do-while condition `(var+1) < side_vertex_count` runs var 0..8. + // Edge vertices (x=8 or y=8) produce valid spawns when the per-object + // displacement shifts the position back into the [0, 192) range. for (int x = 0; x < VerticesPerSide; x++) { for (int y = 0; y < VerticesPerSide; y++) @@ -98,8 +79,13 @@ public static class SceneryGenerator int i = x * VerticesPerSide + y; ushort raw = block.Terrain[i]; - uint terrainType = (uint)((raw >> 2) & 0x1F); - uint sceneType = (uint)((raw >> 11) & 0x1F); + uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 + uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 + + // NOTE: retail does NOT skip based on this vertex's road bit. + // The road test happens AFTER displacement via the 4-corner + // polygonal OnRoad check (see below). Removing the + // pre-displacement early-exit restores retail behavior. if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; @@ -116,7 +102,10 @@ public static class SceneryGenerator uint globalCellX = cellX + blockX; uint globalCellY = cellY + blockY; - // Scene-selection hash: identical to Generate. + // Scene-selection hash: picks one scene from the terrain's scene list. + // Decompiled: chunk_00530000.c line 1144 + // iVar5 = (iVar8 * 0x2a7f2b89 + 0x6c1ac587) * iVar9 + iVar8 * -0x421be3bd + 0x7f8cda01 + // where iVar8=globalCellX, iVar9=globalCellY. uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u) - 1109124029u * globalCellX + 2139937281u; double offset = cellMat * 2.3283064e-10; @@ -127,7 +116,14 @@ public static class SceneryGenerator var scene = dats.Get(sceneId); if (scene is null) continue; - // Per-object frequency setup: identical to Generate. + // Per-object hashes: roll frequency, compute displacement, scale, rotation. + // Decompiled: chunk_00530000.c lines 1168-1174 + // iStack_60 = iVar9 * 0x6c1ac587 → cellYMat + // uStack_78 = iVar9 * iVar8 * 0x5111bfef + 0x70892fb7 → cellMat2 + // iStack_64 = iVar8 * -0x421be3bd → cellXMat + // initial: local_90 = uStack_78 * 0x5b67 (j=0 term) + // per-loop: iStack_70 = (iStack_60 - local_90) + iStack_64; local_90 += uStack_78 + // ⟹ iStack_70 = cellYMat - cellMat2 * (0x5b67 + j) + cellXMat uint cellXMat = unchecked(0u - 1109124029u * globalCellX); uint cellYMat = 1813693831u * globalCellY; uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; @@ -135,13 +131,15 @@ public static class SceneryGenerator for (uint j = 0; j < scene.Objects.Count; j++) { var obj = scene.Objects[(int)j]; - if (obj.WeenieObj != 0) continue; + if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery + // Frequency roll: chunk_00530000.c line 1174 + 1179 + // (fVar1 * _DAT_007c6f10 < (float)piVar11[0x11]) → noise < obj.Frequency double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10; if (noise >= obj.Frequency) continue; - // ─── WB substitution: displacement ─────────────────── - var localPos = SceneryHelpers.Displace(obj, globalCellX, globalCellY, j); + // Displacement: pseudo-random offset within the cell. + var localPos = DisplaceObject(obj, globalCellX, globalCellY, j); float lx = cellX * CellSize + localPos.X; float ly = cellY * CellSize + localPos.Y; @@ -149,11 +147,20 @@ public static class SceneryGenerator if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) continue; - // ─── WB substitution: road check ────────────────────── - if (TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries)) + // 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) + { continue; + } - // Building check: identical to Generate. + // Per-spawn building check on the DISPLACED position's cell. + // Retail: CSortCell::has_building(cell) per spawn, not per vertex. + // WorldBuilder: buildingsGrid[gx2, gy2] with 8×8 cell grid. if (buildingCells is not null) { int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1); @@ -162,35 +169,238 @@ public static class SceneryGenerator continue; } - // ─── WB substitution: slope check ───────────────────── - Vector3 normal = TerrainUtils.GetNormal( - region, terrainEntries, lbX, lbY, - new Vector3(lx, ly, 0)); - if (!SceneryHelpers.CheckSlope(obj, normal.Z)) - continue; + // Slope filter: retail uses CLandCell::find_terrain_poly → + // polygon->plane.N.z to get the triangle-specific normal. + // SampleNormalZFromHeightmap picks the correct triangle via + // the cell's split direction, matching retail + WorldBuilder. + if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f)) + { + float nz = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap( + block.Height, heightTable, + landblockId >> 24, (landblockId >> 16) & 0xFFu, + lx, ly); + if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; + } + // BaseLoc.Z offset: scenery-specific vertical offset from + // the ground (e.g., flowers planted at -0.1m so they + // don't float above grass). The renderer adds groundZ + // later, so pass the BaseLoc.Z through as-is. float lz = obj.BaseLoc.Origin.Z; - // ─── WB substitution: rotation ──────────────────────── - Quaternion rotation; - if (obj.Align != 0) - rotation = SceneryHelpers.ObjAlign(obj, normal, lz, localPos); - else - rotation = SceneryHelpers.RotateObj(obj, globalCellX, globalCellY, j, localPos); + // Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60) + // Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation + // into the frame, THEN calls AFrame::set_heading(degrees). + // + // set_heading uses yaw = -(450 - heading) % 360 before converting + // to a quaternion, which introduces a 90° offset + sign flip + // relative to a naive Z rotation. WorldBuilder's + // SceneryHelpers.SetHeading reproduces this. + // + // For objects with Align != 0, retail uses FUN_005a6f60 to + // align to the landcell polygon's normal instead of setting + // heading from the noise. + // + // Composition: final = baseLoc.Orientation * headingQuat + Quaternion rotation = obj.BaseLoc.Orientation; + if (rotation.LengthSquared() < 0.0001f) + rotation = Quaternion.Identity; - // ─── WB substitution: scale ─────────────────────────── - float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j); + if (obj.MaxRotation > 0f) + { + double rotNoise = unchecked((uint)(1813693831u * globalCellY + - (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u) + - 1109124029u * globalCellX)) * 2.3283064e-10; + float degrees = (float)(rotNoise * obj.MaxRotation); + // AFrame::set_heading transform — matches retail. + float yawDeg = -((450f - degrees) % 360f); + float yawRad = yawDeg * MathF.PI / 180f; + var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad); + rotation = headingQuat * rotation; + } + + // Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern) + // offset constant 0x7f51 = 32593 (not in dumped chunks; cross-verified via ACViewer) + // same LCG structure as rotation/displacement; uint cast per decompiled normalisation + float scale; + if (obj.MinScale == obj.MaxScale) + { + scale = obj.MaxScale; + } + else + { + double scaleNoise = unchecked((uint)(1813693831u * globalCellY + - (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u) + - 1109124029u * globalCellX)) * 2.3283064e-10; + scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); + } if (scale <= 0) scale = 1f; result.Add(new ScenerySpawn( - ObjectId: obj.ObjectId, + ObjectId: obj.ObjectId, LocalPosition: new Vector3(lx, ly, lz), - Rotation: rotation, - Scale: scale)); + Rotation: rotation, + Scale: scale)); } } } return result; } + + /// + /// Returns true if the raw terrain word indicates a road vertex. + /// Bits 0-1 of the terrain word encode the road type; any non-zero value + /// means the vertex is on a road. Ported from ACViewer GetRoad(). + /// + public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; + + /// + /// Half-width of a road ribbon in world units — the road extends from each + /// road vertex by this amount into the neighbor cells. Matches retail's + /// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30. + /// + private const float RoadHalfWidth = 5.0f; + + /// + /// Retail-faithful road ribbon test — direct port of ACViewer's + /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which + /// itself is a port of CLandBlock::on_road (named-retail 0x0052FFF0). + /// + /// Classifies the 4 corners of the cell containing (lx, ly) by road type + /// (bits 0-1 of the terrain word) and applies a different geometric test + /// based on which corners are road vertices. Road ribbons have a 5m + /// half-width (TileLength - RoadWidth = 19m). + /// + private static bool IsOnRoad(LandBlock block, float lx, float ly) + { + int x = (int)MathF.Floor(lx / CellSize); + int y = (int)MathF.Floor(ly / CellSize); + // Clamp so we don't index past the 9x9 terrain grid + x = Math.Clamp(x, 0, CellsPerSide - 1); + y = Math.Clamp(y, 0, CellsPerSide - 1); + + float rMin = RoadHalfWidth; // 5 + float rMax = CellSize - RoadHalfWidth; // 19 + + // Corner road bits (ACViewer convention): + // r0 = (x0, y0) = SW + // r1 = (x0, y1) = NW + // r2 = (x1, y0) = SE + // r3 = (x1, y1) = NE + bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]); + bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]); + bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]); + bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]); + + if (!r0 && !r1 && !r2 && !r3) return false; + + float dx = lx - x * CellSize; + float dy = ly - y * CellSize; + + if (r0) + { + if (r1) + { + if (r2) + { + if (r3) return true; + return dx < rMin || dy < rMin; + } + else + { + if (r3) return dx < rMin || dy > rMax; + return dx < rMin; + } + } + else + { + if (r2) + { + if (r3) return dx > rMax || dy < rMin; + return dy < rMin; + } + else + { + if (r3) return MathF.Abs(dx - dy) < rMin; + return dx + dy < rMin; + } + } + } + else + { + if (r1) + { + if (r2) + { + if (r3) return dx > rMax || dy > rMax; + return MathF.Abs(dx + dy - CellSize) < rMin; + } + else + { + if (r3) return dy > rMax; + return CellSize + dx - dy < rMin; + } + } + else + { + if (r2) + { + if (r3) return dx > rMax; + return CellSize - dx + dy < rMin; + } + else + { + if (r3) return CellSize * 2f - dx - dy < rMin; + return false; + } + } + } + } + + private const int CellsPerSide = 8; + + /// + /// Pseudo-random displacement within a cell for a scenery object. Returns a + /// Vector3 in local cell-offset space (the caller adds it to the cell corner + /// to get landblock-local position). + /// + /// Verified against decompiled acclient.exe: chunk_005A0000.c lines 4844-4903 (FUN_005a6cc0). + /// X offset constant 0xb2cd = 45773; Y offset constant 0x11c0f = 72719. + /// Quadrant hash: line 4880; thresholds 0.25/0.5/0.75 map to _DAT_007c97cc/_DAT_007938b8/_DAT_0079c6dc. + /// Decompiled normalises signed-int LCG results with "if (val < 0) val += 2^32"; our + /// unchecked((uint)(...)) is exactly equivalent. + /// + internal static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq) + { + float x, y; + var baseLoc = obj.BaseLoc.Origin; + + // X displacement: chunk_005A0000.c lines 4858-4866 + // iVar4 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xb2cd)) + param_2 * -0x421be3bd + if (obj.DisplaceX <= 0) + x = baseLoc.X; + else + x = (float)(unchecked((uint)(1813693831u * iy - (iq + 45773u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) + * 2.3283064e-10 * obj.DisplaceX + baseLoc.X); + + // Y displacement: chunk_005A0000.c lines 4871-4878 (same structure, offset 0x11c0f = 72719) + if (obj.DisplaceY <= 0) + y = baseLoc.Y; + else + y = (float)(unchecked((uint)(1813693831u * iy - (iq + 72719u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) + * 2.3283064e-10 * obj.DisplaceY + baseLoc.Y); + + float z = baseLoc.Z; + + // Quadrant selection: chunk_005A0000.c lines 4880-4902 + // iVar4 = (param_3 * 0x6c1ac587 - (param_3 * 0x6f7bd965 + 0x421be3bd) * param_2) + -0x17fcedfd + // 0x6f7bd965=1870387557, 0x421be3bd=1109124029, -0x17fcedfd → -402451965 (uint: 3892515331) + double quadrant = unchecked((uint)(1813693831u * iy - ix * (1870387557u * iy + 1109124029u) - 402451965u)) * 2.3283064e-10; + + if (quadrant >= 0.75) return new Vector3(y, -x, z); + if (quadrant >= 0.5) return new Vector3(-x, -y, z); + if (quadrant >= 0.25) return new Vector3(-y, x, z); + return new Vector3(x, y, z); + } } diff --git a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs index 83cd73f9..3963a5ce 100644 --- a/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs +++ b/tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs @@ -1,14 +1,13 @@ +using System.Numerics; using AcDream.Core.World; using DatReaderWriter.Types; namespace AcDream.Core.Tests.World; /// -/// Tests for SceneryGenerator. As of Phase N.1 (commit b84ecbd / Task 8 final -/// commit), the displacement / road / slope / rotation / scale algorithms run -/// through WorldBuilder's helpers (SceneryHelpers + TerrainUtils). The only -/// our-side code remaining is the small -/// predicate, which is what these tests cover. +/// Tests for SceneryGenerator: road-exclusion, loop bounds, building +/// suppression, and slope filter. The full Generate() pipeline requires +/// real dat files so behavior is tested via internal helpers. /// public class SceneryGeneratorTests { @@ -48,4 +47,63 @@ public class SceneryGeneratorTests $"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}"); } } + + // --- Edge vertex displacement tests --- + // Retail iterates 9×9 vertices (0..8 on each axis). Vertices at x=8 or y=8 + // have base positions at 192 (= 8 * 24), which is AT the landblock boundary. + // These produce valid scenery when displacement shifts them back into [0, 192). + + [Fact] + public void DisplaceObject_EdgeVertex_CanProduceValidPosition() + { + // Vertex (3, 8): base_y = 8 * 24 = 192. + // With DisplaceY > 0, some LCG seeds will produce negative displacement, + // shifting the Y back below 192 into the valid range. + var obj = new ObjectDesc + { + DisplaceX = 12f, + DisplaceY = 12f, + BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) } + }; + + // Search across a range of global cell coords to find at least one + // case where vertex y=8 displaces into [0, 192). + bool foundValid = false; + for (uint gx = 0; gx < 64 && !foundValid; gx++) + { + for (uint gy = 0; gy < 64 && !foundValid; gy++) + { + var localPos = SceneryGenerator.DisplaceObject(obj, gx, gy, 0); + // Vertex (3, 8): cell corner at (3*24, 8*24) = (72, 192) + float lx = 3 * 24f + localPos.X; + float ly = 8 * 24f + localPos.Y; + if (ly >= 0 && ly < 192f && lx >= 0 && lx < 192f) + foundValid = true; + } + } + + Assert.True(foundValid, + "Expected at least one (globalCellX, globalCellY) where vertex y=8 " + + "displaces back into [0, 192) — retail's 9×9 loop relies on this"); + } + + [Fact] + public void DisplaceObject_InteriorVertex_AlwaysNearOrigin() + { + var obj = new ObjectDesc + { + DisplaceX = 12f, + DisplaceY = 12f, + BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) } + }; + + // For interior vertices (x < 8, y < 8), displacement is bounded by + // DisplaceX/Y (max 12 units each), so the result stays within one + // cell of the origin. + var localPos = SceneryGenerator.DisplaceObject(obj, 100, 100, 0); + Assert.True(Math.Abs(localPos.X) <= 12f, + $"Interior displacement X={localPos.X} exceeds DisplaceX=12"); + Assert.True(Math.Abs(localPos.Y) <= 12f, + $"Interior displacement Y={localPos.Y} exceeds DisplaceY=12"); + } }