Commit graph

10 commits

Author SHA1 Message Date
Erik
56975f8919 fix(terrain): align per-cell triangle geometry with ACE's ConstructPolygons convention
Our LandblockMesh, terrain.vert corner tables, and TerrainSurface.SampleZ
used the OPPOSITE diagonal for each CellSplitDirection enum value from
what ACE (and the decompiled retail client at FUN_00532a50) picks for the
same sign bit. Same formula, same sign-bit mapping, inverted geometry.

Symptom: remote players rendered at server-broadcast Z hovered or clipped
by up to ~1m on sloped cells. Flat cells masked the bug because all four
corner heights were equal so any triangle pair returned the same Z. Live
diagnostic confirmed +0.79m hover on cell (7,5) at lb(AA,B4) — a ~20°
slope — while flat neighbors agreed to floating-point noise.

Three coordinated edits so CPU mesh + GPU corner lookup + CPU sampler all
agree on the retail geometry:
 - LandblockMesh: SWtoNE branch now emits {BL,BR,TR}+{BL,TR,TL} (y=x cut),
   SEtoNW emits {BL,BR,TL}+{BR,TR,TL} (x+y=1 cut).
 - terrain.vert: corner-index tables updated to match.
 - TerrainSurface.SampleZ: swapped the two branches' interpolation.

After the fix, 19 live DIAG samples across flat + two slope transitions
all land within 0.01m of server Z. Staircase pattern during remote motion
on slopes is a separate bug (no per-frame collision resolution) and will
be addressed via the transition/FindValidPosition port.

Cross-verified against: ACE LandblockStruct.ConstructPolygons lines 221-
244, decompiled retail FUN_00532a50 (chunk_00530000.c:2235), ClientReference
IsSWtoNECut (tests/AcDream.Core.Tests/Terrain/ClientReference.cs).

Updated test SplitDirection_TerrainSurface_AgreesWith_TerrainBlending
with corrected expectations (Z values swap between the two branches).
All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:20:59 +02:00
Erik
05749f52e0 test: port ACME ClientReference + conformance tests
Ports the decompiled AC client ground-truth oracle and exhaustive
conformance test suite from WorldBuilder-ACME-Edition into acdream's
test project.

ClientReference.cs: faithful C# port of CLandBlockStruct.cpp with
IsSWtoNECut, GetPalCode, GetVertexHeight, GetVertexPosition.

ClientConformanceTests.cs verifies acdream's implementations match:
- SplitDirection: 9 spot-checks + 25,600-cell full sweep (0 mismatches)
- PalCode: 5 spot-checks + 256 exhaustive roads + 1M exhaustive types
- Height sampling: flat terrain exact match, vertex corners match,
  interpolated points in-range
- TerrainSurface.SampleZ agrees with TerrainBlending split direction
- Constants match (CellSize=24, CellsPerBlock=8, BlockLength=192)

27 new tests. 310 total (201 core + 109 net), all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:45:20 +02:00
Erik
e0dfecdf23 feat(core+app): per-cell terrain texture blending (Phase 3c.4)
The visual-win commit that wires up the Phase 3c.1/.2/.3 building blocks:
Holtburg's terrain now uses AC's real per-cell texture-merge blend
(base + up to 3 terrain overlays + up to 2 road overlays, with alpha
masks from the alpha atlas) instead of the flat per-vertex single-layer
atlas lookup that preceded it.

Geometry rewrite:
  - New TerrainVertex struct (40 bytes): Position(vec3) + Normal(vec3) +
    Data0..3 (4x uint32 packed blend recipe)
  - LandblockMesh.Build is now cell-based: iterates 8x8 cells instead of
    the old 9x9 vertex grid, emits 6 vertices per cell (two triangles),
    384 total vertices per landblock
  - For each cell: extract 4-corner terrain/road values → GetPalCode →
    BuildSurface (cached across landblocks via a shared surfaceCache) →
    FillCellData → split direction from CalculateSplitDirection → emit
    6 vertices in the exact gl_VertexID % 6 order WorldBuilder's vertex
    shader expects
  - Per-vertex normals preserved via Phase 3b central-difference
    precomputation on the 9x9 heightmap, interpolated smoothly across
    the cell (we deliberately didn't adopt WorldBuilder's dFdx/dFdy
    flat-shade approach — Phase 3a/3b user-tuned lighting was worth
    keeping)

Renderer rewrite:
  - TerrainRenderer VAO: vec3 Position, vec3 Normal, 4x uvec4 byte
    attributes for Data0..3. The uvec4-of-bytes read pattern matches
    Landscape.vert so the ported shader math stays byte-for-byte
    identical to WorldBuilder's.
  - Binds both atlases: terrain atlas on unit 0 (uTerrain), alpha atlas
    on unit 1 (uAlpha)

Shader rewrite (ports of WorldBuilder Landscape.vert/.frag, trimmed):
  - terrain.vert: unpacks the 4 data bytes + rotation bits, derives the
    cell corner from gl_VertexID % 6 + splitDir, rotates the cell-local
    UV per overlay's rotation field, and computes world-space normal
    for the fragment shader
  - terrain.frag: maskBlend3 three-layer alpha-weighted composite for
    terrain overlays, inverted-alpha road combine, final composite
    base * (1-ovlA)*(1-rdA) + ovl * ovlA*(1-rdA) + road * rdA. Phase
    3a/3b directional lighting applied on top (SUN_DIR, AMBIENT=0.25,
    DIFFUSE=0.75, in sync with mesh.frag).
  - Editor uniforms (grid, brush, unwalkable slopes) deliberately
    omitted — not applicable to a game client
  - Per-texture tiling factor hardcoded to 1.0 for now (WorldBuilder
    reads it from uTexTiling[36] uploaded from the dats); one tile per
    cell = 8 tiles per landblock-side, slightly coarser than the old
    ~2x-per-cell tiling. Tunable via the TILE constant if needed.

TerrainAtlas grew parallel TCode/RCode lists (CornerAlphaTCodes,
SideAlphaTCodes, RoadAlphaRCodes) so TerrainBlendingContext can be
built without the mesh loader touching the dats directly.

GameWindow builds a TerrainBlendingContext once, shares a Dictionary
<uint, SurfaceInfo> surfaceCache across all 9 landblocks. Output:
"terrain: 137 unique palette codes across 9 landblocks" — avg ~15
unique per landblock, cache reuse healthy.

LandblockMeshTests rewritten for 384-vertex layout. 77/77 tests green.
Visual smoke run launches clean: no shader compile/link errors, no
GL warnings, terrain renders to the screen.

User visual verification is the final acceptance gate for Phase 3c.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:02:15 +02:00
Erik
a6cd56663f feat(core): terrain surface recipe + cell data packing (Phase 3c.3)
Ports WorldBuilder's full BuildTexture / FillCellData pipeline as
pure CPU functions in TerrainBlending.cs, along with the SurfaceInfo
recipe record and a TerrainBlendingContext input struct that carries
the atlas index lists the algorithm needs.

This is still pure algorithm work — no GL, no shaders, no mesh gen
changes. Visual Phase 3c.4 next commit wires it into LandblockMesh
and rewrites the terrain shaders to consume Data0..3.

Added (all ports of WorldBuilder LandSurfaceManager methods):
  - ExtractTerrainCodes: inverse of GetPalCode terrain bits
  - PseudoRandomIndex: deterministic hash over palette code for alpha
    variant selection; overflow-dependent int math matches WorldBuilder
    byte-for-byte
  - RotateTerrainCode: *2 with wrap (1→2→4→8→1, multi-corner patterns
    handled in tests)
  - GetRoadCodes: decodes the 8-bit road mask into up to two canonical
    road patterns + allRoad flag; magic 0xE/0xD/0xB/0x7 switch kept verbatim
  - FindTerrainAlpha: picks corner vs side alpha map, walks the 4
    rotations looking for a TCode match, returns (alphaLayer, rotation)
    or (255, 0) for "not found"
  - FindRoadAlpha: same idea for road maps, iterates all maps from a
    pseudo-random offset
  - BuildSurface: composes the above into a SurfaceInfo, handling the
    all-road, all-duplicate-terrain, and distinct-terrain cases via
    BuildOverlayLayers + BuildWithDuplicates (ports GetTerrainTextures +
    BuildTerrainCodesWithDuplicates)
  - FillCellData: packs a SurfaceInfo + CellSplitDirection into the 4
    uint32 vertex attributes Data0..Data3. Byte layout documented in
    XML comment and matches WorldBuilder's Landscape.vert uvec4 byte
    unpacking exactly.

SurfaceInfo record carries resolved atlas byte layers directly (base +
3 terrain overlays + 2 road overlays, each with optional alpha layer
and 0-3 rotation). Sentinel 255 = "slot unused".

Tests (14 new, 75/75 total):
  - ExtractTerrainCodes round-trip with GetPalCode
  - RotateTerrainCode single-corner cycle + multi-corner patterns
  - GetRoadCodes: no-road, all-road, single-corner road
  - PseudoRandomIndex: range, count=0 guard, determinism
  - BuildSurface: all-grass → base only; all-road → road as base;
    two-grass-two-dirt → base + overlay
  - FillCellData: full round-trip bit layout with recognizable
    byte values in every slot, plus a no-road1 case that verifies
    the texRd1 slot collapses to 255 when road1 alpha is absent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:53:32 +02:00
Erik
e6cfcb612b feat(core): terrain palette + cell split math (Phase 3c.1)
First of four steps porting WorldBuilder's texture-merge terrain
blending. This commit is pure CPU math with no GL or dat dependencies
so the ported logic can be verified in isolation before it starts
driving real rendering.

Ported:
  - GetPalCode(r1..r4, t1..t4): packs corner terrain/road bits into
    a 32-bit palette code (bit layout documented in XML comment)
  - CalculateSplitDirection: deterministic hash picking SWtoNE vs
    SEtoNW triangulation for a cell; magic constants kept exact to
    match AC's server-side collision triangulation
  - CellSplitDirection enum with values matching WorldBuilder's so
    later bit-packing stays byte-identical

Tests (10 new, 58/58 passing total):
  - GetPalCode golden value for all-grass-no-roads: 0x10008421
    (hand-computed from the bit layout, not derived from a run)
  - GetPalCode all-zero produces only the sizeBits marker
  - GetPalCode determinism, road-flag isolation (r1 flip touches
    only bit 26), size bit always set, terrain region bounded to
    bits 0-19
  - CalculateSplitDirection hand-computed golden for (0,0,0,0):
    (1813693831 - 1369149221) * (1/2^32) ~= 0.1035 < 0.5 -> SWtoNE
  - Determinism
  - Across a full 8x8 landblock the hash produces a mix of both
    split directions (would fail if the hash collapses)

Deferred to Phase 3c.3 (need dat data for TexMerge):
  BuildSurface, FillCellData, PseudoRandomIndex, SurfaceInfo

Reference: WorldBuilder Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs
           WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:36:35 +02:00
Erik
78ce099440 fix(core): LandblockMesh keys atlas lookup on TerrainInfo.Type
Task 1's subagent used the raw ushort as the map key because the test
used raw ushort 7 as the value. But the atlas map is built from
Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc which keys on
TerrainTextureType enum values, extracted from bits 2-6 of the
TerrainInfo ushort per DatReaderWriter's Types/TerrainInfo.cs.

Reverts to using block.Terrain[hi].Type so the Task 2 TerrainAtlas can
actually find matching keys against real dat terrain. The test is
updated to encode Type=7 correctly as (7 << 2) in the raw ushort.
2026-04-10 20:18:09 +02:00
Erik
324abed6eb feat(core): add Vertex.TerrainLayer + LandblockMesh layer map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:16:25 +02:00
Erik
cc55c3f812 fix: heightmap transpose + solid-color + translucency + clipmap textures
Three root causes found via systematic debugging after the user reported
that the dc60405 texture fix and 4763b97 height table fix had no visible
effect on Holtburg.

## Heightmap transpose (LandblockMesh.Build)

Phase 1's LandblockMesh.Build indexed block.Height as y*9+x but AC packs
per-vertex heights in x-major order (x*9+y, matching ACViewer's
LandblockStruct: Height[x * VertexDim + y]). The bug was invisible on
flat landblocks (Phase 1 smoke test) but left buildings buried by 10-13
world-Z units on Holtburg, because building Frame.Origin positions
reference the un-transposed ground truth.

Diagnostic evidence (before fix, Holtburg 0xA9B4FFFF):
  entity 0x020000A5 at ( 84.6,126.0) entityZ= 66.03 terrainZ= 78.15 delta=-12.13
  entity 0x02000118 at ( 74.2,139.9) entityZ= 66.03 terrainZ= 78.92 delta=-12.89

After fix: deltas are 0.03 to 2.18 — buildings now sit on the ground
with small positive offsets for foundations.

Regression test added: Build_HeightmapPackedAsXMajor_NotYMajor asserts
asymmetric heights land at the correct world positions.

## Solid-color surfaces with Translucency=1.0 (SurfaceDecoder.DecodeSolidColor)

The "bright pink doors and windows" the user saw were 11 Holtburg
surfaces with OrigTextureId==0 — these carry a ColorValue instead of
a texture chain. Phase 2a's TextureCache dropped them into the magenta
fallback. All 11 turned out to be Base1Solid|Translucent with
Translucency=1.00, meaning "fully transparent placeholder surface"
(debug ColorValue is gray/green/red/blue/black, never displayed).

DecodeSolidColor now takes a translucency parameter and multiplies
alpha by (1 - translucency), so Translucency=1.0 → alpha=0, and the
mesh shader's existing alpha discard (< 0.5) makes the pixel invisible.

TextureCache honors Surface.Type.HasFlag(Base1Solid) and passes
surface.Translucency through.

Regression tests added: DecodeSolidColor_Opaque_PreservesAlpha and
DecodeSolidColor_FullyTranslucent_AlphaGoesToZero.

## Clipmap alpha-key (DecodeIndex16)

AC convention (per ACViewer TextureCache.IndexToColor): on surfaces
marked Base1ClipMap, palette indices 0..7 are treated as fully
transparent regardless of their actual palette color. Without this,
low-index pixels on clipmap surfaces (typically doorway cutouts and
foliage) render as opaque using whatever sentinel color is at those
palette slots.

DecodeRenderSurface now takes an isClipMap parameter. TextureCache
passes Surface.Type.HasFlag(Base1ClipMap). DecodeIndex16 forces
rgba=(0,0,0,0) when isClipMap && idx < 8.

Regression test added: DecodeIndex16_ClipMap_ZerosAlphaForLowIndices.

## Notes

- dc60405's PFID_INDEX16 palette decoder remains correct — no change.
- 4763b97's LandHeightTable wiring remains correct — real-table lookup
  still runs, it just happens to be linear at Holtburg's height range.
  The fix is forward-compatible with mountains elsewhere.
- All three bugs were invisible to the original unit tests. The new
  regression tests pin them down.

## State

- dotnet build: 0 warnings, 0 errors
- dotnet test: 42 passing (was 38 + 4 new)
- Runtime: 126 entities hydrated on Holtburg, no exceptions, no
  magenta fallback (counter was 11, now 0 via diagnostic confirmation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:37:06 +02:00
Erik
4763b973da fix(terrain): use real LandHeightTable from Region dat
Phase 1 simplified per-vertex height as byte * 2.0f, but AC stores
heights as byte indices into a 256-entry non-linear float lookup
(Region.LandDefs.LandHeightTable). Static object placements in
LandBlockInfo use the real table, so terrain rendered with the
simplified scale left buildings floating or buried.

LandblockMesh.Build now takes an explicit float[] heightTable so
the core code stays testable without a DatCollection. GameWindow
loads Region id 0x13000000 once at startup and passes its
LandDefs.LandHeightTable into every landblock mesh build. The
Phase 1 tests use an identity table (i * 2f for i in 0..255) so
their expectations remain unchanged.

Addresses the 'buildings buried and floating' issue the user
observed after the Phase 2a visual checkpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:09:27 +02:00
Erik
baf0db303d feat(core): add LandblockMesh flat-terrain generator
Pure CPU mesh generator: takes a DatReaderWriter LandBlock DBObj and
produces 81 vertices + 128 triangles covering 192x192 world units.
Vertices are a readonly record struct (position, normal, texcoord)
so the upcoming GPU upload in Task 8 can sizeof() them directly.
Height byte -> world z uses a simple 2x scale; the real AC height
lookup table is a Phase 2+ concern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:37:52 +02:00