Compare commits
10 commits
91fd9de3f6
...
6010827b21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6010827b21 | ||
|
|
ad8b931be7 | ||
|
|
b0ec6deb50 | ||
|
|
b84ecbda51 | ||
|
|
677a726e61 | ||
|
|
e279c46aac | ||
|
|
ecf4fe9f10 | ||
|
|
804bfbb819 | ||
|
|
4bfcb2b190 | ||
|
|
bbc618a40a |
5 changed files with 317 additions and 355 deletions
|
|
@ -46,6 +46,50 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
# Active issues
|
# 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
|
## #49 — Scenery (X, Y) placement drifts from retail at some landblocks
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
|
|
|
||||||
|
|
@ -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 ✓ |
|
| 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 ✓ |
|
| 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 ✓ |
|
| 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:
|
Plus polish that doesn't get its own phase number:
|
||||||
- FlyCamera default speed lowered + Shift-to-boost
|
- FlyCamera default speed lowered + Shift-to-boost
|
||||||
|
|
@ -529,42 +530,95 @@ submodule replacing `references/WorldBuilder/` snapshot, project
|
||||||
references in our solution. Long-lived `acdream` branch in the fork
|
references in our solution. Long-lived `acdream` branch in the fork
|
||||||
for our deletions/additions; merge upstream `master` periodically.
|
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):**
|
**Sub-phases (strangler-fig with feature flags):**
|
||||||
|
|
||||||
- **N.0 — Setup.** Submodule + project references + build green. ~1-2 hrs.
|
- **✓ SHIPPED — N.0 — Setup.** Shipped 2026-05-08 (commit `c8782c9`).
|
||||||
- **N.1 — Scenery algorithm calls.** Replace `IsOnRoad` /
|
WorldBuilder fork at `github.com/eriknihlen/WorldBuilder.git` registered
|
||||||
`DisplaceObject` / slope-normal calc / rotation / scale inside
|
as git submodule at `references/WorldBuilder/` tracking the `acdream`
|
||||||
`SceneryGenerator.Generate()` with calls to WB's `SceneryHelpers` +
|
branch. `AcDream.Core.csproj` references `WorldBuilder.Shared` +
|
||||||
`TerrainUtils`. Tiny adapter `LandBlock → TerrainEntry[]`. Keeps our
|
`Chorizite.OpenGLSDLBackend`. Build green, all 28 scenery/terrain tests
|
||||||
data flow + `ScenerySpawn` shape. Feature flag
|
passing.
|
||||||
`ACDREAM_USE_WB_SCENERY=1`. ~1-2 days.
|
- **✓ 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` /
|
- **N.2 — Terrain math helpers.** Refactor `TerrainSurface.SampleZ` /
|
||||||
`SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight`
|
`SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight`
|
||||||
/ `GetNormal` internally. ~1-2 days.
|
/ `GetNormal` internally. ~1-2 days. Smallest remaining N phase, low
|
||||||
- **N.3 — Texture decoding.** Replace `TextureCache` decode pipeline
|
risk after N.1's conformance proof on GetNormal.
|
||||||
with WB's `TextureHelpers`. ~2-3 days.
|
- **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`
|
- **N.4 — Object meshing.** Replace `SetupMesh.cs` + `GfxObjMesh.cs`
|
||||||
with calls to WB's `ObjectMeshManager`. Character-appearance
|
with calls to WB's `ObjectMeshManager`. Character-appearance
|
||||||
behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain
|
behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain
|
||||||
ours — ACME is the secondary oracle. ~1 week.
|
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` +
|
- **N.5 — Terrain rendering.** Replace `TerrainChunkRenderer` +
|
||||||
`TerrainAtlas` + `TerrainBlending` with WB's `TerrainRenderManager` +
|
`TerrainAtlas` + `TerrainBlending` with WB's `TerrainRenderManager` +
|
||||||
`LandSurfaceManager` + `TerrainGeometryGenerator`. ~2 weeks.
|
`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` +
|
- **N.6 — Static objects rendering.** Replace `StaticMeshRenderer` +
|
||||||
`InstancedMeshRenderer` with WB's `StaticObjectRenderManager`.
|
`InstancedMeshRenderer` with WB's `StaticObjectRenderManager`.
|
||||||
~2 weeks.
|
**Realistic estimate: 2-3 weeks** (was 2) — interacts with N.4
|
||||||
|
output.
|
||||||
- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
|
- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
|
||||||
`EnvCellRenderManager` + `PortalRenderManager`. ~2 weeks.
|
`EnvCellRenderManager` + `PortalRenderManager`. **Realistic
|
||||||
|
estimate: 2-3 weeks** (was 2).
|
||||||
- **N.8 — Sky + particles.** Replace sky rendering + particle pipeline
|
- **N.8 — Sky + particles.** Replace sky rendering + particle pipeline
|
||||||
(#36 / C.1 work) with WB's `SkyboxRenderManager` +
|
(#36 / C.1 work) with WB's `SkyboxRenderManager` +
|
||||||
`ParticleEmitterRenderer`. ~1 week.
|
`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` +
|
- **N.9 — Visibility / culling.** Replace `CellVisibility` +
|
||||||
`FrustumCuller` with WB's `VisibilityManager`. ~3-5 days.
|
`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
|
- **N.10 — GL infrastructure consolidation (optional).** Replace our
|
||||||
`Shader` / `TextureCache` / `SamplerCache` plumbing with WB's
|
`Shader` / `TextureCache` / `SamplerCache` plumbing with WB's
|
||||||
`ManagedGL*` wrappers + `OpenGLGraphicsDevice`. ~1 week.
|
`ManagedGL*` wrappers + `OpenGLGraphicsDevice`. ~1 week.
|
||||||
|
|
||||||
**Estimated calendar:** 2-3 months. Engineering effort: 6-8 weeks.
|
**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:**
|
**Each sub-phase:**
|
||||||
- Ships behind `ACDREAM_USE_WB_<NAME>=1` flag.
|
- Ships behind `ACDREAM_USE_WB_<NAME>=1` flag.
|
||||||
|
|
|
||||||
132
docs/research/2026-05-08-phase-n3-handoff.md
Normal file
132
docs/research/2026-05-08-phase-n3-handoff.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
# Phase N.3 handoff — texture decoding via WorldBuilder
|
||||||
|
|
||||||
|
**Use this whole document as the prompt** when handing off to a fresh
|
||||||
|
agent. Everything they need to pick up cold is below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background you'll need
|
||||||
|
|
||||||
|
You're working in `acdream`, a from-scratch C# .NET 10 reimplementation
|
||||||
|
of Asheron's Call's retail client. The project's house rule (in
|
||||||
|
`CLAUDE.md`) is **the code is modern, the behavior is retail**.
|
||||||
|
|
||||||
|
acdream just shipped **Phase N.1** (commits `26cf2b8` through `ad8b931`),
|
||||||
|
the first sub-phase of a strategic migration to fork WorldBuilder
|
||||||
|
(`github.com/Chorizite/WorldBuilder`, MIT) and depend on its tested
|
||||||
|
rendering + dat-handling code instead of porting algorithms from retail
|
||||||
|
decomp ourselves.
|
||||||
|
|
||||||
|
**Read first:**
|
||||||
|
- `docs/architecture/worldbuilder-inventory.md` — the full taxonomy of
|
||||||
|
what WB has and what we keep porting ourselves
|
||||||
|
- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
|
||||||
|
— the parent design doc for Phase N
|
||||||
|
- `CLAUDE.md` — especially the "Reference repos" section (now points at
|
||||||
|
WB as the rendering BASE) and the workflow rules
|
||||||
|
|
||||||
|
**Phase N.1 commit history (just shipped):** read
|
||||||
|
`git log --oneline c8782c9..ad8b931` to see how N.0 + N.1 were
|
||||||
|
structured. The pattern repeats for N.3.
|
||||||
|
|
||||||
|
## What N.3 is
|
||||||
|
|
||||||
|
Replace acdream's texture decoding pipeline with WorldBuilder's
|
||||||
|
`Chorizite.OpenGLSDLBackend.Lib.TextureHelpers`. WB handles INDEX16,
|
||||||
|
P8, BGRA, DXT, and alpha-channel decoding. Our existing implementations
|
||||||
|
of these are scattered across `src/AcDream.App/Rendering/TextureCache.cs`
|
||||||
|
and possibly `src/AcDream.Core/Meshing/` — find them with
|
||||||
|
`grep -rln "INDEX16\|P8 decode\|DXT\|BGRA" src/`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Build green (`dotnet build`)
|
||||||
|
- All existing tests green (the 8 pre-existing `DispatcherToMovementIntegrationTests`
|
||||||
|
failures don't count — they exist on main)
|
||||||
|
- New conformance tests added per format that's substituted (one xUnit
|
||||||
|
Theory per: INDEX16, P8, BGRA, DXT). Each compares a fixed input byte
|
||||||
|
array decoded by our path vs WB's path; assertions on output pixel array.
|
||||||
|
- Visual verification at Holtburg (or wherever) shows no texture
|
||||||
|
regressions: terrain texturing, mesh texturing, particle textures all
|
||||||
|
look the same.
|
||||||
|
- ISSUES.md updated with any known cosmetic deltas (the N.1 pattern —
|
||||||
|
if WB and retail disagree on something subtle, file it, don't try
|
||||||
|
to fix it inline).
|
||||||
|
|
||||||
|
## Tasks (suggested decomposition)
|
||||||
|
|
||||||
|
Follow the N.1 plan structure (`docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md`)
|
||||||
|
as the template. Concretely:
|
||||||
|
|
||||||
|
1. **Audit our texture decode paths.** Grep, list every file/method that
|
||||||
|
decodes a texture. Map each to the WB equivalent in
|
||||||
|
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
|
||||||
|
(read it end to end first).
|
||||||
|
2. **Per-format conformance test.** TDD style: write the test, run it
|
||||||
|
to fail, then plumb the substitution. Conformance test fixture inputs
|
||||||
|
should include real-dat byte sequences (read a known-good texture from
|
||||||
|
a dat, encode the bytes as a hex blob in the test).
|
||||||
|
3. **Substitution.** Replace each decode site with the WB call. Keep our
|
||||||
|
GL upload pathways — those are NOT WB's responsibility.
|
||||||
|
4. **Visual verification.** Launch the client at Holtburg, walk around,
|
||||||
|
look at a tree (mesh texture), the ground (atlas texture), particles
|
||||||
|
(the recent C.1 rain/clouds/aurora work), and a building (composite
|
||||||
|
texture). Compare against retail or against a screenshot before the
|
||||||
|
change.
|
||||||
|
5. **Delete legacy decoders** once visual verification passes.
|
||||||
|
6. **Update roadmap + ISSUES** as the final commit.
|
||||||
|
|
||||||
|
## Watchouts (lessons from N.1)
|
||||||
|
|
||||||
|
- **ACME has a downstream fork with extra filters** (`references/WorldBuilder-ACME-Edition/`).
|
||||||
|
WB's `TextureHelpers` may have ACME-specific patches not yet in upstream.
|
||||||
|
Compare both before assuming WB's version is canonical. We forked
|
||||||
|
upstream WB; ACME is reference-only.
|
||||||
|
- **Conformance tests are non-negotiable.** Phase N.1's rotation bug was
|
||||||
|
caught by the conformance test. Don't skip them. If a test fails, it's
|
||||||
|
a real divergence — investigate before "fixing" the test.
|
||||||
|
- **Whackamole stops the migration.** If 3+ visual regressions appear on
|
||||||
|
default-on, stop, file as ISSUES, ship. The migration goal is "use WB's
|
||||||
|
tested code"; pixel-perfect equivalence with our broken hand-ports is
|
||||||
|
not the goal.
|
||||||
|
- **`Setup.SortingSphere` ≠ `Setup.CylSphere`.** The N.1 attempt at
|
||||||
|
`obj_within_block` over-suppressed because we used the wrong radius
|
||||||
|
source (sorting sphere too large). For texture decoding this likely
|
||||||
|
doesn't matter, but the general lesson is: read WB's full source
|
||||||
|
carefully before adapting; don't assume parallel methods do parallel
|
||||||
|
things.
|
||||||
|
- **Per-vertex road check — STOP signal.** If you find yourself reading
|
||||||
|
ACME for "what's missing" and considering a per-vertex filter, STOP.
|
||||||
|
N.1 tried this (commit `e279c46`), regressed visually, reverted in
|
||||||
|
`677a726`. ACME's filter set works as a coherent unit; pick-and-choose
|
||||||
|
fails. If the N.3 work uncovers a similar ACME-only filter, file it
|
||||||
|
in ISSUES and move on, don't port it inline.
|
||||||
|
|
||||||
|
## Where to start
|
||||||
|
|
||||||
|
1. `git pull` on main to get the latest (Phase N.1 just merged).
|
||||||
|
2. Create a new worktree for the work:
|
||||||
|
`git worktree add .claude/worktrees/<your-name> -b claude/<your-name>`.
|
||||||
|
3. Read the three "read first" docs above.
|
||||||
|
4. Run `dotnet build && dotnet test` to confirm clean baseline.
|
||||||
|
5. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
|
||||||
|
end to end. Take notes on the public API surface.
|
||||||
|
6. Run the audit task (#1 in Tasks above). Output should be a markdown
|
||||||
|
table of "our function / file:line / WB equivalent / format covered."
|
||||||
|
7. Use `superpowers:writing-plans` to convert the audit into a concrete
|
||||||
|
per-format plan. Then use `superpowers:subagent-driven-development`
|
||||||
|
to execute it with fresh subagents per format.
|
||||||
|
|
||||||
|
## Useful greps
|
||||||
|
|
||||||
|
- `grep -rln "INDEX16\|IndexedSurface\|P8\|DXT\|BGRA\|TextureFormat" src/` — find decode paths
|
||||||
|
- `grep -rln "TextureCache" src/` — find our cache layer
|
||||||
|
- `grep -n "public static.*Decode\|public static.*Convert" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` — WB's public API
|
||||||
|
|
||||||
|
## Open question to resolve early
|
||||||
|
|
||||||
|
Does `Chorizite.OpenGLSDLBackend.Lib.TextureHelpers` cover ALL the
|
||||||
|
formats we use, or does it have gaps? Audit our texture types against
|
||||||
|
WB's API in step 1. If WB is missing a format we need, the migration for
|
||||||
|
that format gets deferred (file in ISSUES; keep our decoder for it; note
|
||||||
|
in the roadmap).
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using Chorizite.OpenGLSDLBackend.Lib;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
using WorldBuilder.Shared.Modules.Landscape.Lib;
|
||||||
|
|
||||||
namespace AcDream.Core.World;
|
namespace AcDream.Core.World;
|
||||||
|
|
||||||
|
|
@ -23,11 +25,11 @@ namespace AcDream.Core.World;
|
||||||
/// (scale hash constant 0x7f51=32593 not in dumped chunks;
|
/// (scale hash constant 0x7f51=32593 not in dumped chunks;
|
||||||
/// confirmed against ACViewer which matches all other constants)
|
/// confirmed against ACViewer which matches all other constants)
|
||||||
///
|
///
|
||||||
/// Key implementation note: the decompiled client computes each LCG value as a
|
/// Phase N.1 (2026-05-08): migrated all algorithm calls to WorldBuilder's
|
||||||
/// signed 32-bit int, then normalises with "if (val < 0) val += 2^32" before
|
/// <c>SceneryHelpers</c> + <c>TerrainUtils</c>. The legacy in-line implementations
|
||||||
/// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast.
|
/// have been removed; <c>WbSceneryAdapter</c> bridges <c>LandBlock</c> data to WB's
|
||||||
/// ACViewer's reference omits this cast and is subtly wrong for negative inputs.
|
/// <c>TerrainEntry[]</c>. See
|
||||||
/// We deliberately match the decompiled client, not ACViewer.
|
/// <c>docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SceneryGenerator
|
public static class SceneryGenerator
|
||||||
{
|
{
|
||||||
|
|
@ -35,6 +37,7 @@ public static class SceneryGenerator
|
||||||
private const int VerticesPerSide = 9;
|
private const int VerticesPerSide = 9;
|
||||||
private const float CellSize = 24.0f;
|
private const float CellSize = 24.0f;
|
||||||
private const float LandblockSize = 192.0f; // 8 cells * 24 units
|
private const float LandblockSize = 192.0f; // 8 cells * 24 units
|
||||||
|
private const int CellsPerSide = 8;
|
||||||
|
|
||||||
public readonly record struct ScenerySpawn(
|
public readonly record struct ScenerySpawn(
|
||||||
uint ObjectId, // GfxObj or Setup id
|
uint ObjectId, // GfxObj or Setup id
|
||||||
|
|
@ -43,12 +46,9 @@ public static class SceneryGenerator
|
||||||
float Scale);
|
float Scale);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generate all scenery entries for one landblock. Uses the bit-packed
|
/// Generate all scenery entries for one landblock. Phase N.1 migrated this
|
||||||
/// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into
|
/// to call WorldBuilder's <c>SceneryHelpers</c> + <c>TerrainUtils</c>;
|
||||||
/// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] → a SceneInfo
|
/// see <c>docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md</c>.
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<ScenerySpawn> Generate(
|
public static IReadOnlyList<ScenerySpawn> Generate(
|
||||||
DatCollection dats,
|
DatCollection dats,
|
||||||
|
|
@ -57,21 +57,40 @@ public static class SceneryGenerator
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
HashSet<int>? buildingCells = null,
|
HashSet<int>? buildingCells = null,
|
||||||
float[]? heightTable = 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>();
|
var result = new List<ScenerySpawn>();
|
||||||
|
|
||||||
if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
|
if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
|
// Build the TerrainEntry[] WB's helpers consume — once per landblock.
|
||||||
uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
|
var terrainEntries = WbSceneryAdapter.BuildTerrainEntries(block);
|
||||||
|
|
||||||
|
uint blockX = (landblockId >> 24) * 8;
|
||||||
|
uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
|
||||||
|
uint lbX = landblockId >> 24;
|
||||||
|
uint lbY = (landblockId >> 16) & 0xFFu;
|
||||||
|
|
||||||
// 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 x = 0; x < VerticesPerSide; x++)
|
||||||
{
|
{
|
||||||
for (int y = 0; y < VerticesPerSide; y++)
|
for (int y = 0; y < VerticesPerSide; y++)
|
||||||
|
|
@ -79,13 +98,8 @@ public static class SceneryGenerator
|
||||||
int i = x * VerticesPerSide + y;
|
int i = x * VerticesPerSide + y;
|
||||||
ushort raw = block.Terrain[i];
|
ushort raw = block.Terrain[i];
|
||||||
|
|
||||||
uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6
|
uint terrainType = (uint)((raw >> 2) & 0x1F);
|
||||||
uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15
|
uint sceneType = (uint)((raw >> 11) & 0x1F);
|
||||||
|
|
||||||
// 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;
|
if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
|
||||||
var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes;
|
var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes;
|
||||||
|
|
@ -102,10 +116,7 @@ public static class SceneryGenerator
|
||||||
uint globalCellX = cellX + blockX;
|
uint globalCellX = cellX + blockX;
|
||||||
uint globalCellY = cellY + blockY;
|
uint globalCellY = cellY + blockY;
|
||||||
|
|
||||||
// Scene-selection hash: picks one scene from the terrain's scene list.
|
// Scene-selection hash: identical to Generate.
|
||||||
// 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)
|
uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u)
|
||||||
- 1109124029u * globalCellX + 2139937281u;
|
- 1109124029u * globalCellX + 2139937281u;
|
||||||
double offset = cellMat * 2.3283064e-10;
|
double offset = cellMat * 2.3283064e-10;
|
||||||
|
|
@ -116,14 +127,7 @@ public static class SceneryGenerator
|
||||||
var scene = dats.Get<Scene>(sceneId);
|
var scene = dats.Get<Scene>(sceneId);
|
||||||
if (scene is null) continue;
|
if (scene is null) continue;
|
||||||
|
|
||||||
// Per-object hashes: roll frequency, compute displacement, scale, rotation.
|
// Per-object frequency setup: identical to Generate.
|
||||||
// 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 cellXMat = unchecked(0u - 1109124029u * globalCellX);
|
||||||
uint cellYMat = 1813693831u * globalCellY;
|
uint cellYMat = 1813693831u * globalCellY;
|
||||||
uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u;
|
uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u;
|
||||||
|
|
@ -131,15 +135,13 @@ public static class SceneryGenerator
|
||||||
for (uint j = 0; j < scene.Objects.Count; j++)
|
for (uint j = 0; j < scene.Objects.Count; j++)
|
||||||
{
|
{
|
||||||
var obj = scene.Objects[(int)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;
|
double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10;
|
||||||
if (noise >= obj.Frequency) continue;
|
if (noise >= obj.Frequency) continue;
|
||||||
|
|
||||||
// Displacement: pseudo-random offset within the cell.
|
// ─── WB substitution: displacement ───────────────────
|
||||||
var localPos = DisplaceObject(obj, globalCellX, globalCellY, j);
|
var localPos = SceneryHelpers.Displace(obj, globalCellX, globalCellY, j);
|
||||||
|
|
||||||
float lx = cellX * CellSize + localPos.X;
|
float lx = cellX * CellSize + localPos.X;
|
||||||
float ly = cellY * CellSize + localPos.Y;
|
float ly = cellY * CellSize + localPos.Y;
|
||||||
|
|
@ -147,20 +149,11 @@ public static class SceneryGenerator
|
||||||
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
|
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Retail post-displacement road check (FUN_00530d30).
|
// ─── WB substitution: road check ──────────────────────
|
||||||
// Ported from ACViewer Landblock.OnRoad — uses the 4-corner
|
if (TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries))
|
||||||
// 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;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
// Per-spawn building check on the DISPLACED position's cell.
|
// Building check: identical to Generate.
|
||||||
// Retail: CSortCell::has_building(cell) per spawn, not per vertex.
|
|
||||||
// WorldBuilder: buildingsGrid[gx2, gy2] with 8×8 cell grid.
|
|
||||||
if (buildingCells is not null)
|
if (buildingCells is not null)
|
||||||
{
|
{
|
||||||
int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1);
|
int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1);
|
||||||
|
|
@ -169,238 +162,35 @@ public static class SceneryGenerator
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slope filter: retail uses CLandCell::find_terrain_poly →
|
// ─── WB substitution: slope check ─────────────────────
|
||||||
// polygon->plane.N.z to get the triangle-specific normal.
|
Vector3 normal = TerrainUtils.GetNormal(
|
||||||
// SampleNormalZFromHeightmap picks the correct triangle via
|
region, terrainEntries, lbX, lbY,
|
||||||
// the cell's split direction, matching retail + WorldBuilder.
|
new Vector3(lx, ly, 0));
|
||||||
if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f))
|
if (!SceneryHelpers.CheckSlope(obj, normal.Z))
|
||||||
{
|
continue;
|
||||||
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;
|
float lz = obj.BaseLoc.Origin.Z;
|
||||||
|
|
||||||
// Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60)
|
// ─── WB substitution: rotation ────────────────────────
|
||||||
// Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation
|
Quaternion rotation;
|
||||||
// into the frame, THEN calls AFrame::set_heading(degrees).
|
if (obj.Align != 0)
|
||||||
//
|
rotation = SceneryHelpers.ObjAlign(obj, normal, lz, localPos);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
rotation = SceneryHelpers.RotateObj(obj, globalCellX, globalCellY, j, localPos);
|
||||||
double scaleNoise = unchecked((uint)(1813693831u * globalCellY
|
|
||||||
- (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u)
|
// ─── WB substitution: scale ───────────────────────────
|
||||||
- 1109124029u * globalCellX)) * 2.3283064e-10;
|
float scale = SceneryHelpers.ScaleObj(obj, globalCellX, globalCellY, j);
|
||||||
scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale);
|
|
||||||
}
|
|
||||||
if (scale <= 0) scale = 1f;
|
if (scale <= 0) scale = 1f;
|
||||||
|
|
||||||
result.Add(new ScenerySpawn(
|
result.Add(new ScenerySpawn(
|
||||||
ObjectId: obj.ObjectId,
|
ObjectId: obj.ObjectId,
|
||||||
LocalPosition: new Vector3(lx, ly, lz),
|
LocalPosition: new Vector3(lx, ly, lz),
|
||||||
Rotation: rotation,
|
Rotation: rotation,
|
||||||
Scale: scale));
|
Scale: scale));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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 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).
|
|
||||||
/// </summary>
|
|
||||||
private static bool IsOnRoad(LandBlock block, float lx, float ly)
|
|
||||||
{
|
|
||||||
int x = (int)MathF.Floor(lx / CellSize);
|
|
||||||
int y = (int)MathF.Floor(ly / CellSize);
|
|
||||||
// Clamp so we don't index past the 9x9 terrain grid
|
|
||||||
x = Math.Clamp(x, 0, CellsPerSide - 1);
|
|
||||||
y = Math.Clamp(y, 0, CellsPerSide - 1);
|
|
||||||
|
|
||||||
float rMin = RoadHalfWidth; // 5
|
|
||||||
float rMax = CellSize - RoadHalfWidth; // 19
|
|
||||||
|
|
||||||
// Corner road bits (ACViewer convention):
|
|
||||||
// r0 = (x0, y0) = SW
|
|
||||||
// r1 = (x0, y1) = NW
|
|
||||||
// r2 = (x1, y0) = SE
|
|
||||||
// r3 = (x1, y1) = NE
|
|
||||||
bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]);
|
|
||||||
bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]);
|
|
||||||
bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]);
|
|
||||||
bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]);
|
|
||||||
|
|
||||||
if (!r0 && !r1 && !r2 && !r3) return false;
|
|
||||||
|
|
||||||
float dx = lx - x * CellSize;
|
|
||||||
float dy = ly - y * CellSize;
|
|
||||||
|
|
||||||
if (r0)
|
|
||||||
{
|
|
||||||
if (r1)
|
|
||||||
{
|
|
||||||
if (r2)
|
|
||||||
{
|
|
||||||
if (r3) return true;
|
|
||||||
return dx < rMin || dy < rMin;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (r3) return dx < rMin || dy > rMax;
|
|
||||||
return dx < rMin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (r2)
|
|
||||||
{
|
|
||||||
if (r3) return dx > rMax || dy < rMin;
|
|
||||||
return dy < rMin;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (r3) return MathF.Abs(dx - dy) < rMin;
|
|
||||||
return dx + dy < rMin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (r1)
|
|
||||||
{
|
|
||||||
if (r2)
|
|
||||||
{
|
|
||||||
if (r3) return dx > rMax || dy > rMax;
|
|
||||||
return MathF.Abs(dx + dy - CellSize) < rMin;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (r3) return dy > rMax;
|
|
||||||
return CellSize + dx - dy < rMin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (r2)
|
|
||||||
{
|
|
||||||
if (r3) return dx > rMax;
|
|
||||||
return CellSize - dx + dy < rMin;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (r3) return CellSize * 2f - dx - dy < rMin;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const int CellsPerSide = 8;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pseudo-random displacement within a cell for a scenery object. Returns a
|
|
||||||
/// Vector3 in local cell-offset space (the caller adds it to the cell corner
|
|
||||||
/// to get landblock-local position).
|
|
||||||
///
|
|
||||||
/// Verified against decompiled acclient.exe: chunk_005A0000.c lines 4844-4903 (FUN_005a6cc0).
|
|
||||||
/// X offset constant 0xb2cd = 45773; Y offset constant 0x11c0f = 72719.
|
|
||||||
/// Quadrant hash: line 4880; thresholds 0.25/0.5/0.75 map to _DAT_007c97cc/_DAT_007938b8/_DAT_0079c6dc.
|
|
||||||
/// Decompiled normalises signed-int LCG results with "if (val < 0) val += 2^32"; our
|
|
||||||
/// unchecked((uint)(...)) is exactly equivalent.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
using System.Numerics;
|
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.World;
|
namespace AcDream.Core.Tests.World;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for SceneryGenerator: road-exclusion, loop bounds, building
|
/// Tests for SceneryGenerator. As of Phase N.1 (commit b84ecbd / Task 8 final
|
||||||
/// suppression, and slope filter. The full Generate() pipeline requires
|
/// commit), the displacement / road / slope / rotation / scale algorithms run
|
||||||
/// real dat files so behavior is tested via internal helpers.
|
/// 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>
|
/// </summary>
|
||||||
public class SceneryGeneratorTests
|
public class SceneryGeneratorTests
|
||||||
{
|
{
|
||||||
|
|
@ -47,63 +48,4 @@ public class SceneryGeneratorTests
|
||||||
$"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}");
|
$"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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue